メモリとはなんぞや ~ Node.js のメモリ管理を知る ~
こんにちは。株式会社ラキールで DX 基盤開発を行う LaKeel DX Engine Group に所属する星と申します。
普段は LaKeel Synergy Logic という自社 API Gateway を開発しています。
社内ウェビナー(ウェブセミナー)で発表した Node.js のメモリ管理について、その内容の一部を公開します。
メモリ
メモリとは
メモリ(RAM, Random Access Memory)とはコンピュータのデータを一時的に記録するためのパーツで、日本語では主記憶装置と言われたりします。
メモリは主に作業机の広さなどに例えられたりします。
(机が広いと様々な道具を一度に使える≒ブラウザのタブをいっぱい開きながら画像編集がサクサク実行できる)
プログラミングの文脈でいえば、メモリは実行するプログラムそのものやデータ(変数など)を保持する場所になります。
メモリライフサイクル
メモリのライフサイクルは、作業机の区画を確保 → 道具を使う→ 使い終わったら片付ける、のようなライフサイクルになっています。
実際にプログラミングでメモリを割り当て・解放したことがある方は少ないのではないかと思います。
ガベージコレクション
では、実際に誰がプログラムでメモリを解放しているのかというと、ガベージコレクションが行っています。
(※プログラミング言語によってはガベージコレクションが存在しなかったり、有効でなかったりします)
Node.js では JavaScript Engine (JavaScript を機械語に翻訳して実行するランタイム) として V8 を利用しているため、 V8 のガベージコレクションが使用されます。
ガベージコレクションが動くと、使えるメモリ量が増えます。
これにより、プログラマーはメモリを意識せずに実装することができます。
(※実際には後述するような意識しないといけない問題は多々ありますが、目をつむります)
ただし、ガベージコレクションの実行はアプリケーションのパフォーマンスを低下させます。
通常は我慢できる範囲でのパフォーマンス低下しか起こりませんが、最悪の場合、アプリケーションが一時停止することもありえます。
ガベージコレクションでアプリケーションが頻繁に一時停止させられた場合、アプリケーションのパフォーマンスに悪影響が生じます。
Node.js のメモリ管理
メモリ使用量の種類
Node.js では process.memoryUsage(); でメモリ使用量を確認することができます。
ざっくり言うと、heapUsed がオブジェクト系のメモリ使用量、 arrayBuffers がバッファ系(例:メモリ上にのせたファイルなど)のメモリ使用量です。
スタックとヒープ
V8 のメモリ管理にはスタック領域とヒープ領域があります。
スタック領域には一部のプリミティブなローカル変数やポインタ、関数フレームといった、コンパイル時にサイズが確定するような有限で静的なデータを保存します。
スタックは LIFO の単純な構造のため、メモリ管理は OS によって行われます。
ヒープ領域にはスタック領域に保存するもの以外(オブジェクト、関数など)の動的なデータが保存されます。
V8 のガベージコレクションはヒープ領域の解放を行っています。
V8 のヒープ構成と世代別 GC
V8 のガベージコレクション(以下 GC とします)は 世代別 GC という手法が使われています。
世代別 GC とは、多くのオブジェクトは若くして死ぬ(多くの新しいオブジェクトは古いオブジェクトよりも先に使用されなくなる)という「世代別仮説」に基づく GC の手法です。
V8 のヒープ構成には、新しいオブジェクトが配置される New Space と、ある期間生き残った(ある期間以上使用され続けている)オブジェクトが配置される Old Space が存在します。
New Space の GC にはコピー GC という高速ですがヒープ容量の半分しか使えない GC の手法が使われています。
Old Space の GC にはマークスイープ GC 及び マークコンパクト GC という低速ですが全てのヒープ容量が使用できる GC の手法が使われています。
New Space で高速な GC を何度か行ったのち、生き残ったオブジェクトだけを Old Space に移動させることで、高速かつヒープ領域を効率的に使う GC を可能にしています。
メモリに起因する問題とその対応
メモリリーク
メモリリークとはメモリ領域に不要なデータが残り続けてしまうことです。
たとえると、作業机がゴミで散らかりっぱなしになってしまっている状態です。
メモリリークが発生すると、時間経過とともにメモリ使用量が徐々に増加していき、以下の問題が起こる可能性があります。
アプリケーションの速度低下・レイテンシの増大
アプリケーションのクラッシュ
メモリ利用量が必要以上に増加し、クラウドの利用コストの増大
メモリリークはグローバル変数に関連して発生しやすいです。
グローバル変数は GC で解放されないためです。
グローバル変数は使用しないか、必要最小限度の使用に留める必要があります。
配列など値を追加できるオブジェクトには特に注意が必要です。
メモリリークの原因の調査には 3点ヒープダンプ法 (The Three Snapshot Technique) が使用されます。
Heap Snapshot(Heap Dump とも) は到達可能な (まだ使用されている) JavaScript オブジェクトのスナップショットです。
Heap Snapshot を通じて、ヒープ領域で使用中の JavaScript オブジェクトを確認することができます。
Heap Snapshot は Visual Studio Code や Google Chrome の開発者ツールなどのインスペクタクライアントから取得することが出来ます。
3点ヒープダンプ法は Heap Snapshot を3回取得することで、メモリリークの原因となるオブジェクトを特定する方法です。
3点ヒープダンプ法の実行手順は以下になります。
Node.js のアプリケーションを起動させる
Heap Snapshot #1 を取得する
メモリリークが疑われるコードを実行させる(例: API をリクエストする)
Heap Snapshot #2 を取得する
手順3 と同様に、メモリリークが疑われるコードを実行させる
Heap Snapshot #3 を取得する
Heap Snapshot #1 はアプリケーションで継続して使用されるオブジェクトを含むスナップショットになります。
Heap Snapshot #2 は、メモリリークが疑われるコードの実行後に取得されるため、メモリリークしているオブジェクトを含むスナップショットになります。
Heap Snapshot #3 は、Heap Snapshot #2 の取得後に不要になったオブジェクトは GC によって解放されるため、まだ使用されているオブジェクトのみのスナップショットになります。
そのため、Heap Snapshot #1 と #2 の間で割り当てられ、#3で残っている(最後まで GC で解放されない) オブジェクトがメモリリークの原因となるオブジェクトだとわかります。
Heap Snapshot の差分表示は Google Chrome の開発者ツールで行うことが出来ます。
メモリリークの検知ツール OSS として memlab が存在します。
memlab も3点ヒープダンプ法を利用しているようです。
詳しくは以下の memlab のドキュメントをご確認下さい。
OOM(Out Of Memory) エラー
GC は New/Old Space のメモリ使用量が閾値以上になった場合、メモリ解放処理を実行してメモリ使用量を減少させます。
また、前述したように長く使用されるオブジェクトは New Space から Old Space に移動されます。
そのため、解放されるオブジェクトよりも使用され続けるオブジェクトのほうが多い場合、 Old Space のメモリ使用量が増加していきます。
このとき、使用され続けるオブジェクトのメモリ使用量が Old Space の最大容量を超過すると OOM(Out Of Memory) エラーが発生します。
また、物理メモリ(スワップを含む)が不足した場合などにも OOM エラーは発生します。
OOM エラーには以下の原因が考えられます。
メモリリークが発生している
3点ヒープダンプ法などでメモリリークがないか確認する
メモリに巨大なデータをのせてしまっている
Stream などメモリに一度に全てをのせない処理への変更を検討する
上記を改善しても、アプリケーションが求めるメモリ量よりヒープ領域が小さい場合があります。
上述した Old Space の最大容量は Node.js が起動時に自動的に決定しますが、 Node.js の起動オプション ---max-old-space-size で使用することで指定することが出来ます。
指定すべきメモリ量はアプリケーションが求めるメモリ量を実際に測定し算出する方法と、物理メモリ量から算出する方法があります。
Node.js 以外のサービスへの考慮とスワップを避けるため、指定するメモリ量はシステム(コンピュータ)が使用できるメモリ量よりも少なくする必要があります。
2GiB の物理メモリの場合、物理メモリ量から算出する方法では 1.5GiB ほどが良いとされているようです。
V8 のオプションでは他に ---max-semi-space-size があり、 New Space 全体の最大容量が ---max-semi-space-size の 3倍になります。
New Space の GC がよく動くようなアプリケーションでは New Space の最大容量を増加させると、メモリ使用量が上がる代わりにスループットが上がる傾向があります。
詳細は以下の Node.js のドキュメントをご確認下さい。
その他の V8 のオプションは node --v8-options コマンドで確認することが出来ます。
おわりに
以上、メモリ及び Node.js のメモリ管理についてでした。
メモリ管理はプログラミング言語が行ってくれることが多いため、これまであまり意識してきませんでした。
社内ウェビナーというきっかけで、ガベージコレクションの奥深い世界を垣間見ることが出来ました。
社会を裏で支えているガベージコレクションおよび開発者に感謝します。