技術ログ

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

公開: 2026-05-19 · 著者: GRAMSHIFT

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

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

深夜のメモリリーク調査でわかったことを、要点3つで先に。

  1. 個人開発 SaaS の本番運用で最終的に効くデバッグ手法は heap snapshot 一択。本番障害の最中に手順を覚えようとすると間に合わないため、平時の練習が運用品質を決める
  2. pm2 の max_memory_restart は延命策であって根本対処ではない。発火が始まったら 24 時間以内に調査開始ルール化が必要、放置すると数ヶ月単位で症状を麻痺剤で隠す状態になる
  3. 監視ツールは UptimeRobot (無料・1分間隔死活監視) + 自前 Discord Webhook (詳細メトリクス) のハイブリッドが個人開発に最適、Datadog や New Relic は月額費用が個人開発の規模感に合わない

以下、症状検知から犯人特定 (sqlite 接続の解放漏れ)、修正、再発防止運用までの時系列、Node.js メモリリークの 5 つの典型パターン、監視ツール選定の判断軸を含めて詳細に残します。

あなたが本番運用しているNode.jsサーバーで「メモリ使用量がじりじり増えて、ある日OOM Killerに殺される」現象を経験したことがあるでしょうか。GramShift VPSでもこれが3日連続で起き、pm2の自動再起動で延命しながら原因究明を進めた数日間の記録を共有します。最終的な犯人はsqlite接続の解放漏れという、コードレビューであれば1秒で見つかるシンプルなバグでした。この記事は同じ症状で困っている個人開発者向けに、症状の把握から heap snapshot を使った犯人特定、修正、そして「次に同じことを起こさないための運用ルール」までを実体験ベースで残します。

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

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

pm2 monit でリアルタイムのメモリ使用量を見ると、起動時 80MB だったプロセスが、12時間後には 400MB、24時間後には 700MB に到達して再起動、というパターンでした。明らかなメモリリークです。グラフが滑らかに右上がりになっていく形は、典型的な「線形リーク」の見た目で、トラフィックに比例して増えていく性質のものだとすぐに分かりました。

本番障害の最初の判断として大事なのは「これは pm2 が再起動でカバーしているから今すぐサービス停止には至らない」というフレーミングです。とはいえ再起動の度に短時間ですがリクエストが切れていて、長時間運用するクライアント (GramShift Desktop アプリ) には heartbeat の取りこぼしが発生していました。短期的には pm2 で延命しつつ、根本対処を進める方針で動きました。

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

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

該当ジョブを2日間停止して観察すると、メモリ増加ペースが約30%遅くなりました。完全に止まらないので「主犯ではないが、加速要因にはなっている」と判断。これは捜査が前進したというよりは「容疑者リストが1つ減っただけ」の状態で、根本原因はまだ別にあるという結論です。

次に、API リクエスト数とメモリ使用量の相関を調べました。アクセスログをタイムスタンプ別に集計し、メモリ使用量グラフと重ねた結果、リクエストが多い時間帯にメモリ増加が急峻になっているのが見えました。これでようやく「API ハンドラのどこか」と当たりがつきました。原因は深夜のジョブそのものではなく、深夜2時にトラフィック累積がちょうど700MB に達していただけ、という構図です。

この段階で重要だったのは、ログとメモリグラフを「同じ時間軸で重ねる」ことでした。pm2 のメモリログ、アプリの request ログ、Discord webhook の通知タイムスタンプを CSV に落として Excel で同じグラフに乗せて見ると、相関が一目で分かります。本番障害の調査では「カンで動く」と無駄な実験が増えるので、データに語らせる癖が時間を節約します。

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

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

# プロセスIDを取得
pm2 list

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

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

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

Comparison ビューで重要なのは「New」「Deleted」「Delta」のカラムです。Delta が大きく、しかも正の値になっているオブジェクトが、リークの最有力候補です。今回のケースでは Database オブジェクトの Delta が +287 という分かりやすい数字で表示され、それと連動する形で BufferFunction も増加していました。GC で回収されるはずの寿命の短いオブジェクトが、誰かに参照されたまま残っている、というのが Comparison ビューの典型的なシグナルです。

heap snapshot の取り方は本当に簡単ですが、平時にやったことがないと本番障害の最中に手順を調べることになって時間を浪費します。私は今、四半期ごとに非本番ノードで heap snapshot の練習を1回入れていて、手順を体に染み込ませるようにしています。

犯人 — 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 を超えなくなりました。max_memory_restart の発火も完全に止まり、Discord アラートが鳴らない夜が3日続いた時点で「直った」と確信しました。

同時に試して効果がなかった対策

原因特定までの間、私は並行していくつか「効きそうに見える対策」も試していました。これらは結果的に効果がなかったので、同じトラップにハマる人が無駄足を踏まないよう書いておきます。

  • Node.js のメジャーバージョンを最新 LTS に上げる: GC アルゴリズムが改善されているかも、と期待しましたが、リーク量に変化なし。アプリ側のバグはランタイム更新では消えません。
  • --max-old-space-size を大きくする: メモリ上限を 1GB → 2GB に変更しただけ。リーク自体は止まらないので OOM までの時間が延びるだけで、根本対処にはなりません。
  • 不要な npm パッケージを削減: 起動時のメモリは確かに少し減りましたが、線形リークの傾きは同じ。「常駐物の重量」と「リーク」は別物だと再確認しました。
  • process.memoryUsage() を定期ログ化: これは効果ゼロではなく、heap snapshot を取るタイミングを決める判断材料になりました。ただし「ログを取る」だけでは直りません。

振り返ると、これらは全て「メモリの周辺を触っているだけで、増えているオブジェクト自体を特定する作業」をしていない対策です。原因究明では heap snapshot のような「何が増えているか」を直接見る武器に早めにたどり着くのが結局は最短です。

max_memory_restart の正しい使い方

pm2 の max_memory_restart はメモリ管理の万能ツールではなく、運用者が状況を整理するためのバッファです。私は今、この機能を次の3つの線引きで使い分けています。

1つめは「予期しない急増の保険」として。アプリのバグだけでなく、外部 API の応答遅延でリクエストが詰まった場合などにもメモリは膨らみます。突発的なスパイクに対しては再起動で被害を最小化するのが妥当な防御です。

2つめは「リーク発見のセンサー」として。今回のように max_memory_restart が発火し始めたら、それは「アプリにリークがある」というシグナルそのものです。発火を観測したら根本原因の調査を最優先するルールにしました。発火しているのに調査を後回しにする運用は、症状を麻痺剤で隠しているのと同じです。

3つめは「キャパシティプランニングの上限」として。1プロセスあたりのメモリ上限を明示することで、サーバ全体のメモリ予算を逆算しやすくなります。VPS のメモリ 2GB に対して各プロセス 700MB なら同時 2プロセスまで、といった設計判断に直結します。

逆にやってはいけないのは、リーク調査を諦めて max_memory_restart 値を上げ続けることです。これは数ヶ月単位で見ると、リーク量に応じて再起動間隔が変わるだけで、根本問題はそのまま残ります。私は max_memory_restart の発火頻度を SRE 用語で言う SLI のひとつとして扱い、月に1回以上発火したら必ず原因究明に時間を取る運用にしています。

個人開発SaaSで遭遇しやすい他のメモリリークパターン

今回は sqlite 接続の解放漏れでしたが、Node.js のメモリリークには他にも典型パターンがあります。GramShift 開発の過程で他にも何度か遭遇したので、参考までに3つ挙げます。

パターンA: モジュールスコープのキャッシュ Map が無制限に増える。 一度キャッシュした結果を Map で持ち続けるコードは、エビクションを書き忘れると永遠に増え続けます。LRU キャッシュライブラリを使うか、size cap を明示するのが定石です。

パターンB: イベントリスナーの解除漏れ。 長寿命の EventEmitter に対して、リクエストごとに .on() を追加して .off() しないとリスナー配列が膨らみます。Node.js は10個を超えると警告を出してくれるので、警告ログを見落とさないこと。

パターンC: 大きな Buffer をクロージャに閉じ込める。 ファイルアップロード処理などで Buffer を変数に受けたまま、その変数を参照する setTimeout や Promise を残してしまうケースです。Chrome DevTools で Buffer の Delta を見ていれば気づけます。

これらは形は違いますが、本質的には全部「ある時点で取得したオブジェクトが、いつまでも誰かに参照されている」という同じ問題です。heap snapshot の Comparison ビューでは、増えているオブジェクトの種類が違うだけで、検出手順は今回の手順そのままで通用します。

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

このトラブルから得た教訓は3つあります。

第一に、外部リソース (DB接続、ファイルハンドル、ネットワーク接続) は必ず try-finally で解放する習慣をコードレビューで強制すること。これは今や私のコードレビューチェックリストの一行目になっています。1人開発でレビュアーが自分しかいないからこそ、自動化されたルールに頼るしかありません。

第二に、max_memory_restart は単なる延命策であり、根本対処を後回しにする理由にしないこと。再起動が走り始めた瞬間が調査開始のタイミングで、それを過ぎると後手に回ります。

第三に、heap snapshot は本番障害デバッグの最後の武器であり、使い方を平時から練習しておくこと。本番障害の最中に手順を調べる時間は、SaaS の信頼性を直接削ります。

個人開発SaaSの本番運用は、こうした「いつか必ず来るトラブル」との戦いです。早期発見と原因究明のスキルが、安定運用の最大の武器になります。発生を完全に防ぐことは不可能なので、起きたときの初動を速くする訓練にこそ投資する価値があります。

他のVPS運用記録は技術ログに、サポート対応の現実はビジネスカテゴリに追記しています。同じ「夜中に起きるトラブル」シリーズで、Netlify の無料枠が突然枯渇した夜の話も こちら にまとめています。

pm2 + heap snapshot ワークフローの完全版

本記事の中で heap snapshot の基本コマンドは記しましたが、「実戦投入できる完全版ワークフロー」を整理します。私が本番障害時に使っている手順です。

  1. 異常検知: pm2 の max_memory_restart 発火 or Discord Webhook の継続増加アラートで気づく
  2. 影響範囲確認: pm2 monit でリアルタイム CPU + Memory を 30 秒観察、即時的な障害かじわじわ系か判断
  3. 第 1 回 snapshot 取得: pm2 list で PID 確認 → kill -USR2 [PID] 実行、~/.pm2/logs/Heap.<timestamp>.heapsnapshot が生成される
  4. 待機 (3-6 時間): リーク量が分かりやすく増える時間を確保、その間に運用は pm2 自動再起動で延命
  5. 第 2 回 snapshot 取得: 同手順で 2 回目を取得、ファイル名のタイムスタンプで区別
  6. ローカルへ転送: scp root@vps:~/.pm2/logs/Heap.*.heapsnapshot ./ でローカルにダウンロード
  7. Chrome DevTools で比較: DevTools の Memory タブ → Load → 2 ファイル読み込み → Comparison ビューで差分確認
  8. Delta が大きいオブジェクトを特定: Delta カラムでソート、上位 5 件のオブジェクト種類を確認 (Database / Buffer / Function 等)
  9. Retainers を辿る: 該当オブジェクトを選択 → Retainers タブで「誰が参照を保持しているか」を確認、コード上の犯人を特定
  10. 修正 + デプロイ: try-finally パターン or LRU キャッシュ導入等で解放漏れを修正、本番反映
  11. 監視継続: 24-48 時間メモリ使用量を観察、線形増加が止まったことを確認して完了判定

この 11 ステップを手順書化したのは「障害の最中に手順を考える時間が惜しい」からです。実戦では 1-3 ステップが特に重要で、ここで判断を誤ると後の作業が空回りします。手順書を持っているだけで初動の落ち着きが違います。

監視ツールの選定 — UptimeRobot / Datadog / 自前 Discord

本番運用にあたって監視ツールの選定は重要な判断です。個人開発 SaaS の予算感で現実的な選択肢は以下です。

ツール月コスト監視粒度個人開発適性主な用途
UptimeRobot Free0 円5 分間隔死活★★★★★HTTP 監視のみ、最初の保険
UptimeRobot Pro$7/月1 分間隔死活★★★★SLA 重視、複数エンドポイント
Discord Webhook + 自前メトリクス0 円カスタム (秒-時間)★★★★★詳細メトリクス、リーク傾向検知
Datadog$15-31/host/月1 秒-1 分法人向け、個人開発には過剰
New Relic無料枠あり-$99/月1 分★★無料枠は限定的、有料は高額
Grafana + Prometheus 自前構築VPS 代のみ柔軟★★★構築工数大、上級者向け
PM2 Plus$11/月10 秒★★★pm2 直結、リアルタイム監視

私が GramShift VPS で実運用している組み合わせは「UptimeRobot Free + 自前 Discord Webhook」のハイブリッドです。UptimeRobot は 5 分間隔の HTTP 死活監視で「サービスが完全に落ちた」を即検知、Discord Webhook はアプリケーション内部のメトリクス (メモリ使用量 / API 応答時間 / DB クエリ統計) を 10 分間隔でカスタム集計して送信。両方とも月コスト 0 円で、必要な情報の 80% は揃います。

Datadog や New Relic は法人開発で SLA 99.99% を保証するレベルの監視が必要なケース向けで、個人開発で MRR 数万円規模だと費用対効果が見合いません。スケールアップしてから検討すれば十分です。

Node.js メモリリークの 5 大典型パターン (完全版)

本記事内で 3 パターン (キャッシュ Map / イベントリスナー / Buffer クロージャ) を紹介しましたが、私が GramShift 開発の過程で遭遇した全 5 パターンを記録します。最後の 2 つも個人開発でよく見るので、知っておくとデバッグ時の当たりが付けやすくなります。

  1. 外部リソースの解放漏れ (今回のケース): DB 接続 / ファイルハンドル / ネットワーク接続を close() 忘れ。try-finally パターンで対処
  2. モジュールスコープのキャッシュ Map 増殖: LRU ライブラリ (lru-cache) で size cap を明示
  3. イベントリスナーの解除漏れ: 長寿命 EventEmitter に .on() を追加して .off() 忘れ、Node.js が 10 個超で警告
  4. 大きな Buffer のクロージャ閉じ込め: ファイルアップロード処理で Buffer を setTimeout や Promise に残してしまうケース
  5. Promise チェーンの未解決: new Promise() を生成して resolve / reject を呼ばないと、Promise オブジェクトが永久に残る。catch も同様、unhandledRejection ハンドラで検知可能

5 つすべて「ある時点で取得したオブジェクトが、いつまでも誰かに参照されている」という共通構造です。heap snapshot の Comparison ビューでは Delta が大きいオブジェクトの種類が違うだけで、検出手順は本記事の手順そのままで通用します。

本番障害デバッグで意識している運用原則

個人開発 SaaS の本番障害デバッグで、私が意識している運用原則は 4 つあります。

  1. 「データに語らせる」: カンで動かず、ログとメトリクスを CSV に落として相関を確認する習慣
  2. 「最後の武器を平時に練習する」: heap snapshot / strace / tcpdump 等は障害時に覚えるのではなく、非本番環境で四半期 1 回練習する
  3. 「症状の麻痺剤と根本対処を区別する」: pm2 自動再起動 / Discord アラートは麻痺剤、heap snapshot で原因特定が根本対処
  4. 「手順書を書く、本番障害の最中にではなく」: 落ち着いている時に手順を書き、障害時はそれを読むだけで動ける状態に

この 4 原則を守るかどうかで、個人開発 SaaS の MTTR (Mean Time To Recovery) が大きく変わります。GramShift では本記事のリーク事件以降、平均障害復旧時間は 30 分以内に収まっています。1 人で運用する個人開発 SaaS でも、運用品質は法人レベルに近づけられます。

筆者: GRAMSHIFT — Instagram 自動運用 SaaS『GramShift』開発者、saas-diary.com / ai-pick.tech / lab.ai-pick.tech / host.ai-pick.tech 等 複数メディア運営。本記事内のコマンド + コードはすべて私が GramShift VPS で実運用しているものです。

参考記事

関連する個人開発の判断・実装事例を別記事に蓄積しています。

よくある質問

heap snapshot は本番サーバーで取って大丈夫ですか?

取得時に短時間 (数百ミリ秒) プロセスが一時的にブロックされる影響がありますが、本番サーバーで取得可能です。大量のメモリを使うアプリ (10GB+) では数秒間ブロックする可能性があるため、注意が必要です。私は GramShift VPS (メモリ 2GB) で実運用中に何度も取得していますが、ユーザー側の体感速度に影響は出ていません。それでも念のため、トラフィックが低い深夜帯の取得を推奨します。

個人開発 SaaS でいきなり Datadog を導入する必要はありますか?

不要です。Datadog は法人向け SLA 99.99% レベルの監視が必要なケース用で、月額 $15-31/host から始まる料金は個人開発 MRR 数万円規模で見合いません。UptimeRobot Free + 自前 Discord Webhook の組み合わせ (月コスト 0 円) で個人開発に必要な監視の 80% は揃います。Datadog や New Relic を検討するのは MRR が月 100 万円を超えてからで十分です。

pm2 の max_memory_restart は何 MB に設定するべきですか?

「アプリの正常稼働時メモリの 2-3 倍」が私の経験則です。GramShift の場合、起動直後 80MB、正常稼働時 100-200MB なので、max_memory_restart を 700MB (3.5 倍) に設定しています。これより低いと正常な負荷スパイクで誤発火、高すぎるとリーク検知が遅れます。値の決定は「アプリのメモリ挙動を 1 週間観察してから設定」が安全です。

sqlite 接続の解放漏れは ORM 使用時にも起きますか?

はい、起きます。Prisma / Drizzle / Sequelize 等の ORM でも、明示的に <code>$disconnect()</code> や <code>close()</code> を呼ばないと内部の接続プールが解放されないケースがあります。特に短命の DB インスタンス (リクエストごとに生成・破棄するパターン) で発生しやすく、ORM のドキュメントの「Connection Lifecycle」セクションを必ず確認することを推奨します。「ORM だから安全」ではなく、内部実装を理解した上で使うのが本筋です。