
이 글은 우아한테크코스 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를 하나 더 설치하는 방식이었습니다.
이 아키텍처가 동작하는 방식은 다음과 같습니다.
- Public Nginx (Web Server): 이제 이 Nginx의 역할은 전보다 단순해졌습니다. 외부 사용자의 모든 요청(80, 443)을 받아 아무것도 묻지도 따지지도 않고 Private Subnet에 있는 EC2의 80 포트로 그대로 전달합니다. SSL 인증서 처리(HTTPS)도 여기서 담당합니다.
- 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
- 현재 실행 중인 컨테이너 확인
- 스크립트는 먼저 현재 서버에서 어떤 컨테이너가 실행 중인지 확인합니다.
- BLUE 컨테이너가 살아 있다면 새 컨테이너는 GREEN을, GREEN이 살아 있다면 새 컨테이너는 BLUE를 띄우도록 설정합니다.
- 새 컨테이너가 어떤 포트(8081 또는 8082)에 매핑될지도 여기서 결정됩니다.
- 새 컨테이너 실행
- docker run 명령으로 새 컨테이너를 실행합니다.
- graceful shutdown 옵션과 --stop-timeout을 설정하여, 기존 컨테이너 종료 시 진행 중 요청을 안전하게 마무리합니다.
- 컨테이너 내부에서는 항상 8080 포트를 사용하며, 호스트 포트(8081/8082)를 매핑합니다.
- 헬스체크
- 새 컨테이너가 정상적으로 실행되었는지 /actuator/health 엔드포인트로 확인합니다.
- 헬스체크가 통과해야만 기존 컨테이너를 종료합니다.
- 기존 컨테이너 종료
- 헬스체크 성공 후, 기존 컨테이너를 graceful하게 종료합니다.
- 진행 중인 요청이 남아 있어도 일정 시간(--stop-timeout) 동안 마무리 후 종료되므로, 사용자 요청이 끊기지 않습니다.
'개발괴발 > Devops' 카테고리의 다른 글
| [Prometheus, Grafana] 야구보구 부하테스트 모니터링 구축기 (k6 → Prometheus → Grafana) (0) | 2025.11.04 |
|---|---|
| [AWS] CloudWatch 알람 Discord로 보내기 (SNS + Lambda 활용) (3) | 2025.09.08 |
| [GitHub Actions] EC2에서 막힌 CI/CD, Self-hosted Runner로 자동화하기 (5) | 2025.08.01 |
| [Github Actions] CI/CD, 배포 자동화하기 (3) | 2025.07.22 |
| [Jenkins] ERROR: Error fetching remote repo 'origin' (0) | 2024.11.29 |