グレースフルシャットダウン:終了手順が 502 と正常応答を分ける
終了信号で即座に exit すると処理中の要求が切られます。正しい順序は、新規受付の停止、処理中の完了待ち、資源の解放、そして終了です。
ローリング配備で散発する 502 は、ほぼ終了手順の誤りです。終了信号を受けて即座に終了すると処理中の要求が断ち切られ、ロードバランサ側では失敗として記録されます。
正しい四段階
- 新しい接続の受け付けを止める:ヘルスチェックから外れるか、待ち受けを閉じる
- 処理中の要求を待つ:上限付きで
- 資源を解放する:データベース接続、待ち行列、一時ファイル
- 終了する
一段目と二段目は必ず分けます。ここが要点です。先に流量を外し、それから排水しますが、その間に観察窓が必要です。ロードバランサが実際に転送を止めるまで、一、二周期かかります。
実用的な構造
let shuttingDown = false;
const inflight = new Set<Promise<unknown>>();
process.on('SIGTERM', async () => {
shuttingDown = true;
server.close(); // 1. 受付停止
await sleep(3000); // LB が外れるのを待つ
await Promise.race([ // 2. 排水、期限付き
Promise.allSettled([...inflight]),
sleep(25000),
]);
await db.end(); // 3. 解放
process.exit(0); // 4. 終了
});
// ヘルスチェックは状態を反映する
app.get('/healthz', (req, res) =>
res.status(shuttingDown ? 503 : 200).send('ok'));
順序の三つの誤り
| 誤り | 結果 |
|---|---|
閉じる前に process.exit() |
処理中の要求が切れ、502 |
| ヘルスチェックを外さず終了 | LB が転送を続け、同じく 502 |
| 上限なしで待つ | 一つの停滞した要求が配備を止める |
三番目が最も厄介です。排水には必ず上限を設けます。 超えたら終了し、失敗を再試行に委ねます。
コンテナでの追加手順
コンテナは終了信号を実際のプロセスへ転送する必要があります。入口が exec の無いシェルスクリプトだと、信号はシェルに行き、アプリは受け取れず、期限後に SIGKILL されます。
CMD ["node", "server.js"] # exec 形式、シェル包装なし
Kubernetes では terminationGracePeriodSeconds を排水の上限より大きくします。でなければ排水の完了前に SIGKILL が来ます。
検証方法
ログだけを信じないことです。配備中に要求を打ち続け、非 2xx の割合を数えます。
while true; do curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/; done
ローリング再起動中はすべて 200 になるはずです。502 が一つでも出れば、四段階のどこかが欠けています。
終了は一行の exit ではなく手順です。受付停止、排水、解放、終了。一つ欠ければ配備のたびに 502 を残します。

コメント
…