이 글은 총 4개의 파트로 나뉘며 각 파트별 다루는 내용은 위와 같습니다.
다음으로 CI/CD 관련 aws 리소스들을 terraform으로 배포해보자.
main.tf
작성
### versions
terraform {
required_version = ">= 1.3.7"
backend "s3" {
bucket = "<tfstate 저장소>"
key = "<.tfstate 경로>"
region = "<tfstate 저장소의 리전>"
encrypt = true
}
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 4.47"
}
time = {
source = "hashicorp/time"
version = "~> 0.9.1"
}
}
}
### provider
provider "aws" {
region = "<aws region명>"
profile = "tf-user"
}
### locals
locals {
subject = "sample-pipeline"
time_static = formatdate("YYYYMMDDHHmm", time_static.this.rfc3339)
name = join("-", [local.subject, local.time_static])
tags = {
Purpose = local.subject
Owner = "<관리자 이름>"
Email = "<관리자 이메일>"
Team = "<관리자 팀>"
Organization = "<관리자 회사>"
}
# 배포할 리소스들(CodeBuild, CodePipeline, LogGroup, EventRule, EventTarget)에 대한 aws 정보들
region_name = "<배포할 리소스의 region>"
account_id = "<배포할 리소스의 aws account id>"
vpc_id = "<배포할 리소스의 vpc id>"
subnet_ids = {
"<배포할 리소스들의 subnet 이름 1>" : "<배포할 리소스들의 subnet id 1>",
"<배포할 리소스들의 subnet 이름 2>" : "<배포할 리소스들의 subnet id 2>",
"<배포할 리소스들의 subnet 이름 3>" : "<배포할 리소스들의 subnet id 3>",
}
codecommit_repo_name = "sample-pipeline"
ecr_repo_name = "sample-pipeline"
aws_managed_policy_arns = [
"arn:aws:iam::aws:policy/AmazonEKSClusterPolicy",
"arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy",
"arn:aws:iam::aws:policy/AmazonEKSServicePolicy",
"arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy",
]
target_eks_security_group = {
"name" : "<배포할 eks의 SecurityGroup 이름>",
"id" : "<배포할 eks의 SecurityGroup id>",
"cluster_name" : "<배포할 eks 이름>",
}
ci_env_infos = {
"dev" : {
"buildspec_path" : "env/build/dev/buildspec.yml"
},
"stg" : {
"buildspec_path" : "env/build/stg/buildspec.yml"
},
"prd" : {
"buildspec_path" : "env/build/prd/buildspec.yml"
}
}
cd_env_infos = {
"dev" : {
"buildspec_path" : "env/delivery/dev/buildspec.yml"
},
"stg" : {
"buildspec_path" : "env/delivery/stg/buildspec.yml"
},
"prd" : {
"buildspec_path" : "env/delivery/prd/buildspec.yml"
}
}
}
data "aws_subnet" "this" {
# CI, CD에서 사용하는 CodeBuild의 인스턴스가 배포될 subnet. EKS에 프라이빗하게 접근하므로 EKS subnet과 동일하게 함.
for_each = { for k, v in local.subnet_ids : k => v }
id = each.value
}
data "aws_security_group" "target_eks_security_group" {
# CDelivery용 CodeBuild에서 대상이 되는 EKS에 워크로드 매니페스트를 배포하기 위한 접근 권한
name = local.target_eks_security_group.name
id = local.target_eks_security_group.id
}
data "aws_codecommit_repository" "this" {
# CI/CD에서 사용할 git 저장소
repository_name = local.codecommit_repo_name
}
data "aws_ecr_repository" "this" {
# 컨테이너 이미지를 저장할 registry
name = local.ecr_repo_name
}
resource "time_static" "this" {
# 이 tf 파일에서 생성되는 리소스들의 이름에서 suffix로 사용하기 위함
}
resource "aws_s3_bucket" "this" {
# 이 tf 파일에서 생성되는 리소스들에서 사용하는 버킷
bucket = local.name
tags = local.tags
force_destroy = true
}
resource "aws_cloudwatch_log_group" "this" {
# CodeBuild에서 로그 저장소로 사용
name = local.name
tags = local.tags
depends_on = [
aws_s3_bucket.this
]
}
resource "aws_iam_role" "this" {
# CodeBuild, CodePipeline, EventTarget, EventRule에서 쓰임
name = local.name
description = "role to be used by CodeBuild, CodePipeline, EventTarget, and EventRule related to ${local.name} for CI/CD"
tags = local.tags
assume_role_policy = <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Sid" : "",
"Effect": "Allow",
"Principal": {
"Service": [
"codebuild.amazonaws.com",
"codepipeline.amazonaws.com",
"events.amazonaws.com"
]
},
"Action": "sts:AssumeRole"
}
]
}
EOF
# inline_policy {} ### not recommended
# managed_policy_arns = [] ### not recommended
# force_detach_policies = true ### use this when destroy failed
depends_on = [
aws_s3_bucket.this
]
}
resource "aws_iam_policy" "this" {
# CI/CD를 수행하기 위한 customer managed policy
name = local.name
description = "customer managed policy to be attached to role '${local.name}' for CI"
tags = local.tags
policy = <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Sid" : "CodeCommitPull",
"Effect": "Allow",
"Action": [
"codecommit:GitPull",
"codecommit:GetBranch",
"codecommit:GetCommit",
"codecommit:GetRepository",
"codecommit:GetUploadArchiveStatus",
"codecommit:ListBranches",
"codecommit:ListRepositories",
"codecommit:UploadArchive",
"codecommit:CancelUploadArchive"
],
"Resource": [
"${data.aws_codecommit_repository.this.arn}"
]
},
{
"Sid" : "CodeBuild",
"Effect": "Allow",
"Action": [
"codebuild:CreateReportGroup",
"codebuild:CreateReport",
"codebuild:UpdateReport",
"codebuild:BatchPutTestCases",
"codebuild:BatchPutCodeCoverages",
"codebuild:BatchGetBuilds",
"codebuild:StartBuild"
],
"Resource": [
"arn:aws:codebuild:${local.region_name}:${local.account_id}:project/${local.subject}*"
]
},
{
"Sid" : "CodePipeline",
"Effect": "Allow",
"Action": [
"codepipeline:StartPipelineExecution"
],
"Resource": [
"arn:aws:codepipeline:${local.region_name}:${local.account_id}:${local.subject}*"
]
},
{
"Sid" : "ECRGetToken",
"Effect": "Allow",
"Action": [
"ecr:GetAuthorizationToken"
],
"Resource": [
"*"
]
},
{
"Sid" : "ECRRegistry",
"Effect": "Allow",
"Action": [
"ecr:BatchCheckLayerAvailability",
"ecr:BatchGetImage",
"ecr:GetDownloadUrlForLayer",
"ecr:CompleteLayerUpload",
"ecr:InitiateLayerUpload",
"ecr:PutImage",
"ecr:UploadLayerPart"
],
"Resource": [
"${data.aws_ecr_repository.this.arn}"
]
},
{
"Sid" : "CloudWatchLogs",
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": [
"arn:aws:logs:${local.region_name}:${local.account_id}:log-group:${local.name}*:log-stream:${local.name}*/*"
]
},
{
"Sid" : "S3",
"Effect": "Allow",
"Action": [
"s3:*"
],
"Resource": [
"${aws_s3_bucket.this.arn}",
"${aws_s3_bucket.this.arn}/*"
]
}
]
}
EOF
depends_on = [
aws_iam_role.this
]
}
resource "aws_iam_role_policy_attachment" "this_customer_managed" {
# 앞서 생성한 resource IAM policy (customer managed)를 IAM Role에 attach
role = aws_iam_role.this.name
policy_arn = aws_iam_policy.this.arn
depends_on = [
aws_iam_role.this,
aws_iam_policy.this,
]
}
resource "aws_iam_role_policy_attachment" "this_aws_managed" {
# 앞서 정의한 local IAM policy (aws managed)를 IAM Role에 attach
for_each = { for k, v in local.aws_managed_policy_arns : k => v }
role = aws_iam_role.this.name
policy_arn = each.value
depends_on = [
aws_iam_role.this
]
}
resource "aws_codebuild_project" "this_ci" {
# CodeBuild : Continuous Integration
for_each = { for k, v in local.ci_env_infos : k => v }
name = join("-", [local.subject, each.key, "ci", local.time_static])
description = "to build docker image about ${local.name}-${each.key}"
build_timeout = "10"
service_role = aws_iam_role.this.arn
tags = local.tags
artifacts {
type = "CODEPIPELINE"
}
cache {
type = "S3"
location = aws_s3_bucket.this.bucket
}
environment {
compute_type = "BUILD_GENERAL1_SMALL"
image = "aws/codebuild/standard:6.0"
type = "LINUX_CONTAINER"
image_pull_credentials_type = "CODEBUILD"
privileged_mode = true
}
vpc_config {
vpc_id = local.vpc_id
subnets = [
for k, v in local.subnet_ids : data.aws_subnet.this[k].id
]
security_group_ids = [
data.aws_security_group.target_eks_security_group.id
]
}
logs_config {
cloudwatch_logs {
group_name = aws_cloudwatch_log_group.this.name
stream_name = join("-", [local.name, each.key])
}
}
source {
type = "CODEPIPELINE"
buildspec = each.value["buildspec_path"]
}
depends_on = [
aws_iam_role_policy_attachment.this_customer_managed,
aws_iam_role_policy_attachment.this_aws_managed,
]
}
resource "aws_codebuild_project" "this_cd" {
# CodeBuild : Continuous Delivery
for_each = { for k, v in local.cd_env_infos : k => v }
name = join("-", [local.subject, each.key, "cd", local.time_static])
description = "to deploy docker image about ${local.name}-${each.key}"
build_timeout = "10"
service_role = aws_iam_role.this.arn
tags = local.tags
artifacts {
type = "CODEPIPELINE"
}
cache {
type = "S3"
location = aws_s3_bucket.this.bucket
}
environment {
compute_type = "BUILD_GENERAL1_SMALL"
image = "aws/codebuild/standard:6.0"
type = "LINUX_CONTAINER"
image_pull_credentials_type = "CODEBUILD"
privileged_mode = true
}
vpc_config {
vpc_id = local.vpc_id
subnets = [
for k, v in local.subnet_ids : data.aws_subnet.this[k].id
]
security_group_ids = [
data.aws_security_group.target_eks_security_group.id
]
}
logs_config {
cloudwatch_logs {
group_name = aws_cloudwatch_log_group.this.name
stream_name = join("-", [local.name, each.key])
}
}
source {
type = "CODEPIPELINE"
buildspec = each.value["buildspec_path"]
}
depends_on = [
aws_codebuild_project.this_ci
]
}
resource "aws_codepipeline" "this" {
# 순서 : CodeCommit - CodeBuild(CI) - Manual Approval - CodeBuild(CD)
for_each = { for k, v in local.ci_env_infos : k => v }
name = join("-", [local.subject, each.key, local.time_static])
role_arn = aws_iam_role.this.arn
tags = local.tags
artifact_store {
location = aws_s3_bucket.this.bucket
type = "S3"
# encryption_key {
# id = data.aws_kms_alias.s3kmskey.arn
# type = "KMS"
# }
}
stage {
name = "Source"
action {
name = "Source"
category = "Source"
owner = "AWS"
provider = "CodeCommit"
version = "1"
output_artifacts = ["source_output"]
run_order = 1
configuration = {
RepositoryName = local.codecommit_repo_name
BranchName = "master"
PollForSourceChanges = "false"
OutputArtifactFormat = "CODEBUILD_CLONE_REF"
}
}
}
stage {
name = "Build"
action {
name = "Build"
category = "Build"
owner = "AWS"
provider = "CodeBuild"
version = "1"
input_artifacts = ["source_output"]
output_artifacts = ["build_output"]
run_order = 1
configuration = {
ProjectName = aws_codebuild_project.this_ci[each.key].name
}
}
}
stage {
name = "Delivery"
action {
name = "ManualApproval"
category = "Approval"
owner = "AWS"
provider = "Manual"
version = "1"
run_order = 1
input_artifacts = [""]
output_artifacts = [""]
configuration = {
# NotificationArn = ""
CustomData = "Are you going to deploy this to eks?"
# ExternalEntityLink = "${var.approve_url}"
}
}
action {
name = "Delivery"
category = "Build"
owner = "AWS"
provider = "CodeBuild"
version = "1"
run_order = 2
input_artifacts = ["source_output"]
output_artifacts = ["delivery_output"]
configuration = {
ProjectName = aws_codebuild_project.this_cd[each.key].name
}
}
}
depends_on = [
aws_codebuild_project.this_ci,
aws_codebuild_project.this_cd,
]
}
resource "null_resource" "stop_codepipeline_codebuild" {
# local-exec : 이 terraform에서 CodePipeline을 생성할 때 자동 실행되는 첫 번째 인스턴스를 중지 (로그에 오류 발생 시 무시).
for_each = { for k, v in local.ci_env_infos : k => v }
provisioner "local-exec" {
command = <<EOF
echo "stop pipelines" && \
sleep 5 && \
aws codepipeline stop-pipeline-execution \
--profile ${local.tf-user} \
--pipeline-name ${aws_codepipeline.this[each.key].name} \
--pipeline-execution-id $(aws codepipeline list-pipeline-executions --pipeline-name ${aws_codepipeline.this[each.key].name} | jq -r '.pipelineExecutionSummaries[].pipelineExecutionId') \
--abandon --reason 'to stop auto-executed when firstly created by terraform' || true \
sleep 3 && \
echo "stop CI codebuild projects" && \
aws codebuild stop-build \
--profile ${local.tf-user} \
--id arn:aws:codebuild:${local.region_name}:${local.account_id}:build/$(aws codebuild list-builds-for-project --project-name ${aws_codebuild_project.this_ci[each.key].name} | jq -r '.ids[]') --query "build.buildStatus" || true
EOF
}
depends_on = [
aws_codepipeline.this
]
}
resource "aws_cloudwatch_event_rule" "this" {
# 해당 CodeCommit에 새로운 tag가 push됐을 때 특정 prefix를 갖고있는지 점검하고 이벤트를 시작
for_each = { for k, v in local.ci_env_infos : k => v }
name = join("-", [local.subject, each.key, local.time_static])
description = "capture the CodeCommit pushed tag to execute CodePipeline about ${join("-", [local.subject, each.key, local.time_static])}"
role_arn = aws_iam_role.this.arn
event_pattern = <<EOF
{
"source": ["aws.codecommit"],
"resources": ["${data.aws_codecommit_repository.this.arn}"],
"detail-type": ["CodeCommit Repository State Change"],
"detail": {
"event": ["referenceCreated"],
"referenceType": ["tag"],
"referenceName": [{
"prefix": "${each.key}"
}]
}
}
EOF
depends_on = [
null_resource.stop_codepipeline_codebuild
]
}
resource "aws_cloudwatch_event_target" "this" {
# 시작된 이벤트에 따라 해당 CodePipeline을 실행
for_each = { for k, v in local.ci_env_infos : k => v }
arn = aws_codepipeline.this[each.key].arn
rule = aws_cloudwatch_event_rule.this[each.key].name
role_arn = aws_iam_role.this.arn
depends_on = [
aws_cloudwatch_event_rule.this,
]
}
주요 내용은 아래와 같음.
aws
블록에서는 이 terraform 파일을 실행하여 다양한 aws 리소스들을 관리하기 위해 필요한 IAM 권한들이 있는 profile tf-user
를 사용함.OutputArtifactFormat
설정을 CODEBUILD_CLONE_REF
로 하여 해당 git 저장소를 zip으로 다운받는게 아닌 전체 git에 대해 clone을 받아오게 함. 브랜치는 앞서 말한대로 trunk-base development이므로 master
로 고정함. Build 스테이지는 clone한 git 저장소를 기반으로 컨테이너 이미지를 빌드 후 ECR에 로그인 하고 푸시하는 액션. CI에 해당하는 CodeBuild를 지정해줌. Delivery 스테이지에서는 먼저 Manual Approval 액션을 추가하여 사용자가 배포 여부를 선택할 수 있게 함. 그 다음 실제 배포가 이루어지는 Delivery 액션을 CodeBuild로 구현했음. 그 이유는 앞서 말했듯이 AWS는 EKS에 대해 Continuous Deployment를 지원하지 않기에 CodeBuild로 간단히 Continuous Delivery를 구현한 것.null_resource.stop_pipeline_build
블록은 사용자의 로컬 환경의 bash 명령어를 실행하는데 이는 앞서 생성한 CodePipeline과 CodeBuild를 중지하는 작업을 수행함. 그 이유는 terraform으로 CodePipeline을 생성하면 자동으로 실행되기 때문임. 즉, 의도치 않는 실행을 중지시키는 작업. 기본적으로 앞서 provider aws
블록에서 설정해 계속 사용하고 있는 tf-user
를 사용하게 함. 그러므로 tf-user
에 적절한 aws cli 권한이 부여되어 있어야 함.tf 파일 수정 시에 알아두면 좋은 사항들
provider.aws
블록에서는 이 tf 파일에 의해 몇 가지 리소스들을 생성, 삭제 등 관리하기 위해 profile로 tf-user
를 쓰고 있음. 이 tf 파일을 돌리는 환경에서 aws credential 설정으로 tf-user
를 적절히 추가해주자.local.tags
는 이 tf 파일에서 생성되는 리소스들에 붙이는 용도로 만들었는데 필요없다면 제거해도 됨.local.subnet_ids
에 서브넷 갯수는 필요에 따라 사용할 것.리소스 배포
terraform init
terraform apply -no-color -auto-approve 2>&1 | tee "logs/apply-$(date +%Y%m%d-%H%M).log"
배포를 확인하자.
CodePipeline
CodeBuild
EventRule
Event Pattern
Event Target
IAM Role
Log Group
이제 필요한 리소스들은 모두 배포됐음.
실제 테스트를 수행하기 전에 EKS aws-auth에 앞서 terraform으로 생성한 IAM Role을 추가해줘야 함. 이 IAM Role은 다양한 곳에서 사용된다고 했는데 특히 Delivery용 CodeBuild에서 EKS에 워크로드에 대한 매니페스트를 배포해야 하기 때문에 EKS에 대한 kubectl apply에 해당하는 RBAC가 필요함.
EKS에서 RBAC 수정은 네임스페이스 kube-system
에 있는 ConfigMap aws-auth
에서 할 수 있음.
ConfigMap aws-auth
수정
kubectl edit cm -n kube-system aws-auth
# Please edit the object below. Lines beginning with a '#' will be ignored,
# and an empty file will abort the edit. If an error occurs while saving this file will be
# reopened with the relevant failures.
#
apiVersion: v1
data:
mapRoles: |
- groups:
- system:masters
rolearn: arn:aws:iam::<aws account id>:role/sample-pipeline-202310011243
username: sample-pipeline-202310011243
### ... (생략)
kind: ConfigMap
metadata:
name: aws-auth
namespace: kube-system
위에 yaml 처럼, data.mapRoles
에서 rolearn에 system:masters 권한을 부여했음.
rolearn에 role 이름은 앞서 terraform으로 생성된 role의 이름.
💡 사실 최소권한법칙에 어긋나므로 더 최소한의 권한을 부여하는게 맞지만 단순 테스트이므로 그냥 마스터 권한을 줬음.
이제, 필요한 작업들은 모두 끝났으므로 실제 git tag를 push하고 파이프라인이 잘 실행되는지, CI/CD가 잘 수행되는지 테스트해보자.