◆ ESSAY
배포 때마다 에러율 그래프가 튀는 서비스에서 흔히 나오는 첫 질문은 "이번에 뭐가 바뀌었지"다. 그래서 이번 실험은 그 질문을 원천 차단하는 데서 시작했다. 코드도 이미지도 설정도 그대로 두고, 파드만 갈아 끼우는 kubectl rollout restart를 상시 트래픽 아래에서 반복한 것이다. 배포 산출물이 동일하니 에러가 난다면 코드의 죄는 아니다.
결과부터 적으면, 3편까지 쓰던 앱 그대로에 그레이스풀 셧다운까지 멀쩡히 내장된 구성인데도 매번 에러가 났다. 120ms 간격으로 요청을 흘리는 클라이언트 두 개(요청마다 새 커넥션을 여는 쪽과 keep-alive를 재사용하는 쪽)를 두고 배포를 반복하면, 회당 표본 약 370개 중 새 커넥션 쪽에서 ECONNREFUSED가 적게는 1개에서 많게는 4개, keep-alive 쪽에서 ECONNRESET이 3개씩 나온다. 수는 적어 보여도 유형과 시각이 이상할 만큼 일정하다. REFUSED는 배포 직후 1초에서 3초 사이에, RESET은 배포 후 30초를 갓 넘긴 시각에 몰린다.
3편의 마지막에 남겨 둔 질문이 이것이었다. 살아 있는 파드의 readiness 전환은 826개 요청에 에러 0개로 우아하게 끝났는데, 죽을 때도 그러리라는 보장은 없다고 적었다. 실제로 보장이 없었다. 이 글은 그 에러들을 부검한 기록이다. 용의자는 넷이다. 신호를 중간에서 삼키는 PID 1, SIGTERM에 즉사한다고 알려진 Next.js, 죽음을 뒤늦게 아는 라우팅, 그리고 죽은 파드와의 커넥션을 쥐고 놓지 않는 클라이언트. 미리 말해 두면 이 중 하나는 무죄로 판명되고, 대신 통설이 가리키지 않던 범인이 하나 나온다. 부검이 끝나면 처방을 한 층씩 얹어 실패 카운트가 계단처럼 줄어 0에 닿는 것까지 확인한다.
이 글은 "프론트엔드 개발자가 알아야 할 쿠버네티스" 시리즈의 네 번째 편이다. 용어가 낯설면 1편의 개념 지도를, 파드와 컨테이너의 실체는 2편을, 트래픽 경로와 conntrack은 3편을 먼저 읽는 것을 권한다.
측정 환경: Apple M5 macOS 위의 colima VM(4 CPU/8GB), kind v0.32.0(kindest/node v1.36.1, Kubernetes v1.36.1), kube-proxy는 iptables 모드, 앱은 Next.js 16.2.12 standalone(node:24-slim, Node v24.19.0)으로 3편과 같다. 이번 편의 실험용 Deployment는 3레플리카에 readiness probe 5초 간격, 페이지 응답에 RESPONSE_DELAY_MS=400ms를 주입해 종료 시점에 in-flight 요청이 걸쳐 있게 했다. 실패 계수에 대한 정의 하나를 서두에 두어야 한다. 클러스터 안에서 직접 재는 실패는 HTTP 5xx가 아니라 소켓 오류(ECONNREFUSED, ECONNRESET)로 관측된다. 실무 대시보드에서 이것이 5xx로 보이는 것은 앞단의 로드밸런서나 게이트웨이가 업스트림 오류를 502/504로 번역하기 때문이다. 측정 스크립트와 raw 로그는 별도 보관했다.
부검의 첫 단계는 시신의 상태를 있는 그대로 기록하는 것이다. 상시 트래픽 아래에서 파드 하나를 kubectl delete pod로 내리면서, 3편에서 쓴 도구들로 각 층을 약 0.2초 간격(150ms 대기에 명령 실행 시간이 더해진, 3편과 같은 실측 간격)으로 관찰했다. EndpointSlice의 조건, 노드의 KUBE-SEP 규칙(DNAT 대상), 실트래픽의 향방, 그리고 컨테이너의 최종 상태다.
| 사건 (T0 = delete 발행) | 시각 (실측) |
|---|---|
| 해당 파드로의 마지막 실트래픽 | +0.12초 |
| 노드의 KUBE-SEP 규칙(DNAT 대상) 소멸 | +0.19초 |
EndpointSlice 조건 전환: ready=false, serving=true, terminating=true | +0.20초 |
| 컨테이너 종료 관찰, exit code 143 | +0.75초 이내 |
| 파드 오브젝트 소멸 | +1.6초 |
| 이 종료가 만든 트래픽 에러 | 0개 (이 회차) |
주의 깊게 봐야 할 것은 이 회차의 조건이다. 트래픽이 요청마다 새 커넥션을 여는 클라이언트뿐이었고, 그 조건에서 파드 하나의 죽음은 1.6초 만에 에러 없이 끝났다. 신호를 받고(SIGTERM), 드레인하고(143), 명단과 규칙이 0.2초 안에 정리됐다. 이것만 보면 종료는 아무 문제가 없어 보인다. 인트로의 에러들은 이 표에 keep-alive 클라이언트와 3레플리카 동시 교체라는 현실의 조건이 얹힐 때 나타나는데, 그 이야기는 사인들을 하나씩 짚으며 돌아온다.
이 타임라인에서 가장 중요한 사실은 순서가 아니라 순서가 없다는 것이다. 파드에 삭제가 걸리는 순간 두 갈래의 일이 서로를 기다리지 않고 동시에 출발한다. 한쪽에서는 kubelet이 컨테이너에 SIGTERM을 보내고, 다른 쪽에서는 EndpointSlice 컨트롤러가 명단을 고치고 kube-proxy가 각 노드의 규칙을 다시 쓴다. 3편에서 이 전파 구간(명단 갱신부터 규칙 반영까지)을 공식 SLI 메트릭으로 재서 평균 0.76초를 얻었는데, 이번 편의 관찰도 같은 1초 미만 범위에 들어왔다. 문제는 그 1초 미만조차 SIGTERM보다 늦다는 것이다. 앱은 이미 죽음을 통보받았는데, 라우팅은 아직 그 파드로 새 커넥션을 보낼 수 있는 짧은 창이 열린다.
3편에서 이름만 소개하고 미뤄 둔 EndpointSlice의 세 번째 조건도 여기서 회수된다. 종료가 시작된 엔드포인트는 명단에서 바로 사라지는 것이 아니라 ready=false, serving=true, terminating=true로 전환된다. 트래픽 대상에서는 빠졌지만(ready=false) 아직 응답은 할 수 있고(serving=true) 죽는 중(terminating=true)이라는, 임종의 상태 그 자체다.
첫 용의자는 2편의 서두에 나왔던 naive한 Dockerfile, 정확히는 그 마지막 줄 CMD ["npm", "start"]다. 이 이미지로 컨테이너를 띄우고 안을 들여다보면 프로세스가 하나가 아니다.
$ docker exec naive-test ps -eo pid,ppid,comm,args
PID PPID COMMAND
1 0 npm start
18 1 sh -c next start
19 18 next-server (v16.2.12)
PID 1이 npm이고, npm이 셸을 거쳐 진짜 서버(next-server)를 손자로 거느린다. 여기에 5초짜리 요청을 걸어 둔 채 SIGTERM을 보내면 이렇게 된다.
$ docker kill -s TERM naive-test
inflight: http=000 curl_exit=52 (빈 응답, 1.5초 시점 절단)
container: exitCode=1, TERM 후 0.72초 만에 종료
npm error command failed
npm error signal SIGTERM
통설은 이 지점에서 "npm이 신호를 무시해 파드가 30초를 버티다 SIGKILL로 죽는다"고 말하는데, 적어도 이 환경(npm 11, Node 24)에서는 절반만 맞았다. npm은 신호를 무시하지 않는다. 오히려 반응이 지나치게 빨라서 문제였다. SIGTERM을 받은 npm은 0.7초 만에 에러 로그를 남기고 종료해 버리고, PID 1이 사라지면 컨테이너의 나머지 프로세스는 커널이 정리한다. 진행 중이던 5초짜리 요청은 1.5초 시점에 빈 응답으로 절단됐다. 서버는 SIGTERM을 받아 본 적도 없이, 자기 조상이 무너지면서 함께 쓰러진 것이다. 드레인 코드가 있어도 실행될 기회 자체가 없다.
이 구성을 쿠버네티스에 올리고 배포를 6회 반복하면 클라이언트 양쪽 합계로 회당 4개에서 많게는 12개의 실패가 나오는데, 유형이 거의 전부 ECONNRESET(절단)이고 시각은 배포 창에 집중된다. 재미있는 부수 관찰도 있다. 리스너가 워낙 순식간에 사라져서, 우아하게 리스너를 닫는 구성보다 오히려 ECONNREFUSED는 드물다. 빨리 죽는 것과 곱게 죽는 것은 다른 문제다.
처방 노트: 2편의 최종 Dockerfile이 이미 처방이다.
CMD ["node", "server.js"]로 서버 프로세스가 직접 PID 1이 되게 한다. 신호가 서버에 닿는 것이 모든 드레인의 전제 조건이다.
두 번째 용의자는 Next.js 자신이다. "standalone 서버는 SIGTERM을 받으면 in-flight 요청을 버리고 process.exit(0)으로 즉시 죽는다"는 이야기가 오래 돌았고, 실제로 그렇게 동작하던 시절의 이슈가 남아 있다. 그런데 지금 버전의 소스를 열어 보면 이야기가 다르다. Next.js 16.2.12의 next/dist/server/lib/start-server.js에 있는 종료 처리 원문이다.
const cleanup = (signal) => {
// ...(중복 신호 가드 생략)
;(async () => {
// first, stop accepting new connections and finish pending requests,
await new Promise((res) => {
server.close((err) => {
/* ... */ res()
})
if (isDev) {
server.closeAllConnections()
// ...
}
})
// ...(nextServer.close와 트레이스 정리 생략)
// Exit with signal-based exit code (128 + signal number) ...
switch (signal) {
case 'SIGINT':
process.exit(130) // 이하 원문의 break 생략
case 'SIGTERM':
process.exit(143)
}
})()
}
// Make sure commands gracefully respect termination signals (e.g. from Docker)
if (!process.env.NEXT_MANUAL_SIG_HANDLE) {
process.on('SIGINT', cleanup)
process.on('SIGTERM', cleanup)
}
SIGTERM을 받으면 server.close()로 새 커넥션 수신을 멈추고 진행 중인 요청이 끝나기를 기다린 뒤, 신호 종료의 관례적 코드(128+15)인 143으로 내려간다. 실제로 도커에서 5초짜리 요청을 걸고 1초 시점에 SIGTERM을 보내 봤다. 요청은 5.07초에 200으로 완주했고, 컨테이너는 그 직후 exit 143으로 종료됐다. 드레인 중에 새로 들어간 요청만 거부됐다. 즉 이 버전의 Next.js는 무죄다. 신호만 제대로 닿으면 in-flight를 지키는 우아한 종료가 기본 내장이다.
다만 이 무죄 판결에는 흥미로운 각주가 두 개 붙는다. 첫째, 위 원문의 closeAllConnections()(열린 커넥션을 강제로 끊는 Node API)가 isDev 조건 안에 있다. 개발 서버만 커넥션을 강제로 끊고, 프로덕션은 server.close()의 의미론에 전적으로 의존한다는 뜻인데, 이 선택의 대가는 뒤의 인질 절에서 드러난다. 둘째, NEXT_MANUAL_SIG_HANDLE 환경변수를 켜면 이 핸들러 등록 자체를 건너뛴다. 예전 이슈 시절의 해법으로 아직도 블로그들에 이 변수가 돌아다니는데, 지금 이것을 켜고 자기 핸들러를 달지 않으면 어떻게 되는가.
여기서 컨테이너 특유의 규칙이 하나 등장한다. PID 1 프로세스는 핸들러가 등록되지 않은 신호를 무시한다. 일반 프로세스라면 SIGTERM의 기본 동작(종료)이 적용되지만, PID 1에게는 커널이 기본 동작을 적용하지 않는다. 실측으로 확인하면, NEXT_MANUAL_SIG_HANDLE=true만 켠 컨테이너는 SIGTERM을 보내도 아무 일도 일어나지 않고 계속 서빙하다가, 유예가 끝나면 SIGKILL을 받아 exit 137(128+9)로 죽는다. 쿠버네티스에서는 이것이 배포 시간으로 나타난다. 이 구성으로 배포를 3회 반복했더니 새 파드는 늦어도 8초 안에 다 떴는데 옛 파드가 사라지기까지는 매번 34~38초가 걸렸다. terminationGracePeriodSeconds 기본값 30초를 신호 무시로 다 태운 것이다. 통설의 "30초 행"의 진짜 주인공은 npm이 아니라 이쪽, 핸들러 없는 PID 1이었다.
처방 노트: 처방이 "아무것도 하지 말 것"인 드문 경우다.
NEXT_MANUAL_SIG_HANDLE은 직접 핸들러를 등록하겠다는 선언이므로, 등록할 것이 없다면 켜지 않는다. 기본값의 cleanup이 이미 드레인을 한다.
용의자 둘을 정리하고 나면 남는 의문이 있다. 신호도 잘 닿고(PID 1이 node) 드레인도 되는(Next 기본값) 구성에서 왜 여전히 ECONNREFUSED가 나는가. 답은 검안 절의 타임라인에 이미 있다. server.close()는 SIGTERM 즉시 리스너를 닫는데, 그 파드를 가리키는 각 노드의 DNAT 규칙은 1초 가까이 더 산다. 그 짧은 창에 라우팅을 타고 들어온 새 커넥션은 닫힌 리스너에 부딪혀 거부된다. 실측에서 REFUSED가 배포 직후 1~3초(파드들이 순차로 SIGTERM을 받는 구간)에만 몰려 있던 이유다.
이 문제의 교과서적 처방이 preStop 훅이다. 종료 신호를 보내기 전에 잠깐 기다리게 해서, 그 사이에 라우팅이 수렴하게 만드는 것이다. 예전에는 컨테이너 안에서 sleep 명령을 실행하는 식이라 이미지에 sleep 바이너리가 있어야 했지만, 쿠버네티스 v1.34부터 네이티브 sleep 액션이 GA라 매니페스트만으로 된다. 몇 초로 할 것인가에는 이번 시리즈의 실측이 근거가 된다. 3편에서 잰 전파 지연이 평균 0.76초였으니, 여유를 곱해 3초로 걸었다.
lifecycle:
preStop:
sleep:
seconds: 3
효과는 5회 반복에서 재현성 있게 나왔다. 새 커넥션 실패가 5회 전부 0개다. SIGTERM이 3초 미뤄지는 동안 파드는 평소처럼 서빙을 계속하고, 라우팅은 그 사이 조용히 수렴을 끝내서, 리스너가 닫히는 시점에는 이미 새 커넥션이 오지 않는다. 한 가지 유의할 것은 이 3초가 terminationGracePeriodSeconds 안에 포함된다는 점이다. sleep을 길게 잡으면 그만큼 드레인에 쓸 유예가 줄어든다.
처방 노트:
lifecycle.preStop.sleep.seconds: 3(v1.34+). 초수의 근거는 클러스터의 전파 지연 실측이다. "적당히 5초"가 아니라 SLI 메트릭(3편의 network programming latency)을 재서 정하는 쪽이 설명 가능하다.
여기까지의 처방으로 새 커넥션 쪽은 조용해졌는데, keep-alive 클라이언트의 ECONNRESET 3개(3레플리카에서 파드당 1개꼴)는 5회 반복에서 단 한 번도 줄지 않았다. 그리고 이 실패들의 시각이 사건의 성격을 폭로한다. 배포 창이 아니라 배포 후 30~32초, 즉 grace 만료 시점이다.
무슨 일이 벌어지는지 keep-alive 트래픽의 응답을 구간별로 갈라 보면 이렇다. 라우팅에서 진작 빠졌는데도(배포 후 8초부터 34초까지의 구간) 옛 파드들이 keep-alive 커넥션으로 요청을 계속 받아 각각 53~54개씩 처리하고 있었다. 3편에서 본 conntrack 고정 때문에 기존 커넥션은 라우팅 변경의 영향을 받지 않고 원래 파드로 계속 흐르는데, 그 파드의 server.close()는 이 커넥션들을 끊지 못하고 있었던 것이다. 그러다 34초 시점에 SIGKILL이 떨어지자 그 커넥션 위의 요청들이 RST로 절단됐다.
server.close()가 왜 못 끊는지는 Node의 의미론 문제라, 도커에서 소켓 하나짜리 대조 실험으로 확정했다. 유휴 상태의 keep-alive 소켓은 SIGTERM(즉 close 호출) 시점에 즉시 닫힌다. 그런데 그 순간 요청을 처리하던 소켓은 다르다.
TERM 시점까지 처리: 2건
TERM 후 6초간 같은 소켓으로 추가 처리: 19건 (응답에 Connection: close도 실리지 않는다)
클라이언트가 요청을 멈추자: 그제야 드레인 완료, exit 143
close()는 호출 순간에 유휴인 커넥션만 닫는다. 바쁜 커넥션은 이후로도 계속 요청을 받고, 서버는 그 커넥션이 스스로 쉬는 순간을 하염없이 기다린다. 요청이 끊이지 않는 BFF 트래픽에서 그 순간은 오지 않으므로, 드레인은 영원히 끝나지 않고 grace 30초가 상한으로 작동해 SIGKILL이 마무리한다. 앞 절에서 본 closeAllConnections()가 dev 전용이라는 각주가 여기서 대가를 치른다. 프로덕션의 Next.js에는 이 인질극을 서버 쪽에서 끝낼 공개된 손잡이가 없다.
검안 절의 파드 하나짜리 실험을 트래픽만 바꿔 반복하면 인질극의 전모가 표 하나에 들어온다.
같은 delete pod, 트래픽만 다르게 | 새 커넥션만 | keep-alive 재사용 |
|---|---|---|
| 파드 오브젝트 소멸 | +1.6초 | +30.4초 |
| 죽는 동안 그 파드가 처리한 요청 | 0개 (라우팅 이탈 이후) | 61개 (마지막 200이 +29.1초) |
| 절단된 요청 | 없음 | +29.5초에 ECONNRESET |
| 컨테이너 exit code | 143 (드레인 완료) | 137 (grace 만료 SIGKILL) |
같은 코드, 같은 설정, 같은 delete인데 클라이언트의 커넥션 습관 하나로 1.6초짜리 죽음과 30.4초짜리 죽음이 갈린다. 덧붙여 인질 구간의 kubelet 로그에는 readiness probe의 connection refused가 찍힌다. 리스너는 이미 닫혀 probe는 실패하는데 파드는 계속 응답 중인, 임종의 기묘한 상태다.
이 발견은 배포 시간에 대한 통념도 하나 부순다. 그레이스풀 셧다운이 멀쩡히 동작하는 구성(C)의 옛 파드 소진 시간이, 신호를 통째로 무시하는 구성(B)과 똑같이 33~34초였다. keep-alive 클라이언트가 있는 한 "우아한 종료 = 빠른 종료"가 아니다. 우아함은 in-flight를 지켜줄 뿐이고, 종료 시간은 인질 커넥션과 grace가 결정한다.
그러면 마지막 3개의 RESET은 누가 지우는가. 서버가 못 하니 남는 것은 클라이언트다. 그리고 이 시리즈의 전제에서 keep-alive 클라이언트란 남이 아니라 우리가 소유한 BFF다. 절단된 요청은 GET(멱등)이었으므로, 커넥션 수준 실패(ECONNRESET, ECONNREFUSED)에 한해 새 커넥션으로 한 번 다시 보내는 재시도를 클라이언트에 넣고 같은 실험을 5회 반복했다. 실패 0, 재시도로 흡수된 요청 1~3개. 5회 전부다. 인질로 잡혀 있다 절단된 요청들이 새 커넥션을 타고 이미 떠 있는 새 파드에서 완주한 것이다. 흡수가 매회 3개(파드당 1개)가 아닌 것은 재시도의 부수 효과다. 첫 재시도가 커넥션 풀에 새 파드로 가는 커넥션을 만들면 남은 인질 소켓에 요청이 실릴 확률이 낮아지고, 마침 요청이 실려 있지 않은 채 절단된 소켓은 클라이언트에 에러를 남기지 않아 재시도할 것 자체가 없기 때문이다.
처방 노트: 서버가 끝내지 못하는 커넥션 정리는 클라이언트의 몫이다. 멱등 요청에 한해 커넥션 수준 오류를 새 커넥션으로 1회 재시도한다. 비멱등 요청(POST 등)은 함부로 재시도하면 안 되므로, 애초에 오래 사는 커넥션의 수명을 제한하는 쪽(3편의 쏠림 완화책과 같은 방향)을 함께 검토한다.
부검 결과를 모아 처방을 한 층씩 얹은 결과가 이 글의 최종 표다. 같은 조건(새 커넥션·keep-alive 클라이언트 동시, 회당 표본 각 350~414개. 단 A는 관찰 창이 짧아 회당 약 100개인데, A의 실패는 전부 배포 창 안에서 나므로 계수에는 영향이 없다)에서 시나리오별로 배포를 반복한 실패 집계다. 반복 열의 +1은 관찰 조건을 바꿔 추가로 돌린 보충 회차다(A는 옛 파드 소진 시간 실측용, C는 관찰 창을 300초로 늘린 장기 관찰).
| 구성 (누적) | 반복 | 새 커넥션 실패 | keep-alive 실패 | 옛 파드 소진 |
|---|---|---|---|---|
A. naive (npm start, 신호 미달) | 5+1회 | 0~7 (RESET 위주) | 4~5 (거의 전부 RESET) | 즉사 (~4초) |
| B. 신호 무시 (핸들러 없는 PID 1) | 3회 | 0 | RESET 3 | 34~38초 |
| C. 신호 전달 + Next 기본 드레인 | 4+1회 | REFUSED 1~4 (장기 관찰 1회는 6) | RESET 3 (+간헐 REFUSED 1) | 33~34초 |
| D. C + preStop sleep 3초 | 5회 | 0 (5회 전부) | RESET 3 | 32~34초 |
| E. D + 클라이언트 멱등 재시도 | 5회 | 0 | 0 (재시도 흡수 1~3) | 31~33초 |
읽는 축은 실패 유형이 계단마다 하나씩 사라지는 것이다. A에서 C로 가면 절단(RESET)의 대부분이 사라지고 수렴 창의 거부(REFUSED)가 남는다. D의 preStop이 그 거부를 지운다. 그래도 남는 인질 절단 3개는 E의 클라이언트 재시도가 흡수한다. 각 단계가 독립적인 스위치가 아니라 누적이라는 점도 중요하다. 신호가 서버에 닿지 않으면(A) 뒤의 어떤 처방도 실행될 기회가 없다.
한 가지 정직하게 적어 둘 것은 규모다. 이 실험의 실패는 배포당 한 자릿수, 표본의 1% 안팎이다. 문제는 크기가 아니라 성질이다. 이 실패들은 트래픽이 있는 한 배포마다 반드시 나고, 재현 조건이 "배포"라서 코드 리뷰로는 영원히 잡히지 않으며, 트래픽과 레플리카 수에 비례해 자란다. 그리고 계단이 보여주듯 전부 설정과 코드 몇 줄로 지울 수 있는 성질의 것이다.
여기까지의 종료 시퀀스를 비유 하나로 정리해 둔다. 잘 되는 식당의 마감을 생각하면 된다.
| 가게 마감 | 파드 종료 |
|---|---|
| 안내판을 "영업 종료"로 뒤집는다 | EndpointSlice terminating=true 전환 |
| 배달 앱들에서 가게가 내려간다 (앱마다 시차) | 각 노드의 iptables 규칙 갱신 (전파 ~1초) |
| 주방에 마감 공지가 전달된다 | SIGTERM |
| 공지가 홀 매니저 선에서 사라지면 | PID 1이 신호를 삼키는 구성 (사인 1) |
| 앉아 있는 손님은 식사를 마저 한다 | in-flight 드레인 (server.close()) |
| 단골이 "한 그릇만 더"를 계속 외치면 | 바쁜 keep-alive 커넥션의 인질 드레인 (사인 3) |
| 마감 후 30분이 지나면 소등하고 문을 잠근다 | terminationGracePeriodSeconds 만료, SIGKILL |
비유에서 챙길 것은 두 가지다. 배달 앱에서 내려가는 것(라우팅)과 주방 공지(신호)는 서로 다른 경로로 퍼지는 별개의 사건이라는 것, 그리고 소등 시간(grace)은 잔인한 장치가 아니라 "한 그릇만 더"가 끝나지 않을 때를 위한 상한이라는 것이다.
배포 말고도 파드가 죽는 경로는 있다. 그중 실무에서 가장 아픈 것이 probe 오배선이다. 1편에서 liveness 실패는 재시작, readiness 실패는 트래픽 제외라고 표로 갈라 두었는데, 이 구분을 실측으로 확인했다. 같은 앱에 다운스트림(internal-api, 3편의 그 배포)까지 검사하는 "깊은" 헬스체크 /api/health-deep를 만들고, 한쪽 배포는 이것을 liveness에, 다른 쪽 배포는 readiness에 걸었다. 그리고 internal-api를 100초간 정지시켰다.
| 같은 100초의 다운스트림 장애 | liveness에 건 배포 | readiness에 건 배포 |
|---|---|---|
| 컨테이너 재시작 | 4~5회, CrashLoopBackOff 진입(back-off 40s) | 0회 |
| 파드 상태 | 재시작 반복으로 요동 | NotReady (트래픽만 이탈) |
| 다운스트림 복구 후 | 백오프 대기를 거쳐 복귀 | 즉시 Ready 복귀 |
앱 프로세스는 양쪽 다 시종일관 멀쩡했다는 것이 이 표의 핵심이다. 죽을 이유가 없는 프로세스가 이웃의 장애 때문에 15초(5초 간격 3연속 실패)마다 재시작 판정을 받았고, 재시작이 반복되자 다음 절의 백오프까지 걸려 복구가 더 느려졌다. 다운스트림 장애는 그 파드를 재시작한다고 낫지 않는다. liveness에는 프로세스 자신의 생존만, 의존성의 상태는 readiness에 거는 것이 이 실측의 결론이다.
처방 노트: livenessProbe는 앱 자신만 보는 얕은 엔드포인트로. 의존성 검사가 필요하면 readinessProbe로 배선한다. 회복 스위치와 트래픽 스위치는 다른 회로다.
파드가 뜨자마자 죽기를 반복하면 kubelet은 재시작 간격을 지수적으로 벌린다. 1편에서 "10초에서 시작해 최대 5분"이라고 적었던 CrashLoopBackOff다. 시작하자마자 exit 1로 죽는 컨테이너를 13분간 관찰해 그 시간표를 실측했다.
재시작 시각(상대): 0, 0, +13s, +40s, +86s, +172s, +337s, 이후 "back-off 5m0s"
(첫 크래시 직후 한 번은 즉시 재시작되고, 백오프는 그다음부터 걸린다)
간격 수열: 약 10 → 20 → 40 → 80 → 160 → 300초(상한) + 기동 오버헤드 3~7초
두 배씩 벌어져 5분에서 멈추는 교과서적 수열이 그대로 나왔다. 이 실측에는 판정의 의미도 있다. 기본 백오프를 초기 1초, 상한 1분으로 낮추는 KEP-4603이 진행 중이라 어느 버전부터 이 시간표가 달라질 수 있는데, 적어도 v1.36 기본값은 아직 10초/5분이다.
종료 코드 읽는 법도 이 자리에서 정리해 둘 만하다. 이 글의 실측에서만 네 가지가 나왔다.
| exit code | 뜻 | 이 글에서 나온 장면 |
|---|---|---|
| 0 | 정상 종료 | (배포 중에는 오히려 보기 어렵다) |
| 1 | 앱/래퍼의 에러 종료 | npm이 SIGTERM에 에러로 종료할 때 (사인 1) |
| 143 | 128+15, SIGTERM 받고 자발 종료 | Next 드레인 완료의 정상 시그니처 |
| 137 | 128+9, SIGKILL로 강제 종료 | grace 소진(신호 무시·인질), 그리고 OOMKilled |
같은 137이라도 원인이 갈린다는 점이 함정이다. grace를 소진해 죽은 137과 메모리 초과로 죽은 137은 숫자가 같고, kubectl describe의 Reason(OOMKilled 여부)과 사이징 글에서 다룬 메모리 정황으로 구분한다.
지금까지는 파드 하나의 죽음을 봤다면, 마지막 질문은 무리의 관리다. 롤링 업데이트가 한 번에 몇 개를 죽이고 몇 개를 더 띄우는지는 Deployment의 maxSurge(정원 초과 허용량)와 maxUnavailable(결원 허용량)이 정한다. 3레플리카에서 두 극단을 실측으로 대조했다.
| 보폭 (3레플리카) | 교체 중 Ready 파드 수 | 총 파드 수 | 완료까지 |
|---|---|---|---|
| maxSurge=1, maxUnavailable=0 | 3 유지 | 최대 4 (초과) | 3.4초 |
| maxSurge=0, maxUnavailable=1 | 2로 감소 | 3 유지 | 3.3초 |
이 랩은 파드 기동이 1초 안쪽이라 총 소요는 구분이 안 되지만, 관전 지점은 시간축이 아니라 수용량 곡선이다. 첫 행은 새 파드를 정원 초과로 먼저 띄우고 준비가 끝난 만큼만 옛 파드를 줄이므로 교체 내내 결원이 없다. 둘째 행은 정원을 넘지 않는 대신 교체 내내 3분의 1이 결원이다. 노드 자원이 빠듯해 정원 초과가 불가능한 환경이 아니라면, 사용자 대상 서비스에서 결원 없는 첫 행을 마다할 이유는 별로 없다고 생각한다. 참고로 앞 절들의 grace 소진 시나리오처럼 옛 파드가 30초씩 늘어져도 Ready 수와 진행 판정은 달라지지 않는다. 롤링 업데이트의 진행 판정은 새 파드의 readiness 기준이라 Terminating 파드는 정원 계산에서 이미 빠져 있고, 다만 그 파드들까지 세면 그 순간 떠 있는 파드 수 자체는 표의 숫자보다 많을 수 있다.
여기에 헷갈리기 쉬운 안전장치가 하나 더 있다. 1편에서 약속만 하고 미뤄 둔 PDB(PodDisruptionBudget)다. "동시에 내려가도 되는 파드 수의 한도"라는 정의 때문에 배포를 제어하는 장치로 오해되곤 하는데, 실측으로 경계를 확인했다. 3레플리카 전부를 요구하는(minAvailable: 3) PDB를 걸어 두고 세 가지 방법으로 파드를 내려 본 것이다. 이때 롤링 업데이트 쪽은 실험이 결정력을 갖도록 보폭을 일부러 maxSurge=0, maxUnavailable=1로 바꿔 뒀다. 앞 절의 결원 없는 보폭이라면 Ready가 3 밑으로 내려갈 일이 없어서, PDB가 배포에 적용되는지 여부와 무관하게 아무 일도 일어나지 않기 때문이다.
# 1. eviction API (kubectl drain이 쓰는 경로)
$ kubectl create --raw /api/v1/namespaces/default/pods/graceful-lab-.../eviction -f eviction.json
Error from server (TooManyRequests): Cannot evict pod as it would
violate the pod's disruption budget.
# 2. delete
$ kubectl delete pod graceful-lab-...
pod "graceful-lab-..." deleted
# 3. 롤링 업데이트 (maxSurge=0, maxUnavailable=1: 교체 중 예산을 실제로 위반하는 보폭)
$ kubectl rollout restart deploy/graceful-lab
(교체 중 Ready가 2까지 내려가 minAvailable: 3을 위반하는데도, PDB가 막지 않고 그대로 완료된다)
eviction(노드 비우기, 드레인 같은 자발적 중단이 쓰는 API)만 거부되고, delete와 롤링 업데이트는 PDB를 그대로 통과한다. 특히 롤링 업데이트는 PDB가 요구하는 3을 깨고 Ready 2로 내려가면서도 멈추지 않았다. PDB는 배포의 보폭과는 무관한, 관리 작업으로부터의 안전장치다. 배포 중 동시 결원을 줄이는 것은 maxUnavailable의 일이고, 노드 교체 중 서비스가 통째로 비는 것을 막는 것이 PDB의 일이다.
처방 노트: 사용자 대상 서비스라면 maxUnavailable: 0(결원 없이 교체)을 기본으로 검토하고, PDB는 노드 운영(드레인, 업그레이드)을 위해 별도로 건다. 둘은 겹치지 않는 보험이다.
처방 노트들을 한곳에 모으면 이 글 전체가 파일 세 개로 압축된다. 각 줄의 출처 절을 주석으로 달았다.
# Dockerfile (러너 스테이지)
ENV HOSTNAME=0.0.0.0 # 2편: 파드 IP 바인드 함정
CMD ["node", "server.js"] # 사인 1: 서버가 직접 PID 1이 되어 신호를 받는다
# NEXT_MANUAL_SIG_HANDLE은 켜지 않는다 (무죄 판명 절: 기본 드레인이 동작하게)
# Deployment 발췌
spec:
strategy:
rollingUpdate:
maxSurge: 1
maxUnavailable: 0 # 보폭 절: 결원 없이 교체
template:
spec:
terminationGracePeriodSeconds: 30 # 인질 절: 드레인이 못 끝날 때의 상한 (preStop 포함)
containers:
- name: app
lifecycle:
preStop:
sleep:
seconds: 3 # 사인 2: 라우팅 수렴(실측 0.76s)을 기다린 뒤 SIGTERM
readinessProbe:
httpGet: {path: /api/health, port: 3000} # probe 절: 의존성 검사가 필요하면 이 probe를 별도의 깊은 경로로 바꾼다
livenessProbe:
httpGet: {path: /api/health, port: 3000} # probe 절: liveness는 앱 자신의 생존만. readiness에 깊은 검사를 얹는다면 이 경로와 반드시 분리한다
// BFF의 내부 호출 (발췌): 사인 3의 인질 절단은 클라이언트만 지울 수 있다
// 멱등 요청 한정, 커넥션 수준 오류만 새 커넥션으로 1회 재시도
const RETRIABLE = new Set(['ECONNRESET', 'ECONNREFUSED', 'EPIPE'])
async function getWithRetry(url) {
try {
return await fetchOnce(url)
} catch (e) {
if (!RETRIABLE.has(e.cause?.code)) throw e
return await fetchOnce(url, {freshConnection: true})
}
}
부검 결과를 단서 순서대로 다시 적는다.
terminating=true로 뒤집는다. 배포 중 에러의 뿌리가 이 경주다.server.close()로 드레인하고 143으로 내려가는 것이 기본값이다. 다만 커넥션 강제 종료는 dev 전용이라는 각주가 붙는다.이번 편으로 파드 하나의 생애는 탄생(2편), 트래픽(3편), 죽음(이 글)까지 이어졌다. 남은 것은 무리의 크기가 출렁이는 이야기다. 스케일 다운이 내리는 파드도 이 글의 종료 시퀀스를 그대로 타고, 스케일 아웃의 반대편에는 "새 파드가 첫 요청을 받기까지"라는 또 다른 시계가 있다. HPA는 그 시계를 얼마나 기다리게 만드는가. 다음 편인 오토스케일링 편에서 그 구간들을 스톱워치로 분해할 예정이다.