12k
All articles

Node サーバーをグレースフルにシャットダウンする

SIGTERM対応のNodeグレースフルシャットダウン: readinessを切り替え、server.closeでリクエストを終了し、順番にリソースを閉じて502を防ぐ。

OpenReplay Team
OpenReplay Team
Node サーバーをグレースフルにシャットダウンする

Node のグレースフルシャットダウンは、SIGTERM を決まった順序で処理します。まず readiness チェックを失敗させ、ロードバランサーがトラフィックのルーティングを停止するのを待ち、server.close() で処理中のリクエストをドレインし、依存関係の順序でリソースをクローズしてから終了します。

すでにそのほとんどを実装しているのに、デプロイのたびに 502 やコネクションリセットが発生する場合、問題はハンドラ自体にないことがよくあります。原因となるのは、その周辺にある次の 3 つです。シグナルが Node に届いていない、ドレインが完了しない、あるいはドレインが終わる前にオーケストレーターが Pod を kill している、のいずれかです。本記事では小さなハンドラを 1 つ作り、この 3 つすべてを順に検討し、最後に実際に実行できるテストを示します。

要点

  • SIGTERM ハンドラがない場合、Node は即座に終了し、処理中のリクエストはすべてコネクションリセットまたは切り詰められたレスポンスとして終わります。
  • データベースプールをクローズする前に server.close() を await してください。プールを先に閉じると、処理中のクエリがすべてエラーになります。
  • Node 19.0.0 以降、server.close() はアイドル状態の keep-alive コネクションを自ら閉じます。かつての「クローズコールバックが発火しない」ハングは過去のものです。
  • Dockerfile の CMD をシェル形式で書くと、SIGTERM を受け取るのは Node ではなくシェルになるため、ハンドラのコードは一切実行されません。
  • terminationGracePeriodSeconds より短い強制終了タイマーをプロセス内に設定し、SIGKILL を受け取るのではなくプロセス自身が終了方法を選べるようにしましょう。

SIGTERM ハンドラがないと何が起きるのか?

シグナルイベントのドキュメントにあるとおり、SIGTERM に対する Node のデフォルト動作はプロセスの終了です。その瞬間に処理中だったものはレスポンスの途中で断ち切られます。クライアントにはリセットまたは不完全なボディが返り、ロードバランサーは 502 を記録します。セッションリプレイで見ると、この障害には特徴的な形があります。ユーザーの操作がスピナーのまま止まってエラーになり、しかもそれがユーザーの行動とは無関係に、デプロイのタイミングと一致する狭い時間帯に集中して発生するのです。このクライアント側の視点こそ、バグが最初に気づかれる場所であることがほとんどです。

Node のグレースフルシャットダウンの正しい順序

正しいハンドラは 5 つのことを順番に行います。シャットダウンフラグを立てて readiness を失敗させ、ロードバランサーが反応するのを短時間待ち、server.close() を await して処理中のリクエストを完了させ、残りのリソースをクローズし、終了します。重要なのは、このクローズを await する点です。server.close() を呼びながらそのコールバックを待たずにリソースをクリーンアップして終了するハンドラは、防ぐために書いたはずのクラッシュを自ら再現していることになります。

  1. シャットダウンフラグを立て、readiness プローブが 503 を返すようにする。
  2. ロードバランサーが新規トラフィックのルーティングを止めるのを短時間待つ。
  3. server.close() を await し、処理中のリクエストを完了させる。
  4. 残りのリソースを依存関係の順序でクローズする。
  5. プロセスを終了する。
// server.js (Express)
const { setTimeout: sleep } = require('node:timers/promises');

let shuttingDown = false;

app.get('/health/live', (req, res) => res.sendStatus(200));
app.get('/health/ready', (req, res) => res.sendStatus(shuttingDown ? 503 : 200));

async function shutdown() {
  if (shuttingDown) return;
  shuttingDown = true;                    // readiness now returns 503

  await sleep(LB_WAIT_MS);                // matches your preStop sleep

  await new Promise((resolve, reject) =>
    server.close((err) => (err ? reject(err) : resolve()))
  );

  await closeResources();                 // next section
  process.exit(0);
}

process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);

ドレインが止まってしまうのはなぜか?

サポート対象のどの Node リリースでも、server.close() は新規コネクションを停止し、アイドル状態の keep-alive ソケットを閉じ、リクエストを処理中のコネクションが完了するのを待ちます。「アイドルの keep-alive ソケットがあるとクローズコールバックが永遠に発火しない」という広く流布した警告は、19.0.0 より前の Node についての話であり、サポート対象のバージョンではこのハングはもう起きません。それより古いランタイムを使わざるを得ない場合は、クローズを開始した直後に(開始前ではなく)server.closeIdleConnections()(Node 18.2.0 で追加)を呼び、新しく作られたコネクションとの競合を避けてください。

現在もドレインを止め得るのは、正当にアクティブな処理です。遅いハンドラ、Server-Sent Events、別プロトコルにアップグレードされたソケットなどが該当します。後述の強制終了タイマーは、まさにそのために存在します。

リソースは依存関係の順序でクローズする

まず HTTP サーバー、次にキューワーカーとバックグラウンドジョブ、続いて Redis、最後にデータベースプールをクローズします。この順序は依存関係の連鎖に従っています。リクエストハンドラやジョブは Redis とプールを使うため、それらがまだ動いている状態でプールを閉じると、処理中のクエリがすべてエラーになり、せっかく await したドレインが台無しになります。

async function closeResources() {
  await worker.close();   // BullMQ or similar: finish the active job
  await redis.quit();
  await pool.end();       // pg pool last: nothing queries after this
}

猶予期間より長く実行されるジョブに必要なのはドレインではなく、再開できるようにするためのチェックポイント処理です。

グレースフルシャットダウンを壊すコンテナ設定は?

シグナルを実際に受け取るプロセスが Node でなければ、ここまでの話はすべて意味を持ちません。Dockerfile リファレンスは、シェル形式ではコマンドが /bin/sh -c の下で実行され、これは子プロセスにシグナルを渡さないと明記しています。つまり、シェル形式の CMD では docker stop による SIGTERM が Node に届くことはありません。

# Broken: /bin/sh -c receives SIGTERM, node never does
CMD node server.js

# Correct: node is the signal target
CMD ["node", "server.js"]

ゾンビプロセスを回収するために init プロセスが必要な場合、tini は子プロセスへシグナルを転送するため、ハンドラは引き続き動作します。なお、docker stop は Linux ではデフォルトで 10 秒後に SIGKILL へエスカレートします(-t で設定可能)。

Kubernetes では、terminationGracePeriodSeconds(デフォルト 30)を総ドレイン時間より大きく設定し、preStop の sleep を追加してください。エンドポイントの削除は SIGTERM と並行して評価され、EndpointSlices を介して非同期に伝播するため、シグナルの後もしばらくはリクエストが届き続けます。readiness を失敗させる一方で、liveness プローブは通り続けるようにしてください。同じ失敗するエンドポイントに liveness プローブを紐づけていると、ドレインの途中でコンテナが再起動されてしまいます。

terminationGracePeriodSeconds: 30   # > preStop + LB wait + drain + cleanup
lifecycle:
  preStop:
    exec:
      command: ["sleep", "5"]       # LB_WAIT_MS should match

SIGKILL より先に自分で終了方法を決める

ハンドラの冒頭で、オーケストレーターの猶予期間より短い値を設定した強制終了タイマーを仕掛けておきましょう。そうすれば、ドレインが停滞しても SIGKILL ではなく、自分のログ行と終了コードで終わらせられます。最後の手段として server.closeAllConnections()(Node 18.2.0 で追加)を呼び、リクエストを処理中のものも含めてすべてのオープンなコネクションを破棄します。別プロトコルに切り替わったコネクションはこれを生き延びるため、WebSocket には独自のクローズフレームのブロードキャストが必要です。

const forceExit = setTimeout(() => {
  server.closeAllConnections();
  process.exit(1);
}, GRACE_MS - 2_000);   // grace period minus a buffer, never a fixed default
forceExit.unref();

シャットダウンが機能していることをどう証明するか?

受け入れテストは機械的です。サーバーを spawn し、意図的に遅いルートにリクエストを投げ、処理の途中で SIGTERM を送り、それでもレスポンスが 200 で返り、プロセスが 0 で終了することをアサートします。

// verify-shutdown.js
const { spawn } = require('node:child_process');
const assert = require('node:assert');

const child = spawn('node', ['server.js'], { stdio: ['ignore', 'pipe', 'inherit'] });
child.stdout.on('data', async (chunk) => {
  if (!chunk.toString().includes('listening')) return;
  const pending = fetch('http://localhost:3000/slow'); // route awaits ~2s
  setTimeout(() => child.kill('SIGTERM'), 100);
  const res = await pending;
  assert.equal(res.status, 200);
  const code = await new Promise((r) => child.on('exit', r));
  assert.equal(code, 0);
  console.log('graceful shutdown verified');
});

これがローカルでは通るのにデプロイでリクエストが落ち続ける場合、残る容疑者はコンテナ層です。シェル形式の CMD か、ドレインに必要な時間より短い猶予期間のどちらかでしょう。

ハンドラ自体は 40 行ほどです。信頼性をもたらすのは、その順序と周囲の環境です。テストを CI に組み込み、次のリファクタリングで、いま直したばかりの「デプロイ時リセット」バグが黙って再発しないようにしましょう。

FAQ

Node.js プロセスは SIGKILL をキャッチしたり処理したりできますか?

いいえ。Node は SIGKILL に対するリスナーの登録を拒否しますし、このシグナルはコードの内容にかかわらずあらゆるプラットフォームでプロセスを終了させるため、クリーンアップは一切実行されません。SIGSTOP も同様にリッスンできません。だからこそプロセス内の強制終了タイマーが重要になります。オーケストレーターの猶予期間が切れる前に発火させることで、応答する間もなく kill されるのではなく、SIGTERM でドレインして終了できるのです。

closeIdleConnections と closeAllConnections の違いは何ですか?

どちらのメソッドも Node 18.2.0 で追加されました。closeIdleConnections はリクエスト間でアイドル状態になっているソケットだけを閉じるため、リクエスト処理中のものは完了するまで残されます。closeAllConnections はより荒っぽく、リクエストを処理中のものも含めてすべてのオープンなコネクションを破棄します。ただし WebSocket など別プロトコルに切り替わったコネクションはこれを生き延びます。Node 19.0.0 以降は server.close が自らアイドルコネクションを片付けるため、closeIdleConnections を呼ぶ価値があるのは古いランタイムをまだサポートしている場合だけです。

process.exit は処理中のリクエストや保留中の非同期処理を待ちますか?

いいえ。process.exit はプロセスを即座に終了させ、キューに残っている非同期処理を、stdout や stderr への書き込みが完了していない出力に至るまですべて破棄します。だからこそシャットダウンハンドラは exit を呼ぶ前に server.close とリソースのクリーンアップを await するのです。それより早く exit を呼べば、ハンドラが防ぐはずだったリクエスト取りこぼしのクラッシュを再現することになります。ハンドラの外では、process.exitCode を設定してプロセスが自然に終了するのに任せるのが望ましいです。

SIGTERM ハンドラは Windows でも動作しますか?

同じようには動きません。Windows には POSIX シグナルが存在せず、Node.js のドキュメントでも SIGTERM は Windows では非サポートとされています(コード上はリスナーを登録できますが)。Ctrl+C はどの環境でも SIGINT を発生させるため、ハンドラはローカル開発向けに SIGINT もリッスンしています。SIGTERM のドレイン経路は、docker stop や Kubernetes が実際にシグナルを配送する Linux コンテナ内でテストしてください。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.