Go言語でGraceful Shutdownを実装する実践パターン
(victoriametrics.com)- 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は通常、次の3つの条件を満たす必要がある
- HTTP、pub/subのような入口で新しいリクエストやメッセージをこれ以上受け付けない
- すでに進行中のリクエストが終わるまで待ち、長すぎる場合はgraceful errorで応答する
- データベース接続、ファイルロック、ネットワークリスナーのような重要リソースを解放し、最後の後始末を行う
- 外部サービスへ向かうデータベースやキャッシュの接続は、新規リクエスト遮断の段階ですぐには切断しない
- 焦点はHTTPサーバーとコンテナアプリケーションだが、核心原理は他のアプリケーションにも適用できる
終了シグナル処理
- Unix系システムでシグナルは、プロセスに特定の状況が発生したことを知らせるソフトウェア割り込みである
- プロセスは特定のシグナルにハンドラーを登録でき、ハンドラーがなければデフォルト動作に従う
- デフォルト動作は終了、停止、実行継続、無視などになりうる
SIGKILLのような一部のシグナルは捕捉も無視もできず、プロセスを終了させる
- Goランタイムは
main関数の実行前から、SIGTERM、SIGQUIT、SIGILL、SIGTRAPなど複数のシグナルハンドラーを自動登録する - Graceful Shutdownで主に重要な終了シグナルは3つである
SIGTERM: プロセスに終了を要求する標準的で穏当な方法であり、Kubernetesが強制終了前にアプリケーションへ送るシグナルであるSIGINT: ユーザーがターミナルでCtrl+Cによってプロセスを止めようとしたときに送信されるSIGHUP: もともとはターミナル接続解除に使われ、現在では設定リロードの合図としてもよく使われる
- 別途処理をしないまま
SIGTERM、SIGINT、SIGHUPを受けると、Goランタイムはアプリケーションを終了させる
os/signalとNotifyContext
signal.Notifyは、指定したシグナルをデフォルト動作の代わりにチャネルへ渡すようGoランタイムに指示する- シグナルチャネルはバッファサイズ1にしておくほうが安定する
- Go内部ではチャネル送信に
selectとdefaultが使われる - バッファに空きがあればシグナルが配送され、バッファがいっぱいならシグナルは捨てられる
- バッファなしチャネルで受信中のgoroutineがいなければ、シグナルを取りこぼす可能性がある
- Go内部ではチャネル送信に
signal.Notifyは同じシグナルに対して複数回呼び出せ、Goは登録されたすべてのチャネルにそのシグナルを送るCtrl+Cを複数回押しても、通常は2回目の入力が自動的にSIGKILLへ昇格するわけではない- ほとんどのbashやLinuxシェルは自動昇格を行わない
- 強制終了には
kill -9でSIGKILLを直接送る必要がある
- ローカル開発で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.ServerのBaseContextで、すべての接続に共有されるグローバルcontextを提供する
- HTTPサーバーでカスタマイズできるcontextには
BaseContextとConnContextがある- Graceful Shutdownには、サーバー全体に適用されるキャンセル可能なグローバルcontextを作れる
BaseContextのほうが適している
- Graceful Shutdownには、サーバー全体に適用されるキャンセル可能なグローバルcontextを作れる
- Graceful Shutdownは、関数群がcontextのキャンセルを尊重するときに効果を発揮する
context.Background()やtime.Sleep()のようにキャンセルを無視する使い方は避けるべきであるtime.Sleep(duration)は、selectでtime.After(duration)とctx.Done()を同時に待つ方式に置き換えられる
- 古いGoバージョンでは、
time.Afterがタイマー実行までメモリリークを起こすことがある- この問題はGo 1.23以降で修正された
- バージョンがはっきりしないなら、
time.NewTimerとStop、必要に応じて<-t.C確認を使える - 関連Issue: time: stop requiring Timer/Ticker.Stop for prompt GC
ShutdownとCloseの違い
- 同じ原理はHTTPサーバーだけでなく、サードパーティサービスにも適用できる
database/sqlのDB.Closeはデータベース接続を閉じ、新しいクエリ開始を防ぎ、進行中のクエリが終わるまで待つ- 重要なのは、新しいリクエストやメッセージをこれ以上受けず、既存処理に定義されたgrace period内で完了する時間を与えることである
server.Close()は進行中の接続を待たず、即時終了する- ネットワークを使っているハンドラーは読み書き時にエラーを受ける
- クライアントは
ECONNRESETやsocket 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は最後に登録した関数から先に実行されるため、このパターンによく合う
- Goの
- メモリキャッシュのデータをディスクへ書く必要がある場合のように、一部コンポーネントでは別個のshutdown routineを設計する必要がある
全体例の流れ
- 全体例では
signal.NotifyContextでSIGINTとSIGTERMを受ける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件のコメント
Hacker News のコメント
Kubernetes の一部構成では、ロードバランサーのターゲット IP 更新に思ったより時間がかかって痛い目に遭ったことがある。自分の場合、graceful shutdown の 90% は、Pod を終了する前にトラフィックが実際にドレインされることを保証する作業だった。
グローバルな
preStopフックに 15 秒の sleep を入れたところ HTTP 503 の比率が大きく下がり、ロードバランサーの登録解除が始まってからアプリケーションにSIGTERMが渡るまでの時間を稼げたので、アプリケーション側の処理がずっと単純になった。preStopsleep は、高品質なローリングデプロイで SLO を守るための魔法のような解決策だ。Kubernetes が改善できる点は 2 つあると思う。Pod は終了シーケンスを開始する前にまず Endpoints から削除されるべきで、termination grace のように termination delay オプションがあるべきだ。また PDB には、退避前に再作成を許可するオプションがあるべきだ。
一般的な Prometheus の
/metricsエンドポイントを N 秒ごとにスクレイプしている場合、最後のスクレイプと実際のプロセス終了の間に記録されたメトリクスが伝播されない区間が生じる。そのため、終了シーケンス中にエラーがあったかどうかについて誤った印象を受ける可能性がある。注意しないと、サービス終了直前の数秒分のログも失うことがある。たとえばログファイルを Promtail や Vector のようなサイドカーが監視していて、サービスが起動時に同じパスを truncate してから再び書き込む場合、終了中のログが消える競合状態が発生する。
それほど多くの労力をかけても、データの大半は完全に無視され、ビジネス上の洞察も、サーバーに
sshで入ってログファイルをgrepする貧民街版より大きく優れていることはめったにない。このエコシステムに注いだ労力に見合うほど、稼働時間、性能、使いやすさが有意に良くなったのかはよく分からない。「ログ同期」や「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 インフラを扱うメタプラットフォームと参照用プラットフォームライブラリになる予定だ。
こうした挙動のため、私たちのサービスはいまだに statsd を使っている。プッシュベースのモデルにはこの問題がないからだ。
よく見かける小さな落とし穴がある。
log.Fatalを呼んでもdeferが実行されると思っているケースだ。実際には実行されない。log.Fatal("fatal")は内部でos.Exitを呼ぶため即座に終了し、deferは走らない。一方でpanic("fatal")はfatalとin deferの両方を表示する。分散システムが正しく動作するために、クライアントが優雅に終了することを前提に依存しているなら、いつか大きく壊れるに決まっている。
システムがコンポーネントのハードクラッシュに耐えられることを確認する唯一の方法は、ハードクラッシュが常に起きる普通のことになるようにすることだ。カオスモンキーに栄光あれ。
アプリケーションが
sig intで落ちたのとkillで殺されたのとでは大きな違いがある。たとえばブルーグリーン移行には優雅な終了動作が必要だ。考え直すと、もしかすると必要かもしれない。その前提が真であることを保証する唯一の方法かもしれない。数年前の Netflix の chaos monkey のようなやり方だ。
新しいサービスインスタンスが既存のインスタンスからリスニングソケットを引き継ぎ、入ってくる接続を 1 つも切らずにアプリケーションを再起動する方法を扱うのだと思っていた。
systemd では比較的簡単に実装できるし、nginx も 20 年以上前からサポートしている。残念ながら Kubernetes と Docker は、これをロードバランサーやリバースプロキシで処理するものと想定しているためサポートしていない。
私の同僚は、プログラムが
ctrl cといくつかの終了コマンドをきれいに処理できないなら、出来の悪いプログラムだといつも言っていた。Elixir はこういう部分を本当に賢く処理していると思う。経験が豊富なわけではないけれど、小さな VM プロセスがパニックを起こして終了し、また生成されるように設計されているので、意図的に graceful shutdown ルーチンを作る必要が減るように思う
こうした性質がすでにアプリケーションアーキテクチャに組み込まれているからだ
自分のプロジェクトで graceful shutdown を扱うために小さなライブラリを作った: https://github.com/eberkund/graceful
たいてい、起動しなければならないサービスがいくつかあり、それぞれ起動方法や終了方法が違うことが多い。先にオブジェクトをインスタンス化しなければならない場合もあるし、キャンセルしたいコンテキストがある場合もあり、呼び出すべき
Stopメソッドがある場合もある。こうしたものをすべて統一された APIで一か所にまとめるよう設計したhttps://pkg.go.dev/git.sr.ht/~mariusor/wrapper#example-Regis...
終了中の Pod は定義上 ready ではない。Service も endpoint を terminating かつ not ready として表示する。これは Terminating 状態に移行するときに起きるので、あえて readiness check を失敗させる必要はない
SIGTERMとPod.statusや endpoint slice のようなオブジェクト更新の順序は正確には知らない。SIGTERMの後にも接続が入ってくる小さな窓はあり得るが、記事が示唆するような「readiness check が失敗するまで」続く大きな区間ではない。クラスタを管理する立場からすると、そのごく小さな窓はあまり重要ではないと思う。新しい接続を受け付けず、既存の接続を graceful に閉じ、妥当な速さで終了すればよい。ただし、自分が扱うアプリの半分はSIGTERMを処理するものの終了に時間がかかるか、そもそもSIGTERMを処理できないのに終了に時間がかかる部類に入るJustWatch の一部プロジェクトで Google Wire を導入したところ、状況が一変した。意外と知られていないが、Kubernetes でごちゃごちゃした終了ロジックをなくすのに役立つ
Wire がきれいな依存性注入を強制するので、今ではすべてが不明な順序ではなく、決まった順序で終了する
https://go.dev/blog/wire
https://github.com/google/wire