개발괴발/Devops

[Infra] Nginx와 Docker로 구현하는 단일 서버 Blue-Green 무중단 배포

nourzoo 2025. 8. 29. 20:07

 

 

 

이 글은 우아한테크코스 7기를 진행하며 야구보구 프로젝트에 무중단 배포를 적용했던 경험을 작성한 글입니다.

서비스를 업데이트할 때마다 서버가 중단되면 사용자들이 불편을 겪을 수 있기 때문에 저희는 이를 위해 Blue-Green 배포 전략을 선택했습니다.

  • Blue : 현재 운영되고 있는 버전의 환경
  • Green : 새로 배포할 버전의 환경

배포 시 Green 환경을 먼저 구축하고, 테스트를 통해 안정성이 확보되면 트래픽을 Blue에서 Green으로 전환합니다. 이렇게 하면 사용자 입장에서는 서비스 중단을 느끼지 못합니다.

 

최종 아키텍처 한 눈에 보기

저희가 구축한 시스템의 최종 아키텍처는 아래와 같습니다.

핵심은 하나의 EC2 인스턴스 안에서 Nginx와 두 개의 애플리케이션 컨테이너(Blue/Green)가 공존하는 구조입니다.

 

1. 사용자 (Client): 80(HTTP) 또는 443(HTTPS) 포트로 서비스에 접근합니다

2. Nginx (Public Subnet): 웹 서버이자 리버스 프록시. 외부 요청을 받아 Private Subnet에 있는 애플리케이션 서버로 전달합니다

3. 애플리케이션 서버 (Private Subnet): Docker 컨테이너가 실행되는 EC2 인스턴스

  • Blue 컨테이너: 호스트의 8081 포트와 매핑 (8081:8080)
  • Green 컨테이너: 호스트의 8082 포트와 매핑 (8082:8080)

4. DB 서버 (Private Subnet): 애플리케이션 서버만 접근 가능한 DB

 

배포가 실패했다

처음 저희가 구상했던 구조는 Public Subnet에 위치한 nginx 한 대가 모든 역할을 처리하는 것이었습니다.

[초기 아키텍처]

          - Public Subnet -              - Private Subnet -
User ---> [ Nginx (Port 80) ] --(X)--> [ App EC2 (Port 8081/8082) ]
                                          |
                                          +-> Blue Container (8081)
                                          |
                                          +-> Green Container (8082)

 

문제는 위의 (X) 지점에서 발생했습니다.

배포 스크립트를 실행하고 새 버전의 컨테이너가 뜨는 것은 확인했지만, 실제 서비스에서는 변경 사항이 반영되지 않았습니다. 원인은 다음의 두 가지였습니다.

 

원인1: Nginx가 요청을 전달하지 않고 있었다

# 잘못된 설정 예시
server {
    listen 80;
    server_name example.com;

    root /var/www/html;  # 정적 파일 경로
    index index.html;

    # proxy_pass 없음
}

 

Nginx가 리버스 프록시(proxy_pass)로 동작하는 대신, /var/www/html 경로의 정적 파일만 서빙하도록 설정되어 있었습니다.

이러니 Docker 컨테이너가 아무리 8081, 8082 포트에서 실행되어도 Nginx는 그 존재조차 모르고 있었던 것입니다.

즉, proxy_pass 설정이 없기 때문에 nginx는 컨테이너로 요청 전달 안 하고 있었던 것입니다.

 

 

원인2: 보안 그룹 설정 오류

Nginx 설정을 고쳤음에도 오류가 있었습니다. 그 이유는 바로 AWS 보안 그룹이었습니다.

  • Nginx EC2 (Public Subnet): 외부에서 80, 443 포트로 들어오는 요청을 허용
  • App Server EC2 (Private Subnet): 22(SSH), 80, 443 포트만 열려있음

여기서 실수가 있었습니다. nginx는 앱 서버의 8081, 8082 포트로 요청을 전달해야 하는데, 앱 서버의 보안 그룹은 해당 포트들을 허용하지 않았습니다.

같은 VPC 내에 있더라도 서브넷이 다르거나 ec2 인스턴스가 다르면 보안 그룹에서 트래픽을 허용해주어야 통신이 가능한 것이었습니다.

 

 

문제를 해결하다

저희는 보안 그룹 규칙을 수정할 수 없었기 때문에, 아키텍처를 변경하여 문제를 해결했습니다.

Private Subnet의 ec2 내부에 nginx를 하나 더 설치하는 방식이었습니다.

이 아키텍처가 동작하는 방식은 다음과 같습니다.

  1. Public Nginx (Web Server): 이제 이 Nginx의 역할은 전보다 단순해졌습니다. 외부 사용자의 모든 요청(80, 443)을 받아 아무것도 묻지도 따지지도 않고 Private Subnet에 있는 EC2의 80 포트로 그대로 전달합니다. SSL 인증서 처리(HTTPS)도 여기서 담당합니다.
  2. Private Nginx (Reverse Proxy): App EC2 내부에 설치된 이 Nginx가 실질적인 Blue-Green 트래픽 전환을 담당합니다.
    • Public Nginx로부터 80 포트로 요청을 받습니다.
    • 자신의 upstream 설정을 보고 지금 활성화된 컨테이너의 포트(localhost:8081 또는 localhost:8082)로 요청을 다시 보내줍니다.

이 아키텍처 변경에 따라 Nginx 설정도 두 개로 나뉘었습니다.

 

Public Nginx 설정 (gateway)

server {
  listen 443 ssl http2;
  server_name yagubogu.com;

  # TLS
  ssl_certificate    /etc/ssl/certs/origin.pem;
  ssl_certificate_key /etc/ssl/private/origin.pem;
  ssl_protocols TLSv1.2 TLSv1.3;
  ssl_session_timeout 1d;
  add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

  set $upstream http://10.0.20.244;

  # ① 인증 엔드포인트 (POST 전용 제한)
  location ~ ^/api/auth/(login|refresh|logout)$ {
    limit_req zone=auth_2rps burst=10 nodelay;

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

    proxy_read_timeout 60s;
    proxy_connect_timeout 5s;
    proxy_pass $upstream;
  }
  ...
}

 

위와 같이 proxy_pass를 백엔드 애플리케이션 서버 주소($upstream)로 설정하면,

https://yagubogu.com/api/ 경로로 들어오는 요청을 Nginx 서버가 백엔드 애플리케이션 서버로 전달하는 리버스 프록시(reverse proxy) 로 동작합니다.

 

Private Nginx 설정(Reverse Proxy)

upstream spring_app_active {
    server 127.0.0.1:8082 max_fails=3 fail_timeout=10s;
    keepalive 32;
}

server {
    listen 80;
    server_name _;

    include /etc/nginx/conf.d/proxy-common.conf;

    location / {
        proxy_pass http://spring_app_active;
    }
}

 

위는 Green 컨테이너(8082 포트)가 활성화되었을 때 적용되는 Nginx 설정 파일의 예시입니다.

 

저희 아키텍처에서 이 Nginx는 외부 도메인(yagubogu.com)을 직접 받는 것이 아니라, Public Subnet의 Nginx로부터 전달받는 내부 프록시입니다. 따라서 이미 private nginx가 검증한 주소를 또 다시 확인할 필요가 없습니다. 이 서버로 오는 모든 요청을 처리하겠다는 의미로 _를 사용하는 것입니다.

이 방식은 배포 시점에 nginx가 읽는 설정 파일을 바꿔치기 해줍니다. 따라서 저희는 prod서버 내에 /etc/nginx/conf.d/yagubogu-blue.conf, /etc/nginx/conf.d/yagubogu-green.conf 두 개의 설정파일을 준비하고 nginx가 실제로 읽을 설정 파일의 심볼릭 링크를 만들었습니다.

 

배포 스크립트 (Blue-Green + Docker)

다음은 야구보구의 GitHub Actions를 이용한 Blue-Green 무중단 배포 스크립트 입니다.

새 컨테이너 실행 → 헬스체크 → 기존 컨테이너 종료 의 흐름입니다.

name: YaguBogu Backend Deploy

on:
  push:
    branches: [ "main" ]
    paths:
      - "backend/**"
      - ".github/workflows/**"

env:
  IMAGE: yagubogu/yagubogu-backend

jobs:
  cd:
    runs-on: [ self-hosted, production ]
    env:
      IMAGE: yagubogu/yagubogu-backend
      TAG: prod-latest
    steps:
      - name: Docker login
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_DEPLOY_USERNAME }}
          password: ${{ secrets.DOCKERHUB_DEPLOY_TOKEN }}

      - name: Pull latest image
        run: docker pull $IMAGE:$TAG

      - name: Ensure Docker network
        run: docker network create yagubogu-net 2>/dev/null || true

      - name: Blue-Green Deploy
        shell: bash
        run: |
          BLUE="yagubogu-backend-blue"
          GREEN="yagubogu-backend-green"

          # 현재 활성 컨테이너 확인
          if docker ps --format '{{.Names}}' | grep -qx "$BLUE"; then
            OLD="$BLUE"; NEW="$GREEN"; PORT=8082
          else
            OLD="$GREEN"; NEW="$BLUE"; PORT=8081
          fi
          echo "새 컨테이너: $NEW, 포트: $PORT"

          # 이전에 실패한 컨테이너 잔존 시 제거
          if docker ps -a --format '{{.Names}}' | grep -qx "$NEW"; then
            docker rm -f "$NEW" || true
          fi

          # 새 컨테이너 기동
          docker run -d \
            --name "$NEW" \
            --network yagubogu-net \
            -p "$PORT:8080" \
            --stop-signal=SIGTERM \
            --stop-timeout=30 \
            -e SPRING_PROFILES_ACTIVE=prod \
            "$IMAGE:$TAG"

          # 헬스체크 수행
          for i in {1..30}; do
            if curl -fsS "http://127.0.0.1:$PORT/actuator/health" >/dev/null; then
              echo "[$NEW] 컨테이너가 정상 동작 중입니다."
              break
            fi
            echo "헬스체크 대기 중... ($i/30)"
            sleep 2
          done

          # 기존 컨테이너 종료
          echo "기존 컨테이너 종료: $OLD"
          docker stop --time=30 "$OLD" || true
          docker rm "$OLD" || true

 

  1. 현재 실행 중인 컨테이너 확인
    • 스크립트는 먼저 현재 서버에서 어떤 컨테이너가 실행 중인지 확인합니다.
    • BLUE 컨테이너가 살아 있다면 새 컨테이너는 GREEN을, GREEN이 살아 있다면 새 컨테이너는 BLUE를 띄우도록 설정합니다.
    • 새 컨테이너가 어떤 포트(8081 또는 8082)에 매핑될지도 여기서 결정됩니다.
  2. 새 컨테이너 실행
    • docker run 명령으로 새 컨테이너를 실행합니다.
    • graceful shutdown 옵션과 --stop-timeout을 설정하여, 기존 컨테이너 종료 시 진행 중 요청을 안전하게 마무리합니다.
    • 컨테이너 내부에서는 항상 8080 포트를 사용하며, 호스트 포트(8081/8082)를 매핑합니다.
  3. 헬스체크
    • 새 컨테이너가 정상적으로 실행되었는지 /actuator/health 엔드포인트로 확인합니다.
    • 헬스체크가 통과해야만 기존 컨테이너를 종료합니다.
  4. 기존 컨테이너 종료
    • 헬스체크 성공 후, 기존 컨테이너를 graceful하게 종료합니다.
    • 진행 중인 요청이 남아 있어도 일정 시간(--stop-timeout) 동안 마무리 후 종료되므로, 사용자 요청이 끊기지 않습니다.