🖥️

SSR環境におけるメモリリークデバッグガイド(2)

に公開

この記事は、FEConf 2023で発表された講演
A Guide to Debugging Memory Leaks in SSR Environments (Node.js)
の内容をまとめたものです。
この記事は全2回に分けてお届けします。
Part 1では、メモリリークとは何か、そしてモニタリングツールを使ってその兆候を検知する方法を紹介します。
Part 2では、実際のメモリリークをどのようにデバッグして解消するのか、そのプロセスを解説します。

この記事に掲載されている画像は、すべて同名の講演スライドから引用されたものであり、別途の出典表記は行っておりません。
発表資料はFEConf 2023の公式サイトからダウンロードできます。


「SSR環境(Node.js)におけるメモリリークのデバッグガイド」 – Toss Place フロントエンドエンジニア パク・ジヘ氏による講演(FEConf 2023)

本記事では、前回の記事で議論したメモリリークの問題のデバッグと解決について掘り下げていきます。

メモリリークの解決

以前のエレベーターの例え話では、メモリリークを解決する2つの方法を見ました。

ヒープメモリを増やす、または

メモリリークの原因となっている犯人を見つけるためにデバッグする。

これら2つの方法を詳しく見ていきましょう。

ヒープメモリの増加

まず、メモリを増やす方法を探ってみましょう。「メモリが不足しているから増やそう」という考え方は、非常に自然です。しかし、単にヒープメモリを増やすだけでメモリリークは解決するのでしょうか?

alt text

必ずしもそうではありません。ヒープメモリを増やしたとしても、このコードはメモリリークを引き起こし続け、最終的にはサーバーをクラッシュさせるでしょう。なぜでしょうか? Node.jsのV8エンジンがどのようにメモリを管理するかを知っていれば理解できます。V8エンジンは、「マークアンドスイープ」と呼ばれるアルゴリズムを使用してメモリを効果的に管理します。これは、使用中のものに印を付け、使用されていないものを掃き出す(クリーンアップする)ことを意味します。

alt text

配列、オブジェクト、関数のようなデータ型は、ヒープからメモリを割り当てられて動作します。これらの型を「オブジェクト」と呼びましょう。ガベージコレクタは、ルートからこれらのオブジェクトが使用されているかどうかを再帰的にチェックします。その後、もはや使用されていない不要なオブジェクトを収集してメモリス​​ペースを解放します。これが「マークアンドスイープ」アルゴリズムです。

しかし、オブジェクトがどこかから継続的に参照されている場合、それはヒープメモリに残り続けます。このようにオブジェクトが永続化するとどうなるでしょうか? これを理解するためには、ヒープメモリについて知る必要があります。

ヒープメモリ

Node.jsのV8エンジンは、より良い管理のためにメモリをゾーンに分割します。以下は、V8エンジンのライフサイクルを説明するためのガベージコレクタの簡略化された構造です。

alt text

主にヤングジェネレーションとオールドジェネレーションに分かれており、ガベージコレクタはマイナーGC(スカベンジャー)とメジャーGC(マークスイープ&マークコンパクト)に分かれています。オブジェクトが新しく宣言されると、通常、「ナーサリー」(ヤングジェネレーション内)と呼ばれる領域にメモリが割り当てられます。オブジェクトが1回のガベージコレクションサイクルを生き延びると、「中間(intermediate)」または「サバイバー(survivor)」スペース(多くの場合、依然としてヤングジェネレーション内)に移動します。さらに別のガベージコレクションサイクルを生き延びると、オブジェクトは最終的にオールドジェネレーション領域に移動します。V8のドキュメントでは、ここまで到達するオブジェクトはごくわずかであると説明されています。

では、より多くのオブジェクトが生き残り、このオールドジェネレーション領域に蓄積されるとどうなるでしょうか? V8エンジンは、これら2つのジェネレーションを管理することでアプリケーションを動作させます。しかし、ヒープメモリには限られた容量しかないため、最終的にはいっぱいになり、サーバーがクラッシュします。

以前のサンプルコードをもう一度見てみましょう。

alt text

listItems 配列は、グローバル変数として宣言されているため、ガベージコレクタによって収集されず、最終的にオールドジェネレーションに常駐することになります。最初は、概念的な最初の図のように小さな領域を占有するかもしれません。その後、サーバーがリクエストを受信すると、ループが100万回実行され、listItems の長さが増加します。これが繰り返されると、概念的な2番目の図に示されるように、大量のスペースを占有するようになります。最終的に、ヒープメモリがいっぱいになる瞬間が訪れます。そして、サーバーはクラッシュします。

上記の例は単純ですが、実際のコードを書いているときにこのような問題に遭遇した場合、単にヒープメモリを増やすだけで問題は解決するのでしょうか? このような問題に直面してオンラインで検索すると、以下に示すような max-old-space-size を増やすことを提案する回答を簡単に見つけることができます。

alt text

ここで、max-old-space-size はNode.jsのオールドジェネレーションの容量を調整するためのオプションです。言い換えれば、メモリリークを引き起こすほとんどのオブジェクトはオールドジェネレーションに存在するため、先に説明したガベージコレクタの構造に基づいて、オールドジェネレーションの容量を増やすようにというアドバイスをよく見かけるでしょう。

alt text

前述のグローバル変数の他にも、さまざまなメモリリークの原因があります。一般的な例としては、setTimeout や setInterval タイマーをクリアしないこと、そしてクロージャーが挙げられます。クロージャーの場合、実行コンテキスト内でオブジェクトや変数が宣言されて参照され、さらに参照が続く状況は、ヒープメモリの大幅な割り当てにつながる可能性もあります。

これらのさまざまなシナリオでは、非常に大量のヒープメモリが必要となる状況が発生するため、むやみにヒープメモリを増やすことが常に解決策になるとは限りません。

メモリリークのデバッグ
デバッグ方法

さて、デバッグによる解決策を探ってみましょう。node --inspect index.js のように inspect オプションを使用してNode.jsを実行し、ブラウザの開発者ツールを開くと、緑色のNode.jsアイコンが表示されます。私は主にChromeのinspectメニュー(chrome://inspect経由でアクセス可能)を使用します。このメニューには現在実行中のローカルサーバーがリスト表示され、目的のサーバーを選択してそのinspectウィンドウを開くことができます。

alt text

デバッグ用にinspectウィンドウを開くと、以下のようなウィンドウが表示されます。左側のパネル(通常は「メモリ」タブ)には、プロファイリング記録ボタン(多くは円形)があります。特定の期間のメモリ使用状況を確認するには、記録を開始および停止する必要があり、このボタンでそれができます。

alt text

その隣には「すべてのプロファイルをクリア」ボタン(多くは線が引かれた円、またはプロファイルごとの個別のゴミ箱アイコン)があり、その下には完了したプロファイリング結果ファイルのリストがあります。「すべてのプロファイルをクリア」ボタンは、すべてのプロファイリング記録結果ファイルを削除します。

alt text

最後に、ゴミ箱アイコンのボタンがあり、これは手動でガベージコレクタをトリガーします。通常、メモリプロファイリングを開始する前に、ガベージコレクタをトリガーしてメモリ状態を安定させてからプロファイリングを開始します。

alt text

次に、最も重要な領域を見てみましょう。Chromeはメモリタブで3つのプロファイリングタイプをサポートしています。

まず、ヒープスナップショット。このタイプは、特定の瞬間のヒープメモリ使用量を記録します。このタイプを選択すると、下の スナップショットを撮る ボタンがアクティブになります。これをクリックすると、その瞬間のヒープメモリ状態がキャプチャされます。メモリやパフォーマンスの観点から大幅に改善したコードがある場合、変更前後にスナップショットを記録して2つを比較することができます。これは、メモリリークが発生している可能性のある場所を正確に把握しており、その特定の箇所をデバッグしたい場合に便利です。

alt text

2つ目は、タイムライン上のアロケーションインストルメンテーションです。これは非常に便利で頻繁に使用される機能です。定期的にヒープメモリを記録し、記録中に時間経過とともにどれだけのヒープメモリが使用されているかをグラフで表示します。メモリリークを疑ってデバッグを開始するときに、このタイムラインを使用してメモリ使用量が時間とともにどのように変化するかを観察できます。メモリリークがどこで発生しているかを特定するのは難しい場合が多く、この機能はそのような場合に非常に役立ちます。

最後のタイプは、アロケーションサンプリングです。これは2番目のものと似ていますが、主に非常に長期間記録する必要がある場合に使用されます。すべての瞬間を記録するとオーバーヘッドが発生する可能性があるため、この方法では長期間にわたるサンプリング情報を使用してデバッグします。記録ボタンを押しても、あまり何も起こっていないように見えるかもしれませんが、記録は行われています。記録を停止すると、サンプリングされた情報が表示されます。

alt text

これら3つのデバッグ方法を簡単に見てきました。状況に最も適したタイプを選択できますが、一般的には2番目のタイプ(タイムライン上のアロケーションインストルメンテーション)が最もよく使用されます。メモリリークエラーに遭遇した場合は、2番目のタイプでデバッグを開始すると、問題のある領域を迅速に特定するのに役立つでしょう。

メモリリークの犯人を見つける

先に説明した2番目の方法(タイムライン上のアロケーションインストルメンテーション)を使用してデバッグを開始すると、上部にグラフが表示されます。このグラフは、リクエストが処理されている間にNode.jsがどれだけのヒープメモリを使用しているかを示します。

alt text

任意の時点でのグラフの高さは、その瞬間に割り当てられているヒープメモリの総量を表します。灰色の領域はガベージコレクタによって回収されたメモリを示し、青色の領域は現在ヒープ上で占有されているメモリを表します。したがって、青色が持続的に多い場合は、重大なリークを示している可能性があります。灰色の活動が多い場合は、メモリが管理されていることを意味します。

グラフの特定の部分をドラッグして選択し、その区間だけを表示することもできます。まず全範囲を調べてから、青色のグラフが持続している領域を選択することをお勧めします。共通のオブジェクトが一貫して出現しているのを見つけるかもしれません(交差点のように)。これらの領域にデバッグを集中させることで時間を節約できます。

グラフを使用すると、メモリリークがいつ発生しているかを簡単に確認できますが、誰が犯人であるかはわかりません。犯人を見つけるためには多くのことを知る必要がありますが、以下の概念は不可欠です。

alt text

これらはシャローサイズ (Shallow Size) と リテインドサイズ (Retained Size) です。シャローサイズはオブジェクト自体のサイズ(バイト単位)です。リテインドサイズは、オブジェクト自体が削除された場合に解放されるメモリの合計サイズであり、それが排他的に参照するすべてのオブジェクト(および再帰的に続くものすべて)を含みます。さらに、ディスタンス (Distance) と呼ばれるメトリックがあり、これはオブジェクトがガベージコレクタのルートからどれだけ離れているかを示します。ディスタンスの値が大きいほど、メモリリークの一部である可能性が高いことを示唆する場合があります。正確なデバッグメトリックというよりは補助的な指標ですが、迅速な参照には役立ちます。

以前のサンプルコードでは、グローバルに宣言された listItems 配列が関数内で参照されていました。ガベージコレクタはこの変数を回収できなかったため、ヒープメモリを占有し続けました。関数自体は単純ですが、実際の実行コンテキスト内では、この変数は非常に大きなサイズに成長し、大量のヒープメモリを必要とする可能性があります。言い換えれば、そのリテインドサイズはシャローサイズよりもはるかに大きくなる可能性があります。リテインドサイズがシャローサイズよりも著しく大きいオブジェクトや変数にデバッグを集中させるべきです。

alt text

先ほど作成したコードのinspectメニューで、リテインドサイズで降順にソートすると、私が作成した memoryLeakFunction を確認できます。このオブジェクトを選択し、下のリテイナー (Retainers) タブで、このオブジェクトをヒープメモリに割り当てられたままにしている参照の連鎖を確認できます。ファイル名をクリックすると、どのファイルやコードが関連しているかを確認できます。使用している特定のライブラリへのパスも特定できます。

alt text

(リテイナービューまたはオブジェクトリストで)下にスクロールすると、listItems が表示されます。(発表者が示すかもしれない画像のように概念的に)その詳細を見ると、listItems のリテインドサイズがシャローサイズよりもはるかに大きいことがわかります。面倒なプロセスかもしれませんが、これらのオブジェクトを一つ一つ見つけて修正し、これらの数値を減らすことで、メモリリークの犯人を見つけて解決することができます。

以下の画像は、私の実際の経験からのinspect画面です。不要な部分を除外した後、表示されているセクションを見つけました。通常は node_modules のようなパスが表示されますが、私の場合、Yarn BerryでNodeパッケージを管理しているため、.yarn パスが表示されていることがわかります。このパスのどのファイルが問題を引き起こしているかを特定し、メモリリークがそこから発生していることを確認し、修正して変更をデプロイしたところ、メモリリークが解決しました。

alt text

デプロイ前のグラフは先に説明した山形のグラフでしたが、デプロイ後は以下のような穏やかで平坦なグラフに変わったことがわかります。それ以来、メモリリークのない状態を維持しています。

alt text

using - 苦痛を和らげるキーワード

最後に、紹介したいキーワードがあります。using です。C#を使ったことがある方ならおなじみかもしれません。Pythonにも同様の概念があります(with ステートメント)。最近発表されたTypeScript 5.2で目にした方も多いかもしれませんが、実は新しい概念ではありません。

alt text

これはC#に既存の概念であり、JavaScriptのTC39(JavaScript標準を管理するグループ)ではすでにステージ3に達しているため、近いうちにネイティブのJavaScriptでも見られるようになるかもしれません。

簡単に言うと、var、let、const の代わりに using で変数を宣言すると、変数のスコープの終わりにオブジェクトの Symbol.dispose または Symbol.asyncDispose メソッド(実装している場合)が呼び出され、クリーンアップが可能になります。宣言されたイベントリスナーを削除したり、作成されたDB接続を解放したり、接続してから切断する必要があるストリームのようなリソースのライフサイクルを管理したりできます。

alt text

以前のサンプルコードを using を使用するように変更すると、このようになります。const の代わりに using を宣言しました。using で動作させるためには、オブジェクトが Disposable インターフェースを実装している必要があります(つまり、Symbol.dispose メソッドを持っている必要があります)。関数の内容は同じで、dispose メソッドを介してクリーンアップ部分が追加されています。

alt text

この(概念的な)コードを実行すると、以前とは異なり、ヒープメモリの使用量が安定していることがわかります。このように using を活用することで、メモリリークを回避するコードを書くことができるようです。

alt text

まとめ

最後に、これまで議論してきたことをまとめて結論とします。サーバー環境とクライアント環境でのデバッグ方法、サーバー環境での inspect オプションの使用方法とタイムラインでのヒープメモリ使用量のプロファイリング方法、シャローサイズとリテインドサイズを比較してメモリリークを引き起こしているオブジェクトを見つける方法、そして最後に、近日登場予定の using キーワードや同様の明示的なリソース管理パターンを使用して、メモリリークを潜在的に防ぐ方法について見てきました。

alt text

皆さんが業務でメモリリークを経験した際に、今日説明した方法がデバッグの機会を提供し、原因を見つけてパフォーマンスを改善する一助となることを願っています。

ありがとうございました。

Discussion