Nginx Auto-Failover를 사용한 Blue/Green 배포 구축

발행: (2025년 12월 10일 오전 12:54 GMT+9)
10 min read
원문: Dev.to

Source: Dev.to

소개

Blue/Green 배포는 애플리케이션의 두 개 동일한 인스턴스(Blue와 Green)를 실행하고, 문제가 발생했을 때 트래픽을 활성 인스턴스에서 대기 인스턴스로 즉시 전환할 수 있게 해줍니다. 이 가이드에서는 Nginx를 트래픽 디렉터로 사용하고, 두 풀에 대한 작은 Node.js 서비스와, Nginx의 JSON 로그를 읽어 Slack에 알림을 보내는 선택적인 Python 워처를 이용해 완전한 컨테이너 기반 Blue/Green 설정을 구축합니다.

사전 요구 사항

항목이유
Docker + Docker ComposeKubernetes 없이 로컬에서 서비스 실행
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"]

BlueGreen 컨테이너는 모두 이 이미지에서 빌드되며, 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_POOLblue_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 파이프라인과 연동해 자동 릴리스를 구현해 보세요. 즐거운 배포 되세요!

Back to Blog

관련 글

더 보기 »