Memlabの誤検知・検知漏れのメカニズムと対策
Memlab の誤検知・検知漏れのメカニズムと対策
はじめに
Meta が公開しているメモリリーク検出ツール「Memlab」は、JavaScript ヒープスナップショットの解析が可能なツールです。しかし、実際にプロジェクトに導入すると「明らかなリークが出ない(検知漏れ)」「意図したキャッシュがリークとして報告される(誤検知)」が発生することがあります。
これらは Memlab 独自の抽出アルゴリズムが仕様通りに機能している結果です。本記事では、実践的な対策や CI での運用について考察します。
Memlab の概要と主要コマンド
Memlab は、Puppeteer によるブラウザの自動操作と、V8 エンジンのヒープスナップショット解析を組み合わせた E2E のメモリリーク検出ツールです。
用途に応じて以下の CLI コマンドが用意されており、シナリオ実行からオフライン解析まで幅広く対応しています。
memlab run
シナリオを実行し、スナップショットの取得からリーク検出までを一貫して自動で行う基本コマンド
memlab runは、内部で memlab snapshot → memlab find-leaks と同等の処理を実行している
memlab snapshot
リーク解析は行わず、指定したシナリオや URL に対してヒープスナップショットの採取・保存のみを実行する
memlab find-leaks
既にディスク上に保存されているヒープスナップショットファイル(SBP, STP, SFP)を読み込み、オフラインでリーク検出を行う
memlab diff-leaks
2 つのヒープスナップショット間の差分を計算し、どのオブジェクトが増加・減少しているかを直接比較して抽出する
memlab analyze <plugin>
取得したヒープスナップショットに対し、巨大な配列や不要なコレクションを検出する等の Memlab 内蔵プラグインを実行する
memlab trace --node-id <id>
ヒープ上の特定のノード ID を指定し、それがなぜメモリに残っているのかを示すリテーナートレース(保持経路)を出力する
memlab heap
単一のヒープスナップショットを対話的(インタラクティブ)に探索します。
memlab view-heap
単一のヒープスナップショットを対話的に表示・確認します。
memlab measure
ヒープサイズの変動(ノイズ)を把握するため、同じシナリオを複数回反復実行して測定します。
memlab warmup
テスト前にターゲットのウェブアプリ(サーバー)をウォームアップし、キャッシュ等の初期化を済ませます。
memlab list
登録されているすべてのテストシナリオを一覧表示する
memlab anonymize
取得したヒープスナップショットからユーザーデータやテキストを置換・削除し、第三者へ安全に共有できる状態(匿名化)にします。
memlab reset
Memlab が一時的に生成したワークスペース内のスナップショットファイルやキャッシュデータを削除し、状態をリセットする
コマンドラインから memlab run --scenario <シナリオファイル名> を実行する際、シナリオファイルは主に以下の 3 つの関数で構成されます
url: テストを開始する初期画面の URL を指定する
action: クリックなど、リークを発生させる可能性のある操作を定義する
back: モーダルを閉じる、または元の画面に戻るための操作を定義する
基本的なシナリオファイルの実装サンプルは以下の通りです。
module.exports = {
url: () => "example.com",
action: async (page) => {
await page.click("#open-modal");
},
back: async (page) => {
await page.click('[aria-label="Close"]');
},
};
誤検知と検知漏れのメカニズム
Memlab の「誤検知」や「検知漏れ」を正しく理解するためには、デフォルトの挙動として内部で実行される以下の 2 つを知ることが重要です。
時間軸の差分比較による絞り込み
Memlab は (STP \ SBP) ∩ SFP という計算式で差分を抽出する
SBP(初期状態)に存在せず、STP(アクション後)に新規作成され、SFP(戻った後)にも残存するオブジェクトのみを対象とする
SBP の時点で裏側のキャッシュ等に微量でも存在していた場合、今回のシナリオ起因の新規リークではないと正しく判定され除外される
ヒューリスティクスによるノイズの切り捨て
全オブジェクトを抽出後、Detached DOM などリークの可能性が高い特定の種類のオブジェクトのみを厳格なフィルターで残す
その他のプレーンな JavaScript オブジェクトや配列などは、ノイズとして意図的に報告から切り捨てられる
クラスタリングによる代表経路の抽出
検出された膨大なリークオブジェクトを、類似する保持経路ごとにクラスタとしてグループ化する
デフォルトでは各クラスタから代表的な 1 つの経路のみが出力されるため、細かいノイズクラスタが大量に生成されると、リークがログに埋もれて見逃されるリスクがある
CLI オプションと挙動の変更
CLI オプションを付与することで、クラスタリング(類似したリーク経路のグループ化)や抽出対象の挙動を変更できます。
--save-trace-as-unclassified-cluster
全てのトレースを 1 つのクラスタに集約するのではなく、検出された各トレースをそれぞれ個別の未分類クラスタとして出力する挙動となる
--ml-linkage-max-dist <0~1>
クラスタリングにおける結合の最大距離を設定するものである
距離の閾値を 0 に設定した場合、2 つのトレース間の距離が完全に 0(内容が完全に同一)でない限り、それらが同じクラスタに結合されることはない
--trace-all-objects
前述した「ヒューリスティクスによるノイズの切り捨て」を無効化する
差分計算 (STP \ SBP) ∩ SFP に合致したすべてのオブジェクトを対象としてトレースを出力する
leakFilter を活用した V8 内部ノードの除外
CLI オプションで検出候補を増やすと、本当に見つけたいアプリケーション起因のリークかどうかの確認に要します。
シナリオファイル内にアプリケーション固有の leakFilter コールバックを定義することで、特定のオブジェクトを意図的に除外することができます。
例えば、V8 エンジンが自動生成する内部ノードの徹底的な除外です。
hidden や object shape (syntheticノード) など、V8 エンジンが裏側で自動生成する仮想的な C++バインディングノード
これらは、アプリ側のオブジェクトがリークした結果として道連れでメモリに残存しているだけの「症状」に過ぎず、開発者が JS コードから直接破棄できるものではありません。
そのため、leakFilter を用いて明示的に return false とし、探索スコープから除外することができます。
具体的なシナリオファイルの実装例は以下の通りです
module.exports = {
url: () => "example.com",
action: async (page) => {
/* アクションの実行 */
},
back: async (page) => {
/* アクションのクリーンアップ・戻る処理 */
},
leakFilter: (node, _snapshot, _leakedNodeIds) => {
if (node.type === "hidden" || node.type === "synthetic") {
return false; // 経路探索とリーク報告の対象から完全に除外
}
// 任意: アプリ側で本当に深刻なリーク(例: 保持サイズ1MB以上)のみを追跡する
return node.retainedSize > 1024 * 1024;
},
};
継続的なメモリリーク監視と運用の検討
Memlab は詳細なヒープ解析が可能ですが、アプリ規模によっては長い時間を要することもあるため、すべてのコミットに対する毎回の CI 実行に適さない場合があります。
下記は一例であり、アプリケーションの非機能要件に応じて適切な運用を検討する必要があります。
Memlab は Nightly ビルドで運用
通常の PR 同期 CI から切り離し、影響範囲の大きい改修後や夜間バッチでの非同期実行に限定する
毎回の CI には Playwright と CDP を活用
PR 単位の日常的なテストでは、Playwright から CDP(Chrome DevTools Protocol)の Memory.getHeapStats や Runtime.queryObjects 、 Memory.getDOMCounters を叩いて監視する
ソークテストの導入
ページをリロードせずに長時間使用する SPA では、特定の UI 操作を数百回反復実行する「ソークテスト」を検討する
Nolan Lawson 氏が開発した SPA 向け検証ツール fuite でもソークテストの概念を採用している
まとめ
Memlab を利用した調査は、ツールのアルゴリズム特性を把握し、CLI オプションの仕様を正しく理解した上で、leakFilter を用いて計算量を絞り込むことが必要です。
変更ごとのメモリリーク監視には、Memlab は時間がかかり過剰となる場合があるため、アプリケーションの非機能要件に応じて適切な運用を検討する必要があります。また、日本語での運用情報も少なく、調査コストがかかると感じました。
一方で、Memlab はメモリリークが発生しているとわかっている場合の原因究明では有効なツールと思います。
参考資料
Command Line Interface | memlab - Meta Open Source: facebook.github.io
How memlab Works | memlab - Meta Open Source: facebook.github.io
fuite GitHub Repository: nolanlawson/fuite
memlab Issue #123: memlab#123
Mentions
Memlabの誤検知・検知漏れのメカニズムと対策 Memlab の誤検知・検知漏れのメカニズムと対策 はじめに Meta が公開しているメモリリーク検出ツール「Memlab」は、JavaScript ヒープスナップショットの解析が可能なツールです。しかし、実際にプロジェクトに導入すると「明らかなリークが出ない(検知漏れ)」「意図したキャッシュがリークとして報告される(誤検知)」が発生することがあります。 これらは Memlab 独自の抽出アルゴリズムが仕様通りに機能している結果です。本記事では、実践的な対策や CI での運用について考察します。 Memlab の概要と主要コマンド Memlab は、Puppeteer によるブラウザの自動操作と、V8 エンジンのヒープスナップショット解析を組み合わせた E2E のメモリリーク検出ツールです。 用途に応じて以下の CLI コマンドが用意されており、シナリオ実行からオフライン解析まで幅広く対応しています。 memlab run シナリオを実行し、スナップショットの取得からリーク検出までを一貫して自動で行う基本コマンド memlab runは、内部で memlab snapshot → memlab find-leaks と同等の処理を実行している memlab snapshot リーク解析は行わず、指定したシナリオや URL に対してヒープスナップショットの採取・保存のみを実行する memlab find-leaks 既にディスク上に保存されているヒープスナップショットファイル(SBP, STP, SFP)を読み込み、オフラインでリーク検出を行う memlab diff-leaks 2 つのヒープスナップショット間の差分を計算し、どのオブジェクトが増加・減少しているかを直接比較して抽出する memlab analyze <plugin> 取得したヒープスナップショットに対し、巨大な配列や不要なコレクションを検出する等の Memlab 内蔵プラグインを実行する memlab trace --node-id <id> ヒープ上の特定のノード ID を指定し、それがなぜメモリに残っているのかを示すリテーナートレース(保持経路)を出力する memlab heap 単一のヒープスナップショットを対話的(インタラクティブ)に探索します。 memlab view-heap 単一のヒープスナップショットを対話的に表示・確認します。 memlab measure ヒープサイズの変動(ノイズ)を把握するため、同じシナリオを複数回反復実行して測定します。 memlab warmup テスト前にターゲットのウェブアプリ(サーバー)をウォームアップし、キャッシュ等の初期化を済ませます。 memlab list 登録されているすべてのテストシナリオを一覧表示する memlab anonymize 取得したヒープスナップショットからユーザーデータやテキストを置換・削除し、第三者へ安全に共有できる状態(匿名化)にします。 memlab reset Memlab が一時的に生成したワークスペース内のスナップショットファイルやキャッシュデータを削除し、状態をリセットする コマンドラインから memlab run --scenario <シナリオファイル名> を実行する際、シナリオファイルは主に以下の 3 つの関数で構成されます url: テストを開始する初期画面の URL を指定する action: クリックなど、リークを発生させる可能性のある操作を定義する back: モーダルを閉じる、または元の画面に戻るための操作を定義する 基本的なシナリオファイルの実装サンプルは以下の通りです。 module.exports = { url: () => "example.com", action: async (page) => { await page.click("#open-modal"); }, back: async (page) => { await page.click('[aria-label="Close"]'); }, }; 誤検知と検知漏れのメカニズム Memlab の「誤検知」や「検知漏れ」を正しく理解するためには、デフォルトの挙動として内部で実行される以下の 2 つを知ることが重要です。 時間軸の差分比較による絞り込み Memlab は (STP \ SBP) ∩ SFP という計算式で差分を抽出する SBP(初期状態)に存在せず、STP(アクション後)に新規作成され、SFP(戻った後)にも残存するオブジェクトのみを対象とする SBP の時点で裏側のキャッシュ等に微量でも存在していた場合、今回のシナリオ起因の新規リークではないと正しく判定され除外される ヒューリスティクスによるノイズの切り捨て 全オブジェクトを抽出後、Detached DOM などリークの可能性が高い特定の種類のオブジェクトのみを厳格なフィルターで残す その他のプレーンな JavaScript オブジェクトや配列などは、ノイズとして意図的に報告から切り捨てられる クラスタリングによる代表経路の抽出 検出された膨大なリークオブジェクトを、類似する保持経路ごとにクラスタとしてグループ化する デフォルトでは各クラスタから代表的な 1 つの経路のみが出力されるため、細かいノイズクラスタが大量に生成されると、リークがログに埋もれて見逃されるリスクがある CLI オプションと挙動の変更 CLI オプションを付与することで、クラスタリング(類似したリーク経路のグループ化)や抽出対象の挙動を変更できます。 --save-trace-as-unclassified-cluster 全てのトレースを 1 つのクラスタに集約するのではなく、検出された各トレースをそれぞれ個別の未分類クラスタとして出力する挙動となる --ml-linkage-max-dist <0~1> クラスタリングにおける結合の最大距離を設定するものである 距離の閾値を 0 に設定した場合、2 つのトレース間の距離が完全に 0(内容が完全に同一)でない限り、それらが同じクラスタに結合されることはない --trace-all-objects 前述した「ヒューリスティクスによるノイズの切り捨て」を無効化する 差分計算 (STP \ SBP) ∩ SFP に合致したすべてのオブジェクトを対象としてトレースを出力する leakFilter を活用した V8 内部ノードの除外 CLI オプションで検出候補を増やすと、本当に見つけたいアプリケーション起因のリークかどうかの確認に要します。 シナリオファイル内にアプリケーション固有の leakFilter コールバックを定義することで、特定のオブジェクトを意図的に除外することができます。 例えば、V8 エンジンが自動生成する内部ノードの徹底的な除外です。 hidden や object shape (syntheticノード) など、V8 エンジンが裏側で自動生成する仮想的な C++バインディングノード これらは、アプリ側のオブジェクトがリークした結果として道連れでメモリに残存しているだけの「症状」に過ぎず、開発者が JS コードから直接破棄できるものではありません。 そのため、leakFilter を用いて明示的に return false とし、探索スコープから除外することができます。 具体的なシナリオファイルの実装例は以下の通りです module.exports = { url: () => "example.com", action: async (page) => { /* アクションの実行 */ }, back: async (page) => { /* アクションのクリーンアップ・戻る処理 */ }, leakFilter: (node, _snapshot, _leakedNodeIds) => { if (node.type === "hidden" || node.type === "synthetic") { return false; // 経路探索とリーク報告の対象から完全に除外 } // 任意: アプリ側で本当に深刻なリーク(例: 保持サイズ1MB以上)のみを追跡する return node.retainedSize > 1024 * 1024; }, }; 継続的なメモリリーク監視と運用の検討 Memlab は詳細なヒープ解析が可能ですが、アプリ規模によっては長い時間を要することもあるため、すべてのコミットに対する毎回の CI 実行に適さない場合があります。 下記は一例であり、アプリケーションの非機能要件に応じて適切な運用を検討する必要があります。 Memlab は Nightly ビルドで運用 通常の PR 同期 CI から切り離し、影響範囲の大きい改修後や夜間バッチでの非同期実行に限定する 毎回の CI には Playwright と CDP を活用 PR 単位の日常的なテストでは、Playwright から CDP(Chrome DevTools Protocol)の Memory.getHeapStats や Runtime.queryObjects 、 Memory.getDOMCounters を叩いて監視する ソークテストの導入 ページをリロードせずに長時間使用する SPA では、特定の UI 操作を数百回反復実行する「ソークテスト」を検討する Nolan Lawson 氏が開発した SPA 向け検証ツール fuite でもソークテストの概念を採用している まとめ Memlab を利用した調査は、ツールのアルゴリズム特性を把握し、CLI オプションの仕様を正しく理解した上で、leakFilter を用いて計算量を絞り込むことが必要です。 変更ごとのメモリリーク監視には、Memlab は時間がかかり過剰となる場合があるため、アプリケーションの非機能要件に応じて適切な運用を検討する必要があります。また、日本語での運用情報も少なく、調査コストがかかると感じました。 一方で、Memlab はメモリリークが発生しているとわかっている場合の原因究明では有効なツールと思います。 参考資料 Command Line Interface | memlab - Meta Open Source: facebook.github.io How memlab Works | memlab - Meta Open Source: facebook.github.io fuite GitHub Repository: nolanlawson/fuite memlab Issue #123: memlab#123