GitHub Actions 上での Go の Docker ビルドを高速化する
どうも 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 のステップで行っており、イメージとして以下のような内容になっています。
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 /workdir/app /app
ENTRYPOINT ["/app"]
0. 広く知られた方法を守る(割愛)
Docker ビルドを高速化させるために広く知られている以下の方法を守ります。説明は割愛します。
0-1. Docker のベースイメージを小さくする
pull にかかる時間が削減されます。
0-2. レイヤーキャッシュを意識して Dockerfile を書く
COPY
する順番や |
や&
でコマンドを繋げてレイヤを減らすなどのテクニックです。
0-3. マルチステージビルドを行う
マルチステージビルドを行うことで最終イメージを小さくできるので、docker push
にかかる時間を短縮できます。
0-4. ランナーのスペックを上げる
GitHub Actions Runner のスペックを上げることでビルド時間を短縮できます。GitHub Teams および Enterprise のみ有効な方法です。
方法1. Docker レイヤーキャッシュを効かせる
GitHub Actions ホステッドランナーはジョブごとに別のランナーが立ち上がります。つまり、次のビルド時にレイヤーキャッシュを持ち越して使うことができません。持ち越して使うにはレイヤーキャッシュをエクスポートする必要があります。
エクスポートは buildx
の --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 のビルドキャッシュが効いていないためです。
Go は過去のビルド成果物をキャッシュし、次のビルドで再利用できるようになっています。しかしジョブごとに新しい GitHub Action ホステッドランナーが用意されるため、ビルドキャッシュを使い回せておらず、ソースコードを一部変更しただけでも最大のビルド時間がかかってしまう、という仕組みでした。
調べたところ Go のビルドキャッシュは /root/.cache/go-build
で管理されるため、このディレクトリを GitHub Actions Cache に置いて使い回すと良さそうだと分かったのですが、Docker コンテナのファイルシステムの /root/.cache/go-build
ディレクトリが、ホスト(GitHub Actions ランナー)から見たときにどのディレクトリに対応するのかは不明なため、簡単にはキャッシュできません。
しかしその問題も、reproducible-containers/buildkit-cache-dance
というアクションで解決することができました。
このアクションは、RUN --mount=type=cache
の動作を利用して Docker 環境の特定のディレクトリを extract したり inject したりできるアクションで、 docker docs でも紹介されています。
仕組みとしては以下になります。
キャッシュディレクトリの extract
- 自分が用意した Dockerfile と全く同じキャッシュマウント先(
RUN --mount=type=cache
)を持つ Dockerfile を用意する。そのキャッシュマウント先の中身を自身にcp
するステップが書かれている - その Dockerfile をビルドしコンテナの作成までを行う
-
docker cp
コマンドを使って Docker 環境からホスト環境にディレクトリをコピーする
extract してきたファイルが配置されるディレクトリを、今度は GitHub Actions Cache にアップロードすれば、ランナー外に保存できます。docker cp コマンドは知らなかったので勉強になりました。
詳しい処理はソースコードをご覧ください。
キャッシュディレクトリの inject
- 自分が用意した Dockerfile と全く同じキャッシュマウント先(
RUN --mount=type=cache
)を持つ Dockerfile を用意する。そのとき、--mount=type=bind
も併用し、ホストのディレクトリをマウントする。マウントしたディレクトリをキャッシュマウント先にcp
するステップが書かれている - その Dockerfile をビルドする
ビルドされるとステップが実行され、指定したホストのディレクトリの中身が Docker 環境のキャッシュディレクトリにコピーされる仕組みです。ホストのディレクトリには GitHub Actions Cache からダウンロードしてきたファイルを置くことになります。
こちらも詳しい処理はソースコードをご覧ください。
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