GitHub Actions Advent Calendar 2019 の 15 日目の記事です。
この記事では、GitHub Actions のキャッシュ機能について解説します。
目次
- CI/CD とキャッシュ
- 簡単な例 (npm)
- actions/cache
- 複数 OS で matrix ビルドするときのキャッシュ
- 言語ごとの例
- アーティファクトとキャッシュの違い
- 制限事項
- 注意事項
- まとめ
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 すると、以下のようにワークフローが実行されます。
初回実行時はキャッシュがまだ存在しないのでなにも復元されないこと、ジョブの後処理としてキャッシュが保存されていることが読み取れます。
次は、キャッシュが復元されることを確認するために、git commit --allow-empty
でなにも変更せずにコミットして、git push
します。すると、今度は以下のようにワークフローが実行されます。
今度は先ほど保存されたキャッシュが存在するので、npm ci
実行前にキャッシュが復元されるようになりました。また、後処理では同じキーのキャッシュがすでに存在するので、上書き保存はされていないことが読み取れます。
これだけで、npm のキャッシュが保存&復元されるようになりました。この例だと依存関係が少ないので高速化のメリットは感じづらいですが、より依存関係の多いプロジェクトになるにつれてキャッシュの効果が現れるはずです。
actions/cache
例で出てきた、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、存在しなかったときは falserestore-keys
でマッチングしても false
キーのマッチング順序
キーのマッチング順序として、まず現在のブランチのキャッシュを key
と restore-keys
を使って検索し、存在しなかったらプルリク先のベースブランチのキャッシュを key
と restore-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-version
の v1
を v2
にするだけで既存のキャッシュを使わないようにできます。
複数 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 ci
は node_modules
をいったん削除するからです。
言語ごとの例
公式で主要なプログラミング言語とパッケージマネージャーごとの例が用意されています。どのディレクトリをキャッシュすればいいか、どのファイルのハッシュをキーに含めればいいか、といったことがわからないときは参考になると思います。
アーティファクトとキャッシュの違い
CI/CD には、アーティファクトという、キャッシュと似たようなファイルを保存するための仕組みがよく存在します(GitHub Actions にも存在するので、そちらもそのうち解説記事を書きたい)。ファイルを保存するという点ではそれぞれの機能で似ているのですが、アーティファクトとキャッシュの役割は異なります。
アーティファクトは、ワークフローの一回のビルド実行内でファイルを複数のジョブ間で受け渡したり、ワークフロー完了後に保存したファイルを見るために使います。バイナリやアーカイブなどの成果物や、デバッグ用のログやテスト結果、カバレッジなどの情報を保存するために使うことが多いです。
一方で、キャッシュは、ジョブやワークフローの複数の実行の間でファイルを再利用するために使います。基本的に、依存パッケージのような、ほとんど変更されないようなファイルに対して使用します。
制限事項
GitHub Actions のキャッシュは、push
イベントと pull_request
イベントでトリガーされるワークフローのみでアクセス可能です。他のイベントタイプや定期実行では、エラーにはなりませんが警告メッセージが出てキャッシュの保存や復元は行われません。(参考)
ワークフローからアクセスできるキャッシュは、現在のブランチ、プルリクエストのベースブランチ、デフォルトブランチで作成されたキャッシュのみです。
キャッシュの保存期間として、7 日間アクセスされなかったキャッシュは削除されるようです。
また、キャッシュの数には制限はありませんが、キャッシュのサイズには以下のような上限があります。
- 個々のキャッシュはそれぞれ 400 MB を超えたら保存されない
tar
とgzip
で圧縮した後のファイルサイズで判定される様子
- リポジトリ全体で最大 2GB のキャッシュサイズ上限がある
- 2GB を超えた場合、キャッシュのアクセス時間が古い順に追い出される
サイズ上限がある代わりに、現時点では GitHub Actions のキャッシュ機能は完全に無料になっています。しかし、サイズ上限については少ないという意見も多いため、上限の緩和(課金の可能性も含む)が検討されているようです。(参考)
注意事項
キャッシュには、一般に公開してはいけない秘密情報を含めないようにしましょう。リポジトリの閲覧権限があってプルリクエストを作成できる人であれば、誰でもキャッシュデータにアクセスすることができてしまうためです。
まとめ
GitHub Actions のキャッシュ機能についてまとめました。
軽く触ってみた感じでは、CircleCI のキャッシュとかなり近くて使いやすそうですね。
ただ、現状だとキャッシュサイズ上限があるので、大規模なプロジェクトだとちょっと物足りないかもしれません。また、キャッシュを利用できるのが push
と pull_request
に限定されているのは、困るユースケースが普通にありそうです。
とはいえ、それらの問題は認識済みで検討されているようなので、今後の改善に期待です。