2017/02/10更新

[NodeJS] 3点ヒープダンプ法を用いたメモリリークの調査を行う

このエントリーをはてなブックマークに追加      

こんにちは、@yoheiMuneです。
最近仕事でNodeJSのメモリリーク調査を行うことがあったので、その手順をブログに残しておきたいと思います。
画像


目次




メモリリークとは

メモリリークとは、プログラム稼働(サーバー稼働)していて、どんどんとメモリを使ってしまう不具合です。原因としては、ルート(NodeJSの場合はglobal変数、クライアントJSだとwindow変数)から参照できる変数が増えて残り続けることで、GC(ガベージコレクション)でメモリを解放できないことが原因です。詳しくはこちらのブログを参照ください。

メモリリークの調査では「増え続ける変数(=メモリ)は何かを特定する」ことが目的です。



事前準備

メモリリーク調査のために、いくつかの事前準備を行います。

NodeJSのv6.3以上を準備する

この記事では、NodeJSの--inspectオプションを利用するために、v.6.3.0以上のバージョンが必要です。nvmなどでインストールしてください(執筆時点だと7.5.0が最新のよう)。
$ nvm install 7.5.0
nvmの導入や使い方は「nvmをインストールや利用など」をご覧ください。


(任意)Pythonの準備

後ほどグラフ表示を行いますが、もしやりたい場合にはPythonをインストールします。またmatplotlibというグラフ描画ライブラリを使うので導入しておきます。
$ pip install matplotlib
Macの方でmatplotlibのインストールでエラーが発生する場合はこちらの記事が役立つかもしれません。


メモリリーク用のサンプルアプリ

以下におきましたので、適宜ご利用ください。
https://github.com/yoheiMune/node-playground/tree/master/008_memory_leak

// app_memory_leak.js のアプリ部分
// どこかにメモリリークがある.
const leak = [];
class MyClass {
    constructor(str) {
        this.str = str;
    }
}
function func1(val) {
    leak.push(val);
}
setInterval(() => {
    let randomData = Math.random().toString();
    func1(new MyClass(randomData));
}, 10);
この記事では、上記の内容を対象に調査を行います。



ガベージコレクションを行うコードの追加

メモリリークの調査では、ヒープダンプ(=メモリの使用状況)を複数回取得して、その差分を調査します(詳細は後続の「3点ヒープダンプ法」を参照)。ガベージコレクションで綺麗になったメモリからヒープダンプを取得したいので、GCを定期的に実行するコードを追加します。
function checkMemory() {

    // gc.
    try {
        global.gc();
    } catch (e) {
        console.log('You have to run this program as `node --expose-gc app_memory_leak.js`');
        process.exit();
    }

    // Check heap memory.
    const heapUsed = process.memoryUsage().heapUsed;
    console.log('heapSize: ', heapUsed);
}

// 1秒ごとに実施
setInterval(checkMemory, 1000);
これで綺麗さっぱりしたヒープダンプを取得できるようになり、調査が捗ります。



NodeJSの起動

メモリリークの調査のために、以下のオプションを付与してNodeJSを実行します。
オプション 内容
--expose-gc コード内でGCを利用可能にする
--inspect ChromeなどでNodeJSのインスペクトできるようにする
上記のオプションを付与して、アプリを実行します。
$ node --expose-gc --inspect app_memory_leak.js
そうすると、以下のような出力が行われます。
Debugger listening on port 9229.
Warning: This is an experimental feature and could change at any time.
To start debugging, open the following URL in Chrome:
    chrome-devtools://devtools/bundled/inspector.html?experiments=true&v8only=true&ws=127.0.0.1:9229/c9bfcc23-fc0e-47bf-af5a-37158bede690
heapSize:  3045952
heapSize:  3595736
heapSize:  3617984
heapSize:  3395800
heapSize:  3333936
heapSize:  3326776
heapSize:  3335752
heapSize:  3342784
heapSize:  3350088
インスペクトのURL(chrome-devtools://devtools/...)が表示され、その後に1秒おきに利用中メモリサイズが表示されます。起動しておくと、どんどんとメモリが増えていくことがわかります。



(オプション)Pythonでのグラフ表示

上記のプログラムを実行すると、同ディレクトリにstats.jsonが表示されます。中身は1秒ごとのメモリ利用サイズです。それを以下のコードで読み込むとグラフを表示できます。
import matplotlib.pyplot as plt
import json

statsFile = open('stats.json', 'r')
heapSizes = json.load(statsFile)

print('Plotting %s' % ', '.join(map(str, heapSizes)))

plt.plot(heapSizes)
plt.ylabel('Heap Size')
plt.show()
グラフ表示のイメージは以下です。
画像


メモリリークの調査

さてここからが本題です。アプリを起動した時に出たChromeへのURL(以下例)を、Chromeで開きます。
chrome-devtools://devtools/bundled/inspector.html?experiments=true&v8only=true&ws=127.0.0.1:9229/c9bfcc23-fc0e-47bf-af5a-37158bede690
そうすると以下のような画面が表示されます。
画像

3点ヒープダンプ法

今回のメモリリーク調査では3点ヒープダンプ法を用います。ヒープダンプ(=メモリの使用状況)を時間差で3回取得します。そして1回目と2回目の間にメモリが生成されたもののうち、3回目にもまだ残っている(GCされていない)ものを特定します(それがメモリリークの候補です)。
今回のプログラム(app_memory_leak.js)では、1秒ごとにGCを行なっているので、3回目のヒープダンプでも残っているメモリは、メモリリークの可能性が高いという算段です。


ヒープダンプの取得

ヒープダンプを3回取得します。Profilesタブに移動して、「Take Heap Snapshot」を選び、「Take Snapshot」で取得します(Chromeのバージョンによっては違うかも)。
画像 そうすると以下のように1つ目が取得できます。
画像 続いてまた「Profiles」から2回目以降も取得して合計3つを取得します。
画像

3点ヒープダンプ法を実施

3つ取得できたら、「Snapshot 3」を選択したのちに、下記のように「Object allocated between Snapshot 1 and Snapshot 2」を選びます。
画像 そうすると、「1回目と2回目の間にメモリが生成されたもののうち、3回目にもまだ残っている(GCされていない)もの」が表示されます。
画像 まず目に付くのはMyClassという自分で定義したクラスです。いっぱいメモリに残っていることがわかります。
画像 そのほかにもポチポチと開いていくと、ランダム文字列も目につきます。
画像 このように気になる変数や値を見つけて、コードを見返してみると、以下のようにメモリリークの箇所が見えてきます。
const leak = [];
class MyClass {           // ***** メモリリーク候補:MyClass *****
    constructor(str) {
        this.str = str;
    }
}
function func1(val) {
    leak.push(val);
}
setInterval(() => {
    let randomData = Math.random().toString();  // ***** メモリリーク候補:ランダムな文字列 *****
    func1(new MyClass(randomData));
}, 10);
また、インスペクタのMyClassのところを展開してみると、MyClassがランダム文字列を持っていることがわかったり、MyClassはleakというグローバル変数から参照されていることもわかります。
画像 それらの情報から、今回はleak = []でメモリリークしていることがわかります。
const leak = [];
class MyClass {
    constructor(str) {
        this.str = str;
    }
}
function func1(val) {
    // leak.push(val);   // ***** コメントアウトした ******
}
setInterval(() => {
    let randomData = Math.random().toString();
    func1(new MyClass(randomData));
}, 10);

# メモリ使用量が一定になった
$ node --expose-gc app_memory_leak.js 
heapSize:  3267056
heapSize:  3267208
heapSize:  3267056
heapSize:  3267208
heapSize:  3267056
heapSize:  3267208
heapSize:  3267056
heapSize:  3267208
heapSize:  3267056
heapSize:  3267208
heapSize:  3267248



参考資料

メモリリーク調査については、以下記事を参照しました。ありがとうございます。
http://postd.cc/simple-guide-to-finding-a-javascript-memory-leak-in-node-js



最後に

3点ヒープダンプ法はいいですね。実戦で使ったのは初めてでしたが無事に出来て良かったです。上記の参考記事には、非常に助けられました。上記の記事に加えてもう少し書きたいことがあり、今回はブログを書いた次第でした。

最後になりますが本ブログでは、Node.js・Python・Linux・フロントエンド・インフラ・Go言語・開発関連・Swift・Java・機械学習など雑多に情報発信をしていきます。自分の第2の脳にすべく、情報をブログに貯めています。気になった方は、本ブログのRSSTwitterをフォローして頂けると幸いです ^ ^。

最後までご覧頂きましてありがとうございました!





こんな記事もいかがですか?

RSS画像

もしご興味をお持ち頂けましたら、ぜひRSSへの登録をお願い致します。