Docker Compose 블루 그린

처음엔 그냥 배포 명령 하나 바꾸면 될 줄 알았다. 기존에는 서버에서 매번 이렇게 배포하고 있었다.
docker compose pull && docker compose down && docker compose up -d문제는 FastAPI 컨테이너가 내려가는 시간이 생각보다 길었다는 거다. 몇 초든 십몇 초든, 라이선스 API 입장에서는 그 시간 동안 그냥 먹통이다.처음 착각한 것
처음에는 이걸로 충분할 줄 알았다.
docker compose pull license-api
docker compose up -d --no-deps license-apidocker compose down보다는 낫다. 전체 스택을 다 내리지 않고 license-api만 갈아끼우니까. 그런데 지금 구조에서는 FastAPI 컨테이너가 직접 8991 포트를 잡고 있었다.services:
license-api:
ports:
- "8991:8000"이러면 새 컨테이너도 결국 같은 8991을 잡아야 한다. 기존 컨테이너가 포트를 놓기 전에는 새 컨테이너가 못 뜬다. 결국 FastAPI가 내려갔다 올라오는 시간은 남는다.여기서 좀 허무했다. 명령어만 바꾸면 해결될 줄 알았는데, 포트 구조가 문제였던 거다.
중간에 blue-green을 너무 쉽게 봤다
blue-green 배포는 말로 하면 쉽다.
blue 실행 중
green 새 버전 실행
green /health 확인
nginx upstream 전환
blue 종료그런데 실제로 하려니 생각보다 귀찮았다. 지금 active가 blue인지 green인지 알아야 하고, nginx 설정도 바꿔야 하고, 전환 후 old 컨테이너를 언제 죽일지도 정해야 했다.처음엔 gateway nginx가 직접 FastAPI blue/green 포트를 보면 되지 않나 싶었다.
gateway nginx
-> host:8991
-> host:8992근데 그러면 배포할 때마다 gateway nginx 설정을 건드려야 한다. gateway는 외부 진입점이고, 도메인/SSL/라우팅이 걸려있는 쪽이라 앱 배포할 때마다 만지는 게 영 별로였다.결국 구조를 한 번 더 꺾었다.
gateway nginx
-> app host nginx:8991
-> FastAPI blue
-> FastAPI greennginx가 두 번이라 처음엔 좀 이상했는데, 역할을 나누면 꽤 자연스럽다. gateway nginx는 외부 진입점이고, app host nginx는 blue/green 전환용 내부 라우터다.host 쪽 compose를 따로 만들었다
기존 docker-compose.yml은 실서비스가 이미 쓰고 있었다. 바로 갈아엎으면 복구도 귀찮고, 8991 포트 인계하다가 꼬일 수 있었다.
그래서 blue-green용 compose를 따로 만들었다.
services:
license-router:
image: nginx:1.27-alpine
container_name: license-router
ports:
- "8991:80"
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./nginx/license-active.conf:/etc/nginx/license-active.conf:ro
license-api-blue:
image: [이미지]
expose:
- "8000"
env_file:
- .env
stop_grace_period: 30s
license-api-green:
image: [이미지]
expose:
- "8000"
env_file:
- .env
stop_grace_period: 30sFastAPI는 더 이상 외부 포트를 열지 않는다. license-router만 8991을 잡고, FastAPI는 Docker 내부 네트워크에서 8000으로만 받는다.초기 전환은 딱 한 번 짧게 끊겼다.
./deploy-bluegreen.sh init
docker compose stop license-api
docker compose -f docker-compose.bluegreen.yml up -d --no-deps license-router
curl -f http://localhost:8991/health여기까지 하고 나면 구조가 이렇게 바뀐다.gateway nginx -> host:8991 -> license-router -> license-api-bluegateway nginx는 안 건드렸다.배포 스크립트
그 다음부터는 deploy만 치게 만들었다.
./deploy-bluegreen.sh deploy스크립트가 하는 일은 이렇다.현재 active 확인
inactive slot pull
inactive slot up
/health 확인
nginx active upstream 파일 변경
nginx reload
old slot 30초 drain
old slot stop처음에는 drain을 180초로 잡았는데, 막상 실행해보니까 너무 길었다.Switched license-router to green
Draining old slot blue for 180s여기서 한참 멈춘 것처럼 보인다. 실제로는 이미 트래픽 전환이 끝났고, old slot을 늦게 죽이려고 기다리는 중이었다. 그래도 180초는 과했다. API 요청이 대부분 짧은데 굳이 그렇게 오래 기다릴 이유가 없었다.기본값을 30초로 줄였다.
DRAIN_SECONDS="${DRAIN_SECONDS:-30}"필요하면 일회성으로 늘리면 된다.DRAIN_SECONDS=180 ./deploy-bluegreen.sh deployhealthcheck가 로그를 미친 듯이 찍었다전환하고 보니 FastAPI 로그가 이런 식으로 계속 찍혔다.
INFO: [내부경로] - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1 - "GET /health HTTP/1.1" 200 OK
INFO: [내부경로] - "GET /health HTTP/1.1" 200 OK처음엔 뭔가 잘못 붙은 줄 알았다. 알고 보니 대부분 healthcheck였다.127.0.0.1은 FastAPI 컨테이너 자기 자신의 Docker healthcheck고, Docker 내부 IP처럼 보이는 쪽은 nginx router가 /health를 프록시하면서 생긴 로그였다.
그래서 router healthcheck는 FastAPI /health를 때리지 않게 바꿨다.
location = /nginx-health {
access_log off;
return 204;
}그리고 compose healthcheck도 바꿨다.healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/nginx-health || exit 1"]
interval: 30s여기서도 한 번 삽질했다. 처음에는 localhost로 해놨는데, 컨테이너 내부에서 IPv6 쪽으로 붙는 타이밍인지 connection refused가 나면서 license-router가 unhealthy가 됐다. autoheal이 그걸 보고 계속 router를 재시작했다.로그가 이랬다.
Container /license-router found to be unhealthy - Restarting container now외부에서 curl http://localhost:8991/health는 되는데 Docker healthcheck만 실패하니까 더 헷갈렸다. 결국 healthcheck 주소를 127.0.0.1로 박고 해결했다.test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/nginx-health || exit 1"]이건 좀 교훈이었다. localhost도 그냥 localhost가 아니더라.active 파일은 Git 추적에서 빼야 했다
nginx active upstream 파일은 배포할 때마다 바뀐다.
server license-api-blue:8000;다음 배포에는 이렇게 된다.server license-api-green:8000;이걸 Git이 계속 변경사항으로 보여주니까 거슬렸다. 배포 상태 파일이지 소스 파일이 아니니까 추적 대상에서 빼는 게 맞았다.처음엔 .gitignore만 넣으면 될 줄 알았는데, 이미 Git에 올라간 파일은 .gitignore만으로 안 빠진다. 결국 로컬에서는 이렇게 처리했다.
git update-index --skip-worktree nginx/license-active.conf스크립트에서는 파일이 없으면 기본값을 만들어주게 했다.ensure_active_file() {
if [ ! -f "$ACTIVE_FILE" ]; then
mkdir -p "$(dirname "$ACTIVE_FILE")"
printf "server license-api-blue:8000;\n" > "$ACTIVE_FILE"
fi
}멈춘 컨테이너도 그냥 두면 남는다docker compose stop은 컨테이너를 삭제하지 않는다. 실제로 배포 후에 보니 old slot이 stopped 상태로 남아 있었다.
license-api-blue Up
license-api-green Exited
license-api Exited컨테이너 자체가 먹는 writable layer는 크지 않았다. 몇 MB 수준이었다. virtual size는 이미지 공유 크기라 컨테이너마다 새로 먹는 용량은 아니었다.그래도 운영하다 보면 찝찝하다. 그래서 cleanup 명령을 추가했다.
./deploy-bluegreen.sh cleanup이 명령은 현재 active가 아닌 stopped slot과 예전 legacy 컨테이너를 지운다.cleanup() {
local active inactive inactive_service
active="$(current_active)"
inactive="$(opposite_color "$active")"
inactive_service="$(service_for_color "$inactive")"
compose rm -f -s "$inactive_service"
if docker ps -a --format '{{.Names}} {{.Status}}' | grep -q '^license-api Exited'; then
docker rm license-api >/dev/null
fi
docker system df
}이미지 정리는 별도 문제다. 반복 pull을 하면 old image layer가 남을 수 있다.docker image prune -f이 정도는 가끔 해도 되는데, docker system prune -a는 너무 세다. 운영 서버에서 습관처럼 칠 명령은 아니라고 본다.Redis랑 Mongo는 어떻게 봤나
이 라이선스 서버는 active session을 Redis에 TTL로 들고 있었다. 대충 이런 느낌이다.
key = license_key
value = client_id, runtime_session_id, app_id, app_version
TTL = active ttlblue/green 배포 중에는 FastAPI가 잠깐 두 개 뜬다. 둘이 같은 Redis/Mongo를 본다.처음엔 이게 좀 찝찝했는데, 요청 단위 API 서버라면 보통 괜찮다. blue가 만든 Redis 상태를 green도 읽고, green이 heartbeat를 받아 같은 key를 갱신하면 된다.
다만 TTL이 heartbeat 주기랑 같으면 너무 빡빡하다. 배포 전환이나 네트워크 지연이 조금만 있어도 active key가 날아갈 수 있다. 그래서 active TTL은 넉넉하게 늘렸다.
LICENSE_ACTIVE_TTL_SECONDS=180나중에 FastAPI를 상시 여러 개 띄우려면 /check 쪽 Redis 점유 로직은 더 봐야 한다. GET 후 SETEX 같은 구조면 완전 atomic하지 않다. 그때는 SET NX나 Lua로 바꾸는 게 맞다.지금은 상시 1대 운영이고, 배포 순간에만 blue/green이 겹치는 구조라 그 리스크를 일단 감수했다.
남은 찜찜함
ACCOUNT_TOKEN_SECRET 경고도 있었다.
ACCOUNT_TOKEN_SECRET 환경 변수가 없어 SESSION_SECRET_KEY를 계정 토큰 서명에 재사용합니다.처음엔 바로 env를 추가해야 하나 싶었는데, 확인해보니 그건 레거시 /account/* 계정 토큰 계열이었다. 현재 데스크톱 클라이언트는 /check에 계정 토큰을 안 보내고, 웹 쪽은 별도 내부 API 토큰 검증을 탄다.그래서 지금은 일부러 안 추가했다. 폐기된 흐름이면 키를 추가해서 생명 연장하는 것보다, 나중에 코드에서 해당 경고나 레거시 라우트를 정리하는 편이 더 낫다고 봤다.
지금 배포 명령은 이렇게 남았다.
./deploy-bluegreen.sh deploy문제 없으면 정리한다../deploy-bluegreen.sh cleanup상태 확인은 이거다../deploy-bluegreen.sh status처음 생각보다 훨씬 귀찮았다. 그래도 이제는 docker compose down으로 전체를 내리는 배포는 안 한다. gateway nginx는 그대로 두고, app host 안에서만 nginx upstream을 바꿔서 FastAPI blue/green을 넘긴다.한 번 만들어놓고 나니 별거 아닌 것처럼 보이는데, 만드는 중에는 계속 이렇게 생각했다.
아 이거 그냥 롤링 배포 툴 하나 쓰는 이유가 있구나.
댓글 0
포스트 내용에 대한 의견이나 질문을 남길 수 있습니다. 답글과 좋아요는 로그인한 사용자 기준으로 기록됩니다.
이전 글과 다음 글
현재 글을 기준으로 앞뒤 글을 바로 이동해서 볼 수 있습니다.
처음엔 그냥 귀찮아서 Supabase Auth를 썼다. Google 로그인도 버튼 몇 번 누르면 되고, 회원 테이블도 알아서 생기고, 관리자 계정도 대충 이메일로 체크하면 됐다. 근데 나중에 보니 이게 은근히 발목을 잡더라. 사이트 본체는 MongoDB를 쓰고 있는데
Docker Hub에 올린 개인 이미지를 계속 쓰는 게 찝찝했다. 공개 저장소로 둔 것도 있고, 나중에 실수로 민감한 이미지가 올라갈 수도 있고. 그래서 그냥 내부망에 Docker Registry 하나 두고, 필요할 때만 외부 도메인으로 push/pull 가능하게 만들