【Nuxt.js】EC2環境でJavaScript heap out of memoryを解消するステップ
はじめに
EC2環境でnpm run build
を実施したところ、以下のエラーが発生しました。このビルドエラーに対する原因を特定する方法と解消の過程について記事にしていきます。
<--- JS stacktrace --->
FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory
1: 0xa24ed0 node::Abort() [node]
2: 0x966115 node::FatalError(char const*, char const*) [node]
3: 0xb9acde v8::Utils::ReportOOMFailure(v8::internal::Isolate*, char const*, bool) [node]
4: 0xb9b057 v8::internal::V8::FatalProcessOutOfMemory(v8::internal::Isolate*, char const*, bool) [node]
5: 0xd56ea5 [node]
6: 0xd57a2f [node]
7: 0xd65abb v8::internal::Heap::CollectGarbage(v8::internal::AllocationSpace, v8::internal::GarbageCollectionReason, v8::GCCallbackFlags) [node]
8: 0xd6967c v8::internal::Heap::AllocateRawWithRetryOrFailSlowPath(int, v8::internal::AllocationType, v8::internal::AllocationOrigin, v8::internal::AllocationAlignment) [node]
9: 0xd2ee1d v8::internal::Factory::AllocateRaw(int, v8::internal::AllocationType, v8::internal::AllocationAlignment) [node]
環境
- Nuxt: v2.15.7(ビルドツール: Webpack)
- Node: v16.0.0
- npm: v7.10.0
- Amazon EC2
原因を特定する手段
まずは、原因を特定する手段について色々まとめていきます。
ビルドプロセスを監視する
エラー発生時にメモリ使用量について把握するために、ビルドを開始した後、別のターミナルでfree -h
コマンドを定期的に実行してメモリ使用量を確認します。以下は1秒ごとにメモリ使用状況を更新するコマンドです。
watch -n 1 free -h
解析結果は以下の通りです。
表示 | 内容 |
---|---|
total | 合計メモリ量 |
used | 実際にプロセスで使用されているメモリ使用量 |
free | 未使用のメモリ量 |
shared | 共有メモリで使用しているメモリ量 |
buff/cache | ファイルバッファ+キャッシュメモリに使われているメモリ量 |
available | プロセスが利用できるメモリ量 |
大規模なプロジェクトになることによってファイル量も多くなり、メモリ使用量がさらに増加する傾向にありますが、ビルド時にはメモリ使用量が急激に増えている事から、ビルド中にメモリが不足(Node.jsがJavaScriptのヒープ領域を拡張しようとした際に、メモリが不足してヒープ領域の割り当てに失敗)していることが推測されます。
ビルドプロセス中のログを確認
その他に原因を特定する方法として、ビルドプロセス中のログを確認し、メモリ不足に関連する警告やエラーを確認する方法があります。
npm run build 2>&1 | tee build.log
-
2>&1
:標準エラー出力(ファイルディスクリプタ2)を標準出力(ファイルディスクリプタ1)にリダイレクトするシェル構文です。つまり、エラー出力と通常の出力の両方を同じストリームにまとめます。 - (|)パイプにより
npm run build
の出力(標準出力と標準エラー出力の両方)がtee
コマンドに渡されます。 -
tee
コマンドは、標準入力を読み取り、その内容を標準出力に書き出すと同時に、指定されたファイルbuild.log
にも書き出します。
build.log
にビルド中に出力されるエラーがまとめられます。
ℹ Compiling Server
✔ Server: Compiled successfully in 1.44m
Hash: [1m055eb6c0ea6957e81dd3[39m[22m
Version: webpack [1m4.46.0[39m[22m
Time: [1m147071[39m[22mms
Built at: 2024/05/29 [1m13:20:06[39m[22m
...
ビルド時間の計測
time
コマンドを使用してビルド時間を計測します。
time npm run build
解析結果
real 2m15.678s
user 1m45.123s
sys 0m30.456s
- real: コマンドの実行にかかった実時間(経過時間)を表示します。
- user: プロセスがユーザーモードで消費したCPU時間を表示します。
- sys: プロセスがカーネルモードで消費したCPU時間を表示します。
ログファイルにビルド時間を保存する場合は同じようにtee
コマンドを使用します。
{ time npm run build; } 2>&1 | tee build.log
ストレージの確認
df
コマンドを使用することで、システムのディスク使用量や空き容量に問題がないか確認します。
df -h
解析結果
Filesystem Size Used Avail Use% Mounted on
/dev/sda1 50G 30G 20G 60% /
tmpfs 500M 0 500M 0% /dev/shm
tmpfs 500M 8.0M 492M 2% /run
tmpfs 500M 0 500M 0% /sys/fs/cgroup
- Filesystem: ディスクのファイルシステム名。
- Size: ディスクの総容量。
- Used: 使用中のディスク容量。
- Avail: 使用可能なディスク容量。
- Use%: 使用率(パーセント)。
- Mounted on: マウントポイント(ディスクがシステム上でマウントされている場所)。
ビルド後のファイルサイズを比較する
du
(ディスクの使用量をディレクトリごとに集計して表示するコマンド)を使用して、ビルド後のファイルサイズを確認します。
npm run build
# ビルド後のファイルサイズを確認
du -sh .nuxt/dist/*
du
コマンドの出力結果です。
98M .nuxt/dist
ビルドプロセスの詳細解析
ビルドプロセスのステップごとにメモリ使用量を監視し、どのステップでメモリが最も消費されているかを特定します。node --trace-gc
オプションを使用します。
node --trace-gc $(which npm) run build
解析結果
[50468:0x138008000] 191 ms: Scavenge 7.2 (9.9) -> 6.2 (10.2) MB, 0.5 / 0.0 ms (average mu = 1.000, current mu = 1.000) allocation failure
[50468:0x138008000] 218 ms: Scavenge 8.2 (10.7) -> 7.3 (11.4) MB, 0.5 / 0.0 ms (average mu = 1.000, current mu = 1.000) allocation failure
[50468:0x138008000] 232 ms: Scavenge 8.9 (11.7) -> 7.8 (16.7) MB, 0.6 / 0.0 ms (average mu = 1.000, current mu = 1.000) allocation failure
[50468:0x138008000] 300 ms: Scavenge 12.0 (16.7) -> 9.3 (17.9) MB, 0.4 / 0.0 ms (average mu = 1.000, current mu = 1.000) task
- タイムスタンプ: ログの先頭にあるタイムスタンプは、プログラム開始からの経過時間を示しています(例えば、36 ms)。
- ガベージコレクションのタイプ: Scavenge はガベージコレクションの一種で、特に新世代ヒープに対して行われる軽量なガベージコレクションです。
- ヒープサイズ: (before -> after) の形式で、ガベージコレクション前後のヒープメモリ使用量を示しています。例えば、4.1 (4.4) -> 3.4 (5.4) MB は、ガベージコレクション前に4.1MB使用していたが、4.4MB割り当てようとし、3.4MBが成功し、5.4MBのヒープが確保されていることを示しています。
- ガベージコレクション時間: 0.2 / 0.0 ms のように、ガベージコレクションにかかった時間を示しています。
- allocation failure: メモリの割り当てに失敗したことを示しています。
ただ、メモリ割り当ての失敗(allocation failure)によるメモリ不足は一時的なものであり、ガベージコレクションが定期的に実行され、メモリの回収、即座に再割り当てが実施される為、最終的にはビルドが成功することがあります。
Node.jsのデバッグモードを使用
あまり有効ではないかもしれませんが、--inspect-brk
を使用して、デバッグモードを有効にする手段もあります。
node --inspect-brk $(which npm) run build
// コマンドを実行して以下のログがでたらChromeDevToolを開く準備完了です。
Debugger listening on ws://127.0.0.1:9229/e9aa838e-9711-4f7b-a52d-03cd37ff5f2e
この時、chrome://inspectへアクセスし、「inspect」をクリックすればChromeDevToolが開いてデバッグが可能となります。
対応方針
以下エラーに対する対応方法をまとめていきます。
1. Node.jsのメモリ制限を増やす
ビルド時にNode.jsのメモリ使用量を増やす設定を追加します。
プログラムが実行される時に、メモリは主に、ヒープ領域、スタック領域、静的領域、テキスト領域の4つに分かれます。ヒープ領域は、プログラム実行中に動的にメモリを確保するために使用されます。
node --max-old-space-size=4096 $(which npm) run build
ここではNode.jsのメモリ制限を4G(=4096)に設定しています。状況によって適切なメモリ制限を設定する必要があります。
2. スワップメモリの追加
EC2インスタンスにスワップメモリを追加することでメモリ不足を緩和させます。スワップメモリはストレージ上に作られる「仮想的なメモリ領域」のことを指し、メモリが不足した時に割り当てられる領域です。スワップメモリの追加にはdd
コマンドを使用します。
sudo dd if=/dev/zero of=/swapfile bs=1M count=2048 // スワップ領域のファイル作成
sudo chmod 600 /swapfile // スワップ領域のファイルとして設定
sudo mkswap /swapfile // スワップ領域の有効化(使用開始)
sudo swapon /swapfile // スワップ領域の確認
sudo swapon --show // スワップの状況を確認
このままの場合、インスタンスを再起動した時に、作成したスワップファイルがただのファイルに戻ってしまうため、永続化の設定を行います。
/swapfile swap swap defaults 0 0
スワップ領域を作成後、watch -n 1 free -h
を実行すると、swapが有効化されていることが確認できます。
3. 不要なプロセスを落とす
もしビルド前に動いているプロセスが存在すれば、プロセスを落としてから実行します。
kill <pid>
4. 並列処理を制御する
並列処理を実行していた場合は、その数量を制御します。makefileを作成して、make
コマンドの-j
で並行して実行できるジョブの最大数を指定して実施します。
# Makefile
# デフォルトターゲットを指定
.DEFAULT_GOAL := build
# Node.jsのメモリ制限を設定
NODE_OPTIONS := --max-old-space-size=4096
# npm buildコマンドを実行するターゲット
build:
@echo "Running npm build with Node.js memory limit"
NODE_OPTIONS="$(NODE_OPTIONS)" npm run build
make -j 1
5. 依存関係の確認
package.json
を確認、もしくはnpm ls --prod
を使用してパッケージの確認を行い、不要なパッケージは削除します。
git:(main) npm ls --prod
doon@1.0.0 /Users/takahashi_masaki/Desktop/doon
├── @electron-toolkit/preload@3.0.1
├── @electron-toolkit/utils@3.0.0
├── @formkit/tempo@0.1.1
├── @mdxeditor/editor@3.4.0
├── electron-log@5.1.4
├── electron-reload@2.0.0-alpha.1
├── electron-updater@6.1.8
├── fs-extra@8.1.0
├── jotai@2.8.0
├── react-icons@5.0.1
├── react-markdown@9.0.1
├── rehype-highlight@7.0.0
├── rehype-katex@7.0.0
├── rehype-raw@7.0.0
├── remark-gfm@4.0.0
├── remark-math@6.0.0
└── rxjs@7.8.1
6. Webpackの設定調整
Webpackの設定を見直します。以下のコードはNuxtバージョンが2の場合の書き方になります。バージョンが3の場合はWebpack5を使用して、ビルドの効率化が可能かと思います。
export default {
build: {
// Webpackのキャッシュを有効にする
cache: true,
// メモリ使用量を抑えるために並列ビルドを制限する
parallel: false,
// Chunks処理
extend(config) {
if (config.optimization?.splitChunks) {
config.optimization.splitChunks.chunks = 'all'
}
},
},
}
parallel: true
の場合、ビルドプロセスが複数のスレッドを使って並列実行されるため、ビルド時間が短縮される可能性があり、メモリを多く消費する傾向があります。その為、メモリが限られている環境(例:EC2の低メモリインスタンス)では、メモリ不足によるビルド失敗を防ぐためにparallel: false
に設定します。
splitChunks.splitChunks
でオプションを設定すると、とoptimization.splitChunksから重複した依存関係が削除されます。キャッシュが活用され、全体のファイルサイズが小さくなります。
- splitChunks: アプリケーションのバンドルを分割するために使用されます。重複するコードが複数のバンドルに含まれるのを防ぎ、共通のコードを抽出して一つのバンドルにまとめることが可能となります。
- chunks: 'all' : 初期チャンクと非同期チャンクの両方でコード分割が有効になり、共通モジュールが一つのバンドルにまとめられます。
chunksオプションについて
- all
利点: 初期ロードと遅延ロードの両方で共通のモジュールを分割・抽出する為、最大限のキャッシュ利用が可能。
欠点: ビルド時間が長くなる可能性があり、設定や環境によってはメモリ使用量が増加することもある。
- initial
利点: ページの初期ロードパフォーマンスを向上させる。初期ロードに必要なリソースを最小限に抑える。
欠点: 非同期チャンクには効果がないため、動的に読み込まれる部分の最適化が不十分になる可能性がある。
- async
利点: 動的に読み込まれるリソースの最適化に重点を置く。初期ロードのパフォーマンスには影響を与えない。
欠点: 初期ロードのパフォーマンス最適化には寄与しない。
7. インスタンスのサイズを増やす
最終的な手段として、メモリ不足が頻繁に発生する場合、EC2インスタンスのサイズを大きくすることも検討する必要があります。より多くのRAMを持つインスタンスに変更していきます。ただし、料金が加算される為、まずはビルド最適化など別の方針で対応できないかを確認すると良いでしょう。
最後に
今回はビルドエラーに関する解消方法についてまとめました。
最後までお読みいただきありがとうございました。
アルサーガパートナーズ株式会社のエンジニアによるテックブログです。11月14(木)20時〜 エンジニア向けセミナーを開催!詳細とご応募は👉️ arsaga.jp/news/pressrelease-cheer-up-project-november-20241114/
Discussion