GitHub Actions 上での Go の Docker ビルドを高速化する

2024/05/13に公開

どうも GitHub Actions 上で Docker ビルドを行うと時間がかかるなぁと感じていました。
かなり軽量の Go の Web アプリケーションを Docker イメージにしてプッシュするプロセスなのですが、全体で 3 分ほどかかっています。

今回はその速度改善を行ったので、得た知見を記事にしたいと思います。

最終的に、ケース次第では以下のような結果を出すことができました。
※ケース = go のソースコードのほんの一部を変更してワークフローを実行する。 go.mod など依存関係に変化はない。

  • go build: 60秒1秒
  • docker/build-push-action ステップ: 2分30秒30秒
  • ワークフロー: 3分1分

前提

go build は Dockerfile のステップで行っており、イメージとして以下のような内容になっています。

Dockerfile
FROM golang:1.22.2 as build
WORKDIR /workdir
COPY go.mod go.sum /workdir/
RUN go mod download

COPY . /workdir
RUN go build -ldflags='-s -w' -o app .

FROM debian:bookworm-slim
COPY --from=build /workdir/app /app
ENTRYPOINT ["/app"]

0. 広く知られた方法を守る(割愛)

Docker ビルドを高速化させるために広く知られている以下の方法を守ります。説明は割愛します。

0-1. Docker のベースイメージを小さくする

pull にかかる時間が削減されます。

0-2. レイヤーキャッシュを意識して Dockerfile を書く

COPYする順番や |&でコマンドを繋げてレイヤを減らすなどのテクニックです。
https://docs.docker.com/build/cache/

0-3. マルチステージビルドを行う

マルチステージビルドを行うことで最終イメージを小さくできるので、docker pushにかかる時間を短縮できます。
https://docs.docker.com/build/building/multi-stage/

0-4. ランナーのスペックを上げる

GitHub Actions Runner のスペックを上げることでビルド時間を短縮できます。GitHub Teams および Enterprise のみ有効な方法です。
https://docs.github.com/ja/actions/using-github-hosted-runners/about-larger-runners/about-larger-runners

方法1. Docker レイヤーキャッシュを効かせる

GitHub Actions ホステッドランナーはジョブごとに別のランナーが立ち上がります。つまり、次のビルド時にレイヤーキャッシュを持ち越して使うことができません。持ち越して使うにはレイヤーキャッシュをエクスポートする必要があります。

エクスポートは buildx--cache-to オプションを使うことで利用可能です。
https://docs.docker.com/reference/cli/docker/buildx/build/#cache-to

エクスポート先としては以下の 3 つが有力です。

type エクスポート先 備考
registry コンテナレジストリ
gha GitHub Actions Cache 最大7日間の保存。計10GBまで
s3 Amazon S3

今回は gha をエクスポート先に設定しました。docker/build-push-actionを使っている場合は以下のように書くことができます。

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

- name: build and push
  uses: docker/build-push-action@v5
  with:
    context: .
    tags: <TAG>
    cache-from: type=gha
    cache-to: type=gha,mode=max

cache-toオプションが--cache-toにあたります。mode=maxを指定することでマルチステージビルドのすべてのステージのレイヤーキャッシュを保存できます。詳しくは公式ドキュメントを参照してください。

なお、エクスポートしたレイヤーキャッシュを利用するには--cache-fromオプションの指定が必要で、アクションではcache-fromがその指定にあたります。

方法2. Go のビルドキャッシュを効かせる

方法1 で Docker のレイヤーキャッシュが効くようになり、ある程度の効果を出すことができましたが、それでも 2分以上の時間がかかったままでした。

内訳を確認したところ、 RUN go build...で 60 秒以上の時間がかかっており、ソースコードや依存関係の変更が一切なければレイヤーキャッシュが効くものの、少しでもソースコードが変更されれば時間がかかるのが避けられない状況でした。

これは Go のビルドキャッシュが効いていないためです。
https://pkg.go.dev/cmd/go#hdr-Build_and_test_caching

Go は過去のビルド成果物をキャッシュし、次のビルドで再利用できるようになっています。しかしジョブごとに新しい GitHub Action ホステッドランナーが用意されるため、ビルドキャッシュを使い回せておらず、ソースコードを一部変更しただけでも最大のビルド時間がかかってしまう、という仕組みでした。

調べたところ Go のビルドキャッシュは /root/.cache/go-build で管理されるため、このディレクトリを GitHub Actions Cache に置いて使い回すと良さそうだと分かったのですが、Docker コンテナのファイルシステムの /root/.cache/go-buildディレクトリが、ホスト(GitHub Actions ランナー)から見たときにどのディレクトリに対応するのかは不明なため、簡単にはキャッシュできません。

しかしその問題も、reproducible-containers/buildkit-cache-danceというアクションで解決することができました。
https://github.com/reproducible-containers/buildkit-cache-dance

このアクションは、RUN --mount=type=cacheの動作を利用して Docker 環境の特定のディレクトリを extract したり inject したりできるアクションで、 docker docs でも紹介されています。
https://docs.docker.com/build/ci/github-actions/cache/#cache-mounts

仕組みとしては以下になります。

キャッシュディレクトリの extract

  1. 自分が用意した Dockerfile と全く同じキャッシュマウント先(RUN --mount=type=cache)を持つ Dockerfile を用意する。そのキャッシュマウント先の中身を自身に cp するステップが書かれている
  2. その Dockerfile をビルドしコンテナの作成までを行う
  3. docker cpコマンドを使って Docker 環境からホスト環境にディレクトリをコピーする

extract してきたファイルが配置されるディレクトリを、今度は GitHub Actions Cache にアップロードすれば、ランナー外に保存できます。docker cp コマンドは知らなかったので勉強になりました。
https://docs.docker.com/reference/cli/docker/container/cp/

詳しい処理はソースコードをご覧ください。
https://github.com/reproducible-containers/buildkit-cache-dance/blob/main/src/extract-cache.ts

キャッシュディレクトリの inject

  1. 自分が用意した Dockerfile と全く同じキャッシュマウント先(RUN --mount=type=cache)を持つ Dockerfile を用意する。そのとき、--mount=type=bindも併用し、ホストのディレクトリをマウントする。マウントしたディレクトリをキャッシュマウント先に cp するステップが書かれている
  2. その Dockerfile をビルドする

ビルドされるとステップが実行され、指定したホストのディレクトリの中身が Docker 環境のキャッシュディレクトリにコピーされる仕組みです。ホストのディレクトリには GitHub Actions Cache からダウンロードしてきたファイルを置くことになります。

こちらも詳しい処理はソースコードをご覧ください。
https://github.com/reproducible-containers/buildkit-cache-dance/blob/main/src/inject-cache.ts

reproducible-containers/buildkit-cache-dance を取り入れた後にソースコードを一部修正してワークフローを実行したところ、go buildの所要時間が 1 秒になりました。テスト用の修正は軽微なものだったので、どんなときでも 1 秒で完了するわけではないと思いますが、今まではどんなに小さな修正でもまるまる 60 秒以上はかかっていたので、大幅な短縮になりました。

おわりに

GitHub Actions 上で行う Docker ビルドのビルド時間を短縮しました。
今回はもともとのビルド時間が 3 分程度なのでメリットは少ないですが、大きな Web アプリケーションの場合にどれくらい効果があるのかが個人的に気になっています。

同じ悩みをお持ちの方はぜひ reproducible-containers/buildkit-cache-dance を使ってみてください。

最後までお読みいただきありがとうございました。

※おまけ:ランナー内で go build する

今回のケースでは Dockerfile で go build を行っていましたが、そもそもランナーでビルドを行うようにすれば RUN --mount=type=cacheなどの複雑なことをせずに、直感的にビルドキャッシュを操作できる気がします。

Discussion