Graceful shutdown(우아한 종료)은 서버 프로세스를 종료할 때, 이미 처리 중인 요청은 끝까지 마무리하고 새 요청은 받지 않으면서 자원을 정리한 뒤 내려가는 방식입니다. 여기에 커넥션 드레이닝(connection draining)이 붙는데, 로드밸런서가 "이 인스턴스는 이제 곧 죽으니 새 트래픽을 보내지 말라"고 표시하고 기존 연결이 자연히 빠지길 기다리는 과정을 말합니다.
무중단 배포의 마지막 1%가 바로 여기 있습니다. 코드가 아무리 완벽해도 배포 때마다 502 에러가 몇 개씩 튀는 경험, 다들 있을 겁니다. 저도 그랬습니다. 원인의 상당수는 "프로세스를 그냥 죽여서" 처리 중이던 요청이 중간에 잘려나간 것이었습니다.
왜 그냥 kill하면 안 되나
배포 도구가 컨테이너에 SIGTERM을 보내면, 아무 처리도 안 한 프로세스는 그 순간 죽습니다. 그런데 그 찰나에 벌어지는 일들을 보면 아찔합니다.
- 3초짜리 결제 요청을 처리하던 중이었다면, 그 요청은 응답 없이 끊깁니다. 사용자는 결제가 됐는지 안 됐는지 모릅니다.
- 로드밸런서는 아직 이 인스턴스가 죽은 줄 모르고, 그새 새 요청을 계속 보냅니다. 전부 연결 거부로 실패합니다.
- DB 커넥션, 메시지 컨슈머, 열린 파일이 정리 안 된 채 사라져 자원이 샙니다.
올바른 종료 순서
핵심은 순서입니다. 저는 이 순서를 몸으로 익힌 뒤부터 배포 중 에러가 사라졌습니다.
- SIGTERM 수신 — 종료 신호를 잡는다. 여기서 바로 죽지 않는다.
- readiness를 실패로 전환 — 헬스체크(준비성 프로브)를 실패로 바꿔, 로드밸런서가 새 트래픽을 끊게 한다.
- 드레이닝 대기 — 로드밸런서가 이 변화를 감지할 때까지 몇 초 기다린다. 이 대기가 없으면 여전히 새 요청이 들어온다.
- 새 연결 수신 중단 — 리스너를 닫아 더는 새 요청을 받지 않는다.
- 진행 중 요청 완료 대기 — 처리 중인 요청이 끝날 때까지, 단 타임아웃을 걸고 기다린다.
- 자원 정리 — DB 풀, 컨슈머, 캐시 연결을 닫고 프로세스를 끝낸다.
// Node.js/Express 예시
const server = app.listen(3000)
let shuttingDown = false
app.get('/healthz', (req, res) =>
res.status(shuttingDown ? 503 : 200).end())
process.on('SIGTERM', async () => {
shuttingDown = true // 2) readiness 실패
await sleep(5000) // 3) LB가 감지할 시간
server.close(async () => { // 4) 새 연결 차단 + 5) 기존 완료
await db.end() // 6) 자원 정리
await consumer.disconnect()
process.exit(0)
})
setTimeout(() => process.exit(1), 25000) // 안전장치: 강제 종료
})
여기서 3번의 "몇 초 대기"를 빼먹는 실수가 정말 흔합니다. server.close()만 부르면 이미 연결된 요청은 지키지만, 로드밸런서가 아직 이 인스턴스를 정상이라 여겨 보내는 새 요청은 여전히 거부됩니다. readiness를 먼저 내리고 잠깐 기다리는 게 502를 없애는 핵심입니다.
쿠버네티스에서 특히 조심할 것
쿠버네티스는 파드를 종료할 때 엔드포인트 제거와 SIGTERM 전송이 병렬로 일어납니다. 즉 SIGTERM을 받은 시점에도 잠깐은 서비스 라우팅 테이블에 이 파드가 남아 새 트래픽이 올 수 있습니다. 그래서 위의 "드레이닝 대기"가 반드시 필요하고, 또 하나 챙길 게 terminationGracePeriodSeconds입니다.
| 설정 | 역할 | 주의점 |
|---|---|---|
| terminationGracePeriodSeconds | SIGTERM 후 대기 시간 | 넘기면 SIGKILL(강제) |
| preStop 훅 | 종료 전 지연·드레이닝 | 유예 시간에 포함됨 |
| readinessProbe | 트래픽 라우팅 판단 | 종료 시 실패로 전환 |
유예 시간이 처리 중인 요청의 최대 소요보다 짧으면, 못 끝낸 요청이 SIGKILL로 잘립니다. 반대로 너무 길게 잡으면 배포가 느려지죠. 저는 보통 "가장 긴 요청 처리시간 + 드레이닝 여유"로 잡습니다.
흔히 빠뜨리는 것들
- 백그라운드 워커·컨슈머도 graceful 대상입니다. 카프카 컨슈머가 메시지를 처리하다 잘리면 재처리(중복)가 발생하니, 현재 배치를 마치고 오프셋을 커밋한 뒤 종료해야 합니다.
- SIGKILL은 잡을 수 없습니다. 우아한 종료 로직은 SIGTERM에만 걸어야 하고, SIGKILL이 오기 전에 끝내야 합니다.
- keep-alive 연결은 드레이닝을 방해할 수 있습니다. 종료 시
Connection: close를 보내 클라이언트가 재연결하도록 유도하면 깔끔합니다.
자주 묻는 질문
드레이닝 대기는 몇 초가 적당한가요?
로드밸런서나 쿠버네티스가 엔드포인트 변화를 감지하는 주기에 달렸습니다. 보통 5~15초면 충분하지만, 환경마다 다르니 배포 중 502가 나는지 실제로 관측하며 조정하세요.
SIGTERM을 무시하고 바로 죽어도 되는 경우가 있나요?
완전히 무상태이고 처리 시간이 극히 짧은 서비스라면 영향이 작을 수 있습니다. 하지만 로드밸런서 라우팅 지연 문제는 상태와 무관하게 발생하므로, readiness를 내리고 잠깐 기다리는 최소한의 처리는 권장합니다.
진행 중 요청이 유예 시간 안에 안 끝나면요?
타임아웃을 걸어 강제 종료해야 합니다. 무한정 기다리면 배포가 멈춥니다. 대신 그런 초장기 요청은 애초에 비동기 작업으로 빼는 설계를 고려하세요.
graceful shutdown이 무중단 배포를 완전히 보장하나요?
절반의 조건입니다. 나머지 절반은 롤링 업데이트로 새 인스턴스가 준비된(readiness 통과) 뒤에 기존 것을 내리는 배포 전략입니다. 이 둘이 맞물려야 진짜 무중단이 됩니다.

댓글 0