![[시리즈 3/5] Docker + Blue/Green: 무중단 배포 구현하기](https://blog.flyerschal.com/uploads/posts/cicd-s3-cover.png)
[시리즈 3/5] Docker + Blue/Green: 무중단 배포 구현하기
[시리즈 3/5] Docker + Blue/Green: 무중단 배포 구현하기

이 글은 "Claude Code로 구축하는 무중단 자동 배포 시스템" 시리즈의 세 번째 글입니다.
- 무중단 자동 배포 시스템이란?
- GitHub + Webhook: Git 푸시로 배포 트리거하기
- [현재] Docker + Blue/Green: 무중단 배포 구현하기
- 텔레그램 알림 + 로그 전략
- Claude Code로 배포 시스템 구축하기
이 글에서 다루는 것
Webhook이 트리거되면 실행되는 deploy.sh의 모든 것을 다룹니다. Dockerfile 작성, docker-compose 구성, Blue/Green 전환 로직, 헬스체크, Nginx upstream 전환까지.
1단계: Dockerfile 작성
Multi-stage 빌드란?
Docker 이미지를 만들 때 빌드 도구(npm, typescript 등)가 최종 이미지에 포함되면 이미지 크기가 커지고, 보안 취약점이 늘어납니다.
Multi-stage 빌드는 빌드 단계와 실행 단계를 분리합니다:
# 빌드 단계 — 여기서 npm install, build 수행
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# 실행 단계 — 빌드 결과물만 복사
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# 최소한의 파일만 복사
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
이미지 크기 비교
| 방식 | 이미지 크기 | 포함 내용 |
|---|---|---|
| 단일 스테이지 | ~1.2GB | node_modules, 소스, 빌드 도구 전부 |
| Multi-stage | ~150MB | 실행 파일만 (standalone) |
10배 가까이 줄어듭니다. 디스크 절약은 물론, 배포 속도도 빨라집니다.
실전 Dockerfile (이 프로젝트에서 사용 중)
# ~/Documents/services/blog/Dockerfile
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# 시스템 사용자 생성 (root 실행 방지)
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# 빌드 결과물 복사 (호스트에서 빌드 완료 후)
COPY public ./public
COPY .next/standalone ./
COPY .next/static ./.next/static
# Prisma 엔진 복사
COPY prisma ./prisma
COPY src/generated/prisma ./src/generated/prisma
COPY package.json ./
RUN npm install prisma @prisma/client --save-prod 2>/dev/null || true
RUN npx prisma generate 2>/dev/null || true
# 업로드 디렉토리 생성
RUN mkdir -p /app/public/uploads && chown nextjs:nodejs /app/public/uploads
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
왜 호스트에서 빌드하나?
두 가지 접근이 있습니다:
| Docker 내부 빌드 | 호스트 빌드 + Docker 패키징 | |
|---|---|---|
| 장점 | 환경 일관성 | 빠른 빌드 (캐시 활용) |
| 단점 | 느림 (캐시 미활용) | 호스트 환경 의존 |
| 적합 | CI/CD 서버 | 단일 서버 배포 |
이 시스템에서는 호스트에서 빌드 후 결과물만 Docker 이미지에 넣습니다. npm 캐시를 활용할 수 있어 빌드가 훨씬 빠릅니다.
2단계: docker-compose.yml
# ~/Documents/services/blog/docker-compose.yml
services:
blog-blue:
image: fc-blog:latest
container_name: blog-blue
restart: unless-stopped
env_file:
- .env.production
ports:
- "14001:3000" # 호스트 14001 → 컨테이너 3000
networks:
- blog-net
blog-green:
image: fc-blog:latest
container_name: blog-green
restart: unless-stopped
env_file:
- .env.production
ports:
- "15001:3000" # 호스트 15001 → 컨테이너 3000
networks:
- blog-net
webhook:
build: ./webhook
container_name: blog-webhook
restart: unless-stopped
env_file:
- .env.production
ports:
- "9000:9000"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- .:/repo
- ../nginx/conf.d:/nginx-conf
networks:
- blog-net
networks:
blog-net:
driver: bridge
포트 설계
여러 프로젝트를 운영할 때 포트 충돌을 방지하는 규칙:
| 프로젝트 | Blue 포트 | Green 포트 |
|---|---|---|
| blog | 14001 | 15001 |
| home | 14002 | 15002 |
| ledger-api | 14003 | 15003 |
규칙: 14xxx = Blue, 15xxx = Green
3단계: deploy.sh (핵심 배포 스크립트)
#!/bin/bash
# Blue/Green 배포 스크립트
# Webhook 서버에서 자동 실행됨
SERVICE="blog"
BLOG_DIR="$(cd "$(dirname "$0")" && pwd)"
NGINX_CONF_DIR="${BLOG_DIR}/../nginx/conf.d"
BUILD_DIR="/tmp/fc-blog-build"
# 환경변수 로드
set -a
source "${BLOG_DIR}/.env.production"
set +a
REPO_URL="https://${GITHUB_TOKEN}@github.com/YourOrg/fc-blog.git"
# ========== 1. 현재 활성 환경 확인 ==========
CURRENT="blue"
if [ -f "$NGINX_CONF_DIR/active_blog_green" ]; then
CURRENT="green"
fi
if [ "$CURRENT" = "blue" ]; then
TARGET="green"
PORT=15001
else
TARGET="blue"
PORT=14001
fi
CONTAINER="blog-${TARGET}"
NETWORK="blog_blog-net"
echo "[$(date)] 배포 시작: ${CURRENT} → ${TARGET}"
# ========== 2. 소스 Clone ==========
rm -rf "$BUILD_DIR"
git clone --depth 1 "$REPO_URL" "$BUILD_DIR"
cd "$BUILD_DIR"
# ========== 3. 애플리케이션 빌드 ==========
npm install
DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy" npx prisma generate
npm run build
if [ $? -ne 0 ]; then
echo "[$(date)] 빌드 실패!"
rm -rf "$BUILD_DIR"
exit 1
fi
# ========== 4. Docker 이미지 빌드 ==========
cp "$BLOG_DIR/Dockerfile" "$BUILD_DIR/Dockerfile"
docker build -t fc-blog:latest "$BUILD_DIR"
if [ $? -ne 0 ]; then
echo "[$(date)] Docker 이미지 빌드 실패!"
rm -rf "$BUILD_DIR"
exit 1
fi
# 빌드 소스 삭제 (디스크 절약)
cd "$BLOG_DIR"
rm -rf "$BUILD_DIR"
# ========== 5. 새 컨테이너 시작 ==========
docker rm -f "$CONTAINER" 2>/dev/null || true
docker run -d \
--name "$CONTAINER" \
--restart unless-stopped \
--env-file "$BLOG_DIR/.env.production" \
--network "$NETWORK" \
-p "${PORT}:3000" \
fc-blog:latest
# nginx 네트워크에도 연결
docker network connect nginx_default "$CONTAINER" 2>/dev/null || true
# ========== 6. 헬스체크 (최대 60초) ==========
HEALTH_OK=false
for i in $(seq 1 30); do
if curl -sf "http://localhost:${PORT}" > /dev/null 2>&1; then
HEALTH_OK=true
echo "[$(date)] 헬스체크 통과 (${i}번째 시도)"
break
fi
sleep 2
done
if [ "$HEALTH_OK" = false ]; then
docker stop "$CONTAINER"
echo "[$(date)] 헬스체크 실패! ${CURRENT} 유지"
exit 1
fi
# ========== 7. Nginx 트래픽 전환 ==========
if [ "$TARGET" = "green" ]; then
touch "$NGINX_CONF_DIR/active_blog_green"
else
rm -f "$NGINX_CONF_DIR/active_blog_green"
fi
docker exec nginx nginx -s reload
echo "[$(date)] 전환 완료: ${CURRENT} → ${TARGET}"
# ========== 8. 이전 환경 정지 ==========
docker stop "blog-${CURRENT}" 2>/dev/null || true
# ========== 9. 정리 ==========
docker image prune -f > /dev/null 2>&1
echo "[$(date)] 배포 완료! 활성: ${TARGET} (port:${PORT})"
deploy.sh 상세 설명
1. 활성 환경 감지
Nginx 설정 디렉토리에 active_blog_green 파일이 있으면 Green이 활성입니다. 없으면 Blue가 활성입니다. 이 파일의 존재 여부로 Blue/Green을 판단합니다.
# Nginx 설정에서 이 파일을 체크
if (-f /etc/nginx/conf.d/active_blog_green) {
set $backend "blog-green";
}
2. Shallow Clone
--depth 1 옵션으로 최신 커밋만 가져옵니다. 전체 히스토리가 필요 없으므로 네트워크와 시간을 절약합니다.
3. 헬스체크 로직
2초 간격으로 최대 30번(60초) 시도합니다. Next.js 앱이 시작되는 데 보통 3~10초 소요되므로 충분한 시간입니다.
헬스체크가 실패하면:
- 새 컨테이너를 정지
- 이전 환경을 유지 (자동 롤백)
- 에러 코드 1로 종료
4단계: Nginx Blue/Green 설정
server {
listen 443 ssl;
server_name blog.your-domain.com;
# SSL 인증서
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
# Blue/Green 라우팅
set $backend "blog-blue";
if (-f /etc/nginx/conf.d/active_blog_green) {
set $backend "blog-green";
}
# Docker DNS resolver
resolver 127.0.0.11 valid=10s;
location / {
proxy_pass http://$backend:3000;
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;
}
# 현재 활성 환경 확인용
location /health {
default_type text/plain;
return 200 $backend;
}
}
resolver 127.0.0.11
Docker의 내장 DNS 서버 주소입니다. Nginx에서 변수($backend)를 사용한 proxy_pass는 시작 시에만 DNS를 resolve합니다. resolver를 설정하면 10초마다 다시 resolve하여, 컨테이너가 재생성되어도 새 IP를 찾을 수 있습니다.
전환 원리
파일 없음 → $backend = "blog-blue" → http://blog-blue:3000
파일 있음 → $backend = "blog-green" → http://blog-green:3000
deploy.sh가 touch active_blog_green 후 nginx -s reload를 실행하면, Nginx가 graceful reload됩니다. 진행 중인 요청은 완료될 때까지 기다리고, 새 요청부터 새 backend로 전달됩니다.
다운타임: 0
5단계: 동시 배포 방지
두 번의 push가 거의 동시에 발생하면, deploy.sh가 동시에 2번 실행될 수 있습니다. 이를 방지합니다:
# deploy.sh 시작 부분에 추가
LOCK_FILE="/tmp/${SERVICE}-deploy.lock"
if [ -f "$LOCK_FILE" ]; then
echo "[$(date)] 이미 배포 진행 중, 스킵"
exit 0
fi
# 스크립트 종료 시 자동으로 lock 해제
trap "rm -f $LOCK_FILE" EXIT
touch "$LOCK_FILE"
6단계: 디스크 관리
매번 Docker 이미지를 빌드하면 오래된 이미지가 쌓입니다:
# deploy.sh 마지막에 추가
docker image prune -f > /dev/null 2>&1
# 더 공격적인 정리 (72시간 이상 된 이미지)
docker image prune -f --filter "until=72h" > /dev/null 2>&1
디스크 사용량 모니터링
# 현재 Docker 디스크 사용량
docker system df
# 상세
docker system df -v
⚠️ 실제 경험: 디스크 부족으로 Docker 데몬이 응답하지 않는 상황이 발생할 수 있습니다. 배포 스크립트에 이미지 정리를 반드시 포함하세요.
전체 배포 소요 시간
| 단계 | 소요 시간 |
|---|---|
| git clone (shallow) | ~3초 |
| npm install (캐시 있을 때) | ~10초 |
| npm run build | ~15초 |
| Docker 이미지 빌드 | ~50초 |
| 컨테이너 시작 + 헬스체크 | ~5초 |
| Nginx 전환 | ~1초 |
| 총 소요 | ~1분 30초 |
이 단계에서 완성된 것
✅ Multi-stage Dockerfile ✅ docker-compose.yml (Blue/Green + Webhook) ✅ deploy.sh (전체 배포 로직) ✅ Nginx Blue/Green 전환 설정 ✅ 동시 배포 방지 (Lock file) ✅ 디스크 관리 (Image prune)
다음 글에서는 배포 과정을 텔레그램으로 실시간 알림받는 방법과, 로그를 구조화하여 Claude Code로 분석하는 전략을 다룹니다.

