Linkers Tech Blog

リンカーズ株式会社の開発者ブログです。

webpackのSplitChunksでビルドを3倍速に

はじめに

こんにちは、情報システム部の呉です。

あっという間に、2023年の終わりが近づいてきました。今年はLinkers Sourcing / Marketing (LS/LM)ではシステムの様々な内部改善を行いました。

その一環としてwebpackのパフォーマンスを改善しましたので、今回は共有させて頂きたいと思います。

問題の発覚

きっかけは今年行ったCoffeeScriptの撤廃作業でした。撤廃作業はあくまで内部改善なので、並行している他の開発タスクと競合しないように、ファイルもしくは機能毎に分割し、サイドミッションの形で段階的に進めていました。

作業はほぼ問題なくスムーズにできていましたが、進めるに従って「あれ?今日のデプロイが重くなってない?」という声は自分からもメンバーからも、しばしば上がってきました。

過去のデプロイ履歴を改めて確認しますと、

本番環境のデプロイ時間推移。一時的にデプロイ失敗の谷がありますが、本件とは無関係なので無視してください

ご覧の通り、CoffeeScript移行作業中(赤枠内)だけではなく、実はその前にすでにデプロイ時間がだんだん長くなっていく傾向でした。

更に、撤廃作業がすべて終わってしばらく経ちましたら、検証サーバー(スペックが本番サーバーより低い)へデプロイする際、JavaScript heap out of memoryというエラーが起きてwebpackがビルド途中で落ちたり、 サーバーOOM(Out of Memory)までも頻発するようになっていました。

datadogのメモリ監視でデプロイ時のメモリ利用量を確認してみたら、webpackが非常にメモリを食っていることが分かりました。

datadogのサーバーメモリ監視

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の設定について、allasyncinitialの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を適当に設定することでパフォーマンスが改善する可能性があると思いますので、ぜひ検討してみてください!

参考