무중단 배포의 드레인: 연결을 비우는 시간과 버전이 섞이는 시간
배포 버튼을 누르고 얼마 뒤 모니터링에 502 가 몇 개 찍힙니다. 새 버전은 헬스체크를 통과했고, 옛 버전은 로드밸런서에서 뺐습니다. 순서대로 했는데 요청이 끊겼습니다. 같은 시각 브라우저 콘솔에는 ChunkLoadError 가 뜹니다. 방금 받은 페이지가 존재하지 않는 자바스크립트 파일을 달라고 한 것입니다.
두 증상 모두 드레인(draining)이라는 구간에서 생깁니다. 옛 서버를 내리기 전에 새 요청은 막고 이미 받은 요청만 마저 처리하게 두는 시간입니다.
「빼고 기다리면 끝」이라는 이해
흔히 드레인을 이렇게 이해합니다. 로드밸런서에서 옛 서버를 빼면 새 요청은 더 오지 않고, 남은 요청이 끝날 때까지 기다렸다가 끄면 안전하다고요. 틀린 설명은 아닙니다. 다만 세 군데가 빠져 있어서 위의 두 증상을 설명하지 못합니다.
첫째, 「빼는 일」은 즉시 일어나지 않고, 끄는 신호와 동시에 시작되기도 합니다. 둘째, 어떤 연결은 기다려도 끝나지 않습니다. 셋째, 드레인이 비우는 것은 연결이지 버전이 아닙니다. 드레인 동안에는 옛 버전과 새 버전이 함께 살아 있고, 그 사이에 오간 요청은 서로 다른 버전을 만납니다.
드레인의 정의와 기본값
AWS 의 Application Load Balancer 는 이 과정을 「등록 취소 지연(deregistration delay)」이라고 부릅니다. 문서의 설명은 짧습니다.
Elastic Load Balancing stops sending requests to targets that are deregistering. By default, Elastic Load Balancing waits 300 seconds before completing the deregistration process, which can help in-flight requests to the target to complete.
빼는 서버의 상태는 draining 이 되고, 기본 300초가 지나면 unused 가 됩니다. 처리 중인 요청도 열린 연결도 없으면 기다리지 않고 바로 끝납니다. 같은 문서의 다음 문장이 첫머리의 502 를 설명합니다.
If a deregistering target terminates the connection before the deregistration delay elapses, the client receives a 500-level error response.
드레인 시간이 아무리 넉넉해도, 서버 프로세스가 그 전에 스스로 연결을 끊으면 사용자는 5xx 를 받습니다. 드레인은 로드밸런서가 기다려 주는 시간이지, 서버가 살아 있기로 약속한 시간이 아닙니다.
빼는 일과 끄는 일이 동시에 시작되는 쿠버네티스
쿠버네티스에서는 이 틈이 더 분명하게 드러납니다. 파드를 지우면 kubelet 은 컨테이너에 종료 신호(SIGTERM)를 보내고, 기본 30초 유예 뒤에도 살아 있으면 SIGKILL 로 끕니다. 트래픽에서 빼는 일은 그 다음 순서가 아닙니다. 파드 종료 흐름은 이렇게 적습니다.
At the same time as the kubelet is starting graceful shutdown of the Pod, the control plane evaluates whether to remove that shutting-down Pod from EndpointSlice objects
동시에 시작되니, 신호를 받은 순간에도 엔드포인트 목록이 아직 갱신되지 않은 로드밸런서나 프록시가 요청을 보낼 수 있습니다. 앱이 SIGTERM 을 받자마자 종료하면 그 요청들이 끊깁니다. 그래서 흔히 preStop 훅에 몇 초 기다리는 명령을 넣어, 목록에서 빠질 시간을 먼저 줍니다. 몇 초가 적당한지는 클러스터와 프록시마다 달라서 저도 정해진 숫자를 말하기 어렵습니다.
기다려도 끝나지 않는 연결
드레인은 「진행 중인 요청이 끝나기를」 기다립니다. 웹소켓, SSE(Server-Sent Events), 긴 폴링은 요청 하나가 몇 분에서 몇 시간 이어지는 연결입니다. 이런 연결이 하나라도 있으면 드레인은 제한 시간까지 가고, 제한 시간이 오면 연결은 끊깁니다. 배포할 때마다 몇 분씩 늘어지고, 그 끝에 실시간 화면이 한 번씩 끊기는 이유입니다.
HTTP keep-alive 도 한때 같은 모양을 만들었습니다. 프록시와 앱 사이의 재사용 연결은 한가해도 열려 있어서, 「열린 연결이 다 닫히면 종료」가 끝나지 않았습니다. Node.js 는 19 부터 HTTP 서버의 server.close()가 요청을 보내고 있지 않은 연결을 닫도록 바뀌었습니다. 그래도 SSE 나 웹소켓처럼 계속 흐르는 연결은 남으니, 종료 처리에는 상한 시간이 따로 있어야 합니다.
저는 이 경우 드레인 시간을 늘리는 쪽보다, 클라이언트가 끊기면 다시 붙도록 만드는 쪽을 먼저 봅니다. 서버가 먼저 끊어도 되는 연결이면 드레인이 짧아도 됩니다.
드레인이 막지 못하는 버전 스큐
첫머리의 ChunkLoadError 는 연결 문제가 아닙니다. 옛 버전이 내준 HTML 에는 옛 자바스크립트 파일 이름이 적혀 있습니다. 브라우저가 그 파일을 나중에 달라고 할 때 요청이 새 버전 서버로 가면, 새 서버에는 그 파일이 없습니다. 드레인이 아무리 깔끔해도 이 요청은 생깁니다. 페이지는 옛 버전이 줬고 파일 요청은 드레인이 끝난 뒤에 오기 때문입니다.
이것을 버전 스큐(version skew)라고 부릅니다. Vercel 의 Skew Protection 문서는 「클라이언트와 서버에서 서로 다른 버전이 돌 때 생기는 오류」라고 정의하고, 새 배포가 필수 필드를 추가했는데 옛 클라이언트가 그 필드 없이 요청을 보내는 예를 듭니다. 해법은 요청마다 배포 ID 를 붙여, 그 요청을 처음 페이지를 준 배포로 보내는 것입니다. 기본값으로 배포 후 하루 동안 옛 배포를 살려 둡니다.
플랫폼이 이 일을 해 주지 않는 환경에서는 두 가지를 직접 해야 합니다. 정적 파일은 이름에 내용 해시를 붙이고 이전 배포분을 한동안 지우지 않습니다. API 와 DB 스키마는 옛 코드와 새 코드가 동시에 읽어도 되는 방향으로만 바꿉니다. 칼럼은 먼저 더하고, 옛 코드가 다 빠진 다음 배포에서 지웁니다.
설계할 때 달라지는 것
드레인을 「기다리는 시간」이 아니라 「두 버전이 겹치는 시간」으로 보면, 챙길 것이 서버·클라이언트·데이터 세 곳으로 나뉩니다.
| 어디 | 해야 할 일 |
|---|---|
| 서버 | SIGTERM 을 받으면 준비 상태(readiness)부터 실패 → 목록에서 빠질 시간 → 새 연결 차단 · 열린 요청 마무리. 전체에 상한 시간 |
| 클라이언트 | 오래 붙는 연결은 끊기면 다시 붙기. 청크 로드 실패는 새로고침으로 복구 |
| 데이터 | 정적 파일은 해시 이름 + 이전 배포분 보존. 스키마는 더하기 먼저, 지우기는 다음 배포 |
서버 쪽의 모양은 대략 이렇습니다.
let ready = true; // 헬스체크(readiness)가 이 값을 돌려준다
process.on("SIGTERM", () => {
ready = false; // 로드밸런서가 먼저 빼 가게 한다
setTimeout(() => {
server.close(() => process.exit(0)); // 새 연결을 막고, 열린 연결이 끝나면 종료
setTimeout(() => process.exit(1), 20_000).unref(); // 끝나지 않는 연결을 위한 상한
}, 5_000); // 목록에서 빠질 시간. 환경마다 다르다
});이 숫자들을 합한 값이 플랫폼의 종료 유예(쿠버네티스라면 terminationGracePeriodSeconds, 기본 30초)보다 작아야 합니다. 그렇지 않으면 종료 처리가 끝나기 전에 SIGKILL 이 옵니다.
여기까지가 확실한 부분
AWS 의 300초, 쿠버네티스의 30초와 동시 시작, Vercel 의 하루는 문서에 적힌 기본값과 동작입니다. 그 밖의 숫자, 예를 들어 preStop 에 몇 초를 넣을지, 드레인을 몇 초로 줄일지는 프록시가 목록을 얼마나 빨리 갱신하는지와 가장 긴 정상 요청이 얼마인지에 달려 있어서 환경마다 직접 재야 합니다. 컨테이너를 통째로 바꿔 끼우는 배포(예: docker compose up 으로 다시 만들기)는 드레인 단계가 아예 없어서, 짧게라도 끊기는 배포라고 보는 편이 정확합니다.
참고: AWS ALB 등록 취소 지연 · 쿠버네티스 파드 종료 흐름 · Vercel Skew Protection
함께 읽기
- Vercel 재배포 직후 500: 재배포 탓인지 값 탓인지 가르는 순서운영에 올릴 새 커밋은 없었습니다. main 과 develop 이 같은 커밋이었고, 마지막 운영 배포는 그 커밋이 올라간 지 4분 뒤에 만들어져 있었습니다. 그래도 한 번 다시 올려 두자고 해서 vercel redeploy 로 같은 배포를 다시 만들었습니다. 2분 만에 Ready 가 떴고 도메인에 붙었습니다.
- Vercel vs Cloudflare Pages vs Netlify vs 자체 서버: 배포 플랫폼 선택 가이드웹사이트를 만들 때 자주 듣는 이름으로 Vercel, Cloudflare Pages, Netlify가 있습니다. 여기에 AWS나 직접 관리하는 서버까지 더하면 선택지가 너무 많아 보입니다. 하지만 먼저 구분할 것이 있습니다. 이들은 완성된 홈페이지를 만들어 주는 서비스가 아니라, 개발자가 작성한 코드를 빌드하고 실행하는…
- Self-hosted Runner와 Vercel 배포 비교: git push 뒤에서 무엇이 다른가GitHub에 커밋을 푸시하면 자동으로 서비스가 배포됩니다. 겉으로 보면 GitHub Actions의 Self-hosted Runner와 Vercel은 똑같아 보입니다.
- 배포가 실패해도 5분 안에 되돌리는 소규모 서비스 운영법빠른 롤백은 사람이 명령어를 빨리 입력하는 능력이 아니다. 이전 버전이 무엇인지 알고, 같은 산출물을 다시 실행할 수 있고, DB가 이전 코드와 호환되도록 미리 설계한 결과다. 준비 없이 배포한 뒤 5분 안에 되돌리겠다는 목표는 지키기 어렵다.
- Next.js 프리페치와 fail2ban: 앱이 자기 IP를 차단시키는 경로오후에 사내에서 서비스가 통째로 안 열린다는 이야기를 들었습니다. 한 곳이 아니라 여러 곳이었습니다. 업무 시스템 데모도, 웹 데스크톱도, 파일 전송망도 전부 응답이 없었습니다. 그런데 같은 주소가 바깥에서는 멀쩡히 열렸습니다.