SODA Engineering Blog
🐙

Goのビルドキャッシュを使ってCIを7分短縮した話

2024/12/10に公開

SODA inc. Advent Calendar 2024, Go Advent Calendar 2024に寄稿させていただきました。

はじめに

CIで動く統合テストが異様に遅いので調査したところ、テスト本体ではなくソースコードのビルドに時間が掛かっていることがわかった。そこでビルド時に生成されるキャッシュをGitHub Actionsに保存させることで時間を短縮させた。

この記事ではGitHub Actionsでビルドキャッシュを使い回す方法と、テスト時のビルドに掛かる時間を調べる方法を説明する。

GoのビルドキャッシュをGitHub Actionsに保存する方法

概要

1回のテストワークフローの流れの概要は下記の通り。

  1. GitHub Actions Cachesに保存されているGoのビルドキャッシュをランナーに読み込む
  2. テストを実行する(読み込んだビルドキャッシュを使いつつ、ソースコードの変更に対応する新しいビルドキャッシュが作られる)
  3. 新しいビルドキャッシュをGitHub Actions Cachesに保存する。

実際のコード例

弊社ではテストをランナーの中でDockerコンテナを立ち上げて実行しているため、ランナー内でホストとコンテナ間のキャッシュコピーが必要になる。

  1. GitHubに保存されているビルドキャッシュをランナーに読み込む
  2. ランナーに読み込んだものをDockerコンテナにコピー
  3. Dockerコンテナ内でテストを実行する(ここで新しいビルドキャッシュが作られる)
  4. Dockerコンテナ内で生成されたキャッシュをランナーにコピー
  5. ランナーにコピーしたビルドキャッシュを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) 条件によってキャッシュをクリアする。次節で後述するが、変更するファイルによって、キャッシュが効く効かないがある。キャッシュが効かない場合、古いキャッシュが消えないまま新しいキャッシュがどんと作られて一気にストレージを逼迫させるので、特定のファイルを更新したらキャッシュを削除している。

ビルドに掛かっている時間を調べる方法

ビルドに時間が掛かっていることに気付くのは結構難しいので、この節で説明する。

ざっくり調べる方法は下記の通り。

  1. go clean -cache でキャッシュクリア
  2. date && go test && date のような形でテストの実行時間を計測する
  3. 再度(2)のコマンドを実行して、テストの実行時間を計測する
  4. (2)の実行時間 - (3)の実行時間が、ビルドに掛かっている時間である

go test コマンドを実行すると、テストが実行される前にソースコードがビルドされるのだが、そういった情報は実行時何も出力されてこないので、dateコマンドを使うなりして外部から計測する必要がある。

なお、ビルドに時間が掛かっていることに疑いを持ったきっかけを補足すると以下の通りである。

  • テスト数が少ないのにも関わらず異様に時間が掛かっているワークフローがある
  • ローカル環境とCI環境でテストの実行時間が大きく異なる(ローカル環境ではキャッシュが溜まっているので早いが、CI環境ではキャッシュなしの状態でテストが実行される)

ビルドに時間が掛かるのはどういうときか

どういう時にビルドに時間がかかるのかを最後に述べるが、経験的に厄介なのが以下であった。

  • 統合テストのように、大量のパッケージに依存するパッケージ。統合テストをしようと思うと、ビルドに時間が掛かる。単体テストはそれらに比べるとビルドに掛かる時間が大幅に少ない。
  • ドメインモデルのように、大量のパッケージから依存されるパッケージ。これらを変更するとキャッシュが効かなくなり、ビルドに時間が掛かる。
  • これを組みさあわって、「ドメインモデルを変更すると、統合テストのビルドに異様に時間が掛かる」という現象に当たる。

弊社では歴史的経緯で統合テストやドメインモデルを単一のパッケージに実装しているが、これをパッケージを分割することで依存ツリーを小さくできるのではないかと思っている(未検証)

SODA Engineering Blog
SODA Engineering Blog

Discussion