Blue/Green 무중단 배포를 적용해 보자

글을 시작하기 전에 일단 최종적으로 이건 실패했다. 하지만 트러블 슈팅 과정을 상세하게 작성해 보았다.

 

 

현재 프로젝트에서는 Docker를 사용함을 알린다. 거쳐온 배포 과정으로는

  1. 수동 배포
  2. GitHub Actions를 적용해 CI/CD 구축 (배포 자동화 적용)

방식 1에서 2로 넘어오면서 백엔드 개발자로의 편의는 많이 좋아졌으나, 이 두 방식 모두에서 서비스 다운 타임은 피할 수 없는 문제이다.

즉, 새 프로젝트가 빌드될 때 기존 컨테이너가 내려가고 새로운 도커 이미지를 바탕으로 컨테이너가 뜬다. 이 사이에 서버는 다운되기 때문에 실제 사용자가 있는 상황이었다면 큰 불편을 줄 수 있는 상황이다. 이를 해결하기 위해 무중단 배포를 도입하려고 한다.

 

📋 무중단 배포의 종류

무중단 배포에는 여러 종류가 있다. 

  1. 롤링
  2. 카나리
  3. 블루/그린

그럼에도 Blue/Green 방식을 선택한 이유는 안정성이 크다는 것과 즉시 롤백이 가능하다는 점이었다. 그리고 "동아리 프로젝트 수준에서 점진적 적용이 필요할까?"에 대한 의문이었다. (마지막에 설명하겠지만.... 이건 큰 실수였던 것 같다. 😭😭)

 


✍️ Blue/Green 배포란?

두 개의 운영 환경(Blue, Green)을 사용하는 배포 전략이다.

 

현재 프로젝트의 아키텍처 다이어그램의 초안이다. 여기서는 로드 밸런서로 Nginx를 사용 중이다.

 

  1. 현재 Docker로 운영 중인 환경 : Blue
  2. 새로 배포한 환경 : Green -> EC2에 배포
  3. Green을 테스트 및 헬스체크
  4. Nginx 트래픽을 Green으로 변경 후 Blue 중단
☑️ 두 개의 앱 컨테이너(app-blue, app-green) 를 다른 호스트 포트로 띄움 (8081, 8082)
☑️ Nginx는 현재 활성 포트(8081 또는 8082)로만 프록시됨

 

🔁 Blue/Green 방식 적용하기

1. 헬스 체크를 위해 Acuator 추가

// build.gradle

implementation 'org.springframework.boot:spring-boot-starter-actuator'
# application.yml에 추가

management:
  endpoints:
    web:
      exposure:
        include: health
  endpoint:
    health:
      probes:
        enabled: true	# /actuator/health/readiness 활성화
// SecurityConfig.java에서 허용 경로 추가

.authorizeHttpRequests(req -> req
    .requestMatchers("/actuator/health/**").permitAll()
    // ... 나머지
    .anyRequest().authenticated()
)

 

2. docker-compose-prod.yml

더보기

로컬과 프로덕션 환경을 분리한 상태여서 실제 파일명은 docker-compose-prod.yml / docker-compose-dev.yml로 구성되어 있다. 여기선 배포 환경만 다뤘기 때문에 docker-compose-prod.yml에 대한 내용만 작성하였다.

 

Blue/Green 배포 방식은 호스트 포트만 다르게 하여 두 개의 컨테이너를 정의해 주어야 한다.

포트 스위칭의 경우에는 현재 GitHub Actions를 이용해 CI/CD를 구현하고 있기에 CD 과정에서 진행하도록 하였다. (CD 워크플로우에 해당하는 코드는 뒤에서 작성한다.) 

 

포트 변경을 적용하면 Nginx는 8081 또는 8082로 라우팅만 스위칭한다.

 

# docker-compose-prod.yml

services:
  app:
    image: ${DOCKERHUB_USERNAME}/${DOCKERHUB_REPOSITORY}:${IMAGE_TAG}
    pull_policy: always
    env_file:
      - .env
    environment:
      SPRING_PROFILES_ACTIVE: prod
      SERVER_HOST_FRONT: 프론트_URL
      SPRING_APPLICATION_JSON: >
        {"server":{"host":{"front":"${SERVER_HOST_FRONT}"}},
         "management":{"endpoint":{"health":{"probes":{"enabled":true}}},
                       "endpoints":{"web":{"exposure":{"include":"health"}}}}}
    ports:
      - "${APP_PORT:-8081}:8080"
    restart: always

 

  • 위 코드에서 호스트 포트를 변수화하였다.
    (SERVER_HOST_FRONT에 관한 코드는 다른 부분에서 쓰는 거랑 Blue/Green 배포와는 상관없다.)

 

3. Nginx 템플릿을 포트만 바꿔 꽂는 형식으로 작성

이전에 HTTPS를 적용하면서 Nginx 설정을 했었다. (자세한 건 아래 글에 나와있다.)

2025.08.14 - [Infra] - Vercel 배포를 커스텀 도메인과 연결해 보자 (+ api 서브 도메인)

 

Vercel 배포를 커스텀 도메인과 연결해 보자 (+ api 서브 도메인)

2025.08.14 - [Infra] - 도메인 연결하고 HTTPS를 적용해 보자 도메인 연결하고 HTTPS를 적용해 보자동아리 프로젝트에서 배포 파트를 담당했다. 첫 도전이라 우당탕탕이지만.. 공부하고 적용한 내용을

caminobelllo.tistory.com

 

 

Nginx 실제 활성 파일은 이전에 발급받은 인증서를 유지하는 방식으로 구현하고자 했다. 따라서 아래 파일을 통해서 배포 때마다 이 템플릿에 포트만 바꿔서 덮어씌우도록 동작하도록 했다.

upstream backend_upstream {
    server 127.0.0.1:${APP_PORT};
}

server {
    listen 443 ssl;
    server_name 인증서_등록한_URL;

    ssl_certificate     /etc/letsencrypt/live/인증서_등록한_URL/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/인증서_등록한_URL/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

    location / {
        proxy_pass http://backend_upstream;
        proxy_http_version 1.1;

        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_read_timeout    120s;
        proxy_connect_timeout 10s;
        proxy_send_timeout    120s;
    }
}

# HTTP → HTTPS 리다이렉트(기존 certbot 스타일 유지)
server {
    if ($host = 인증서_등록한_URL) { return 301 https://$host$request_uri; }
    listen 80;
    server_name 인증서_등록한_URL;
    return 404;
}

 

 

근데 이 파일은 접속한 서버에 루트에 위치하게 두어야 했다.

  • 하지만 템플릿을 직접 레포에 파일 형태로 두기도 보안적으로 좀 찜찜했고... 줄바꿈이나 특수문자가 제대로 안 들어가는 문제가 발생하여 GitHub secrets에 NGINX_API_CONF_TPL과 같은 방식으로 넣는 방식으로 적용하였다.
  • 그래서 CD에서 이 시크릿을 파일로 만들어서 ${APP_PORT}로 치환하는 방식이다.

 

그래서 프로젝트 레포에 시크릿으로 등록했다. 그리고 다음 CD 코드와 같이 시크릿을 전달하면 된다.

 

더보기

만약에 위 파일 내용이 깨진다면(줄바꿈 혹은 특수문자) base64로 인코딩해서 보관하는 것이 안전하다. 
macOS를 사용 중이기 때문에 일단 로컬에서 파일(infra/nginx/api.conf.tpl)을 만들고
base64 -b 0 infra/nginx/api.conf.tpl 

이 결과를 GitHub Secrets에 저장하면 된다. 필자는 그냥 원문 그대로 저장하긴 했다.

 

 

4. CD workflow 수정 (Blue ↔ Green 스위치)

name: CD - Deploy to AWS EC2

on:
  workflow_run:
    workflows:
      - "CI"
    types:
      - completed

jobs:
  deploy:
    if: ${{ github.event.workflow_run.conclusion == 'success' }}
    runs-on: ubuntu-22.04

    steps:
      # CI한 커밋 체크아웃
      - name: Checkout repository at CI commit
        uses: actions/checkout@v4
        with:
          ref: ${{ github.event.workflow_run.head_sha }}
          fetch-depth: 1

      # compose 파일 확인
      - name: Check compose files
        run: |
          echo "HEAD SHA: ${{ github.event.workflow_run.head_sha }}"
          test -f docker-compose-prod.yml || (echo "compose file missing" && exit 1)

      # 원격 디렉토리 준비
      - name: Prepare remote dir
        uses: appleboy/ssh-action@v1.2.0
        with:
          host: ${{ secrets.EC2_SERVER_IP }}
          username: ${{ secrets.EC2_USERNAME }}
          key: ${{ secrets.EC2_PRIVATE_KEY }}
          script: |
            mkdir -p ~/${{ secrets.REPOSITORY_PATH }}

      # 서버에 compose 파일 업로드
      - name: Upload compose file
        uses: appleboy/scp-action@v0.1.7
        with:
          host: ${{ secrets.EC2_SERVER_IP }}
          username: ${{ secrets.EC2_USERNAME }}
          key: ${{ secrets.EC2_PRIVATE_KEY }}
          source: "docker-compose-prod.yml"
          target: "~/${{ secrets.REPOSITORY_PATH }}/"

      # Blue/Green 배포
      - name: Blue/Green deploy to EC2
        uses: appleboy/ssh-action@v1.2.0
        env:
          IMAGE_TAG: ${{ github.event.workflow_run.head_sha }}
          DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
          DOCKERHUB_REPOSITORY: ${{ secrets.DOCKERHUB_REPOSITORY }}
          NGINX_API_CONF_TPL: ${{ secrets.NGINX_API_CONF_TPL }}
        with:
          host: ${{ secrets.EC2_SERVER_IP }}
          username: ${{ secrets.EC2_USERNAME }}
          key: ${{ secrets.EC2_PRIVATE_KEY }}
          envs: IMAGE_TAG,DOCKERHUB_USERNAME,DOCKERHUB_REPOSITORY,NGINX_API_CONF_TPL

          script: |
            set -eo pipefail
            cd ~/${{ secrets.REPOSITORY_PATH }}

			# 현재 Nginx가 프록시하는 포트 파악 (없으면 8081로 가정)
            ACTIVE_PORT=$(grep -oE '127\.0\.0\.1:([0-9]+)' /etc/nginx/conf.d/api.newsintelligent.site.conf 2>/dev/null | tail -1 | cut -d: -f2 || echo 8081)
            if [ "$ACTIVE_PORT" = "8081" ]; then NEW_PORT=8082; else NEW_PORT=8081; fi
            if [ "$NEW_PORT" = "8081" ]; then NEW_COLOR=blue; OLD_COLOR=green; else NEW_COLOR=green; OLD_COLOR=blue; fi

            echo "ACTIVE_PORT=$ACTIVE_PORT, NEW_PORT=$NEW_PORT"
            echo "NEW_COLOR=$NEW_COLOR, OLD_COLOR=$OLD_COLOR"

             # 새 버전 pull & up
      		APP_PORT=$NEW_PORT APP_COLOR=$NEW_COLOR \
      		DOCKERHUB_USERNAME=$DOCKERHUB_USERNAME DOCKERHUB_REPOSITORY=$DOCKERHUB_REPOSITORY IMAGE_TAG=$IMAGE_TAG \
      		docker-compose -p app_${NEW_COLOR} -f docker-compose-prod.yml pull

      		APP_PORT=$NEW_PORT APP_COLOR=$NEW_COLOR \
      		DOCKERHUB_USERNAME=$DOCKERHUB_USERNAME DOCKERHUB_REPOSITORY=$DOCKERHUB_REPOSITORY IMAGE_TAG=$IMAGE_TAG \
      		docker-compose -p app_${NEW_COLOR} -f docker-compose-prod.yml up -d --force-recreate

			# readiness 체크
            for i in $(seq 1 30); do
              if curl -fsS "http://127.0.0.1:${NEW_PORT}/actuator/health/readiness" | grep -q '"status":"UP"'; then
                echo "New ${NEW_COLOR} healthy"; break
              fi
              echo "waiting health... ($i)"
              sleep 2
            done

            if ! curl -fsS "http://127.0.0.1:${NEW_PORT}/actuator/health/readiness" | grep -q '"status":"UP"'; then
              echo "New ${NEW_COLOR} failed to become healthy"
              APP_PORT=$NEW_PORT APP_COLOR=$NEW_COLOR docker-compose -p app_${NEW_COLOR} -f docker-compose-prod.yml logs --tail=200 || true
              exit 1
            fi

			# GitHub secret에 있는 Nginx 템플릿을 파일로 복원 
      		sudo mkdir -p /etc/nginx/templates
      		sudo bash -c "cat > /etc/nginx/templates/api.conf.tpl << 'EOF'
			$NGINX_API_CONF_TPL
			EOF"

      		# 포트 치환 후 활성 conf 교체
            sudo sh -c "sed 's/\\${APP_PORT}/${NEW_PORT}/g' /etc/nginx/templates/api.conf.tpl > /etc/nginx/conf.d/api.newsintelligent.site.conf"
            sudo nginx -t
            sudo systemctl reload nginx

            # 이전 색상 종료
            APP_PORT=$ACTIVE_PORT APP_COLOR=$OLD_COLOR \
          	docker-compose -p app_${OLD_COLOR} -f docker-compose-prod.yml down || true

          	docker image prune -f

 

 

이렇게 push를 하게 되면 GitHub Actions에서 CI → CD가 돌아가고 서버에서 제대로 컨테이너가 떴는지 확인하면 된다.

하지만 다음처럼 자꾸 CD 과정에서 오류가 발생했다. 

GitHub repository → Actions

 

로그를 확인해 보니 컨테이너까지는 app-blue로 설정되나, 스프링이 뜨는 과정에서 서버가 그냥 죽어버리는 것이었다.

 

Cannot invoke "jdk.internal.platform.CgroupInfo.getMountPoint()" because "anyController" is null

 

GPT의 답변에 의하면...

"JDK 17.0.2의 cgroup v2 버그 때문에 ProcessorMetrics(시스템 CPU 메트릭) 만들다 죽는 거예요. 런타임 이미지가 openjdk:17-jdk-slim(대부분 17.0.2)라서 터진 거고, 그래서 헬스체크도 계속 실패했습니다."

 

라고 하길래 GitHub Issues를 좀 더 구글링 해보니 비슷한 사례가 꽤나 있었다. 그래서 런타임 이미지를 eclipse-temurin:17-jre로 변경했다.

 

하지만 계속되는 CD 실패, 그리고 로그를 확인하면 "Empty reply / connection reset" , 즉 서버 응답 전에 소켓을 끊어버리는 상황이다. 그리고 기존에 Remote directory 단계가 보통 5초 내외로 끝났었는데 5분 넘게 In progress 상태였다. 이건 필히 SSH 접속이 막힌다고 생각하여 터미널에서 서버 접속을 시도했으나 역시나 실패.  (솔직히 말하면 여기서 멘탈이 터진 듯 싶다.....)

 

public IP, 보안그룹(22번), secret key 오류, 네트워크 문제 전부 아니었다.

급하게 인스턴스 모니터링을 들어가 보니 CPU 사용률이 99.2%였다. 아, 그래서 접속이 막히는 거였구나. 배포(ssh-action) 단계가 “Prepare remote dir”에서 오래 걸리는 것도 서버가 과부하라 SSH 자체가 안 붙는 것이다.

 

일단 접속이 되어야 컨테이너를 내리든 뭘 하든 할 수 있으니 컨테이너 중지하고 접속해 Docker 컨테이너를 내리는 작업부터 했다. Swap 메모리 2GB도 확보를 했으나 여전히 안정성 측면에서 불안하다고 생각했다. (이 글에선 다루진 않겠다. 물론 이것도 쉽지 않았다. 눈물 찔끔 난 듯)  

 

그래서 결론은 결국엔 다시 단일 컨테이너 배포 환경으로 롤백했다. 

많이 아쉬운 결과이긴 하다. 하지만 수많은 삽질 가운데에서 얻은 건 많은 듯하다. 다음 프로젝트에서는 꼭 실제 적용까지 해보고 싶다.

 

 

 

 

공부하는 학생입니다. 지적이나 피드백은 언제든지 환영합니다 🙇‍♀️