AWS ECR + S3 + CodeDeploy 로 Auto Scaling Group BlueGreen 배포 자동화 (+ Github Actions, Docker Compose)

sangho·2025년 3월 13일

배경


성능 테스트 진행 도중 EC2 인스턴스의 CPU 사용량이 거의 MAX에 다다르는 것을 확인하였습니다. EC2 인스턴스가 병목 지점이라고 판단되어 scale out이 필요하겠다고 느꼈습니다. 기존 프로젝트 인프라 아키텍처는 단일 인스턴스에 배포하고 있었는데, 여러 인스턴스에 자동 배포 파이프라인을 적용하기 위해선 AWS CodeDeploy를 사용해야 겠다는 생각이 들었습니다.

무중단 배포 방식으로는 Rolling, Blue/Green, Canary 등 여러 방식이 있는데, AWS CodeDeploy 를 사용하면 Blue/Green 방식의 무중단 배포를 쉽게 적용할 수 있으므로 이를 선택했습니다.

쉽다고 하지만 처음이었기 때문에 엄청난 삽질을 했습니다...흑흑

AWS 인프라 구성과 CI/CD 과정에서 사용되는 yaml 파일들은
# [Infra] Github Actions + ECR + Auto Scaling Group + EC2 + CodeDeploy + S3 를 사용하여 Blue/Green CI/CD 구축하기-kshired님의 글 을 많이 참고하였습니다. 따라서 이 글을 먼저 읽어보는것을 추천드리는데, 차이점은 저는 레디스는 EC2 인스턴스에 스프링 애플리케이션과 같이 배포하는 구조였기 때문에 docker compose를 사용하였습니다.

Github Actions workflow 파일을 작성하는데 애를 많이 먹었기 때문에, 전반적인 배포 프로세스를 확인하며 workflow 파일을 왜 이렇게 작성했는지를 위주로 설명하겠습니다.

AWS IAM 권한, ALB, Auto Scaling Group, CodeDeploy, ECR에 대한 디테일한 설정 방법은 위 링크 혹은 다른 글을 참고하시면 좋을거 같습니다.

배포 프로세스


출처: # [Infra] Github Actions + ECR + Auto Scaling Group + EC2 + CodeDeploy + S3 를 사용하여 Blue/Green CI/CD 구축하기-kshired님의 글

전반적인 배포 프로세스를 이해하기 위해서 하나씩 살펴봐야 할거 같습니다.

Blue/Green 배포

Blue/Green 배포 방식은 트래픽을 처리하고 있는 기존 인스턴스 그룹을 유지시킨 채, 새로 배포할 인스턴스 그룹을 만들고 이 새로운 그룹이 트래픽을 받을 준비가 끝나면 그때부턴 트래픽을 이 새로운 그룹에 전달하는 방식입니다.

기존 그룹을 유지한 채 새로운 그룹을 만든다는 점에서 Canary 배포와 유사하지만, 새로운 그룹에 점진적으로 트래픽을 전달하는 Canary 배포와 달리 Blue/Green 배포는 새로운 그룹이 트래픽을 받을 준비가 끝나면 한번에 모두 새로운 그룹으로 전환한다는 차이가 있습니다.

기존 그룹을 Blue Group, 새로 배포되는 그룹을 Green Group이라고 부릅니다.

AWS CodeDeploy

AWS CodeDeploy를 사용하면 이러한 Blue/Green 배포 과정을 자동화할 수 있습니다.

저는 CPU 사용량에 따라 동적으로 인스턴스를 수평 확장할 수 있게 하기 위해 Auto Scaling Group을 설정하고, CodeDeploy의 배포 그룹에 이를 적용했습니다.

Auto Scaling은 필요에 따라 새로운 인스턴스를 자동으로 생성해야 하는데, 이렇게 인스턴스를 생성하는데 필요한 정보를 설정하기 위해 AMI와 Launch Template 이 필요합니다.

AMI: 운영 체제와 소프트웨어 구성을 포함한 인스턴스의 기본 이미지. 새로운 배포 그룹 인스턴스를 이 AMI를 기반으로 생성합니다.
Launch Template: 인스턴스 시작 시 필요한 다양한 설정을 미리 정의하여 인스턴스 시작 과정을 간소화합니다.

Auto Scaling Group은 인스턴스를 생성할 때 이 Launch Template(시작 템플릿)에 정의된 대로 인스턴스를 생성하게 됩니다.

저는 로드 밸런서와 로드 밸런싱 대상이 되는 대상 그룹을 미리 만들고 Auto Scaling Group을 기존 로드 밸런서에 연결하도록 하였습니다.

AWS CodeDeploy의 배포 그룹에 위와 같이 Auto Scaling 그룹을 연결해주었습니다.

AWS ECR

스프링 애플리케이션을 도커 이미지로 빌드하여 배포하는 방식을 선택했기 때문에, 도커 이미지를 저장해둘 레지스트리로 AWS ECR을 선택했습니다. (Docker Hub를 사용해도 됨)

appspec.yml

출처: https://docs.aws.amazon.com/ko_kr/codedeploy/latest/userguide/reference-appspec-file-structure-hooks.html

AWS CodeDeploy는 배포 과정에서 위와 같이 일련의 이벤트 훅을 발생시키는데, 이 이벤트 훅 과정에서 실행할 스크립트를 지정하기 위해선 appspec.yml 파일을 작성해야 합니다.

version: 0.0  
os: linux  
files:  
  - source: /  
    destination: /home/ubuntu/app  
    overwrite: yes  
  
permissions:  
  - object: /  
    pattern: "**"  
    owner: ubuntu  
    group: ubuntu  
    mode: 755  
  
hooks:  
  AfterInstall:  
    - location: scripts/deploy.sh  
      timeout: 60  
      runas: ubuntu

위와 같이 작성하면 appspec.yml 파일이 위치한 디렉토리의 모든 파일과 디렉토리를 인스턴스의 /home/ubuntu/app 에 복사합니다. AfterInstall 훅 단계에서 deploy.sh 이라는 또 다른 쉘 스크립트를 실행시키도록 합니다.

mkdir -p scripts  
cat > scripts/deploy.sh << 'EOF'  
#!/bin/bash  
set -e  
aws ecr get-login-password --region ap-northeast-2 | docker login --username AWS --password-stdin ${본인의 ecr 리포지토리 URI}  
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"   
docker-compose -f "$DIR/docker-compose.yml" down || true  
docker container prune -f  
docker-compose -f "$DIR/docker-compose.yml" up -d  
EOF  

deploy.sh은 위와 같이 ECR에 로그인 후 실행중이던 docker-compose 컨테이너들을 종료 후 새로 실행시키도록 합니다.

deploy.sh을 실행할 때 docker-compose.yml 파일을 못찾아서 배포를 실패하는 현상이 발생하여
docker-compose.yml 파일을 deploy.sh과 같은 디렉토리인 scripts 디렉토리에 넣고, appspec.yml과 함께 압축하여 S3 버킷으로 업로드하도록 하였습니다.

이후 deploy.sh 파일이 배포될 EC2 인스턴스에서 실행될 때 docker-compose.yml 파일과 deploy.sh이 같은 위치에 있으므로 docker-compose 명령어를 실행할 때 DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"를 통해 디렉토리 절대 경로를 추출해서 사용하도록 하도록 하였더니 해결되었습니다.

S3 버킷에 저장되는 zip 파일은 위와 같은 구조입니다.

version: "3.4"  
  
services:  
  friendchise:  
    container_name: friendchise  
    image: ${ECR_REGISTRY}/${ECR_REPOSITORY}:${IMAGE_TAG}  
    ports:  
      - "8080:8080"  
    environment:  
      - DATASOURCE_URL=${DATASOURCE_URL}  
      - DATASOURCE_USERNAME=admin  
      - DATASOURCE_PASSWORD=${DATASOURCE_PASSWORD}  
      - REDIS_HOST=redis  
      - REDIS_PASSWORD=${REDIS_PASSWORD}  
      - JWT_ISSUER=${JWT_ISSUER}  
      - JWT_SECRET=${JWT_SECRET}  
      - KAKAO_API_KEY=${KAKAO_API_KEY}  
      - OPENAI_API_KEY=${OPENAI_API_KEY}  
    networks:  
        - friendchise_network  
  redis:  
    container_name: redis  
    image: redis:latest  
    ports:  
      - "6379:6379"  
    command: ["redis-server", "--requirepass", "${REDIS_PASSWORD}"]  
    networks:  
        - friendchise_network  
  
networks:  
  friendchise_network:

docker-compose.yml 파일은 위와 같은데, 컨테이너에 환경변수를 어떻게 주입할까 고민하다가

cat > scripts/.env << EOF  
ECR_REGISTRY=$ECR_REGISTRY  
ECR_REPOSITORY=friendchise-origin  
IMAGE_TAG=$IMAGE_TAG  
REDIS_PASSWORD=$REDIS_PASSWORD  
DATASOURCE_URL=$DATASOURCE_URL  
DATASOURCE_PASSWORD=$DATASOURCE_PASSWORD  
JWT_ISSUER=$JWT_ISSUER  
JWT_SECRET=$JWT_SECRET  
KAKAO_API_KEY=$KAKAO_API_KEY  
OPENAI_API_KEY=$OPENAI_API_KEY  
EOF  

그냥 Github Secrets에 저장해둔 .env 파일 내용을 꺼내와 workflow 과정에서 다시 .env 파일을 만들고 zip 파일에 함께 압축해서 업로드하도록 했습니다.

주의할점은 앞서 deploy.sh 파일을 작성할 때는 저 EOF에 따옴표를 넣었는데, .env 파일을 작성할 때는 따옴표를 넣으면 안됩니다. (따옴표를 넣으면 $ 부분을 리터럴로 인식하고 따옴표를 넣지 않으면 변수로 인식합니다. .env 파일의 경우 $부분이 값이 치환되어야 하기 때문에 따옴표를 넣으면 안됩니다. )

전체 workflow 파일은 다음과 같습니다.

name: Deploy to AWS CodeDeploy  
  
on:  
  push:  
    branches:  
      - 'dev'   
  
jobs:  
  deploy:  
    runs-on: ubuntu-latest  
  
    steps:  
      - name: Checkout Code  
        uses: actions/checkout@v4  
  
      - name: Set up Java 17  
        uses: actions/setup-java@v4  
        with:  
          distribution: 'corretto'  
          java-version: '17'  
  
      - name: Cache Gradle packages  
        uses: actions/cache@v4  
        with:  
          path: |  
            ~/.gradle/caches  
            ~/.gradle/wrapper  
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}  
          restore-keys: |  
            ${{ runner.os }}-gradle-  
  
      - name: Grant execute permission for gradlew  
        run: chmod +x gradlew  
  
      - name: Build with Gradle  
        run: ./gradlew clean build  
  
      - name: Configure AWS Credentials  
        uses: aws-actions/configure-aws-credentials@v1  
        with:  
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}  
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}  
          aws-region: ap-northeast-2  
  
      - name: Login to ECR  
        id: login-ecr  
        uses: aws-actions/amazon-ecr-login@v1  
  
      - name: build docker file and setting deploy file  
        env:  
            ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}  
            ECR_REPOSITORY: friendchise-origin  
            IMAGE_TAG: ${{ github.sha }}  
            REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD }}  
            DATASOURCE_URL: ${{ secrets.DATASOURCE_URL }}  
            DATASOURCE_PASSWORD: ${{ secrets.DATASOURCE_PASSWORD }}  
            JWT_ISSUER: ${{ secrets.JWT_ISSUER }}  
            JWT_SECRET: ${{ secrets.JWT_SECRET }}  
            KAKAO_API_KEY: ${{ secrets.KAKAO_API_KEY }}  
            OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}  
        run: | 
          docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .  
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG  
          
          mkdir -p scripts  
          cat > scripts/deploy.sh << 'EOF'  
          #!/bin/bash  
          set -e  
          echo "Logging in to ECR..."  
          aws ecr get-login-password --region ap-northeast-2 | docker login --username AWS --password-stdin ${본인의 ecr 리포지토리 URI}  
          DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"   
          docker-compose -f "$DIR/docker-compose.yml" down || true  
          docker container prune -f  
          docker-compose -f "$DIR/docker-compose.yml" up -d  
          EOF  
          
          cat > scripts/.env << EOF  
          ECR_REGISTRY=$ECR_REGISTRY  
          ECR_REPOSITORY=friendchise-origin  
          IMAGE_TAG=$IMAGE_TAG  
          REDIS_PASSWORD=$REDIS_PASSWORD  
          DATASOURCE_URL=$DATASOURCE_URL  
          DATASOURCE_PASSWORD=$DATASOURCE_PASSWORD  
          JWT_ISSUER=$JWT_ISSUER  
          JWT_SECRET=$JWT_SECRET  
          KAKAO_API_KEY=$KAKAO_API_KEY  
          OPENAI_API_KEY=$OPENAI_API_KEY  
          EOF  
          
          chmod +x scripts/deploy.sh  
      - name: upload to s3  
        env:  
          IMAGE_TAG: ${{ github.sha }}  
        run: |  
          mv docker-compose.yml scripts/  
          zip -r deploy-$IMAGE_TAG.zip ./scripts appspec.yml docker-compose.yml .env  
          aws s3 cp --region ap-northeast-2 --acl private ./deploy-$IMAGE_TAG.zip s3://friendchisebucket  
      - name: start deploy  
        env:  
          IMAGE_TAG: ${{ github.sha }}  
        run: |  
          aws deploy create-deployment --application-name deploy \  
          --deployment-config-name CodeDeployDefault.OneAtATime \  
          --deployment-group-name deploy-group \  
          --s3-location bucket=friendchisebucket,bundleType=zip,key=deploy-$IMAGE_TAG.zip

정리


전반적인 workflow 프로세스를 정리하자면
ECR에는 우리의 스프링 애플리케이션 도커 이미지를 푸시하고
CodeDeploy Agent가 배포할 인스턴스에서 해당 이미지로 도커 컨테이너를 띄우게 하기 위해
S3 버킷에 appspec.yml, deploy.sh과 docker-compose.yml, .env 파일을 압축하여 zip 업로드 합니다.

Green 그룹 인스턴스들 생성과 트래픽 전환, 기존 Blue 그룹 인스턴스 종료 등은 CodeDeploy가 알아서...

Blue/Green 배포에 성공하였습니다..!

무수한 실패 끝에..

참고


https://velog.io/@pshsh910/AWS-Code-Deploy%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-CD-%EA%B5%AC%ED%98%84
https://jjeongil.tistory.com/1577
https://velog.io/@juhyeon1114/AWSSpring-Auto-Scalable-Spring-%EC%84%9C%EB%B2%84-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0

profile
기록하는 습관을 갖자

0개의 댓글