🚄

Next.js の CI における build cache を高速化した

2024/12/02に公開

対象

  • Next.js を利用したプロジェクト
  • GitHub Actions を利用している
  • CI で build cache (.next/cache をキャッシュ) している

忙しい人のための結論

hashFiles の検索パターンから node_modules を省いておこう!

uses: actions/cache@v4
with:
  path: |
    ~/.npm
    ${{ github.workspace }}/.next/cache
+  key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx', '!node_modules/**/*') }}
-  key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
  restore-keys: |
   ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-

前置き

Next.js プロジェクトの CI をセットアップする場合、当然のように公式ドキュメントを参考に設定することになる
https://nextjs.org/docs/pages/building-your-application/deploying/ci-build-caching#github-actions

uses: actions/cache@v4
with:
  path: |
    ~/.npm
    ${{ github.workspace }}/.next/cache
  key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
  restore-keys: |
    ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-

これはビルドパフォーマンスを向上させるために .next/cache をビルド間で共有するための設定で、キャッシュがヒットした場合に Next.js のビルド時間を短縮することができる。
記述の通り runner の OS、 npm の lock ファイル、 js,ts ファイル から hash を計算してキャッシュキーとしている。

実際に私が担当しているプロジェクトでも同様に設定しており、キャッシュヒット時には30秒前後ビルド時間が早くなっていた。

問題発生

とある日を境に、下記エラーでビルドの CI が確率でコケるようになった...😱

エラーの内容

これは GitHubActions の (js,ts ファイル群が検索対象の) hashFiles の実行時間が上限の120秒を超えてしまい、タイムアウトすることによって CI がコケている。
いやなんでやねん。

調査

よくよく検証すると、そもそもキャッシュがヒットしても actions/cache のステップに 2分弱かかっていた。
ビルドの job 全体から見るとほぼ半分占めていてめちゃくちゃ遅い。(キャッシュしている意味なくね?)

多少のブレはあるが、私が担当しているプロジェクトのビルド CI の時間は以下の通りだった。

ステップ キャッシュなし キャッシュあり
キャッシュ検索 2min 1.5min ~ 2min
ビルド 2.5min 2min
キャッシュ保存 1min 1min
合計時間 5.5min 4.5min ~ 5min

対応方針の検討

hashFiles 自体には120秒タイムアウトをなんとかする(時間を延長する)機能は残念ながらない。
Issue は上がっているが放置気味...

また強いランナーで殴る方法もなくはなかったが、結局プロジェクトが肥大化するにつれて再発するため、根本的な解決にならない。
当初はプロジェクト自体がかなり肥大化しているのが原因だろうとチームで話していて、テストファイルを検索から除くなどをやっていたがあまり効果がなかった...
なんならコケるくらいならキャッシュの意味ほぼないから消すか?みたいな話すら上がっていた。

そこで hashFiles が何をしていてなんで時間がかかってるのかをちゃんと調べることにした。

hashFiles の挙動

https://github.com/actions/runner/blob/main/src/Runner.Worker/Expressions/HashFilesFunction.cs
実装を見に行ったところ、検索パターンを受け取って(複数あればそれを結合し)一致するファイルを探索していた(それはそう)

ログ出力が随所に仕込まれていることがわかったので、 デバッグログを有効にして build job を再実行してみた。

驚愕の事実

するとどうだろうか。
hashFiles の検索にヒットしたファイル約63000の出力がなされ、えげつないスクロールが発生した。

これはその出力のごく一部(pnpm だけど気にしないで)
degublog

これだけのファイルを探索して hash 計算してたらそりゃタイムアウトするなーと思った。(小並感)
ただよくみてみると、出力のほぼ全てが node_modules のファイルだということに気づいた(な、なんだって〜!!)

対応方針の決定

ビルドキャッシュなのでもちろん node_modules の変更は検知しないといけないが、ここで公式の設定をもう一度見てみよう
https://nextjs.org/docs/pages/building-your-application/deploying/ci-build-caching#github-actions

uses: actions/cache@v4
with:
  path: |
    ~/.npm
    ${{ github.workspace }}/.next/cache
  key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
  restore-keys: |
    ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-

そう、package-lock.json を別で hash 計算しているのである。

つまり、 node_modules にある大量の実体を hash 計算しなくとも、 package-lock.json でカバーできている。

以上のことから、hashFiles のファイル検索及びハッシュ計算にブラックホールよりも重い node_modules が対象となっていることがわかり、それをやめるようにすることにした。

というわけで結論

以下の通り、hashFiles の検索から node_modules を無視するようにした。
また、package-lock.json はプロジェクトルートだけでいいのでそれも固定させた。

uses: actions/cache@v4
with:
  path: |
    ~/.npm
    ${{ github.workspace }}/.next/cache
+  key: ${{ runner.os }}-nextjs-${{ hashFiles('package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx', '!node_modules/**/*') }}
+  restore-keys: |
+   ${{ runner.os }}-nextjs-${{ hashFiles('package-lock.json') }}-
-  key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
-  restore-keys: |
-   ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-

なお、node_modules のインストール前にこのキャッシュステップを設定することでも回避できる。

調整した結果

結果としてビルドキャッシュによるビルド時間を短縮する恩恵を受けながら、キャッシュ自体の時間はおおよそ 2min 短縮できた👏

ステップ Before After diff
キャッシュ検索 2min 30s -90s
キャッシュ保存 1min 30s -30s
合計 3min 1min -2min

公式の設定通りだと気づかずに遅くなっている可能性があるので、確認されてみてはいかがでしょう?

DeNA Engineers

Discussion