Taste of Tech Topics

Acroquest Technology株式会社のエンジニアが書く技術ブログ

メモリリークを検出するmemlabをAngularで試してみた

アクロクエスアドベントカレンダー 12月15日 の記事です。

こんにちは。 最近の趣味がAtCoderのmaron8676です。パズルを解くみたいに楽しめて、勉強にもなるので毎週コンテストに参加しています。

さて、2022/09にMeta社からmemlabというメモリリーク検出のためのフレームワークがリリースされました。

facebook.github.io

気になって調べたところチュートリアルを試していた方はいましたが、他にもスナップショットファイルの解析などmemlabが提供している機能があるようだったため、この機会に試してみた結果を書いていきます。

memlabとメモリリーク

memlabJavaScriptのヒープ解析とメモリリーク検出を行うためのフレームワークです。

JavaScriptにはガベージコレクションの機能があり、基本的に使い終わったメモリ領域は自動的に解放されていきます。しかし、プログラムの書き方によってはメモリが自動的に解放されず、メモリ使用量が増えていってしまう「メモリリーク」と呼ばれる問題が発生します。メモリリークの問題があるサイトは、操作しているうちにだんだん動作が重くなってしまい、場合によってはダウンしてしまうこともあります。

このように避けたい問題であるメモリリークですが、memlabのドキュメントに書かれているように、原因を探すのは大変な作業です。そのため、フレームワークを活用して原因調査を効率化しつつ、メモリリークの検出も自動化できるとよいですね。

memlabの機能

memlabのGitHubページでは以下の機能が書かれています。

  1. メモリリークの検知
  2. オブジェクト指向のヒープトラバースAPI
  3. メモリ最適化のためのツールボックス
  4. メモリ内容のアサーション

Angularのメモリリークを検出してみる

検出対象のWebアプリ

検証のためのアプリとして、Angular公式のチュートリアルアプリをベースに、setIntervalを消し忘れる問題を追加したWebアプリを作成しました。 問題を追加したのはheroes.component.tsで、以下の通りです。

export class HeroesComponent implements OnInit, OnDestroy {
  heroes: Hero[] = [];
  leakObjects: number[] = [];

  constructor(private heroService: HeroService) { }

  ngOnInit(): void {
    window.setInterval(() => {
      for (let i = 0; i < 1000; i++) {
        this.leakObjects.push(Math.random());
      }
    }, 100);
    this.getHeroes();
  }
...

このコードには「setIntervalで設定した定期実行スケジュールの解除を忘れているため、SPA内の別ページに切り替えてもfunctionオブジェクトが残ってしまう」という問題があります。

memlabを使った検出

memlabを使ってメモリリークを検出してみましょう。 まずは、memlabがメモリリークを検出する方式について説明します。memlabは以下の流れでメモリリークを検出します。

  1. ヘッドレスブラウザのChromiumを使ってテスト対象のページを開く ...①
  2. 新たにメモリを確保するような画面操作を行う ...②
  3. ページを開いた初期状態に戻るための画面操作を行う ...③
  4. それぞれのタイミングで取得したメモリダンプファイルを参照して、②のタイミングに増えたが、③のタイミングで減っていないようなメモリ領域を検出し、所定の条件(後述)を満たすかチェックする。

memlabがメモリリークと判断する条件は、以下の通りです。

  1. オブジェクトは action 関数でトリガーされたインタラクションによって割り当てられている
  2. オブジェクトは back 関数実行後も開放されていない
  3. オブジェクトは切り離された DOM 要素またはアンマウントされた React Fiber ノードである

次に実際に書いたテストコードを紹介します。今回は以下のようなテストコードtests/test-memory.jsを作成しました。

/**
 * シナリオとして最初に開くURLを書く
 */
function url() {
    return 'http://localhost:4200/dashboard';
}

/**
 * メモリリークを起こすきっかけとなる処理を書く
 *
 * @param page - Puppeteer's page object:
 * https://pptr.dev/api/puppeteer.page/
 */
async function action(page) {
    await page.click('a[href="/heroes"]'); // 問題のコンポーネントを表示
    await page.click('button.clear'); // 画面表示しているメッセージを削除
}

/**
 * action関数の結果をリセットするための処理を書く
 *
 * @param page - Puppeteer's page object:
 * https://pptr.dev/api/puppeteer.page/
 */
async function back(page) {
    await page.click('a[href="/dashboard"]'); // 初期状態のコンポーネントを表示
    await page.click('button.clear'); // 画面表示しているメッセージを削除
}

/**
 * リークしたオブジェクトかどうか判定する条件を書く
 *
 * @param node 判定対象オブジェクト
 * @param _snapshot スナップショット
 * @param _leakedNodeIds 判定対象オブジェクトのID
 */
function leakFilter(node, _snapshot, _leakedNodeIds) {
    return node.retainedSize > 1024 * 1024;
}

module.exports = { action, back, leakFilter, url };

このテストコードは、url関数、action関数、back関数、leakFilter関数で構成されています。それぞれの役割は以下の通りです。 url, action, back, leakFilterがそれぞれ、メモリリークを検出する方式の1, 2, 3, 4に対応しています。 今回見つけたい「解放されないfunctionオブジェクト」はmemlabが設定している初期条件を満たさないため、1MiB以上のオブジェクトを追加条件としました。

関数名 役割
url (必須)テスト対象のURL文字列を指定する
action (必須)新たにメモリを確保するような画面操作について記載する
back (必須)ページを開いた初期状態に戻るための画面操作について記載する
leakFilter (任意)リークと判断する追加条件を記載する

コンソールで npx memlab run --scenario .\tests\test-memory.js --work-dir memlab_resultsと実行すると、テスト結果が表示されます。 workディレクトリとして指定したmemlab_resultsにはスナップショットなどの結果が保存されます。 以下の図を見ると、メモリリークを検出することができました。

メモリリークを検出したテスト結果

memlabを使った原因調査

memlabを使って原因調査をしていきます。 テスト結果からは、3MBのクロージャが問題になっているということまでしか分かりませんが、 memlabのメモリ解析ツールを使うことでもう少し詳しい情報を得ることができます。 今回は以下のようなコード(analyze.js)を作成しました。

const memlab = require('memlab');

(async function () {
    const analysis = new memlab.ObjectSizeAnalysis();
    const result = await analysis.analyzeSnapshotFromFile("tests/s3.heapsnapshot");
})()

memlab workディレクトリの構成にあるs3.heapsnapshotファイルをtestsにコピーしてから実行しています*1。 初期状態に戻すback関数実行後である、s3.heapsnapshotの解析結果は以下のようになりました。

メモリ解析結果

問題のクロージャ内で参照している、leakObjectsという名前のArray変数が3MBで検出されています。 今回使ったObjectSizeAnalysisはスナップショットの中で大きいサイズのオブジェクトを表示してくれるため、問題の特定につなげることができました。 他にもドキュメントを見ると、いろいろな解析パターンがあるため試してみるとよいと思います。 例えばGlobalVariableAnalysisは、意図せずグローバル変数となってしまった問題を見つけるのに役立ちそうです。

修正後の確認

原因が分かったため、以下のようにHeroesComponentのデストラクタでclearIntervalを実行するようにして、テストを再実行してみましょう。

export class HeroesComponent implements OnInit, OnDestroy {
  heroes: Hero[] = [];
  leakObjects: number[] = [];
  private timer: number | undefined;

  constructor(private heroService: HeroService) { }

  ngOnInit(): void {
    this.timer = window.setInterval(() => {
      for (let i = 0; i < 1000; i++) {
        this.leakObjects.push(Math.random());
      }
    }, 100);
    this.getHeroes();
  }

  ngOnDestroy(): void {
    clearInterval(this.timer);
  }
...

以下のように、メモリリークが解決していることを確認できます*2

問題修正後のテスト結果

まとめ

memlabを使って、Angularアプリのコンポーネント内に含まれたメモリリークを検出しました。 また、memlabのスナップショット解析ツールを使って、メモリリークの原因を特定する作業の一例を紹介しました。変数名レベルまで出してくれるため、便利そうです。 メモリリークはいったん起きてしまうと、解析が難しく、時間もかかる問題であるため、フレームワークで調査を助けてもらえるのはありがたいですね。

参考サイト

  1. memlab
    https://facebook.github.io/memlab/
  2. memlab Docs
    https://facebook.github.io/memlab/docs/intro
  3. memlab GitHub
    https://github.com/facebook/memlab
  4. memlab を使って Web サイトのメモリリークを検出しよう
    https://zenn.dev/sa2knight/articles/analyze_javascript_heap_with_memlab
Acroquest Technologyでは、キャリア採用を行っています。
  • ディープラーニング等を使った自然言語/画像/音声/動画解析の研究開発
  • Elasticsearch等を使ったデータ収集/分析/可視化
  • マイクロサービス、DevOps、最新のOSSを利用する開発プロジェクト
  • 書籍・雑誌等の執筆や、社内外での技術の発信・共有によるエンジニアとしての成長
  少しでも上記に興味を持たれた方は、是非以下のページをご覧ください。 www.wantedly.com

*1:analyzeSnapshotFromFileにmemlab_results/cur/s3.heapsnapshotと指定できればよかったのですが、なぜか読み込みエラーになってしまうためファイルを移動しています。もう少し調査が必要そうです

*2:完全に初期状態と同じメモリ使用量ではありませんが、全て元に戻せるとも限らないため、大きく増えていなければ問題なしとしています。