GitHub Actions の actions/cache は、CI高速化の1つの手段ですが、素朴に使うと意外とハマりどころが多い機能でもあります。
- キャッシュが更新されない
- ブランチを切ると急に遅くなる
- いつのキャッシュを使っているのかわからない
この記事では、実際に運用している複数のWorkflow構成を例に、GitHub Actions のキャッシュ制約を踏まえた、壊れにくい設計について整理します。
想定読者
- GitHub Actions を日常的に使っている
-
actions/cacheを使っているが、挙動にモヤっとしたことがある - CI の安定性・再現性を重視したい
今回キャッシュしている対象
今回の例では、コードフォーマット用の CI において以下をキャッシュしています。
- PHP-CS-Fixer のキャッシュ (
.php-cs-fixer.cache) - Composer dependencies
ポイントは、
- 依存ライブラリのキャッシュ
- ツール実行結果のキャッシュ
は性質が異なり、特に後者は 更新されないとパフォーマンス劣化が顕著 になる点です。
GitHub Actions キャッシュの制約
まず押さえておくべき制約です。
キャッシュは上書きできない
- 同じキーのキャッシュは一度作成されると、その後のジョブでは上書きできません
restore されたキャッシュは、そのジョブ中ずっと「存在する」
- キャッシュが restore された時点で、GitHub Actions は「このジョブではそのキャッシュが存在する」と判断します
- そのため、ジョブの途中でキャッシュを削除しても、その実行中にキャッシュを「更新」する処理は行われません
- 結果として、キャッシュが存在しない状態のままジョブが終了する可能性があります
ブランチ間でキャッシュは自動共有されない
- GitHub Actions のキャッシュはブランチ単位で分離されており、feature ブランチどうしでキャッシュを共有することはできません
- feature ブランチでは master ブランチのキャッシュを
restore-keys経由で参照することはできますが、そのキャッシュを上書き・更新することはできません
これらの制約を前提に設計する必要があります。
feature ブランチ用 Workflow の設計
まずは、通常の feature ブランチで動かす Workflow です。
キャッシュキーに run_id を含める
key: ${{ runner.os }}-php-cs-fixer-${{ github.ref }}-${{ github.run_id }}
restore-keys: |
${{ runner.os }}-php-cs-fixer-${{ github.ref }}-
${{ runner.os }}-php-cs-fixer-
この設計の狙い
- 毎回必ず新しいキャッシュを作る
- restore では過去のキャッシュを拾う
- 「キャッシュが更新されない問題」を回避
キャッシュを「更新する」のではなく 「常に作り直す」設計です。
concurrency で重複実行を防ぐ
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
この設定には、キャッシュを守るという意味があります。
- 同一ブランチで複数 CI が同時に走らないようにする
- 途中キャンセルによって「古いキャッシュを消したが、新しいキャッシュが作られない」という キャッシュ空白期間 を作らない
特に、
- キャッシュを「毎回作り直す」
- 古いキャッシュを明示的に削除する
という運用では、並列実行を許すと事故りやすいため、この制御はあったほうが良いです。
キャッシュ削除はジョブの最後に行う
- キャンセル時の事故を防ぐため
「キャッシュが存在しない状態」をなるべく作らないために、最後に古いキャッシュ削除 → 即座に新しいキャッシュ作成という順序にしています。
feature ブランチではキャッシュ共有をしない
GitHub Actions の標準キャッシュでは、feature ブランチどうしでキャッシュを共有することはできません。
これを無理に実現しようとすると、外部ストレージの導入や排他制御が必要になり、構成が一気に複雑になります。
そのため、キャッシュの起点は master ブランチに集約し、feature ブランチでは master のキャッシュを restore できれば十分と割り切ります。
全体像
以下に、featureブランチ向けのyamlの全体を貼ります
name: Code format
on:
push:
branches-ignore:
- master
paths:
- '**/*.php'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
code-format:
runs-on: ubuntu-latest
permissions: write-all
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Cache PHP CS Fixer cache
uses: actions/cache@v4
with:
path: .php-cs-fixer.cache
key: ${{ runner.os }}-php-cs-fixer-${{ github.ref }}-${{ github.run_id }}
restore-keys: |
${{ runner.os }}-php-cs-fixer-${{ github.ref }}-
${{ runner.os }}-php-cs-fixer-
- name: Cache composer dependencies
uses: actions/cache@v4
with:
path: vendor
key: ${{ runner.os }}-php-fmt-${{ hashFiles('composer.lock') }}
restore-keys: |
${{ runner.os }}-php-fmt-
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.5'
- name: Install dependencies
run: composer install --prefer-dist --no-progress --no-scripts
- name: Run PHP CS Fixer
run: ...
continue-on-error: true
- name: Remove old caches
run: |
readarray -t caches < <(gh cache list --ref ${{ github.event.ref }} --json id,key --jq '.[] | select(.key | startswith("${{ runner.os }}-php-cs-fixer-${{ github.ref }}")) | .id')
for cache in "${caches[@]}"; do
echo "Deleting PHP CS Fixer cache: $cache"
gh cache delete "$cache" || true
done
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
master ブランチ専用 Workflow の役割
なぜ master 専用のキャッシュ生成 Workflow を作るのか
GitHub Actions では、master ブランチで作られたキャッシュは feature ブランチから参照可能という性質があります。
そのため、
- master = キャッシュの「供給源」
- feature = キャッシュの「消費者」
という役割分担にしています。
全体像
以下に、master ブランチ専用 Workflow のyamlの全体を貼ります
name: Master cache for code formatter
on:
push:
branches: [master]
paths:
- '**/*.php'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
code-format-master-cache:
runs-on: ubuntu-latest
permissions: write-all
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Cache PHP CS Fixer cache
uses: actions/cache@v4
with:
path: .php-cs-fixer.cache
key: ${{ runner.os }}-php-cs-fixer-${{ github.ref }}-${{ github.run_id }}
restore-keys: |
${{ runner.os }}-php-cs-fixer-${{ github.ref }}-
${{ runner.os }}-php-cs-fixer-
- name: Cache composer dependencies
uses: actions/cache@v4
with:
path: vendor
key: ${{ runner.os }}-php-fmt-${{ hashFiles('composer.lock') }}
restore-keys: |
${{ runner.os }}-php-fmt-
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.5'
- name: Install dependencies
run: composer install --prefer-dist --no-progress --no-scripts
- name: Run PHP CS Fixer
run: ...
continue-on-error: true
- name: Remove old caches
run: |
readarray -t caches < <(gh cache list --ref ${{ github.event.ref }} --json id,key --jq '.[] | select(.key | startswith("${{ runner.os }}-php-cs-fixer-${{ github.ref }}")) | .id')
for cache in "${caches[@]}"; do
echo "Deleting PHP CS Fixer cache: $cache"
gh cache delete "$cache" || true
done
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
キャッシュサイズ肥大化への対策
ここまでの構成では、
-
run_idを含めたキャッシュキー - キャッシュを毎回作り直す運用
を採用しているため、放っておくとキャッシュが増え続けるという別の問題が発生します。
GitHub Actions のキャッシュには保存容量の上限があるため、長期間運用していると 古いブランチや PR のキャッシュが無駄に容量を圧迫します。
その対策として、キャッシュを明示的に削除する Workflow を別途用意しています。
ブランチ削除時にキャッシュを削除する
ブランチが削除されたタイミングで、そのブランチに紐づくキャッシュを削除します。
name: Delete branch cache
on:
delete
jobs:
delete-branch-cache:
runs-on: ubuntu-latest
permissions: write-all
steps:
- uses: actions/checkout@v4
- name: Delete caches for deleted branch
run: |
readarray -t caches < <(gh cache list --ref refs/heads/${{ github.event.ref }} --json id,key --jq '.[] | .id')
for cache in "${caches[@]}"; do
echo "Deleting cache: $cache"
gh cache delete "$cache" || true
done
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
ポイント
-
on: deleteを使うことで、ブランチ削除イベントをトリガーにできる -
--ref refs/heads/<branch>を指定して、そのブランチ専用のキャッシュのみ削除
PR クローズ(マージ)時にキャッシュを削除する
Pull Request が マージされてクローズされた場合のみ、PR 用のキャッシュを削除します。
name: Clear cache on pull request close or merge
on:
pull_request:
types:
- closed
jobs:
delete-merged-pr-cache:
runs-on: ubuntu-latest
permissions: write-all
steps:
- uses: actions/checkout@v4
- name: Remove caches for closed or merged pull request
run: |
if [ "${{ github.event.pull_request.merged }}" = "true" ]; then
readarray -t caches < <(gh cache list --ref ${{ format('refs/pull/{0}/merge', github.event.pull_request.number) }} --json id,key --jq '.[] | .id')
for cache in "${caches[@]}"; do
echo "Deleting cache: $cache"
gh cache delete "$cache" || true
done
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
ポイント
-
pull_request.closedでも merged のときだけ削除 - PR を close しただけの場合はキャッシュを削除しない
- 再オープン時に restore でき、作業再開時の CI が遅くならない
なぜ自動削除が必要なのか
この構成では、
- キャッシュは「更新」ではなく「新しい世代を作る」
- その代わり、不要になったタイミングで明示的に削除する
という設計になっています。そのため、
- feature ブランチ削除
- PR マージ完了
といった ライフサイクルの終端イベントでキャッシュも一緒に掃除することで、
- キャッシュ容量の肥大化を防ぐ
- GitHub Actions の制限に引っかからない
- 長期運用でも安心できる
という状態を保てます。
この Workflow 構成のメリット・デメリット
メリット
- 毎回必ず新しいキャッシュが作られる
- キャッシュ有無による CI 実行時間の揺れを抑えられる
- キャッシュを単世代運用にすることで肥大化を防げる
- ブランチ間で影響しないため、キャッシュ起因の事故が起きにくい
デメリット
- Workflow が複数になり構成が複雑
-
gh cache deleteのために権限が必要 - 初見では全体像が理解しづらい
まとめ
GitHub Actions のキャッシュは、性質上、
「使う」ものではなく 「ライフサイクルを設計する」もの
だと考えられます。
- key 設計
- restore 戦略
- 削除タイミング
- ブランチ責務分離
これらを整理することで、壊れにくく、予測可能な CI に近づけます。
全体のフロー
以下は、今回の構成におけるキャッシュのライフサイクルを図にしたものです。master ブランチを起点にキャッシュを生成し、feature ブランチでは master ブランチを起点にキャッシュを restore し、不要になったキャッシュはイベント駆動で削除しています。
参考リンク
備考
この記事はGitHub ActionsのymlファイルをベースにAIで記事を生成し、人間が調整しています。