概要
GitHub Actionsでactions/setup-nodeのcache: pnpmを使っていたが、外したら劇的に速くなった。CIの依存キャッシュは定番のベストプラクティスとして推奨されているが、自分のケースではむしろ逆効果だった。そのまとめ。
背景
zudo-paper(このブログのリポジトリ)はpnpmのモノレポで、AstroのブログとDocusaurusのドキュメントサイトが入っている。CIはGitHub Actionsでセルフホステッドランナーを使っている。
ワークフローのactions/setup-nodeではcache: pnpmを指定していた。actions/setup-nodeの公式ドキュメントでも推奨されている設定で、pnpm installのたびにpnpmストアをキャッシュから復元することで高速化を図るもの。
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
遅いと気づいた経緯
Production Deployワークフローの実行時間が約18分かかっていた。pnpm install + ビルドだけにしては長い。
調べてみたら、pnpmストアのキャッシュが4.5GBに膨れ上がっていた。dev依存も含めて全部キャッシュされていて、依存の変更に伴うゴミも蓄積されている。たとえばimg.lyのパッケージにはLLMモデルのようなソースコードが含まれていて、それだけで500MBほど消費していた。プロダクションビルドでは使わないパッケージだが、pnpmストア全体がキャッシュされるので含まれてしまう。こういった依存の整理も大事だが、根本的な問題はキャッシュが大きすぎるということ。4.5GBのキャッシュをGitHub Actionsのキャッシュストレージからリストアするのは、特にセルフホステッドランナーだとGitHubのサーバーからネットワーク越しにダウンロードすることになるので、普通に遅い。
同じパターンは以前にも経験していた。Playwrightのブラウザバイナリ(300MB以上)をキャッシュしようとしたことがある。結果、Microsoftの公式CDNから直接ダウンロードしたほうがキャッシュのリストアより速かった。CDNはブラウザバイナリの配信に最適化されているので、汎用のキャッシュストレージより速いのは当然といえば当然。Playwrightについて言えば、環境的に可能であれば公式のDockerコンテナを使うのがおそらく一番良さそうではある。
AWS Amplifyのビルドでもnpm/pnpmのキャッシュあり・なしを比較してみたことがあった。普段は面倒なのでそこまで気にしないが、Claude Code以降は頼んだ後は眺めてるだけなので試してみたところ、キャッシュなしのほうがわずかに速かった。劇的な差ではないが、一貫してキャッシュなしのほうが速かったことしか、自分にとってはいまのところ起こっていない。
なぜCDNのほうが速いのか
npm/pnpmのレジストリCDNは、パッケージ配信に特化したグローバルCDN。パッケージの配信だけを目的に最適化されている。
一方、GitHub Actionsのキャッシュストレージは汎用のBlobストレージ。特にセルフホステッドランナーの場合、キャッシュはGitHubのサーバーからネットワーク経由でダウンロードされるので、レイテンシが乗る。
CDNアプローチには他にも利点がある。常に必要なものだけをダウンロードするので、古いパッケージや不要なパッケージが混ざらない。キャッシュは時間が経つと使わなくなったパッケージが残り続けて肥大化するが、CDNからのフレッシュインストールにはその問題がない。
CIの変更と結果
zudo-paperのワークフローからcache: pnpmを外した。変更はシンプルで、actions/setup-nodeからcache: pnpmの行を消しただけ。
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
セルフホステッドランナーでのProduction Deployパイプラインの結果。
| Job | Before(キャッシュあり) | After(キャッシュなし) | 差分 |
|---|---|---|---|
| Detect Runner | 3s | 5s | +2s |
| Build | 18m13s | 4m13s | -14m |
| E2E Tests | 7m40s | 5m43s | -1m57s |
| Deploy | 4m33s | 3m19s | -1m14s |
| Notification | 8s | 7s | -1s |
| 合計 | 約31m | 約14m | -17m |
Buildジョブが18m13sから4m13sになった。パイプライン全体で55%の短縮。
E2E TestsとDeployも速くなっている。4.5GBのキャッシュ操作がなくなったことで、セルフホステッドランナーのI/O負荷が下がったためだろう。
どういうケースで効くか
以下のような条件だと、キャッシュを外すことで速くなる可能性が高い。
- セルフホステッドランナー: キャッシュがGitHubのサーバーからネットワーク越しに来る
- 依存が多いモノレポ: キャッシュサイズが大きくなりやすい
- 依存の変更が頻繁なプロジェクト: キャッシュが古くなりやすい
逆に、GitHub-hostedランナーで依存が少ないシンプルなプロジェクトなら、キャッシュの恩恵があるかもしれない。
余談
「CIでは依存をキャッシュすべき」は広く言われているベストプラクティスだが、npm/pnpmのレジストリCDNが十分に速い今、必ずしも正しくない。やったら良さそうということでやりがちだが、時間をよく見れば原因は一目瞭然なので、キャッシュしないことも視野に入れると良さそう。