Node.jsでメモリリーク箇所を特定
この記事は最終更新日から1年以上が経過しています。
芽萌丸プログラミング部@programming
投稿日 2020/3/16
更新日 2020/4/3 ✏

Node.jsでメモリリーク箇所を特定

memwatch-nextモジュールを使ってNode.jsアプリケーションのメモリリーク箇所を特定する方法のメモです。 この方法だとChrome DevToolsなどは不要で、シンプルにメモリリークを発見できます。

目次

メモリリークサンプルコード

例えば、次のようなメモリリークしているNode.jsアプリケーションがあるとします。

leak.js

let mem = [];

class LeakClass {
  constructor(str) {
    this.str = str;
  }
}

// 一定間隔で LeakClass インスタンスを配列へプッシュ:
setInterval(() => {
  mem.push(new LeakClass(Math.random().toString()));
  // mem = []; // NOTE: ここでメモリを解放すればメモリリークは解消されます。
}, 10);

// 2秒ごとにGC&メモリ使用状況を出力
setInterval(function generateHeapDumpAndStats() {
  // 1. 強制的にGCを行う
  try {
    global.gc();
  } catch (e) {
    console.log("次のコマンドで実行して下さい: 'node --expose-gc leak.js");
    process.exit();
  }
  // 2.メモリ使用状況を出力
  const heapUsed = process.memoryUsage().heapUsed;
  console.log(heapUsed + " バイト使用中")
}, 2000);

これを--expose-gcオプション(ガベージコレクション実行の有効化)を付けて実行すると、memの中にLeakClassのインスタンスがどんどん追加され続けます。memへの参照は残り続けているため、定期的にガベージコレクション(GC)を走らせているにもかかわらずメモリ使用量が膨らんでいくことが分かります:

$ node --expose-gc leak.js

4536392 バイト使用中
4552640 バイト使用中
4572248 バイト使用中
4587880 バイト使用中
4633232 バイト使用中
...
## (メモリリークが発生しているため、メモリ使用量がどんどん増えていきます。。。)

メモリリーク箇所を特定

先ほどのコードのメモリリークを特定するには、コードを次のように改造します:

let mem = [];

class LeakClass {
  constructor(str) {
    this.str = str;
  }
}

// 一定間隔で LeakClass インスタンスを配列へプッシュ:
setInterval(() => {
  mem.push(new LeakClass(Math.random().toString()));
  // mem = []; // NOTE: ここでメモリを解放すればメモリリークは解消されます。
}, 10);

// メモリ使用状況調査を開始
startHeapDiff();

function startHeapDiff() {
  const memwatch = require('memwatch-next');
  // メモリ使用状況の最初のスナップショットを取得
  const hd = new memwatch.HeapDiff();
  // 2秒ごとにGC&メモリ使用状況を出力
  setInterval(function generateHeapDumpAndStats() {
    // 1. 強制的にGCを行う
    try {
      global.gc();
    } catch (e) {
      console.log("次のコマンドで実行して下さい: 'node --expose-gc leak.js");
      process.exit();
    }
    // 2.メモリ使用状況を出力
    const heapUsed = process.memoryUsage().heapUsed;
    console.log(heapUsed + " バイト使用中")
  }, 2000);

  // CTRL + C でメモリ使用状況の終了直前のスナップショットを取得しdiffる
  process.on('SIGINT', function() {
    const diff = hd.end();
    // diff情報をコンソール出力:
    console.log("memwatch diff:", JSON.stringify(diff, null, 2));
    // diff情報をファイルにダンプするのも良いかも:
    // const fs = require("fs");
    // fs.writeFileSync("./memdiffdump.json", JSON.stringify(diff, null, 2));
    process.exit();
  });
}

追加されたstartHeapDiff()に注目して下さい。 この関数内ではmemwatch-nextモジュールを使ってアプリケーションの起動直後と終了直前のメモリ使用状況のスナップショットを取り、メモリ使用状況の差分情報を取得しています。

このコードを実際に実行し、途中でCTRL+Cで強制終了してみましょう。すると、以下のようなログが出力されます:

## memwatch-nextモジュールのインストール
$ npm install --save-dev memwatch-next

## コードを実行
$ node --expose-gc leak.js

4536392 バイト使用中
4552640 バイト使用中
4572248 バイト使用中
4587880 バイト使用中
4633232 バイト使用中
...
## (メモリリークが発生しているため、メモリ使用量がどんどん増えていきます。。。)

## (CTRL + C で強制終了すると以下のログが出力)

memwatch diff: {
  "before": {
    "nodes": 33510,
    "size_bytes": 4259976,
    "size": "4.06 mb"
  },
  "after": {
    "nodes": 38702,
    "size_bytes": 4776400,
    "size": "4.56 mb"
  },
  "change": {
    "size_bytes": 516424,
    "size": "504.32 kb",
    "freed_nodes": 737,
    "allocated_nodes": 5929,
    "details": [
      ...
      ## ↓LeakClassという見覚えのあるクラス名が出現!
      ## メモリ割当数の増加量が +2595 の純増となっており、
      ## このクラスがメモリリークの原因となっていることが分かりました。
      {
        "what": "LeakClass",
        "size_bytes": 83040,
        "size": "81.09 kb",
        "+": 2595,
        "-": 0
      },
      ...

上記ログを見ると**LeakClassの割当が純増**していることが分かりました! つまりこれがメモリリークの原因となっている可能性があるということになります。

ここまで分かれば、後はLeakClassを保存している箇所を調べ上げて無駄な参照が残っていないか確認し、メモリリークの解消に繋げることができます。

TIPS

Node.js v12.16.1(最新LTS)に対応したmemwatch

2020/04/03時点でmemwatch-nextは Node.js v12.16.1 (執筆時点の最新LTS) に未対応です。しかし、幸いにもfork版の@ardatan/node-memwatchが利用できました。

インストール:

$ npm install --save-dev @ardatan/node-memwatch

使い方:

const memwatch = require('@ardatan/node-memwatch');
// node-memwatchとIFは同じ。

関連

Node.jsアプリケーションのパフォーマンス改善のヒントにNode.js の Event Loop ブロッキングを検出の記事も合わせてどうぞ。

以上です。


芽萌丸プログラミング部
芽萌丸プログラミング部@programming
プログラミング関連アカウント。Web標準技術を中心に書いていきます。フロントエンドからサーバサイドまで JavaScript だけで済ませたい人たちの集いです。記事は主に @TanakaSoftwareLab が担当。