Next.js の CI における build cache を高速化した
対象
- 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 をセットアップする場合、当然のように公式ドキュメントを参考に設定することになる
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
の挙動
実装を見に行ったところ、検索パターンを受け取って(複数あればそれを結合し)一致するファイルを探索していた(それはそう)
ログ出力が随所に仕込まれていることがわかったので、 デバッグログを有効にして build job を再実行してみた。
驚愕の事実
するとどうだろうか。
hashFiles
の検索にヒットしたファイル約63000の出力がなされ、えげつないスクロールが発生した。
これはその出力のごく一部(pnpm だけど気にしないで)
これだけのファイルを探索して hash 計算してたらそりゃタイムアウトするなーと思った。(小並感)
ただよくみてみると、出力のほぼ全てが node_modules
のファイルだということに気づいた(な、なんだって〜!!)
対応方針の決定
ビルドキャッシュなのでもちろん 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') }}
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 |
公式の設定通りだと気づかずに遅くなっている可能性があるので、確認されてみてはいかがでしょう?
Discussion