Node.jsアプリケーションでのメモリリークのデバッグ

公開: 2022-03-11

私はかつてV8ツインターボエンジンを搭載したアウディを運転しましたが、その性能は素晴らしかったです。 道路に誰もいないとき、私は午前3時にシカゴ近くのIL-80高速道路を約140MPHで運転していました。 それ以来、「V8」という言葉は私にとって高性能に関連するようになりました。

Node.jsは、ChromeのV8 JavaScriptエンジン上に構築されたプラットフォームであり、高速でスケーラブルなネットワークアプリケーションを簡単に構築できます。

アウディのV8は非常に強力ですが、それでもガスタンクの容量には制限があります。 同じことがGoogleのV8(Node.jsの背後にあるJavaScriptエンジン)にも当てはまります。 そのパフォーマンスは驚くべきものであり、Node.jsが多くのユースケースでうまく機能する理由はたくさんありますが、ヒープサイズによって常に制限されます。 Node.jsアプリケーションでさらに多くのリクエストを処理する必要がある場合は、垂直方向にスケーリングするか、水平方向にスケーリングするかの2つの選択肢があります。 水平スケーリングは、より多くの同時アプリケーションインスタンスを実行する必要があることを意味します。 正しく実行すると、より多くのリクエストを処理できるようになります。 垂直スケーリングとは、アプリケーションのメモリ使用量とパフォーマンスを改善するか、アプリケーションインスタンスで使用可能なリソースを増やす必要があることを意味します。

Node.jsアプリケーションでのメモリリークのデバッグ

Node.jsアプリケーションでのメモリリークのデバッグ
つぶやき

最近、メモリリークの問題を修正するために、Toptalクライアントの1つでNode.jsアプリケーションを使用するように依頼されました。 APIサーバーであるこのアプリケーションは、毎分数十万のリクエストを処理できるようにすることを目的としていました。 元のアプリケーションは約600MBのRAMを占有していたため、ホットAPIエンドポイントを取得して再実装することにしました。 多くのリクエストを処理する必要がある場合、オーバーヘッドは非常に高価になります。

新しいAPIには、ネイティブMongoDBドライバーを使用したrestifyと、バックグラウンドジョブ用のKueを選択しました。 非常に軽量なスタックのようですね。 完全ではありません。 ピーク負荷時には、新しいアプリケーションインスタンスが最大270MBのRAMを消費する可能性があります。 したがって、1XHerokuDynoごとに2つのアプリケーションインスタンスを持つという私の夢は消えました。

Node.jsメモリリークデバッグアーセナル

Memwatch

「ノードのリークを見つける方法」を検索すると、おそらく最初に見つかるツールはmemwatchです。 元のパッケージはずっと前に放棄され、現在は維持されていません。 ただし、リポジトリのGitHubのフォークリストで新しいバージョンを簡単に見つけることができます。 このモジュールは、ヒープが5つの連続したガベージコレクションを超えて大きくなるのを確認すると、リークイベントを発行する可能性があるため便利です。

ヒープダンプ

Node.js開発者がヒープスナップショットを取得し、後でChrome開発者ツールを使用してそれらを検査できる優れたツール。

ノードインスペクター

実行中のアプリケーションに接続し、ヒープダンプを取得し、その場でデバッグして再コンパイルすることもできるため、ヒープダンプのさらに便利な代替手段です。

スピンのための「ノードインスペクター」を取る

残念ながら、Herokuで実行されている本番アプリケーションには接続できません。これは、実行中のプロセスにシグナルを送信できないためです。 ただし、Herokuだけがホスティングプラットフォームではありません。

node-inspectorの動作を体験するために、restifyを使用して単純なNode.jsアプリケーションを作成し、その中にメモリリークの小さなソースを配置します。 ここでのすべての実験は、V8v3.28.71.19に対してコンパイルされたNode.jsv0.12.7を使用して行われます。

 var restify = require('restify'); var server = restify.createServer(); var tasks = []; server.pre(function(req, res, next) { tasks.push(function() { return req.headers; }); // Synchronously get user from session, maybe jwt token req.user = { id: 1, username: 'Leaky Master', }; return next(); }); server.get('/', function(req, res, next) { res.send('Hi ' + req.user.username); return next(); }); server.listen(3000, function() { console.log('%s listening at %s', server.name, server.url); });

ここでのアプリケーションは非常に単純で、非常に明白なリークがあります。 アレイタスクはアプリケーションの存続期間中に増大し、速度が低下し、最終的にはクラッシュします。 問題は、クロージャだけでなく、リクエストオブジェクト全体もリークしていることです。

V8のGCは、ストップザワールド戦略を採用しているため、ガベージコレクションにかかる時間が長くなるほど、メモリ内にあるオブジェクトが増えることを意味します。 以下のログでは、アプリケーションの寿命の初めにガベージを収集するのに平均20ミリ秒かかることがはっきりとわかりますが、数十万のリクエストの後は約230ミリ秒かかります。 私たちのアプリケーションにアクセスしようとしている人は、GCのために230ms長く待たなければなりません。 また、GCが数秒ごとに呼び出されることがわかります。これは、ユーザーが数秒ごとにアプリケーションへのアクセスで問題が発生することを意味します。 そして、アプリケーションがクラッシュするまで遅延が大きくなります。

 [28093] 7644 ms: Mark-sweep 10.9 (48.5) -> 10.9 (48.5) MB, 25.0 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 7717 ms: Mark-sweep 10.9 (48.5) -> 10.9 (48.5) MB, 18.0 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 7866 ms: Mark-sweep 11.0 (48.5) -> 10.9 (48.5) MB, 23.2 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 8001 ms: Mark-sweep 11.0 (48.5) -> 10.9 (48.5) MB, 18.4 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. ... [28093] 633891 ms: Mark-sweep 235.7 (290.5) -> 235.7 (290.5) MB, 357.3 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 635672 ms: Mark-sweep 235.7 (290.5) -> 235.7 (290.5) MB, 331.5 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 637508 ms: Mark-sweep 235.7 (290.5) -> 235.7 (290.5) MB, 357.2 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested].

これらのログ行は、Node.jsアプリケーションが–trace_gcフラグで開始されたときに出力されます。

 node --trace_gc app.js

このフラグを使用してNode.jsアプリケーションを既に開始していると仮定します。 アプリケーションをnode-inspectorに接続する前に、実行中のプロセスにSIGUSR1シグナルを送信する必要があります。 クラスターでNode.jsを実行する場合は、必ずいずれかのスレーブプロセスに接続してください。

 kill -SIGUSR1 $pid # Replace $pid with the actual process ID

これにより、Node.jsアプリケーション(正確にはV8)をデバッグモードにします。 このモードでは、アプリケーションはV8デバッグプロトコルを使用してポート5858を自動的に開きます。

次のステップは、実行中のアプリケーションのデバッグインターフェイスに接続し、ポート8080で別のWebインターフェイスを開くnode-inspectorを実行することです。

 $ node-inspector Node Inspector v0.12.2 Visit http://127.0.0.1:8080/?ws=127.0.0.1:8080&port=5858 to start debugging.

アプリケーションが本番環境で実行されていて、ファイアウォールが設定されている場合は、リモートポート8080をローカルホストにトンネリングできます。

 ssh -L 8080:localhost:8080 [email protected]

これで、Chrome Webブラウザーを開いて、リモートの本番アプリケーションに接続されているChrome開発ツールにフルアクセスできます。 残念ながら、Chromeデベロッパーツールは他のブラウザでは機能しません。

漏れを見つけよう!

V8でのメモリリークは、C / C ++アプリケーションからのメモリリークであることがわかっているため、実際のメモリリークではありません。 JavaScriptでは、変数はボイドに消えることはなく、単に「忘れられる」だけです。 私たちの目標は、これらの忘れられた変数を見つけて、ドビーが無料であることを彼らに思い出させることです。

Chromeデベロッパーツール内では、複数のプロファイラーにアクセスできます。 特に、時間の経過とともに複数のヒープスナップショットを実行および取得するレコードヒープ割り当てに関心があります。 これにより、どのオブジェクトがリークしているかを明確に確認できます。

ヒープ割り当ての記録を開始し、ApacheBenchmarkを使用してホームページで50人の同時ユーザーをシミュレートしましょう。

スクリーンショット

 ab -c 50 -n 1000000 -k http://example.com/

新しいスナップショットを作成する前に、V8はマークスイープガベージコレクションを実行するため、スナップショットに古いガベージがないことは間違いありません。

その場でリークを修正する

3分間にわたってヒープ割り当てスナップショットを収集した後、次のようになります。

スクリーンショット

いくつかの巨大な配列、多くのIncomingMessage、ReadableState、ServerResponse、およびDomainオブジェクトもヒープ内にあることがはっきりとわかります。 リークの原因を分析してみましょう。

20秒から40秒までのチャートでヒープ差分を選択すると、プロファイラーを開始してから20秒後に追加されたオブジェクトのみが表示されます。 このようにして、すべての通常のデータを除外できます。

システム内に各タイプのオブジェクトがいくつあるかに注意して、フィルターを20秒から1分に拡張します。 すでにかなり巨大なアレイが成長し続けていることがわかります。 「(配列)」の下には、等距離のオブジェクト「(オブジェクトプロパティ)」がたくさんあることがわかります。 これらのオブジェクトは、メモリリークの原因です。

また、「(closure)」オブジェクトも急速に成長していることがわかります。

文字列も見ると便利かもしれません。 文字列リストの下には、「HiLeakyMaster」というフレーズがたくさんあります。 それらは私たちにもいくつかの手がかりを与えるかもしれません。

私たちの場合、文字列「HiLeakyMaster」は「GET/」ルートでしか組み立てられないことがわかっています。

リテーナパスを開くと、この文字列がreqを介して何らかの形で参照されていることがわかります。コンテキストが作成され、これらすべてがクロージャの巨大な配列に追加されます。

スクリーンショット

したがって、この時点で、ある種の巨大なクロージャの配列があることがわかります。 実際に行って、[ソース]タブですべてのクロージャにリアルタイムで名前を付けましょう。

スクリーンショット

コードの編集が完了したら、CTRL + Sを押して、コードをその場で保存および再コンパイルできます。

次に、別のヒープ割り当てスナップショットを記録して、どのクロージャがメモリを占有しているかを確認しましょう。

SomeKindOfClojure()が私たちの悪役であることは明らかです。 これで、 SomeKindOfClojure()クロージャーが、グローバル空間のタスクという名前の配列に追加されていることがわかります。

この配列が役に立たないことは簡単にわかります。 コメントアウトできます。 しかし、どのようにしてすでに占有されているメモリを解放するのでしょうか? 非常に簡単です。空の配列をタスクに割り当てるだけで、次のリクエストでそれがオーバーライドされ、次のGCイベントの後にメモリが解放されます。

スクリーンショット

ドビーは無料です!

V8でのゴミの寿命

ええと、V8 JSにはメモリリークがなく、変数を忘れただけです。

ええと、V8 JSにはメモリリークがなく、変数を忘れただけです。
つぶやき

V8ヒープは、いくつかの異なるスペースに分割されています。

  • 新しいスペース:このスペースは比較的小さく、1MBから8MBのサイズです。 ほとんどのオブジェクトはここに割り当てられます。
  • 古いポインタスペース:他のオブジェクトへのポインタを持つ可能性のあるオブジェクトがあります。 オブジェクトが新しいスペースで十分長く存続する場合、オブジェクトは古いポインタスペースに昇格します。
  • 古いデータスペース:文字列、ボックス化された数値、ボックス化されていないdoubleの配列などの生データのみが含まれます。 新しいスペースでGCを十分長く生き残ったオブジェクトも、ここに移動されます。
  • 大きなオブジェクトスペース:他のスペースに収まらないほど大きいオブジェクトがこのスペースに作成されます。 各オブジェクトには、メモリ内に独自のmmap領域があります
  • コードスペース:JITコンパイラによって生成されたアセンブリコードが含まれます。
  • セルスペース、プロパティセルスペース、マップスペース:このスペースには、 CellPropertyCell 、およびMapが含まれます。 これは、ガベージコレクションを簡素化するために使用されます。

各スペースはページで構成されています。 ページは、mmapを使用してオペレーティングシステムから割り当てられたメモリの領域です。 大きなオブジェクトスペースのページを除いて、各ページのサイズは常に1MBです。

V8には、Scavenge、Mark-Sweep、Mark-Compactの2つのガベージコレクションメカニズムが組み込まれています。

Scavengeは非常に高速なガベージコレクション手法であり、 NewSpaceのオブジェクトで動作します。 Scavengeは、Cheneyのアルゴリズムの実装です。 アイデアは非常に単純です。新しいスペースは、To-SpaceとFrom-Spaceの2つの等しいセミスペースに分割されます。 スカベンジGCは、To-Spaceがいっぱいになると発生します。 ToスペースとFromスペースを交換し、すべてのライブオブジェクトをTo-Spaceにコピーするか、2つのスカベンジを生き残った場合は、古いスペースの1つにプロモートし、スペースから完全に消去します。 スカベンジは非常に高速ですが、2倍のサイズのヒープを保持し、オブジェクトを常にメモリにコピーするというオーバーヘッドがあります。 スカベンジを使用する理由は、ほとんどのオブジェクトが若くして死ぬためです。

Mark-Sweep&Mark-Compactは、V8で使用される別のタイプのガベージコレクターです。 もう1つの名前はフルガベージコレクターです。 すべてのライブノードにマークを付けてから、すべてのデッドノードをスイープしてメモリをデフラグします。

GCのパフォーマンスとデバッグのヒント

Webアプリケーションの場合、高性能はそれほど大きな問題ではないかもしれませんが、それでもリークを回避する必要があります。 フルGCのマークフェーズ中、ガベージコレクションが完了するまでアプリケーションは実際に一時停止されます。 これは、ヒープ内にあるオブジェクトが多いほど、GCの実行にかかる時間が長くなり、ユーザーが待機する時間が長くなることを意味します。

クロージャと関数には常に名前を付けてください

すべてのクロージャと関数に名前が付いていると、スタックトレースとヒープを調べるのがはるかに簡単になります。

 db.query('GIVE THEM ALL', function GiveThemAllAName(error, data) { ... })

ホットファンクションで大きなオブジェクトを避ける

理想的には、すべてのデータがNew Spaceに収まるように、ホット関数内の大きなオブジェクトを避けたいと考えています。 すべてのCPUおよびメモリバウンド操作はバックグラウンドで実行する必要があります。 また、ホット関数の最適化解除トリガーを回避します。最適化されたホット関数は、最適化されていない関数よりも少ないメモリを使用します。

ホット機能を最適化する必要があります

実行速度は速いがメモリ消費量が少ないホット関数は、GCの実行頻度を減らします。 V8は、最適化されていない機能または最適化されていない機能を見つけるための便利なデバッグツールをいくつか提供します。

ホット機能でのICのポリモーフィズムを回避する

インラインキャッシュ(IC)は、オブジェクトプロパティアクセスobj.keyまたはいくつかの単純な関数をキャッシュすることにより、コードの一部のチャンクの実行を高速化するために使用されます。

 function x(a, b) { return a + b; } x(1, 2); // monomorphic x(1, “string”); // polymorphic, level 2 x(3.14, 1); // polymorphic, level 3

x(a、b)を初めて実行すると、V8は単相ICを作成します。 xをもう一度呼び出すと、V8は古いICを消去し、整数と文字列の両方のタイプのオペランドをサポートする新しいポリモーフィックICを作成します。 3回目にICを呼び出すと、V8は同じ手順を繰り返し、レベル3の別の多形ICを作成します。

ただし、制限があります。 ICレベルが5に達すると( –max_inlining_levelsフラグで変更できます)、関数はメガモーフィックになり、最適化可能とは見なされなくなります。

単相関数が最も速く実行され、メモリフットプリントも小さいことは直感的に理解できます。

大きなファイルをメモリに追加しないでください

これは明白でよく知られています。 大きなCSVファイルなど、処理する大きなファイルがある場合は、ファイル全体をメモリにロードするのではなく、1行ずつ読み取り、小さなチャンクで処理します。 csvの1行が1MBを超える場合がかなりまれであるため、 NewSpaceに収めることができます。

メインサーバースレッドをブロックしないでください

画像のサイズを変更するAPIなど、処理に時間がかかるホットAPIがある場合は、別のスレッドに移動するか、バックグラウンドジョブに変換します。 CPUを集中的に使用する操作は、メインスレッドをブロックし、他のすべての顧客が待機して要求を送信し続けることを強制します。 未処理のリクエストデータはメモリにスタックされるため、完全なGCが完了するまでに長い時間がかかります。

不要なデータを作成しないでください

私はかつてrestifyで奇妙な経験をしました。 無効なURLに数十万のリクエストを送信すると、アプリケーションのメモリは最大100メガバイトで急速に増加し、数秒後に完全なGCが開始されます。これにより、すべてが正常に戻ります。 無効なURLごとに、restifyは長いスタックトレースを含む新しいエラーオブジェクトを生成することがわかりました。 これにより、新しく作成されたオブジェクトは、新しいスペースではなく、大きなオブジェクトスペースに割り当てられました。

このようなデータにアクセスできることは、開発中に非常に役立つ可能性がありますが、本番環境では明らかに必要ありません。 したがって、ルールは単純です。確かに必要でない限り、データを生成しないでください。

あなたのツールを知っている

最後になりますが、確かに重要なことは、ツールを知ることです。 さまざまなデバッガー、リークキャザー、および使用状況グラフジェネレーターがあります。 これらのツールはすべて、ソフトウェアをより高速かつ効率的にするのに役立ちます。

結論

V8のガベージコレクションとコードオプティマイザーがどのように機能するかを理解することは、アプリケーションのパフォーマンスの鍵です。 V8はJavaScriptをネイティブアセンブリにコンパイルし、場合によっては、適切に記述されたコードがGCCコンパイル済みアプリケーションと同等のパフォーマンスを達成する可能性があります。

ご参考までに、私のToptalクライアント用の新しいAPIアプリケーションは、改善の余地はありますが、非常にうまく機能しています。

Joyentは最近、V8の最新バージョンの1つを使用するNode.jsの新しいバージョンをリリースしました。 Node.js v0.12.x用に作成された一部のアプリケーションは、新しいv4.xリリースと互換性がない場合があります。 ただし、新しいバージョンのNode.jsでは、アプリケーションのパフォーマンスとメモリ使用量が大幅に向上します。