본문 바로가기

프로젝트/오디

CD 배포 스크립트 실행 중 오류가 발생한다면? : tag를 활용한 롤백 전략 구축 하기

우아한 테크코스 Level 3 마지막 주에는 크루와 코치님 한분이 팀을 이루어 서로가 팀 프로젝트 안에서 배운 것을 면접 식으로 문답하는 '레벨 인터뷰'라는 문화가 있습니다.

 

저는 CI / CD 스크립트 작성을 맡아 이에 대한 질문을 받았는데요. 많은 질문 중 한 가지 질문에 크게 당황하며 답하지 못했던 기억이 있습니다. 바로 이 질문인데요.

 

CD 스크립트 수행 중 오류가 발생하면
이를 대처할 롤백 전략을 세워두셨습니까?

 

 

왜 생각해보지 못했나?

-  운영 단계가 아니라 dev 서버 밖에 존재하지 않았기에 CD 스크립트 실패를 크게 염두해두지 않았습니다

 

 

그러나, 운영환경에서 CD 스크립트가 실패한다면?

운영환경에 영향을 주는 prod cd 스크립트가 실패하면?

 

그러나 운영 단계에서는 애플리케이션을 띄우는 과정에서 오류가 발생했다면 이를 대처할만한 롤백전략의 중요성이 강조되었습니다. 만약 애플리케이션 실행에 실패한 상태로 운영 서버 상에 어떠한 대처도 없다면 실제 사용자들이 서비스를 제공받지 못하는 최악의 상황으로 이어질 수 있기 때문입니다.

 

이번 글에서는 기존에 운용하던 CD 스크립트의 flow를 먼저 소개하고, tag를 통해 간단하게 롤백 전략을 구축하는 방법에 대해 알아봅니다

 


어떻게 CD 스크립트가 구성되어 있는가?

 

먼저 우리 팀의 인프라 구조를 간단히 설명하고 이에 맞춰 기존의 CD 스크립트가 어떻게 동작하는지 소개합니다.

 

CD 스크립트의 큰 흐름 동작을 그림으로 나타내면 다음과 같습니다

 

1. CD 스크립트 trigger event가 발생

    - main branch에 pull_request가 closed될 때

on:
  pull_request:
    branches:
      - main
    types:
      - closed

 

2. main 브랜치의 이미지를 build 하고 docker repository에 push

    - github runner에서 main branch를 checkout

 

defaults:
  run:
    working-directory: backend #backend 디렉토리에서 수정사항이 있었을 때만 run

steps:
  # checkout main
  - uses: actions/checkout@v4
    with:
      ref: main
  
  # JDK setup
  - name: Set up JDK 17
    uses: actions/setup-java@v4
    with:
      java-version: '17'
      distribution: 'temurin'

  - name: Setup Gradle
    uses: gradle/actions/setup-gradle@v3

  # build
  - name: Clean Build With Gradle Wrapper
    run: ./gradlew clean build

  # test report
  - name: Publish Test Results
    uses: EnricoMi/publish-unit-test-result-action@v2
    if: always()
    with:
      files: ${{ github.workspace }}/backend/build/test-results/**/*.xml

  - name: JUnit Report Action
    uses: mikepenz/action-junit-report@v4
    if: always()
    with:
      report_paths: ${{ github.workspace }}/backend/build/test-results/**/*.xml

  # setup Docker
  - name: Setup Docker Buildx
    uses: docker/setup-buildx-action@v3
   
  # login to Docker
  - name: Login to Docker Hub
    uses: docker/login-action@v3
    with:
      username: ${{ secrets.DOCKERHUB_USERNAME }} # github.secrets 내에 있는 환경 변수
      password: ${{ secrets.DOCKERHUB_PASSWORD }}
 
  # image build and push to repository
  - name: Docker Image Build And Push
    run: docker build --platform linux/arm64 -t ${{ secrets.DOCKERHUB_USERNAME }}/$DOCKERHUB_REPOSITORY:${{ github.sha }}-prod -f Dockerfile . --build-arg JASYPT_ENCRYPTOR_PASSWORD=${{ secrets.JASYPT_PASSWORD }} --push

 

 

3. prod ec2 내 self-hosted runner에서 이미지를 pull

   

- docker repository에서 push된 현재 workflow 상의 이미지를 pull 받습니다

> self-hosted runner를 쓴 이유?

더보기

요구사항 중 `등록되지 않은 외부 접근 가능 ip는 사용하지 못한다`라는 제한사항이 있었습니다.

우테코에서 aws az에서 접근 가능한 port를 80과 443으로 한정해놓았습니다. 

일반적으로 원격 접속 가능한 포트인 22번 포트를 활용하지 못하는 상황이었습니다.

 

따라서 docker image를 pull하고 run 하는 작업은 EC2 내에 내장된 self-hosted runner를 통해 작업을 수행하도록 하였습니다.

 

ref) https://mildwhale.github.io/2021-04-24-build-machine-with-m1-macmini/

 

pull-and-deploy:
  needs: build-and-push

  runs-on: ${{ matrix.environment }}

  strategy:
    max-parallel: 1 # 직렬처리
    matrix:
      environment: [ prod-a, prod-b ] #각각 prod-a와 prod-b self hosted runner에서 실행

  steps:
    - name: Login to Docker Hub
      uses: docker/login-action@v3
      with:
        username: ${{ secrets.DOCKERHUB_USERNAME }}
        password: ${{ secrets.DOCKERHUB_PASSWORD }}

    - name: Clean Up Legacy Image And Pull
      run: |
        docker image prune -a -f #사용하지 않고 있는 image들 정리하기
        docker pull --platform linux/arm64 ${{ secrets.DOCKERHUB_USERNAME }}/$DOCKERHUB_REPOSITORY:${{ github.sha }}-prod #push한 이미지 pull 받기

 

 

4. prod ec2 내에서 pull 받은 image를 실행합니다

- 현재 ec2 내 도커 환경에서 중복 이름의 컨테이너를 제거하고 pull 받은 image를 run하여 컨테이너화 합니다

> 포트 포워딩 한 이유 - 80:8080

더보기

이유를 이해라면 도커 컨테이너 포트와 호스트 포트에 대해 알아야하는데

간단히 설명하면 호스트 포트는 외부로부터 요청을 받은 EC2 자체의 포트, 도커 컨테이너 포트는 EC2 내 도커에서 호스트 포트와 독립적으로 운용되는 컨테이너 하나가 돌아가는 포트를 의미한다.

 

우테코 보안그룹 인바운드 규칙이 HTTP 80포트로만 가능하였다.

따라서 host 80포트로 오는 요청을 도커 컨테이너 디폴트 포트인 8080포트로 포워딩해주겠다는 의미를 지녔다

 

host 포트와 도커 컨테이너 포트에 대해 궁금하다면 다음 링크를 참고하자

https://blog.naver.com/alice_k106/220278762795

 

    - name: Set up Container And Run Docker Image
      run: |
        # 중복 이름의 container가 있다면 실행을 중지
        docker stop $DOCKER_CONTAINER_NAME || true
        
        # 중복 이름의 container를 삭제
        docker rm $DOCKER_CONTAINER_NAME || true   
        
        #push 받은 이미지를 도커라이즈 > 컨테이너화
        docker run -d --platform linux/arm64 --name $DOCKER_CONTAINER_NAME -v /var/logs/ody-prod-logs:/ody-prod-logs -p 80:8080 -e SPRING_PROFILES_ACTIVE=prod -e JASYPT_ENCRYPTOR_PASSWORD=${{ secrets.JASYPT_PASSWORD }} ${{ secrets.DOCKERHUB_USERNAME }}/$DOCKERHUB_REPOSITORY:${{ github.sha }}-prod

태깅을 통해 롤백 전략 구축하기

 

이제 글의 본격적인 주제인 롤백 전략으로 들어가봅시다. 어떻게 해야 배포 과정에서 발생하는 오류, 조금 더 정확히 이야기하면 image build까지는 잘 되는데 ' docker run image '에서 컨테이너화에 실패하는 상황을 대처할 수 있을까요?

 

저와 함께 페어였던 제리는 2가지 대안을 생각했습니다.

- docker commit 활용하기 : 현재 container를 image화하여 keep 해둔 뒤 실패하면 다시 실행

- docker tag 활용하기 : 이전 image와 현재 image를 태그를 통해 분류하고 실패하면 이전 이미지 실행

 

그리고, 후자인 tag를 활용해 이전 이미지와 현재 이미지를 구분하는 전략을 선택하였는데요. 그 이유는 다음과 같습니다.

- 일관성 :  CD 스크립트의 버전을 tagging을 통해 일관성있게 관리할 수 있음

- 자동화 가능 : commit 값은 매번 바뀌기에 CI/CD 자동화 스크립트와 호환성이 높음

 

 

그럼 그림을 통해 롤백 전략의 큰 흐름들을 알아보겠습니다

 

본격적인 롤백 전략에 대한 대비는 새로운 image를 build & push 할 때 부터 시작됩니다.

 

1. 이전 이미지 파일 리태깅 : latest  >  previous

      - name: Back Up Image For Rollback
        run: |
          docker pull ${{ secrets.DOCKERHUB_USERNAME }}/$DOCKERHUB_REPOSITORY:latest || true
          docker tag ${{ secrets.DOCKERHUB_USERNAME }}/$DOCKERHUB_REPOSITORY:latest ${{ secrets.DOCKERHUB_USERNAME }}/$DOCKERHUB_REPOSITORY:previous || true
          docker push ${{ secrets.DOCKERHUB_USERNAME }}/$DOCKERHUB_REPOSITORY:previous || true

 

  • 가장 최근 이미지인 latest 이미지 pull
  • latest 이미지 태그를 리네이밍 : latest -> previous
  • push를 통해 docker hub에 태그 리네이밍 반영

 


2. main branch 이미지 build and push

 

- name: Docker Image Build And Push
  run: docker build --platform linux/arm64 -t ${{ secrets.DOCKERHUB_USERNAME }}/$DOCKERHUB_REPOSITORY:${{ github.sha }}-prod -f Dockerfile . --build-arg JASYPT_ENCRYPTOR_PASSWORD=${{ secrets.JASYPT_PASSWORD }} --push

 

  • 태그 : {github.sha} - prod

 

 

3. main branch 이미지 pull & 컨테이너화 시도

 

4-1. 배포 성공시

 

  • 배포에 성공한 이미지를 latest로 리태깅
  • docker hub에 push하여 latest 이미지 갱신
      - name: Tag successful deployment as latest
        if: success()
        run: |
          docker tag ${{ secrets.DOCKERHUB_USERNAME }}/$DOCKERHUB_REPOSITORY:${{ github.sha }}-prod ${{ secrets.DOCKERHUB_USERNAME }}/$DOCKERHUB_REPOSITORY:latest
          docker push ${{ secrets.DOCKERHUB_USERNAME }}/$DOCKERHUB_REPOSITORY:latest

 

 

4-2. 배포 실패 시

 

  • 이전 빌드 이미지인 previous 이미지 pull
  • prod ec2 내에 previous image re-build
      - name: Rollback if Health Check fails
        if: failure()
        run: |
          docker stop $DOCKER_CONTAINER_NAME || true
          docker rm $DOCKER_CONTAINER_NAME || true
          docker run -d --platform linux/arm64 --name $DOCKER_CONTAINER_NAME -v /var/logs/ody-prod-logs:/ody-prod-logs -p 80:8080 -e SPRING_PROFILES_ACTIVE=prod -e JASYPT_ENCRYPTOR_PASSWORD=${{ secrets.JASYPT_PASSWORD }} ${{ secrets.DOCKERHUB_USERNAME }}/$DOCKERHUB_REPOSITORY:previous

 

 

다음은 전체적인 prod CD 스크립트의 모습입니다.

더보기
name: backend-cd-prod

on:
  pull_request:
    branches:
      - main
    types:
      - closed

env:
  DOCKERHUB_REPOSITORY: ody-official
  DOCKER_CONTAINER_NAME: ody-prod-app

jobs:
  build-and-push:
    if: github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'release-be/')
    runs-on: ubuntu-latest
    env:
      TZ: 'Asia/Seoul'

    defaults:
      run:
        working-directory: backend

    steps:
      - uses: actions/checkout@v4
        with:
          ref: main

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'

      - name: Check system timezone
        run: |
          echo "Current date and time: $(date)"
          echo "TZ environment variable: $TZ"

      - name: Setup Gradle
        uses: gradle/actions/setup-gradle@v3

      - name: Clean Build With Gradle Wrapper
        run: ./gradlew clean build

      - name: Publish Test Results
        uses: EnricoMi/publish-unit-test-result-action@v2
        if: always()
        with:
          files: ${{ github.workspace }}/backend/build/test-results/**/*.xml

      - name: JUnit Report Action
        uses: mikepenz/action-junit-report@v4
        if: always()
        with:
          report_paths: ${{ github.workspace }}/backend/build/test-results/**/*.xml

      - name: Setup Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_PASSWORD }}

      - name: Back Up Image For Rollback
        run: |
          docker pull ${{ secrets.DOCKERHUB_USERNAME }}/$DOCKERHUB_REPOSITORY:latest || true
          docker tag ${{ secrets.DOCKERHUB_USERNAME }}/$DOCKERHUB_REPOSITORY:latest ${{ secrets.DOCKERHUB_USERNAME }}/$DOCKERHUB_REPOSITORY:previous || true
          docker push ${{ secrets.DOCKERHUB_USERNAME }}/$DOCKERHUB_REPOSITORY:previous || true

      - name: Docker Image Build And Push
        run: docker build --platform linux/arm64 -t ${{ secrets.DOCKERHUB_USERNAME }}/$DOCKERHUB_REPOSITORY:${{ github.sha }}-prod -f Dockerfile . --build-arg JASYPT_ENCRYPTOR_PASSWORD=${{ secrets.JASYPT_PASSWORD }} --push

  pull-and-deploy:
    needs: build-and-push

    runs-on: ${{ matrix.environment }}

    strategy:
      max-parallel: 1 # 직렬처리
      matrix:
        environment: [ prod-a, prod-b ]

    steps:
      - name: Login to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_PASSWORD }}

      - name: Clean Up Legacy Image And Pull
        run: |
          docker image prune -a -f
          docker pull --platform linux/arm64 ${{ secrets.DOCKERHUB_USERNAME }}/$DOCKERHUB_REPOSITORY:${{ github.sha }}-prod

      - name: Set up Container And Run Docker Image
        run: |
          docker stop $DOCKER_CONTAINER_NAME || true
          docker rm $DOCKER_CONTAINER_NAME || true          
          docker run -d --platform linux/arm64 --name $DOCKER_CONTAINER_NAME -v /var/logs/ody-prod-logs:/ody-prod-logs -p 80:8080 -e SPRING_PROFILES_ACTIVE=prod -e JASYPT_ENCRYPTOR_PASSWORD=${{ secrets.JASYPT_PASSWORD }} ${{ secrets.DOCKERHUB_USERNAME }}/$DOCKERHUB_REPOSITORY:${{ github.sha }}-prod

      - name: Health Check with Retry
        uses: nick-invision/retry@v2
        with:
          timeout_minutes: 1
          max_attempts: 5
          retry_wait_seconds: 6
          command: |
            response=$(curl -s https://prod.oody.site/actuator/health)
            status=$(echo $response | jq -r '.status')
            if [ "$status" = "UP" ]; then
              echo "Status is UP. Continuing..."
              exit 0
            else
              echo "Status is NOT UP."
              exit 1
            fi

      - name: Rollback if Health Check fails
        if: failure()
        run: |
          docker stop $DOCKER_CONTAINER_NAME || true
          docker rm $DOCKER_CONTAINER_NAME || true
          docker run -d --platform linux/arm64 --name $DOCKER_CONTAINER_NAME -v /var/logs/ody-prod-logs:/ody-prod-logs -p 80:8080 -e SPRING_PROFILES_ACTIVE=prod -e JASYPT_ENCRYPTOR_PASSWORD=${{ secrets.JASYPT_PASSWORD }} ${{ secrets.DOCKERHUB_USERNAME }}/$DOCKERHUB_REPOSITORY:previous

      - name: Tag successful deployment as latest
        if: success()
        run: |
          docker tag ${{ secrets.DOCKERHUB_USERNAME }}/$DOCKERHUB_REPOSITORY:${{ github.sha }}-prod ${{ secrets.DOCKERHUB_USERNAME }}/$DOCKERHUB_REPOSITORY:latest
          docker push ${{ secrets.DOCKERHUB_USERNAME }}/$DOCKERHUB_REPOSITORY:latest

      - name: Check Docker Process
        if: always()
        run: docker ps

 


실행 결과

 

그럼 실제로 rollback이 동작하는지 dev 서버로 테스트해보도록 하겠습니다.

실제로 docker image run 과정에서 application이 뜰 때 실패하는 코드를 넣고 돌려보면

 

Rollback script가 실행되는 것을 확인할 수 있으며 previous tag가 붙은 이미지를 실행하는 것으로 보아 롤백 전략이 성공적으로 작동하고 있음을 알 수 있습니다.