この記事で話すこと
- 優雅なシャットダウンとは終了前にリソースを安全に解放することであり、 HTTP サーバーではリクエストを完了まで持っていくことが目標になることが多い。
- HTTPサーバーの優雅なシャットダウンを妨げる構造的な問題がいくつもある。
- ロングポーリングや長い非同期ジョブの対策はアプリケーション側で行う必要がある。
- Kubernetes の優雅なシャットダウンには Pod の Service からの退役にまつわる構造的な問題がある。
- HTTP/1.1 の持続的接続の切断には構造的な問題がある。
- Node.js 組み込みの HTTP Server の close メソッドには構造的な問題がある。
優雅なシャットダウンとは
優雅なシャットダウン (graceful shutdown) とは、確保した資源を返却してからプロセスを終了する手続きのことです。HTTPサーバーの場合、「確保した資源」とは受け付けた HTTP request のことであり、リクエストの完了を見届けてからプロセスを終了することがサービスの信頼性にとって重要です。
もしプロセスが HTTP request の完了を見届けずに終了してしまうと、クライアントからは TCP 接続がリセットされるか、ハングアップしたかのように見えてしまいます。途中にL7ロードバランサなどの中間者がいる場合は、 HTTP 502 や 504 などのエラーコードが返ってくる場合もあり、いずれにしてもレスポンスに失敗したことになります。逆に、前段にロードバランサがいる場合は、以下のような優雅なシャットダウン手順を正しく実装することで安全にサービスを継続できます:
- ロードバランサに終了通知を送り、新規接続を停止する (以降のリクエストは他のアプリケーションサーバーに回される)
- 進行中のリクエストの完了を見届ける
- プロセスを終了する
シグナルの仕組み
シグナルはユーザーランドにおける割り込みの同等物です。シグナルは以下の2つの機構に分けて説明することができます。
- シグナルを配送する部分
- 配送されたシグナルに基づいて、プロセス内でユーザーランド割り込みをかける部分
シグナルの配送
シグナルの配送は一種のイベントキューです。シグナルはシグナル番号とペイロードを持ち、通常はシグナル番号のみでイベントの意図が決定されます。代表的なシグナルは以下の通りです:
- 終了要求
- SIGKILL (9): プロセスの強制終了
- SIGTERM (15): プロセスの終了要求
- SIGQUIT (3): プロセスの終了+コアダンプ要求
- SIGINT (2): 実行中の処理の終了要求 (Ctrl+C)
- 中断/再開
- SIGSTOP: プロセスの強制実行中断
- SIGTSTP: プロセスの実行中断 (Ctrl+Z)
- SIGCONT: 中断したプロセスの再開
- ハングアップ
- SIGHUP (1): 端末からの切り離し
- SIGPIPE (13): 閉じたパイプへの書き込み
- 異常状態:
- SIGSEGV (11): メモリアクセス違反
- 汎用:
- SIGUSR1 / SIGUSR2
シグナルはOSによって発行される場合と、ユーザー空間から手動で発行する場合があります。
- OSによって発行されるシグナルの典型例は SIGSEGV です。ユーザー空間に割り当てられていないメモリ領域にアクセスしようとしたとき、ページフォールトを経由してOSが発行します。
- 端末で Ctrl+C を入力した場合も、 tty を経由してOSによりSIGINTが発行されます。
- ユーザーランドのプログラムは kill(2), tgkill(2) や sigqueue(3) によりシグナルを発行できます。 kill(1) コマンドなどを使えばユーザーが直接シグナルを発行できます。
シグナルは個別のプロセスのかわりにプロセスグループ宛てに送ることもできます。この場合、プロセスグループに属する全てのプロセスにそれぞれシグナルが配送されます。
Linux ではスレッドがプロセスの亜種として管理され、いわゆる「プロセス」はスレッドグループと等価です。プロセス宛てのシグナルはスレッドグループ宛てに配送されますが、これはスレッドグループ内の全てのスレッドに配送されるのではなく、共有のキューに積まれ、1回だけ処理されます。これとは別にスレッド宛てのシグナルが存在し、同期的な処理に起因するシグナルはスレッド宛てに配送されます。
シグナルによる割り込み
配送されたシグナルの処理方法はいくつかあります:
- OSによるデフォルト処理が適用される。
- 何もしない。
- プロセスに割り込む形でシグナルハンドラが呼び出される。
- (Linux の場合) その場では何もせず、 signalfd 系のAPIを使ってあとで処理する。
- ptrace などで外から横取りする。
終了系のシグナルのデフォルト処理は当然、プロセスの終了です。これは実行中の処理が突然終了され、OSが明示的に管理しているリソースのみが強制的に解放される結果になります。これでは困る場合こそ優雅なシャットダウンの出番です。
シグナルを自分で処理する場合の古典的な方法は、シグナルハンドラを登録する方法です。シグナルハンドラが呼び出される場合、そのプロセスの次のタイムスライスが開始するときの復帰シーケンスが変更されます。通常であれば元の命令ポインタに戻るだけですが、シグナルハンドラを呼び出すときはOSが以下の処理を行います。
- ユーザー空間のスタックを勝手に拡張してフレームを作る。
- レジスタ、復帰先命令ポインタとシグナル用の情報をフレームに退避し、リターンアドレスをシグナルトランポリンと呼ばれるコードに向ける。
- シグナルハンドラに登録されたポインタに jump (call) する。
シグナルを受ける側のプロセスから見ると、実行中の処理を強制的に中断させられ(タイマー割り込みの場合)、同じプロセスを再利用して強制的に新しい関数呼び出しが行われることになります。そのため、途中まで実行していた処理が安全な位置で中断している保証がなく、シグナルハンドラで安全にできる処理は厳しく限定されます (たとえば printf(3) や malloc(3) は一般に async-signal-unsafe です)。
Node.js のシグナル処理
Node.js はそれ自体がシグナルハンドラを持っています。コマンドとして起動した場合 (kNoDefaultSignalHandling はオフ) は以下の初期化処理を行います。
- シグナルハンドラを初期状態に復帰
- SIGINT, SIGTERM のハンドラを登録 (1回限りのハンドラとして登録)
登録されているハンドラの処理は以下の通りです:
- ターミナルの状態を復帰
- 自分自身に同じシグナルを送り直す
1回限りのハンドラとして登録しているため、送り直したシグナルはOSのデフォルト処理で処理されます。これはプロセスを終了させます。
また、 JavaScript コードから明示的にシグナルハンドラを登録することができます。
コンテナとシグナル
コンテナへの終了通知
コンテナ環境における終了通知の慣例があります。
たとえば、 docker stop は以下のルールで送信するシグナルを決定します:
- stopコマンドの
--signalオプションがあれば、それを使う。 - コンテナ作成時に
--stop-signalオプションがあれば、それを使う。 - イメージの StopSignal 設定 (Dockerfile の STOPSIGNAL) があれば、それを使う。
- いずれにも該当しなければ、 SIGTERM を使う。
シグナルを送信後、一定時間以内にコンテナが終了しなかった場合は SIGKILL でコンテナを強制終了させます。
Kubernetes の場合は以下のルールで送信するシグナルを決定します:
- [Kubernetes 1.33 alpha] lifecycle.stopSignal があれば、それを使う。
- イメージの StopSignal 設定 (Dockerfile の STOPSIGNAL) があれば、それを使う。
- いずれにも該当しなければ、 SIGTERM を使う。
シグナルを送信後、一定時間以内にコンテナが終了しなかった場合は SIGKILL でコンテナを強制終了させます。
以上のように基本的には SIGTERM が送られるので、アプリケーションは SIGTERM を優雅なシャットダウンのために利用するべきです。 SIGQUIT を利用する nginx のように、すでに決まったシグナルがある場合は STOPSIGNAL の設定を検討しましょう。
退役手順
受動的なサービスインスタンスの場合、単にプロセスを安全に終了するだけではリクエストを安全にルーティングし続けることができません。この場合、ワークロードをサービスから退役させるための追加の手順が必要です。
Kubernetes では Pod の Service からの退役がこの手順にあたります。ところが Kubernetes には有名な欠陥があり、 Service からの退役を待ってから Pod の終了処理を開始するための仕組みが整備されていません。この設計はKubernetesの分散性に由来する意図的なものであり、簡単な修正では解決しないとされています。そのため、残念なことながら、現在にいたるまで、 Kubernetes における最も優れた退役手段は「終了処理の開始を一定時間遅らせる」というものです:
spec:
containers:
- # ... #
lifecycle:
preStop:
exec:
command: ["/bin/sh","-c","sleep 20"]
terminationGracePeriodSeconds: 60
ただ、このパターンを書きやすくし、コンテナへの依存を減らすために、 Kubernetes 1.34 でGAした native sleep action を使うことができます:
spec:
containers:
- # ... #
lifecycle:
preStop:
sleep:
seconds: 20
terminationGracePeriodSeconds: 60
PID 1 の問題
コンテナ環境では、指定したコマンドが PID 1 で実行されます。これは以下の2つの結果をもたらします:
- シグナルに対するOSのデフォルト動作が発生しない。
- 孤児プロセスがアタッチされる。
Node.js 自身は PID 1 への対策を行っていません。シグナルはOSのデフォルト動作で処理されます (SIGINT, SIGTERM は登録されているように見えますが、シグナルの送り直しで処理しているので、OSの動作に依存しています)。そのため、コンテナ環境で Node.js を使う場合は手前に小さい init process を置いておくことが推奨されます。
ロングポーリングとストリーミング
優雅なシャットダウンは、リソースの解放を短い有界な時間で行えることを前提としています。HTTPサーバーで「リクエストを最後までやり切る」ことを目標にする場合、レスポンスにかかる時間が解放時間になりますが、これが処理できるのは「通常のリクエストは短い時間で処理される」ということを前提にしています。通常のリクエストはレスポンスをなるべく速く準備して返送することが目標になるため、この仮定や要求が成立するとみなせます。
いっぽう、ロングポーリングやストリーミングはこの限りではありません。ロングポーリングは意図的に接続を待たせていますし、ストリーミングはロングポーリングと同様の目的で行われるか、クライアントからの背圧か、または帯域の問題によりリクエストに時間がかかることを想定した仕組みであり、どの理由にせよリクエストが短く完了することを前提にできません。
このようなケースで優雅なシャットダウンを実現するには、リクエストを中断・移行する仕組みをアプリケーションレイヤで実装するなどの対策が必要です。たとえば通知を待機するロングポーリングの場合は、サーバーから中断通知を送れるようにします。クライアントは中断通知を受け取ったら単に接続を再試行することで、リクエストを引き継げる別のサーバーに接続することができます。
また、能動的に動作するワークロード (非同期ジョブワーカーなど) でも、長いタスクを処理しようとすると同じような問題に直面します。この場合も、タスクの再開可能点を増やすなどして分割することが重要です。
HTTPの持続的接続 (keep-alive)
HTTP の3つのトランスポート
HTTP には3種類の異なるトランスポートがあります:
- HTTP/1.1: 基本のトランスポート。リクエストはブロッキングで実行される
- HTTP/2: 複数のストリームを1つのTCP接続にmuxするが、 head-of-line blocking が起きる
- HTTP/3: QUIC を使うことで完全なmuxを提供
持続的接続 (keep-alive) とその問題
持続的接続 (persistence, keep-alive) は HTTP/1.0 の時代に登場し HTTP/1.1 でデフォルト機能として取り込まれた仕組みです。もともとの HTTP では1回のリクエストごとにTCP接続を張っていましたが、持続的接続ではリクエストが完了したTCP接続を使い回して次のリクエストを発行できます。
HTTP/1.0 と HTTP/1.1 では持続的接続のデフォルト値が違いますが、以下では HTTP/1.1 を基準に説明します。
持続的接続をするかどうかはクライアント・サーバー両者の交渉によって決まります。クライアントはリクエストヘッダ内で、サーバーはレスポンスヘッダ内で持続的接続の希望を記述します。両者が持続可能だった場合、クライアントは当該TCP接続を再利用して次のリクエストを送ることができます。以降はこの交渉を繰り返し行います。つまり、最初に持続可能だからといって、次回以降も持続可能とは限りません。
クライアントは直前のレスポンスを待たずに、持続的接続の成立を予期して投機的に次のリクエストを投げることもできます (パイプライン化)。ただし、この投機的なリクエストを安全に行うためには厳しい条件が課されており、Webブラウザはパイプライン化を事実上放棄しています。
さらに大きな問題がサーバー側タイムアウトです。持続的接続の交渉権がサーバー側にあるのはリクエストからレスポンスまでの期間中のみです。アイドル期間中に安全に持続的接続を閉じられるのはクライアント側のみで、サーバー側は本質的に「すれ違い」のリスクを抱えています。想定されるシナリオは以下の通りです:
- リクエスト・レスポンスが一周し、持続的接続が合意される。
- クライアントが次のリクエストを要求しないまま時間が経過する。
- クライアントが次のリクエストを発行する。
- それと前後して、サーバー側がタイムアウトで接続を閉じる。
- クライアントはサーバー側から接続を閉じられたことを知る。サーバーは、閉じた接続から次のリクエストが要求されていたことを知る。
HTTP/2 の場合
HTTP/2 では GOAWAY に Last-Stream-ID を含めるため、すれ違いが発生した場合であっても、「どのストリームが成立したか」について合意を取ることができます。クライアント側がこれを適切にハンドリングすれば、すれ違いによって中断されたリクエストを単になかったことにすることは可能です。
羃等にリトライすればいいか
HTTP は原則論としてリクエストの確実性を保証せず、羃等なリクエストのリトライによって堅牢化することを推奨しています。これ自体は正しいものの、実際のクライアントであらゆるリクエストの暗黙的なリトライを期待することは難しいです。サービスの信頼性を上げるには、リトライを前提としたセーフティーネットは置きつつも、なるべくひとつひとつのリクエストも丁寧に処理するのが望ましいと思います。
Node.js の HTTP server を優雅にシャットダウンする方法
Node.js 組み込みの HTTP server は close メソッドを提供しています。これは以下の処理を行います。
- 新規TCP接続の受付を停止する
- 全てのアイドル接続を切断する
- 全てのTCP接続が切れたら、コールバックを呼ぶ
これは一見すると優雅なシャットダウンをうまく実現しているように見えますが、以下の構造的欠陥があります。
- close時点ではアクティブな接続がclose後にアイドル状態になったとき、この接続が自動的に閉じられない。 (それどころか、 close のタイミングでタイムアウト処理の一部が停止されてしまっているので、より悪い状況になりうる)
さらにこれは以下の2つの段階に大別できます:
- close時点で、リクエストを受けとってからレスポンスヘッダを構築するまでのどこかにいた場合
- close時点で、レスポンスヘッダを構築してからレスポンスを送信しきるまでのどこかにいた場合
これらの対策をするなら、たとえば以下のようなコードを書く必要があります。
// Listen 前に Keep-Alive を無効化するフックを仕込んでおく (最初は潜伏)
let closing = false;
const server = http.createServer((req, res) => {
if (closing) {
res.setHeader("Connection", "close");
}
app(req, res);
});
// サーバーを閉じるとき
process.on("SIGTERM", () => {
// 次から Connection: close を送るようにする
closing = true;
// タイミング的に Connection: close を送りそこねた場合でもなるべくすぐ切断する (入れ違いは仕方ないので許容)
// 0 はタイムアウトなしになってしまうので 1ms で設定
server.keepAliveTimeout = 1;
server.keepAliveTimeoutBuffer = 1;
server.close(() => /* ... */);
});
このように書くと、レスポンス構築前であれば Connection: close を注入して持続的接続を止めることができます。レスポンス構築後の場合はヘッダを変更できないので、レスポンスを送り終えてからなるべく速くタイムアウトで接続を閉じるようにします。
このようにしても、クライアントのリクエストに応答できない以下のケースがあります:
- close時にアイドルとみなして切断した接続に、次のリクエストが送信中であった場合
- close時に途中だったレスポンスを完了させ、短いタイムアウトで切断するまでの間に、次のリクエストが送信中であった場合
これらは前述のように、HTTP/1.1の持続的接続の構造的な欠陥に由来するものなので、完璧な対処は難しいです。
まとめ
- 優雅なシャットダウンとは終了前にリソースを安全に解放することであり、 HTTP サーバーではリクエストを完了まで持っていくことが目標になることが多い。
- HTTPサーバーの優雅なシャットダウンを妨げる構造的な問題がいくつもある。
- ロングポーリングや長い非同期ジョブの対策はアプリケーション側で行う必要がある。
- Kubernetes の優雅なシャットダウンには Pod の Service からの退役にまつわる構造的な問題がある。
- HTTP/1.1 の持続的接続の切断には構造的な問題がある。
- Node.js 組み込みの HTTP Server の close メソッドには構造的な問題がある。