MongoPoolClosedError の修正: クローズされた接続プールからの接続チェックアウト

intermediate🍃 MongoDB2026-05-10| Node.js 16以降、MongoDB Node.js Driver 4.x/5.x、Mongoose 6.x/7.x、Linux/macOS/Windows上で動作

Error Message

MongoPoolClosedError: Attempted to check out a connection from closed connection pool
#mongodb#connection-pool#mongoose#nodejs#graceful-shutdown

何が起きているのか

コードのどこかで 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)が再起動できます。

Related Error Notes