GoにおけるSIGTERM受信時のグレースフルシャットダウンとリクエストドレイニング
プラットフォームがSIGTERMを送信すると、プロセスが即座に強制終了され、処理中のリクエストがドロップされます。以下に、新しいトラフィックを停止し、ドレインし、順番にクリーンアップする手順を示します。
終了シグナルを受け取った瞬間に終了するコンテナは、処理中だったすべてのリクエストを中断してしまいます。クライアントにはコネクションリセットや5xxエラーが発生し、オープンなデータベーストランザクションは書き込みの途中でロールバックされ、バッファリングされたログのバッチはフラッシュされることなく、プロセスが保持していたロックはどこか別の場所でタイムアウトするまで保持され続けます。この修正は複雑ではありませんが、新しい処理の受け入れを停止し、処理中の作業をデッドライン内に完了させ、その後リソースを解放するという、特定の順序で行う必要があります。この記事では、Goにおけるその一連の流れを、シグナルハンドリング、サーバーのドレイン、クリーンアップフックとともに解説し、さらに、プラットフォームに途中で強制終了させられないようにデッドラインをどのように調整する必要があるかについても説明します。
終了は例外ではなく、正常なイベントである
最初の意識改革は、シャットダウンを稀な障害として扱うのをやめることです。コンテナプラットフォームでは、シャットダウンはあなたのプロセスに起こる最も頻繁な出来事の一つです。オートスケーラーは負荷に応じてインスタンスを追加し、負荷が下がると削除します。ローリングデプロイは、すべてのインスタンスを一つずつ置き換えます。ノードはメンテナンスのためにドレインされ、その上のすべてのコンテナは退去を求められます。スケールトゥゼロのサービスは、アイドル期間の後に破棄されます。これらのいずれも、あなたのプロセスに対して同じメッセージで終わります。それは終了シグナル、通常はSIGTERMであり、もし自力で終了しなかった場合は猶予期間(グレースピリオド)の後に強制的なSIGKILLが続きます。
もしマシンのクラッシュ時にしかシャットダウンについて考えないと、その実装は不十分なものになります。なぜなら、クラッシュは稀であり、たまにリクエストがドロップされることは許容できるからです。しかし、デプロイは稀ではありません。もしすべてのデプロイが、置き換えられるインスタンスで処理中だったリクエストをドロップするなら、すべてのデプロイで一部のユーザーにエラーが表示されることになります。一日に何度もデプロイし、一日中スケールアップ・ダウンするサービスにおいて、「SIGTERMを正しく処理する」ことは、あれば良いというものではありません。それは、デプロイがユーザーに見えないものになるか、それとも小規模な障害になるかの違いなのです。
デフォルトでは、多くのプログラムはSIGTERMに対して特別な処理を何もしません。このシグナルのデフォルトアクションはプロセスを即座に終了させることであり、それはまさにあなたが望まないことです。この即時終了が具体的にどのようなコストをもたらすかを以下に示します。
- 処理中のHTTPリクエストが切断されます。クライアントは接続のリセットや不完全なレスポンスを受け取り、メソッドによっては再試行が安全である場合とそうでない場合があります。
- 開いているトランザクションが中断されます。リクエストが複数ステートメントの書き込みの途中だった場合、データベースはそれをロールバックします。これは少なくとも一貫性を保ちますが、ユーザーのアクションは何も起こらなかったかのように静かに失敗します。
- バッファリングされた書き込みが失われます。後でフラッシュするためにメモリにバッチ処理したデータ、ログ行、メトリクス、アウトバウンドのイベントキューなど、すべてがプロセスと共に失われます。
- 保持されているリソースがクリーンに解放されません。分散ロック、リース、通信の向こう側にあるプールからの使用中の接続など、これらすべてが、返却される代わりにタイムアウトによって回収されることに依存するようになります。
これらの問題は、ローカルでのテストでは表面化しません。なぜなら、ローカルではサーバーがアイドル状態のときにCtrl-Cで停止させるからです。これらの問題は本番環境で、すべてのデプロイに薄く広く分散して現れます。そして、トラフィックではなく自分自身のデプロイと相関しているため、原因を特定するのが難しい、低頻度のバックグラウンドエラーとして現れるのです。
シャットダウンシーケンス、その順序
このテクニック全体は、決まった順序で行われる3つのフェーズから成ります。順序を間違えることが最もよくある間違いであるため、コードの前に平易に説明しておく価値があります。
| フェーズ | アクション | この位置にある理由 |
|---|---|---|
| 1. 受け入れ停止 | Readinessチェックを失敗させ、新しい接続の受け入れを停止する | 待機する前に新しいリクエストを停止しなければならない。さもなければキューは決して空にならない |
| 2. ドレイン | 期限内に、処理中のリクエストが完了するのを待つ | すでに受け入れられたリクエストは完了させるべきである |
| 3. クリーンアップ | バッファをフラッシュし、ロックを解放し、接続とプールを閉じる | それらのリソースを使用しているものが何もなくなった後でのみ安全である |
| 4. 残りの作業を諦める | 期限を過ぎたものはすべて破棄するが、ログには記録する | プラットフォームはまもなくSIGKILLする。ログに残された残余は、ハングしたプロセスよりはましである |
最初に入力を停止しなければならない理由は機械的なものです。新しいリクエストがまだ到着している間に処理中のリクエストを待ち始めると、処理中のリクエストのセットは決してゼロに縮小せず、ドレインは毎回期限まで実行されることになります。流入を止めると、既存のリクエストが一つずつ完了し、カウントは自然にゼロになります。その時になって初めて、データベース接続を閉じたりバッファをフラッシュしたりするのが安全になります。なぜなら、ハンドラがまだ実行中にそれを行うと、その足元をすくうことになるからです。
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 メソッドがドレインです。このメソッドはリスナーを停止して新しい接続を受け付けなくし、その後、すべてのアクティブリクエストが返るか、渡されたコンテキストがキャンセルされるまでブロックします。そのコンテキストにデッドラインが含まれます。
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を送信しようとしているときには、これは正しい判断です。デッドラインに達する割合の上昇が可視化されるように、その事実をログに記録してください。なぜなら、それは通常、リクエストが許容される時間枠よりも長くかかっており、その時間枠またはリクエスト自体に注意を払う必要があることを意味するからです。
注意点が1つあります:Shutdownは、WebSocketsやその他の長寿命ストリームのようなハイジャックされた接続をクローズせず、それらを待ち続けます。もしそのような接続がある場合、ドレインが実際に完了できるように、それらがselectで待機しているコンテキストをキャンセルするなどして、個別にクローズするようシグナルを送る必要があります。
ドレインする前にヘルスチェックを失敗させる
drainがShutdownを呼び出す前にhealth.SetNotReady()を呼び出すことに注意してください。これは人々が見落としがちな部分であり、これがないとドレインは競合状態 (racy) になります。
インスタンスの前には、準備完了 (readiness) チェックやヘルスチェックに基づいてトラフィックのルーティング先を決定するロードバランサーやサービスメッシュがあります。もし直接Shutdownに移行すると、ロードバランサーがこのインスタンスをまだ正常だと信じて新しいリクエストを送り続ける時間帯が存在し、それらのリクエストは接続を拒否しているサーバーに到達してしまいます。その結果は、まさに避けようとしていたリクエストのドロップであり、ただ発生場所が移動しただけです。
解決策は、まず準備完了エンドポイントを失敗状態に切り替え、次にロードバランサーがそれに気づいてプールからインスタンスを削除するのに十分な時間待機し、その後に初めて実際のドレインを開始することです。実際には、これはヘルスチェックの間隔に合わせて調整された短いスリープとなります。
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がドアを閉じる前にロードバランサーがルーティングを停止できます。正確な待機時間はプラットフォームによるプローブの方法に依存しますが、原則はどこでも同じです。つまり、利用できなくなる前に、利用可能であるという通知を停止することです。
クリーンアップフック: flush、release、close
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...)
}
クリーンアップの内部でも順序は重要です。フラッシュが必要とする接続を閉じる前に、フラッシュしてください。ロックストアと通信するクライアントを閉じる前に、ロックを解放してください。プールは最後に閉じてください。もし最初にデータベースプールを閉じてから、データベースに書き込むバッチをフラッシュしようとすると、フラッシュは失敗し、バッチを失います。これはまさに防ごうとしていた損失です。
ロックを明示的に解放することは、特筆に値します。もしリースの有効期限切れのみに依存している場合、クリーンシャットダウンしても、リース期間が満了するまでロックは保持されたままになり、その間どのピアもそれを取得できません。終了時にロックを返却することで、次のインスタンスが即座に処理を進めることができます。そして、有効期限切れは、プロセスが完全に強制終了されたようなクリーンでないケースのためのセーフティネットとして残ります。
ドレインのデッドラインをプラットフォームの猶予期間内に収める
ドレインのデッドラインは、プラットフォームが SIGTERM と SIGKILL の間に与える猶予期間よりも厳密に短くする必要があります。これが全体をまとめる制約であり、その数値が2つの異なる場所に存在するため、間違いやすいのです。
例えば、プラットフォームが SIGTERM を送信し、もし終了していなければ30秒後に SIGKILL を送信するとします。readiness ポーズ、ドレイン、クリーンアップを合わせた合計シャットダウン時間は、マージンをもってその30秒以内に収まらなければなりません。もしドレインだけで30秒に設定されていると、readiness ポーズとクリーンアップによって猶予期間を超えてしまい、クリーンアップの途中で SIGKILL が到着し、フラッシュしていたバッファを失うことになります。30秒の猶予期間に対する安全な分割は、数秒の readiness ポーズ、20から25秒程度のドレイン、そしてクリーンアップのために確保された数秒、といった具合になるでしょう。
実用的な注意点が2つあります。1つ目は、プラットフォームの猶予期間が設定可能であれば、デフォルトを信用するのではなく、明示的に設定することです。なぜなら、デフォルト値はしばしば短く(10秒が一般的です)、10秒の猶予期間に対して25秒のドレインを設定すると、常に SIGKILL が勝つことになるからです。2つ目は、デッドラインは目標ではなく、上限であるということです。ほとんどのリクエストは短時間で終わるため、ほとんどのシャットダウンは制限を大幅に下回る時間でドレインが完了します。デッドラインが問題になるのは、処理が遅いテール(末端)のリクエストだけです。それに達することは稀であるべきで、発生するたびにログに記録する価値があるほどです。
冪等性、リトライ、そしてドレインのコスト
正しいドレインであっても、最終的には何かが失われます。グレースピリオド全体よりも真に遅いリクエスト、ロードバランサーが反応する前のreadinessウィンドウ中に切断される接続、プロセスが応答不能になったことによるクリーンでないSIGKILLなどです。グレースフルシャットダウンは、失われるセットをほぼゼロにまで縮小しますが、ほぼゼロはゼロではありません。そのため、最後の層として、失われたリクエストが生き残れるようにする必要があります。
2つのプロパティが、この作業のほとんどを担います。書き込み操作を冪等にすることで、不確実なリクエストを再試行するクライアントやアップストリームが、それを二重に適用しないようにします。リクエストに冪等性キーを持たせ、最近の操作と照合することで、再試行された支払いや作成処理を安全なno-op(何もしない操作)に変えます。そして、接続がリセットされた際には、クライアントが冪等なリクエストを再試行できるようにします。なぜなら、ローリングデプロイ中には、再試行は通常、健全なインスタンスに到達して成功するからです。これらを組み合わせることで、ドレインをすり抜けた稀なリクエストが失われるのではなく、再試行されて完了することを意味します。
これが、完璧なドレインを追い求めるべきではない理由でもあります。最後の遅いリクエストを捕捉するためにデッドラインを引き上げることは、実際のコストを伴います。そして、冪等性とリトライは、そのテール部分を、より長いデッドラインよりもはるかに安価にカバーします。
ドレインは無料ではありません。今や、すべてのデプロイは、各インスタンスが終了する前に、そのインスタンスで処理中の最も遅いリクエストを待つことになります。そのため、ローリングデプロイはより長い時間を要し、ドレインのデッドラインとロールアウトの形態によって制約されます。スケールダウンも同じ理由で遅くなります。削除されたインスタンスがドレインされるまで残り続けるからです。もし寛大に25秒のデッドラインを設定し、ロールアウトがインスタンスを順次置き換える場合、追加される数分は現実のものです。
調整ノブはドレインのデッドラインであり、それは最大化すべき値ではなく、真のトレードオフです。短すぎると、1秒後には完了していたであろうリクエストを切り捨ててしまい、本末転倒です。長すぎると、すべてのデプロイとすべてのスケールインイベントが長引き、結局のところ冪等性とリトライで処理できたはずのロングテールを待つことになります。リクエストのレイテンシ分布の大部分、つまり絶対的な最悪ケースではなく通常のリクエストの高いパーセンタイルをカバーするデッドラインを選び、それを超えるものはリトライで後始末させます。目標は、リクエストが絶対に失われないことを保証することではありません。目標は、シャットダウンを日常的なものにすることです。そうすることで、オートスケーラーやデプロイパイプラインが、ユーザーに影響を与えることなく一日中インスタンスを移動させられるようになります。