3P by GN⁺ | ★ favorite | 댓글 1개
  • Go 애플리케이션의 Graceful Shutdown은 새 요청을 막고, 진행 중인 작업을 기다린 뒤, 데이터베이스 연결·파일 락·네트워크 리스너 같은 자원을 정리하는 종료 절차임
  • 종료 처리는 SIGTERM·SIGINT 같은 종료 시그널os/signal 또는 Go 1.16 이상의 signal.NotifyContext로 받아 기본 즉시 종료 동작을 대체하는 데서 시작함
  • Kubernetes에서는 기본 30초 grace period 안에 종료를 마쳐야 하며, preStop 지연이나 readiness probe 실패로 외부 로드밸런서까지 트래픽 중단 상태가 전파될 시간을 확보해야 함
  • http.Server.Shutdown은 새 연결을 막고 활성 요청 완료를 기다리지만, 핸들러가 context cancellation을 따르지 않으면 부분 쓰기, 데이터 손실, 열린 트랜잭션 같은 문제가 생길 수 있음
  • 중요 자원은 종료 시그널 직후가 아니라 요청 종료 이후나 제한 시간 만료 뒤에 정리해야 하며, 초기화의 역순으로 종료하면 컴포넌트 의존성을 지키기 쉬움

Graceful Shutdown의 최소 조건

  • Graceful Shutdown은 보통 세 가지 조건을 만족해야 함
    • HTTP, pub/sub 같은 진입점에서 새 요청이나 메시지를 더 받지 않음
    • 이미 진행 중인 요청이 끝날 때까지 기다리고, 너무 오래 걸리면 graceful error로 응답함
    • 데이터베이스 연결, 파일 락, 네트워크 리스너 같은 중요 자원을 해제하고 마지막 정리를 수행함
  • 외부 서비스로 나가는 데이터베이스나 캐시 연결은 새 요청 차단 단계에서 바로 끊지 않음
  • 초점은 HTTP 서버와 컨테이너 애플리케이션이지만, 핵심 원리는 다른 애플리케이션에도 적용 가능함

종료 시그널 처리

  • Unix 계열 시스템에서 시그널은 프로세스에 특정 상황이 발생했음을 알리는 소프트웨어 인터럽트임
  • 프로세스는 특정 시그널에 핸들러를 등록할 수 있고, 핸들러가 없으면 기본 동작을 따름
    • 기본 동작은 종료, 정지, 계속 실행, 무시 등이 될 수 있음
    • SIGKILL 같은 일부 시그널은 잡거나 무시할 수 없으며 프로세스를 종료함
  • Go 런타임은 main 함수 실행 전부터 SIGTERM, SIGQUIT, SIGILL, SIGTRAP 등 여러 시그널 핸들러를 자동 등록함
  • Graceful Shutdown에서 주로 중요한 종료 시그널은 세 가지임
    • SIGTERM: 프로세스에 종료를 요청하는 표준적이고 완곡한 방식이며, Kubernetes가 강제 종료 전에 애플리케이션에 보내는 시그널임
    • SIGINT: 사용자가 터미널에서 Ctrl+C로 프로세스를 멈추려 할 때 전송됨
    • SIGHUP: 원래 터미널 연결 해제에 쓰였고, 현재는 설정 재로드 신호로도 자주 활용됨
  • 별도 처리 없이 SIGTERM, SIGINT, SIGHUP을 받으면 Go 런타임은 애플리케이션을 종료함

os/signalNotifyContext

  • signal.Notify는 지정한 시그널을 기본 동작 대신 채널로 전달하도록 Go 런타임에 지시함
  • 시그널 채널은 버퍼 크기 1로 만드는 편이 안정적임
    • Go 내부는 채널 전송에 selectdefault를 사용함
    • 버퍼에 공간이 있으면 시그널이 전달되고, 버퍼가 가득 차면 시그널은 버려짐
    • 버퍼 없는 채널에서 수신 중인 goroutine이 없으면 시그널을 놓칠 수 있음
  • signal.Notify는 같은 시그널에 대해 여러 번 호출할 수 있으며, Go는 등록된 모든 채널에 해당 시그널을 보냄
  • Ctrl+C를 여러 번 눌러도 보통 두 번째 입력이 자동으로 SIGKILL로 승격되지는 않음
    • 대부분의 bash나 Linux 셸은 자동 승격을 하지 않음
    • 강제 종료는 kill -9SIGKILL을 직접 보내야 함
  • 로컬 개발에서 두 번째 Ctrl+C로 강제 종료되게 하려면 첫 번째 시그널을 받은 직후 signal.Stop으로 추가 시그널 수신을 중단할 수 있음
  • Go 1.16부터는 signal.NotifyContext로 시그널 처리를 context cancellation과 연결할 수 있음
    • ctx.Done() 이후에도 stop()을 호출해야 두 번째 Ctrl+C가 애플리케이션을 강제로 종료할 수 있음

종료 제한 시간과 Kubernetes 동작

  • 종료 시그널을 받은 뒤 애플리케이션이 실제로 쓸 수 있는 종료 시간을 먼저 알아야 함
  • Kubernetes의 기본 grace period는 terminationGracePeriodSeconds를 따로 지정하지 않으면 30초
  • 이 시간이 지나면 Kubernetes는 SIGKILL을 보내 애플리케이션을 강제로 중단함
    • SIGKILL은 잡거나 처리할 수 없음
  • 남은 요청 처리와 자원 해제까지 포함해 모든 종료 로직은 이 시간 안에 끝나야 함
  • 기본 30초를 기준으로 약 20%를 안전 마진으로 남기면, 전체 종료는 25초 안에 끝내는 편이 좋음

새 요청 차단과 readiness 처리

  • Go의 net/http에서는 http.Server.Shutdown으로 Graceful Shutdown을 수행할 수 있음
    • 새 연결 수락을 중단함
    • 활성 요청이 완료될 때까지 기다림
    • 이후 idle connection을 닫음
  • 이미 진행 중인 요청은 완료할 수 있고, 완료 뒤 해당 연결은 idle 상태가 되어 닫힘
  • 종료 중 새 연결을 시도하는 클라이언트는 리스너가 이미 닫혀 있어 보통 connection refused 오류를 받음
  • 컨테이너 환경이나 외부 로드밸런서가 있는 오케스트레이션 환경에서는 새 요청 수락을 즉시 중단하지 않는 편이 중요함
    • pod가 종료 대상으로 표시된 뒤에도 잠시 트래픽을 받을 수 있음
    • Kubernetes 내부 컴포넌트인 kube-proxy는 pod 상태가 Terminating으로 바뀐 것을 빠르게 인지함
    • 외부 로드밸런서는 Kubernetes와 독립적으로 자체 헬스체크를 사용하므로 상태 전파에 시간이 필요함
  • 트래픽 차단 전파를 기다리는 방식은 두 가지임
    • preStop 훅에서 잠시 sleep해 외부 로드밸런서가 pod 종료 상태를 인식할 시간을 줌
      • preStop에 걸린 시간은 terminationGracePeriodSeconds에 포함됨
    • 코드 수준에서 readiness probe를 실패시키고 잠시 대기함
      • Kubernetes뿐 아니라 로드밸런서가 준비 상태를 알아야 하는 다른 환경에도 적용 가능함
  • readiness probe는 컨테이너가 트래픽을 받을 준비가 되었는지 주기적으로 확인함
    • HTTP 요청, TCP 연결, 명령 실행 같은 방식으로 헬스체크를 수행할 수 있음
    • probe가 실패하면 Kubernetes는 pod를 service endpoint에서 제거해 트래픽을 받지 않게 함
  • 종료 준비 시 isShuttingDown 같은 atomic.Bool을 사용해 /healthz가 HTTP 503을 반환하도록 만들 수 있음
  • readiness 상태를 실패로 바꾼 뒤에는 변경 사항 전파를 위해 몇 초 기다려야 함
    • 예시 설정은 periodSeconds: 5이며, 본문 예시는 5초 대기를 사용함
    • 정확한 대기 시간은 readiness probe 설정에 따라 달라짐

진행 중인 요청 처리

  • shutdown budget에 맞춰 context.WithTimeout으로 제한 시간을 만들고 server.Shutdown(ctx)에 전달함
  • server.Shutdown이 반환되는 경우는 두 가지임
    • 모든 활성 연결이 닫히고 모든 핸들러 처리가 끝남
    • 전달한 context가 핸들러 완료 전에 만료되어 서버가 대기를 포기함
  • 어느 경우든 Shutdown은 서버가 요청 처리를 완전히 멈춘 뒤 반환함
  • 핸들러는 빠르고 context-aware하게 동작해야 함
    • 그렇지 않으면 제한 시간 만료 시 작업 중간에 끊길 수 있음
    • 부분 쓰기, 데이터 손실, 일관성 없는 상태, 열린 트랜잭션, 손상된 데이터 같은 문제가 생길 수 있음
  • 핸들러에 종료 신호를 전달하는 대표 방식은 두 가지임
    • 미들웨어로 각 요청 context에 취소 로직을 주입함
    • http.ServerBaseContext로 모든 연결에 공유되는 전역 context를 제공함
  • HTTP 서버에서 커스터마이즈할 수 있는 context는 BaseContextConnContext가 있음
    • Graceful Shutdown에는 서버 전체에 적용되는 취소 가능한 전역 context를 만들 수 있는 BaseContext가 더 적합함
  • Graceful Shutdown은 함수들이 context 취소를 존중할 때 효과가 있음
    • context.Background(), time.Sleep()처럼 취소를 무시하는 사용을 피해야 함
    • time.Sleep(duration)selecttime.After(duration)ctx.Done()을 함께 기다리는 방식으로 대체할 수 있음
  • 오래된 Go 버전에서는 time.After가 타이머가 실행될 때까지 메모리를 누수할 수 있음

ShutdownClose의 차이

  • 같은 원리는 HTTP 서버뿐 아니라 서드파티 서비스에도 적용됨
  • database/sqlDB.Close는 데이터베이스 연결을 닫고 새 쿼리 시작을 막으며, 진행 중인 쿼리가 끝날 때까지 기다림
  • 핵심은 새 요청이나 메시지를 더 받지 않고, 기존 작업이 정의된 grace period 안에 끝날 시간을 주는 것임
  • server.Close()는 진행 중인 연결을 기다리지 않고 즉시 종료함
    • 네트워크를 사용 중인 핸들러는 읽기·쓰기 시 오류를 받음
    • 클라이언트는 ECONNRESET이나 socket hang up 같은 연결 오류를 즉시 받을 수 있음
    • 네트워크와 상호작용하지 않는 장기 실행 핸들러는 백그라운드에서 계속 실행될 수 있음
  • server.Shutdown()이 오류를 반환한 뒤 server.Close()를 사용할 수는 있지만, 종료 전략에 따라 달라짐
  • 종료 신호를 context로 전파하는 방식이 더 신뢰성 있고 graceful한 접근임

중요 자원 해제 순서

  • 흔한 실수는 종료 시그널을 받자마자 중요 자원을 해제하는 것임
  • 이 시점에는 핸들러와 in-flight 요청이 여전히 해당 자원을 사용할 수 있으므로, 자원 정리는 shutdown timeout이 지나거나 모든 요청이 끝난 뒤로 미뤄야 함
  • 많은 경우 프로세스 종료만으로도 운영체제가 자원을 회수함
    • Go가 할당한 메모리는 프로세스 종료 시 해제됨
    • 파일 디스크립터는 운영체제가 닫음
    • 프로세스 핸들 같은 OS 수준 자원도 회수됨
  • 명시적 정리가 필요한 경우도 있음
    • 데이터베이스 연결은 제대로 닫아야 하며, 열린 트랜잭션은 commit 또는 rollback이 필요함
    • 메시지 큐와 브로커는 메시지 flush, offset commit, 클라이언트 종료 알림이 필요할 수 있음
    • 외부 서비스는 연결 끊김을 즉시 감지하지 못할 수 있으므로, 수동으로 연결을 닫으면 TCP timeout을 기다리는 것보다 빠르게 정리할 수 있음
  • 컴포넌트는 초기화의 역순으로 종료하는 것이 좋은 규칙임
    • Go의 defer는 마지막에 등록한 함수가 먼저 실행되므로 이 패턴에 잘 맞음
  • 메모리 캐시 데이터를 디스크에 써야 하는 경우처럼 일부 컴포넌트는 별도 shutdown routine을 설계해야 함

전체 예시의 흐름

  • 전체 예시는 signal.NotifyContextSIGINTSIGTERM을 받는 root context를 구성함
  • /healthz 엔드포인트는 isShuttingDown이 true이면 HTTP 503과 Shutting down을 반환하고, 아니면 OK를 반환함
  • 샘플 요청 핸들러는 2초 뒤 Hello, world!를 반환하거나, 요청 context가 취소되면 HTTP request timeout으로 응답함
  • BaseContext에는 ongoingCtx를 연결해 in-flight 요청이 SIGTERM 직후 바로 취소되지 않게 함
  • 종료 시그널을 받으면 다음 순서로 진행함
    • stop() 호출로 추가 기본 처리를 허용함
    • isShuttingDown.Store(true)로 readiness 실패 상태를 만듦
    • _readinessDrainDelay인 5초 동안 readiness check 전파를 기다림
    • _shutdownPeriod인 15초 제한 시간으로 server.Shutdown을 호출함
    • stopOngoingGracefully()로 진행 중 context를 취소함
    • Shutdown이 실패하면 _shutdownHardPeriod인 3초 동안 강제 취소 대기 시간을 둠

댓글과 토론

Hacker News 의견들
  • Kubernetes가 일부 구성에서 로드 밸런서 대상 IP를 갱신하는 데 생각보다 오래 걸려서 당한 적이 있음. 내 경우 graceful shutdown의 90%는 파드 종료 전에 트래픽이 실제로 드레인되는지 보장하는 일이었음
    전역 preStop 훅에 15초 sleep을 넣자 HTTP 503 비율이 크게 줄었고, 로드 밸런서 등록 해제가 시작된 뒤 애플리케이션에 SIGTERM이 전달되기까지 시간을 벌어 애플리케이션 쪽 처리가 훨씬 단순해짐

    • 맞음. preStop sleep은 고품질 롤링 배포에서 SLO를 지키는 마법 같은 해법임
      Kubernetes가 개선할 수 있는 건 두 가지라고 봄. 파드는 종료 시퀀스를 시작하기 전에 Endpoints에서 먼저 제거되어야 하고, termination grace처럼 termination delay 옵션이 있어야 함. 또 PDB는 축출 전에 재생성을 허용하는 옵션이 있어야 함
  • 일반적인 Prometheus /metrics 엔드포인트를 N초마다 스크레이프한다면, 마지막 스크레이프와 실제 프로세스 종료 사이에 기록된 지표가 전파되지 않는 구간이 생김. 그래서 종료 시퀀스 중 오류가 있는지에 대해 잘못된 인상을 받을 수 있음
    조심하지 않으면 서비스 종료 직전 몇 초의 로그도 잃을 수 있음. 예를 들어 로그 파일을 Promtail이나 Vector 같은 사이드카가 감시하고, 서비스가 시작 시 같은 경로를 truncate한 뒤 다시 쓰면 종료 중 로그가 사라지는 경쟁 조건이 생김

    • 관측성 스택은 좀 터무니없어 보임. 로그, 지표, 추적이 각각 자기 데이터베이스, 사이드카, 시각화 스택을 갖고 있고, 언어별 통합 라이브러리는 제각각이며, 클라우드 비용도 엄청남
      그렇게 많은 노력을 들여도 데이터 대부분은 완전히 무시되고, 비즈니스 통찰도 서버에 ssh로 들어가 로그 파일을 grep하는 빈민가 버전보다 크게 나을 때가 드묾. 이 생태계에 쏟은 노력만큼 가동 시간, 성능, 사용성이 유의미하게 좋아졌는지는 잘 모르겠음
    • 지난 8년 넘게 Go 고부하 애플리케이션을 다루며 겪은 문제들을 플랫폼 라이브러리에서 정확히 이런 식으로 처리하고 있음. 회사마다 플랫폼과 롤링 배포를 개발·개선하는 게 취미였음
      “로그 동기화”, “ingress가 liveness handler를 따라잡을 때까지 대기” 같은 것들을 다룰 예정임
      https://github.com/utrack/caisson-go/blob/main/caiapp/caiapp...
      https://github.com/utrack/caisson-go/tree/main/closer
      문서는 아직 부족하고 빠진 것도 있지만, 휴가에서 돌아오면 첫 릴리스를 할 계획임. 최종적으로는 일반적인 k8s/otel/grpc+http 인프라를 다루는 메타 플랫폼과 참조 플랫폼 라이브러리가 될 예정임
    • Prometheus와 관련 도구들이 왜 풀 모델을 쓰는지 늘 이해가 안 됐음. 대부분은 푸시 모델을 쓰는데 말임
    • 이 문제에 대한 편한 해법을 본 적이 있는지 궁금함. 스크레이프 간격이 15초라면 지표를 두 번 기록하려고 30초를 기다릴 수는 없음
      이런 동작 때문에 우리 서비스는 아직도 statsd를 쓰고 있음. 푸시 기반 모델에는 이 문제가 없기 때문임
  • 자주 보는 작은 함정이 있는데, log.Fatal을 호출해도 defer가 실행된다고 생각하는 경우가 있음. 실제로는 실행되지 않음
    log.Fatal("fatal")은 내부에서 os.Exit을 호출하므로 즉시 종료되어 defer가 돌지 않음. 반면 panic("fatal")fatalin defer를 모두 보여줌

  • 분산 시스템이 제대로 동작하려면 클라이언트가 우아하게 종료해야 한다는 전제에 의존한다면, 언젠가는 크게 망가질 수밖에 없음

    • 그 믿음이 강해서 설계할 때 graceful shutdown 자체를 고려하지 않음. 구성요소는 안전하게, 심지어 자주 하드 크래시할 수 있어야 하고, 시스템의 중요한 비율이 의도대로 동작 중이라면 전체 시스템에 의미 있는 영향이 없어야 함
      시스템이 구성요소의 하드 크래시를 견딜 수 있는지 확인하는 유일한 방법은 하드 크래시가 항상 일어나는 정상적인 일이 되게 하는 것임. 카오스 몽키에게 영광을
    • 클라이언트나 워크플로에 친절하기 위한 graceful shutdown과, 시스템이 작동하려면 클라이언트가 그것에 의존해야 하는 건 큰 차이가 있음
    • 예전 물리 서버 시절에는 그걸 위해 STONITH를 썼음: https://smcleod.net/2015/07/delayed-serial-stonith/
    • 복구 가능한 상황이라도 일반적인 종료가 재앙적인 종료처럼 보이지 않게 할 타당한 이유는 있음
      애플리케이션이 sig int로 내려간 것과 kill로 죽은 것은 큰 차이가 있음. 예를 들어 블루-그린 마이그레이션에는 우아한 종료 동작이 필요함
    • 맞음. 그래도 소프트웨어가 플러그를 뽑아도 견디도록 설계됐다고 해서 종료할 때 실제로 플러그를 뽑을 필요는 없음
      다시 생각해보면 어쩌면 필요할지도 모름. 그 가정이 참인지 보장하는 유일한 방법일 수 있음. 몇 년 전 Netflix의 chaos monkey 같은 방식임
  • 새 서비스 인스턴스가 기존 인스턴스로부터 리스닝 소켓을 넘겨받아, 들어오는 연결을 하나도 끊지 않고 애플리케이션을 재시작하는 방법을 다룰 줄 알았음
    systemd에서는 비교적 간단히 구현할 수 있고, nginx도 20년 넘게 지원해 왔음. 아쉽게도 Kubernetes와 Docker는 이걸 로드 밸런서나 리버스 프록시에서 처리한다고 가정해서 지원하지 않음

  • 내 동료는 프로그램이 ctrl c와 몇 가지 종료 명령을 깔끔하게 처리하지 못한다면 잘못 작성된 프로그램이라고 늘 말했음

    • Ctrl-C는 클립보드로 복사하는 데 예약돼 있음. 프로그램을 멈추는 동작으로 쓰는 건 매우 직관에 어긋나며 사용자들을 화나게 할 것임
  • Elixir가 이런 부분을 정말 영리하게 처리한다고 봄. 경험이 많지는 않지만, 작은 VM 프로세스들이 패닉이 나고 종료되고 다시 생성되도록 설계되기 때문에 의도적으로 graceful shutdown 루틴을 만들 필요가 줄어드는 것 같음
    이런 성질이 이미 애플리케이션 아키텍처에 내장돼 있기 때문임

    • 그게 글쓴이가 다룬 graceful shutdown의 필요성을 어떻게 없애는지 궁금함
  • 내 프로젝트에서 graceful shutdown을 처리하려고 작은 라이브러리를 만들었음: https://github.com/eberkund/graceful
    보통 시작해야 하는 서비스가 몇 개 있고, 각자 시작과 종료 방식이 다를 때가 많음. 객체를 먼저 인스턴스화해야 할 때도 있고, 취소하고 싶은 컨텍스트가 있을 때도 있으며, 호출해야 하는 Stop 메서드가 있을 때도 있음. 이 모든 것을 통일된 API로 한곳에 모으려고 설계했음

  • 종료 중인 파드는 정의상 준비 상태가 아님. 서비스도 엔드포인트를 terminating이자 not ready로 표시함. 이건 Terminating 상태로 전환될 때 발생하므로, 굳이 readiness check를 실패시킬 필요는 없음
    SIGTERMPod.status나 endpoint slice 같은 객체 갱신의 순서는 정확히 모르겠음. SIGTERM 뒤에도 연결이 들어올 수 있는 작은 창은 있을 수 있지만, 글이 암시하는 것처럼 “readiness check가 실패할 때까지” 이어지는 큰 구간은 아님. 클러스터를 관리하는 입장에서 그 극히 작은 창은 별로 중요하지 않다고 봄. 새 연결을 받지 말고, 기존 연결을 우아하게 닫고, 합리적으로 빠르게 종료하면 됨. 다만 내가 다루는 앱의 절반은 SIGTERM을 처리하지만 종료가 오래 걸리거나, 아예 SIGTERM 처리를 못 하면서도 종료가 오래 걸리는 쪽에 속함

  • JustWatch의 일부 프로젝트에서 Google Wire를 도입했는데 판도를 바꿔 놓았음. 의외로 덜 알려져 있지만 Kubernetes에서 지저분한 종료 로직을 없애는 데 도움이 됨
    Wire가 깔끔한 의존성 주입을 강제해서, 이제 모든 것이 알 수 없는 순서가 아니라 정해진 순서대로 종료됨
    https://go.dev/blog/wire
    https://github.com/google/wire