Home AWS를 사용해 무중단 배포 자동화 환경 구축하기 - 6. CodeDeploy 연동 / 마무리
Post
Cancel

AWS를 사용해 무중단 배포 자동화 환경 구축하기 - 6. CodeDeploy 연동 / 마무리

AWS를 사용해 무중단 배포 자동화 환경 구축하기 시리즈

  1. 개요
  2. VPC와 기본 리소스
  3. VPC와 기본 리소스 생성하기
  4. 어플리케이션 구축 및 로드밸런서 적용
  5. AWS 리소스 세팅
  6. CodeDeploy 연동 / 마무리

CodeDeploy 연동 / 마무리

S3 접근 IAM 사용자 생성

무중단 배포의 흐름은 코드 커밋 - 커밋 트리거로 github action 수행 - gradle 빌드 - jar 파일 s3 전송 - code deploy 트리거 - code deploy가 배포 수행 으로 이루어 집니다.

그 중 github action으로 빌드한 jar 파을 s3에 전송하려면 권한이 있어야 하겠죠? 권한을 가진 사용자를 만들어 보겠습니다. IAM -> 사용자 생성으로 사용자 추가 화면으로 이동합니다.

deploy-ready-2
자격 증명 유형은 프로그래밍 방식 액세스를 선택합니다.

deploy-ready-3
정책 필터에 AmazonS3FullAccess || AWSCodeDeployFullAccess 를 검색해 나온 두가지 정책을 연결합니다. 그 후 쭉쭉 넘겨 사용자를 생성합니다.

마지막 5단계에 도달하면 액세스 키와 비밀 액세스 키를 다운받을 수 있는 화면이 나타납니다. 이 자격 증명 csv는 해당 화면에서만 다운받을 수 있으니 잘 저장해 두도록 합니다.
deploy-ready-4

S3 버킷 생성

사용자를 생성하였으니 S3 버킷을 생성하겠습니다. AWS 콘솔 -> S3 -> 버킷 만들기 로 이동하여 버킷을 생성합니다. 이때 모든 퍼블릭 액세스를 차단합니다.
deploy-ready-5

Github Repository 생성 및 코드 commit & push

간단한 Spring boot 어플리케이션을 만들어 Github에 올립니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@RestController
@SpringBootApplication
public class SimpleApplication {

    public static void main(String[] args) {
        SpringApplication.run(SimpleApplication.class, args);
    }

    @GetMapping("/ip")
    public Map<String, String> ip(HttpServletRequest request) throws UnknownHostException {
        var map = new HashMap<String, String>();
        map.put("hostIp", InetAddress.getLocalHost().getHostAddress());
        map.put("accessIp", request.getHeader("x-forwarded-for"));
        
        return map;
    }

}

Github Repository에 시크릿 키 등록

Github Action에 시크릿 키를 그대로 노출시키면 보안상 큰 문제가 되겠죠? 따라서 Workflow에서 변수를 사용할 수 있도록 설정에 시크릿 키를 등록하도록 하겠습니다.

deploy-ready-6
위 캡쳐와 같이 repository -> settings -> 좌측 secrets -> actions 로 이동하며 IAM 사용자 생성 마지막 단계에서 저장해 두었던 키값들을 등록합니다.

S3에 업로드하기

github workflow에서 s3로 빌드된 jar 파일을 옮기는 job을 추가합니다. .github/workflows/gradle.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
43
44
45
46
47
48
49
name: github action codedeploy CI/CD

on:
  push:
    branches: [ "master" ]
  pull_request:
    branches: [ "master" ]

permissions:
  contents: read

jobs:
  build:

    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3
      - name: Set up JDK 11
        uses: actions/setup-java@v3
        with:
          java-version: '11'
          distribution: 'temurin'

      - name: Gradlew permission
        run: chmod +x gradlew

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

      - name: Make Directory
        run: mkdir dist

      - name: Copy jar
        run: cp ./build/libs/*.jar ./dist/

      - name: zip
        run: tar -zcvf deploy.tar.gz ./dist

      - name: Upload to S3
        env:
          AWS_ACCESS_KEY_ID: $
          AWS_SECRET_ACCESS_KEY: $
        run: |
          aws s3 cp \
          --region ap-northeast-2 \
          --acl private \
          ./deploy.tar.gz \
          s3://keencho-codedeploy-s3-bucket/$/deploy.tar.gz  \

run_number를 사용하지 않으면 action이 수행될때마다 새로운 파일이 overwirte 되기 때문에 백업 파일을 남길수 없습니다. 따라서 run_number를 사용하여 백업 파일을 남길수 있도록 하였습니다.

이제 repository에 push 하고 actions탭에 들어가 작업이 잘 수행되는지 확인합니다. 작업이 모두 성공하였다면 s3로 이동해 deploy.tar.gz 파일이 업로드외어 있는지 확인합니다.
deploy-ready-7
deploy-ready-8

CodeDeploy 호출

s3에 파일이 잘 업로드 되었으니 CodeDeploy를 호출해야 합니다. 스크립트를 수정하기 전 기존 AutoScaling Group에 포함된 인스턴스에 CodeDeploy Agent가 실행중인지 체크하세요.

1
2
3
sudo service codedeploy-agent status

The AWS CodeDeploy agent is running as PID 23029

다음은 스크립트를 수정해 보겠습니다.

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
name: github action codedeploy CI/CD

on:
  push:
    branches: [ "master" ]
  pull_request:
    branches: [ "master" ]

permissions:
  contents: read

jobs:
  build:

    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v3
    - name: Set up JDK 11
      uses: actions/setup-java@v3
      with:
        java-version: '11'
        distribution: 'temurin'

    - name: Gradlew permission
      run: chmod +x gradlew

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

    - name: Make Directory
      run: mkdir dist

    - name: Copy jar
      run: cp ./build/libs/*.jar ./dist/

    - name: zip
      run: tar -zcvf deploy.tar.gz ./dist

    - name: Configure AWS Credentials
      uses: aws-actions/configure-aws-credentials@v1
      with:
        aws-access-key-id: $
        aws-secret-access-key: $
        aws-region: ap-northeast-2

    - name: Upload to S3
      run: |
        aws s3 cp \
        --region ap-northeast-2 \
        --acl private \
        ./deploy.tar.gz \
        s3://keencho-codedeploy-s3-bucket/$/deploy.tar.gz  \

    - name: Call CodeDeploy
      run: |
        aws deploy create-deployment \
        --application-name keencho-codedeploy-app \
        --deployment-group-name keencho-codedeploy-app-group \
        --region ap-northeast-2 \
        --s3-location bucket=keencho-codedeploy-s3-bucket,bundleType=tgz,key=$/deploy.tar.gz \

aws 인증부분을 Configure AWS Credentials로 따로 뻈고, Call CodeDeploy 부분을 추가하였습니다. application-name과 deployment-group-name 이 aws콘솔상의 이름과 일치하는지 다시한번 확인해 보세요.

이제 다시 github action을 실행시키고 CodeDeploy 콘솔로 이동하여 배포가 진행되는지 확인합니다.

CodeDeploy agent was not able to receive the lifecycle event. Check the CodeDeploy agent logs on your host and make sure the agent is running and can connect to the CodeDeploy server. 라는 에러메시지가 뜨면서 CodeDeploy가 더이상 진행되지 않는다면 이는 CodeDeploy Agent가 설치되어 있지 않다는 뜻입니다.
앞 포스팅에서 언급한 것처럼 쉘 스크립트의 첫번째 부분에서 -xe를 제거해 보시고, 그래도 안된다면 구글링을 통해 CodeDeploy Agent를 실행시키는 방법을 찾아보세요.

The CodeDeploy agent did not find an AppSpec file within the unpacked revision directory at revision-relative path "appspec.yml" 라는 메시지가 뜨며 배포가 실패한다면 지금까지는 성공입니다.

appspec.yml 세팅

CodeDeploy는 AppSpec file을 배포 관리 yaml 파일로 사용합니다. 이전에 만든 템플릿에는 codedeploy-agent 설치 까지만 진행했었는데요, nginx나 기타 필요한 세팅은 이곳에서 진행됩니다.

배포될 스프링 부트 프로젝트의 root에서 codedeploy 라는 폴더를 만들고 appspec.yml 파일을 생성하고 아래 내용을 붙여넣습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
version: 0.0
os: linux
files:
  - source:  /
    destination: /home/ec2-user
permissions:
  - object: /
    pattern: "**"
    owner: ec2-user
    group: ec2-user
hooks:
  AfterInstall:
    - location: init.sh
      timeout: 300
      runas: root
  ApplicationStart:
    - location: deploy.sh
      timeout: 300
      runas: ec2-user
  ValidateService:
    - location: validate.sh
      timeout: 200
      runas: ec2-user

위 파일을 보시면 hooks 섹션을 확인하실 수 있습니다. hooks 섹션은 이름에서도 유추 가능하듯이 인스턴스 배포시 각각 한번 실행됩니다. 각각의 훅이 실행되는 시점에 대한 자세한 내용은 이곳을 참고 하세요.

많은 훅들이 있지만 여기서 사용할 훅은 세가지 입니다. 그중 먼저 AfterInstall 훅에 사용될 스크립트를 정의해 보겠습니다.

AfterInstall

appspec.yml 파일이 위치한 곳에 init.sh 파일을 생성합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#!/bin/bash
# 타임존 세팅
rm /etc/localtime
ln -s /usr/share/zoneinfo/Asia/Seoul /etc/localtime

# nginx 설치
amazon-linux-extras install nginx1 -y
systemctl enable nginx
systemctl start nginx
chmod 755 /var/log/nginx

# nginx config file 위치 이동
mv /home/ec2-user/nginx/*.conf /etc/nginx/conf.d/
rm -rf /home/ec2-user/nginx

systemctl restart nginx

# open jdk 11 설치
amazon-linux-extras install java-openjdk11 -y

# deploy.sh 권한 변경
chmod +x /home/ec2-user/deploy.sh

타임존 세팅, nginx 설치, nginx config 설정, open jdk 11 설치, deploy.sh 권한 변경 작업이 이루어집니다. 이것이 시작 템플릿의 사용자 데이터에서 nginx나 jdk등을 설치하지 않는 이유입니다.

위 스크립트를 보시면 중간에 미리 정의해둔 nginx config 파일을 옮기는 부분이 있습니다. 프로젝트 root > codedeploy > nginx 폴더를 생성하고 nginx.conf 파일을 만들고 아래 내용을 작성하겠습니다.

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
server {

        set $proxy_pass http://127.0.0.1:8090;

        listen 80;

        location /health {
            access_log off;
            return 200;
        }

        location / {
            proxy_redirect     off;
            proxy_set_header   Host $host;
            proxy_set_header   X-Forwarded-Proto $scheme;
            proxy_set_header   X-Real-IP $remote_addr;
            proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header   X-Forwarded-Host $server_name;
            proxy_read_timeout 120;

            proxy_buffer_size 128k;
            proxy_buffers 4 256k;
            proxy_busy_buffers_size 256k;

            proxy_pass $proxy_pass;
        }
}

이전 포스팅에서 로드밸랜서 / 대상그룹에 대해 알아본 적이 있습니다. 테스트 어플리케이션도 하나 만들었지요. 그때 /health 경로에 대해 200 상태값을 반환하도록 설정하였었는데요, 여기서도 동일합니다. 대상 그룹이 새로운 인스턴스의 health 상태를 체크하기 위해 사용됩니다.

ApplicationStart

인스턴스 세팅이 끝났으니 SpringBoot 어플리케이션을 실행하는 스크립트가 필요합니다. appspec.yml 파일이 위치한 곳에 deploy.yml 파일을 생성합니다.

1
2
3
#!/bin/bash
nohup java -jar -Dserver.port=8090 /home/ec2-user/application.jar 1> /dev/null 2>&1 &
sleep 30s

스프링부트의 포트와 nginx의 포트가 일치하는지 확인합니다.

ValidateService

혹시 스프링부트가 뜨지 않았다면 어떻게 할까요?(예를들어 하이버네이트의 스키마 검증…) 타겟그룹은 현재인스턴스/health 경로로 health-check 를 진행하였기 때문에 인스턴스 내부의 어플리케이션이 정상적으로 동작하는지 까지는 확인하지 않습니다. 따라서 여기서는 별도의 스크립트를 통해 어플리케이션이 잘 시작되었는지 확인하겠습니다.

일단 스프링부트 어플리케이션에 메소드를 추가해 보겠습니다. 단순히 200 상태값을 체크하는데 사용되는 메소드 입니다.

1
2
3
4
5
@GetMapping("/script-health-check")
    public ResponseEntity<?> scriptHealthCheck() {
        return ResponseEntity.ok().build();
    }

appspec.yml 파일이 위치한 곳에 validate.sh 파일을 생성합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#!/bin/bash
validate () {
	count=1
	while [ ${count} -le 11 ]; do
		response=$(curl --write-out '%{http_code}' --silent --output /dev/null $1)

		if [ ${response} -eq 200 ]; then
			break
		else
			if [ ${count} -eq 11 ]; then
				echo "$2 어플리케이션 검증 실패"
				exit 1
			fi

			echo "$2 어플리케이션 검증 재시도... ${count}"
			sleep 10s
			count=$((count + 1))
		fi
	done

}

validate "localhost:8090/script-health-check" "샘플"

총 열번, 각 시도마다 10초의 유휴시간을 가지는 검증 스크립트 입니다. 검증에 실패한다면 exit1 코드를 통해 CodeDeploy 배포가 종료되게 됩니다.

gradle.yml 최종수정

필요한 스크립트들이 스프링부터 jar 파일과 같이 압축될수 있도록 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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
name: github action codedeploy CI/CD

on:
  push:
    branches: [ "master" ]
  pull_request:
    branches: [ "master" ]

permissions:
  contents: read

jobs:
  build:

    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3
      - name: Set up JDK 11
        uses: actions/setup-java@v3
        with:
          java-version: '11'
          distribution: 'temurin'

      - name: Gradlew permission
        run: chmod +x gradlew

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

      - name: Make Directory
        run: mkdir dist

      - name: Move Jar
        run: mv ./build/libs/*.jar ./dist/application.jar

      - name: Move CodeDeploy Script
        run: mv ./codedeploy/* ./dist/

      - name: zip
        run: tar -zcvf deploy.tar.gz ./dist

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: $
          aws-secret-access-key: $
          aws-region: ap-northeast-2

      - name: Upload to S3
        run: |
          aws s3 cp \
          --region ap-northeast-2 \
          --acl private \
          ./deploy.tar.gz \
          s3://keencho-codedeploy-s3-bucket/$/deploy.tar.gz  \

      - name: Call CodeDeploy
        run: |
          aws deploy create-deployment \
          --application-name keencho-codedeploy-app \
          --deployment-group-name keencho-codedeploy-app-group \
          --region ap-northeast-2 \
          --s3-location bucket=keencho-codedeploy-s3-bucket,bundleType=tgz,key=$/deploy.tar.gz \


Move CodeDeploy Script 부분이 추가되었습니다.

결과 확인

이제 필요한 스크립트는 모두 작성되었습니다. Github에 push하여 결과를 확인합니다.

CodeDeploy 콘솔에서 작업이 성공적으로 끝난것을 확인하였다면 로드밸런서의 DNS 를 통해 사이트로 접속하여 결과물을 확인합니다. 결과를 확인하였다면 이제는 스프링부트 어플리케이션에 테스트 메소드를 추가하여 다시한번 CodeDeploy 배포를 수행해 보세요.

CodeDeploy 를 확인하면서 수시로 사이트에 접속해 보세요. 이전 어플리케이션과 최신 어플리케이션이 번갈아가며 호출되는것을 확인할 수 있습니다. 로드밸런서가 어떤 식으로 동작하는지 파악 / 확인하는것은 중요합니다. (포스팅에서 사용된 형태의 스프링 부트 어플리케이션을 사용하셨다면 ec2 인스턴스의 private ip로 확인할 수 있습니다.)

이 시점에서 만약 CodeDeploy Task가 시작조차 못하고 첫번째 단계인 Application Stop 에서 멈춰버린다면, 새로운 인스턴스에 CodeDeploy Agent가 설치되지 않았음을 의미합니다.
/var/log/cloud-init-output.log 에서 확인하실 수 있는데요, 제 경우는 실수로 NAT 게이트웨이를 삭제해 외부 인터넷 연결이 되지 않아 yum 패키지를 다운받을 수 없어 에러가 발생 하였습니다.
다양한 원인이 있을 수 있으므로 문제 발생시 위 로그를 확인해 보세요.

deploy-ready-9
모든 TASK가 성공하였습니다!

마무리

이 시리즈에서는 VPC생성, 서브넷 생성부터 Github Action, CodeDeploy까지 진행해 보았습니다. 사실 실제 프로덕션 환경에서는 실경써줘야할 부분이 더 많습니다.

예를들어 CodeDeploy 배포에 실패한 경우 CodeDeploy 에 의해 새롭게 시작된 Auto Scaling Group은 자동종료되지 않기 때문에 새로운 인스턴스가 실행된 상태(아무것도 하지 않는 상태)로 멈추어 이중 과금이 발생합니다. 따라서 이에 대응할 전략이 필요합니다. 제 경우 CodeDeploy의 성공 / 실패 여부를 30초마다 Polling 하는 스크립트를 Jenkins에 포함시켜서 실패한 경우 미리 정의해둔 롤백 람다를 호출하는 방식을 사용하고 있습니다.

세세한 부분까지 신경쓰지 못했지만 이 포스팅이 AWS를 이용해 무중단 배포를 구현하시려는 분들께 도움이 되길 바랍니다.

이 포스팅에서 사용된 전체 코드는 이곳 에서 확인하실 수 있습니다.

This post is licensed under CC BY 4.0 by the author.

AWS를 사용해 무중단 배포 자동화 환경 구축하기 - 5. AWS 리소스 세팅

AWS Application Load Balancer에 SSL 인증서 적용하기