Next.js 8 の webpack メモリ改善
投稿日: 2019-02-19
投稿者: Connor Davis (@connordav_is), Tim Neutkens (@timneutkens)
最近、Next.js 8 がリリースされました。このリリースにはビルド時のメモリ使用量を大幅に削減する改善が含まれており、本記事ではコミュニティのために我々がどのように webpack を最適化したかを解説します。
Next.js はゼロコンフィグを目指し、webpack や Babel のようなツールの上に構築されています。目的は重要なこと、つまりアプリケーションのコードに集中できるようにすることです。
モダンなウェブアプリケーションは、ホームページ、ブログ、ダッシュボード、商品一覧など、1ページまたは複数ページで構成されます。Next.js ではこれらのページがプロジェクトルートの特殊な pages ディレクトリ内のファイルになります。例えば pages/about.js は URL /about にマップされます。
フレームワーク設計上の重要な制約の1つは、単一ページから数千ページまでどちらにも適切に動作する必要があることです。
問題の発見
Serverless Next.js を実装する過程で、何百ページもあるプロジェクトで next build を実行するとメモリ使用量が高くなり、Node.js のヒープ制限(約 1.4 GB)を超えることがあることに気づきました。
ビルドプロセスのメモリ使用量を Chrome Developer Tools でプロファイリングしたところ、webpack が一度に 548 MB のメモリを確保するポイントを発見しました。確保されるメモリ量はページ数と相関しており、ページが多いほどメモリ使用量が増えていました。
プロファイルのスタックトレースを辿ることで、メモリ割り当てのスパイクを引き起こしている関数を特定しました。割り当ては source.source() メソッドが呼ばれることに由来しており、このメソッドは生成されたファイルをメモリに格納します。
さらに上の呼び出し元を見ると、compilation.assets を asyncLib.forEach で反復しており、提供された関数が compilation.assets 配列内のすべてのファイルに対して同時に呼ばれていることが分かりました。つまり、例えば 100 ページある場合、ディスクに書き出すために 100 個のファイルを同時に生成・書き出そうとしてしまっていました。
同時書き込み数の制限
この問題の解決策は、セマフォを使って同時書き込みの数を制限することです。通常は async-sema を使いますが、このケースでは webpack が既に利用している neo-async の asyncLib.forEach に適切なメソッドがありました。
従来の同時実行(すべてのアセットで関数を同時に実行):
asyncLib.forEach(compilation.assets, (source, file, callback) => {
});
同時実行数を制限したコード(最大 15 並列):
asyncLib.forEachLimit(compilation.assets, 15, (source, file, callback) => {
});
この同時実行制限を導入して再プロファイリングしたところ、メモリ割り当てが一度に 34 MB 程度ずつ小分けにされるようになりました。
キャッシュされたアセットとガベージコレクション
ただし、この変更だけでは実運用でビルドがまだメモリ不足になることがあり、さらなる調査を続けました。メモリプロファイルを詳しく見ると、source.source() を呼んだ後にメモリが解放(ガベージコレクション)されていないことが分かりました。
webpack のアセットは通常 Source クラスのインスタンスで、これらは source() メソッドを実装してファイルソースを生成します。プロファイルでは多くのアセットが CachedSource のインスタンスであることが示されていました。CachedSource は source() が呼ばれると結果をメモリにキャッシュし、アセットが dispose されるまで保持します。
Next.js が使っている webpack プラグインを調べたところ、webpack がファイルを書き出した後に source() を呼ぶプラグインは存在せず、書き出した値をキャッシュすることに利点がないことが分かりました。
output.futureEmitAssets の導入
Tobias Koppers と協力した結果、output.futureEmitAssets という新しいオプションが実装され、 새로운アセット書き出しの挙動を opt-in できるようになりました。この新しい挙動により、時間経過で割り当てられるチャンクサイズは 182 KB 程度まで減少しました。
最終的な最適化後のプロファイラでは、時間経過で 184 KB 程度のチャンクが割り当てられているのが確認できます。
結果と影響
Next.js 8 にはこれらの最適化がすでに組み込まれています。Next.js を使用する際に何かを変更する必要はありません。この最適化は webpack 側で導入されたため、Next.js ユーザーだけでなくすべての webpack ユーザーが恩恵を受けます。
今後も Next.js と webpack のメモリ使用量とパフォーマンスの改善を継続して行っていきます。