← 전체 글

Caddy를 배포 경로에서 떼어낸 이야기 — SPOF를 자동으로 건드리는 게 구조적으로 문제였다

· 12 분 분량 · 수정 2026년 7월 22일
배포 중 Cloudflare가 origin에 닿지 못해 523을 반환한 화면
배포 중 origin이 사라진 순간. Cloudflare는 Working, Host만 Error입니다.

NOTE

25분 동안 배포가 8번 나갔고, 그때마다 서비스가 끊겼습니다. 범인은 서비스명을 적지 않은 docker compose up -d --force-recreate 한 줄이었습니다.

회사에서 운영하는 블로그 마케팅 플랫폼은 ARM 서버 한 대(2 OCPU / 12GB) 위에 docker compose로 전체 스택을 올립니다. main에 머지되면 GitHub Actions가 SSH로 붙어 git pull → build → up -d를 돌리는 흔한 구성입니다. 그런데 개발자 둘이 연달아 머지하기 시작하니 배포할 때마다 서비스가 끊겼습니다. 이 글은 그 원인을 파고들어 blue-green으로 다시 세우고, 마지막까지 남은 리버스 프록시를 배포 경로에서 떼어낸 기록입니다.


1. 문제 — 배포 시각과 장애 시각이 정확히 겹친다

먼저 실측입니다. 25분 동안 배포가 여덟 번 나갔습니다.

05:34 #142 3m27s success
05:47 #144 1m34s success
05:50 #146 1m53s success
05:51 #147 4m27s success
05:56 #145 2m18s success
05:56 #149 3m11s success ◀── 05:58:54 장애가 이 런 안에서 발생
05:58 #148 50s cancelled ◀── 대기 중 다음 런이 도착해 밀려남
05:59 #150 3m41s success

#148이 취소된 건 cancel-in-progress: false의 정상 동작입니다. 대기 슬롯이 하나뿐이라 세 번째 런이 오면 대기 중이던 런이 밀려납니다. 즉 배포가 이미 유실되고 있었습니다.

그리고 05:58:54에 사용자 화면이 죽었습니다. 이상한 건 앱과 아무 상관 없는 정적 블로그까지 같이 죽었다는 겁니다. {slug}.example.com 블로그는 Next.js 앱을 거치지 않고 리버스 프록시가 빌드 산출물을 직접 내려주는 순수 정적 파일이라, 앱이 재시작된다고 죽을 이유가 없습니다.

죽은 건 앱이 아니라 리버스 프록시였다

에러 코드가 결정적인 단서였습니다. 502가 아니라 521이었습니다.

코드이 경우의 의미
502origin에 붙었는데 뒤가 죽어 있음앱 컨테이너만 죽은 상태
521origin에 TCP 연결 자체가 안 됨80/443을 아무도 안 듣고 있음

/api/auth/google/start 같은 API 경로까지 521이 떴습니다. 앱만 죽었다면 프록시가 살아서 502를 돌려줬어야 합니다. 즉 죽은 건 뒤쪽 앱이 아니라 진입점(Caddy) 자체였습니다.

이 플랫폼에서 Caddy는 단순한 라우터가 아닙니다.

example.com ──► caddy ──► admin:3000 (랜딩·로그인)
admin.example.com ──► caddy ──► admin:3000 (슈퍼 어드민)
{slug}.example.com ──► caddy ──► dist/{slug}/ ★ 정적 파일. 앱을 안 거침
커스텀 도메인 ──► caddy ──► dist/{slug}/ ★ 동일

Caddy가 곧 블로그 웹서버입니다. 그래서 Caddy가 죽으면 앱과 무관한 블로그까지 전멸합니다.


2. 원인 — 서비스명 없는 --force-recreate 한 줄

워크플로에서 배포를 수행하는 줄은 이것 하나였습니다.

Terminal window
docker compose -f deploy/compose.yml up -d --remove-orphans --force-recreate

서비스명이 없습니다. compose에서 이건 “전부”라는 뜻이고, 이 파일의 전부는 여섯 개였습니다.

서비스역할재생성되면
dbPostgres(pgvector). sites·posts·schedules 전부도미노의 첫 조각. depends_on 연쇄로 api·admin까지 끌고 감
redisBullMQ 큐 (noeviction + appendonly)api·worker가 healthy 대기로 묶임
api내부 API(:8787). 외부 미노출, admin이 호출admin이 떠 있어도 데이터를 못 가져와 500
adminNext.js. 랜딩·온보딩·개인 어드민·슈퍼 어드민유일한 사용자 대면 앱. 20~40초 공백
workerAstro 빌드·예약 발행 잡 소비무해. 큐가 흡수해서, 뜨면 밀린 잡을 처리
caddy80/443 바인딩 + 블로그 정적 서빙521. 진입점이 사라짐

여기에 --force-recreate가 겹쳤습니다. compose는 평소 해석이 끝난 설정 전체를 해시로 만들어 컨테이너 라벨(com.docker.compose.config-hash)에 저장해두고 다음 up -d 때 비교해 달라진 것만 다시 만드는데, 이 플래그는 그 비교를 건너뜁니다. 공식 문서 표현 그대로 “Recreate containers even if their configuration and image haven’t changed.”

결과적으로 앱 한 줄 고친 배포가 db와 Caddy까지 파괴하는 배포가 되어 있었습니다.

recreate는 restart가 아니다

여기서 한 가지를 짚고 가야 합니다.

stop → rm → create → start
이 구간엔 컨테이너가 아예 존재하지 않는다

restart는 프로세스만 다시 시작하고 컨테이너는 계속 존재합니다. recreate는 지웠다가 다시 만듭니다. Caddy가 이 구간에 있으면 호스트의 80/443을 아무 프로세스도 바인딩하지 않습니다. 방화벽 차단이 아니라 연결 자체가 거부되고, Cloudflare가 그걸 521로 표시합니다.

그리고 depends_on 연쇄가 이걸 초 단위에서 분 단위로 늘립니다.

0s db·redis stop→rm→create→start
10s ├─ db healthy 대기 (pg 부팅)
20s api stop→rm→create→start
40s ├─ api healthy 대기
50s admin stop→rm→create→start
90s caddy stop→rm→create→start ◀── 여기서만 521
대상죽는 구간대략
블로그 (정적)caddy 재생성 때만5~10초 (521)
랜딩 · 개인 어드민 · 슈퍼 어드민db→api→admin→caddy 내내60~100초

블로그의 5~10초는 짧지만 사라져도 되는 시간은 아닙니다. 하루 여덟 번 배포하면 크롤러가 521을 받을 기회가 여덟 번 생기고, 이건 원래 0이어야 하는 숫자입니다.


3. 설계 — 무엇을 2벌로 만들지 않을 것인가

blue-green이라고 하면 “앱을 두 벌 띄워서 번갈아 쓴다” 정도로 요약되곤 하는데, 실제로 설계할 때 시간을 가장 많이 쓴 건 어디까지 복제하지 않을 것인가를 정하는 일이었습니다.

┌──────────────────────────────┐
사용자 ────────► │ 라우터 (Caddy/nginx/ALB) │ ← 항상 살아있음. 전환 = 설정 flip
└───────┬──────────────┬───────┘
│ (활성) │ (대기)
┌───────▼──────┐ ┌────▼─────────┐
│ app BLUE │ │ app GREEN │ ← 무상태 앱만 2벌
└───────┬──────┘ └────┬─────────┘
└───────┬───────┘
┌─────────▼─────────┐
│ DB / Redis 1벌 │ ← 상태 보유. 절대 2벌로 안 만듦
└───────────────────┘

복제하는 건 무상태 앱 티어뿐입니다. blue-green은 “앱 배포”의 무중단을 보장하는 기법이지, DB 재시작의 무중단을 보장하는 기법이 아닙니다.

① 상태를 가진 것은 복제하지 않는다

redis는 캐시가 아니라 입니다.

command: ["redis-server", "--maxmemory-policy", "noeviction", "--appendonly", "yes", ...]

noeviction + appendonly는 “이 데이터는 절대 잃으면 안 된다”는 선언입니다. 안에 든 게 Astro 빌드, 예약 발행 같은 실제 작업 큐거든요. 2벌로 나누면 이렇게 됩니다.

blue-redis [잡A, 잡B, 잡C] ← blue-worker 가 처리 중
green-redis [ ] ← 텅 빔
flip → green 으로 전환
green-worker 는 빈 큐를 봄 → 잡A·B·C 는 아무도 처리하지 않음
blue-redis 를 정리하는 순간 → 영구 유실

캐시라면 미스가 나도 다시 채우면 됩니다. 큐는 그럴 수 없습니다. 예약 발행된 글이 조용히 사라지고, 사용자는 원인을 알 수 없습니다.

db는 더 명확합니다. 2벌로 만들면 전환 구간의 쓰기가 갈리고, 롤백하면 그 사이 데이터가 사라집니다. 제대로 하려면 스트리밍 복제 + failover가 필요한데 그건 blue-green이 아니라 HA 구성입니다. dist·OG 이미지 같은 공유 상태도 마찬가지입니다.

서비스2벌?근거
admin○ 2벌유일한 사용자 대면 경로. 다운타임이 곧 장애
api✕ 1벌외부 미노출. admin만 호출하므로 재시작 20초는 admin이 흡수 가능
worker✕ 1벌큐가 다운타임을 흡수한다. 2벌이면 다른 버전 둘이 같은 큐를 먹는 위험
db✕ 1벌단일 진실. 복제는 HA의 영역
redis✕ 1벌큐. 쪼개면 잡 유실
caddy✕ 1벌라우터는 재시작 대상이 아니라 reload 대상

worker는 큐 기반이라 애초에 무중단이 필요 없습니다. 내려가 있으면 잡이 쌓였다가 뜨면 처리됩니다.

NOTE

“admin 2벌”이 상시 2개 가동을 뜻하는 것도 아닙니다. 평상시엔 한 개만 뜨고 배포 1회당 1~2분만 겹칩니다. 2 OCPU / 12GB 한 대에서 arm64 빌드까지 돌리기 때문에, 상시 2벌은 빌드 피크와 정면으로 부딪힙니다.

② 라우터는 재시작하는 게 아니라 reload하는 것이다

Caddy를 재생성 대상에서 빼는 것만으로는 끝나지 않습니다. 배포마다 반영돼야 하는 것이 있기 때문입니다 — 새로 만들어진 블로그의 도메인 설정, 그리고 ③에서 추가될 blue/green upstream.

reload는 재시작이 아닙니다.

[caddy 프로세스 — 계속 살아있음, 소켓 계속 점유]
reload
├─ 새 config 파싱 · 검증
├─ 검증 실패 → 옛 config 유지. 아무 일도 일어나지 않음
└─ 검증 성공 → 새 config 활성화
처리 중이던 요청 ──► 옛 config 로 끝까지 완료
새로 들어온 요청 ──► 새 config 로 처리
[소켓은 단 한 순간도 안 풀림]

포트가 비는 순간이 없으니 521이 구조적으로 불가능합니다. 게다가 caddy validate가 먼저 돌기 때문에 틀린 설정을 밀어도 서비스가 죽지 않습니다. 재생성은 정반대입니다 — 설정이 잘못되면 컨테이너가 못 뜨고 그대로 장애입니다.

여기서 좀 허무한 사실 하나. 이 reload 스텝은 워크플로에 원래부터 있었습니다. 그런데 앞 단계에서 --force-recreate로 Caddy를 이미 죽여버려서, 무중단 도구를 갖춰놓고 그 효과를 스스로 없애고 있었습니다.

파일만 바꿨는데 왜 Caddy는 안 죽나

설정을 바꾸면 컨테이너를 다시 만들어야 반영되는 것 아닌가? compose는 볼륨의 마운트 경로를 해시에 넣지 파일 내용은 넣지 않습니다.

./active:/etc/caddy/active:ro → 해시에 포함 (경로가 바뀌면 재생성)
admin.caddy 안의 blue / green → 해시와 무관 (내용은 몇 번을 바꿔도 그대로)

게다가 bind mount는 호스트와 같은 파일을 공유합니다. 배포 스크립트가 active/admin.caddy를 다시 써도 compose는 “바뀐 게 없다”고 넘어갑니다. 운영 중 바뀌어야 하는 것들은 애초에 재생성이 필요 없는 자리에 있습니다.

reload가 안 끊기는 원리도 같은 맥락입니다. Caddy는 새 config의 리스너를 먼저 바인딩한 뒤에 옛것을 내립니다. ③에서 admin에 적용할 “만들고 죽이기” 순서를 자기 설정 교체에 이미 쓰고 있었던 셈입니다.

다만 caddy validate가 지켜주는 건 이미 떠 있는 Caddy에 새 설정을 밀어 넣는 경우뿐입니다. Caddy가 재생성되어 처음부터 기동하는 경로는 얘기가 다릅니다. 설정이 파싱조차 안 되면 컨테이너가 부팅에 실패하고, restart 루프에 빠지고, 80/443은 계속 비어 있습니다. 그래서 원칙을 하나 더 뒀습니다.

Caddy는 언제 기동하든 파싱 가능한 설정을 갖고 있어야 한다.

upstream이 실제로 살아있는지는 그다음 문제입니다. 파싱만 되면 Caddy가 떠서 블로그(정적)는 정상 서빙되고 앱만 502가 됩니다. 전멸(521)과는 차원이 다른 피해입니다. 그래서 배포 스크립트에 --ensure 단계를 두어, 활성 색 설정 파일이 없으면 먼저 만들어놓고 그다음에 up -d가 돌게 했습니다. Caddy의 depends_on: admin도 지웠습니다. 앱 재생성이 Caddy로 번지는 경로였습니다.

③ 순서를 뒤집는다 — 죽이고 만들기에서 만들고 죽이기로

앱 다운타임의 정체는 단순합니다. 재생성이 죽이고 나서 만드는 순서라는 것.

stop rm create start Next.js 부팅 healthy
│ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼
[admin 살아있음] ──── admin 이 존재하지 않음 ───── [admin 살아있음]
└──────── 20~40초 ────────┘
caddy 는 살아있는데 보낼 곳이 없음 → 502

흔한 오해를 하나 짚고 갑니다. Caddyfile엔 이미 dynamic a가 있었지만 푸는 문제가 다릅니다.

dynamic a내용
푸는 것새 컨테이너의 IP가 바뀌어도 5초 안에 따라간다 → 복구가 빠르다
못 푸는 것컨테이너가 아예 없는 20~40초

대상이 없으면 IP를 아무리 정확히 추종해도 502입니다. 슬롯이 하나인 한 못 고칩니다. 그래서 순서를 뒤집습니다.

기존 (kill → start)
[blue 살아있음]────╳────────────[blue' 살아있음]
└── 다운 ──┘
blue-green (start → kill)
[blue 살아있음 ─────────────────────]────╳
[green 준비 ──────────────────────]
▲ 여기서 전환
두 컨테이너가 겹치는 구간이 있음 → 다운 없음

전환 장치는 파일 한 장입니다. 도메인별 설정을 import로 붙이는 패턴을 그대로 재사용했고, active/admin.caddy 하나가 활성 색을 결정합니다.

# 배포 스크립트가 이 파일만 다시 쓴다
(admin_upstream) {
reverse_proxy {
dynamic a { name admin-green port 3000 refresh 5s resolvers 127.0.0.11 }
header_up Host {host}
}
}

그 결과 전 과정에서 “응답할 수 있는 admin”이 항상 최소 하나 존재합니다.

t0 blue 활성 · green 없음 caddy → blue ✔ 응답 가능
t1 green 기동 시작 caddy → blue ✔ blue 가 계속 처리
t2 green Next.js 부팅 중 caddy → blue ✔ 여전히 blue
t3 green healthy 통과 caddy → blue ✔ 아직 안 넘김
t4 caddy reload ◀── 전환 caddy → green ✔ green 은 이미 준비 완료
t5 drain 30s (blue 살려둠) caddy → green ✔ blue 의 잔여 요청도 완료
t6 blue stop caddy → green ✔ 넘어간 지 한참 뒤

핵심은 t3와 t4의 순서입니다. healthy를 확인한 뒤에만 전환하므로 준비 안 된 곳으로 트래픽이 가는 순간이 없습니다. 여기서 두 가지가 딸려옵니다.

  • t3에서 실패하면 전환하지 않습니다. blue가 그대로 서비스 중이라 깨진 커밋이 배포돼도 사용자는 모릅니다. 전에는 깨진 커밋이 곧 장애였습니다.
  • t6에서 rm하지 않고 stop만 합니다. 설정과 이미지가 남아 있어 start + reload로 20~30초 만에 롤백됩니다.

healthcheck도 손봤습니다. 기존 fetch('http://localhost:3000/')는 미들웨어 host 분기를 타서 “정말 서비스 가능한 상태”를 보장하지 않는데, blue-green에서는 이 판정이 곧 트래픽 전환 조건이라 오판이 곧 장애입니다. api 도달성까지 확인하는 /api/healthz로 바꿨습니다.

셋은 따로 놀지 않는다

세 조치는 하나만 빠져도 0이 되지 않습니다.

① 상태 있는 것 제외 → 도미노를 끊는다 (없으면 admin 2벌이 무의미)
③ admin 2벌 → 앱 교체 구간을 덮는다 (없으면 502 20~40초 잔존)
② caddy reload → 전환 지점을 만든다 (없으면 전환할 방법 자체가 없음)

특히 ①이 없으면 나머지가 헛수고입니다. db가 죽으면 api가 죽고, api가 죽으면 admin이 살아있어도 화면이 500이니까요. 그래서 db·redis 강제 재생성은 없애지 않고 workflow_dispatch 입력으로 분리했습니다. 하루 여덟 번 자동으로 터지는 것과, 필요할 때 의도적으로 하는 것을 나눈 것입니다.

최종 배포 흐름은 이렇게 정리됐습니다.

1. ACTIVE 읽기 → TARGET = 반대색
2. DB migrate → ⚠ backward-compatible 만 (5절)
3. build + up admin-$TARGET → --force-recreate --no-deps
4. healthy 대기 (최대 180s)
└ 실패 → TARGET 정리 후 배포 실패 종료. ACTIVE 무사 = 서비스 무영향
5. active/admin.caddy 를 TARGET 으로 재작성
6. caddy validate → caddy reload ◀── 트래픽 전환 지점
7. drain 30s
8. old color stop (rm 하지 않음)
9. active/COLOR = TARGET

3단계에서 --force-recreate다시 등장하는 게 포인트입니다. 처음엔 “이 플래그를 지우자”고 생각했는데 부정확한 진단이었습니다. 플래그가 문제가 아니라 대상이 없는 게 문제였습니다. 새로 띄우는 색은 오히려 강제 재생성하는 게 맞습니다 — 잔여 상태 없이 깨끗하게 떠야 하니까요. --no-deps가 함께 있어야 의존성을 타고 db까지 번지지 않습니다.


4. 격리 — 다운타임 5초의 진짜 비용은 5초가 아니었다

여기까지 하고 한동안 잘 돌았습니다. 그런데 5초가 남았습니다. 코드를 한 줄도 안 바꾼 배포에서 Caddy가 재생성되길래 봤더니, 범인은 이미지였습니다.

시점이미지 IDCreatedAt
배포 a24f5a31f34d2026-07-08
배포 19dc2184f2022026-07-08

CreatedAt은 그대로인데 ID만 바뀌었습니다. docker compose build는 레이어를 전부 캐시 재사용하면서도 매번 새 이미지 ID를 찍고, compose는 그 ID를 설정 해시에 넣습니다. 빌드만 해도 뒤따르는 up -d가 Caddy를 재생성하는 겁니다. Dockerfile 해시를 저장해두고 바뀐 경우에만 빌드하도록 막았습니다.

그런데 그건 트리거 하나였습니다.

설정 해시에 들어가는 건 이미지 ID만이 아닙니다. env 값, 볼륨, 포트, 라벨이 전부 입력입니다. 실제로 이런 게 남아 있었습니다.

같은 서버, 같은 파일, 같은 시각에 계산한 해시
환경변수를 셸에 올리지 않고 계산 → cf673c18…
올리고 계산 (= 실행 중인 컨테이너) → a9d8d63e…

공백이 든 값은 compose의 ${} 보간이 첫 토큰에서 잘라먹어서, 워크플로가 그 값을 셸 환경변수로 따로 올려 우회하고 있었습니다. 그런데 그 우회를 거치지 않고 docker compose up -d를 한 번이라도 실행하면 해시가 달라지고 Caddy가 재생성됩니다. 우회 코드는 세 군데에 흩어져 있었습니다.

트리거를 하나 막을 때마다 다음 트리거가 나왔습니다. 방식이 틀렸다는 신호입니다.

5초가 아니라 “5초 아니면 무기한”

방향을 바꾸기 전에 비용부터 다시 계산했습니다. 재생성이 실패하면 어떻게 되는지를 그제야 제대로 봤습니다.

성공하면 → 80/443 이 비는 5초 → 521 → 복구
실패하면 → 설정 파싱 실패 → 컨테이너가 기동조차 못 함
→ restart: unless-stopped 가 무한 재시작
→ 80/443 은 계속 비어 있음 → 사람이 고칠 때까지 전면 장애

실제로 한 번 겪었습니다. 활성 색 설정 파일이 없는 상태에서 Caddy가 재생성되자 (admin_upstream)이 정의되지 않아 파싱에 실패했고, 컨테이너가 restart 루프에 빠졌습니다. 3분 만에 복구된 건 시스템이 회복해서가 아니라 사람이 3분 만에 알아챘기 때문입니다.

재생성의 비용은 “5초”가 아니라 “5초 아니면 무기한”이라는 분산이다. 관리해야 하는 건 평균이 아니라 꼬리다.

이렇게 놓고 보면 할 일이 바뀝니다. 5초를 4초로 줄이는 건 의미가 없습니다. 꼬리에 노출되는 횟수를 줄이고, 노출될 때 실패 확률을 0으로 만드는 것이 답입니다.

재생성을 사고에서 결정으로

먼저 배포 경로에서 Caddy를 뺐습니다.

Terminal window
# 이전 — 해시가 흔들리면 조용히 재생성됐다
docker compose up -d --no-deps db redis api worker caddy
# 이후 — 목록에 없다
docker compose up -d --no-deps db redis api worker

빼도 되는 이유는 3절 ②에서 확인했습니다. 이 목록이 유일하게 담당하던 건 compose 파일의 Caddy 블록 자체가 바뀌는 경우뿐이고, 그건 없애는 대신 감지로 바꿨습니다.

Terminal window
DESIRED=$(docker compose config --hash caddy | awk '{print $2}')
RUNNING=$(docker inspect -f '{{index .Config.Labels "com.docker.compose.config-hash"}}' caddy)
[ "$DESIRED" != "$RUNNING" ] && echo "::warning::Caddy 설정이 변경됐다. 배포는 건드리지 않았다."

판정만 해서 Actions 요약에 남기고, 실제 적용은 workflow_dispatch로 사람이 엽니다. db·redis에 이미 쓰던 정책을 Caddy에도 준 것뿐입니다. ::error가 아니라 ::warning인 건 의도적입니다 — Caddy 설정이 흔들렸다고 무관한 앱 배포까지 막으면 사람이 워크플로를 우회하기 시작합니다.

내리기 전에, 뜬다는 걸 증명한다

caddy validate는 원래도 돌고 있었습니다. 문제는 위치였습니다.

기존 Caddy 를 내린다 → 새로 띄운다 → validate ◀── 이미 늦었다
이후 validate (일회용 컨테이너) → 통과해야 내린다

살아있는 Caddy 안에서 도는 validate는 “떠 있는 프로세스에 새 설정을 밀어 넣을 때”만 지켜줍니다. 컨테이너를 새로 띄우는 경로는 무방비였습니다.

Terminal window
docker run --rm --network none \
-v "$PWD/caddy:/etc/caddy:ro" \
--entrypoint caddy "$IMG" \
validate --config /etc/caddy/Caddyfile --adapter caddyfile

--network none이라 포트를 잡지 않아 돌고 있는 Caddy와 충돌하지 않습니다. 실패하면 재생성 자체를 하지 않으니 사용자 영향은 0입니다. 기대보다 강력했던 건 validate가 파싱만이 아니라 모듈 provisioning까지 한다는 점입니다. DNS 인증서 발급용 토큰을 빠뜨렸더니 API token '' appears invalid로 걸렸습니다. 인증서 발급이 깨질 원인까지 재생성 전에 잡힙니다.

그리고 종료할 때 SIGTERM 대신 SIGKILL을 보내 재생성 시간을 1~2초로 줄였습니다. Caddy의 grace_period는 종료 시 리스너를 먼저 닫고 기다리기 때문에, 그 대기 시간은 521만 나가는 순수 dead air입니다.

⚠️ 대신 grace_period 값 자체는 건드리지 않았습니다. reload 때는 새 설정이 먼저 바인딩된 뒤에 옛것이 내려가 dead air가 없고, 그 값은 처리 중인 요청의 상한으로 제 역할을 합니다. 한 설정이 두 상황을 겸하므로 낮추면 reload 쪽만 망가집니다.

건드려졌으면 즉시 안다

마지막으로, 배포가 끝나면 Caddy가 살아남았는지 단언합니다.

Terminal window
STARTED=$(docker inspect -f '{{.State.StartedAt}}' caddy)
if [ "$(date -d "$STARTED" +%s)" -gt "$JOB_START_EPOCH" ]; then
echo "::error::이번 배포에서 Caddy 가 재생성됐다"
exit 1
fi

사실 uptime을 출력하는 스텝은 원래도 있었고 주석까지 정확했습니다 — “이 값이 배포마다 리셋되면 재생성되고 있다는 뜻”. 그런데 출력만 하고 아무도 비교하지 않았습니다. 회귀 탐지기가 사람 눈에 의존하고 있었던 겁니다.

실제로 이 단언을 넣고 나서야, 그전 이틀 사이에 Caddy가 두 번이나 교체·재시작됐는데 워크플로 로그에는 아무 기록이 없었다는 걸 알았습니다.


5. 결과

앞단 캐시가 origin의 공백을 가려주지 않도록 Cloudflare를 우회해 origin의 443을 직접 0.5초 간격으로 찔렀습니다. 실패 0건. 프로브가 조용히 죽었어도 0건이 나오므로, 같은 구간 액세스 로그에 437건이 찍힌 걸로 교차 확인했습니다. 가장 확실한 지표는 컨테이너 자체입니다.

배포 전 Created 03:10:58 StartedAt 05:52:26
배포 후 Created 03:10:58 StartedAt 05:52:26 ◀── 같은 컨테이너, 재시작조차 없음

빈 배포도 아니었습니다. 같은 배포에서 api·worker는 재생성됐고 admin은 green으로 전환됐습니다. 정상 배포에서 Caddy만 격리된 것입니다.

항목이전이후
블로그521 · 5~10초0
랜딩 · 개인 어드민 · 슈퍼 어드민502 · 60~100초0
Caddy 설정을 바꾸는 배포521 · 5~10초0 (배포가 안 건드림)
Caddy 재생성이 실패했을 때무기한 전면 장애무영향 (재생성 안 함)
깨진 커밋 배포 시장애무영향 (전환 안 됨)
롤백재빌드·재배포설정 한 줄, 20~30초

4번째 줄이 이 작업의 핵심입니다. 5초가 1초가 된 게 아니라, 꼬리가 잘렸습니다.

대가 — DB를 바꿀 때 조심스러워진다

무중단의 원리가 “잠깐 두 버전을 동시에 띄우는 것”이다 보니, 그 30초 동안 옛 코드와 새 코드가 같은 DB를 함께 씁니다. 새 코드에 맞춰 컬럼을 지웠는데 옛 코드가 아직 그걸 읽고 있으면 그 30초 동안 500이 납니다. DB 변경은 옛 코드도 그대로 돌아가는 형태여야 합니다.

1차 새 컬럼을 추가하고 데이터를 채운다 (옛 컬럼은 그대로 둔다)
2차 코드가 새 컬럼만 보도록 바꾼다 ◀── 이제 옛 컬럼을 읽는 코드가 없다
3차 옛 컬럼을 지운다

무중단 장치를 갖춰도 이 순서를 어기면 다운타임은 그대로 발생합니다. 배포 방식을 바꾼 것이 코드 쓰는 방식까지 바꾼 셈입니다.


같은 “무중단”이어도 처방이 달랐다는 게 이 글에서 가장 오래 남은 교훈입니다. admin은 배포마다 바뀌는 게 정상이니 두 벌을 띄우고 전환하는 게 맞고, Caddy는 안 바뀌는 게 정상이니 변경을 감지하고 사전에 검증하는 게 맞습니다. 둘 다 무중단이지만 같은 도구를 쓰면 안 됐습니다.