단일 EC2 + Nginx로 구현하는 Blue-Green 무중단 배포

HamJina·2026년 9월 8일
post-thumbnail

배포할 때마다 서비스가 몇 초라도 끊긴다면, 그 몇 초 사이에 발생하는 요청은 전부 실패한다. 이 글은 별도의 로드밸런서나 여러 대의 서버 없이, EC2 한 대 + Docker + Nginx 조합만으로 무중단 배포(Blue-Green Deployment)를 구현한 과정을 정리한 것이다.

목차

  1. Blue-Green 배포란?
  2. 단일 EC2에서 Blue-Green을 구성하는 이유
  3. 전체 아키텍처
  4. 트래픽 전환 흐름
  5. 구현 과정
  6. 마무리

1. Blue-Green 배포란?

Blue-Green 배포는 동일한 서비스를 두 개의 환경(Blue / Green)으로 나눠 운영하다가, 배포 시점에 트래픽을 한쪽에서 다른 쪽으로 전환하는 방식이다.

  • 평소에는 두 환경 중 하나(예: Blue)만 실제 트래픽을 받는다.
  • 새 버전을 배포할 때는 반대편 환경(Green)에 새 버전을 띄운다.
  • Green이 정상 동작하는지 헬스체크로 확인한다.
  • 문제가 없으면 트래픽을 Green으로 전환한다.
  • 이전 환경(Blue)은 종료하거나 다음 배포를 위한 대기 상태로 남긴다.

핵심은 "새 버전을 미리 켜두고, 준비가 끝난 뒤에 트래픽만 옮긴다"는 점이다. 기존 버전을 내리고 새 버전을 올리는 방식(Rolling 없는 단순 재배포)과 달리 서비스 중단 구간이 없다.

2. 단일 EC2에서 Blue-Green을 구성하는 이유

Blue-Green 배포는 보통 ALB(Application Load Balancer)와 여러 대의 EC2, 또는 ECS/Kubernetes 같은 오케스트레이션 도구를 이용해 구성한다. 하지만 이런 구성은 비용과 운영 복잡도가 올라간다.

이번 구조는 EC2 한 대 안에서 Docker 컨테이너 두 개(Blue/Green)를 서로 다른 포트로 띄우고, 그 앞단의 Nginx가 리버스 프록시 역할을 하면서 트래픽을 전환하는 방식이다. 서버 한 대만으로도 무중단 배포가 가능하다는 점에서, 초기 단계의 서비스나 비용에 민감한 프로젝트에 적합하다.

물론 트레이드오프도 있다. EC2 한 대에 의존하기 때문에 해당 인스턴스 자체가 죽으면 서비스 전체가 중단되고, 두 컨테이너가 동시에 떠 있는 시점에는 CPU/메모리 자원을 동시에 소모한다. 인스턴스 사양을 여유 있게 잡아야 하는 이유다.

3. 전체 아키텍처

포트 구조를 정리하면 다음과 같다.

구성 요소포트역할
Nginx80외부 요청을 받아 Blue/Green으로 라우팅
Blue 컨테이너8080 (Host) → 8080 (Container)운영 트래픽을 받는 환경
Green 컨테이너8081 (Host) → 8080 (Container)대기 또는 신규 배포 환경

Docker의 포트 매핑 문법은 "호스트포트:컨테이너포트"이다. Spring Boot는 컨테이너 내부에서 항상 8080번을 리슨하고, 이 포트가 호스트(EC2)의 8080 또는 8081번으로 노출되는 구조다. Nginx는 컨테이너 내부 포트를 알 필요가 없고, EC2 호스트 기준 포트(localhost:8080, localhost:8081)만 바라본다.

Spring Boot (컨테이너 내부 8080)
   ↑
Docker 포트 매핑 (8080:8080 또는 8081:8080)
   ↑
EC2 Host Port (8080 / 8081)
   ↑
Nginx (upstream이 localhost:8080 또는 localhost:8081 참조)

4. 트래픽 전환 흐름

배포 스크립트가 실행됐을 때 내부적으로 일어나는 순서는 다음과 같다.

여기서 핵심은 Nginx 설정 자체를 바꾸는 게 아니라, service-env.inc라는 변수 파일 하나만 갈아끼운다는 것이다. 이 파일에는 $service_url 변수 값(blue 또는 green)만 들어 있고, nginx -s reload는 워커 프로세스를 무중단으로 재시작하기 때문에 서비스가 끊기지 않는다.

5. 구현 과정

5.1 EC2 초기 설정 - JDK 설치

# 우분투 버전
sudo apt update

# jdk 17 설치
sudo apt install openjdk-17-jdk -y

# 설치 확인
java -version

Spring Boot 애플리케이션을 EC2에서 직접 빌드/실행할 일은 없지만(빌드는 GitHub Actions에서, 실행은 Docker 컨테이너로), Docker 이미지 빌드나 로컬 디버깅을 대비해 JDK를 설치해둔다.

5.2 Docker / Docker Compose 설치

sudo apt-get update && \
    sudo apt-get install -y apt-transport-https ca-certificates curl software-properties-common && \
    curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add - && \
    sudo apt-key fingerprint 0EBFCD88 && \
    sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" && \
    sudo apt-get update && \
    sudo apt-get install -y docker-ce && \
    sudo usermod -aG docker ubuntu && \
    sudo curl -L "https://github.com/docker/compose/releases/download/1.23.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose && \
    sudo chmod +x /usr/local/bin/docker-compose && \
    sudo ln -s /usr/local/bin/docker-compose /usr/bin/docker-compose

# 설치 확인
docker -v
docker compose version

# 데몬 상태 확인
systemctl status docker

Docker 저장소를 apt에 등록하고 docker-ce를 설치한 뒤, ubuntu 유저를 docker 그룹에 추가해 매번 sudo 없이 명령어를 쓸 수 있게 한다. 이 스크립트는 EC2를 새로 띄울 때마다 반복하기보다, User Data(EC2 초기 실행 스크립트)에 등록해두면 인스턴스 생성과 동시에 자동으로 설치되도록 할 수 있다.

5.3 Nginx 설치

# 패키지 목록 최신화
sudo apt update

# nginx 설치에 필요한 라이브러리
sudo apt install curl gnupg2 ca-certificates lsb-release ubuntu-keyring

# nginx 공식 서명 키 등록
curl https://nginx.org/keys/nginx_signing.key | gpg --dearmor \
    | sudo tee /usr/share/keyrings/nginx-archive-keyring.gpg >/dev/null

# 저장소 등록
echo "deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] \
http://nginx.org/packages/ubuntu $(lsb_release -cs) nginx" \
    | sudo tee /etc/apt/sources.list.d/nginx.list

# nginx 설치
sudo apt update
sudo apt install nginx

nginx.org의 공식 패키지를 쓰기 위해 서명 키를 등록하고 저장소를 추가하는 과정이다. Ubuntu 기본 저장소의 nginx보다 최신 버전을 안전하게 받을 수 있다.

sudo systemctl start nginx
sudo systemctl status nginx

설치 후 systemctl로 서비스를 시작하고 상태를 확인한다.

5.4 Nginx 설정 - Blue/Green 업스트림 구성

/etc/nginx/conf.d/default.conf를 아래와 같이 수정한다.

# =========================
# Blue / Green Upstream
# =========================

# 운영 중인 서버 (localhost:8080)
upstream blue {
    server localhost:8080;
}

# 대기 또는 신규 배포 서버 (localhost:8081)
upstream green {
    server localhost:8081;
}

# =========================
# Nginx Server
# =========================

server {
    listen 80;
    server_name localhost;

    # 현재 트래픽을 어디로 보낼지는 이 파일에서 결정한다.
    # nginx 재시작 없이 이 include 파일만 바꾸면 Blue/Green 전환이 가능하다.
    include /etc/nginx/conf.d/service-env.inc;

    location / {
        # 핵심: $service_url 값에 따라 blue 또는 green으로 프록시
        proxy_pass http://$service_url;

        # 클라이언트 실제 IP 전달
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

        # 원본 Host 유지 (OAuth, CORS 대응에 필요)
        proxy_set_header Host $http_host;

        # 타임아웃 (Spring Boot 권장값)
        proxy_connect_timeout 5s;
        proxy_send_timeout 30s;
        proxy_read_timeout 30s;
    }

    error_page 500 502 503 504 /50x.html;
    location = /50x.html {
        root /usr/share/nginx/html;
    }
}

이 설정의 포인트는 proxy_pass http://$service_url;이다. $service_urlblueupstream blue(즉 localhost:8080)로, green이면 upstream green(localhost:8081)으로 요청이 전달된다. 어느 쪽으로 보낼지는 아래 service-env.inc 파일 하나가 결정한다.

5.5 트래픽 스위치 변수 파일

/etc/nginx/conf.d/service-env.inc

# 최초 기동 시에는 blue 환경으로 시작
set $service_url blue;

배포 스크립트가 이 한 줄을 set $service_url green;으로 바꾸고 nginx -s reload만 실행하면, Nginx 프로세스를 새로 켜지 않고도 트래픽 대상이 바뀐다.

# 문법 검사
sudo nginx -t

# 무중단 반영
sudo nginx -s reload

nginx -s reload는 기존 연결을 끊지 않고 워커 프로세스만 교체하기 때문에, 이 시점에 들어오는 요청도 유실되지 않는다.

참고: 로드밸런서(ALB)를 앞단에 둔다면, ALB의 리스너 포트(80)와 대상 그룹 포트(80)를 맞추고, EC2 보안 그룹에서 ALB 보안 그룹으로부터의 80번 포트 인바운드를 허용해야 한다.

5.6 헬스체크 엔드포인트

배포 스크립트가 새로 띄운 컨테이너(Green)가 정상 기동됐는지 확인하는 용도로 헬스체크 API를 둔다.

@Slf4j
@RestController
public class TestController {

    @Value("${server.port}")
    private int serverPort;

    @Value("${server.env}")
    private String serverEnv;

    @Value("${serverName}")
    private String serverName;

    @GetMapping("/health_check")
    public TestResponseDto healthCheck() {
        return new TestResponseDto("테스트에 성공하였습니다.", serverPort, serverEnv, serverName);
    }

    @GetMapping("/env")
    public String env() {
        return serverEnv;
    }
}

server.env, serverName 같은 값은 Spring 프로필(blue/green)에 따라 다르게 주입되며, 지금 응답하는 컨테이너가 blue인지 green인지 확인하는 용도로 쓴다. /health_check는 배포 스크립트가 새 컨테이너의 기동 완료 여부를 폴링하는 엔드포인트로 사용된다.

5.7 Spring Boot 설정 - 프로필 분리

application.yml에서 blue/green/common 세 프로필로 나눠 관리한다. 아래 값 중 자격 증명, 엔드포인트 등 민감한 정보는 실제 값 대신 환경변수/플레이스홀더로 표시했다.

spring:
  profiles:
    group:
      blue: blue, common
      green: green, common
  application:
    name: ForDay

---
# Blue
spring:
  config:
    activate:
      on-profile: blue

server:
  port: 8080   # 컨테이너 내부 포트 (blue/green 모두 동일)
  env: blue
serverName: blue_server

---
# Green
spring:
  config:
    activate:
      on-profile: green

server:
  port: 8080
  env: green
serverName: green_server

---
# 공통 설정
spring:
  config:
    activate:
      on-profile: common

  datasource:
    url: jdbc:mysql://${DB_HOST}:3306/${DB_NAME}
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}

  jpa:
    database: mysql
    database-platform: org.hibernate.dialect.MySQLDialect
    generate-ddl: false
    hibernate:
      ddl-auto: update
    show-sql: false

  data:
    redis:
      host: ${REDIS_HOST}
      port: 6379

  jwt:
    secret: ${JWT_SECRET}
    access-expiration: 180
    refresh-expiration: 14

server:
  address: 0.0.0.0

management:
  endpoints:
    web:
      exposure:
        include: health

blue/green 프로필은 server.envserverName만 다르고, 둘 다 컨테이너 내부에서는 동일하게 8080 포트를 리슨한다(외부 노출 포트만 Docker 실행 시 8080/8081로 갈린다). DB, Redis, JWT 같은 민감한 값은 코드/설정 파일에 하드코딩하지 않고 환경변수나 GitHub Actions Secrets로 주입하는 것을 권장한다.

5.8 Dockerfile

FROM eclipse-temurin:17-jdk

WORKDIR /app

COPY build/libs/*SNAPSHOT.jar app.jar

EXPOSE 8080

ENTRYPOINT ["java", "-jar", "app.jar"]

Gradle로 빌드된 jar를 컨테이너에 복사하고 8080 포트로 실행하는 단순한 구조다. blue/green 이미지는 완전히 동일하며, 차이는 실행 시 SPRING_PROFILES_ACTIVE 환경변수와 호스트 포트 매핑에서만 발생한다.

5.9 docker-compose 파일 (참고용)

로컬 테스트나 수동 배포 시 참고할 수 있는 compose 파일이다. (실제 CI/CD 배포는 5.10의 deploy.yml에서 docker run으로 직접 처리한다.)

# docker-compose-blue.yml
version: '3.8'

services:
  blue:
    image: <ECR_REPOSITORY_URI>:latest
    container_name: blue
    ports:
      - "8080:8080"   # EC2 외부 노출 : 컨테이너 내부(Spring Boot)
    environment:
      - SPRING_PROFILES_ACTIVE=blue
      - ENV=blue
    restart: always
# docker-compose-green.yml
version: '3.8'

services:
  green:
    image: <ECR_REPOSITORY_URI>:latest
    container_name: green
    ports:
      - "8081:8080"
    environment:
      - SPRING_PROFILES_ACTIVE=green
      - ENV=green
    restart: always

두 파일의 차이는 컨테이너 이름, 호스트 포트(8080/8081), SPRING_PROFILES_ACTIVE 값뿐이다.

5.10 GitHub Actions로 CI/CD 자동화

main 브랜치에 푸시되면 빌드 → ECR 푸시 → EC2 SSH 접속 → Blue/Green 전환까지 자동으로 처리한다.

Build Job

name: Deploy To EC2 (Blue-Green)

on:
  push:
    branches: [ "main" ]

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: 17

      - name: Create application.yml
        run: echo "${{ secrets.APPLICATION_PROPERTIES }}" > ./src/main/resources/application.yml

      - name: Build Spring Boot
        run: |
          chmod +x ./gradlew
          ./gradlew clean build -x test

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-region: ${{ secrets.AWS_REGION }}
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

      - name: Login to ECR
        run: |
          aws ecr get-login-password --region ${{ secrets.AWS_REGION }} \
          | docker login --username AWS --password-stdin ${{ secrets.ECR_REGISTRY }}

      - name: Build & Push Docker Image
        run: |
          docker build -t forday .
          docker tag forday:latest ${{ secrets.ECR_REGISTRY }}/forday:latest
          docker push ${{ secrets.ECR_REGISTRY }}/forday:latest

application.yml처럼 민감 정보가 담긴 설정 파일은 리포지토리에 커밋하지 않고, GitHub Actions Secrets(APPLICATION_PROPERTIES)에 전체 내용을 저장해뒀다가 빌드 시점에 파일로 복원한다. AWS 계정 ID, ECR 리전 등도 코드에 노출하지 않고 Secrets(ECR_REGISTRY, AWS_REGION)로 관리한다.

Deploy Job

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - name: Configure SSH
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
          chmod 600 ~/.ssh/id_rsa
          cat <<EOF >> ~/.ssh/config
          Host ec2-server
            HostName ${{ secrets.EC2_PUBLIC_IP }}
            User ubuntu
            IdentityFile ~/.ssh/id_rsa
            StrictHostKeyChecking no
          EOF

      - name: Blue-Green Deploy via SSH
        run: |
          ssh ec2-server << 'EOF'
            set -e

            aws ecr get-login-password --region ${{ secrets.AWS_REGION }} \
              | docker login --username AWS --password-stdin ${{ secrets.ECR_REGISTRY }}

            # 1) service-env.inc 파일이 없으면 blue로 초기화
            if [ ! -f /etc/nginx/conf.d/service-env.inc ]; then
              echo "set \$service_url blue;" | sudo tee /etc/nginx/conf.d/service-env.inc
            fi

            # 2) 현재 트래픽이 blue/green 중 어디로 가고 있는지 확인
            CURRENT_VAL=$(grep -oP '(?<=set \$service_url ).*(?=;)' /etc/nginx/conf.d/service-env.inc || echo "blue")

            # 3) 반대편을 배포 대상으로 지정
            if [ "$CURRENT_VAL" = "blue" ]; then
              TARGET="green"; TARGET_PORT=8081; OLD_TARGET="blue"
            else
              TARGET="blue"; TARGET_PORT=8080; OLD_TARGET="green"
            fi

            echo "배포 대상: $TARGET (포트: $TARGET_PORT)"
            docker pull ${{ secrets.ECR_REGISTRY }}/forday:latest

            # 4) 기존에 같은 이름의 컨테이너가 있다면 정리 후 새로 기동
            docker stop $TARGET || true
            docker rm $TARGET || true

            docker run -d \
              --name $TARGET \
              --restart=always \
              --network forday-net \
              -v /etc/localtime:/etc/localtime:ro \
              -e TZ=Asia/Seoul \
              -e SPRING_PROFILES_ACTIVE=$TARGET \
              -p $TARGET_PORT:8080 \
              ${{ secrets.ECR_REGISTRY }}/forday:latest

            # 5) 헬스체크 - 최대 20회, 5초 간격으로 재시도
            for i in {1..20}; do
              if curl -sf http://localhost:$TARGET_PORT/health_check; then
                HEALTH_OK=true
                break
              fi
              echo "대기 중... ($i/20)"
              sleep 5
            done

            if [ "$HEALTH_OK" != "true" ]; then
              echo "헬스 체크 실패"
              docker logs $TARGET
              exit 1
            fi

            # 6) 헬스체크 통과 시에만 Nginx 트래픽 전환
            echo "set \$service_url $TARGET;" | sudo tee /etc/nginx/conf.d/service-env.inc
            sudo nginx -t && sudo nginx -s reload

            # 7) 이전 컨테이너 정리
            docker stop $OLD_TARGET || true
            docker rm $OLD_TARGET || true
            docker image prune -af
          EOF

이 스크립트에서 배포 실패를 막는 안전장치는 두 가지다.

  • 헬스체크를 통과하기 전에는 절대 Nginx 설정을 바꾸지 않는다. HEALTH_OK 플래그가 true가 되지 않으면 exit 1로 종료되고, 기존 컨테이너(트래픽을 받고 있던 쪽)는 그대로 유지된다.
  • 이전 컨테이너는 전환이 끝난 뒤에 종료한다. 전환 도중 문제가 생겨도 롤백할 대상이 남아있는 구조다.

민감 정보(AWS 자격 증명, ECR 레지스트리 주소, EC2 IP, SSH 키 등)는 모두 GitHub Actions Secrets로 분리해 워크플로 코드에는 값이 노출되지 않는다.

6. 마무리

이 구조를 한 줄로 요약하면, "Nginx 앞단은 그대로 두고, 뒷단의 upstream 대상만 변수 파일 하나로 바꿔치기한다"는 것이다. 배포 순서는 항상 아래와 같이 진행된다.

  1. 반대편(대기) 컨테이너에 새 버전을 띄운다.
  2. 헬스체크로 정상 기동을 확인한다.
  3. 통과하면 Nginx의 $service_url만 바꾸고 리로드한다.
  4. 이전 컨테이너를 종료한다.

서버 한 대로 구현할 수 있어 비용 부담이 적고 구조도 단순하지만, 인스턴스 자체의 장애에는 취약하다는 한계가 있다. 트래픽과 가용성 요구 수준이 올라가면 ALB + 다중 EC2, 혹은 ECS/EKS 기반의 Blue-Green/Canary 배포로 확장하는 것이 다음 단계가 될 것이다.

0개의 댓글