3 ポイント 投稿者 GN⁺ 2025-05-06 | 1件のコメント | WhatsAppで共有
  • GoアプリケーションにおけるGraceful Shutdownは、新しいリクエストを止め、進行中の処理を待ってから、データベース接続・ファイルロック・ネットワークリスナーのようなリソースを片付ける終了手順である
  • 終了処理は、SIGTERMSIGINTのような終了シグナルos/signalまたはGo 1.16以降のsignal.NotifyContextで受け取り、デフォルトの即時終了動作を置き換えるところから始まる
  • Kubernetesではデフォルトの30秒のgrace period内に終了を完了する必要があり、preStopの遅延やreadiness probeの失敗によって、外部ロードバランサーまでトラフィック停止状態が伝播する時間を確保する必要がある
  • http.Server.Shutdownは新しい接続を止め、アクティブなリクエストの完了を待つが、ハンドラーがcontext cancellationに従わないと、部分書き込み、データ損失、開いたままのトランザクションのような問題が生じうる
  • 重要なリソースは終了シグナル直後ではなく、リクエスト終了後または制限時間満了後に解放すべきであり、初期化の逆順で終了するとコンポーネント依存関係を保ちやすい

Graceful Shutdownの最小条件

  • Graceful Shutdownは通常、次の3つの条件を満たす必要がある
    • HTTP、pub/subのような入口で新しいリクエストやメッセージをこれ以上受け付けない
    • すでに進行中のリクエストが終わるまで待ち、長すぎる場合はgraceful errorで応答する
    • データベース接続、ファイルロック、ネットワークリスナーのような重要リソースを解放し、最後の後始末を行う
  • 外部サービスへ向かうデータベースやキャッシュの接続は、新規リクエスト遮断の段階ですぐには切断しない
  • 焦点はHTTPサーバーとコンテナアプリケーションだが、核心原理は他のアプリケーションにも適用できる

終了シグナル処理

  • Unix系システムでシグナルは、プロセスに特定の状況が発生したことを知らせるソフトウェア割り込みである
  • プロセスは特定のシグナルにハンドラーを登録でき、ハンドラーがなければデフォルト動作に従う
    • デフォルト動作は終了、停止、実行継続、無視などになりうる
    • SIGKILLのような一部のシグナルは捕捉も無視もできず、プロセスを終了させる
  • Goランタイムはmain関数の実行前から、SIGTERMSIGQUITSIGILLSIGTRAPなど複数のシグナルハンドラーを自動登録する
  • Graceful Shutdownで主に重要な終了シグナルは3つである
    • SIGTERM: プロセスに終了を要求する標準的で穏当な方法であり、Kubernetesが強制終了前にアプリケーションへ送るシグナルである
    • SIGINT: ユーザーがターミナルでCtrl+Cによってプロセスを止めようとしたときに送信される
    • SIGHUP: もともとはターミナル接続解除に使われ、現在では設定リロードの合図としてもよく使われる
  • 別途処理をしないままSIGTERMSIGINTSIGHUPを受けると、Goランタイムはアプリケーションを終了させる

os/signalNotifyContext

  • signal.Notifyは、指定したシグナルをデフォルト動作の代わりにチャネルへ渡すようGoランタイムに指示する
  • シグナルチャネルはバッファサイズ1にしておくほうが安定する
    • Go内部ではチャネル送信にselectdefaultが使われる
    • バッファに空きがあればシグナルが配送され、バッファがいっぱいならシグナルは捨てられる
    • バッファなしチャネルで受信中のgoroutineがいなければ、シグナルを取りこぼす可能性がある
  • signal.Notifyは同じシグナルに対して複数回呼び出せ、Goは登録されたすべてのチャネルにそのシグナルを送る
  • Ctrl+Cを複数回押しても、通常は2回目の入力が自動的にSIGKILLへ昇格するわけではない
    • ほとんどのbashやLinuxシェルは自動昇格を行わない
    • 強制終了にはkill -9SIGKILLを直接送る必要がある
  • ローカル開発で2回目のCtrl+Cで強制終了させたいなら、最初のシグナルを受けた直後にsignal.Stopで追加シグナルの受信を止められる
  • Go 1.16以降ではsignal.NotifyContextによってシグナル処理をcontext cancellationと結び付けられる
    • ctx.Done()の後でもstop()を呼ばないと、2回目の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と独立した独自ヘルスチェックを使うため、状態伝播に時間が必要である
  • トラフィック遮断の伝播を待つ方法は2つある
    • 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が返るケースは2つある
    • すべてのアクティブ接続が閉じられ、すべてのハンドラー処理が終わった
    • 渡したcontextがハンドラー完了前に期限切れとなり、サーバーが待機を諦めた
  • どちらの場合でも、Shutdownはサーバーがリクエスト処理を完全に止めた後に返る
  • ハンドラーは高速で、かつcontext-awareに動作すべきである
    • そうでないと、制限時間満了時に処理が途中で切られる可能性がある
    • 部分書き込み、データ損失、一貫性のない状態、開いたままのトランザクション、破損データなどの問題が生じうる
  • ハンドラーへ終了シグナルを伝える代表的な方法は2つある
    • ミドルウェアで各リクエスト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()は進行中の接続を待たず、即時終了する
    • ネットワークを使っているハンドラーは読み書き時にエラーを受ける
    • クライアントはECONNRESETsocket hang upのような接続エラーを即座に受けることがある
    • ネットワークと相互作用しない長時間実行ハンドラーは、バックグラウンドで継続実行される可能性がある
  • server.Shutdown()がエラーを返した後にserver.Close()を使うことはできるが、終了戦略次第である
  • 終了シグナルをcontextとして伝播する方法のほうが、より信頼性が高くgracefulなアプローチである

重要リソース解放の順序

  • よくあるミスは、終了シグナルを受けた直後に重要リソースを解放してしまうことだ
  • この時点ではハンドラーやin-flightリクエストがまだそのリソースを使っている可能性があるため、リソース整理はshutdown timeout経過後、またはすべてのリクエストが終わった後まで遅らせるべきである
  • 多くの場合、プロセス終了だけでもOSがリソースを回収する
    • Goが割り当てたメモリはプロセス終了時に解放される
    • ファイルディスクリプタはOSが閉じる
    • プロセスハンドルのような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秒の強制キャンセル待機時間を置く

1件のコメント

 
GN⁺ 2025-05-06
Hacker News のコメント
  • Kubernetes の一部構成では、ロードバランサーのターゲット IP 更新に思ったより時間がかかって痛い目に遭ったことがある。自分の場合、graceful shutdown の 90% は、Pod を終了する前にトラフィックが実際にドレインされることを保証する作業だった。
    グローバルな preStop フックに 15 秒の sleep を入れたところ HTTP 503 の比率が大きく下がり、ロードバランサーの登録解除が始まってからアプリケーションに SIGTERM が渡るまでの時間を稼げたので、アプリケーション側の処理がずっと単純になった。

    • その通り。preStop sleep は、高品質なローリングデプロイで SLO を守るための魔法のような解決策だ。
      Kubernetes が改善できる点は 2 つあると思う。Pod は終了シーケンスを開始する前にまず 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 秒なら、メトリクスを 2 回記録するために 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 のようなやり方だ。
  • 新しいサービスインスタンスが既存のインスタンスからリスニングソケットを引き継ぎ、入ってくる接続を 1 つも切らずにアプリケーションを再起動する方法を扱うのだと思っていた。
    systemd では比較的簡単に実装できるし、nginx も 20 年以上前からサポートしている。残念ながら Kubernetes と Docker は、これをロードバランサーやリバースプロキシで処理するものと想定しているためサポートしていない。

  • 私の同僚は、プログラムが ctrl c といくつかの終了コマンドをきれいに処理できないなら、出来の悪いプログラムだといつも言っていた。

    • Ctrl-C はクリップボードへのコピー用に予約されている。プログラムを止める操作として使うのは非常に直感に反しており、ユーザーを怒らせるだろう。
  • Elixir はこういう部分を本当に賢く処理していると思う。経験が豊富なわけではないけれど、小さな VM プロセスがパニックを起こして終了し、また生成されるように設計されているので、意図的に graceful shutdown ルーチンを作る必要が減るように思う
    こうした性質がすでにアプリケーションアーキテクチャに組み込まれているからだ

    • それが筆者の扱っている graceful shutdown の必要性をどうなくすのか気になる
  • 自分のプロジェクトで graceful shutdown を扱うために小さなライブラリを作った: https://github.com/eberkund/graceful
    たいてい、起動しなければならないサービスがいくつかあり、それぞれ起動方法や終了方法が違うことが多い。先にオブジェクトをインスタンス化しなければならない場合もあるし、キャンセルしたいコンテキストがある場合もあり、呼び出すべき Stop メソッドがある場合もある。こうしたものをすべて統一された APIで一か所にまとめるよう設計した

  • 終了中の Pod は定義上 ready ではない。Service も endpoint を terminating かつ not ready として表示する。これは Terminating 状態に移行するときに起きるので、あえて readiness check を失敗させる必要はない
    SIGTERMPod.status や endpoint slice のようなオブジェクト更新の順序は正確には知らない。SIGTERM の後にも接続が入ってくる小さな窓はあり得るが、記事が示唆するような「readiness check が失敗するまで」続く大きな区間ではない。クラスタを管理する立場からすると、そのごく小さな窓はあまり重要ではないと思う。新しい接続を受け付けず、既存の接続を graceful に閉じ、妥当な速さで終了すればよい。ただし、自分が扱うアプリの半分は SIGTERM を処理するものの終了に時間がかかるか、そもそも SIGTERM を処理できないのに終了に時間がかかる部類に入る

  • JustWatch の一部プロジェクトで Google Wire を導入したところ、状況が一変した。意外と知られていないが、Kubernetes でごちゃごちゃした終了ロジックをなくすのに役立つ
    Wire がきれいな依存性注入を強制するので、今ではすべてが不明な順序ではなく、決まった順序で終了する
    https://go.dev/blog/wire
    https://github.com/google/wire