何が起きているのか
コードのどこかで client.close() または mongoose.disconnect() が実行されました。これによってコネクションプールが排出・シャットダウンされました。問題は、保留中のリクエスト、非同期コールバック、またはバックグラウンドジョブが、その後もMongoDBの操作を実行しようとして、閉じられたドアに突き当たったことです。
完全なエラーは次のようになります:
MongoPoolClosedError: Attempted to check out a connection from closed connection pool
at ConnectionPool.checkOut (/node_modules/mongodb/lib/cmap/connection_pool.js:...)
at Server.command (/node_modules/mongodb/lib/sdam/server.js:...)
これはネットワークの瞬断やレプリカセットのフェイルオーバーではありません。プールは意図的にクローズされました — 通常はアプリのシャットダウン中に — 処理中のオペレーションがまだ実行されている状態で。典型的な競合状態(レースコンディション)です。
ステップ1:プールが早期にクローズされる箇所を特定する
まず、簡単なログでタイミングを確認します:
// ネイティブドライバー
client.on('connectionPoolClosed', () => {
console.log('[MongoDB] Connection pool closed at', new Date().toISOString());
});
// Mongoose
mongoose.connection.on('disconnected', () => {
console.log('[Mongoose] Disconnected at', new Date().toISOString());
});
シャットダウンをトリガーし(SIGTERM、SIGINT、またはプロセスを停止する方法で)、タイムスタンプを比較します。プールが最後のDB操作の前にクローズされていれば、競合状態が発生しています。ほとんどのアプリでは、その差は200ms未満 — 遅い findOne() が滑り込むには十分な時間です。
ステップ2:シャットダウンハンドラーを確認する
十中八九、原因は次のようなコードです:
// BAD — プールを即座にクローズし、処理中のリクエストと競合する
process.on('SIGTERM', () => {
mongoose.disconnect(); // awaitなし — 投げっぱなし
server.close();
process.exit(0);
});
非同期パイプラインの内部にすでに入っているリクエスト — まだawaitされていないクエリ — は、クローズされたプールに当たって MongoPoolClosedError をスローします。負荷がかかっている状態では、ほぼすべてのデプロイで発生します。
ステップ3:シャットダウンの順序を修正する
まず新しい作業の受け入れを停止します。既存の作業を完了させます。MongoDBは最後にクローズします。
// GOOD — 順序立てたグレースフルシャットダウン
async function shutdown(signal) {
console.log(`Received ${signal}, shutting down...`);
// 1. 新しいHTTPリクエストの受け入れを停止
await new Promise((resolve) => server.close(resolve));
console.log('HTTP server closed');
// 2. すべてのHTTPハンドラーが完了 — 切断しても安全
await mongoose.disconnect();
console.log('MongoDB disconnected');
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
server.close() は新しい接続の受け入れを停止しますが、アクティブなリクエストの完了は許可します。そのコールバックが発火した後にのみ、MongoDBを切断します。処理中のリクエストがクローズされたプールに到達することはありません。
ステップ4:タイムアウトガードを追加する
ハングしたリクエスト1つがシャットダウンを永遠にブロックする可能性があります。10秒のハードリミットでそれを防ぎます:
async function shutdown(signal) {
const TIMEOUT_MS = 10_000; // 10秒後に強制終了
const forceExit = setTimeout(() => {
console.error('Shutdown timed out, forcing exit');
process.exit(1);
}, TIMEOUT_MS);
forceExit.unref(); // このタイマーのためにイベントループを生かし続けない
try {
await new Promise((resolve) => server.close(resolve));
await mongoose.disconnect();
console.log('Graceful shutdown complete');
process.exit(0);
} catch (err) {
console.error('Error during shutdown:', err);
process.exit(1);
}
}
10秒あれば大多数の遅いクエリに対応できます。レイテンシーに敏感なサービスでは5秒に短縮し、ジョブが本当に長時間実行される場合は30秒に延長してください。
ステップ5:バックグラウンドジョブを個別に処理する
cronジョブ、キューコンシューマー、および setInterval ループは server.close() からは見えません。HTTPサーバーが停止した後もMongoDBクエリを発行し続ける可能性があります。明示的に追跡しましょう:
const activeJobs = new Set();
function runJob(fn) {
const job = fn().finally(() => activeJobs.delete(job));
activeJobs.add(job);
return job;
}
async function shutdown(signal) {
// 新しいジョブのスケジューリングを停止(clearInterval、queue.close()など)
// 現在実行中のジョブを完了させる
if (activeJobs.size > 0) {
console.log(`Waiting for ${activeJobs.size} background job(s)...`);
await Promise.allSettled([...activeJobs]);
}
await new Promise((resolve) => server.close(resolve));
await mongoose.disconnect();
process.exit(0);
}
ジョブを runJob() でラップすることで、実行中のジョブ数をリアルタイムで把握できます。サイレントクラッシュの代わりに Waiting for 3 background job(s)... のようなログ行が表示されます。
ステップ6:ネイティブMongoDBドライバー
同じルールが適用されます。client.close() は最後に呼び出します:
const { MongoClient } = require('mongodb');
const client = new MongoClient(process.env.MONGODB_URI);
async function shutdown() {
await new Promise((resolve) => server.close(resolve));
await client.close();
console.log('MongoDB client closed');
process.exit(0);
}
force ブール値についての補足:client.close(false)(デフォルト)はプールをグレースフルに排出します。client.close(true) は即座にクローズします — これは本当に処理中のオペレーションに対してこのエラーをスローする可能性があります。デフォルトがほぼ常に望ましい選択です。
確認
アプリを再起動し、トラフィックを送信してから、リクエストの処理中にシャットダウンをトリガーします:
# ターミナル1 — リクエストを連続送信
while true; do curl -s http://localhost:3000/api/data > /dev/null; done
# ターミナル2 — 5秒後にシャットダウンをトリガー
sleep 5 && kill -SIGTERM $(pgrep -f 'node app.js')
ログを確認します。表示されるべき順序は:HTTPサーバーのクローズ → MongoDB切断 → 終了コード0です。MongoPoolClosedError は表示されないはずです。これが確認できれば、完了です。
教訓
- 順序は思っている以上に重要です。 MongoDBは常に最後にクローズするものです — HTTPサーバーとジョブキューが完了した後に。
disconnect()を投げっぱなしにしてはいけません。 必ずawaitしてください。そうしないと、プールが実際にいつ(または本当に)クローズされたかわかりません。- バックグラウンドジョブは必ずあなたを苦しめます。 MongoDBに触れる
setIntervalやキューコンシューマーは、プールをクローズする前に停止・完了させる必要があります。 - タイムアウトはデプロイを救います。 永遠にハングする可能性があるグレースフルシャットダウンは、タイムアウトするものよりも悪い状態です — 少なくともプロセスマネージャー(systemd、Docker、PM2)が再起動できます。

