[CI/CD] 배포자동화? 나도 할 수 있어

혼자서 토이프로젝트를 진행하던 중, 백엔드(Spring)에 대한 CI/CD를 구축하기로 했다. 내가 필요한 것을 구현하고 내 블로그에 적용할 거라 유지보수가 잦을거라고 생각했고, 한 번 구축하면 편해질 거라고 생각했다.

Flow

CI/CD Flow

GitHub Actions를 이용하여 디폴트 브런치에 push 또는 pull request를 하는 경우에 대해서 워크플로우를 정의한다.

  1. on Pull Request: 테스트 및 빌드만 하고, 배포는 하지 않는다.
  2. on Push: 테스트 및 빌드, 배포까지 실행한다.

각각에 대하여 워크플로우를 2개 생성하게된다.

CI (Continuous Integration)

CI를 구축하는 순서는 아래와 같다.

  1. 워크플로우 생성
  2. 워크플로우 커스텀

1. 워크플로우 생성

GitHub Actions에 이미 선구자들이 만들어 놓은 워크플로우들이 있다. 이를 이용하여 워크플로우를 생성하는데, 중간 즈음에 ‘Continuous integration workflows’ 섹션에 Java with Gradle을 선택한다.

GitHub Actions에서 워크플로우 생성

2. 워크플로우 커스텀

최초 소스는 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time
# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-gradle

name: Java CI with Gradle

on:
  push:
    branches: [develop]
  pull_request:
    branches: [develop]

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v2
      - name: Set up JDK 11
        uses: actions/setup-java@v2
        with:
          java-version: "11"
          distribution: "adopt"
          cache: gradle
      - name: Grant execute permission for gradlew
        run: chmod +x gradlew
      - name: Build with Gradle
        run: ./gradlew build

이 중에서 나에게 필요한 부분을 수정한다. 내가 하고자하는 것은 아래와 같다.

  • 나는 모든 브런치에 대해서 pull_request가 발생할 때마다 실행할 것이다.
  • Java 8을 사용할 것이다
  • 디렉터리는 백엔드를 기준으로 한다
  • 스프링 프로젝트를 빌드 및 테스트를 한다

조금 애먹었던 부분이 세 번째 조건이다. 현재 내 프로젝트는 한 레포지토리 안에 백엔드와 프론트엔드로 디렉터리가 나뉘어져 있고, 백엔드를 빌드하려고 하므로 이를 설정해주어야한다.

네 번째 조건은 gradle에서 빌드하면 테스트가 자동으로 실행된다.

아래는 이를 적용한 CI 워크플로우이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time
# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-gradle

name: backend CI

on: pull_request

jobs:
  backend_ci:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: i-like-this-page-backend

    steps:
      - name: Checkout
        uses: actions/checkout@v2

      - name: Set up JDK 8
        uses: actions/setup-java@v2
        with:
          java-version: "8"
          distribution: "adopt"
          cache: gradle

      - name: Grant execute permission for gradlew
        run: chmod +x gradlew

      - name: Build with Gradle
        run: ./gradlew build

11~13 번째 라인을 통해 작업 디렉터리를 백엔드로 지정한 것을 알 수 있다. yaml 파일 명은 본인이 원하는대로 설정하며, 이를 커밋한 후에, Pull Request를 만들어보면 자동으로 빌드되는 것을 확인할 수 있다.

현재 GitHub Actions라는 공간에 프로젝트가 빌드된 것이다. 이제 이것을 배포해보자.

Production 설정

본격적인 배포를 하기에 앞서, 설정을 production으로 빌드해야 서비스로서 배포할 수 있다. 위에서의 스프링 부트에서 빌드는 기본적으로 application.yml을 기반으로 하고, 여기에는 development 설정이 담겨있다.

실제 production 설정은 DB 접속 정보를 포함하고 있다. production 설정을 그대로 깃헙에 올렸다가는 DB를 누구에게나 공개한 것이나 마찬가지이므로, 공격당하기 십상이다. 현재 AWS RDS를 이용중인데 누구나 접근하게 되면 요금 폭탄을 맞게 될 것이다.

따라서, production 설정을 외부 저장소(S3)에 저장하였다. 그리고 application.yml을 덮어쓰기한 후에 빌드하였다. 그러면 워크플로우는 아래와 같이 바뀐다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time
# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-gradle

name: backend CI/CD

on:
  push:
    branches: [develop]

jobs:
  backend_cicd:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: i-like-this-page-backend

    steps:
      - name: Checkout
        uses: actions/checkout@v2

      - name: Set up JDK 8
        uses: actions/setup-java@v2
        with:
          java-version: "8"
          distribution: "adopt"
          cache: gradle

      - name: Grant execute permission for gradlew
        run: chmod +x gradlew

      - name: Copy production configuration from S3
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        run: |
          aws s3 cp \
          --region ap-northeast-2 \
          --acl private \
          s3://iltp-deploy-bucket/application.yml src/main/resources/

      - name: Test and Build with Gradle
        run: ./gradlew clean build

31~39 번째 라인이 AWS S3로부터 application.yml을 가져오는 단계이다. 해당 파일을 src/main/resources/에 복사(덮어쓰기)함으로써 production 설정으로 빌드하도록 한다.

AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY는 GitHub에서 설정할 수 있다. 해당 GitHub 레포지토리에서 Settings > Secrets and variables > Actions > New repository secret으로 추가할 수 있다.

GitHub New Secret

CD (Continuous Delivery/Deploy)

CI 까지는 쉽다고 느껴졌지만 CD는 꽤나 복잡하다. CD를 구축하는 과정은 아래와 같다.

  1. 빌드한 프로젝트를 압축
  2. 압축된 파일을 AWS S3에 복사
  3. S3에 있는 파일을 AWS CodeDeploy를 통해 EC2에 배포
  4. EC2에서 jar를 실행
  5. 배포 테스트

1. 빌드한 프로젝트를 압축

프로젝트 디렉터리 자체를 S3에 복사할 수도 있지만, CodeDeploy에서 압축을 저절로 풀어주므로 자원을 절약하기 위해서 프로젝트를 압축한다. 워크플로우에 단계를 추가한다.

1
2
- name: Zip entire project directory
  run: zip -r iltp-backend *

2. 압축된 파일을 AWS S3에 복사

AWS S3 버킷을 만들어야하는데 그 전에, AWS 사용자를 추가해야한다. S3에 접근할 사용자를 추가하고 이를 이용해 S3에 접근할 것이다.

이 단계부터 AWS 서비스들을 많이 다루게 되는데, 처음이라면 아래 설명들이 조금 불친절할 수도 있다. 맨 위에 내가 참고했던 사이트에 자세히 나와있으니 그것들을 참고하길 바란다.

2-1. AWS IAM 사용자 추가

AWS IAM > 사용자 > 사용자 추가를 클릭하여 사용자를 새로 만든다. 이 사용자는 프로그래밍 방식으로 접근할 것이며, 권한은 기존 정책 중 AmazonS3FullAccess, AWSCodeDeployFullAccess를 가지고 있어야 한다.

AWS IAM 사용자 추가

만들고 나면 엑세스 키 ID비밀 엑세스 키가 나올텐데 이를 복사해놓아야 한다. 이 때 아니면 구경 못한다.

2-2. AWS S3 버킷 만들기

적당한 버킷 이름과 리전(region)을 선택한 후 버킷을 만든다. 모든 퍼블릭 액세스 차단이 체크되어 있는지 확인한다.(기본적으로 되어 있음)

AWS S3 버킷 만들기

2-3. 워크플로우에 S3에 복사하는 step 추가

AWS S3에 복사하는 step을 추가한다. 압축했던 파일 iltp-backend.zip을 S3 버킷 iltp-deploy-bucket에 저장하는 커맨드다. --acl 옵션은 S3에 복사된 파일의 접근 제어에 관한 것이다. 자세한 사항은 AWS 문서에서 확인할 수 있다.

1
2
3
4
5
6
7
8
9
- name: Deliver to AWS S3
  env:
    AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
    AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
  run: |
    aws s3 cp \
    --region ap-northeast-2 \
    --acl private \
    iltp-backend.zip s3://iltp-deploy-bucket/

3. S3에 있는 파일을 AWS CodeDeploy를 통해 EC2에 배포

CodeDeploy는 코드 배포를 자동화하는 서비스이다. EC2에 배포하는 경우 무료로 사용 가능하다. 이를 이용해서 S3에 복사했던 프로젝트 압축 파일을 EC2에 배포할 것이다.

3-1. AWS IAM 역할 만들기

AWS의 EC2와 CodeDeploy를 실행하는데 필요한 권한을 Role(역할)을 통해 부여한다. 아까 위에서 ‘사용자’를 추가한 것은 AWS 외부에서의 접근 권한이라면, ‘역할’은 AWS 내부에서의 접근 권한을 부여한다고 생각하면 될 것 같다.

EC2를 위한 역할을 만들 때, 권한 중 AmazonEC2RoleforAWSCodeDeploy를 선택하여 만든다.

EC2를 위한 역할 만들기

CodeDeploy를 위한 역할을 만들 때, 권한 중 AWSCodeDeployRole를 선택하여 만든다. 역할 이름은 “test-CodeDeployRole”으로 하겠다.

CodeDeploy를 위한 역할 만들기

3-2. EC2 설정

EC2 인스턴스를 만든 후, 아래 이미지처럼 IAM 역할 수정 메뉴에 들어가서 역할을 바꾸면 된다. 아까 만들었던 역할 중 AmazonEC2RoleforAWSCodeDeploy 정책을 선택했던 역할로 수정한다.

EC2 역할 변경

다음 EC2에 접속하여 AWS CLI를 설치한다.

1
2
sudo apt-get update
sudo apt install awscli

EC2에 codedeploy-agent를 설치한다. 다운받아지지 않는다면 해당 인스턴스에 역할을 제대로 주었는지 확인한다.

1
2
3
aws s3 cp s3://aws-codedeploy-ap-northeast-2/latest/install . --region ap-northeast-2
chmod +x ./install
sudo ./install auto

codedeploy-agent가 잘 설치되었다면 상태를 확인했을 때, 아래와 같이 나온다.

EC2 codedeploy-agent 상태

3-3. CodeDeploy 설정

AWS CodeDeploy 애플리케이션을 먼저 만든다. 컴퓨팅 플랫폼은 EC2를 선택한다

CodeDeploy 앱 생성

다음, CodeDeploy를 사용하여 애플리케이션을 배포하기 전에 배포 그룹을 생성해야 한다. 애플리케이션 생성을 누르면, 바로 배포 그룹을 만드는 곳으로 이동하게 될 것이다. 배포 그룹 생성을 클릭해 배포 그룹을 생성한다.

CodeDeploy 배포 그룹 생성

서비스 역할 섹션에서 아까 생성했던 test-CodeDeployRole이 검색되는데 선택한다.

다음 환경 구성 섹션에서 CodeDeploy를 적용할 EC2 인스턴스를 특정할 수 있다.

CodeDeploy 배포 그룹 생성 2

태그를 이용해서 EC2를 특정할 수 있는데, 키에는 Name을 입력하고, 값에는 위에서 생성했던 본인의 EC2 인스턴스 이름을 넣어준다.

그 다음 설정들은 기본값으로 주었고, 마지막에 로드밸런서는 비활성화시켰다.

3-4. appspec.yml

프로젝트 폴더에 appspec.yml을 생성한다. 이 파일은 CodeDeploy에서 사용하는 파일이다.

1
2
3
4
5
version: 0.0
os: linux
files:
  - source: /
    destination: /home/ubuntu/app/deploy

적당한 destination을 선택하고, 이 파일을 프로젝트에 추가한다. 나는 배포하고자 하는 대상 디렉터리가 프로젝트 하위 디렉터리이기 때문에 이 파일의 위치는 하위 디렉터리에 위치시켰다. 대략적인 프로젝트 구조는 아래처럼 되겠다.

1
2
3
├── i-like-this-page-backend
│   └── appspec.yml
└── i-like-this-page-frontend

3-5. 워크플로우에 CodeDeploy 단계 추가

이제 GitHub Actions의 워크플로우에 배포 단계를 추가한다.

1
2
3
4
5
6
7
8
9
10
- name: Deploy
  env:
    AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
    AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
  run: |
    aws deploy create-deployment \
    --application-name iltp \
    --deployment-group-name iltp-deploy-group \
    --s3-location bucket=iltp-deploy-bucket,bundleType=zip,key=iltp-backend.zip \
    --region ap-northeast-2
  • application-name: 위에서 CodeDeploy 애플리케이션을 만들 때 썼던 이름을 쓴다.
  • deployment-group-name: 위에서 CodeDeploy 배포 그룹을 만들 때 썼던 이름을 쓴다.
  • s3-location
    • bucket: 위에서 S3 버킷을 만들 때 썼던 이름을 쓴다.
    • bundleType: zip으로 압축했기 때문에 zip을 입력한다.
    • key: S3에서 참조할 파일 이름을 쓴다. 위에서 프로젝트 압축할 때 iltp-backend라는 이름을 썼었으므로 그 뒤에 .zip를 붙여서 사용한다.
  • region: 해당하는 지역을 쓴다.

여기까지 모두 합쳐진 GitHub Actions 워크플로우 코드는 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time
# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-gradle

name: backend CI/CD

on:
  push:
    branches: [develop]

jobs:
  backend_cicd:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: i-like-this-page-backend

    steps:
      - name: Checkout
        uses: actions/checkout@v2

      - name: Set up JDK 8
        uses: actions/setup-java@v2
        with:
          java-version: "8"
          distribution: "adopt"
          cache: gradle

      - name: Grant execute permission for gradlew
        run: chmod +x gradlew

      - name: Copy production configuration from S3
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        run: |
          aws s3 cp \
          --region ap-northeast-2 \
          --acl private \
          s3://iltp-deploy-bucket/application.yml src/main/resources/

      - name: Test and Build with Gradle
        run: ./gradlew clean build

      - name: Zip entire project directory
        run: zip -r iltp-backend *

      - name: Deliver to AWS S3
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        run: |
          aws s3 cp \
          --region ap-northeast-2 \
          --acl private \
          iltp-backend.zip s3://iltp-deploy-bucket/

      - name: Deploy
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        run: |
          aws deploy create-deployment \
          --application-name iltp \
          --deployment-group-name iltp-deploy-group \
          --s3-location bucket=iltp-deploy-bucket,bundleType=zip,key=iltp-backend.zip \
          --region ap-northeast-2

여기까지 자동으로 프로젝트를 빌드하고 EC2에 배포하는 것까지 완료한 것이다.

테스트하려면, Pull Request를 만들고 잠시 후, EC2에 접속하여 보면 3-4. appspec.yml에서 지정했던 경로 /home/ubuntu/app/deploy에 프로젝트 파일들이 있는 것을 확인하면 된다.

4. EC2에서 jar를 실행

그럼 이제 EC2에 프로젝트 파일들이 전달되었으니, jar를 실행시키기만 하면 된다.

4-1. EC2 인바운드 설정

스프링 프로젝트를 실행시키기 전에 먼저 EC2의 8080번 포트에 대한 인바운드를 열어주어야 한다. 나의 스프링 프로젝트는 8080번 포트를 사용하기 떄문에 8080번 포트에 대한 인바운드를 열어주는 것이다.

EC2 인스턴스의 보안 그룹을 찾아 들어가면 아래와 같은 화면이 나온다. 뭔가 중요할 것 같은 정보들은 가렸다.

EC2 보안그룹

아래 쪽에 인바운드 규칙을 설정하는 부분이 있는데, 나는 이미 8080번 포트를 열어둔 상태이다. 우측에 Edit inbound rules을 클릭하여 인바운드 규칙을 추가한다.

EC2 인바운드 편집

보이는 것처럼, 8080번 포트는 누구나 접근할 수 있도록 열어두었다. 22번 포트(SSH)는 현재 내 IP만 열어두었는데, 내 IP가 아닌 다른 IP로 접속할 일이 생기면 그 때 또 AWS 콘솔에서 추가할 생각이다.

4-2. 배포 스크립트

jar를 실행시키기 위해서 스크립트를 짠다. CodeDeploy에서 해당 스크립트를 실행시킴으로써 서버를 가동시킨다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#!/bin/bash

echo "> Checking pid of the running application..."

APP_BASE_PATH=/home/ubuntu/app/

CURRENT_PID=$(pgrep -f i-like-this-page)

echo "PID = $CURRENT_PID"

if [ -z $CURRENT_PID ]; then
    echo "> There is no running application."
else
    echo "> kill -15 $CURRENT_PID"
    kill -15 $CURRENT_PID
    sleep 5
fi

echo "> Deploying a new application..."

echo "> Copy jar files"

cp $APP_BASE_PATH/deploy/build/libs/*.jar $APP_BASE_PATH/jar/

JAR_NAME=$(ls -tr $APP_BASE_PATH/jar/ | grep 'i-like-this-page' | tail -n 1)

echo "> JAR Name: $JAR_NAME"

nohup java -jar $APP_BASE_PATH/jar/$JAR_NAME &
  • 7 ~ 9: 현재 실행되고 있는 애플리케이션(스프링)이 있는 지 확인하고
  • 11 ~ 17: 있으면 죽인다(kill).
  • 23: 빌드된 파일들(jar)을 복사한다.
  • 25: jar가 모여있는 디렉터리에서 가장 최근에 만들어진 파일을 선택한다.
  • 29: 해당 jar를 백그라운드로 실행시킨다. nohup은 유저가 로그아웃하더라도 프로세스가 꺼지지 않도록 한다.

4-3. CodeDeploy로 스크립트 실행

CodeDeploy에서 배포가 끝나면 스크립트를 실행시키는 코드를 추가한다. appspec.yml에서 배포가 끝난 시점(이벤트)을 잡을 수 있다.

1
2
3
4
5
6
7
8
9
10
version: 0.0
os: linux
files:
  - source: /
    destination: /home/ubuntu/app/deploy

hooks:
  AfterInstall:
    - location: execute-deploy.sh
      timeout: 180

CodeDeploy에서 deploy.sh를 바로 실행시킬 수 없어 우회하는 방법을 사용해야 한다. 자세한 이유는 AWS 문서에서 확인할 수 있다.

따라서 execute-deploy.sh 파일을 만들어 우회할 것이다. 파일 내용은 아래와 같다.

1
2
3
4
5
#!/bin/bash

touch /home/ubuntu/deploy.log

/home/ubuntu/deploy.sh > /home/ubuntu/deploy.log 2> /home/ubuntu/deploy.log < /dev/null &

이 스크립트를 실행시킴으로써 deploy.sh가 실행되도록 할 수 있다. 스크립트 실행에 따른 로그를 deploy.log에 저장한다. 입력은 아무것도 가리키지 않도록 /dev/null로 리디렉션 했다.

>는 출력, 2>는 에러, <는 입력을 의미한다.

참고한 게시글 보면 모두 /dev/null로 리디렉션해서 로그를 볼 수 없는데, 로그를 보고싶다면 위와 같이 설정하면 된다. 물론 모두 /dev/null로 해도 무방하다.

드디어 모든 설정이 완료되었다!

5. 배포 테스트

이제 실제로 코드를 수정해보고 Pull Request를 만들고, 디폴트 브런치에 병합 해본다. 그에 따라 실제로 서버가 자동으로 배포되는지 확인한다.

먼저 현재 develop 브런치에서 release-test 브런치를 새로 판다.

release-test 브런치 생성

코드를 조금 수정한다. 간단한 API를 추가하는데, GET /github 요청에 대해서 GitHub 주소를 반환하는 API를 구현했다.

InfoRestController

이 변경사항을 커밋하고, 푸시한다. 만약 브런치가 원격에 만들어지지 않았다면 퍼블리싱해야 한다. 아래 이미지는 GitHub Desktop을 이용한 화면이다.

GitHub Desktop

release-testdevelop에 병합하기 위해 Pull Request를 생성한다.

GitHub PR

Pull Request를 생성했으므로, CI 워크플로우가 실행된다.

GitHub Actions CI

Merge pull request를 눌러 병합하면 devleop 브런치에 푸시되면서 CI/CD 워크플로우가 시작된다.

GitHub Actions CICD

약 1분간 CI/CD 워크플로우가 동작하면서 자동으로 EC2에 배포하고, 스크립트를 실행시킨다. 그 결과 이전에 없던 API가 생긴 것을 확인할 수 있다.

API success

배포 자동화를 성공했다!

OFF THE RECORD

CodeDeploy가 알아서 배포를 해주는데, 스크립트 실행하면서 발생한 에러를 볼 수 없어서 답답하게 디버깅했었다. 로그를 찍으려고 해봐도 로그가 안나와서 한참을 삽질한 끝에, 파일 경로 문제임을 알았다. CodeDeploy에서 현재 디렉터리 위치는 /opt/codedeploy-agent였다. 내가 찍었던 로그파일들은 여기에 있었다. 파일 경로를 다룰 때 왠만하면 절대 경로를 이용해야 함을 배웠다.

댓글 남기기