Nginx Auto-Failover를 사용한 Blue/Green 배포 구축
Source: Dev.to
소개
Blue/Green 배포는 애플리케이션의 두 개 동일한 인스턴스(Blue와 Green)를 실행하고, 문제가 발생했을 때 트래픽을 활성 인스턴스에서 대기 인스턴스로 즉시 전환할 수 있게 해줍니다. 이 가이드에서는 Nginx를 트래픽 디렉터로 사용하고, 두 풀에 대한 작은 Node.js 서비스와, Nginx의 JSON 로그를 읽어 Slack에 알림을 보내는 선택적인 Python 워처를 이용해 완전한 컨테이너 기반 Blue/Green 설정을 구축합니다.
사전 요구 사항
| 항목 | 이유 |
|---|---|
| Docker + Docker Compose | Kubernetes 없이 로컬에서 서비스 실행 |
| Node.js (앱 빌드용) | 작은 데모 서비스 컴파일 |
| (선택) Slack 웹훅 URL | 장애 전환 알림 수신 |
| 터미널 및 텍스트 편집기 | 파일 생성 및 편집 |
프로젝트 구조
.
├─ app/
│ ├─ package.json
│ ├─ app.js
│ └─ Dockerfile
├─ nginx/
│ └─ nginx.conf.template
├─ watcher/
│ ├─ requirements.txt
│ └─ watcher.py
├─ docker-compose.yaml
└─ .env
1. Node.js 애플리케이션
package.json
{
"name": "blue-green-app",
"version": "1.0.0",
"main": "app.js",
"license": "MIT",
"scripts": {
"start": "node app.js"
},
"dependencies": {
"express": "^4.18.2"
}
}
app.js
const express = require('express');
const app = express();
const APP_POOL = process.env.APP_POOL || 'unknown';
const RELEASE_ID = process.env.RELEASE_ID || 'unknown';
const PORT = process.env.PORT || 3000;
let chaosMode = false;
let chaosType = 'error'; // 'error' or 'timeout'
// Add tracing headers
app.use((req, res, next) => {
res.setHeader('X-App-Pool', APP_POOL);
res.setHeader('X-Release-Id', RELEASE_ID);
next();
});
app.get('/', (req, res) => {
res.json({
service: 'Blue/Green Demo',
pool: APP_POOL,
releaseId: RELEASE_ID,
status: chaosMode ? 'chaos' : 'healthy',
chaosMode,
chaosType: chaosMode ? chaosType : null,
timestamp: new Date().toISOString(),
endpoints: {
version: '/version',
health: '/healthz',
chaos: '/chaos/start, /chaos/stop'
}
});
});
app.get('/healthz', (req, res) => {
res.status(200).json({ status: 'healthy', pool: APP_POOL });
});
app.get('/version', (req, res) => {
if (chaosMode && chaosType === 'error')
return res.status(500).json({ error: 'Chaos: server error' });
if (chaosMode && chaosType === 'timeout')
return; // simulate hang
res.json({
version: '1.0.0',
pool: APP_POOL,
releaseId: RELEASE_ID,
timestamp: new Date().toISOString()
});
});
app.post('/chaos/start', (req, res) => {
const mode = req.query.mode || 'error';
chaosMode = true;
chaosType = mode;
res.json({ message: 'Chaos started', mode, pool: APP_POOL });
});
app.post('/chaos/stop', (req, res) => {
chaosMode = false;
chaosType = 'error';
res.json({ message: 'Chaos stopped', pool: APP_POOL });
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`App (${APP_POOL}) listening on ${PORT}`);
console.log(`Release ID: ${RELEASE_ID}`);
});
서비스가 제공하는 엔드포인트:
GET /healthz– Nginx용 헬스 체크GET /version– 버전 정보 반환; chaos 모드로 오류 또는 타임아웃 강제 가능POST /chaos/start?mode=error|timeout– 장애 시뮬레이션 활성화POST /chaos/stop– chaos 비활성화
2. 두 풀을 위한 Docker 이미지
Dockerfile
FROM node:18-alpine
WORKDIR /app
# Install production dependencies
COPY package*.json ./
RUN npm install --only=production
# Copy source code
COPY . .
EXPOSE 3000
CMD ["npm", "start"]
Blue와 Green 컨테이너는 모두 이 이미지에서 빌드되며, APP_POOL, RELEASE_ID 등 환경 변수만 다릅니다.
3. Nginx 트래픽 디렉터
nginx/nginx.conf.template
events {
worker_connections 1024;
}
http {
# Structured JSON access logs
log_format custom_json '{"time":"$time_iso8601"'
',"remote_addr":"$remote_addr"'
',"method":"$request_method"'
',"uri":"$request_uri"'
',"status":$status'
',"bytes_sent":$bytes_sent'
',"request_time":$request_time'
',"upstream_response_time":"$upstream_response_time"'
',"upstream_status":"$upstream_status"'
',"upstream_addr":"$upstream_addr"'
',"pool":"$sent_http_x_app_pool"'
',"release":"$sent_http_x_release_id"}';
upstream blue_pool {
server app-blue:3000 max_fails=1 fail_timeout=3s;
server app-green:3000 backup;
}
upstream green_pool {
server app-green:3000 max_fails=1 fail_timeout=3s;
server app-blue:3000 backup;
}
server {
listen 80;
server_name localhost;
# JSON access log (shared volume)
access_log /var/log/nginx/access.json custom_json;
# Simple health endpoint for the load balancer itself
location /healthz {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
location / {
# $UPSTREAM_POOL is set by Docker‑Compose env substitution
proxy_pass http://$UPSTREAM_POOL;
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_set_header X-Forwarded-Proto $scheme;
# Fast timeouts → quick failover
proxy_connect_timeout 1s;
proxy_send_timeout 3s;
proxy_read_timeout 3s;
# Retry on errors / timeouts, try backup upstream
proxy_next_upstream error timeout http_500 http_502 http_503 http_504;
proxy_next_upstream_tries 2;
proxy_next_upstream_timeout 10s;
proxy_pass_request_headers on;
proxy_hide_header X-Powered-By;
}
}
}
핵심 설정
max_fails=1 fail_timeout=3s– 한 번 실패하면 짧은 기간 동안 해당 upstream을 down 상태로 표시합니다.- 짧은
proxy_*_timeout값은 기본 풀에 문제가 있을 때 클라이언트가 오래 기다리지 않게 합니다. proxy_next_upstream와 재시도 설정을 통해 자동으로 백업 풀로 라우팅됩니다.
4. 선택적 Slack 워처
watcher/requirements.txt
requests==2.32.3
watcher/watcher.py
import json, os, time, requests
from collections import deque
from datetime import datetime, timezone
LOG_PATH = os.getenv("NGINX_LOG_FILE", "/var/log/nginx/access.json")
SLACK_WEBHOOK_URL = os.getenv("SLACK_WEBHOOK_URL", "")
SLACK_PREFIX = os.getenv("SLACK_PREFIX", "from: @Watcher")
ACTIVE_POOL = os.getenv("ACTIVE_POOL", "blue")
ERROR_RATE_THRESHOLD = float(os.getenv("ERROR_RATE_THRESHOLD", "2"))
WINDOW_SIZE = int(os.getenv("WINDOW_SIZE", "200"))
ALERT_COOLDOWN_SEC = int(os.getenv("ALERT_COOLDOWN_SEC", "300"))
MAINTENANCE_MODE = os.getenv("MAINTENANCE_MODE", "false").lower() == "true"
def now_iso():
return datetime.now(timezone.utc).isoformat()
def post_to_slack(message):
if not SLACK_WEBHOOK_URL:
return
payload = {"text": f"{SLACK_PREFIX} {message}"}
try:
requests.post(SLACK_WEBHOOK_URL, json=payload, timeout=5)
except Exception as e:
print(f"Slack post failed: {e}")
def parse_log_line(line):
try:
return json.loads(line)
except json.JSONDecodeError:
return None
def main():
recent = deque(maxlen=WINDOW_SIZE)
last_alert = 0
while True:
try:
with open(LOG_PATH, "r") as f:
# Seek to end and read new lines
f.seek(0, os.SEEK_END)
while True:
line = f.readline()
if not line:
time.sleep(0.5)
continue
entry = parse_log_line(line.strip())
if not entry:
continue
recent.append(entry)
# Detect failover: pool header changed from ACTIVE_POOL
if entry.get("pool") and entry["pool"] != ACTIVE_POOL:
now = time.time()
if now - last_alert > ALERT_COOLDOWN_SEC:
msg = f"Failover detected! Traffic switched from {ACTIVE_POOL} to {entry['pool']}"
post_to_slack(msg)
print(now_iso(), msg)
last_alert = now
except FileNotFoundError:
time.sleep(1)
except Exception as e:
print(f"Watcher error: {e}")
time.sleep(2)
if __name__ == "__main__":
main()
워처는 JSON 로그를 tail하면서 간단한 오류 비율 윈도우를 계산하고, 트래픽이 비활성 풀로 이동하거나 오류 비율이 설정된 임계값을 초과하면 Slack에 알림을 보냅니다.
5. 환경 변수 (.env)
# Choose which pool is primary (blue or green)
ACTIVE_POOL=blue
# Labels for the two app containers
APP_BLUE_POOL=blue
APP_GREEN_POOL=green
# Release identifiers (optional, useful for tracing)
RELEASE_ID_BLUE=2025-12-09-blue
RELEASE_ID_GREEN=2025-12-09-green
# Nginx upstream selector – will be substituted in the template
UPSTREAM_POOL=${ACTIVE_POOL}_pool
# Watcher settings (adjust as needed)
ERROR_RATE_THRESHOLD=2
WINDOW_SIZE=200
ALERT_COOLDOWN_SEC=300
# Slack webhook (leave empty to disable alerts)
SLACK_WEBHOOK_URL=
ACTIVE_POOL=blue인 경우, Nginx 템플릿은 UPSTREAM_POOL을 blue_pool로 치환해 Blue 서비스가 기본 upstream이 되도록 합니다.
6. Docker Compose 파일
version: "3.9"
services:
app-blue:
build: ./app
environment:
- APP_POOL=${APP_BLUE_POOL}
- RELEASE_ID=${RELEASE_ID_BLUE}
ports: [] # not exposed directly
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/healthz"]
interval: 5s
timeout: 2s
retries: 2
app-green:
build: ./app
environment:
- APP_POOL=${APP_GREEN_POOL}
- RELEASE_ID=${RELEASE_ID_GREEN}
ports: [] # not exposed directly
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/healthz"]
interval: 5s
timeout: 2s
retries: 2
nginx:
image: nginx:1.25-alpine
depends_on:
- app-blue
- app-green
ports:
- "8080:80"
volumes:
- ./nginx/nginx.conf.template:/etc/nginx/nginx.conf.template:ro
- ./nginx/log:/var/log/nginx
environment:
- ACTIVE_POOL=${ACTIVE_POOL}
- UPSTREAM_POOL=${UPSTREAM_POOL}
command: /bin/sh -c "envsubst '\$UPSTREAM_POOL' /etc/nginx/nginx.conf && nginx -g 'daemon off;'"
watcher:
build:
context: ./watcher
depends_on:
- nginx
volumes:
- ./nginx/log:/var/log/nginx
environment:
- ACTIVE_POOL=${ACTIVE_POOL}
- SLACK_WEBHOOK_URL=${SLACK_WEBHOOK_URL}
- ERROR_RATE_THRESHOLD=${ERROR_RATE_THRESHOLD}
- WINDOW_SIZE=${WINDOW_SIZE}
- ALERT_COOLDOWN_SEC=${ALERT_COOLDOWN_SEC}
# Remove this service if you don't need Slack alerts
nginx 서비스는 시작하기 전에 envsubst를 사용해 템플릿 안의 $UPSTREAM_POOL을 교체합니다.
7. 데모 실행
# Start everything
docker compose --env-file .env up -d
# Verify Nginx health endpoint
curl http://localhost:8080/healthz
# → should return "healthy"
# Call the application through the load balancer
curl http://localhost:8080/
기본적으로 Blue 풀의 X-App-Pool 헤더 정보가 포함된 JSON 응답을 확인할 수 있습니다.
장애 시뮬레이션
# Put the Blue app into chaos mode (force 500 errors)
curl -X POST "http://localhost:8080/chaos/start?mode=error"
# Or simulate a timeout
curl -X POST "http://localhost:8080/chaos/start?mode=timeout"
장애가 트리거되면 이후 http://localhost:8080/에 대한 요청은 Green 풀로 자동 전환됩니다. 이는 Nginx의 proxy_next_upstream 로직 덕분이며, 워처가 활성화돼 있다면 Slack에 장애 전환 알림이 전송됩니다.
Chaos를 중지하려면:
curl -X POST "http://localhost:8080/chaos/stop"
8. 활성 풀 전환
Green을 기본으로 만들고 싶지만 장애 전환을 일으키고 싶지 않을 경우, .env 파일을 수정합니다:
ACTIVE_POOL=green
그런 다음 Nginx(또는 전체 스택)를 재시작해 템플릿을 다시 생성합니다:
docker compose up -d --no-deps --build nginx
이제 새로운 트래픽은 Green이 먼저 라우팅되고, Blue는 대기 상태를 유지합니다.
9. 정리
docker compose down -v
-v 옵션은 Nginx 로그를 저장하던 익명 볼륨을 함께 삭제합니다.
10. 배운 내용
- 순수 Docker Compose만으로 구현하는 Blue/Green 패턴 – Kubernetes 불필요
- 즉시 장애 전환을 위한
max_fails,fail_timeout,proxy_next_upstream를 활용한 Nginx upstream 설정 - upstream 풀 정보를 헤더로 노출하는 구조화된 JSON 접근 로그
- 복원력을 테스트하기 위한 최소한의 chaos 인터페이스
- 로그 이벤트를 Slack 알림으로 전환하는 선택적 watcher
다른 언어로 패턴을 적용하거나 TLS 종료를 추가하고, CI/CD 파이프라인과 연동해 자동 릴리스를 구현해 보세요. 즐거운 배포 되세요!