背景紹介
私たちのサービスがリリースされた後、実行環境(コンテナ、pm2 など)によるスケジューリングやサービスのアップグレードによって再起動が発生したり、さまざまな異常が原因でプロセスがクラッシュすることは避けられません。通常、実行環境にはサービスプロセスのヘルスチェック機能があり、プロセスが異常終了した場合には再起動が行われ、アップグレード時にはローリングアップデートの戦略が適用されます。しかし、実行環境のスケジューリング戦略は、サービスプロセスをブラックボックスとして扱うため、プロセスの内部状態には関与しません。そのため、サービスプロセスが自ら実行環境のスケジューリング動作を検知し、適切な終了処理を行う必要があります。
そこで、今回は Node.js プロセスが終了するさまざまな状況を整理し、プロセス終了時のイベントを監視することでどのような対応が可能かを検討します。
原理
プロセスが終了する理由は、大きく分けて次の 2 つのパターンしかありません。
- プロセスが自発的に終了する
- システムからのシグナルを受け取って終了する
システムシグナルによる終了
Node.js の公式ドキュメントには、代表的なシステムシグナルが一覧として掲載されています。ここでは、特に注目すべきシグナルをいくつか紹介します。
-
SIGHUP:
Ctrl+C
ではなく、コマンドラインターミナルを直接閉じると、このシグナルが発生する。 -
SIGINT:
Ctrl+C
を押すと発生する。pm2 でプロセスを再起動または停止する際にも、子プロセスにこのシグナルが送信される。 - SIGTERM: 一般的に、プロセスの優雅な終了を通知するために使用される。たとえば、Kubernetes(k8s)で pod を削除すると、このシグナルが pod に送信される。pod はタイムアウト(デフォルトでは 30 秒)までにクリーンアップ処理を行うことができる。
-
SIGBREAK: Windows システムでは、
Ctrl+Break
を押すとこのシグナルが発生する。 -
SIGKILL: プロセスを強制終了するシグナルで、プロセスはクリーンアップ処理を実行できない。
kill -9 <pid>
コマンドを実行すると、このシグナルが送信される。k8s では、pod 削除時に 30 秒経過しても終了しない場合に SIGKILL を送信し、即座に pod を終了させる。pm2 でも、プロセスの停止または再起動時に 1.6 秒を超えて終了しない場合、SIGKILL を送信する。
強制終了以外のシグナルを受け取った場合、Node.js プロセスはそのシグナルをリスニングし、独自の終了処理を実行できます。たとえば、CLI ツールを開発しているとき、長時間のタスクを実行している場合、ユーザーが Ctrl+C
を押して中断しようとした際に確認を促すことができます。
const readline = require('readline');
process.on('SIGINT', () => {
// readline を使用して対話的に処理を進める
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
rl.question('タスクがまだ完了していません。本当に終了しますか?', (answer) => {
if (answer === 'yes') {
console.log('タスクを中断し、プロセスを終了します');
process.exit(0);
} else {
console.log('タスクを継続します...');
}
rl.close();
});
});
// 1 分間の処理をシミュレート
const longTimeTask = () => {
console.log('タスク開始...');
setTimeout(() => {
console.log('タスク終了');
}, 1000 * 60);
};
longTimeTask();
実行すると、Ctrl+C
を押すたびに以下のようなメッセージが表示されます。
タスクがまだ完了していません。本当に終了しますか? no
タスクを継続します...
タスクがまだ完了していません。本当に終了しますか? no
タスクを継続します...
タスクがまだ完了していません。本当に終了しますか? yes
タスクを中断し、プロセスを終了します
プロセスの自発的な終了
Node.js プロセスが自発的に終了する主なケースは以下の通りです。
- コードの実行中にキャッチされないエラーが発生する(
process.on('uncaughtException')
で監視可能) - コードの実行中に未処理の Promise リジェクションが発生する(Node.js v16 以降ではこれが原因でプロセスが終了する)。
process.on('unhandledRejection')
で監視可能 -
EventEmitter
がリスナーのないerror
イベントを発火する -
process.exit
関数を明示的に呼び出してプロセスを終了する(process.on('exit')
で監視可能) - Node.js のイベントループが空になり、実行すべきコードがなくなった場合(
process.on('exit')
で監視可能)
たとえば、pm2 にはプロセスを監視・再起動する機能があります。プロセスがエラーで異常終了しても、pm2 は自動で再起動を試みます。これと同じように、Node.js の cluster
モジュールを使用して、子プロセスを監視し、エラーが発生して終了した場合に再起動する機能を実装できます(pm2 もこのような仕組みを利用しています)。
const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;
const process = require('process');
// マスタープロセスの処理
if (cluster.isMaster) {
console.log(`マスタープロセス起動: ${process.pid}`);
// CPU コアの数に応じてワーカープロセスを作成
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
// ワーカープロセスの終了を監視し、再起動
cluster.on('exit', (worker, code, signal) => {
console.log(
`ワーカープロセス ${worker.process.pid} が終了(コード: ${code || signal})。再起動中...`
);
cluster.fork();
});
}
// ワーカープロセスの処理
if (cluster.isWorker) {
// 未処理のエラーをキャッチ
process.on('uncaughtException', (error) => {
console.log(`ワーカープロセス ${process.pid} でエラー発生`, error);
process.emit('disconnect');
process.exit(1);
});
// Web サーバーを作成
http
.createServer((req, res) => {
res.writeHead(200);
res.end('hello world\n');
})
.listen(8000);
console.log(`ワーカープロセス起動: ${process.pid}`);
}
実践的なアプローチ
これまでに Node.js プロセスが終了するさまざまなケースを整理しました。次に、プロセス終了時にカスタム処理を実行できるようにするユーティリティを作成します。
// exit-hook.js
// 実行する終了処理を保存
const tasks = [];
// 終了処理を追加
const addExitTask = (fn) => tasks.push(fn);
const handleExit = (code, error) => {
// ...handleExit の実装は後述
};
// 各種終了イベントを監視
process.on('exit', (code) => handleExit(code));
// POSIX の規約に従い、128 + シグナル番号を終了コードとして扱う
// シグナル番号は `kill -l` コマンドで確認可能
process.on('SIGHUP', () => handleExit(128 + 1));
process.on('SIGINT', () => handleExit(128 + 2));
process.on('SIGTERM', () => handleExit(128 + 15));
// Windows で `Ctrl+Break` を押したときのシグナル
process.on('SIGBREAK', () => handleExit(128 + 21));
// キャッチされないエラーや Promise リジェクションによる異常終了
process.on('uncaughtException', (error) => handleExit(1, error));
process.on('unhandledRejection', (error) => handleExit(1, error));
次に、実際にプロセス終了処理を実装します。ユーザーが登録する処理は同期的なものと非同期的なものがあるため、process.nextTick
を活用して同期処理の完了を保証します。process.nextTick
は各イベントループの同期処理が完了した後に実行されます(詳細な説明: process.nextTick
について)。また、非同期タスクについては、ユーザーがコールバックを呼び出して完了を通知できるようにします。
// 複数回実行されないようにフラグを設定
let isExiting = false;
const handleExit = (code, error) => {
if (isExiting) return;
isExiting = true;
// 一度だけ `process.exit` を実行するためのフラグ
let hasDoExit = false;
const doExit = () => {
if (hasDoExit) return;
hasDoExit = true;
process.nextTick(() => process.exit(code));
};
// 非同期タスクのカウント
let asyncTaskCount = 0;
// 非同期タスクが完了した際に呼び出すコールバック
let asyncTaskCallback = () => {
process.nextTick(() => {
asyncTaskCount--;
if (asyncTaskCount === 0) doExit();
});
};
// 登録された終了処理を実行
tasks.forEach((taskFn) => {
if (taskFn.length > 1) {
// 非同期タスクの場合(コールバックを受け取る)
asyncTaskCount++;
taskFn(error, asyncTaskCallback);
} else {
taskFn(error);
}
});
// 非同期タスクが存在する場合
if (asyncTaskCount > 0) {
// 10 秒経過後、強制終了
setTimeout(() => {
doExit();
}, 10 * 1000);
} else {
doExit();
}
};
これでプロセス終了時にカスタム処理を実行できるユーティリティが完成しました。完全な実装は、オープンソースライブラリ async-exit-hook
で確認できます。
プロセスの優雅な終了(Graceful Shutdown)
通常、Web サーバーが再起動されるときや、コンテナのスケジューリング(pm2 や Docker など)によってプロセスが終了するとき、または異常が発生してプロセスがクラッシュするときに、適切な終了処理を実行することが求められます。
具体的には、以下のような処理を終了前に行いたいケースが考えられます。
- すでに接続しているクライアントへのレスポンスを完了させる
- データベース接続をクローズする
- エラーログを記録し、アラートを発生させる
- その他のリソース(ファイルハンドルや外部 API との接続など)を適切にクリーンアップする
終了処理を完了した後にプロセスを終了することで、よりスムーズなサービス運用が可能になります。これには、先ほど作成したプロセス終了監視ツールを活用できます。
const http = require('http');
// Web サーバーの作成
const server = http
.createServer((req, res) => {
res.writeHead(200);
res.end('hello world\n');
})
.listen(8000);
// 開発したツールを利用してプロセス終了時の処理を追加
addExitTask((error, callback) => {
// エラーログの記録、アラートの発生、データベース接続の解放など
console.log('プロセスが異常終了しました', error);
// 新規リクエストの受付を停止
server.close((error) => {
if (error) {
console.log('新規リクエストの受付停止エラー', error);
} else {
console.log('新規リクエストの受付を停止しました');
}
});
// 簡易的なアプローチとして、一定時間(ここでは 5 秒)待機し、既存リクエストの処理を完了させる
// すべてのリクエストが完了するのを完全に保証するには、すべての接続を記録し、それらが解放された後に終了する必要がある
// 参考: https://github.com/sebhildebrandt/http-graceful-shutdown
setTimeout(callback, 5 * 1000);
});
このようにすることで、プロセスが終了する前に適切なクリーンアップ処理を行い、クライアントに対する影響を最小限に抑えることができます。
まとめ
本記事を通じて、Node.js プロセスが終了するさまざまなケースについて整理しました。
サービスを本番環境にデプロイすると、k8s や pm2 などのツールによって異常終了時に自動でプロセスが再起動され、サービスの可用性が確保されます。しかし、単にプロセスを再起動するだけではなく、アプリケーションコードの中でプロセスの異常やスケジューリングによる再起動を能動的に検知し、適切な終了処理を実装することが重要です。これにより、問題を早期に発見し、より安定した運用が可能になります。
私たちはLeapcell、Node.jsプロジェクトのホスティングの最適解です。
Leapcellは、Webホスティング、非同期タスク、Redis向けの次世代サーバーレスプラットフォームです:
複数言語サポート
- Node.js、Python、Go、Rustで開発できます。
無制限のプロジェクトデプロイ
- 使用量に応じて料金を支払い、リクエストがなければ料金は発生しません。
比類のないコスト効率
- 使用量に応じた支払い、アイドル時間は課金されません。
- 例: $25で6.94Mリクエスト、平均応答時間60ms。
洗練された開発者体験
- 直感的なUIで簡単に設定できます。
- 完全自動化されたCI/CDパイプラインとGitOps統合。
- 実行可能なインサイトのためのリアルタイムのメトリクスとログ。
簡単なスケーラビリティと高パフォーマンス
- 高い同時実行性を容易に処理するためのオートスケーリング。
- ゼロ運用オーバーヘッド — 構築に集中できます。
Xでフォローする:@LeapcellHQ