← 목록으로
BinaryHub 개발

BinaryHub 개발기 ⑤ 무중단 배포와 캐시 함정

4
BinaryHub 개발기 ⑤ 무중단 배포와 캐시 함정

BinaryHub 개발기. 운영 PC 사이트의 다운타임과 캐시 함정에 대해.

빌드할 때마다 5초씩 끊겼다

운영 PC + PM2 + Cloudflare Tunnel 구조는 단순하고 안정적이지만 한 가지 거슬리는 게 있었다. 글 한 편 올릴 때마다 사이트가 5~6초 끊긴다. pm2 restart binaryhub-blog가 실행되는 동안 Next.js 서버가 내려갔다가 다시 올라오는 동안 502가 나간다.

부원이 마침 글을 쓰는 중이고 마침 새로고침을 한 타이밍이면 502를 본다. 빈도가 낮긴 해도 — 농담 사이트도 아닌 곳에서 502를 보여주는 건 마음에 안 들었다.

처음엔 별 수 없다고 생각했다. PM2 cluster 모드를 쓰면 무중단이 된다지만, Next.js standalone + Windows 조합에서는 cluster 모드가 잘 안 붙는다. Vercel처럼 Atomic 스왑을 해 주는 인프라가 있어도 좋겠는데, 그러려고 운영 PC 구조를 통째로 바꾸는 건 본말이 전도된다. 답이 없다고 미뤘다.

blue/green을 직접 짜기

답이 있긴 했다. 인스턴스 두 개를 띄우고, 둘 사이를 switch하는 거다.

  • binaryhub-blog-blue (포트 3001, .next-blue/ 디렉토리)
  • binaryhub-blog-green (포트 3011, .next-green/ 디렉토리)

PM2 ecosystem에 두 인스턴스를 등록해 두고, Next.js의 distDir를 환경변수로 분리해서 빌드 디렉토리도 안 섞이게 했다.

// next.config.ts
distDir: process.env.NEXT_DIST_DIR ?? ".next",

그러면 NEXT_DIST_DIR=.next-blue로 띄운 인스턴스와 .next-green로 띄운 인스턴스가 같은 코드를 별개의 빌드 결과로 굴린다.

그 둘 앞에 Caddy를 리버스 프록시로 둔다. Cloudflare Tunnel은 localhost:8080으로만 트래픽을 보내고, Caddy가 호스트 매처로 사이트별 분기하면서, 활성 인스턴스 포트로 reverse_proxy한다.

:8080 {
  @blog host binaryhub.club
  handle @blog { reverse_proxy localhost:3011 }   # 현재 활성: green
 
  @tools host tools.binaryhub.club
  handle @tools { reverse_proxy localhost:3000 }  # 현재 활성: blue
}

배포 스크립트(scripts/deploy.ps1)는 이렇게 동작한다.

  1. 현재 활성 인스턴스 파악 (Caddyfile 읽음)
  2. 대체 인스턴스(target)에 새 빌드를 만든다
  3. target 인스턴스를 pm2 restart로 띄운다
  4. target 포트에 헬스체크 — 200이 돌아올 때까지 대기
  5. Caddyfile의 포트를 target으로 갈아 끼우고 caddy reload
  6. 옛 활성 인스턴스 pm2 stop

caddy reload는 atomic이다. 새 설정이 검증되고 적용된 순간 트래픽이 새 인스턴스로 옮겨 가고, 진행 중이던 요청은 옛 인스턴스에서 계속 처리된다. 드롭되는 요청 0건으로 검증됐다(배포 중 1초에 한 번씩 폴링해서 0개).

PowerShell의 NativeCommandError 함정

이 스크립트를 PowerShell 5.1에서 처음 돌렸을 때, Caddy 리로드 단계에서 스크립트가 매번 죽었다. 이유는 황당했다.

PowerShell 5.1은 네이티브 실행 파일(caddy.exe, npm.exe)의 stderr 출력을 자동으로 ErrorRecord로 래핑한다. 그게 $?를 false로 만들고, 스크립트가 거기서 멈춘다. caddy가 정보 로그를 stderr로 흘리는 것조차 "에러"로 받아들이는 거다.

회피하려면 PowerShell 자체 흐름을 안 타고 cmd /c "exe args 2>&1"로 명령을 cmd에 위임하고, 종료 코드는 $LASTEXITCODE로 직접 확인하면 된다. 며칠 헤맸는데, 알고 보면 별 거 아니었다. 운영체제와 셸의 작은 어긋남이 종종 이렇게 시간을 잡아먹는다.

그 다음 만난 함정 — Cloudflare CDN

무중단 배포가 잘 굴러가니까, 글 올리는 흐름이 한결 부드러워졌다. 그 무렵 만난 다음 함정은 — Cloudflare CDN이 query string을 무시한다는 것이었다.

StudyAce의 CSS를 손봐서 라이브에 올리려는데, CSS 파일이 옛 버전 그대로 응답되는 일이 있었다. 사용자는 새로고침해도 옛 디자인을 본다. style.css?v=1style.css?v=2로 cache-bust 쿼리를 바꿔도 동일한 응답이 돌아온다. 두 URL의 ETag도 똑같았다.

이유는 Cloudflare의 기본 캐시 정책에 있었다. 정적 자산을 캐시할 때 query string을 캐시 키에서 무시하도록 잡혀 있어서, ?v=1이든 ?v=2든 같은 객체로 본다. 즉 cache-bust 쿼리는 효과 없음.

해결책은 단순했다. 파일 이름 자체를 바꾼다.

<!-- before -->
<link rel="stylesheet" href="css/style.css?v=2">
 
<!-- after -->
<link rel="stylesheet" href="css/style-20260609b.css">

새 path는 캐시 키도 새로 만들어지니까, edge 어디서 보든 처음 보는 URL이라 origin까지 가서 새 파일을 받아 온다. 더 robust하게 가려면 — 정말 중요한 룰은 HTML element에 style=로 inline까지 박아 두면, 어떤 캐시 단계가 stale해도 적용된다. 농담 같지만, 운영하다 보면 진짜로 그렇게 한다.

운영을 굴려 본다는 것

무중단 배포도 캐시 함정도, 글로만 보면 다 별 거 아닌 디테일이다. 그런데 실제로 사이트를 굴려 보지 않으면 어디서 어떻게 부딪칠지 모른다. 5초짜리 502가 신경 쓰이는 사람이 아니면 blue/green을 직접 짤 일이 없고, edge 캐시에 한 번 데이지 않으면 query string이 cache-bust로 안 통한다는 걸 알 길이 없다.