Goのビルドキャッシュを使ってCIを7分短縮した話
SODA inc. Advent Calendar 2024, Go Advent Calendar 2024に寄稿させていただきました。
はじめに
CIで動く統合テストが異様に遅いので調査したところ、テスト本体ではなくソースコードのビルドに時間が掛かっていることがわかった。そこでビルド時に生成されるキャッシュをGitHub Actionsに保存させることで時間を短縮させた。
この記事ではGitHub Actionsでビルドキャッシュを使い回す方法と、テスト時のビルドに掛かる時間を調べる方法を説明する。
GoのビルドキャッシュをGitHub Actionsに保存する方法
概要
1回のテストワークフローの流れの概要は下記の通り。
- GitHub Actions Cachesに保存されているGoのビルドキャッシュをランナーに読み込む
- テストを実行する(読み込んだビルドキャッシュを使いつつ、ソースコードの変更に対応する新しいビルドキャッシュが作られる)
- 新しいビルドキャッシュをGitHub Actions Cachesに保存する。
実際のコード例
弊社ではテストをランナーの中でDockerコンテナを立ち上げて実行しているため、ランナー内でホストとコンテナ間のキャッシュコピーが必要になる。
- GitHubに保存されているビルドキャッシュをランナーに読み込む
- ランナーに読み込んだものをDockerコンテナにコピー
- Dockerコンテナ内でテストを実行する(ここで新しいビルドキャッシュが作られる)
- Dockerコンテナ内で生成されたキャッシュをランナーにコピー
- ランナーにコピーしたビルドキャッシュをGitHubに保存し、古いビルドキャッシュ(手順1で持ってきたもの)を削除
- name: Restore Go cache
id: restore-go-cache
uses: actions/cache/restore@v4
with:
path: |
/tmp/.cache
key: test-integration-${{ runner.os }}-go-${{ github.sha }}
restore-keys: |
test-integration-${{ runner.os }}-go-
- name: Copy Go build cache to container
run: |
mkdir -p /tmp/.cache/go-build
docker exec container_name mkdir -p /root/.cache
docker cp /tmp/.cache container_name:/root
rm -rf /tmp/.cache
- name: Test
run: make test-integration
- name: Copy Gso build cache from container
run: |
mkdir -p /tmp/.cache/go-build
docker cp container_name:/root/.cache/go-build /tmp/.cache
- name: Save Go cache
id: cache-primes-save
uses: actions/cache/save@v4
with:
path: |
/tmp/.cache
key: test-integration-${{ runner.os }}-go-${{ github.sha }}
- name: delete older cache
run: |
gh extension install actions/gh-actions-cache
# キャッシュがないと終了コード1になりジョブが失敗してしまうので( || true)で回避する
gh actions-cache delete ${{ steps.restore-go-cache.outputs.cache-matched-key }} -B ${{ github.ref }} --confirm || true
env:
GH_TOKEN: ${{ github.token }}
キャッシュのライフサイクル
ブランチごとにビルドキャッシュが作られるような形になる。GitHub Actions Cacheは、当該のブランチかベースブランチのキャッシュを利用するようになっている。コミットごとにキャッシュを更新する(正確には更新はできないので、新規作成&削除の2ステップを行う)。
運用課題〜キャッシュのサイズが大きいことに対する対策
この統合テストでは、1回のビルドで1GBくらいのキャッシュが生成される。GitHub Actionsのキャッシュストレージは10GB程度なので、無闇にキャッシュを生成するとあっという間にストレージを逼迫する。また、ランナーのストレージにも上限があり、それを逼迫することもあった。そこでいくつか対策を打っている。
(1) ランナーのスペックを上げる: Large Runnerを利用するとランナーで利用可能なストレージが大きくなる。
(2) キャッシュを複数のワークフローで使い回す: 時短のため統合テストを複数のワークフローに分割しているが、それぞれ別々にキャッシュをActionsに保存するとストレージを逼迫する。実際にキャッシュの内容がほとんど変わらないことを確認しているため、キャッシュの保存は1種類のワークフローのでのみ行い、それを全てのワークフローで共有するようにしている。
(3) 条件によってキャッシュをクリアする。次節で後述するが、変更するファイルによって、キャッシュが効く効かないがある。キャッシュが効かない場合、古いキャッシュが消えないまま新しいキャッシュがどんと作られて一気にストレージを逼迫させるので、特定のファイルを更新したらキャッシュを削除している。
ビルドに掛かっている時間を調べる方法
ビルドに時間が掛かっていることに気付くのは結構難しいので、この節で説明する。
ざっくり調べる方法は下記の通り。
-
go clean -cache
でキャッシュクリア -
date && go test && date
のような形でテストの実行時間を計測する - 再度(2)のコマンドを実行して、テストの実行時間を計測する
- (2)の実行時間 - (3)の実行時間が、ビルドに掛かっている時間である
go test
コマンドを実行すると、テストが実行される前にソースコードがビルドされるのだが、そういった情報は実行時何も出力されてこないので、dateコマンドを使うなりして外部から計測する必要がある。
なお、ビルドに時間が掛かっていることに疑いを持ったきっかけを補足すると以下の通りである。
- テスト数が少ないのにも関わらず異様に時間が掛かっているワークフローがある
- ローカル環境とCI環境でテストの実行時間が大きく異なる(ローカル環境ではキャッシュが溜まっているので早いが、CI環境ではキャッシュなしの状態でテストが実行される)
ビルドに時間が掛かるのはどういうときか
どういう時にビルドに時間がかかるのかを最後に述べるが、経験的に厄介なのが以下であった。
- 統合テストのように、大量のパッケージに依存するパッケージ。統合テストをしようと思うと、ビルドに時間が掛かる。単体テストはそれらに比べるとビルドに掛かる時間が大幅に少ない。
- ドメインモデルのように、大量のパッケージから依存されるパッケージ。これらを変更するとキャッシュが効かなくなり、ビルドに時間が掛かる。
- これを組みさあわって、「ドメインモデルを変更すると、統合テストのビルドに異様に時間が掛かる」という現象に当たる。
弊社では歴史的経緯で統合テストやドメインモデルを単一のパッケージに実装しているが、これをパッケージを分割することで依存ツリーを小さくできるのではないかと思っている(未検証)
株式会社SODAの開発組織がお届けするZenn Publicationです。 是非Entrance Bookもご覧ください! → recruit.soda-inc.jp/engineer
Discussion