memlab を使って Web サイトのメモリリークを検出しよう
概要
本記事は、メタ社(旧 FaceBook) が開発した OSS であるメモリリーク検知ツールである [memlab] をさっそく試してみた記録になります。
公式ドキュメント以上の付加価値はあまりありませんが、ざっくりと雰囲気を掴んでもらって使用を検討して頂ければ幸いです。
memlabについて
memlab
は、 Puppeteer API を用いたシナリオを作成することで、そのシナリオ実行によって発生するメモリリークの検出及びヒープ領域の分析を補助してくれるツールです。
本記事は実際に動かすところに重きを置くので、ツールの背景などの詳細は以下記事を参照ください。
ざっくり言うと、 memlab
では以下のことが行なえます。
-
Puppeteer
ベースでの宣言的なシナリオの作成 - ヒープ領域とメモリリークの可視化
- シナリオ内でのヒープのスナップショットの自動取得
- スナップショットの解析とメモリリークのフィルタリング
- 同種のメモリリークのグルーピングと集約
- メモリリーク発生に至るまでのトレースの生成
- メモリリークのフィルタリングルールのカスタマイズ
- API を用いたヒープメモリへの簡易的なアクセス
- CLI を用いたレポートの参照
SPA におけるメモリリークのテストはブラウザのデベロッパーツールを活用するような複雑で困難なものが多いですが、それを簡易的に自動化できるツールと考えて頂ければ大丈夫です。
インストール
ここではドキュメントに従ってグローバルインストールしておきますが、ローカルインストールでも問題ないと思います。
npm install -g memlab
$ memlab version
memlab@1.1.27
@memlab/heap-analysis@1.0.9
@memlab/e2e@1.0.11
@memlab/core@1.1.10
@memlab/cli@1.0.11
@memlab/api@1.0.11
サンプルコード
まず、サンプルコードとして YouTube にアクセスし、トップページから任意の動画を再生する導線を検証するコードを紹介します。
以下コードは memlab
のサンプルコードであり、実際の YouTube
におけるメモリリークの検出として適切なコードではありません
// 最初に読み込む URL (1)
function url() {
return "https://www.youtube.com";
}
// メモリリークの有無を検出するための任意のアクション (2)
async function action(page) {
await page.click('[id="video-title-link"]');
}
// アクション完了後に、初期状態に戻るための操作 (3)
async function back(page) {
await page.click('[id="logo-icon"]');
}
// 少なくとも上記3種はエクスポートする
module.exports = { url, action, back };
memlab
のシナリオコードでは、3種類の関数をエクスポートします。
1. 読み込むURL
シナリオ実行時に最初に開くURLを返す関数です。
ここでは YouTube
のトップページの URL を返します。
2. メモリリークを検出するための任意のアクション
メモリリークが発生する可能性がある操作を Puppeteer
API を用いて記述します。
ここでは先頭の動画へのリンクである要素(#video-title-link
) を押下することで、動画再生ページに遷移します。
3. アクション実行後に初期状態に戻るための手続き
(2) でアクション完了後に、初期状態(トップページ)に戻るための手続きを Puppeteer
API を用いて記述します。
ここでは (2) で動画再生画面に遷移していたので、 YouTube
のロゴである #logo-icon
を押下することでトップページに戻ります。
以上、 (1)(2)(3) をエクスポートするモジュールを作成し、それを memlab
で実行します。
サンプルコードを実行する
前述のサンプルコードを、 memlab run
コマンドで実行すると、以下のようにメモリリークが検出されます。
特筆すべきは先頭行の
page-load[26MB](baseline)[s1] > action-on-page[33.5MB](target)[s2] > revert[33MB](final)[s3]
の部分です。これはそれぞれサンプルコードにおける url
action
back
に該当するもので
- s1:
url
の返り値を元にサイトを開いた時点でのヒープ領域の使用量 - s2:
action
を実行した後のヒープ領域の使用量 - s3:
back
を実行した後のヒープ領域の使用量
を表し、 s1, s2, s3 それぞれのヒープ領域のスナップショットを元に、メモリリークが発生するかを判定しています。
ここでは YouTube
がメモリリークを起こしているか、その検出方法が正しいかは重要ではないので、具体的な結果には触れずに、サンプルアプリケーションで再度試してみます。
DOM によるメモリリークを検出する
本項では、memlab
のモノレポで公開されているサンプルアプリケーションを使ってメモリリークの検出を体験します。
サンプルアプリケーションのセットアップ
以下モノレポから、サンプルアプリケーションのセットアップを行います。
$ git clone git@github.com:facebookincubator/memlab.git
$ cd memlab
$ npm install && npm run build
$ cd packages/e2e/static/example
$ npm install && npm run dev
モノレポなのもあって、2回インストール作業が必要ですが、これで localhost:3000
でサンプルアプリケーションが立ち上がりました。
そこに対して memlab
でメモリリークの検出を行ってみましょう。
サンプルアプリケーションを確認する
localhost:3000
で稼働しているサンプルアプリケーションのうち、/examples/detached-dom
のページをテストします。
このページには Create detached DOMs
というボタンがありますが、このボタンは押下するたびに、どこからも参照されない div
要素を 1024個 window
オブジェクトに対して突っ込むという凶悪なコードになっています。
export default function DetachedDom() {
const addNewItem = () => {
if (!window.leakedObjects) {
window.leakedObjects = [];
}
// 凶悪なメモリリークを引き起こすコード
for (let i = 0; i < 1024; i++) {
window.leakedObjects.push(document.createElement('div'));
}
console.log('Detached DOMs are created. Please check Memory tab in devtools')
};
return (
<div className="container">
<div className="row">
<Link href="/">Go back</Link>
</div>
<br />
<div className="row">
<button type="button" className="btn" onClick={addNewItem}>
Create detached DOMs
</button>
</div>
</div>
);
}
言わずもがな、このページはボタンを押下すればするほど不要な DOM が生成され、ヒープ領域を逼迫しつづけます。
この事実を memlab
で検出してみましょう。
シナリオを作成する
memlab
におけるテストシナリオの書き方は YouTube
を使用した例と同様なので詳細は割愛しますが、以下のようになります。
function url() {
return "http://localhost:3000/examples/detached-dom";
}
async function action(page) {
const elements = await page.$x("//button[contains(., 'Create detached DOMs')]");
const [button] = elements;
if (button) {
await button.click();
}
await Promise.all(elements.map((e) => e.dispose())); // Puppeteer で確保しているオブジェクト開放する
}
async function back(page) {
await page.click('a[href="/"]');
}
module.exports = { action, back, url };
先程の s1, s2, s3 に対応する処理が以下になります。
- s1:
localhost:3000/examples/detached-dom
にアクセスし - s2:
Create detached DOMs
ボタンをクリック - s3: トップページへのリンクをクリック
メモリリークを検出する
前述のシナリオを memlab
で実行します。
memlab run --scenario 02_find_memory_leaks.js
実行結果から、以下のことがわかりました。
- 1024 オブジェクトがリークしている (実際に1204回ループして
div
を生成しているため) - リークしたオブジェクトのサイズは 143.3KB である
- リークしたオブジェクトは
window.leakedObjects
内のHTMLDivElement
である
メモリリークを修正する
memlab
によって検知されたメモリリークからも原因が特定できました。 window.leakedObjects
に DOM をプッシュする処理は完全に不要であるため、コメントアウトしてしまいましょう。
const addNewItem = () => {
// if (!window.leakedObjects) {
// window.leakedObjects = [];
// }
// for (let i = 0; i < 10000; i++) {
// window.leakedObjects.push(document.createElement('div'));
// }
// console.log('Detached DOMs are created. Please check Memory tab in devtools')
};
これで再実行すると、無事にメモリリークの検出がなくなり、修正に成功しました。
デフォルトで検出されないメモリリークを検出する
前項においては、 window
オブジェクトからの参照が残ってしまった div
要素がメモリリークとして検出されました。
memlab
はどういった基準でメモリリークを判断しているのでしょうか。
デフォルトでは、あるオブジェクトが以下の基準を満たした場合のみ、メモリリークとして検出しています。
- オブジェクトは action 関数でトリガーされたインタラクションによって割り当てられている
- オブジェクトは back 関数実行後も開放されていない
- オブジェクトは切り離された DOM 要素またはアンマウントされた React Fiber ノードである
前項の例では、 action
関数での操作(ボタンクリック)によって DOM 要素が生成され、それが back
関数実行後も開放されていなかったことから、メモリリークと判定されました。
ではそれ以外のパターンとしてどういったものが考えられるか、それをどう対処するのかの例を紹介します。
サンプルアプリケーションを確認する
サンプルアプリケーション自体は前回の例と同じものを使用するので、引き続き localhost:3000
で動いているものを使用します。
今回は /examples/oversized-object
に該当するページが対象となります。
本ページのコードは以下のようになっており、巨大オブジェクト(bigArray
) に依存したイベントハンドラ使ったイベントの購読を行っています。
コードに React の文脈を含みますが、ご存知でない方は雰囲気で読んでください
export default function OversizedObject() {
// 巨大オブジェクト
const bigArray = Array(1024 * 1024 * 2).fill(0);
// 巨大オブジェクトに依存したイベントハンドラ
const eventHandler = () => {
console.log('Using hugeObject', bigArray);
};
// イベントの購読 (解除を忘れている)
useEffect(() => {
window.addEventListener('custom-click', eventHandler);
}, []);
return (
<div className="container">
<div className="row">
<Link href="/">Go back</Link>
</div>
<br/>
<div className="row">
Object<code>bigArray</code>is leaked. Please check <code>Memory</code>{' '}
tab in devtools
</div>
</div>
);
}
ここでは useEffect
を用いて、ページ読み込み時にコールバックでイベントを購読していますが、その解除を適切に行なっていないため、ページを行き来するたびにイベントがどんどん登録され、その分だけ巨大オブジェクトがヒープ領域に展開され続けます。
これは言わずもがなメモリリークであるため、 memlab
で検出してみましょう。
シナリオを作成する
シナリオを書くのも3度目なので、掲載のみで説明は割愛します。
function url() {
return "http://localhost:3000/";
}
// action where you suspect the memory leak might be happening
async function action(page) {
await page.click('a[href="/examples/oversized-object"]');
}
// how to go back to the state before action
async function back(page) {
await page.click('a[href="/"]');
}
module.exports = { action, back, url };
メモリリークを検出する(失敗例)
$ memlab run --scenario 03_find_oversiz3ed_object.js
グラフからメモリ使用量が戻っていないことがわかるのに、メモリリークとして検出されていません!!
これだけ明らかなのに検出されないのは、前述のデフォルトでのメモリリーク検出基準を満たしていないからです。
ルールをもう一度見てみましょう。
- オブジェクトは action 関数でトリガーされたインタラクションによって割り当てられている
- オブジェクトは back 関数実行後も開放されていない
- オブジェクトは切り離された DOM 要素またはアンマウントされた React Fiber ノードである
今回の場合、ページを開いた時点で useEffect
によって発生する副作用だったため、 action
関数からトレースできていないし、そもそも DOM
要素でなくプリミティブな Array
であるため対象外という結果になりました。
では memlab
はこの程度のメモリリークも検出できないのでしょうか?
そんなことはなく、多くの方法でメモリリークを分析、検出することができます。
今回はその中から、 leakFilter
を定義するパターンを試してみましょう。
leakFilter
を実装する
leakFilter
関数は、シナリオコード(url
action
back
を定義したモジュール) に追加する形で実装する、「それがメモリリークであるかの判断を行う関数」です。
以下のように、怪しいノード及びスナップショット情報が関数に渡されるので、今回はサイズが 1MB 以上であればアウトにします。
// 前略
function leakFilter(node, _snapshot, _leakedNodeIds) {
return node.retainedSize > 1000 * 1000;
}
module.exports = { action, back, url, leakFilter };
メモリリークを検出する(リベンジ)
leakFilter
が実装されたことで、デフォルトの検出ルールに加えて、サイズが 1MB 以上のノードが残っていたらアウトという判定になりました。
これで再度 memlab
を実行します。
見事メモリリークを検出し、8.3MB の bigArray
が eventHandler
内にあることを教えてくれました。
メモリリークを修正する
検出されたメモリリークを修正します。
ページ離脱時にイベントの購読を解除しなかったことが原因であるため、 useEffect
でクリーンアップ関数を書いてあげましょう。
useEffect(() => {
// 購読する
window.addEventListener('custom-click', eventHandler);
return () => {
// 解除する
window.removeEventListener('custom-click', eventHandler);
}
}, []);
これで最後 memlab
を実行すると、メモリリークが修正されたことを確認できました。
グラフからも、トップページに戻ることでメモリが開放されていることが可視化されています。
まとめ
本記事では、メタ社(旧 FaceBook) が開発した OSS である memlab をさっそく試し、いくつかのメモリリークの検出と、その修正までを試しました。
個人的にはメモリリークの基準を自分で定義して分析するというのは、ブラウザの低レイヤーでのヒープの振る舞いを意識する良いキッカケになったので、これを活用してパフォーマンスの良いサービスを作れるようになれればと思います。
memlab
は記事執筆時点でリリースされたばかりの新鋭ツールであるため、今後どんどん強化されたり、ノウハウの記事が増えていったりすると思うので、今後の発展も非常に楽しみにしています。
Discussion