CI/CD 안정성 강화: 동시 배포 방지, 자기 재빌드 제거, 헬스체크 추가

- deploy.sh: flock으로 동시 배포 방지, deployer를 빌드 대상에서 제외
- deploy.sh: 배포 후 헬스체크 (4개 서비스 /health 확인)
- deploy.sh: 릴리즈 백업 최근 5개만 유지, 원자적 백업 (mv)
- deploy-nas.sh: .env 동기화 제거 (운영 시크릿 보호), __pycache__ 제외
- deployer: threading.Lock으로 동시 배포 방어, TimeoutExpired 개별 처리
- docker-compose: deployer 포트 localhost 바인딩, stock-lab 환경변수 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-03 01:20:25 +09:00
parent ff975defbd
commit 6a1a2c4552
4 changed files with 65 additions and 45 deletions

View File

@@ -1,14 +1,18 @@
import os, hmac, hashlib, subprocess import os, hmac, hashlib, subprocess, threading
from fastapi import FastAPI, Request, HTTPException, BackgroundTasks from fastapi import FastAPI, Request, HTTPException, BackgroundTasks
import logging import logging
# 로깅 설정 logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s")
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("deployer") logger = logging.getLogger("deployer")
app = FastAPI() app = FastAPI()
SECRET = os.getenv("WEBHOOK_SECRET", "") SECRET = os.getenv("WEBHOOK_SECRET", "")
if not SECRET:
logger.warning("WEBHOOK_SECRET is not set! All webhooks will be rejected.")
_deploy_lock = threading.Lock()
def verify(sig: str, body: bytes) -> bool: def verify(sig: str, body: bytes) -> bool:
if not SECRET or not sig: if not SECRET or not sig:
return False return False
@@ -18,10 +22,13 @@ def verify(sig: str, body: bytes) -> bool:
return any(hmac.compare_digest(sig, c) for c in candidates) return any(hmac.compare_digest(sig, c) for c in candidates)
def run_deploy_script(): def run_deploy_script():
"""배포 스크립트를 백그라운드에서 실행하고 로그를 남김""" """배포 스크립트를 백그라운드에서 실행 (동시 실행 방지)"""
logger.info("Starting deployment script...") if not _deploy_lock.acquire(blocking=False):
logger.info("Deploy already in progress, skipping")
return
try: try:
# 타임아웃 10분 설정 logger.info("Starting deployment script...")
p = subprocess.run(["/bin/bash", "/scripts/deploy.sh"], capture_output=True, text=True, timeout=600) p = subprocess.run(["/bin/bash", "/scripts/deploy.sh"], capture_output=True, text=True, timeout=600)
if p.returncode == 0: if p.returncode == 0:
@@ -29,8 +36,12 @@ def run_deploy_script():
else: else:
logger.error(f"Deployment FAILED ({p.returncode}):\n{p.stdout}\n{p.stderr}") logger.error(f"Deployment FAILED ({p.returncode}):\n{p.stdout}\n{p.stderr}")
except subprocess.TimeoutExpired:
logger.error("Deployment TIMEOUT (10 min exceeded)")
except Exception as e: except Exception as e:
logger.exception(f"Exception during deployment: {e}") logger.exception(f"Exception during deployment: {e}")
finally:
_deploy_lock.release()
@app.get("/health") @app.get("/health")
def health(): def health():

View File

@@ -31,6 +31,9 @@ services:
- WINDOWS_AI_SERVER_URL=${WINDOWS_AI_SERVER_URL:-http://192.168.0.5:8000} - WINDOWS_AI_SERVER_URL=${WINDOWS_AI_SERVER_URL:-http://192.168.0.5:8000}
- GEMINI_API_KEY=${GEMINI_API_KEY:-} - GEMINI_API_KEY=${GEMINI_API_KEY:-}
- GEMINI_MODEL=${GEMINI_MODEL:-gemini-1.5-flash} - GEMINI_MODEL=${GEMINI_MODEL:-gemini-1.5-flash}
- ADMIN_API_KEY=${ADMIN_API_KEY:-}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
volumes: volumes:
- ${STOCK_DATA_PATH:-./data/stock}:/app/data - ${STOCK_DATA_PATH:-./data/stock}:/app/data
@@ -45,6 +48,7 @@ services:
- TZ=${TZ:-Asia/Seoul} - TZ=${TZ:-Asia/Seoul}
- MUSIC_AI_SERVER_URL=${MUSIC_AI_SERVER_URL:-} - MUSIC_AI_SERVER_URL=${MUSIC_AI_SERVER_URL:-}
- MUSIC_MEDIA_BASE=${MUSIC_MEDIA_BASE:-/media/music} - MUSIC_MEDIA_BASE=${MUSIC_MEDIA_BASE:-/media/music}
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
volumes: volumes:
- ${MUSIC_DATA_PATH:-./data/music}:/app/data - ${MUSIC_DATA_PATH:-./data/music}:/app/data
@@ -88,7 +92,7 @@ services:
container_name: webpage-deployer container_name: webpage-deployer
restart: unless-stopped restart: unless-stopped
ports: ports:
- "19010:9000" # 외부 노출 필요 없으면 내부만 (리버스프록시로 /webhook만 노출 추천) - "127.0.0.1:19010:9000" # localhost만 허용 (nginx /webhook 프록시 경유)
environment: environment:
- WEBHOOK_SECRET=${WEBHOOK_SECRET} - WEBHOOK_SECRET=${WEBHOOK_SECRET}
volumes: volumes:

View File

@@ -30,33 +30,14 @@ cd "$SRC"
# 레포에서 운영으로 반영할 항목들만 복사/동기화 (필요한 것만 적기) # 레포에서 운영으로 반영할 항목들만 복사/동기화 (필요한 것만 적기)
# backend, travel-proxy, deployer, nginx, scripts, docker-compose.yml, .env 등 # backend, travel-proxy, deployer, nginx, scripts, docker-compose.yml, .env 등
rsync -a --delete \ RSYNC_EXCLUDES="--exclude .git --exclude __pycache__ --exclude *.pyc --exclude data/"
--exclude ".git" \
--exclude ".releases" \
"$SRC/backend/" "$DST/backend/"
rsync -a --delete \
--exclude ".git" \
"$SRC/travel-proxy/" "$DST/travel-proxy/"
rsync -a --delete \
--exclude ".git" \
"$SRC/deployer/" "$DST/deployer/"
rsync -a --delete \
--exclude ".git" \
"$SRC/stock-lab/" "$DST/stock-lab/"
rsync -a --delete \
--exclude ".git" \
"$SRC/music-lab/" "$DST/music-lab/"
rsync -a --delete \
--exclude ".git" \
"$SRC/nginx/" "$DST/nginx/"
rsync -a --delete \
--exclude ".git" \
"$SRC/scripts/" "$DST/scripts/"
# compose 파일 / env 파일 for dir in backend travel-proxy deployer stock-lab music-lab nginx scripts; do
rsync -a --delete $RSYNC_EXCLUDES \
"$SRC/$dir/" "$DST/$dir/"
done
# compose 파일만 동기화 (.env는 절대 동기화하지 않음 — 운영 시크릿 보호)
rsync -a "$SRC/docker-compose.yml" "$DST/docker-compose.yml" rsync -a "$SRC/docker-compose.yml" "$DST/docker-compose.yml"
if [ -f "$SRC/.env" ]; then
rsync -a "$SRC/.env" "$DST/.env"
fi
echo "SYNC_OK" echo "SYNC_OK"

View File

@@ -1,6 +1,10 @@
#!/bin/bash #!/bin/bash
set -euo pipefail set -euo pipefail
# ── 동시 배포 방지 (flock) ──
exec 200>/tmp/deploy.lock
flock -n 200 || { echo "Deploy already running, skipping"; exit 0; }
# 1. 자동 감지: Docker 컨테이너 내부인가? # 1. 자동 감지: Docker 컨테이너 내부인가?
if [ -d "/repo" ] && [ -d "/runtime" ]; then if [ -d "/repo" ] && [ -d "/runtime" ]; then
echo "Detected Docker Container environment." echo "Detected Docker Container environment."
@@ -13,9 +17,8 @@ else
set -a; source .env; set +a set -a; source .env; set +a
fi fi
# 환경변수가 없으면 현재 디렉토리를 SRC로
SRC="${REPO_PATH:-$(pwd)}" SRC="${REPO_PATH:-$(pwd)}"
DST="${RUNTIME_PATH:-/volume1/docker/webpage}" # 기본값 설정 DST="${RUNTIME_PATH:-/volume1/docker/webpage}"
if [ -z "$DST" ]; then if [ -z "$DST" ]; then
echo "Error: RUNTIME_PATH is not set." echo "Error: RUNTIME_PATH is not set."
@@ -32,19 +35,40 @@ cd "$SRC"
git fetch --all --prune git fetch --all --prune
git pull --ff-only git pull --ff-only
# 릴리즈 백업(롤백용): 아래 5번과 연결 # ── 릴리즈 백업 (롤백용) ──
TAG="$(date +%Y%m%d-%H%M%S)" TAG="$(date +%Y%m%d-%H%M%S)"
mkdir -p "$DST/.releases/$TAG" BACKUP_DIR="$DST/.releases/$TAG"
mkdir -p "$BACKUP_DIR.tmp"
rsync -a --delete \ rsync -a --delete \
--exclude ".releases" \ --exclude ".releases" \
"$DST/" "$DST/.releases/$TAG/" "$DST/" "$BACKUP_DIR.tmp/"
mv "$BACKUP_DIR.tmp" "$BACKUP_DIR"
# 소스 → 운영 반영 (네가 이미 만든 deploy-nas.sh가 있으면 그걸 호출해도 됨) # 오래된 릴리즈 정리 (최근 5개만 유지)
# 예: repo/scripts/deploy-nas.sh가 운영으로 복사/동기화하는 로직이라면: ls -dt "$DST/.releases"/*/ 2>/dev/null | tail -n +6 | xargs -r rm -rf
# ── 소스 → 운영 반영 ──
bash "$SRC/scripts/deploy-nas.sh" bash "$SRC/scripts/deploy-nas.sh"
# ── 컨테이너 재빌드 (deployer 제외 — 자기 자신을 재빌드하면 스크립트 중단됨) ──
cd "$DST" cd "$DST"
docker compose up -d --build backend travel-proxy stock-lab frontend deployer docker compose up -d --build backend travel-proxy stock-lab music-lab frontend
docker exec lotto-frontend nginx -s reload 2>/dev/null || true docker exec lotto-frontend nginx -s reload 2>/dev/null || true
# ── 배포 후 헬스체크 ──
echo "Waiting for services to start..."
sleep 5
HEALTH_OK=true
for endpoint in "http://backend:8000/health" "http://stock-lab:8000/health" "http://travel-proxy:8000/health" "http://music-lab:8000/health"; do
if ! curl -sf --max-time 5 "$endpoint" > /dev/null 2>&1; then
echo "HEALTH_FAIL: $endpoint"
HEALTH_OK=false
fi
done
if [ "$HEALTH_OK" = false ]; then
echo "DEPLOY_WARN: Some services failed health check. Consider rollback: $TAG"
else
echo "DEPLOY_OK $TAG" echo "DEPLOY_OK $TAG"
fi