본문 바로가기
나의 개발일지/CS

Github Actions, Nginx, Docker를 이용한 Blue-Green 무중단 배포

by stella_gu 2024. 5. 13.

현재 회사 서버는 단일 EC2 인스턴스에서 하나의 Docker 컨테이너를 운영하여 작동 중인데 컨테이너 교체 방식 (In-place Deployment)으로 작동하다 보니 약간의 서비스 중단 시간이 발생한다.

 

Github Actions 내역을 통해 매번 1분 이상의 deploy 시간 동안 서비스가 중단 되는 걸 확인했는데 본격적으로 서비스 운영에 들어가게 되면서 1분의 중단 시간도 없애는 게 안정적인 서비스 운영에 도움이 되겠다는 생각에 무중단 배포를 건의했다.

물론 백엔드가 나 혼자뿐이라 내가 다 하면 되므로 현 상황에 맞는 방법을 찾아보았다.

 

Docker를 사용한 무중단 배포 전략으로는 대표적으로 롤링 업데이트(rolling update) 블루-그린 배포(blue-green deployment)가 존재한다. 

 

컨테이너 교체 방식
(In-place Deployment)
롤링 업데이트
(rolling update)
블루-그린 배포
(blue-green deployment)
- 기존 컨테이너를 중지하고 새 컨테이너를 시작.

- 간단하고 빠른 전환을 제공하지만 교체 과정 중 짧은 서비스 중단 시간이 발생.
- 인스턴스를 하나씩 순차적으로 업데이트하여 새 버전으로 교체.

- 다중 인스턴스에 적합.
- 두 개의 동일한 환경을 설정하여 하나는 현재 운영 중인 환경(블루)으로, 다른 하나는 새 버전을 배포할 준비가 된 환경(그린)으로 구성.

- 새 버전의 배포가 준비되면 트래픽을 새 환경으로 전환.

- 이전 버전으로 쉽게 롤백 가능.

 

우선, 현재 회사 서버의 경우 단일 ec2 인스턴스에 nodejs 싱글 스레드 하나만 돌리기 때문에 롤링 업데이트는 적합하지 않다.

단일 ec2 내에 블루, 그린 컨테이너를 각각 띄우고 전환하는 게 가장 적합해 보였는데 트래픽을 새 환경으로 어떻게 전환할 것인가라는 문제가 남았다.

도커를 사용하여 블루-그린 배포를 하는 방법을 찾아보면 nginx를 이용하는 게 가장 많이 보였고, 이전에 nginx를 사용해본 경험이 있어서 현 상황에서 가장 경제적인 방법이란 생각이 들었다.

최종적으로 단일 인스턴스 내에서 nginx 설치 및 blue, green 컨테이너 생성하여 무중단 배포를 구현하는 방법을 결정하였다. 그리고 github actions를 이용하여 자동 배포하는 것까지.

 

기존의 배포 과정에서 크게 벗어나지 않기 위해 다음과 같은 배포 전략을 세웠다.

1. 현재 실행 중인 컨테이너 버전(blue인지 green인지)을 확인한다.

2. 미 실행 버전으로 업데이트 된 소스 코드를 build 한다. (실행 중인 컨테이너가 blue라면 green을, green이라면 blue를)

3. docker hub 등의 원격 저장소에 push 한다.

4. 원격 저장소에서 새로운 버전의 이미지를 pull 받은 후 실행한다.

5. 새로운 버전으로 헬스 체크를 실행한다

6. 새로운 버전으로 nginx 프록시를 변경하고 nginx를 reload 해준다.

7. 새로운 버전이 올라가면서 구 버전이 된 컨테이너를 중지해준다. (새로운 버전에 문제가 생겼을 시 구 버전을 다시 구동하여 돌리기 위해 이미지는 유지.)

 

blue가 실행 중이면 green으로 새로운 버전 build하여 실행 후 green으로 프록시 변경

 

 

 

 

docker-compose.dev.yml

// 이하는 development 개발환경 docker-compose.dev.yml 파일 예시입니다.
// (docker-compose 파일을 image build 하는 데엔 사용하지 않아 build 옵션은 없음)

version: '3.8'
services:
  app-blue:
    image: 'blue 이미지'
    container_name: 'blue 컨테이너'
    environment:
      NODE_ENV: development
      TZ: Asia/Seoul
    ports:
      - '3001:3000'
    // 기타 필요 설정들...

  app-green:
    image: 'green 이미지'
    container_name: 'green 컨테이너'
    environment:
      NODE_ENV: development
      TZ: Asia/Seoul
    ports:
      - '3002:3000'
    // 기타 필요 설정들...

 

처음엔 nginx도 docker compose로 생성하여 컨테이너로 관리하려 했었다.

nginx, blue, green 모두 docker compose로 생성하고  nginx 컨테이너 내부에 접속해서 설정 파일을 수정하고 하다보니 nginx의 활용이 더 많아질 수도 있고 서버가 늘어날 수도 있는 상황이 생기면 꽤 귀찮아 지겠다는 생각이 들었다.

결국 nginx는 ec2 자체에 설치하고 docker compose로 app-blue와 app-green을 정의해 주었다.

 

 

github actions

  check-version:
  	# 현재 실행 중인 버전 확인
    runs-on: # 생략 (개발 환경에 맞춰 설정)
    outputs:
      is_blue_active: ${{ steps.check-version.outputs.is_blue_active }}
    steps:
      - name: Check Current Active Version
        id: check-version
        run: |
          echo "Checking active service..."
          echo "::set-output name=is_blue_active::$(docker ps | grep app-blue)"
          # 'docker ps | grep app-blue'로 blue가 실행 중인지 확인. 
          # 컨테이너 이름에 맞춰 'app-blue' 이름 조정

  build:
    needs: check-version
    runs-on: # 생략 (개발 환경에 맞춰 설정)
    steps:
      # 필요한 과정 있으면 추가
      - name: Checkout Source Code
        uses: actions/checkout@v3
      # docker build 과정 - 개발 환경에 맞춰 설정
      - name: Set up Docker Buildx 
        id: buildx
        uses: docker/setup-buildx-action@v2
      - name: Login to DockerHub
        uses: docker/login-action@v2
        with:
          registry: docker.io
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}
      # tag도 개발 환경에 맞춰 설정
      - name: Determine Build Tag
        id: build_tag
        run: |
          if [ -n "${{ needs.check-version.outputs.is_blue_active }}" ]; then
            echo "DEPLOY_TAG=태그" >> $GITHUB_ENV
          else
            echo "DEPLOY_TAG=태그" >> $GITHUB_ENV
          fi
      - name: Build and Push Docker Image
        id: docker_build
        uses: docker/build-push-action@v3
        with:
          context: .
          push: true
          tags: ${{ env.DEPLOY_TAG }}
      # 필요한 과정 있으면 추가

  deploy:
    needs: build
    runs-on: # 생략 (개발 환경에 맞춰 설정)
    steps:
      # 필요한 과정 있으면 추가
      - name: Checkout Source Code
        uses: actions/checkout@v3
      - name: Set up Docker Environment
        run: |
          docker-compose -f docker-compose.dev.yml config
      - name: Login to DockerHub
        uses: docker/login-action@v2
        with:
          registry: docker.io
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}
      - name: Check Current Active Version
        id: check-version
        run: |
          echo "Checking active service..."
          echo "::set-output name=is_blue_active::$(docker ps | grep app-blue)"
      - name: Start New Version
        run: |
          if [ -n "${{ steps.check-version.outputs.is_blue_active }}" ]; then
            echo "Starting Green service..."
            # 구 버전 컨테이너와 이미지 삭제 (서버 용량이 차는 것 방지하기 위해)
            docker container prune -f && docker image prune -af
            docker-compose -f docker-compose.dev.yml pull app-green
            docker-compose -f docker-compose.dev.yml up -d app-green
          else
            echo "Starting Blue service..."
            # 구 버전 컨테이너와 이미지 삭제 (서버 용량이 차는 것 방지하기 위해)
            docker container prune -f && docker image prune -af
            docker-compose -f docker-compose.dev.yml pull app-blue
            docker-compose -f docker-compose.dev.yml up -d app-blue
          fi
          # Health Check 바로 실행 시 에러날 수 있어서 서비스가 완전히 시작될 때까지 30초 기다림.
          sleep 30 
          # sleep은 개발 환경에 맞춰 변경
      - name: Health Check
        run: |
          if [ -n "${{ steps.check-version.outputs.is_blue_active }}" ]; then
            echo "Green Health check start..."
            if ! curl -f --retry 5 --retry-delay 3 --connect-timeout 5 http://localhost:3002/health-checks; then
              # 헬스 체크 실패 시 슬랙 알림 전송
              echo "Health check failed on Green. Send to Slack..."
              curl -X POST -H 'Content-type: application/json' --data '{ "text": "[dev - Github Actions Deployment failed] Health check failed on Green. Previous version operational." }' ${{ secrets.WEBHOOK_URL_ERROR }}
              exit 1
            fi
          else
            echo "Blue Health check start..."
            if ! curl -f --retry 5 --retry-delay 3 --connect-timeout 5 http://localhost:3001/health-checks; then
              # 헬스 체크 실패 시 슬랙 알림 전송
              echo "Health check failed on Blue. Send to Slack..."
              curl -X POST -H 'Content-type: application/json' --data '{ "text": "[dev - Github Actions Deployment failed] Health check failed on Blue. Previous version operational." }' ${{ secrets.WEBHOOK_URL_ERROR }}
              exit 1
            fi
          fi

 

이제 새로운 버전으로 nginx 프록시를 변경하고 nginx를 reload 해주는 과정이 필요하다.

이를 자동화 하기 위해 blue로 프록시를 pass하는 설정 파일인 nginx-green.conf와 green으로 프록시를 pass하는 설정 파일인 nginx-green.conf를 미리 ec2에 저장해두고 해당 파일을 nginx.conf로 변경하여 프록시를 변경해주었다.

 

nginx-blue.conf

# For more information on configuration, see:
#   * Official English Documentation: http://nginx.org/en/docs/
#   * Official Russian Documentation: http://nginx.org/ru/docs/

user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /run/nginx.pid;

# Load dynamic modules. See /usr/share/doc/nginx/README.dynamic.
include /usr/share/nginx/modules/*.conf;

events {
    worker_connections 1024;
}

http {

	##
	# Basic Settings
	##

    sendfile            on;
    tcp_nopush          on;
    keepalive_timeout   65;
    types_hash_max_size 4096;

    include             /etc/nginx/mime.types;
    default_type        application/octet-stream;

    # Load modular configuration files from the /etc/nginx/conf.d directory.
    # See http://nginx.org/en/docs/ngx_core_module.html#include
    # for more information.
    include /etc/nginx/conf.d/*.conf;

	##
	# Logging Settings
	##

    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                        '$status $body_bytes_sent "$http_referer" '
                        '"$http_user_agent" "$http_x_forwarded_for"';
	access_log /var/log/nginx/access.log;
	error_log /var/log/nginx/error.log;

	##
	# Gzip Settings
	##

	gzip on;
	# gzip_vary on;
	# gzip_proxied any;
	# gzip_comp_level 6;
	# gzip_buffers 16 8k;
	# gzip_http_version 1.1;
	# gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;

    upstream app {
        server 127.0.0.1:3001;
        server 127.0.0.1:3002 backup;
    }

    server {
        listen 80;
        location / {
            proxy_pass http://app;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        }
    }
}
// nginx-green.conf에서 변경되는 부분

upstream app {
        server 127.0.0.1:3001 backup;
        server 127.0.0.1:3002;
}

대부분 nginx 설치 시 생성 되는 기본 설정값을 따랐고, upstream을 추가하여 특정 컨테이너로 프록시를 보내주었다. nginx-green.conf의 경우 upstream 부분만 변경된다.

 

nginx-blue.conf와 nginx-green.conf 파일을 ec2 내부에 저장해두고, github actions에서 다음 과정을 추가하여 프록시를 변경한다.

나의 경우 ec2 내부의 /home/ec2-user 디렉토리에 nginx 폴더를 만든 후 저장해 두었다.

      - name: Update and Reload Nginx Configuration
        run: |
          if [ -n "${{ steps.check-version.outputs.is_blue_active }}" ]; then
              sudo cp /home/ec2-user/nginx/nginx-green.conf /etc/nginx/nginx.conf
              sudo nginx -s reload
              # 이제 구 버전이 된 blue stop
              docker-compose -f docker-compose.dev.yml stop app-blue
            else
              sudo cp /home/ec2-user/nginx/nginx-blue.conf /etc/nginx/nginx.conf
              sudo nginx -s reload
              # 이제 구 버전이 된 green stop
              docker-compose -f docker-compose.dev.yml stop app-green
          fi

 

 

※ 그렇다면 프록시가 전환 되고 구 버전 컨테이너가 stop되는 찰나의 순간에 남아 있는 요청들은 어떻게 되는가?

→ nginx를 reload 하는 작업은 nginx 프로세스 자체를 종료 후 재시작하는 것이 아닌,  새로운 설정에 맞춰 새로운 worker 프로세스를 시작하여 새로 들어오는 트래픽을 가져가고, 기존의 프로세스는 남은 작업을 모두 처리 후 종료된다.

docker 컨테이너 stop 작업은 기본적으로 graceful shutdown을 지원한다. stop 명령어 실행 시 도커는 해당 프로세스에 SIGTERM 신호를 주고, 기본적으로 10초를 기다린 후 타임아웃이 지나면 SIGKILL 신호를 보내 종료시킨다. 만약 10초 안에 처리가 끝나지 않는 작업이 존재한다면 timeout 옵션 등을 이용하여 이를 조정할 수 있다.

참고) run --stop--timeout

참고) stop_grace_period 옵션

참고) Why do my services take 10 seconds to recreate or stop?

 

 

최종 파일 

github actions의 전체 과정은 다음과 같다.

 

github actions cicd-dev.yml

name: cicd-dev

env:
  # 필요한 env 추가

on:
  # 생략 (개발 환경에 맞춰 설정)

jobs:
  test:
    # 생략

  check-version:
  	# 현재 실행 중인 버전 확인
    needs: test
    runs-on: # 생략 (개발 환경에 맞춰 설정)
    outputs:
      is_blue_active: ${{ steps.check-version.outputs.is_blue_active }}
    steps:
      - name: Check Current Active Version
        id: check-version
        run: |
          echo "Checking active service..."
          echo "::set-output name=is_blue_active::$(docker ps | grep app-blue)"
          # 'docker ps | grep app-blue'로 blue가 실행 중인지 확인. 
          # 컨테이너 이름에 맞춰 'app-blue' 이름 조정

  build:
    needs: check-version
    runs-on: # 생략 (개발 환경에 맞춰 설정)
    steps:
      # 필요한 과정 있으면 추가
      - name: Checkout Source Code
        uses: actions/checkout@v3
      # docker build 과정 - 개발 환경에 맞춰 설정
      - name: Set up Docker Buildx 
        id: buildx
        uses: docker/setup-buildx-action@v2
      - name: Login to DockerHub
        uses: docker/login-action@v2
        with:
          registry: docker.io
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}
      # tag도 개발 환경에 맞춰 설정
      - name: Determine Build Tag
        id: build_tag
        run: |
          if [ -n "${{ needs.check-version.outputs.is_blue_active }}" ]; then
            echo "DEPLOY_TAG=태그" >> $GITHUB_ENV
          else
            echo "DEPLOY_TAG=태그" >> $GITHUB_ENV
          fi
      - name: Build and Push Docker Image
        id: docker_build
        uses: docker/build-push-action@v3
        with:
          context: .
          push: true
          tags: ${{ env.DEPLOY_TAG }}
      # 필요한 과정 있으면 추가

  deploy:
    needs: build
    runs-on: # 생략 (개발 환경에 맞춰 설정)
    steps:
      # 필요한 과정 있으면 추가
      - name: Checkout Source Code
        uses: actions/checkout@v3
      - name: Set up Docker Environment
        run: |
          docker-compose -f docker-compose.dev.yml config
      - name: Login to DockerHub
        uses: docker/login-action@v2
        with:
          registry: docker.io
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}
      - name: Check Current Active Version
        id: check-version
        run: |
          echo "Checking active service..."
          echo "::set-output name=is_blue_active::$(docker ps | grep app-blue)"
      - name: Start New Version
        run: |
          if [ -n "${{ steps.check-version.outputs.is_blue_active }}" ]; then
            echo "Starting Green service..."
            # 구 버전 컨테이너와 이미지 삭제 (서버 용량이 차는 것 방지하기 위해)
            docker container prune -f && docker image prune -af
            docker-compose -f docker-compose.dev.yml pull app-green
            docker-compose -f docker-compose.dev.yml up -d app-green
          else
            echo "Starting Blue service..."..."
            # 구 버전 컨테이너와 이미지 삭제 (서버 용량이 차는 것 방지하기 위해)
            docker container prune -f && docker image prune -af
            docker-compose -f docker-compose.dev.yml pull app-blue
            docker-compose -f docker-compose.dev.yml up -d app-blue
          fi
          # Health Check 바로 실행 시 에러날 수 있어서 서비스가 완전히 시작될 때까지 30초 기다림.
          sleep 30
          # sleep은 개발 환경에 맞춰 변경
      - name: Health Check
        run: |
          if [ -n "${{ steps.check-version.outputs.is_blue_active }}" ]; then
            echo "Green Health check start..."
            if ! curl -f --retry 5 --retry-delay 3 --connect-timeout 5 http://localhost:3002/health-checks; then
              # 헬스 체크 실패 시 슬랙 알림 전송
              echo "Health check failed on Green. Send to Slack..."
              curl -X POST -H 'Content-type: application/json' --data '{ "text": "[dev - Github Actions Deployment failed] Health check failed on Green. Previous version operational." }' ${{ secrets.WEBHOOK_URL_ERROR }}
              exit 1
            fi
          else
            echo "Blue Health check start..."
            if ! curl -f --retry 5 --retry-delay 3 --connect-timeout 5 http://localhost:3001/health-checks; then
              # 헬스 체크 실패 시 슬랙 알림 전송
              echo "Health check failed on Blue. Send to Slack..."
              curl -X POST -H 'Content-type: application/json' --data '{ "text": "[dev - Github Actions Deployment failed] Health check failed on Blue. Previous version operational." }' ${{ secrets.WEBHOOK_URL_ERROR }}
              exit 1
            fi
          fi
      - name: Update and Reload Nginx Configuration
        run: |
          if [ -n "${{ steps.check-version.outputs.is_blue_active }}" ]; then
              sudo cp /home/ec2-user/nginx/nginx-green.conf /etc/nginx/nginx.conf
              sudo nginx -s reload
              # 이제 구 버전이 된 blue stop
              docker-compose -f docker-compose.dev.yml stop app-blue
            else
              sudo cp /home/ec2-user/nginx/nginx-blue.conf /etc/nginx/nginx.conf
              sudo nginx -s reload
              # 이제 구 버전이 된 green stop
              docker-compose -f docker-compose.dev.yml stop app-green
          fi

 

 

 

막상 정리하고 보니 간단하지만

약 800분 정도의 github actions를 소진한 끝에 겨우 성공하였다

헬스 체크도 통과하고

nginx 파일도 잘 변경 되었다!

 

 

플로우 상 헬스 체크 미 통과 시 기존의 프록시를 유지하는데

db 스키마가 변경된 경우에는...? 기존 버전과 맞지 않아 에러가 발생할텐데 이 경우에는 어떻게 해야할지에 대한 고민이 남는다 이 부분은 더 찾아봐야겠다

 

 

참고) nginx 설치 후 nginx.conf (/etc/nginx/nginx.conf)를 nginx-blue.conf의 내용으로 변경해주고,

로컬에서 green 이미지 build → docker hub 등의 원격 저장소에 push → ec2 내부에서 Pull 받고 실행하는 과정을 초기에 한번 직접 해주었다. 이후 github actions를 통해 green → blue로 무사히 변경되는 것 확인 후 다시 blue → green, green → blue로 잘 변경되는지 몇 번의 테스트를 더 거쳤다.