Back

Node.jsメモリリークのデバッグ:実例で学ぶ完全トラブルシューティングガイド

最初は問題なさそうに見えます。Node.jsサーバーが何時間も、何日も安定して動作しているからです。でもある日、モニタリングダッシュボードを見ると、メモリ使用量のグラフが少しずつ...でも確実に上昇していることに気づきます。サーバーの再起動が週次のルーティンになり、日次のルーティンになり、気づいたら数時間ごとに自動再起動するcronジョブを書いています。「これはまずい」と嫌な予感がしますよね。

Node.jsのメモリリークは、本当にやっかいなバグの一つです。構文エラーのようにすぐエラーが出るわけではなく、静かに忍び寄ってきます。普段は大丈夫なのに、高負荷時や、デバッグツールが限られた本番環境で発現したりします。そして深夜3時にOOM(Out of Memory)でサーバーが落ちると、「このヒープダンプ、どこから見ればいいの…」と途方に暮れます。

この記事では、メモリリークを怖がっていた方が、体系的に問題を解決できるようになることを目指します。基本的な概念から始めて、実際のツールを使ったデバッグの流れ、Node.jsでよくあるリークパターンを解説します。どんなコードベースでも使えるデバッグの考え方を身につけていきましょう。


Node.jsにおけるメモリの理解:基礎から始めましょう

メモリリークを修正するには、まずNode.jsがどのようにメモリを管理しているかを理解する必要があります。Node.jsはV8 JavaScriptエンジンを使用しており、ガベージコレクションを通じて自動メモリ管理を実装しています。

V8メモリモデル

V8はメモリをいくつかの空間に分割します:

┌─────────────────────────────────────────────────────────────────┐
│                      V8 ヒープメモリ                             │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  ┌─────────────────────┐    ┌─────────────────────────────────┐ │
│  │    NEW SPACE        │    │         OLD SPACE               │ │
│  │   (Young世代)       │    │        (Old世代)                │ │
│  │                     │    │                                 │ │
│  │  • 短命なオブジェクト│───▶│  • 長寿命のオブジェクト         │ │
│  │  • 高速GC           │    │  • New Spaceから昇格           │ │
│  │    (Scavenge)       │    │  • 低速GC (Mark-Sweep)         │ │
│  └─────────────────────┘    └─────────────────────────────────┘ │
│                                                                 │
│  ┌─────────────────────┐    ┌─────────────────────────────────┐ │
│  │    LARGE OBJECT     │    │         CODE SPACE              │ │
│  │       SPACE         │    │   (コンパイル済みJS関数)        │ │
│  └─────────────────────┘    └─────────────────────────────────┘ │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

New Space(Young世代):新しいオブジェクトが割り当てられる場所です。小さく、高速な「Scavenger」アルゴリズムを使用して頻繁にガベージコレクションされます。

Old Space(Old世代):New Spaceで複数回のガベージコレクションを生き延びたオブジェクトがここに昇格されます。より遅いMark-Sweep-Compactアルゴリズムを使用して、より低頻度で収集されます。

メモリリークとは?

簡単に言うと、メモリリークは「もう使わないメモリを解放できない状態」のことです。JavaScriptはGC(ガベージコレクタ)が自動でメモリを整理してくれますが、使わないオブジェクトなのにどこかから参照されていると、GCはそれを整理できません。

GCは誰からも参照されていないオブジェクトだけを回収します。だから、うっかりオブジェクトへの参照をどこかに残してしまうと、実際には使わなくてもそのオブジェクトは永遠にメモリに居座り続けます。

リークを引き起こす一般的なパターン:

  1. データを蓄積し続けるグローバル変数
  2. 意図せず変数をキャプチャするクロージャ
  3. 追加されて削除されないイベントリスナー
  4. 削除ポリシーのないキャッシュ
  5. クリアされないタイマー(setInterval)
  6. 特定の状況での循環参照

メモリリークの見分け方:こんな症状が出たら要注意!

「メモリリークなのか、ただメモリを多く使っているのか」迷うこともありますよね。以下のパターンが見られたら、リークを疑ってみてください:

症状1:おかしくなったノコギリ波パターン

Node.jsの正常なメモリ使用量はノコギリ波のようになります:オブジェクトが割り当てられるとメモリが上昇し、ガベージコレクション中に急激に低下します。

正常なメモリパターン:
     ▲
     │    /\    /\    /\    /\
     │   /  \  /  \  /  \  /  \
     │  /    \/    \/    \/    \
     └────────────────────────────▶
                  時間

メモリリークは異なるパターンを示します:ノコギリ波のベースラインが上昇し続けます:

メモリリークパターン:
     ▲
     │                      /\
     │                 /\  /  \
     │            /\  /  \/
     │       /\  /  \/
     │  /\  /  \/
     │ /  \/
     └────────────────────────────▶
                  時間

症状2:増加するOld Space

--expose-gcフラグを使用して、定期的にガベージコレクションを強制しながらメモリをログに記録します:

// memory-monitor.js if (global.gc) { setInterval(() => { global.gc(); const used = process.memoryUsage(); console.log({ heapUsed: Math.round(used.heapUsed / 1024 / 1024) + 'MB', heapTotal: Math.round(used.heapTotal / 1024 / 1024) + 'MB', external: Math.round(used.external / 1024 / 1024) + 'MB', rss: Math.round(used.rss / 1024 / 1024) + 'MB', }); }, 10000); }

以下のように実行します:

node --expose-gc memory-monitor.js

GC直後でもheapUsedが増加し続ける場合、リークがあります。

症状3:応答時間の増加

ヒープが大きくなるにつれて、ガベージコレクションサイクルが長くなり、より頻繁になります。これは、レイテンシの増加とリクエスト処理中の定期的な「一時停止」として現れます。


デバッグツール:武器を揃えよう

Node.jsにはメモリ問題を解決するための優秀なツールが色々あります。一つずつ見ていきましょう。

1. Chrome DevTools(最強のツール)

Node.jsは強力なヒープ分析のためにChrome DevToolsと統合されています。

inspectモードでアプリを起動します:

node --inspect server.js # または最初の行でブレークするには: node --inspect-brk server.js

Chromeから接続:

  1. Chromeでchrome://inspectを開く
  2. Node.jsターゲットの下の「inspect」をクリック
  3. 「Memory」タブに移動

ヒープスナップショットの取得:

最も強力なテクニックは、時間の経過とともにヒープスナップショットを比較することです:

  1. ベースラインでスナップショットを取得(サーバー起動後)
  2. リークが疑われるアクションを実行(リクエストの送信など)
  3. ガベージコレクションを強制(ゴミ箱アイコンをクリック)
  4. 別のスナップショットを取得
  5. スナップショットを比較

比較ビューは、スナップショット間で割り当てられ、解放されなかったオブジェクトを表示します。

2. Node.js組み込み:process.memoryUsage()

迅速で簡単なメモリモニタリング:

function logMemory(label = '') { const used = process.memoryUsage(); console.log(`Memory ${label}:`, { rss: `${Math.round(used.rss / 1024 / 1024)} MB`, heapTotal: `${Math.round(used.heapTotal / 1024 / 1024)} MB`, heapUsed: `${Math.round(used.heapUsed / 1024 / 1024)} MB`, external: `${Math.round(used.external / 1024 / 1024)} MB`, }); } // 疑わしい操作を囲んで使用 logMemory('操作前'); await suspiciousOperation(); logMemory('操作後');

メトリクスの理解:

  • rss(Resident Set Size):プロセスに割り当てられた総メモリ
  • heapTotal:割り当てられたヒープの合計
  • heapUsed:実際に使用されているヒープメモリ
  • external:JavaScriptにバインドされたC++オブジェクトが使用するメモリ(Bufferなど)

3. コードによるヒープスナップショット

Chrome DevToolsなしでプログラム的にヒープスナップショットを生成できます:

const v8 = require('v8'); const fs = require('fs'); const path = require('path'); function takeHeapSnapshot() { const filename = path.join( __dirname, `heap-${Date.now()}.heapsnapshot` ); const snapshotStream = v8.writeHeapSnapshot(filename); console.log(`ヒープスナップショットを保存しました: ${filename}`); return filename; } // 本番デバッグ用にHTTPエンドポイントでトリガー app.get('/debug/heap-snapshot', (req, res) => { const file = takeHeapSnapshot(); res.json({ file }); });

これらの.heapsnapshotファイルをChrome DevToolsにロードして分析できます。

4. Clinic.js:本番環境に対応したツールスイート

Clinic.jsは、Node.jsのパフォーマンス分析のための素晴らしいツールスイートです:

npm install -g clinic # メモリリークを含むさまざまな問題を検出 clinic doctor -- node server.js # ヒーププロファイリングに特化 clinic heap -- node server.js # CPUプロファイリング用のフレームグラフ clinic flame -- node server.js

Clinic Doctorは負荷下でサーバーを実行し、潜在的な問題を強調したビジュアルレポートを生成します。

5. Memwatch-next:コード内リーク検出

自動化されたリーク検出のために:

const memwatch = require('memwatch-next'); memwatch.on('leak', (info) => { console.error('メモリリークを検出しました:', info); // info.growth: リークしたバイト数 // info.reason: リークと判断された理由 }); memwatch.on('stats', (stats) => { console.log('GC統計:', stats); // 詳細なガベージコレクション統計 });

7つの危険なリークパターン(とその直し方)

パターン1:無制限のキャッシュ

問題:

// ❌ メモリリーク: キャッシュが永遠に成長する const cache = {}; function getCachedData(key) { if (cache[key]) { return cache[key]; } const data = expensiveComputation(key); cache[key] = data; // 決して削除されない! return data; }

修正: 最大サイズのあるLRU(Least Recently Used)キャッシュを使用します:

// ✅ 修正済み: 削除ポリシーのある制限付きキャッシュ const LRU = require('lru-cache'); const cache = new LRU({ max: 500, // 最大500アイテム maxAge: 1000 * 60 * 5, // 5分後にアイテムが期限切れ updateAgeOnGet: true, // アクセス時に寿命をリセット }); function getCachedData(key) { const cached = cache.get(key); if (cached !== undefined) { return cached; } const data = expensiveComputation(key); cache.set(key, data); return data; }

パターン2:イベントリスナーの蓄積

問題:

// ❌ メモリリーク: ホットパスでリスナーを追加し、削除しない function handleConnection(socket) { const onData = (data) => { processData(data); }; // すべての接続が新しいリスナーを追加、決して削除されない eventEmitter.on('data', onData); }

修正: 不要になったらリスナーを常に削除します:

// ✅ 修正済み: 切断時にリスナーを削除 function handleConnection(socket) { const onData = (data) => { processData(data); }; eventEmitter.on('data', onData); socket.on('close', () => { eventEmitter.removeListener('data', onData); }); }

または、一度きりのリスナーにはonceを使用します:

eventEmitter.once('data', onData);

パターン3:コンテキストをキャプチャするクロージャ

問題:

// ❌ メモリリーク: クロージャが大きなデータをキャプチャ function processRequest(req, res) { const largePayload = req.body; // 10MBのデータ // このクロージャがlargePayloadをキャプチャ someAsyncOperation(() => { // req.body.idのみを使用しているが、ペイロード全体が保持される console.log('処理完了:', req.body.id); res.send('done'); }); }

修正: 必要なものだけを抽出します:

// ✅ 修正済み: 必要なデータのみをキャプチャ function processRequest(req, res) { const { id } = req.body; // 必要なものだけを抽出 someAsyncOperation(() => { console.log('処理完了:', id); // 'id'のみがキャプチャされる res.send('done'); }); }

パターン4:孤立したタイマー

問題:

// ❌ メモリリーク: setIntervalが決してクリアされない class DataPoller { constructor(url) { this.url = url; this.intervalId = setInterval(() => { this.poll(); }, 5000); } poll() { fetch(this.url).then(/* ... */); } // クリーンアップメソッドがない!インスタンスは決してガベージコレクションされない }

修正: 常にクリーンアップを提供します:

// ✅ 修正済み: 明示的なクリーンアップ class DataPoller { constructor(url) { this.url = url; this.intervalId = setInterval(() => { this.poll(); }, 5000); } poll() { fetch(this.url).then(/* ... */); } destroy() { clearInterval(this.intervalId); } } // 使用方法 const poller = new DataPoller('/api/data'); // 終了時: poller.destroy();

パターン5:成長するグローバル状態

問題:

// ❌ メモリリーク: グローバル配列が永遠に成長 const requestLog = []; app.use((req, res, next) => { requestLog.push({ timestamp: Date.now(), url: req.url, method: req.method, // ...潜在的に大きなヘッダーとボディ }); next(); });

修正: データ構造に制限を設けます:

// ✅ 修正済み: ローテーション付きの制限付きログ const MAX_LOG_SIZE = 1000; const requestLog = []; app.use((req, res, next) => { requestLog.push({ timestamp: Date.now(), url: req.url, method: req.method, }); // 古いエントリを削除 while (requestLog.length > MAX_LOG_SIZE) { requestLog.shift(); } next(); });

またはさらに良い方法として、リングバッファを使用するか、ログを外部ストレージにストリーミングします。

パターン6:忘れられたPromise

問題:

// ❌ 潜在的なリーク: エラー処理のないプロミスが蓄積 function fetchAll(urls) { urls.forEach(url => { fetch(url).then(response => { process(response); }); // .catch()がない - 処理されない拒否が参照を蓄積 }); }

修正: 常にプロミスの拒否を処理します:

// ✅ 修正済み: 適切なエラー処理 async function fetchAll(urls) { const promises = urls.map(async url => { try { const response = await fetch(url); await process(response); } catch (error) { console.error(`${url}の取得に失敗しました:`, error); // 適切に処理され、蓄積なし } }); await Promise.all(promises); }

パターン7:クロージャを介した循環参照

問題:

// ❌ 厄介なリーク: クロージャを通じた循環参照 function createConnection() { const connection = { data: new Array(1000000).fill('x'), // 大きなデータ }; connection.onClose = function() { // このクロージャが'connection'を参照し、循環参照を作成 console.log('接続がクローズされました', connection.id); cleanup(connection); }; return connection; }

モダンなV8はほとんどの循環参照をうまく処理しますが、クロージャはコレクションを妨げる可能性があります:

修正: 明示的にサイクルを断ち切ります:

// ✅ 修正済み: クリーンアップ時に循環参照を断ち切る function createConnection() { const connection = { data: new Array(1000000).fill('x'), }; connection.onClose = function() { console.log('接続がクローズされました', connection.id); cleanup(connection); connection.onClose = null; // サイクルを断ち切る connection.data = null; // 大きなデータを解放 }; return connection; }

実践的なデバッグセッション

現実的なメモリリークシナリオをステップバイステップでデバッグしてみましょう。

シナリオ

WebSocket接続を処理するExpressサーバーがあります。メモリが増加し続けています。

// server.js - リークのあるコード const express = require('express'); const WebSocket = require('ws'); const app = express(); const wss = new WebSocket.Server({ port: 8080 }); // 疑わしい: 接続用のグローバルストレージ const connections = new Map(); // 疑わしい: グローバルメッセージ履歴 const messageHistory = []; wss.on('connection', (ws, req) => { const userId = req.url.split('?userId=')[1]; connections.set(userId, ws); ws.on('message', (message) => { // すべてのメッセージを永遠に保存 messageHistory.push({ userId, message: message.toString(), timestamp: Date.now(), }); // 全員にブロードキャスト connections.forEach((client) => { if (client.readyState === WebSocket.OPEN) { client.send(message.toString()); } }); }); // バグ: 接続終了時のクリーンアップがない! }); app.listen(3000);

ステップ1:リークの確認

メモリモニタリングを追加します:

setInterval(() => { const used = process.memoryUsage(); console.log(`[Memory] Heap: ${Math.round(used.heapUsed / 1024 / 1024)}MB, ` + `Connections: ${connections.size}, ` + `Messages: ${messageHistory.length}`); }, 5000);

シミュレートされたトラフィックで実行した後、以下のように確認できます:

[Memory] Heap: 45MB, Connections: 100, Messages: 1000
[Memory] Heap: 67MB, Connections: 98, Messages: 2500
[Memory] Heap: 89MB, Connections: 102, Messages: 4200
[Memory] Heap: 112MB, Connections: 95, Messages: 6100

接続数は安定しているのにメモリが増加しています。ここではmessageHistory配列が明らかな原因ですが、接続も確認しましょう。

ステップ2:ヒープスナップショットの取得

Chrome DevToolsを使用:

  1. node --inspect server.jsに接続
  2. 起動後にスナップショットを取得
  3. 100接続をシミュレートし、切断
  4. 別のスナップショットを取得
  5. 比較

比較ビューで確認できる可能性があるもの:

  • 多くのArrayオブジェクト(メッセージ履歴)
  • Mapから削除されていないWebSocketオブジェクト

ステップ3:修正の適用

// server.js - 修正版 const express = require('express'); const WebSocket = require('ws'); const app = express(); const wss = new WebSocket.Server({ port: 8080 }); const connections = new Map(); // 修正済み: 制限付きメッセージ履歴 const MAX_HISTORY = 1000; const messageHistory = []; wss.on('connection', (ws, req) => { const userId = req.url.split('?userId=')[1]; connections.set(userId, ws); ws.on('message', (message) => { messageHistory.push({ userId, message: message.toString(), timestamp: Date.now(), }); // 古いメッセージを削除 while (messageHistory.length > MAX_HISTORY) { messageHistory.shift(); } connections.forEach((client) => { if (client.readyState === WebSocket.OPEN) { client.send(message.toString()); } }); }); // 修正済み: 終了時のクリーンアップ ws.on('close', () => { connections.delete(userId); }); ws.on('error', () => { connections.delete(userId); }); }); app.listen(3000);

ステップ4:修正の検証

同じ負荷テストを実行します。メモリは安定するはずです:

[Memory] Heap: 45MB, Connections: 100, Messages: 1000
[Memory] Heap: 52MB, Connections: 98, Messages: 1000
[Memory] Heap: 49MB, Connections: 102, Messages: 1000
[Memory] Heap: 51MB, Connections: 95, Messages: 1000

成功です!


本番環境戦略

1. メモリ制限とアラート

暴走クラッシュを防ぐために明示的なメモリ制限を設定します:

node --max-old-space-size=512 server.js # 512MB制限

アラートを実装します:

const MEMORY_THRESHOLD = 450 * 1024 * 1024; // 450MB setInterval(() => { const used = process.memoryUsage(); if (used.heapUsed > MEMORY_THRESHOLD) { alertOperations('メモリしきい値を超過しました', { heapUsed: used.heapUsed, threshold: MEMORY_THRESHOLD, }); } }, 60000);

2. グレースフルリスタート

メモリが高くなりすぎたら、グレースフルに再起動します:

const RESTART_THRESHOLD = 500 * 1024 * 1024; setInterval(() => { if (process.memoryUsage().heapUsed > RESTART_THRESHOLD) { console.log('メモリしきい値を超過しました、グレースフルシャットダウンを開始'); // 新しい接続の受け入れを停止 server.close(() => { // 既存のリクエストが完了するのを待つ setTimeout(() => { process.exit(0); // PM2やコンテナオーケストレータが再起動 }, 5000); }); } }, 60000);

3. ヘルスチェックエンドポイント

モニタリング用にメモリメトリクスを公開します:

app.get('/health', (req, res) => { const memory = process.memoryUsage(); const uptime = process.uptime(); res.json({ status: 'ok', uptime: Math.round(uptime), memory: { heapUsed: Math.round(memory.heapUsed / 1024 / 1024), heapTotal: Math.round(memory.heapTotal / 1024 / 1024), rss: Math.round(memory.rss / 1024 / 1024), }, // カスタムメトリクス connections: connections.size, cacheSize: cache.size, }); });

4. オンデマンドヒープダンプ

事後分析用に本番環境でのヒープダンプを有効にします:

app.post('/debug/heap', authenticateAdmin, (req, res) => { const v8 = require('v8'); const filename = v8.writeHeapSnapshot(); res.json({ filename }); });

予防:リークに強いコードの書き方

ルール1:常にイベントリスナーをクリーンアップする

// 簡単なクリーンアップのためにAbortControllerを使用 const controller = new AbortController(); element.addEventListener('click', handler, { signal: controller.signal }); // 後で: このコントローラで追加されたすべてのリスナーを削除 controller.abort();

ルール2:すべてのコレクションに制限を設ける

// 無制限の配列の代わりに const items = []; items.push(newItem); // 制限付きコレクションを使用 const MAX_ITEMS = 10000; if (items.length >= MAX_ITEMS) { items.shift(); } items.push(newItem);

ルール3:メタデータにはWeakMapとWeakSetを使用する

オブジェクトにメタデータを付加する場合:

// ❌ 強い参照を作成し、GCを妨げる const metadata = new Map(); metadata.set(someObject, { extra: 'data' }); // ✅ 弱い参照、GCを妨げない const metadata = new WeakMap(); metadata.set(someObject, { extra: 'data' }); // someObjectが他の場所で参照されなくなると、 // メタデータエントリは自動的に削除される

ルール4:Disposeパターンを実装する

class ResourceManager { #resources = []; #disposed = false; acquire(resource) { if (this.#disposed) { throw new Error('Managerはdisposedされています'); } this.#resources.push(resource); return resource; } dispose() { this.#disposed = true; for (const resource of this.#resources) { resource.close?.(); resource.destroy?.(); } this.#resources.length = 0; // 配列をクリア } }

まとめ:考え方を変えよう

メモリリークのデバッグには、コードに対する見方を変える必要があります。「これ、動く?」だけでなく、こんな質問をしてみてください:

  1. このデータはどこに行く? すべての割り当てには明確なライフサイクルがあるべきです。
  2. これはいつクリーンアップされる? すべてのaddには対応するremoveがあるべきです。
  3. 上限は? すべてのコレクションには最大サイズを設定すべきです。
  4. 誰がこれを参照している? 参照グラフを把握しましょう。

メモリリークは謎ではありません。単に必要以上に長生きしているオブジェクトなだけです。この記事で紹介したツールとパターンを使えば、どんなリークも体系的に追跡できます。再起動なしで何ヶ月も安定して動くアプリケーションを作れるようになります。

次にサーバーのメモリが上昇し始めたら、どこを見ればいいか、どう直せばいいか、もうわかりますよね。


楽しいデバッグライフを!ヒープは小さく、GCは暴れずに。

nodejsmemory-leakdebuggingperformancejavascriptbackenddevops

関連ツールを見る

Pockitの無料開発者ツールを試してみましょう