はじめに
こんにちは、情報システム部の呉です。
あっという間に、2023年の終わりが近づいてきました。今年はLinkers Sourcing / Marketing (LS/LM)ではシステムの様々な内部改善を行いました。
その一環としてwebpackのパフォーマンスを改善しましたので、今回は共有させて頂きたいと思います。
問題の発覚
きっかけは今年行ったCoffeeScriptの撤廃作業でした。撤廃作業はあくまで内部改善なので、並行している他の開発タスクと競合しないように、ファイルもしくは機能毎に分割し、サイドミッションの形で段階的に進めていました。
作業はほぼ問題なくスムーズにできていましたが、進めるに従って「あれ?今日のデプロイが重くなってない?」という声は自分からもメンバーからも、しばしば上がってきました。
過去のデプロイ履歴を改めて確認しますと、
ご覧の通り、CoffeeScript移行作業中(赤枠内)だけではなく、実はその前にすでにデプロイ時間がだんだん長くなっていく傾向でした。
更に、撤廃作業がすべて終わってしばらく経ちましたら、検証サーバー(スペックが本番サーバーより低い)へデプロイする際、JavaScript heap out of memory
というエラーが起きてwebpackがビルド途中で落ちたり、 サーバーOOM(Out of Memory)までも頻発するようになっていました。
datadogのメモリ監視でデプロイ時のメモリ利用量を確認してみたら、webpackが非常にメモリを食っていることが分かりました。
webpackの--progress
CLIオプションを指定するとビルドの進捗とプロファイリング情報が出力されますので、調査には非常に助かりました。
webpack --progress profile
一回戦: Nodeのメモリ制限を調整する
Nodeはデフォルトでプロセスのメモリ利用量を制限しています。--max-old-space-sizeというオプションで調整することが可能です。デフォルト値はドキュメントに明確に記載されず、実行環境によって動的に決められます1。webpackがそれ以上のメモリを利用するとJavaScript heap out of memory
エラーが起きてしまいます。
弊社の一つの検証サーバー(Amazon Linux 2023|8GBメモリ|Node.js v20.5.1)ではデフォルト値が約2GBになっていることが確認できました。2
> require('v8').getHeapStatistics().heap_size_limit / 1024 / 1024 2096
いくつかの実験をした結果、サーバーメモリを8GBに増設 + webpackのheapサイズを4GBに設定 + RollbarSourceMapPluginを一時的に無効化することにしました。
NODE_OPTIONS=--max-old-space-size=4096 webpack
max-old-space-size | RollbarSourceMapPlugin | デプロイ結果 | サーバーメモリ消耗ピーク(Total: 8GB) |
---|---|---|---|
2048(default) | 有効 | X 失敗 | 4.64GB(58%) |
2048(default) | 無効 | O 成功 | 4.16GB(52%) |
4096 | 有効 | X 失敗 | 7.04GB(88%) |
4096 | 無効 | O 成功 | 4.16GB(52%) |
6144 | 有効 | O 成功 | 7.04GB(88%) |
(当時の実験データ。メモリ消耗は、すでに稼働中のサービスも含まれていますので厳密なものではありません)
これで一旦安定しましたが、デプロイ時間は改善されていないままですし、そもそもwebpackが数GBのメモリを食うのが非常におかしいと思い、webpackの設定周りの調査を始めました。
二回戦: webpack設定をいじる
この時点ではwebpackのビルド時間は15分以上かかっていて、バンドルサイズも60MB以上になっています。LS/LMのコードベースの規模に合わない大きい数値でした。
$ webpack ... assets by path js/ 62 MiB ... webpack 5.88.1 compiled with 3 warnings in 973133 ms
バンドルファイルを分析する
この60MB以上のバンドルには一体どんなものが入っているかを確認するために、webpack-bundle-analyzerを使ってバンドルファイルを分析してみました。
実際の結果は省略しますが、イメージは以下の感じでした。(ファイル数はこれの30倍を想像してみてください。。。)
共通に使われるはずのライブラリは、すべてのファイルに重複して含まれていますね。
LS/LMのウェブサイトでは、コントローラー毎にJSファイルをロードしているため大量のエントリーポイントがあります。このような重複はビルドパフォーマンスに大きい影響があるはずです。
また、出力ファイル名のcontenthash
は、ソースファイルの内容により計算されます。一つのライブラリのアップデートで全てのバンドルファイルのcontenthash
が変わりますので、ブラウザキャッシュの利用率が良くありません。
これは絶対チューニングが必要ですね。
SplitChunksを設定する
SplitChunksPluginは、webpackが提供しているコード分割の仕組みです。適当に設定することで複数の箇所に使われているファイルやライブラリが共通バンドルファイルとして出力されます。
SplitChunksの詳細についてドキュメントをご参照ください。
複雑な設定で細かくチューニングすることも可能ですが、簡単に3つのグループに分割してみました。
module.exports = { optimization: { splitChunks: { chunks: 'all', cacheGroups: { // vue系のモジュール vueVendors: { test: /[\\/]node_modules[\\/](@vue|vue|vue-i18n)[\\/]/, name: 'vendors_vue', priority: -5, }, // element-plusモジュール elementPlusVendors: { test: /[\\/]node_modules[\\/](element-plus)[\\/]/, name: 'vendors_element_plus', priority: -10, }, // 他のnode_modulesモジュール commonVendors: { test: /[\\/]node_modules[\\/]/, name: 'vendors_common', priority: -20, }, }, }, }, };
chunksの設定について、all
, async
, initial
の3つの値が設定できます。
webpackのv5アップグレードガイドの推奨に従って、一番強力なall
に設定しました。
- It's recommended to use either the defaults or
optimization.splitChunks: { chunks: 'all' }
.
これで、vue系のモジュール、ElementPlus、残りのnode_modulesモジュールが各エントリーポイントのバンドルファイルから切り出されて、個別のファイルに出力されます。
vendors_common.[contenthash].js
vendors_element_plus.[contenthash].js
vendors_vue.[contenthash].js
分割された出力ファイルをHTMLファイルにscriptタグでロードすることが必要なのでお忘れなく!
上記の設定を適用した後再度バンドルを分析しました。
node_modulesの重複が解消されました!今度デプロイしてみますと、
$ webpack ... assets by path js/ 7.42 MiB ... webpack 5.88.2 compiled with 3 warnings in 140172 ms
バンドルサイズもビルド時間もだいぶ改善されましたね。良かったです!
これでライブラリのアップデートをしてもアプリ側のバンドルファイルに影響がありませんし、アプリ側のJSコード修正によりnode_modulesのバンドルファイルも変わりませんので、クライアント側のブラウザキャッシュもより効くようになります。
しばらく観察してみたら、デプロイ時間は結構落ち着くようになりました :tada:
おわりに
LS/LMのコードベースは、長年の歴史があります。
昔はほとんどのJSコードはCoffeeScriptで実装されて、Railsのアセットパイプラインに依存していましたが、Railsのバージョンアップと技術スタックの進化に従って、モダンなJSエコシステムに徐々に移行してきました。それに伴って、元々は適切だったwebpackの設定も徐々に陳腐化していきました。
CoffeeScriptの撤廃により大量のバンドル対象が追加されて、webpack設定の問題はついに表面に出てきました。
今回はデプロイ時間を改善する目的で、node_modulesを中心にしてSplitChunksを設定してみましたが、SplitChunksは結構強力な機能ですので、またアプリ側のコードに向けてSplitChunks設定をチューニングして画面パフォーマンス向上を試してみたいと思っております。
皆さん、いかがでしたでしょうか。特に複数のエントリーポイントが設定されている場合、SplitChunksを適当に設定することでパフォーマンスが改善する可能性があると思いますので、ぜひ検討してみてください!