Terraform과 AWS CodeSeries 기반의 기본적인 CI/CDelivery 파이프라인 구축하기 (3/4)

박종배·2023년 10월 22일
0

이 글은 총 4개의 파트로 나뉘며 각 파트별 다루는 내용은 위와 같습니다.


Terraform 작성 및 배포

다음으로 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,
  ]
}

주요 내용은 아래와 같음.

  • provider aws블록에서는 이 terraform 파일을 실행하여 다양한 aws 리소스들을 관리하기 위해 필요한 IAM 권한들이 있는 profile tf-user를 사용함.
  • CodeCommit과 ECR은 수동으로 관리하며 local 블록과 data 블록으로 관련 정보를 사용함.
  • CodeBuild 캐시와 CodePipeline 아티팩트 스토어 용도로 s3 버킷을 생성함.
  • LogGroup 생성하여 CodeBuild들에 대한 로그를 저장.
  • IAM Role과 aws 또는 customer에 의한 managed IAM Policy 생성 및 연결하여 여기서 생성하는 리소스들에게 권한 부여.
  • CodeBuild는 CI와 CD로 나누고 또 배포 환경별(dev, stg, prd)로 나눔(즉, 총 6개의 CodeBuild project 생성). role은 앞서 생성한 역할을 사용함. 캐시로 앞서 생성한 s3를 사용함. vpc_config를 통해 CodeBuild 인스턴스를 EKS와 같은 내부망에 배포하도록 했는데 이는 EKS가 private일 때 필요하며 public이라면 불필요함 (옵셔널한 설정). source나 artifact(결과)는 모두 codepipeline으로 설정함. 특히, buildspec의 경로는 배포 환경별로 적용해줌.
  • CodePipeline은 artifact 스토어를 앞서 생성한 s3로 설정함. 스테이지는 크게 3개로 Source, Build, Delivery를 생성. Source 스테이지는 CodeCommit에서 git clone하는 액션. git tag 명령어로 tag 정보를 가져와 활용하기 위해 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 권한이 부여되어 있어야 함.
  • EventBridge rule은 우리의 CodeCommit에서 git tag가 푸시됐을 때 설정한 prefix로 push 됐는지 확인하고 이벤트를 작동 시키는 역할을 함.
  • EventBridge target은 rule에 의해 감지된 상황에 따라 수행할 작업으로 CodePipeline을 실행하도록 함.

tf 파일 수정 시에 알아두면 좋은 사항들

  • tfstate에 대한 backend를 굳이 s3로 쓸 필요는 없음.
  • 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에 IAM Role 추가

실제 테스트를 수행하기 전에 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가 잘 수행되는지 테스트해보자.

profile
기록하는 엔지니어 되기 💪

0개의 댓글