グレースフルシャットダウン:終了手順が 502 と正常応答を分ける

終了信号で即座に exit すると処理中の要求が切られます。正しい順序は、新規受付の停止、処理中の完了待ち、資源の解放、そして終了です。

ローリング配備で散発する 502 は、ほぼ終了手順の誤りです。終了信号を受けて即座に終了すると処理中の要求が断ち切られ、ロードバランサ側では失敗として記録されます。

正しい四段階

  1. 新しい接続の受け付けを止める:ヘルスチェックから外れるか、待ち受けを閉じる
  2. 処理中の要求を待つ:上限付きで
  3. 資源を解放する:データベース接続、待ち行列、一時ファイル
  4. 終了する

一段目と二段目は必ず分けます。ここが要点です。先に流量を外し、それから排水しますが、その間に観察窓が必要です。ロードバランサが実際に転送を止めるまで、一、二周期かかります。

実用的な構造

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 を残します。

← 記事一覧に戻る

コメント

…