서버를 재배포할 때마다 SSE 연결이 끊기고 일시적으로 502가 떴습니다. 투자킹(twojaking.com)은 코인 가격 실시간 스트리밍을 제공하기 때문에 배포 순간의 연결 단절이 사용자 경험에 직접 영향을 줬습니다.
Kubernetes나 ECS 없이, AWS Lightsail 단일 서버에서 추가 비용 없이 무중단 배포를 구현하는 방법을 정리합니다. 전체 구조는 FastAPI + Docker Compose + Nginx이며, 이 글에 나오는 코드는 실제 운영 중인 코드입니다.
전체 아키텍처
외부 트래픽
↓
Cloudflare Tunnel (QUIC)
↓
Nginx (로드밸런싱)
├─ api-0:8000 (FastAPI, WORKER_ID=0)
└─ api-1:8001 (FastAPI, WORKER_ID=1)
백그라운드
├─ worker (뉴스 수집·AI 분석)
├─ redis (작업 큐, 캐시)
└─ infinity (임베딩 서버, 포트 7997)
핵심은 API 서버를 두 개 유지하는 것입니다. 배포 시 한쪽씩 순서대로 재시작하면 Nginx가 살아있는 서버로만 트래픽을 흘립니다.
SSL 종료는 Cloudflare Tunnel이 처리하므로 Nginx는 HTTP만 다룹니다. 인증서 갱신 문제가 없어지는 부수 효과도 있습니다.
docker-compose.yml
서비스 구성 전체를 먼저 파악하는 것이 중요합니다.
name: twojaking_be
services:
api-0:
build:
context: ..
dockerfile: docker/Dockerfile
container_name: twojaking-api-0
restart: always
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1 --log-level info
env_file: ../.env
environment:
- WORKER_ID=0
- REDIS_URL=redis://redis:6379/0
expose:
- "8000"
depends_on:
- redis
networks:
- backend
api-1:
build:
context: ..
dockerfile: docker/Dockerfile
container_name: twojaking-api-1
restart: always
command: uvicorn app.main:app --host 0.0.0.0 --port 8001 --workers 1 --log-level info
env_file: ../.env
environment:
- WORKER_ID=1
- REDIS_URL=redis://redis:6379/0
expose:
- "8001"
depends_on:
- redis
networks:
- backend
worker:
build:
context: ..
dockerfile: docker/Dockerfile
container_name: twojaking-worker
restart: always
command: python -m app.worker
env_file: ../.env
environment:
- REDIS_URL=redis://redis:6379/0
depends_on:
- redis
networks:
- backend
redis:
image: redis:7-alpine
container_name: twojaking-redis
restart: always
volumes:
- redis_data:/data
networks:
- backend
nginx:
image: nginx:alpine
container_name: twojaking-nginx
restart: always
volumes:
- ./nginx/twojaking.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- api-0
- api-1
networks:
- backend
cloudflared:
image: cloudflare/cloudflared@sha256:6b599ca3...
container_name: twojaking-cloudflared
restart: always
env_file: ../.env
environment:
- TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN}
command: tunnel --no-autoupdate --protocol quic run
depends_on:
- api-0
- api-1
networks:
- backend
infinity:
build:
context: .
dockerfile: Dockerfile.infinity
container_name: twojaking-infinity
restart: always
volumes:
- infinity_models:/app/.cache
ports:
- "7997:7997"
networks:
- backend
volumes:
redis_data:
infinity_models:
networks:
backend:
driver: bridge
설계 포인트 몇 가지:
exposevsports:expose는 Docker 내부 네트워크에서만 접근 가능합니다.ports로 바꾸면 호스트 외부에 직접 노출됩니다. api-0, api-1, nginx는 Cloudflare Tunnel을 통해서만 외부와 통신하므로expose로 충분합니다.WORKER_ID환경변수: 각 API 인스턴스가 자신의 식별자를 알아야 하는 경우(예: 특정 노드에서만 실행할 작업 분기)에 사용합니다.infinity는 텍스트 임베딩 전용 서버로, API 서버와 분리되어 있어 배포 시 재시작하지 않습니다.cloudflared이미지는 SHA256으로 고정합니다.latest를 쓰면 이미지가 예고 없이 바뀌어 장애가 날 수 있습니다.
로컬 개발 환경 분리
로컬에서는 nginx와 certbot이 필요 없습니다. docker-compose.local.yml로 override합니다.
# docker-compose.local.yml
services:
api-0:
ports:
- "8000:8000"
api-1:
ports:
- "8001:8001"
nginx:
profiles:
- prod
certbot:
profiles:
- prod
# 로컬 실행
docker compose -f docker/docker-compose.yml -f docker/docker-compose.local.yml up -d
# 프로덕션 배포
bash docker/scripts/deploy.sh
Nginx 설정
docker/nginx/twojaking.conf 전체 구조입니다.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# Rate Limiting: IP당 초당 30요청
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
limit_conn_zone $binary_remote_addr zone=conn:10m;
upstream twojaking_backend {
least_conn;
server api-0:8000 max_fails=1 fail_timeout=10s;
server api-1:8001 max_fails=1 fail_timeout=10s;
keepalive 32;
}
server {
listen 80;
listen [::]:80;
server_name api.twojaking.com;
access_log /var/log/nginx/twojaking-access.log;
error_log /var/log/nginx/twojaking-error.log;
# docs 외부 차단
location ~ ^/api/v1/(docs|redoc|openapi\.json) {
deny all;
return 403;
}
# SSE 전용: proxy_buffering off 필수
location ~ ^/api/v1/(coins/stream|user/portfolio/stream|users/[^/]+/portfolio/stream|btc-dashboard/stream) {
limit_req zone=api burst=100 nodelay;
limit_conn conn 50;
proxy_pass http://twojaking_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Connection "";
proxy_connect_timeout 60s;
proxy_send_timeout 3600s;
proxy_read_timeout 3600s;
proxy_buffering off;
proxy_cache off;
}
location / {
limit_req zone=api burst=100 nodelay;
limit_conn conn 50;
proxy_pass http://twojaking_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_connect_timeout 60s;
proxy_send_timeout 3600s;
proxy_read_timeout 3600s;
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
}
# 헬스체크: 타임아웃 짧게, 로그 제외
location /health {
proxy_pass http://twojaking_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
access_log off;
proxy_connect_timeout 2s;
proxy_send_timeout 2s;
proxy_read_timeout 2s;
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
}
client_max_body_size 10M;
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml text/javascript application/json application/javascript;
}
각 설정의 이유:
least_conn: round_robin은 요청 수 기준으로 분산합니다. SSE처럼 하나의 연결이 수십 분간 유지되는 경우, round_robin은 연결 수 불균형이 심해집니다. least_conn은 현재 활성 연결이 적은 서버로 보내므로 SSE 환경에서 더 균등합니다.
max_fails=1 fail_timeout=10s: 한 번만 실패해도 10초간 해당 서버를 제외합니다. 배포 중 재시작하는 순간을 빠르게 감지하기 위해 max_fails를 1로 설정했습니다.
proxy_next_upstream: 요청을 보낸 서버가 502를 반환하면 자동으로 다른 서버로 재시도합니다. 배포 중 재시작 타이밍에 유입된 요청이 클라이언트에게는 정상 응답으로 전달됩니다.
SSE에서 proxy_buffering off 필수: Nginx는 기본적으로 업스트림 응답을 버퍼에 모았다가 전달합니다. SSE는 청크 단위로 끊임없이 데이터를 push하는 구조인데, 버퍼링이 켜져 있으면 클라이언트가 데이터를 실시간으로 받지 못합니다. proxy_buffering off는 SSE location에만 적용하고 일반 API에는 적용하지 않습니다.
/health 전용 location: 헬스체크는 타임아웃을 2초로 짧게 설정했습니다. 일반 API의 3600초 타임아웃을 그대로 쓰면 헬스체크가 느린 서버를 제때 감지하지 못합니다. 또한 access_log off로 헬스체크 폴링 로그를 제외해 노이즈를 줄입니다.
헬스체크 엔드포인트
배포 스크립트에서 서버 기동을 확인하려면 헬스체크 엔드포인트가 있어야 합니다.
from fastapi import APIRouter
router = APIRouter()
@router.get("/health/ready")
async def health_ready():
return {"status": "ok"}
단순히 200을 반환합니다. DB 연결 체크를 추가하면 더 정확하지만, DB가 일시적으로 느릴 때 배포가 실패할 수 있습니다. 운영 초기에는 단순 응답으로 시작하는 편이 안전합니다.
배포 스크립트에서 컨테이너 내부에서 직접 호출합니다.
docker exec twojaking-api-0 curl -fs http://localhost:8000/api/v1/health/ready
무중단 배포 스크립트 (deploy.sh)
docker/scripts/deploy.sh 전체입니다.
#!/bin/bash
set -e
# Linux에서 QUIC 성능을 위한 UDP 버퍼 크기 설정
if [[ "$(uname)" == "Linux" ]]; then
sysctl -w net.core.rmem_max=7340032 > /dev/null 2>&1 || true
sysctl -w net.core.wmem_max=7340032 > /dev/null 2>&1 || true
fi
COMPOSE="docker compose -f docker/docker-compose.yml --env-file .env"
START_TIME=$(date +%s)
# Step 1: 이미지 빌드
echo "Step 1/4: 이미지 빌드 중..."
$COMPOSE build --quiet
# Step 2: api-0 재시작
echo "Step 2/4: api-0 재시작 중..."
$COMPOSE up -d --no-deps api-0
sleep 3
if docker exec twojaking-api-0 curl -fs http://localhost:8000/api/v1/health/ready > /dev/null 2>&1; then
echo "api-0 정상"
else
echo "api-0 헬스체크 실패"
$COMPOSE logs --tail=20 api-0
exit 1
fi
# Step 3: api-1 재시작
echo "Step 3/4: api-1 재시작 중..."
$COMPOSE up -d --no-deps api-1
sleep 3
if docker exec twojaking-api-1 curl -fs http://localhost:8001/api/v1/health/ready > /dev/null 2>&1; then
echo "api-1 정상"
else
echo "api-1 헬스체크 실패 (api-0으로 운영 중)"
fi
# Step 4: worker 재시작
echo "Step 4/4: worker 재시작 중..."
$COMPOSE up -d --no-deps worker
sleep 2
if $COMPOSE ps worker | grep -q "Up"; then
echo "worker 정상"
else
echo "worker 재시작 실패"
$COMPOSE logs --tail=20 worker
exit 1
fi
END_TIME=$(date +%s)
ELAPSED=$((END_TIME - START_TIME))
echo "배포 완료! (${ELAPSED}초)"
echo "Commit: $(git rev-parse --short HEAD)"
echo ""
echo "로그 확인:"
echo " docker compose -f docker/docker-compose.yml logs -f api-0"
echo " docker compose -f docker/docker-compose.yml logs -f worker"
--no-deps가 핵심입니다. 이 옵션 없이 docker compose up -d api-0을 실행하면 depends_on에 정의된 Redis도 함께 재시작됩니다. Redis가 재시작되면 캐시와 작업 큐가 초기화됩니다. --no-deps는 지정한 서비스만 재시작합니다.
배포 흐름을 시각화하면 다음과 같습니다.
[배포 시작]
↓
이미지 빌드 (api-0, api-1 동시)
↓
api-0 재시작 → 3초 대기 → 헬스체크
(이 순간 트래픽: api-1 단독 처리)
↓ 헬스체크 실패 시 exit 1
api-1 재시작 → 3초 대기 → 헬스체크
(이 순간 트래픽: api-0 단독 처리)
↓
worker 재시작 → 2초 대기 → 상태 확인
(worker는 외부 트래픽과 무관)
↓
[배포 완료 + 소요 시간 출력]
api-1 헬스체크 실패는 exit 1을 하지 않습니다. api-0이 정상이면 서비스는 유지되기 때문입니다. api-0 헬스체크 실패는 즉시 중단합니다.
worker는 뉴스 수집, AI 분석 등 백그라운드 작업을 담당합니다. 잠깐 재시작해도 외부 사용자에게 영향이 없으므로 API와 독립적으로 마지막에 처리합니다.
실제 다운타임
이론적으로는 0이지만, SSE 연결이 끊기는 순간이 있습니다.
api-0이 재시작하는 약 3초 동안, api-0에 연결된 SSE 클라이언트는 연결이 끊깁니다. Nginx의 proxy_next_upstream은 새로 들어오는 요청을 api-1로 보내지만, 이미 맺어진 SSE 스트림은 전환하지 않습니다.
클라이언트 측 대응이 필요합니다. 브라우저의 EventSource는 연결이 끊기면 기본적으로 약 3초 후 자동 재연결을 시도합니다. 별도 처리 없이도 잠깐 끊겼다가 복구됩니다.
완전한 무중단(기존 SSE 연결 유지 포함)을 원하면 Kubernetes 롤링 업데이트나 AWS ECS Blue/Green 배포를 써야 합니다. 1인 개발, 단일 서버 환경에서는 이 구조로 운영 비용 없이 충분한 수준을 달성할 수 있습니다.
운영 팁
로그 확인
# 전체 실시간
docker compose -f docker/docker-compose.yml logs -f
# api-0만
docker logs -f twojaking-api-0
# worker만
docker logs -f twojaking-worker
nginx/cloudflared 문제 시 수동 복구
# nginx 설정 확인
docker exec twojaking-nginx nginx -t
# nginx 리로드 (무중단)
docker exec twojaking-nginx nginx -s reload
# cloudflared 재시작
docker compose -f docker/docker-compose.yml restart cloudflared
서비스 전체 상태 확인
docker compose -f docker/docker-compose.yml ps
NAME STATUS
twojaking-api-0 Up 2 hours
twojaking-api-1 Up 2 hours
twojaking-worker Up 2 hours
twojaking-redis Up 2 hours
twojaking-nginx Up 2 hours
twojaking-cloudflared Up 2 hours
twojaking-infinity Up 2 hours