🖥️

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

に公開

この記事は、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)

こんにちは、Toss Placeのフロントエンドエンジニア、パク・ジヘです。
この記事では、Node.jsで構築されたサーバーサイドレンダリング(SSR)環境において発生する可能性のあるメモリリークのデバッグ方法について紹介します。

「SSR環境(Node.js)におけるメモリリークのデバッグガイド」というタイトルを見て、どのキーワードが重要だと思いますか?
私自身は、**「Node.js」「メモリリーク」**が最も重要なキーワードだと考えています。
この中でも、今回は「メモリリーク」という概念に焦点を当てて、実際の経験をもとに話を進めていきます。

ある日、チームのDevOpsエンジニアから「特定のサービスでOOM(Out Of Memory:メモリ不足)エラーが発生している。確認してもらえますか?」と相談を受けました。
最初は、コードを少し見直せばすぐに直ると思っていたのですが、コードを開いても明らかな問題が見つからず、リークは止まりませんでした。
そこで、私は改めて腰を据えて学習し、地道にデバッグして修正に取り組むことにしました。

この記事を通じて、次の2つのメッセージを伝えたいと思います:

  1. 「私にもできる」と思える自信
  2. ブラウザのMemoryタブを活用し、複雑な環境でも原因を突き止めるためのノウハウ

もし同じような問題に直面している方がいれば、かつての私のように、この記事がヒントや支えになれば嬉しいです。

メモリリークとは?なぜ問題なのか?

メモリリーク

メモリリークとは、本来解放されるべきメモリが解放されず、使い続けられてしまう状態のことです。

より直感的に理解するために、「エレベーター」を例にしてみましょう。


📝 スライド要約:「降りるべき人が降りずに居座ってしまうエレベーター=解放されないメモリ」

エレベーターの定員が10人で、4人がずっと乗ったままだとしましょう。
他の人が乗り降りしても、常に4人分のスペースが占拠されたままなので、実質的に使えるスペースは6人分しかありません
結果として効率が悪くなり、すぐに満員になります。

これと同じで、不要になったメモリが解放されず残っている状態をメモリリークと呼びます。

なぜメモリリークは危険なのか?

エレベーターが非効率だと困るのと同じように、アプリケーションでもメモリが十分にないと性能が著しく低下します。

JavaScriptは**ガーベジコレクタ(GC)**によって不要なメモリを自動的に解放しますが、メモリリークがあるとGCの稼働頻度が上がり、CPU使用率が増加します。
その結果、イベントループがブロックされる可能性が出てきます。
イベントループはJavaScriptの中核を担っているため、ここが滞るとアプリケーション全体が遅延します。

最悪の場合、サーバーがクラッシュすることもあります。
自動再起動の仕組みがあったとしても、その一瞬はユーザーにサービスを提供できない=サービス停止に繋がります。

つまり、メモリリークはパフォーマンス低下、安定性の欠如、サーバーダウンなど多くの問題を引き起こします。


📝 スライド要約:「メモリリークはメモリ不足、GCの負荷増大、イベントループの遅延、サーバークラッシュを引き起こす」

メモリリークの検出方法

では、Node.js環境でどうやってメモリリークを検知すればよいのでしょうか?
もっとも一般的な兆候は、Node.jsを実行しているターミナルにheap out of memoryというエラーが表示されることです。


📝 スライド要約:「'heap out of memory'エラー=典型的なメモリリークの兆候」

とはいえ、常にターミナルを監視しているわけにもいきません。
現実的には、モニタリングツールを用いてサーバーの状態をグラフなどで可視化しながら観察します。

サーバー環境であれば、こうしたモニタリングは比較的簡単に設定できます。
一方、クライアント(ブラウザ)環境では、ユーザーごとにブラウザやハードウェアが異なるため、観測が難しくなります。
とはいえ、デバッグの基本的な方法はサーバーでもクライアントでも共通ですので、今回は共通アプローチで説明します。

モニタリングツールでメモリリークを検知する

このセクションでは、実際のソースコードを使って、モニタリングツールを通じてメモリリークを観察する方法を紹介します。

意図的にメモリリークを仕込んだコードを用意し、これをデバッグしていきます。
以下の例では、リークがないバージョンとあるバージョンの2つを比較します。


📝 スライド要約:「Node.jsの基本的なHTTPサーバーコード」

上記は、リクエストに対してHTMLを返す単純なNode.jsサーバーです。


📝 スライド要約:「2つのコードは1行だけ異なる。その違いがメモリリークを生む」

条件文を使って、メモリリークあり/なしを切り替えられるようにしています。


📝 スライド要約:「トラフィックを模倣するため、一定間隔でリクエストを送るシェルスクリプト」

まずはメモリリークのないバージョンを見てみましょう。

このバージョンでは、listItems配列を関数スコープ内に定義しています。
ループで100万件のデータを生成し、使用中のヒープメモリを表示します。


📝 スライド要約:「関数内で定義されたリストは、使用後にGCで回収される」

このコードを実行しても、メモリ使用量は25MB前後で安定しており、異常な増加は見られません。


📝 スライド要約:「メモリ使用量が安定している=メモリリークなし」

モニタリングツール上でもフラットなグラフが確認できるはずです。
このように、明確な増加傾向がない場合、メモリリークの可能性は低いと判断できます。


📝 スライド要約:「フラットなグラフ=安定したメモリ使用」

では、今度はメモリリークありのバージョンを見てみましょう。

このバージョンでは、listItemsが**関数外(グローバルスコープ)**に定義されています。


📝 スライド要約:「グローバルに定義されたリストは解放されず、メモリリークを起こす」

実行すると、メモリ使用量が33MBから193MBまで増加し、最終的にはheap out of memoryでプロセスがクラッシュします。


📝 スライド要約:「時間とともにメモリが増加し、最終的にクラッシュ」

このときのモニタリンググラフはどうなるでしょうか?

右肩上がりのグラフになり、サーバーがクラッシュして再起動されるたびに急降下→再上昇という形になります。
これが**「山型」パターン**で、メモリリークの典型的な兆候です。


📝 スライド要約:「山型のグラフ=再起動と再発を繰り返すメモリリーク」

次回は、このメモリリークをどのようにデバッグし、解決するかについて詳しく解説していきます。

Discussion