GitHub Actions のキャッシュ活用で CI を高速化する

概要

GitHub Actions で CI を回していると、依存関係のインストールだけで毎回数分かかる、という状況になりがちです。npm install、composer install、pip install、Docker のビルドなど、「前回と内容が変わっていないのに毎回ゼロからダウンロードする」部分をキャッシュすれば、CI 全体の実行時間は大きく短縮できます。

この記事では、actions/cache の仕組みと正しいキー設計、言語別セットアップアクションの組み込みキャッシュ、Docker レイヤーキャッシュまで、手を動かしながら高速化する手順をまとめます。

環境

  • GitHub Actions(GitHub-hosted runner, ubuntu-latest)
  • 検証に使った主なアクションのメジャーバージョン
  • actions/cache@v4
  • actions/setup-node@v4
  • actions/setup-python@v5
  • docker/build-push-action@v6 / docker/setup-buildx-action@v3

※ アクションのバージョンは記事執筆時点のものです。利用時は各リポジトリの最新リリースを確認してください。

発生した問題

典型的なワークフローはこうなっています。

name: CI
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci        # ← 毎回フルダウンロードで数分
      - run: npm test

npm ci は node_modules を毎回作り直します。ランナーは実行ごとに破棄されるクリーンな環境なので、キャッシュを明示しない限り、依存のダウンロードとビルドが毎回ゼロから走ります。依存が多いプロジェクトでは、この部分だけで CI の大半の時間を占めることも珍しくありません。

原因:ランナーは使い捨て

GitHub-hosted runner は ジョブごとに使い捨て です。前回のジョブで作ったファイルは次回には残っていません。そのため、

  • パッケージマネージャのダウンロードキャッシュ(~/.npm, ~/.cache/pip, ~/.composer/cache など)
  • ビルド成果物(node_modules, Docker レイヤーなど)

を「変化していない限り再利用する」仕組みを明示的に入れないと、毎回フルで取得・ビルドすることになります。これを解決するのが actions/cache と、各 setup-* アクションの組み込みキャッシュ機能です。

対応方法:高速化の3つの勘所

  1. 何をキャッシュするか:ダウンロードキャッシュ(~/.npm 等)か、ビルド成果物(node_modules 等)か
  2. キーをどう設計するか:ロックファイルのハッシュを含めて「依存が変わったら作り直す」ようにする
  3. restore-keys で部分ヒットを許すか:完全一致しなくても近いキャッシュを再利用して差分だけ取得する

キャッシュキーの設計が最重要

key が完全一致したときだけキャッシュが復元され、かつ ヒットした場合はジョブ終了時に保存し直しません。そのため、

  • key にはロックファイルのハッシュを入れる → 依存が変わると自動で新しいキャッシュになる
  • restore-keys には前方一致のフォールバックを並べる → 完全一致が無くても近いものを拾う

という設計が基本になります。

      - uses: actions/cache@v4
        with:
          path: ~/.npm
          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-node-

hashFiles('**/package-lock.json') は対象ファイルの内容ハッシュを返します。package-lock.json が1文字でも変われば key が変わり、新しいキャッシュが作られます。変わらなければ完全一致で復元されます。

実装例

1. setup-* アクションの組み込みキャッシュ(まずこれを使う)

多くの言語で、setup-* アクションに cache オプションがあり、これを使うのが一番簡単で確実です。actions/cache を自前で書く前に、まずこちらを検討してください。

Node.js

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'    # package-lock.json を自動で検出してキャッシュ
      - run: npm ci
      - run: npm test

モノレポなどロックファイルが特殊な場所にある場合は cache-dependency-path を指定します。

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
          cache-dependency-path: 'frontend/package-lock.json'

Python

      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
          cache: 'pip'
      - run: pip install -r requirements.txt

Java(setup-java の cache)や Go(setup-go の組み込みキャッシュ)も同様です。

2. node_modules そのものをキャッシュする

setup-node の cache: 'npm' はダウンロードキャッシュ(~/.npm)までで、npm ci 自体は走ります。インストール処理ごと省きたい場合は node_modules をキャッシュします。

      - uses: actions/cache@v4
        id: npm-cache
        with:
          path: node_modules
          key: ${{ runner.os }}-modules-${{ hashFiles('**/package-lock.json') }}

      - name: Install if cache miss
        if: steps.npm-cache.outputs.cache-hit != 'true'
        run: npm ci

cache-hit 出力で完全一致したかを判定し、ヒット時は npm ci をスキップします。ただし node_modules をキャッシュする場合は、

  • OS・アーキテクチャ・Node バージョンが同じでないとネイティブモジュールが壊れる
  • 完全一致ヒット時だけスキップ(restore-keys の部分ヒットでスキップしてはいけない)

という点に注意が必要です。ネイティブ依存が絡むなら、無理に node_modules を抱え込まず ~/.npm キャッシュ+npm ci の方が安全です。

3. Docker のビルドレイヤーをキャッシュする

Docker イメージをビルドする CI では、Buildx の GitHub Actions キャッシュバックエンド(type=gha)が手軽です。

      - uses: docker/setup-buildx-action@v3

      - uses: docker/build-push-action@v6
        with:
          context: .
          push: false
          tags: myapp:ci
          cache-from: type=gha
          cache-to: type=gha,mode=max

mode=max は中間レイヤーも含めてキャッシュするため、ヒット率が上がります。Dockerfile の命令順を工夫し、「変わりにくい依存インストール」を先に、「変わりやすいアプリコードの COPY」を後に置くと、キャッシュがさらに効きやすくなります。

# 依存だけ先にコピーしてインストール(ここがキャッシュされる)
COPY package*.json ./
RUN npm ci
# アプリコードは後から(コード変更ではここから下だけ再ビルド)
COPY . .

確認方法

キャッシュが効いているかは、ワークフロー実行のログで確認できます。

  • actions/cache のステップログに Cache restored from key: ...(ヒット)または Cache not found for input keys: ...(ミス)が出る
  • Cache saved with key: ... が出ればジョブ終了時に保存されている
  • リポジトリの Actions → Caches 画面で、保存済みキャッシュの一覧・サイズ・最終使用日時を確認できる

2回目以降の実行で「依存インストールのステップが数秒で終わる」「Docker ビルドが CACHED と表示される」ことを確認できれば成功です。

注意点

  • 総容量の上限:リポジトリあたりのキャッシュ総量には上限(10 GB 程度)があり、超えると古いものから自動削除されます。
  • 退避(eviction)ルール:7日間アクセスされなかったキャッシュは削除されます。
  • ブランチのスコープ:キャッシュはブランチ単位でアクセス制御されます。基本的に同じブランチと、そのベースブランチ(および default ブランチ)で作られたキャッシュを参照できます。PR ごとに毎回ミスする場合はこのスコープが原因のことがあります。default ブランチで一度温めておくと、各 PR から再利用できます。
  • キャッシュポイズニングに注意:信頼できない PR(fork からの pull_request)で作ったキャッシュを default ブランチのビルドが拾わないよう、キーやトリガーの設計に気を配ります。
  • 「速くなった」を過信しない:ロックファイルのハッシュをキーに必ず含め、依存変更時は確実に作り直す設計にします。

まとめ

  • まずは setup-* アクションの cache オプションを使う。多くのケースはこれで十分速くなる
  • 自前で actions/cache を書くときは、key にロックファイルのハッシュ、restore-keys に前方一致フォールバック が基本形
  • node_modules を丸ごとキャッシュするなら、完全一致ヒット時のみスキップし、ネイティブ依存に注意
  • Docker は Buildx の type=gha キャッシュ+Dockerfile の命令順最適化が効く
  • 容量上限・退避・ブランチスコープを理解して、「ヒットしているつもりで実はミスしている」状態を避ける

キャッシュは「設定して終わり」ではなく、ログでヒット率を見ながら key を調整する運用が肝心です。

\ 最新情報をチェック /

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください。