技術ログ

VPSのメモリリークを発見した夜のデバッグ記録

公開: 2026-05-19 · 著者: Sasaki Ryuji

個人開発SaaSの本番運用で「いつか必ず来るメモリリーク」のデバッグ実例

VPSのメモリリークを発見した夜のデバッグ記録

あなたが本番運用しているNode.jsサーバーで「メモリ使用量がじりじり増えて、ある日OOM Killerに殺される」現象を経験したことがあるでしょうか。GramShift VPSでもこれが3日連続で起き、pm2の自動再起動で延命しながら原因究明を進めた数日間の記録を共有します。最終的に犯人はsqlite接続の解放漏れでした。同じ症状で困っている個人開発者の参考になればと思います。

症状 — メモリ使用率が毎日深夜2時頃に100%へ

異常に気づいたきっかけは、Discord アラートでした。pm2 の max_memory_restart を 700MB に設定していて、それを超えると自動的にプロセスを再起動する仕組みにしていたのですが、毎晩深夜2時頃にこのトリガーが発火していました。3日連続で同じ時間帯に再起動通知が来たので、これは偶然ではないと判断しました。

pm2 monit でリアルタイムのメモリ使用量を見ると、起動時 80MB だったプロセスが、12時間後には 400MB、24時間後には 700MB に到達して再起動、というパターンでした。明らかなメモリリークです。

調査ステップ1 — どのコードパスが原因か絞り込む

Node.js のメモリリークは、原因箇所を特定するのが最も難しい部分です。私はまず、深夜2時に何が動いているかをログから探りました。GramShift VPSでは、毎晩1時から3時の間にバックグラウンドジョブ (古いアクティビティログのクリーンアップ、統計集計) が動いていて、これが怪しい候補でした。

該当ジョブを2日間停止して観察すると、メモリ増加ペースが約30%遅くなりました。完全に止まらないので「主犯ではないが、加速要因にはなっている」と判断。次に、API リクエスト数とメモリ使用量の相関を調べると、リクエストが多い時間帯に急増していました。これでようやく「API ハンドラのどこか」と当たりがつきました。

調査ステップ2 — heap snapshot で犯人を特定

原因コードパスをさらに絞るために、Node.js の heap snapshot を取得しました。手順は以下の通りです。

# プロセスIDを取得
pm2 list

# heap snapshot をトリガー (SIGUSR2 シグナル)
kill -USR2 [PID]

# 数秒後に Heap..heapsnapshot が生成される
ls -lh ~/.pm2/logs/*.heapsnapshot

生成された .heapsnapshot ファイルを Chrome DevTools の Memory タブで読み込みます。比較のため、起動直後と6時間後の2つの snapshot を取り、両者を Comparison ビューで差分を見ました。結果、Database オブジェクトのインスタンスが起動直後の数個から6時間後には数百個に増えていることが分かりました。

犯人 — sqlite接続の解放漏れ

原因コードはあっけないほどシンプルなパターンでした。当時、ある API ハンドラで動的に better-sqlite3 の Database インスタンスを生成して、処理後に db.close() を呼ぶのを忘れていました。具体的にはこんなコードです。

// バグのあった実装
fastify.get('/api/stats/:userId', async (req, reply) => {
 const db = new Database(`./data/user_${req.params.userId}.db`);
 const stats = db.prepare('SELECT * FROM stats').all();
 return reply.send(stats); // db.close() を呼んでいない!
});

// 修正後
fastify.get('/api/stats/:userId', async (req, reply) => {
 const db = new Database(`./data/user_${req.params.userId}.db`);
 try {
 const stats = db.prepare('SELECT * FROM stats').all();
 return reply.send(stats);
 } finally {
 db.close();
 }
});

このエンドポイントへのリクエストが1日数百件あったため、Database インスタンスが累積してメモリリークしていました。try-finallyパターンで db.close() を確実に呼ぶように修正したところ、24時間運用してもメモリは 100MB を超えなくなりました。

得られた教訓と運用ルール

このトラブルから得た教訓は3つあります。第一に、外部リソース (DB接続、ファイルハンドル、ネットワーク接続) は必ず try-finally で解放する習慣をコードレビューで強制すること。第二に、pm2 の max_memory_restart は単なる延命策であり、根本対処を後回しにする理由にしないこと。第三に、heap snapshot は本番障害デバッグの最後の武器であり、使い方を平時から練習しておくこと。

個人開発SaaSの本番運用は、こうした「いつか必ず来るトラブル」との戦いです。早期発見と原因究明のスキルが、安定運用の最大の武器になります。

他のVPS運用記録は技術ログに、サポート対応の現実はビジネスカテゴリに追記しています。