
배포할 때마다 서비스가 몇 초라도 끊긴다면, 그 몇 초 사이에 발생하는 요청은 전부 실패한다. 이 글은 별도의 로드밸런서나 여러 대의 서버 없이, EC2 한 대 + Docker + Nginx 조합만으로 무중단 배포(Blue-Green Deployment)를 구현한 과정을 정리한 것이다.
Blue-Green 배포는 동일한 서비스를 두 개의 환경(Blue / Green)으로 나눠 운영하다가, 배포 시점에 트래픽을 한쪽에서 다른 쪽으로 전환하는 방식이다.

핵심은 "새 버전을 미리 켜두고, 준비가 끝난 뒤에 트래픽만 옮긴다"는 점이다. 기존 버전을 내리고 새 버전을 올리는 방식(Rolling 없는 단순 재배포)과 달리 서비스 중단 구간이 없다.
Blue-Green 배포는 보통 ALB(Application Load Balancer)와 여러 대의 EC2, 또는 ECS/Kubernetes 같은 오케스트레이션 도구를 이용해 구성한다. 하지만 이런 구성은 비용과 운영 복잡도가 올라간다.
이번 구조는 EC2 한 대 안에서 Docker 컨테이너 두 개(Blue/Green)를 서로 다른 포트로 띄우고, 그 앞단의 Nginx가 리버스 프록시 역할을 하면서 트래픽을 전환하는 방식이다. 서버 한 대만으로도 무중단 배포가 가능하다는 점에서, 초기 단계의 서비스나 비용에 민감한 프로젝트에 적합하다.
물론 트레이드오프도 있다. EC2 한 대에 의존하기 때문에 해당 인스턴스 자체가 죽으면 서비스 전체가 중단되고, 두 컨테이너가 동시에 떠 있는 시점에는 CPU/메모리 자원을 동시에 소모한다. 인스턴스 사양을 여유 있게 잡아야 하는 이유다.

포트 구조를 정리하면 다음과 같다.
| 구성 요소 | 포트 | 역할 |
|---|---|---|
| Nginx | 80 | 외부 요청을 받아 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 참조)
배포 스크립트가 실행됐을 때 내부적으로 일어나는 순서는 다음과 같다.

여기서 핵심은 Nginx 설정 자체를 바꾸는 게 아니라, service-env.inc라는 변수 파일 하나만 갈아끼운다는 것이다. 이 파일에는 $service_url 변수 값(blue 또는 green)만 들어 있고, nginx -s reload는 워커 프로세스를 무중단으로 재시작하기 때문에 서비스가 끊기지 않는다.
# 우분투 버전
sudo apt update
# jdk 17 설치
sudo apt install openjdk-17-jdk -y
# 설치 확인
java -version
Spring Boot 애플리케이션을 EC2에서 직접 빌드/실행할 일은 없지만(빌드는 GitHub Actions에서, 실행은 Docker 컨테이너로), Docker 이미지 빌드나 로컬 디버깅을 대비해 JDK를 설치해둔다.
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 초기 실행 스크립트)에 등록해두면 인스턴스 생성과 동시에 자동으로 설치되도록 할 수 있다.
# 패키지 목록 최신화
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로 서비스를 시작하고 상태를 확인한다.
/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_url이 blue면 upstream blue(즉 localhost:8080)로, green이면 upstream green(localhost:8081)으로 요청이 전달된다. 어느 쪽으로 보낼지는 아래 service-env.inc 파일 하나가 결정한다.
/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번 포트 인바운드를 허용해야 한다.
배포 스크립트가 새로 띄운 컨테이너(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는 배포 스크립트가 새 컨테이너의 기동 완료 여부를 폴링하는 엔드포인트로 사용된다.
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.env와 serverName만 다르고, 둘 다 컨테이너 내부에서는 동일하게 8080 포트를 리슨한다(외부 노출 포트만 Docker 실행 시 8080/8081로 갈린다). DB, Redis, JWT 같은 민감한 값은 코드/설정 파일에 하드코딩하지 않고 환경변수나 GitHub Actions Secrets로 주입하는 것을 권장한다.
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 환경변수와 호스트 포트 매핑에서만 발생한다.
로컬 테스트나 수동 배포 시 참고할 수 있는 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 값뿐이다.
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
이 스크립트에서 배포 실패를 막는 안전장치는 두 가지다.
HEALTH_OK 플래그가 true가 되지 않으면 exit 1로 종료되고, 기존 컨테이너(트래픽을 받고 있던 쪽)는 그대로 유지된다.민감 정보(AWS 자격 증명, ECR 레지스트리 주소, EC2 IP, SSH 키 등)는 모두 GitHub Actions Secrets로 분리해 워크플로 코드에는 값이 노출되지 않는다.
이 구조를 한 줄로 요약하면, "Nginx 앞단은 그대로 두고, 뒷단의 upstream 대상만 변수 파일 하나로 바꿔치기한다"는 것이다. 배포 순서는 항상 아래와 같이 진행된다.
$service_url만 바꾸고 리로드한다.서버 한 대로 구현할 수 있어 비용 부담이 적고 구조도 단순하지만, 인스턴스 자체의 장애에는 취약하다는 한계가 있다. 트래픽과 가용성 요구 수준이 올라가면 ALB + 다중 EC2, 혹은 ECS/EKS 기반의 Blue-Green/Canary 배포로 확장하는 것이 다음 단계가 될 것이다.