🦬

【Nuxt.js】EC2環境でJavaScript heap out of memoryを解消するステップ

2024/05/31に公開

はじめに

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のヒープ領域を拡張しようとした際に、メモリが不足してヒープ領域の割り当てに失敗)していることが推測されます。

https://qiita.com/yasushi-jp/items/5553ec20dc3a9dbab1f7



ビルドプロセス中のログを確認

その他に原因を特定する方法として、ビルドプロセス中のログを確認し、メモリ不足に関連する警告やエラーを確認する方法があります。

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

https://atmarkit.itmedia.co.jp/ait/articles/1610/25/news016.html



ビルドプロセスの詳細解析

ビルドプロセスのステップごとにメモリ使用量を監視し、どのステップでメモリが最も消費されているかを特定します。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: メモリの割り当てに失敗したことを示しています。

https://nodejs.org/en/learn/diagnostics/memory/using-gc-traces

ただ、メモリ割り当ての失敗(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が開いてデバッグが可能となります。

https://qiita.com/nju33/items/9c5fd326bd7a58886af8



対応方針

以下エラーに対する対応方法をまとめていきます。



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が有効化されていることが確認できます。

https://www.ibm.com/docs/ja/aix/7.1?topic=d-dd-command

https://qiita.com/messhii222/items/4b8549ba7f1ee90a0f52

https://www7390uo.sakura.ne.jp/wordpress/archives/1349



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

https://www.ibm.com/docs/ja/aix/7.2?topic=command-using-make-in-parallel-run-mode



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

https://docs.npmjs.com/cli/v8/commands/npm-ls



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に設定します。

https://webpack.js.org/guides/code-splitting/

https://github.com/nuxt/nuxt/issues/5131

splitChunks.splitChunksでオプションを設定すると、とoptimization.splitChunksから重複した依存関係が削除されます。キャッシュが活用され、全体のファイルサイズが小さくなります。

  • splitChunks: アプリケーションのバンドルを分割するために使用されます。重複するコードが複数のバンドルに含まれるのを防ぎ、共通のコードを抽出して一つのバンドルにまとめることが可能となります。
  • chunks: 'all' : 初期チャンクと非同期チャンクの両方でコード分割が有効になり、共通モジュールが一つのバンドルにまとめられます。

chunksオプションについて

- all
利点: 初期ロードと遅延ロードの両方で共通のモジュールを分割・抽出する為、最大限のキャッシュ利用が可能。
欠点: ビルド時間が長くなる可能性があり、設定や環境によってはメモリ使用量が増加することもある。

- initial
利点: ページの初期ロードパフォーマンスを向上させる。初期ロードに必要なリソースを最小限に抑える。
欠点: 非同期チャンクには効果がないため、動的に読み込まれる部分の最適化が不十分になる可能性がある。

- async
利点: 動的に読み込まれるリソースの最適化に重点を置く。初期ロードのパフォーマンスには影響を与えない。
欠点: 初期ロードのパフォーマンス最適化には寄与しない。

https://zenn.dev/msy/articles/672488b7ed28a9

https://qiita.com/soarflat/items/1b5aa7163c087a91877d



7. インスタンスのサイズを増やす

最終的な手段として、メモリ不足が頻繁に発生する場合、EC2インスタンスのサイズを大きくすることも検討する必要があります。より多くのRAMを持つインスタンスに変更していきます。ただし、料金が加算される為、まずはビルド最適化など別の方針で対応できないかを確認すると良いでしょう。



最後に

今回はビルドエラーに関する解消方法についてまとめました。
最後までお読みいただきありがとうございました。


Arsaga Developers Blog

Discussion