Infra

[CI/CD] 배포에 관한 생각

immersive 2025. 4. 5. 12:42

CI/CD 흐름도

배포 방법론에 대한 생각

개발자는 본인이 개발한 작업물을 불특정 다수에게 공개하기 위해 필연적으로 배포라는 과정을 거쳐야 합니다. 저 또한 처음으로 팀 프로젝트를 진행하면서 로컬에서 개발한 후, 클라우드 서비스에 배포하여 모든 팀원들이 작업물을 공유할 수 있었을 때 신기하면서도 뿌듯했던 기억이 있습니다.

우연히 유튜브를 보다가 6가지 배포 전략에 대해서 소개하는 유튜브 영상을 봤습니다. (영상은 하단에 첨부했습니다) 영상에 소개된 배포 전략 중 제가 실제로 적용했던 배포 방식도 있어서 이 글에서는 제가 학부 시절 프로젝트에서 사용했던 배포 방식부터, 현재 회사에서 사용하는 배포 방식까지 소개해 보려고 합니다.

배포 과정에서 자주 언급되는 개념 중 하나가 CI/CD(Continuous Integration & Continuous Deployment)입니다.

  • CI(Continuous Integration): 코드를 병합하고 빌드한 뒤, 테스트를 거쳐 배포 직전 상태를 만드는 과정입니다.
  • CD(Continuous Deployment): 코드 변경 사항이 테스트를 통과하면 자동으로 프로덕션 환경에 배포되는 과정입니다.

즉, 코드를 빌드하고 배포하는 작업을 자동화하는 방법론이라고 볼 수 있습니다. 프로젝트의 규모와 요구 사항에 따라 배포 방식은 달라질 수 있으며, 이를 수행하기 위한 도구로는 대표적으로 GitHub Actions, Jenkins, Travis CI 등이 있습니다. 이 글에서는 이러한 툴의 사용법보다는, 제가 직접 적용해본 배포 방법론에 대해 이야기해보겠습니다.

Big Bang Deployment

Big Bang Deployment란, 모든 시스템 구성 요소를 한 번에 교체하는 배포 방식입니다. 즉, 배포할 때마다 서버를 일시적으로 중지한 후, 새로운 버전을 배포하고 다시 서버를 가동하는 방식입니다. 이 방식의 장점은 배포 프로세스가 단 한 번만 실행되므로 절차가 단순하다는 점입니다. 또한, 모든 구성 요소가 동시에 업데이트되기 때문에 버전 불일치와 같은 문제가 발생하지 않습니다.

 

제가 프로젝트에서 처음 적용해본 배포 방식도 이와 유사했습니다. 단일 환경에서 컨테이너를 교체하는 방식으로,

  1. 기존 컨테이너(aurora-app)를 중지하고 제거한 뒤,
  2. 새로 빌드된 Docker 이미지(aurora-dev:latest)를 pull하여 바로 실행하는 구조였습니다.

즉, 기존 것을 내리고 새로운 것을 올리는 Big Bang Deployment 방식입니다.

name: CI/CD using GitHub Actions & Docker

on:
  pull_request:
    branches: [ "dev" ]

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout source
        uses: actions/checkout@v3

      - name: Setup Java
        uses: actions/setup-java@v3
        with:
          distribution: 'temurin'
          java-version: '17'

      - name: Gradle Caching
        uses: actions/cache@v3
        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 Project with Gradle
        run: |
          echo ${{ secrets.APPLICATION_SECRET_DEV }} | base64 --decode > ./src/main/resources/application-secret-dev.yml
          ./gradlew bootJar

      - name: Login to DockerHub
        uses: docker/login-action@v1
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_TOKEN }}

      - name: Build Docker image
        run: docker build --platform linux/amd64 -t ${{ secrets.DOCKER_USERNAME }}/aurora-dev .

      - name: Publish image to Docker Hub
        run: docker push ${{ secrets.DOCKER_USERNAME }}/aurora-dev:latest

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to Server
        uses: appleboy/ssh-action@master
        with:
          username: ubuntu
          host: ${{ secrets.AURORA_IP_DEV }}
          key: ${{ secrets.EC2_SSH_KEY_DEV }}
          script_stop: true
          script: |
            # 기존 컨테이너 중지 및 제거
            sudo docker stop aurora-app || true
            sudo docker rm aurora-app || true
            
            # 최신 이미지 풀
            sudo docker pull ${{ secrets.DOCKER_USERNAME }}/aurora-dev:latest
            
            # 새로운 컨테이너 실행
            sudo docker run -d --name aurora-app -p 8080:8080 ${{ secrets.DOCKER_USERNAME }}/aurora-dev:latest

      - name: Check server URL
        uses: jtalk/url-health-check-action@v3
        with:
          url: http://${{ secrets.AURORA_IP_DEV }}:8080/env
          max-attempts: 5
          retry-delay: 10s

 

눈치챘겠지만, 이 방식에는 치명적인 단점이 있는데요. 그것은 바로 배포할 때 마다 서버 다운타임이 발생한다는 점입니다. 아무리 빠르게 배포하더라도 일시적으로 서비스가 중단될 수밖에 없습니다. 실 서비스 운영 시, Big Bang Deployment 방법을 적용하면 업데이트를 할 때마다 점검 시간을 둬야 합니다. 
현대적인 소프트웨어 개발 환경은 여러 서비스로 이루어져 있으며 변경 사항이 빈번히 일어나기 때문에 이와 같은 배포 방식을 사용하기보다 점진적으로 배포하는 CI/CD 방식이 더 선호됩니다.

그럼에도 불구하고, 저는 서비스 초기 개발 단계이거나 다운타임이 허용되는 비즈니스 환경에서는 Big Bang Deployment가 여전히 매력적인 배포 방식이라고 생각합니다.

Blue Green Deployment

Big Bang Deployment의 단점인 서비스 다운 타임이 발생하는 단점을 개선하기 위해 저는 다음 프로젝트에서 Blue/Green Deployment을 적용했습니다. Blue/Green Deployment란 두 개의 동일한 프로덕션 환경(Blue와 Green)을 유지하며, 한 환경에서 현재 서비스를 실행하는 동안 다른 환경에 새 버전을 배포하는 방식입니다. 배포가 완료되고 새 환경이 안정적으로 작동하는 것이 확인되면 트래픽을 새 환경으로 전환합니다. 즉, Blue/Green 전략을 사용하면 서비스 중단 없이 새로운 버전을 배포하고 안정적으로 새로운 버전으로 전환할 수 있습니다.

  • Blue: 현재 서비스가 실행 중인 환경
  • Green: 새 버전이 배포될 준비 환경
# 빌드 부분은 Big Bang Deployment 방식과 동일

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - name: Set Target IP
        run: |
          STATUS=$(curl -o /dev/null -w "%{http_code}" "http://${{ secrets.AURORA_IP_DEV }}/env")
          echo $STATUS
          if [ $STATUS = 200 ]; then
            CURRENT_UPSTREAM=$(curl -s "http://${{ secrets.AURORA_IP_DEV }}/env")
          else
            CURRENT_UPSTREAM=green
          fi
          echo CURRENT_UPSTREAM=$CURRENT_UPSTREAM >> $GITHUB_ENV
          if [ $CURRENT_UPSTREAM = blue ]; then
            echo "CURRENT_PORT=8080" >> $GITHUB_ENV
            echo "STOPPED_PORT=8081" >> $GITHUB_ENV
            echo "TARGET_UPSTREAM=green" >> $GITHUB_ENV
          else
            echo "CURRENT_PORT=8081" >> $GITHUB_ENV
            echo "STOPPED_PORT=8080" >> $GITHUB_ENV
            echo "TARGET_UPSTREAM=blue" >> $GITHUB_ENV
          fi

      - name: Docker Compose
        uses: appleboy/ssh-action@master
        with:
          username: ubuntu
          host: ${{ secrets.AURORA_IP_DEV }}
          key: ${{ secrets.EC2_SSH_KEY_DEV }}
          script_stop: true
          script: |
            sudo docker pull ${{ secrets.DOCKER_USERNAME }}/aurora-dev:latest
            sudo docker-compose -f docker-compose-${{env.TARGET_UPSTREAM}}.yml up -d

      - name: Check deploy server URL
        uses: jtalk/url-health-check-action@v3
        with:
          url: http://${{ secrets.AURORA_IP_DEV }}:${{ env.STOPPED_PORT }}/env
          max-attempts: 5
          retry-delay: 10s

      - name: Change nginx upstream
        uses: appleboy/ssh-action@master
        with:
          username: ubuntu
          host: ${{ secrets.AURORA_IP_DEV }}
          key: ${{ secrets.EC2_SSH_KEY_DEV }}
          script_stop: true
          script: |
            sudo docker exec -i nginxserver bash -c 'echo "set \$service_url ${{env.TARGET_UPSTREAM}};" > etc/nginx/conf.d/service-env.inc && nginx -s reload'

      - name: Stop current server
        uses: appleboy/ssh-action@master
        with:
          username: ubuntu
          host: ${{ secrets.AURORA_IP_DEV }}
          key: ${{ secrets.EC2_SSH_KEY_DEV }}
          script_stop: true
          script: |
            sudo docker stop ${{ env.CURRENT_UPSTREAM }}
            sudo docker rm ${{ env.CURRENT_UPSTREAM }}
배포 단계 (deploy) 단계는 아래와 같습니다.
1. 환경 결정 (Set Target IP):
  • 현재 실행 중인 환경(CURRENT_UPSTREAM)을 확인합니다. /env 엔드포인트로 HTTP 상태 코드를 확인하고, 200이면 현재 환경(blue 또는 green)을 가져옵니다. 실패 시 기본값으로 green을 설정
  • Blue(8080 포트)가 실행 중이면 Green(8081 포트)에 배포하고, Green이 실행 중이면 Blue에 배포하도록 포트와 타겟 환경을 설정

2. 새 환경 배포 (Docker Compose)

  • 새 이미지를 풀(pull)하고, docker-compose-${{env.TARGET_UPSTREAM}}.yml 파일로 타겟 환경(Green 또는 Blue)에 새 컨테이너를 띄움
    • ex) Green에 배포 시 docker-compose-green.yml을 사용

3. 헬스 체크 (Check deploy server URL)

  • 새로 띄운 환경의 /env 엔드포인트를 확인해 정상 작동 여부를 점검 (포트는 STOPPED_PORT 사용)
  • 최대 5번 시도하며, 실패 시 배포가 중단

4. 트래픽 전환 (Change nginx upstream)

  • Nginx의 upstream 설정을 변경해 트래픽을 새 환경으로 라우팅
    • ex) Green으로 전환 시 service_url green으로 설정하고 Nginx를 리로드

5. 기존 환경 종료 (Stop current server)

  • 이전 환경(Blue 또는 Green)의 컨테이너를 중지하고 제거
Blue/Green 배포 흐름

하지만, 모든 소프트웨어 방법론이 그렇듯 Blue/Green 배포 전략도 완벽한 해결책은 아닙니다. 이 방식의 가장 큰 단점은 리소스 낭비가 발생할 수 있다는 점입니다.

무중단 배포를 실현하기 위해 Blue → Green 혹은 Green → Blue로 전환할 때, 일시적으로 두 개의 서버가 동시에 떠 있어야 하는 시점이 존재합니다. 이 때문에 EC2를 사용할 경우, 평소에는 서버 한 대만 운영하더라도 배포 시에는 두 대를 안정적으로 실행할 수 있는 리소스를 확보해야 합니다. (즉 리소스의 200%를 사용하는 배포 방식입니다.)

실제로 이 방법을 적용했을 때, 처음에는 t2.micro 사양의 EC2를 사용했지만, 배포 시 메모리 부족으로 서버가 다운되는 문제가 발생했습니다. 이를 해결하기 위해 메모리 2GB를 제공하는 t2.small 사양으로 Scale-Up하자 문제가 해결되었습니다.

이처럼 Blue/Green 배포는 무중단 배포라는 강력한 장점이 있지만, 리소스 관리 측면에서 신중한 고려가 필요합니다.

Rolling Deployment

그동안 진행해온 프로젝트는 모놀리식 구조였기 때문에, 하나의 서버를 어떻게 배포할지, 더 나아가 어떻게 하면 중단 시간 없이 배포할 수 있을지에 대한 고민을 주로 해왔습니다. 하지만 현업에서는 MSA(Microservices Architecture) 구조로 서비스가 운영되면서, 다중화된 서버를 어떻게 안정적으로 배포할 것인지에 대한 고민이 필요해졌습니다. Docker Swarm과 같은 컨테이너 오케스트레이션 환경에서 제가 경험한 Rolling 배포 전략에 대해 소개해 보겠습니다.

 

Rolling 배포는 Blue/Green와 같이 서비스를 중단하지 않으면서 애플리케이션의 새 버전으로 교체해 나가는 방식으로 동작합니다. Rolling 배포는 전체 시스템을 한 번에 업데이트 하는 대신, 애플리케이션을 순차적으로 업데이트 합니다. Docker Swarm 환경에서는 서비스에 속한 여러 레플리카(replica)를 관리하며, 일부 컨테이너를 중지하고 새 버전으로 교체 한 후 정상 작동을 확인한 뒤 다음 컨테이너로 넘어가는 방식으로 진행됩니다.

 

Rolling 배포의 구체적인 배포 방식은 다음과 같습니다: 현재 상태 -> 업데이트 시작 -> 점진적 교체 -> 완료 -> 롤백

  • 현재 상태: aurora-service라는 서비스가 5개의 컨테이너(레플리카)로 실행 중이라고 가정 (각 컨테이너는 버전 1.0 실행)
  • 업데이트 시작: 새로운 Docker 이미지(버전 2.0)를 Swarm에 배포 명령으로 업데이트
  • 점진적 교체: Swarm은 설정에 따라 한 번에 1~2개의 컨테이너를 중지하고, 새 이미지(버전 2.0)로 교체
    • ex) 5개 중 1개를 중지 -> 새 버전 실행 -> 헬스 체크 통과 -> 다음 컨테이너로 진행
  • 완료: 모든 컨테이너가 새 버전으로 교체될 때 까지 이 과정이 반복되며 결과적으로 서비스 중단 없이 컨테이너 5개 모두 버전 2.0으로 업데이트됨
  • 롤백: 새 버전에 문제가 생기면 Swarm은 이전 상태로 롤백하거나 업데이트를 중단함
docker service update \
  --update-parallelism 2 \
  --update-delay 10s \
  --image <username>/<image-name>:2.0 \
  aurora-service
 

예를 들어 위 설정은 한 번에 2개의 컨테이너를 업데이트하고, 각 단계마다 10초를 대기합니다.

 

# docker-stack.base.yml 파일의 일부

version: "3.8"

services:
  python-test:
    image: ${CU_DOCKER_STACK_IMAGE}

    ports:
      - "8000:8000"

    deploy:
      mode: replicated
      replicas: 5

      placement:
        constraints:
          - "node.labels.service-api-node==yes"

      resources:
        limits:
          cpus: "1.00"
          memory: 500M
        reservations:
          cpus: "0.01"
          memory: 150M

      update_config:
        delay: 30s
        order: start-first
        monitor: 10s

      restart_policy:
        condition: on-failure
        delay: 100s
        max_attempts: 3
        window: 30s

 

위 파일은 회사에서 진행 중인 MSA 환경의 마이크로서비스의 docker-stack.base.yml 파일 중 일부입니다. 위 파일을 기반으로 Docker Swarm 클러스터에 배포하면 Rolling 배포 방식으로 동작합니다. Docker Swarm에서 Rolling 배포는 기본적으로 서비스 업데이트 시 적용되며, deploy 섹션의 update_config 설정을 통해 제어됩니다

deploy 섹션:
  • mode: replicated -> 서비스가 여러 레플리카(컨테이너)로 실행됨을 의미
  • replicas: 5 -> 5개의 컨테이너가 배포

update_config 설정:

  • delay: 30s: 각 컨테이너 업데이트 사이에 30초 지연
  • order: start-first: 새 컨테이너를 먼저 시작한 후 기존 컨테이너를 중지
  • monitor: 10s: 업데이트 후 새 컨테이너가 정상적으로 실행되는지 10초 동안 모니터링

즉, docker stack deploy 명령으로 이 파일을 배포하거나, 이미 배포된 상태에서 이미지 버전을 변경하고 업데이트(docker service update)를 실행하면, Swarm은 update_config에 따라 5개의 컨테이너를 순적으로 교체합니다.

 

하지만, 롤백 배포 방법은 운영 환경에서 두 개의 버전이 동시에 실행될 수 있는 단점이 존재합니다. 예를 들어, API 변경 시에 신/구 버전이 잠시 공존하므로 호환성 문제가 생길 가능성이 있습니다. 이 문제는 API에 버전 번호를 명시적으로 추가해서 신/구 버전 간 요청을 분리 한 후에 Nginx와 같은 API Gateway에서 버전에 따라 트래픽을 분배하는 방식으로 문제를 예방할 수 있습니다. 

그 외 배포 방법

그 외에도 제가 직접 적용해보지는 안않지만, 유튜브에서 소개된 배포 방법에 대해서 간략하게 설명하도록 하겠습니다.

 

- Canary Deployment

카나리 배포는 새 버전을 소규모 사용자 그룹(예: 5% 또는 특정 지역)에게 먼저 배포한 후, 문제가 없으면 점차 전체 사용자에게 확대 적용하는 배포 방식입니다. 카나리 배포를 적용했을 때, 새 버전에 문제 발생 시 소수 사용자만 영향을 받아  리스크를 최소화할 수 있는 장점이 있습니다.

 

- AB Test Deployment

AB Test 배포는 두 개 이상의 버전(A와 B)을 동시에 배포해 사용자 반응을 비교하고, 더 나은 버전을 선택하는 방식입니다. 주로 기능이나, UI , 성능 테스르할 때 사용되는 방식입니다. 이 배포 방식은 안정성보단 사용자의 선호도를 파악하며 비즈니스 목표를 달성하기 위한 배포 방식입니다. 

 

- Shadow Deployment

Shadow 배포 방식은 새 버전을 실제 트래픽에 노출시키지 않고, 기존 버전의 트래픽을 복제(mirroring)해 새 버전에서 병렬로 실행하며 테스트하는 방식입니다. 기존 버전(프로덕션)이 트래픽을 처리하며 정상적으로 서비스를 제공하는 동시에 동일한 요청을 새 버전(Shadow)으로 복제해 실행하여 새 버전의 성능과 오류 등을 모니터링하는 방식입니다. 이 방식은 리스크 없이 새 버전을 테스트할 수 있는 장점이 있지만, 트래픽 복제와 모니터링을 위한 추가 인프라가 필요하다는 단점이 있습니다.


참고 문헌

- https://www.youtube.com/watch?v=eyzzwHcAZSY&t=29s