Go에서 SIGTERM 시 우아한 종료 및 요청 드레이닝
플랫폼이 SIGTERM을 보내면 프로세스가 즉시 종료되어 처리 중인 요청이 손실됩니다. 다음은 순서대로 새로운 트래픽을 중단하고, 드레이닝하고, 정리하는 방법입니다.
종료 신호를 받는 즉시 종료되는 컨테이너는 처리 중이던 모든 요청을 끊어버립니다. 클라이언트는 연결 재설정이나 5xx 오류를 보게 되고, 열려 있던 데이터베이스 트랜잭션은 쓰기 도중 롤백되며, 버퍼링된 로그 배치는 플러시되지 않고, 프로세스가 점유하고 있던 락은 다른 곳에서 타임아웃될 때까지 유지됩니다. 해결책은 복잡하지 않지만, 특정 순서대로 진행되어야 합니다. 즉, 새로운 작업을 받지 않고, 진행 중인 작업은 데드라인 내에 완료되도록 하며, 그런 다음 리소스를 해제하는 것입니다. 이 글에서는 Go 언어로 그 순서를 단계별로 살펴봅니다. 신호 처리, 서버 드레인, 정리 훅을 다루고, 플랫폼이 중간에 프로세스를 종료하지 않도록 데드라인을 어떻게 맞춰야 하는지도 알아봅니다.
종료는 예외가 아닌 정상적인 이벤트입니다
첫 번째 인식의 전환은 종료를 드문 실패로 취급하는 것을 멈추는 것입니다. 컨테이너 플랫폼에서 이는 프로세스에 가장 빈번하게 일어나는 일 중 하나입니다. 오토스케일러는 부하가 있을 때 인스턴스를 추가하고 부하가 줄어들면 제거합니다. 롤링 배포는 모든 인스턴스를 하나씩 교체합니다. 노드는 유지보수를 위해 드레이닝(drained)되며, 그 위의 모든 컨테이너는 종료를 요청받습니다. 스케일 투 제로(scale-to-zero) 서비스는 유휴 시간 이후에 해체됩니다. 이 모든 경우는 프로세스에 동일한 메시지로 끝납니다. 즉, 종료 신호(보통 SIGTERM)가 전달되고, 유예 기간 내에 스스로 종료하지 않으면 강제적인 SIGKILL이 이어집니다.
머신이 충돌할 때만 종료를 생각한다면, 이를 불충분하게 구축하게 될 것입니다. 왜냐하면 충돌은 드물고 가끔 요청이 누락되는 것은 용납할 수 있기 때문입니다. 하지만 배포는 드문 일이 아닙니다. 모든 배포가 교체되는 인스턴스에서 처리 중이던 요청을 누락시킨다면, 모든 배포가 일부 사용자에게 오류를 보여주게 됩니다. 하루에 여러 번 배포하고 하루 종일 스케일 업/다운하는 서비스에서 "SIGTERM을 올바르게 처리하는 것"은 있으면 좋은 것이 아닙니다. 이는 배포가 보이지 않는 것과 배포가 작은 장애가 되는 것의 차이입니다.
기본적으로 많은 프로그램은 SIGTERM에 대해 특별한 작업을 수행하지 않습니다. 신호의 기본 동작은 프로세스를 즉시 종료하는 것이며, 그것은 바로 여러분이 원하지 않는 것입니다. 그 즉각적인 종료가 구체적으로 어떤 대가를 치르게 하는지는 다음과 같습니다.
- 처리 중인 HTTP 요청이 끊어집니다. 클라이언트는 연결 재설정 또는 불완전한 응답을 받게 되며, 메서드에 따라 재시도가 안전할 수도 있고 그렇지 않을 수도 있습니다.
- 열려 있는 트랜잭션이 중단됩니다. 요청이 다중 구문 쓰기 중간에 있었다면 데이터베이스는 이를 롤백하는데, 이는 적어도 일관성은 있지만 사용자의 작업은 조용히 일어나지 않은 일이 됩니다.
- 버퍼링된 쓰기가 손실됩니다. 나중에 플러시하기 위해 메모리에 일괄 처리한 모든 것, 즉 로그 라인, 메트릭, 아웃바운드 이벤트 큐 등이 프로세스와 함께 사라집니다.
- 보유한 리소스가 깔끔하게 해제되지 않습니다. 분산 락, 리스(lease), 네트워크 반대편 풀의 사용 중인 연결 등 이 모든 것들은 이제 제대로 반환되는 대신 타임아웃에 의존하여 회수되어야 합니다.
이 중 어떤 것도 로컬 테스트에서는 나타나지 않습니다. 로컬에서는 서버가 유휴 상태일 때 Ctrl-C로 중지하기 때문입니다. 이는 프로덕션 환경에서, 모든 배포에 걸쳐 엷게 퍼져 나타나며, 트래픽이 아닌 자체 배포와 상관관계가 있기 때문에 원인을 찾기 어려운 낮은 백그라운드 오류율로 나타납니다.
순서대로 보는 종료 시퀀스
전체 기술은 정해진 순서에 따른 세 단계로 구성됩니다. 순서를 틀리는 것이 가장 흔한 실수이므로, 코드를 보기 전에 명확하게 설명할 가치가 있습니다.
| 단계 | 작업 | 이 위치에 있는 이유 |
|---|---|---|
| 1. 수신 중단 | 준비 상태 확인(readiness check)에 실패 처리하고, 새 연결 수신을 중단합니다 | 대기하기 전에 새 요청이 중단되어야 합니다. 그렇지 않으면 큐가 절대 비워지지 않습니다 |
| 2. 드레이닝(Drain) | 마감 시간 내에 진행 중인 요청이 완료되기를 기다립니다 | 이미 수락된 요청은 완료되어야 합니다 |
| 3. 정리(Clean up) | 버퍼를 플러시하고, 잠금을 해제하고, 연결 및 풀을 닫습니다 | 해당 리소스를 더 이상 사용하는 것이 없을 때만 안전합니다 |
| 4. 남은 작업 포기 | 마감 시간을 초과한 모든 것을 포기하되, 로그로 남깁니다 | 플랫폼이 곧 SIGKILL을 보낼 것입니다. 응답 없는(hung) 프로세스보다는 남은 작업을 로그로 남기는 것이 낫습니다 |
수신을 먼저 중단해야 하는 이유는 구조적입니다. 새 요청이 계속 도착하는 동안 진행 중인 요청을 기다리기 시작하면, 진행 중인 요청 집합은 절대 0으로 줄어들지 않으며, 드레이닝은 매번 마감 시간까지 실행됩니다. 유입을 중단하면 기존 요청이 하나씩 완료되고, 카운트는 저절로 0에 도달합니다. 핸들러가 아직 실행 중일 때 데이터베이스 연결을 닫고 버퍼를 플러시하는 것은 그 기반을 무너뜨리는 것과 같기 때문에, 오직 그 이후에야 안전합니다.
Go에서 신호 잡기
Go는 신호 관련 부분을 깔끔하게 처리합니다. 표준 라이브러리에 signal.NotifyContext가 추가되었으므로, SIGTERM과 SIGINT 신호 중 하나라도 도착하면 취소되는 컨텍스트로 변환하고, 전체 종료 과정을 해당 취소에 연결할 수 있습니다.
func main() {
ctx, stop := signal.NotifyContext(context.Background(),
syscall.SIGTERM, syscall.SIGINT)
defer stop()
srv := &http.Server{Addr: ":8080", Handler: newRouter()}
// Serve in a goroutine so main can wait on the signal.
go func() {
if err := srv.ListenAndServe(); err != nil &&
!errors.Is(err, http.ErrServerClosed) {
log.Fatalf("listen: %v", err)
}
}()
<-ctx.Done() // blocks until SIGTERM or SIGINT
log.Println("shutdown signal received, draining")
if err := drain(srv); err != nil {
log.Printf("graceful shutdown incomplete: %v", err)
}
log.Println("shutdown complete")
}
중요한 세부 사항은 ListenAndServe를 의도적으로 종료하면 http.ErrServerClosed를 반환한다는 것이며, 이는 실패로 처리할 오류가 아니라 정상적이고 예상된 반환 값이라는 점입니다. 이를 치명적인 오류로 처리하는 것은 정상적인 종료 시 불필요한 오류를 로그에 기록하게 만드는 흔한 버그입니다.
Shutdown으로 진행 중인 요청 드레이닝하기
서버 자체의 Shutdown 메서드가 드레인(drain)입니다. 이 메서드는 리스너를 중지하여 새로운 연결이 수락되지 않도록 한 다음, 모든 활성 요청이 반환되거나 전달한 컨텍스트가 취소될 때까지 블로킹합니다. 바로 그 컨텍스트에 데드라인이 있습니다.
func drain(srv *http.Server) error {
// Stop routing to this instance first.
health.SetNotReady()
// Give in-flight requests a bounded window to finish.
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
// Shutdown stops new connections, then waits for active ones.
if err := srv.Shutdown(ctx); err != nil {
// Deadline hit: some requests were still running. Give up on
// them but record it, do not hang waiting for a SIGKILL.
return fmt.Errorf("drain deadline exceeded: %w", err)
}
// Safe now: nothing is still serving. Release resources.
return cleanup()
}
Shutdown이 nil을 반환하는 것은 드레인이 깔끔하게 완료되고 모든 요청이 완료되었음을 의미합니다. 컨텍스트의 데드라인 오류를 반환하는 것은 요청이 여전히 활성 상태인 채로 시간이 초과되었음을 의미합니다. 이것은 크래시가 아닙니다. 이는 더 오래 기다리는 것이 계속 진행하는 것보다 나쁘다고 당신이 결정하는 것이며, 어차피 플랫폼이 SIGKILL을 보내려고 할 때 올바른 판단입니다. 데드라인 초과 비율이 증가하는 것을 볼 수 있도록 해당 사실을 로그로 남기십시오. 이는 보통 요청이 허용된 시간보다 오래 걸리고 있으며, 시간 창이나 요청에 주의가 필요하다는 것을 의미하기 때문입니다.
한 가지 주의할 점: Shutdown은 WebSocket이나 다른 오래 지속되는 스트림과 같은 하이재킹된 연결을 닫지 않으며, 해당 연결들을 기다립니다. 만약 그러한 연결이 있다면, 드레인이 실제로 완료될 수 있도록 별도로 닫으라는 신호를 보내야 합니다. 예를 들어, 그들이 선택하는 컨텍스트를 취소하는 방법이 있습니다.
drain하기 전에 상태 확인을 실패시키세요
drain이 Shutdown을 호출하기 전에 health.SetNotReady()를 호출한다는 점에 주목하세요. 이것이 사람들이 놓치는 부분이며, 이것이 없으면 drain은 경쟁 상태(racy)가 됩니다.
인스턴스 앞단에는 준비 상태(readiness) 또는 상태 확인(health check)을 기반으로 트래픽을 어디로 라우팅할지 결정하는 로드 밸런서나 서비스 메시가 있습니다. 만약 바로 Shutdown으로 넘어가면, 로드 밸런서가 이 인스턴스가 여전히 정상(healthy)이라고 믿고 계속해서 새로운 요청을 보내는 시간 창이 존재하며, 이 요청들은 이제 연결을 거부하는 서버에 도달하게 됩니다. 그 결과는 여러분이 피하려고 했던 바로 그 요청 누락(dropped requests)이며, 단지 다른 지점으로 옮겨졌을 뿐입니다.
해결책은 먼저 준비 상태(readiness) 엔드포인트를 실패 상태로 전환한 다음, 로드 밸런서가 이를 인지하고 풀(pool)에서 여러분을 제거할 때까지 충분히 기다리고, 그런 다음에야 실제 drain을 시작하는 것입니다. 실제로는 상태 확인 간격(health check interval)에 맞춰진 짧은 sleep처럼 보입니다.
func (h *Health) SetNotReady() { h.ready.Store(false) }
func (h *Health) ReadyHandler(w http.ResponseWriter, r *http.Request) {
if h.ready.Load() {
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusServiceUnavailable)
}
SetNotReady 후, 잠시(헬스 체크 간격의 몇 배) 멈추면 Shutdown이 문을 닫기 전에 로드 밸런서가 라우팅을 중지할 수 있습니다. 정확한 중지 시간은 플랫폼이 사용자를 프로빙하는 방식에 따라 다르지만 원칙은 어디에서나 동일합니다. 즉, 실제로 사용 불가능해지기 전에 사용 가능하다고 알려지는 것을 중단해야 합니다.
정리 훅: 플러시, 해제, 닫기
Shutdown이 정상적으로 반환되면 실행 중인 핸들러가 없으므로, 핸들러가 의존하는 것들을 해체하는 것이 마침내 안전해집니다. 이 단계에서 그 순서대로 버퍼를 플러시하고, 임대와 잠금을 반환하고, 연결 풀을 닫습니다.
func cleanup() error {
var errs []error
// Flush anything batched in memory before connections close.
if err := logBuffer.Flush(context.Background()); err != nil {
errs = append(errs, fmt.Errorf("flush logs: %w", err))
}
if err := metrics.Flush(context.Background()); err != nil {
errs = append(errs, fmt.Errorf("flush metrics: %w", err))
}
// Release distributed locks and leases so peers can proceed now,
// instead of waiting for the lease to time out.
if err := locks.ReleaseAll(context.Background()); err != nil {
errs = append(errs, fmt.Errorf("release locks: %w", err))
}
// Close pools last, after flush and release have used them.
db.Close()
cache.Close()
return errors.Join(errs...)
}
정리 작업 내부에서도 순서가 중요합니다. flush에 필요한 연결을 닫기 전에 flush를 실행하세요. 잠금 저장소(lock store)와 통신하는 클라이언트를 닫기 전에 잠금을 해제하세요. 풀(pool)은 마지막에 닫으세요. 만약 데이터베이스 풀을 먼저 닫고 데이터베이스에 쓰는 배치를 flush하려고 하면, flush가 실패하여 배치를 잃게 되는데, 이는 여러분이 막으려 했던 손실입니다.
잠금을 명시적으로 해제하는 점은 강조할 만합니다. 만약 임대 만료(lease expiry)에만 의존하면, 정상적인 종료(clean shutdown) 시에도 전체 임대 기간 동안 잠금이 유지되어 어떤 피어(peer)도 잠금을 획득할 수 없습니다. 종료 시 잠금을 반환하면 다음 인스턴스가 즉시 진행할 수 있으며, 임대 만료는 프로세스가 갑자기 종료되는 비정상적인 경우를 위한 안전망으로 남게 됩니다.
드레인 마감 시간을 플랫폼의 유예 기간보다 짧게 유지하세요
드레인 마감 시간은 플랫폼이 SIGTERM과 SIGKILL 사이에 제공하는 유예 기간보다 반드시 짧아야 합니다. 이것이 전체를 하나로 묶는 제약 조건이며, 관련 수치가 서로 다른 두 곳에 있기 때문에 잘못 설정하기 쉽습니다.
플랫폼이 SIGTERM을 보내고 30초 후에도 종료되지 않으면 SIGKILL을 보낸다고 가정해 봅시다. 준비성 일시 중지, 드레인, 정리 작업을 더한 전체 종료 과정은 여유를 두고 그 30초 안에 완료되어야 합니다. 만약 드레인 시간만 30초로 설정하면, 준비성 일시 중지와 정리 작업 때문에 유예 기간을 초과하게 되고, 정리 작업 중간에 SIGKILL이 도착하여 플러시하던 버퍼를 잃게 됩니다. 30초의 유예 기간에 대한 안전한 시간 분배는 몇 초의 준비성 일시 중지, 약 20~25초의 드레인, 그리고 정리 작업을 위해 남겨둔 몇 초가 될 수 있습니다.
두 가지 실용적인 참고 사항이 있습니다. 첫째, 플랫폼의 유예 기간을 설정할 수 있다면 기본값을 신뢰하기보다 명시적으로 설정하세요. 기본값은 종종 짧고(보통 10초), 10초의 유예 기간에 25초의 드레인을 설정하면 항상 SIGKILL이 이기게 되기 때문입니다. 둘째, 마감 시간은 목표가 아니라 상한선입니다. 대부분의 요청이 짧기 때문에, 대부분의 종료는 제한 시간보다 훨씬 짧은 시간 안에 드레인됩니다. 마감 시간은 느린 꼬리 부분(slow tail)에서만 문제가 되며, 마감 시간에 도달하는 경우는 각 발생 사례를 로그 한 줄로 남길 가치가 있을 정도로 매우 드물어야 합니다.
멱등성, 재시도, 그리고 드레이닝의 비용
올바른 드레이닝이라도 결국 무언가를 유실합니다. 전체 유예 기간보다 정말로 느린 요청, 로드 밸런서가 반응하기 전 준비 기간(readiness window) 동안 끊어지는 연결, 프로세스가 멈춰서 발생하는 깔끔하지 않은 SIGKILL 등이 그 예입니다. 정상 종료(Graceful shutdown)는 유실되는 집합을 거의 0으로 줄이지만, 거의 0은 0이 아니므로 마지막 계층은 유실된 요청이 살아남을 수 있도록 만드는 것입니다.
두 가지 속성이 대부분의 작업을 수행합니다. 쓰기 작업을 멱등성 있게 만들어, 확신할 수 없는 요청을 재시도하는 클라이언트나 업스트림이 이를 이중으로 적용하지 않도록 합니다. 요청에 있는 멱등성 키를 최근 작업과 비교하여 확인하면, 재시도된 결제나 생성을 안전한 무연산(no-op)으로 바꿉니다. 그리고 연결이 재설정될 때 클라이언트가 멱등성 있는 요청을 재시도하도록 허용하십시오. 왜냐하면 롤링 배포 중에는 재시도가 보통 정상 인스턴스에 도달하여 성공하기 때문입니다. 이 둘을 합치면 드레이닝을 통과하는 드문 요청이 유실되는 대신 재시도되어 완료된다는 것을 의미합니다.
이것이 또한 완벽한 드레이닝을 추구해서는 안 되는 이유입니다. 마지막 느린 요청을 잡기 위해 마감 시간을 늘리는 것에는 실제 비용이 따르며, 멱등성과 재시도는 더 긴 마감 시간보다 훨씬 저렴하게 그 꼬리 부분(tail)을 처리합니다.
드레이닝은 공짜가 아닙니다. 이제 모든 배포는 각 인스턴스가 종료되기 전에 해당 인스턴스에서 가장 느린 진행 중인 요청을 기다립니다. 따라서 롤링 배포는 드레이닝 마감 시간과 롤아웃 형태에 따라 제한되어 더 오래 걸립니다. 스케일 다운(축소)도 같은 이유로 더 느립니다. 제거된 인스턴스가 드레이닝될 때까지 남아있기 때문입니다. 만약 넉넉하게 25초의 마감 시간을 설정하고 롤아웃이 인스턴스를 순차적으로 교체한다면, 추가되는 몇 분은 실제입니다.
조정 손잡이는 드레이닝 마감 시간이며, 이는 최대화해야 할 값이 아니라 진정한 트레이드오프입니다. 너무 짧으면 1초 후에 끝났을 요청을 끊게 되어 목적을 잃게 됩니다. 너무 길면 모든 배포와 모든 스케일 인(축소) 이벤트가 지연되고, 어차피 멱등성과 재시도가 처리했을 롱테일(long tail)을 주로 기다리게 됩니다. 요청 지연 시간 분포의 대부분을 포함하는 마감 시간, 즉 절대적인 최악의 경우가 아닌 정상 요청의 높은 백분위수를 선택하고, 그 범위를 벗어나는 것은 재시도가 처리하도록 하십시오. 목표는 어떤 요청도 절대 유실되지 않도록 보장하는 것이 아닙니다. 목표는 종료를 일상적인 일로 만들어, 오토스케일러와 배포 파이프라인이 하루 종일 인스턴스를 이동시켜도 그 어떤 영향도 사용자에게 미치지 않도록 하는 것입니다.
관련 글
처리 속도보다 빠르게 채워지는 워커 큐를 위한 백프레셔
생산자가 작업자보다 더 빨라지면, 무한 큐는 부하를 흡수하는 것이 아니라 충돌을 지연시킬 뿐입니다. 역압력이 시스템을 계속 유지하는 방법은 다음과 같습니다.
Cloud Run 서비스 대 잡: 하나의 Go 바이너리, 두 개의 진입점
Cloud Run 서비스와 Job은 하나의 Go 이미지와 하나의 빌드를 공유할 수 있습니다. 여기서는 단일 바이너리를 서브 모드와 배치 작업으로 분할하는 방법과 분할하지 말아야 할 경우를 소개합니다.
Redis 키스페이스 알림 및 임대를 사용한 GPU 워커 풀의 로드 밸런싱
GPU는 비싸고 한 번에 하나의 무거운 작업만 실행하므로, 라운드 로빈 라우팅은 바쁜 워커 뒤에서 지연됩니다. 여기에 Redis 리스(lease)와 키스페이스 알림(keyspace notification)으로 구축된 바쁨을 인지하는 스케줄러가 있습니다.
MongoDB 변경 스트림을 이용한 Cron 스케줄 핫스왑
cron을 하드코딩하면 스케줄이 변경될 때마다 재배포해야 합니다. 스케줄을 데이터베이스에 저장하고 데몬이 변경 스트림으로 수정 사항에 반응하도록 하십시오. 여기에 그 설계와 장애 처리 방법이 있습니다.
CAS와 하트비트를 사용한 단일 실행기 작업을 위한 리스 잠금
일반적인 락은 홀더가 죽었을 때 영원히 교착 상태에 빠집니다. 리스는 만료됩니다. 여기서는 `compare-and-swap` 획득과 하트비트를 사용하여 리스를 구축하는 방법과, 설계를 결정하는 실패 모드를 다룹니다.
멱등성 업서트를 위한 결정적 ULID
데이터베이스가 자동 쓰기 재시도를 금지하는 경우, 모든 쓰기는 그 자체로 멱등적이어야 합니다. 콘텐츠 기반 ID는 이를 추가 비용 없이 가능하게 합니다. 여기에 그 유도 과정과 안전성을 유지하는 한 가지 불변식이 있습니다.