Tutorial for Use AWS CodeBuild with Jenkins #2


이번에는 AWS CodeBuild Project와 이에 필요한 Resource들을 Terraform을 이용하여 배포하고, Multibranch Pipeline Project를 생성하여 실제로 Build가 되는 과정까지 다룰 것이다.

전편 : #1 EC2에 Jenkins 설치

2. CodeBuild Project 생성 및 Jenkins Multibranch

1. 관련 AWS Resource 배포

CodeBuild Project를 배포하기 위해서는 먼저 role 정의가 필요하다. 해당 role에는 다음의 권한들이 필요하다.

  • CloudWatch에 Log를 쌓고, 볼 수 있는 권한
  • S3에 Object을 올리고, 다운받을 수 있는 권한 : Source code를 Jekins에서 S3로 올리며, CodeBuild에서 내려받는 형식
  • ECR에 Docker Image를 올리고, 다운받을 수 있는 권한 : CodeBuild에서 Docker Image를 만들어서 ECR에 올림. Build시 기존 Image를 ECR에서 내려받아 빌드 할 수도 있음
  • VPC 내에 NetworkInterface를 생성할 수 있는 권한

  • iam_role.tf
resource "aws_iam_role" "codebuild" {
    name               = "tutorial_codebuild"
    path               = "/service-role/"
    assume_role_policy = <<POLICY
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "codebuild.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
POLICY
}
  • policy_attachment.tf
resource "aws_iam_policy_attachment" "AmazonEC2ContainerRegistryFullAccess" {
    name       = "AmazonEC2ContainerRegistryFullAccess"
    policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryFullAccess"
    roles      = [ "${aws_iam_role.codebuild.name}" ]
}

resource "aws_iam_policy_attachment" "CloudWatchLogsFullAccess" {
    name       = "CloudWatchLogsFullAccess"
    policy_arn = "arn:aws:iam::aws:policy/CloudWatchLogsFullAccess"
    roles      = ["${aws_iam_role.codebuild.name}"]
}

resource "aws_iam_policy_attachment" "AmazonS3FullAccess" {
    name       = "AmazonS3FullAccess"
    policy_arn = "arn:aws:iam::aws:policy/AmazonS3FullAccess"
    roles      = [ "${aws_iam_role.codebuild.name}", "${aws_iam_role.ci.name}" ]
}

resource "aws_iam_policy_attachment" "AmazonVPCFullAccess" {
    name       = "AmazonVPCFullAccess"
    policy_arn = "arn:aws:iam::aws:policy/AmazonVPCFullAccess"
    roles      = ["${aws_iam_role.codebuild.name}"]
}

참고로, 기존에 다른 role들과 policy들이 참조된 상태라면 이 상태로 terraform apply를 2회하면 기존 다른 role들과 해당 policy들의 관계는 모두 끊기게 된다. 기존에 다른 role 생성이 없는 상태였다면 괜찮다. 이럴 경우 3가지 방안을 생각해 볼 수 있다.

  1. policy_attachment를 terraform으로 관리하지 않고 AWS console에서 기존처럼 관리를 하던가,
  2. 1번의 실행으로 적용을 한 뒤 tfstate파일 및 policy_attachment에서 관련 부분들을 찾아서 삭제 or 주석처리를 하던가,
  3. 2번째 실행했을때 적용을 끊을 role들이 나오는데 그것을 policy_attachment에 추가를 하면 된다.

Terraform은 명령을 실행하는 방식이 아니라 현재 상태를 정의해 놓고, 기존 상태에서 현재 상태로 만들기 위해서 Resource들을 배포, 삭제, 수정하는 방식이라 여기에 대한 이해도가 부족하다면 기존에 이미 배포된 Resource들을 삭제할 수 있어 치명적이다. 잘못해서 현재 서비스 중인 RDS를 삭제한다던지, 이미 배포되어서 잘 사용하고 있는 Jenkins, Jira 등도 삭제 될 수 있다.

Jenkins와 CodeBuild 사이에 소스코드를 전달하는데 사용 될 S3를 배포한다.

  • s3.tf
resource "aws_s3_bucket" "jenkins_tutorial" {
  bucket = "jenkins-tutorial-dev"
  acl    = "private"
  versioning {
    enabled = true
  }
}

빌드된 server 이미지를 저장할 ECR을 배포한다. ECR에는 최근 10개의 이미지만 저장하도록 설정하겠다.

  • ecr.tf
resource "aws_ecr_repository" "jenkins_tutorial" {
  name = "jenkins_tutorial"
}

resource "aws_ecr_lifecycle_policy" "jenkins_tutorial" {
  repository = aws_ecr_repository.jenkins_tutorial.name

  policy = <<EOF
{
    "rules": [
        {
            "rulePriority": 1,
            "description": "Expire images more than 20 counts.",
            "selection": {
                "tagStatus": "any",
                "countType": "imageCountMoreThan",
                "countNumber": 10
            },
            "action": {
                "type": "expire"
            }
        }
    ]
}
EOF
}

CodeBuild에서 사용할 SecurityGroup을 생성한다. 별다른 설정없이 그냥 모두 허용하겠다.

  • security_group.tf
resource "aws_security_group" "ci_agent" {
    name        = "tutorial_ci_agent"
    description = "Tutorial CI Agent"
    vpc_id      = aws_vpc.tutorial.id

    ingress {
        from_port       = 0
        to_port         = 65535
        protocol        = "tcp"
        cidr_blocks     = ["0.0.0.0/0"]
    }

    egress {
        from_port       = 0
        to_port         = 0
        protocol        = "-1"
        cidr_blocks     = ["0.0.0.0/0"]
    }

    tags = { Name = "Tutorial CI Agent" }
}

이제 가장 어려운 Private Subnet을 배포해보자. 이게 뭐냐면 외부에서는 접속이 불가능하지만, 나는 인터넷 접속이 가능한 subnet을 의미한다. NAT (Network Address Translation) Gateway를 이용하여 인터넷 이용이 가능한 Public Subnet 하나를 Private Subnet의 Router Table에 연결해주는 방식이다. 말로 설명하면 좀 복잡한데, 직접 AWS Console에서 생성을 하면 더 복잡하다. 하지만, Terraform으로 정의한 내용만을 본다면 생각보다 간단하다.

  • subnet.tf
resource "aws_subnet" "codebuild_private" {
	vpc_id                  = aws_vpc.tutorial.id
	cidr_block              = "172.32.64.0/20"
	availability_zone       = "ap-northeast-2a"

	tags = { Name = "Tutorial Private" }
}
  • route_table.tf
resource "aws_route_table" "tutorial_private" {
  vpc_id = aws_vpc.tutorial.id

  tags = { Name = "Tutorial Route Private Table" }
}

resource "aws_route_table_association" "tutorial_private" {
	subnet_id      = aws_subnet.codebuild_private.id
	route_table_id = aws_route_table.tutorial_private.id
}
  • nat.tf
resource "aws_eip" "tutorial" {
  vpc = true
}

resource "aws_nat_gateway" "public_a" {
  allocation_id = aws_eip.tutorial.id
  subnet_id     = aws_subnet.public_a.id
}

resource "aws_route" "tutorial_private" {
	route_table_id         = aws_route_table.tutorial_private.id
	destination_cidr_block = "0.0.0.0/0"
	nat_gateway_id         = aws_nat_gateway.public_a.id
}

이제 어려운건 거의 끝났다. 마지막으로 CodeBuild Project를 만들자. 여기서 주의할 점이 뭐냐면 예전에는 각 빌드할 언어별로 AWS에서 CodeBuild용 Docker Image를 제공했다. 예를 들어 aws/codebuild/docker:18.09.0 , aws/codebuild/android-java-8:26.1.1-1.6.0 , aws/codebuild/nodejs:10.14.1 이런식이었다. 하지만, 최근에는 이런식으로 이미지는 제공하지 않고, standard 이미지 하나에 모든 언어의 빌드가 가능하도록 제공한다. 현재(2020년 08월) 기준으로 aws/codebuild/standard:4.0 이미지는 Ubuntu 18.04에 거의 대부분 언어들이 다 설치되어 있다. 그 목록은 아래 Link에서 확인이 가능하다.

예전 방식의 이미지도 사용은 가능하나, 그 목록을 볼 수 있는 곳이 제공되지 않는다. 그리고 예전 방식과 요즘 제공되는 이미지 사이에 큰 차이가 있는데, 두 이미지에서 제공하는 AWS CLI 버전이 다르다. 예전 이미지는 1버전이 제공되고, 요즘 이미지는 2버전이 제공된다. 1버전과 2버전 사이에는 명령어도 조금 다른게 있어서 이걸 주의해야한다. 한가지 예로 Docker Image를 ECR에 올리기 위해서는 AWS ECR로 로그인을 해야하는데 그 명령어가 바뀌었다.

또, 주의해야할 한가지는 기본적으로는 CodeBuild 이미지에서 docker 명령어를 내릴 수가 없다. 이걸 가능하게 하기 위해서는 Privileged modeON 해야 한다. 요즘 Server쪽은 대부분 Docker Image로 만들어서 Kubernetes로 배포하는 경우가 많아서 이 부분을 주의해야 한다.

  • codebuild.tf
variable "buildspec_tutorial" {
    type = string
    default = <<EOF
version: 0.2

phases:
  #install:
    #commands:
      # - command
      # - command
  #pre_build:
    #commands:
      # - command
      # - command
  build:
    commands:
      - echo "hello world"
      # - command
  #post_build:
    #commands:
      # - command
      # - command
#artifacts:
  #files:
    # - location
    # - location
  #name: $(date +%Y-%m-%d)
  #discard-paths: yes
  #base-directory: location
#cache:
  #paths:
    # - paths
EOF
}

resource "aws_codebuild_project" "tutorial" {
  name          = "tutorial"
  build_timeout = "20"
  service_role  = aws_iam_role.codebuild.arn

  source {
    type            = "S3"
    location        = "jenkins-tutorial-dev/tutorial"
    buildspec = var.buildspec_tutorial
  }

  environment {
    compute_type = "BUILD_GENERAL1_SMALL"
    image        = "aws/codebuild/standard:4.0"
    type         = "LINUX_CONTAINER"
    privileged_mode = true
  }

  vpc_config {
    vpc_id = aws_vpc.tutorial.id
    subnets = [ aws_subnet.codebuild_private.id ]
    security_group_ids = [ aws_security_group.ci_agent.id ]
  }

  artifacts {
    type = "NO_ARTIFACTS"
  }
}

CodeBuild Project를 생성했으면, AWS Console로 접속해서 VPC 설정이 제대로 되었는지 확인을 한 번 한다.

CodeBuild -> Build -> Build Projects -> tutorial -> Edit -> Environment -> Additional configuration -> Validate VPC Settings

The VPC with ID vpc-xxxxxxxxx has an internet connection.

위와 같은 메세지가 확인되면 성공한 것이다. subnet 설정이 제대로 안되었다면 여기에 에러 메세지가 뜨는데, 그것만 보고는 문제를 해결하기 힘들다.

2. Jenkins Pipiline 정의

샘플 서버코드에 Jenkinsfile을 정의한다. root에 바로 생성한다.

pipeline {
  agent none

  stages {
    stage('Notify starting job') {
      agent {
        label 'master'
      }
      steps {
        slackSend(
          channel: "#it_notifications", 
          color: "#EEEEEE", 
          message: "[Starting] ${env.JOB_NAME} #${env.BUILD_NUMBER} (<${env.RUN_DISPLAY_URL}|Open>)"
        )
      }
    }
    stage('Build Docker Image') {
      agent {
        label 'master'
      }
      steps {
        awsCodeBuild(
          credentialsType: 'keys',
          projectName: 'tutorial',
          region: 'ap-northeast-2',
          sourceControlType: 'jenkins',
          sseAlgorithm: 'AES256',
          buildSpecFile: "ci/${env.BRANCH_NAME}/buildspec.yml"
        )
      }
    }
  }

  post {
    success {
      slackSend(
        channel: "#it_notifications", 
        color: "good", 
        message: "[Successful] ${env.JOB_NAME} #${env.BUILD_NUMBER} (<${env.RUN_DISPLAY_URL}|Open>)"
      )
    }
    failure {
      slackSend(
        channel: "#it_notifications", 
        color: "danger", 
        message: "[Failed] ${env.JOB_NAME} #${env.BUILD_NUMBER} @channel (<${env.RUN_DISPLAY_URL}|Open>)"
      )
    }
  }
}

이런 Jenkinsfile을 Scripted Pipeline이라고도 부른다. Jenkins에서 각각의 build step들의 정의가 가능하지만, Jekins 2부터는 이런식으로도 작성이 가능하다. Jenkins에 Slack 인증정보를 저장을 한 경우에는 위와 같이 Slack으로 메세지를 보낼 수 있다. Slack 연동을 원하지 않으면 slackSend 관련 step들을 모두 삭제하면 된다. 현재는 Build Docker Image 단계만을 실행했는데, stage를 계속해서 추가도 가능하며, 이 파일안에서도 조건별로 분기를 타는게 가능하다. 자세한 Pipeline 정의 방법은 여기에서 다루지 않겠다.

3. CodeBuild용 buildspec 정의 및 Dockerfile 생성

위 Jenkinsfile에 ci/${env.BRANCH_NAME}/buildspec.yml이 파일을 읽는 것으로 정의했다. 샘플 프로젝트에서는 master branch만을 사용하고 있으므로 ci/master/buildspec.yml에 아래와 같이 정의한다.

version: 0.2
env:
  variables:
    AWS_ACCESS_KEY_ID: "---"
    AWS_SECRET_ACCESS_KEY: "---"
    AWS_DEFAULT_REGION: "ap-northeast-1"
    ECR_URL: "---.dkr.ecr.ap-northeast-2.amazonaws.com"

phases:
  build:
    commands:
      - aws ecr get-login-password --region ap-northeast-2 | docker login --username AWS --password-stdin $ECR_URL
      - docker build -t jenkins_tutorial .
      - docker tag jenkins_tutorial:latest $ECR_URL/jenkins_tutorial:latest
      - docker push $ECR_URL/jenkins_tutorial:latest

참고로 위 명령어는 AWS CLI 2 기준이다. 1버전은 조금 다를 수 있다.

샘플 프로젝트 root에 Dockerfile을 하나 만든다.

FROM node:12

ARG WORKDIR=/JenkinsCodebuildTutorial

COPY . ${WORKDIR}
WORKDIR ${WORKDIR}

RUN yarn
RUN yarn tsc

RUN chmod +x ci/entrypoint.sh
ENTRYPOINT [ "ci/entrypoint.sh" ]
CMD ["yarn", "start:prod"]
  • ci/entrypoint.sh
#!/bin/bash

exec "$@"

4. Jenkins에서 Multibranch Pipeline Project 생성

그전에 Jenkins의 Slack 연동을 먼저 진행하자. 위 Jenkinsfile에서 slackSend 부분을 삭제한 경우에는 이 부분은 넘어가도 된다.

  • Jenkins 관리 -> 시스템 설정
    • Slack
      • Workspace : 슬랙 workspace 명 입력
      • Credential -> Add -> Jenkins
        • Kind : Secret Text
          • Id : 아무거나. 그냥 slack으로 입력했음
          • Secret : Slack에서 좌측하단 Apps+ 눌러서 Jenkins CI 선택하고 표시되는 token값 입력
      • Default channel / member_id : 슬랙 메세지가 전달된 channel 명 입력
      • Test Connection을 눌렀을 때 해당 채널에 아래와 같은 메세지가 표시되면 성공이다.

Slack/Jenkins plugin: you’re all set on http://xxxx:8080/

Jenkins에 접속해서 Project를 생성한다.

  • 새로운 Item -> Multibranch Pipeline 으로 tutorial 이란 이름으로 생성
  • Branch Sources
    • Add Source -> Git
      • Project Repository : https://github.com/DevStarSJ/JenkinsCodebuildTutorial.git (각자 source code url)
      • Credentials -> Add -> Jenkins
        • Kind : Username with password (다른 인증 방식을 사용해도 됨)
          • Username : 각자 Github username
          • Password : 각자 Github password
      • Credentials -> 방금 입력한 정보 선택
      • Behaviours -> Add -> Filter by name (with wildcards) : 원하는 Branch만 선택 가능. 일단 그래도 둬도 됨

이제 저장을 누르면 바로 Branch들을 Indexing한다. master branch에서 Jenkinsfile을 찾았다는 메세지가 나오면서 빌드가 시작된다. 정상적으로 빌드가 완료되면 슬랙채널에서 아래와 같은 메세지가 확인 가능하다.

[Starting] tutorial/master #1 (Open)
[Successful] tutorial/master #1 (Open)

마치며…

이번에는 Jenkins와 CodeBuild를 연동하는 방법, Multibranch Pipeline Project를 생성하는 방법, Private Subnet을 생성하는 방법 등에 대해서 다루어 보았다. 다음에는 EKS를 배포한 뒤, 여기서 만든 Docker Image를 배포하는 방법에 대해서 다룰 예정이다.

이 글이 도움이 되셨다면 공감 및 광고 클릭을 부탁드립니다 :)