본문으로 건너뛰기

무중단 배포 전략 — Rolling·Blue-Green·Canary

소프트웨어 배포는 피할 수 없는 작업이지만, 배포 때마다 서비스를 중단하는 것은 현대 비즈니스에서 받아들이기 어렵습니다. 무중단 배포(Zero Downtime Deployment)는 사용자에게 영향을 주지 않으면서 새로운 버전을 배포하는 기술입니다. 이 챕터에서는 Rolling Update, Blue-Green, Canary 세 가지 핵심 전략을 상세히 다루고, 각 전략의 구현 방법과 적합한 상황을 설명합니다.

무중단 배포가 필요한 이유

서비스 중단의 비용

배포 시 서비스를 잠깐 중단하는 것이 당연하던 시절이 있었습니다. 하지만 24시간 글로벌 서비스, 마이크로서비스 아키텍처, CI/CD 파이프라인으로 하루에도 수십 번 배포가 발생하는 현대 환경에서 서비스 중단은 곧 손실입니다.

배포 빈도별 연간 다운타임 (배포당 5분 중단 기준):
- 주 1회 배포: 연 52회 × 5분 = 260분(4.3시간) 다운타임
- 일 1회 배포: 연 365회 × 5분 = 1,825분(30.4시간) 다운타임
- 일 10회 배포: 연 3,650회 × 5분 = 18,250분(304시간) 다운타임

이처럼 배포 빈도가 높아질수록 전통적인 중단 배포는 현실적으로 불가능합니다.

무중단 배포의 핵심 요구사항

  1. 배포 중 모든 요청이 정상 처리될 것
  2. 배포 실패 시 즉각적인 롤백이 가능할 것
  3. 배포 중 데이터 무결성이 유지될 것
  4. 사용자는 배포 중임을 인지하지 못할 것

Rolling Update (롤링 업데이트)

롤링 업데이트는 인스턴스를 순차적으로 교체하는 방식입니다. 전체 서버를 한 번에 교체하지 않고, 하나씩 또는 소수씩 교체하면서 전체 플릿을 업데이트합니다.

동작 방식

초기 상태: [v1] [v1] [v1] [v1]   ← 4개 모두 v1

1단계: [v2] [v1] [v1] [v1] ← 1번 서버 교체 중 (트래픽 일시 제외)
교체 완료 후 트래픽 복귀

2단계: [v2] [v2] [v1] [v1] ← 2번 서버 교체

3단계: [v2] [v2] [v2] [v1] ← 3번 서버 교체

4단계: [v2] [v2] [v2] [v2] ← 완료

장단점

장점:

  • 추가 인프라 비용 없음
  • 구현이 상대적으로 단순
  • 점진적 배포로 이상 징후 조기 발견 가능

단점:

  • 배포 중 v1, v2 버전이 동시에 서비스됨 (하위 호환성 필수)
  • 완전한 롤백이 느림 (모든 서버를 다시 v1으로 교체)
  • 배포 시간이 긴 편

Nginx Upstream 설정 (Rolling Update)

# /etc/nginx/conf.d/upstream.conf
upstream app_backend {
# max_fails: 연속 실패 횟수, fail_timeout: 서버 제외 유지 시간
server 192.168.1.20:8080 max_fails=3 fail_timeout=30s;
server 192.168.1.21:8080 max_fails=3 fail_timeout=30s;
server 192.168.1.22:8080 max_fails=3 fail_timeout=30s;
server 192.168.1.23:8080 max_fails=3 fail_timeout=30s;
}

server {
listen 80;

location / {
proxy_pass http://app_backend;
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
proxy_connect_timeout 5s;
proxy_read_timeout 30s;
}
}

Rolling Update 배포 스크립트

#!/bin/bash
# rolling-deploy.sh

SERVERS=("192.168.1.20" "192.168.1.21" "192.168.1.22" "192.168.1.23")
APP_PORT=8080
NEW_VERSION=$1
HEALTH_CHECK_URL="http://SERVER_IP:$APP_PORT/health"
NGINX_UPSTREAM_CONF="/etc/nginx/conf.d/upstream.conf"

if [ -z "$NEW_VERSION" ]; then
echo "Usage: $0 <version>"
exit 1
fi

deploy_to_server() {
local SERVER=$1
echo "=== Deploying $NEW_VERSION to $SERVER ==="

# 1. Nginx upstream에서 서버 제외
echo "Removing $SERVER from upstream..."
ssh root@$SERVER "curl -s -X POST http://localhost:8080/actuator/pause || true"
sleep 5 # 진행 중인 요청 완료 대기

# 2. 새 버전 배포
echo "Deploying new version..."
ssh root@$SERVER "
cd /opt/app
docker pull myapp:$NEW_VERSION
docker stop app || true
docker rm app || true
docker run -d --name app -p $APP_PORT:8080 myapp:$NEW_VERSION
"

# 3. 헬스체크
echo "Waiting for health check..."
for i in {1..30}; do
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
"http://${SERVER}:${APP_PORT}/health" 2>/dev/null)
if [ "$HTTP_CODE" = "200" ]; then
echo "$SERVER is healthy (attempt $i)"
return 0
fi
echo "Attempt $i: HTTP $HTTP_CODE, waiting..."
sleep 3
done

echo "Health check failed for $SERVER"
return 1
}

# 순차 배포
for SERVER in "${SERVERS[@]}"; do
if ! deploy_to_server "$SERVER"; then
echo "DEPLOYMENT FAILED on $SERVER. Manual intervention required."
exit 1
fi
echo "Successfully deployed to $SERVER"
echo "---"
done

echo "Rolling update completed successfully!"

Blue-Green 배포

Blue-Green 배포는 완전히 동일한 두 개의 환경(Blue, Green)을 유지하고, 한 번에 트래픽을 전환하는 방식입니다. 현재 서비스 중인 환경이 Blue라면, Green에 새 버전을 배포하고 준비가 완료되면 트래픽을 Blue에서 Green으로 전환합니다.

동작 방식

배포 전:
클라이언트 → Nginx(VIP) → Blue(v1) [Active]
→ Green(v1) [Idle]

새 버전 배포:
클라이언트 → Nginx(VIP) → Blue(v1) [Active]
→ Green(v2) [준비 완료, 테스트 중]

트래픽 전환:
클라이언트 → Nginx(VIP) → Blue(v1) [Standby, 30분간 유지]
→ Green(v2) [Active]

롤백 필요 시:
클라이언트 → Nginx(VIP) → Blue(v1) [Active로 즉각 전환]
→ Green(v2) [Standby]

장단점

장점:

  • 즉각적인 롤백 (트래픽 전환만 하면 됨)
  • 배포 중 단 하나의 버전만 서비스됨
  • 새 버전을 프로덕션 환경에서 충분히 테스트 후 전환 가능

단점:

  • 두 배의 인프라 비용
  • DB 스키마 변경 시 하위 호환성 필수
  • 세션 처리 복잡 (전환 시 Blue의 세션 데이터 처리)

Nginx Blue-Green 전환 스크립트

#!/bin/bash
# blue-green-switch.sh

NGINX_CONF_DIR="/etc/nginx/conf.d"
BLUE_UPSTREAM="upstream_blue.conf"
GREEN_UPSTREAM="upstream_green.conf"
CURRENT_SYMLINK="$NGINX_CONF_DIR/current_upstream.conf"

# 현재 활성 환경 확인
get_current_env() {
if [ -L "$CURRENT_SYMLINK" ]; then
readlink "$CURRENT_SYMLINK" | grep -oP '(blue|green)'
else
echo "blue" # 기본값
fi
}

CURRENT=$(get_current_env)
echo "Current active environment: $CURRENT"

if [ "$CURRENT" = "blue" ]; then
NEW_ENV="green"
NEW_CONF="$NGINX_CONF_DIR/$GREEN_UPSTREAM"
else
NEW_ENV="blue"
NEW_CONF="$NGINX_CONF_DIR/$BLUE_UPSTREAM"
fi

echo "Switching to $NEW_ENV environment..."

# Nginx 설정 파일 내용 (Blue 환경)
cat > "$NGINX_CONF_DIR/$BLUE_UPSTREAM" << 'EOF'
upstream app_backend {
server 192.168.1.20:8080;
server 192.168.1.21:8080;
keepalive 32;
}
EOF

# Nginx 설정 파일 내용 (Green 환경)
cat > "$NGINX_CONF_DIR/$GREEN_UPSTREAM" << 'EOF'
upstream app_backend {
server 192.168.1.30:8080;
server 192.168.1.31:8080;
keepalive 32;
}
EOF

# 심볼릭 링크 교체 (원자적 작업)
ln -sfn "$NEW_CONF" "$CURRENT_SYMLINK"

# Nginx 설정 검증 후 리로드
if nginx -t 2>/dev/null; then
nginx -s reload
echo "Successfully switched to $NEW_ENV environment"
echo "Previous environment ($CURRENT) remains on standby for 30 minutes"
else
# 롤백
ln -sfn "$NGINX_CONF_DIR/${CURRENT}_upstream.conf" "$CURRENT_SYMLINK"
echo "ERROR: Nginx config test failed, rolled back to $CURRENT"
exit 1
fi

Nginx 설정 (Blue-Green)

# /etc/nginx/nginx.conf
include /etc/nginx/conf.d/current_upstream.conf; # 심볼릭 링크

server {
listen 80;
server_name example.com;

location / {
proxy_pass http://app_backend;
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_next_upstream error timeout http_502 http_503 http_504;
proxy_next_upstream_tries 2;
}
}

Canary 배포

Canary 배포는 새 버전을 전체 트래픽의 일부(예: 1~5%)에만 먼저 적용하고, 문제가 없으면 점진적으로 트래픽 비율을 늘려가는 방식입니다. 탄광에서 유해 가스 감지를 위해 카나리아 새를 사용한 것에서 유래했습니다.

동작 방식

1단계: v1(95%) + v2(5%)   — 초기 카나리아 배포
2단계: v1(75%) + v2(25%) — 이상 없으면 비율 증가
3단계: v1(50%) + v2(50%) — 절반 전환
4단계: v1(0%) + v2(100%) — 전체 전환 완료

Nginx Canary 설정 (weight 활용)

# /etc/nginx/conf.d/canary.conf
upstream app_backend {
# weight로 트래픽 비율 제어
server 192.168.1.20:8080 weight=95; # v1 (95%)
server 192.168.1.21:8080 weight=95; # v1 (95%)
server 192.168.1.30:8080 weight=5; # v2 Canary (5%)

keepalive 32;
}

server {
listen 80;

location / {
proxy_pass http://app_backend;
}

# Canary 모니터링용 별도 엔드포인트
location /canary-status {
proxy_pass http://192.168.1.30:8080/status; # v2 직접 접속
}
}

Canary 점진적 전환 스크립트

#!/bin/bash
# canary-deploy.sh

NGINX_CONF="/etc/nginx/conf.d/canary.conf"
CANARY_STEPS=(5 10 25 50 75 100) # 점진적 비율 증가
CANARY_SERVER="192.168.1.30:8080"
STABLE_SERVER1="192.168.1.20:8080"
STABLE_SERVER2="192.168.1.21:8080"
ERROR_THRESHOLD=1 # 오류율 임계값 (%)
WAIT_TIME=300 # 각 단계 관찰 시간 (초)

check_error_rate() {
# Prometheus 또는 로그에서 오류율 확인 (예시)
ERROR_RATE=$(curl -s "http://prometheus:9090/api/v1/query?query=rate(http_requests_total{status=~'5..'}[5m])/rate(http_requests_total[5m])*100" \
| jq '.data.result[0].value[1]' 2>/dev/null | tr -d '"')
echo "${ERROR_RATE:-0}"
}

update_nginx_weight() {
local CANARY_WEIGHT=$1
local STABLE_WEIGHT=$((100 - CANARY_WEIGHT))

cat > "$NGINX_CONF" << EOF
upstream app_backend {
server $STABLE_SERVER1 weight=$STABLE_WEIGHT;
server $STABLE_SERVER2 weight=$STABLE_WEIGHT;
server $CANARY_SERVER weight=$CANARY_WEIGHT;
keepalive 32;
}
EOF

nginx -t && nginx -s reload
}

# 단계별 카나리아 배포
for STEP in "${CANARY_STEPS[@]}"; do
echo "=== Setting canary weight to ${STEP}% ==="
update_nginx_weight $STEP

echo "Observing for ${WAIT_TIME} seconds..."
sleep $WAIT_TIME

ERROR_RATE=$(check_error_rate)
echo "Current error rate: ${ERROR_RATE}%"

if (( $(echo "$ERROR_RATE > $ERROR_THRESHOLD" | bc -l) )); then
echo "ERROR: Error rate ${ERROR_RATE}% exceeds threshold ${ERROR_THRESHOLD}%"
echo "Rolling back to 100% stable..."
update_nginx_weight 0
exit 1
fi

echo "Error rate acceptable, proceeding to next step..."
done

echo "Canary deployment completed successfully! 100% traffic on new version."

세 전략 비교표

항목Rolling UpdateBlue-GreenCanary
배포 속도중간빠름느림
롤백 용이성느림즉각적빠름
인프라 비용기존과 동일2배약간 증가
서비스 중단없음없음없음
버전 혼재있음없음있음
리스크중간낮음매우 낮음
적합 환경소규모, 개발환경중대형 서비스대규모, 실험적
DB 변경 처리하위 호환 필요하위 호환 필요하위 호환 필요

Tomcat 핫 디플로이

Tomcat은 서버 재시작 없이 애플리케이션을 배포할 수 있는 핫 디플로이 기능을 제공합니다.

autoDeploy 설정

<!-- /opt/tomcat/conf/server.xml -->
<Host name="localhost" appBase="webapps"
unpackWARs="true"
autoDeploy="true" <!-- WAR 파일 변경 감지 자동 배포 -->
deployOnStartup="true">
</Host>

Tomcat Manager App을 이용한 배포

# WAR 파일 배포 (Manager REST API)
curl -u admin:password \
-T /path/to/new-app.war \
"http://localhost:8080/manager/text/deploy?path=/app&update=true"

# 응답 확인
# OK - Deployed application at context path [/app]

# 애플리케이션 재시작
curl -u admin:password \
"http://localhost:8080/manager/text/reload?path=/app"

# 배포 목록 확인
curl -u admin:password \
"http://localhost:8080/manager/text/list"

WAR 파일 교체 무중단 배포 스크립트

#!/bin/bash
# tomcat-deploy.sh

TOMCAT_HOME="/opt/tomcat"
WEBAPPS_DIR="$TOMCAT_HOME/webapps"
APP_NAME="myapp"
NEW_WAR=$1
TOMCAT_MANAGER_URL="http://localhost:8080/manager/text"
TOMCAT_USER="admin"
TOMCAT_PASS="secret"
BACKUP_DIR="/opt/tomcat/backup"

if [ -z "$NEW_WAR" ] || [ ! -f "$NEW_WAR" ]; then
echo "Usage: $0 <war_file_path>"
exit 1
fi

mkdir -p "$BACKUP_DIR"

# 1. 현재 WAR 백업
if [ -f "$WEBAPPS_DIR/${APP_NAME}.war" ]; then
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
cp "$WEBAPPS_DIR/${APP_NAME}.war" "$BACKUP_DIR/${APP_NAME}_${TIMESTAMP}.war"
echo "Backed up current WAR to $BACKUP_DIR/${APP_NAME}_${TIMESTAMP}.war"
fi

# 2. Tomcat Manager를 통한 무중단 배포
echo "Deploying new WAR via Tomcat Manager..."
RESULT=$(curl -s -u "$TOMCAT_USER:$TOMCAT_PASS" \
-T "$NEW_WAR" \
"$TOMCAT_MANAGER_URL/deploy?path=/${APP_NAME}&update=true")

echo "Deploy result: $RESULT"

if echo "$RESULT" | grep -q "^OK"; then
echo "Deployment successful!"

# 3. 헬스체크
sleep 5
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
"http://localhost:8080/${APP_NAME}/health")

if [ "$HTTP_CODE" = "200" ]; then
echo "Health check passed (HTTP 200)"
else
echo "WARNING: Health check returned HTTP $HTTP_CODE"
echo "Consider rollback if issues persist"
fi
else
echo "ERROR: Deployment failed"
echo "Response: $RESULT"
exit 1
fi

Docker 환경에서의 Rolling Update

# docker-compose.yml
version: '3.8'
services:
app:
image: myapp:${APP_VERSION:-latest}
deploy:
replicas: 3
update_config:
parallelism: 1 # 한 번에 1개씩 교체
delay: 10s # 각 교체 간격
failure_action: rollback # 실패 시 자동 롤백
monitor: 30s # 새 컨테이너 안정화 모니터링 시간
max_failure_ratio: 0.1 # 최대 허용 실패율 (10%)
rollback_config:
parallelism: 0 # 동시에 전체 롤백
delay: 0s
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 30s
ports:
- "8080:8080"
# Docker Compose Rolling Update 실행
APP_VERSION=v2.0.0 docker compose up -d --no-deps --build app

# 롤백
docker compose rollback app

전문가 팁

  • 무중단 배포의 전제 조건은 하위 호환 APIDB 스키마 마이그레이션 전략입니다. 배포 이전에 DB 스키마를 하위 호환으로 변경하고, 이후 애플리케이션을 배포하세요.
  • Blue-Green 배포 시 세션 문제를 해결하려면 Redis 같은 외부 세션 스토어를 사용하거나, JWT 같은 무상태 인증을 채택하세요.
  • Canary 배포는 특정 사용자 그룹(예: 내부 직원, 베타 사용자)에게만 먼저 배포하는 방식으로도 구현할 수 있습니다 (Feature Flag와 조합).
  • 배포 파이프라인에 자동 롤백 조건을 설정하세요: 에러율 임계값, 응답 시간 증가, 헬스체크 실패 등.
  • 각 배포 후 메트릭(에러율, 응답 시간, CPU/메모리 사용량)을 최소 30분간 모니터링하는 것을 원칙으로 하세요.