生産性向上ブログ

継続的な生産性向上を目指すエンジニアのためのブログ

GitHub Actions でキャッシュを使った高速化

GitHub Actions Advent Calendar 2019 の 15 日目の記事です。

この記事では、GitHub Actions のキャッシュ機能について解説します。

目次

CI/CD とキャッシュ

CI/CD のビルドでは、リポジトリが依存するパッケージのダウンロードが原因でビルド時間が長くなってしまうことがよくあります。近年の CI/CD ではビルドごとに完全にクリーンな実行環境が用意され、前回のビルドでダウンロードしたファイルが持ち越されないからです。

このため、CI/CD が提供するキャッシュ機能を用いて、異なるビルド間でダウンロードしたパッケージを使い回して高速化することがよくあります。GitHub Actions でもキャッシュ機能が提供されています。

簡単な例 (npm)

実際に GitHub Actions で npm のパッケージダウンロードをキャッシュする簡単な例を作成してみます。

実験用リポジトリ作成

キャッシュを試すために、簡単な Node.js + npm のリポジトリを用意します。このあたりは本題ではなく実際に手を動かして試してみたい人用なので、それ以外の人はキャッシュの話が出てくるまで読み飛ばして OK です。

簡単な FizzBuzz プログラムとそのテストコードを作成して、CI でテストを実行するところまで作ります。

まずは、GitHub でリポジトリ(名前は test-github-actions-cache とします)を作成して、手元に clone します。そのディレクトリ内で、npm init を、だいたい以下のような感じで実行します。

$ npm init
(省略)
package name: (test-github-actions-cache)
version: (1.0.0)
description:
entry point: (index.js)
test command: mocha
git repository: (作成したリポジトリの URL)
keywords:
license: (ISC) MIT
(省略)
Is this OK? (yes) yes

今回の例では mocha でテストを流す想定なので、以下のように npm install 実行します。

$ npm install --save-dev mocha

次に、以下の簡単な FizzBuzz コードを index.js として保存します。

module.exports = function fizzbuzz(value) {
  if (value % 15 === 0) {
    return "FizzBuzz";
  }
  if (value % 3 === 0) {
    return "Fizz";
  }
  if (value % 5 === 0) {
    return "Buzz";
  }
  return String(value);
};

以下のテストコードを test/index.js として保存します。

const assert = require("assert");
const fizzbuzz = require("../index");

describe("fizzbuzz", () => {
  it("returns FizzBuzz when value is divisible by 15", () => {
    assert(fizzbuzz(30) === "FizzBuzz");
  });

  it("returns Fizz when value is divisible by 3", () => {
    assert(fizzbuzz(9) === "Fizz");
  });

  it("returns Buzz when value is divisible by 5", () => {
    assert(fizzbuzz(10) === "Buzz");
  });

  it("returns the value when value is not divisible by 3 or 5", () => {
    assert(fizzbuzz(7) === "7");
  });
});

次に、GitHub Actions でテストを実行するために、以下の YAML を .github/workflows/workflow.yml として保存します。

name: Main workflow
on: [push]
jobs:
  run:
    name: Run
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2.0.0
      - name: Set Node.js 12.x
        uses: actions/setup-node@v1.3.0
        with:
          version: 12.x
      - name: npm ci
        run: npm ci
      - name: Test
        run: npm test

ここまでに作成されたファイルを git add して git commit して git push すると、GitHub Actions でテストコードが実行されるようになります。この時点ではまだキャッシュは使われません。

キャッシュ

ここから本題のキャッシュを利用します。

.github/workflows/workflow.yml を以下のように、actions/cache アクションを使うように修正します。

name: Main workflow
on: [push]
jobs:
  run:
    name: Run
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2.0.0
      - name: Set Node.js 12.x
        uses: actions/setup-node@v1.3.0
        with:
          node-version: 12.x
      - uses: actions/cache@v1.0.3
        with:
          path: ~/.npm
          key: node-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            node-
      - name: npm ci
        run: npm ci
      - name: Test
        run: npm test

この変更を push すると、以下のようにワークフローが実行されます。

f:id:miya-jan:20191215173911p:plain

初回実行時はキャッシュがまだ存在しないのでなにも復元されないこと、ジョブの後処理としてキャッシュが保存されていることが読み取れます。

次は、キャッシュが復元されることを確認するために、git commit --allow-empty でなにも変更せずにコミットして、git push します。すると、今度は以下のようにワークフローが実行されます。

f:id:miya-jan:20191215173926p:plain

今度は先ほど保存されたキャッシュが存在するので、npm ci 実行前にキャッシュが復元されるようになりました。また、後処理では同じキーのキャッシュがすでに存在するので、上書き保存はされていないことが読み取れます。

これだけで、npm のキャッシュが保存&復元されるようになりました。この例だと依存関係が少ないので高速化のメリットは感じづらいですが、より依存関係の多いプロジェクトになるにつれてキャッシュの効果が現れるはずです。

actions/cache

github.com

例で出てきた、actions/cache アクションについてもう少し詳細を見てみます。

Inputs と Outputs

actions/cache アクションは、以下のパラメータを Inputs として受け取ります。

  • path: キャッシュとして保存&復元するディレクトリのパス
    • 絶対パスか working directory からの相対パスを指定する
  • key: キャッシュを保存&復元するためのキー
    • 最大 512 文字で、それ以上の長さの文字列を渡すとエラーになるとのこと
  • restore-keys: キャッシュ復元時に key に完全に一致するキャッシュが存在しなかったときに使われるキーのリスト (optional)

path で指定できるディレクトリは一つのみで、複数のディレクトリをキャッシュしたいときは、ディレクトリごとに actions/cache アクションを実行する必要があります

key には GitHub Actions が提供する context や関数などを含めることができます。(参考

よく使うのは、依存関係が変わったときにキーが変わるように、hashFiles 関数を依存関係を定義しているファイルに対して呼び出してキーに含める方法です。上の例だと、npm は package-lock.json で厳密に依存関係が定義されているので、hashFiles 関数に渡して key に含めています。

また、以下のパラメータを Outputs として設定します。

  • cache-hit: key に完全一致するキャッシュが存在したときは true、存在しなかったときは false
    • restore-keys でマッチングしても false

キーのマッチング順序

キーのマッチング順序として、まず現在のブランチのキャッシュを keyrestore-keys を使って検索し、存在しなかったらプルリク先のベースブランチのキャッシュを keyrestore-keys を使って検索するようです。(デフォルトブランチも関係するようなことが書いてあるけど、細かい挙動はよくわからず)

ブランチ内のキャッシュの検索は、まず key に完全一致するキャッシュが検索されます。

存在しなかったら、restore-keys に書かれたキーを上から順に前方一致で検索します。なので、restore-keys に複数のキーを指定するときは、より長い順に書いていくのがよさそうです。

restore-keys の特定のキーに前方一致するキャッシュが複数あるときは、より最近に作成されたキャッシュが使われます。

ビルド失敗時

actions/cache アクションを実行していても、そのジョブのビルドが失敗したときはキャッシュが保存されません。キャッシュが保存されるのはビルド成功時のみです。

キャッシュクリア

一度作成したキャッシュの上書きはできません。新しいキーでキャッシュを作成する必要があります。

なので、環境変数などでキャッシュキーの先頭にバージョン名などを入れておくと、その数字を増やすだけでキャッシュをまとめて無効化できるので便利です。(CircleCI で公式で推奨されている方法です)

name: Main workflow
on: [push]
env:
  cache-version: v1
jobs:
  run:
    name: Run
    runs-on: ubuntu-latest
    steps:
      (省略)
      - uses: actions/cache@v1.0.3
        with:
          path: ~/.npm
          key: ${{ env.cache-version }}-node-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ env.cache-version }}-node-
      (省略)

上のように書くと、cache-versionv1v2 にするだけで既存のキャッシュを使わないようにできます。

複数 OS で matrix ビルドするときのキャッシュ

簡単な例では path~/.npm で決め打ちにしましたが、Windows だと NPM のキャッシュディレクトリは %AppData%/npm-cache になります。また、キャッシュする内容も OS によって異なってくる可能性があります。

なので、複数 OS で matrix ビルドを行うときは、npm config get cache でキャッシュディレクトリを取得し、key に OS を含めるのがよさそうです。

簡単な例に複数 OS での matrix ビルドを適用すると、以下のようになります。

name: Main workflow
on: [push]
env:
  cache-version: v1
jobs:
  run:
    name: Run
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v2.0.0
      - name: Set Node.js 12.x
        uses: actions/setup-node@v1.3.0
        with:
          node-version: 12.x
      - name: Get npm cache directory
        id: npm-cache
        run: |
          echo "::set-output name=dir::$(npm config get cache)"
      - uses: actions/cache@v1.0.3
        with:
          path: ${{ steps.npm-cache.outputs.dir }}
          key: ${{ env.cache-version }}-${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ env.cache-version }}-${{ runner.os }}-node-
      - name: npm ci
        run: npm ci
      - name: Test
        run: npm test

ちなみに、node_modules はキャッシュしないのが最近では一般的です。Node のバージョンが変わると壊れる可能性がありますし、npm cinode_modules をいったん削除するからです。

言語ごとの例

github.com

公式で主要なプログラミング言語とパッケージマネージャーごとの例が用意されています。どのディレクトリをキャッシュすればいいか、どのファイルのハッシュをキーに含めればいいか、といったことがわからないときは参考になると思います。

アーティファクトとキャッシュの違い

CI/CD には、アーティファクトという、キャッシュと似たようなファイルを保存するための仕組みがよく存在します(GitHub Actions にも存在するので、そちらもそのうち解説記事を書きたい)。ファイルを保存するという点ではそれぞれの機能で似ているのですが、アーティファクトとキャッシュの役割は異なります。

アーティファクトは、ワークフローの一回のビルド実行内でファイルを複数のジョブ間で受け渡したり、ワークフロー完了後に保存したファイルを見るために使います。バイナリやアーカイブなどの成果物や、デバッグ用のログやテスト結果、カバレッジなどの情報を保存するために使うことが多いです。

一方で、キャッシュは、ジョブやワークフローの複数の実行の間でファイルを再利用するために使います。基本的に、依存パッケージのような、ほとんど変更されないようなファイルに対して使用します。

制限事項

GitHub Actions のキャッシュは、push イベントと pull_request イベントでトリガーされるワークフローのみでアクセス可能です。他のイベントタイプや定期実行では、エラーにはなりませんが警告メッセージが出てキャッシュの保存や復元は行われません。(参考

ワークフローからアクセスできるキャッシュは、現在のブランチ、プルリクエストのベースブランチ、デフォルトブランチで作成されたキャッシュのみです。

キャッシュの保存期間として、7 日間アクセスされなかったキャッシュは削除されるようです。

また、キャッシュの数には制限はありませんが、キャッシュのサイズには以下のような上限があります。

  • 個々のキャッシュはそれぞれ 400 MB を超えたら保存されない
    • targzip で圧縮した後のファイルサイズで判定される様子
  • リポジトリ全体で最大 2GB のキャッシュサイズ上限がある
    • 2GB を超えた場合、キャッシュのアクセス時間が古い順に追い出される

サイズ上限がある代わりに、現時点では GitHub Actions のキャッシュ機能は完全に無料になっています。しかし、サイズ上限については少ないという意見も多いため、上限の緩和(課金の可能性も含む)が検討されているようです。(参考

注意事項

キャッシュには、一般に公開してはいけない秘密情報を含めないようにしましょう。リポジトリの閲覧権限があってプルリクエストを作成できる人であれば、誰でもキャッシュデータにアクセスすることができてしまうためです。

まとめ

GitHub Actions のキャッシュ機能についてまとめました。

軽く触ってみた感じでは、CircleCI のキャッシュとかなり近くて使いやすそうですね。

ただ、現状だとキャッシュサイズ上限があるので、大規模なプロジェクトだとちょっと物足りないかもしれません。また、キャッシュを利用できるのが pushpull_request に限定されているのは、困るユースケースが普通にありそうです。

とはいえ、それらの問題は認識済みで検討されているようなので、今後の改善に期待です。