GitHub ActionsでGoのコンテナイメージをビルド・プッシュする際のベストプラクティスを考える
この記事は MICIN Advent Calendar 2024 の 24日目の記事です。
前回は菅原さんの、「MiROHAのエンジニアとして入社してみて」 でした。
はじめに
本記事では、GitHub ActionsでGoのコンテナイメージをビルド・プッシュする際のベストプラクティスを検討、紹介します。特に、キャッシュをどう設定するかに主軸を置いて展開していきます。
Goのコンテナイメージのビルド・プッシュに関する公式ドキュメント、記事などはたくさんある一方で、実際のプロダクト開発でどうCIを組めばベストなのか、計測まで行って比較検討したものは筆者の観測する限りほとんどありません。そのため、取り入れてみても思いの外改善しないな…となることが多いです。
そこで、2024年12月時点で考えうる手順をいろいろ計測しながら試してみて、ベストプラクティスを提示してみようと思います。
なお、筆者のプロダクト開発で採用し得るユースケースに絞っていますので、その点はご了承ください。また、先に結果を知りたい方はまとめからお読みください。
対象となるコード・環境
今回実験に使用したコードはこちらに置いています。
CI、コードなどの仕様は以下の通りです。
- CIはGitHub Actions固定。Runnerはデフォルトのもの固定
- ビルドするOS/CPUアーキテクチャは
linux/amd64
固定 - AWS Lambdaで利用するコンテナイメージを想定。ベースイメージは
public.ecr.aws/lambda/provided:al2023
を使用 - コンテナレジストリはAmazon Elastic Container Registry、us-west-2を使用
- Goのコードは中身はシンプルだが、それなりの量の外部パッケージ依存を再現するため
aws-sdk-go-v2
をたくさんblank import
import (
// ...
_ "github.com/aws/aws-sdk-go-v2/service/amplify"
_ "github.com/aws/aws-sdk-go-v2/service/apigatewayv2"
_ "github.com/aws/aws-sdk-go-v2/service/appconfig"
_ "github.com/aws/aws-sdk-go-v2/service/appconfigdata"
_ "github.com/aws/aws-sdk-go-v2/service/appmesh"
_ "github.com/aws/aws-sdk-go-v2/service/apprunner"
_ "github.com/aws/aws-sdk-go-v2/service/athena"
_ "github.com/aws/aws-sdk-go-v2/service/batch"
// ...
)
実験方法
- 4つのシナリオで計測してみました
- No cache キャッシュなし
- Use cache, no changes キャッシュあり、変更なし
- Use cache, code changes キャッシュあり、コードの変更あり。本記事ではこれを重視
- Use cache, package changes キャッシュあり、外部パッケージの変更あり
- シナリオそれぞれ3回ずつ実行、その平均値を使用します。小数点以下は切り捨てます
- 3回は連続して計測しているため、時間特性による偏りが多少あるかもしれませんが、今回はそこまで考慮しておりません
ちなみに、計測は以下のコードで、gh
コマンドを叩きまくる力業で対応しています。
実験
まずはマルチステージビルド、キャッシュなし
まずはよくあるマルチステージビルドで、キャッシュなしでビルド・プッシュしてみます。
Dockerfileは以下の通りです。
FROM golang:1.23-bullseye AS builder
WORKDIR /go/src/github.com/micin-jp/chicken-api
COPY go.mod go.sum ./
RUN go mod download
COPY main.go ./
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /dist/app .
FROM public.ecr.aws/lambda/provided:al2023 AS runner
COPY /dist/app ./app
ENTRYPOINT ["./app"]
GitHub Actionsのstepsは次のようになります。
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-west-2
- uses: aws-actions/amazon-ecr-login@v2
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
file: multistage-copy.Dockerfile
push: true
platforms: linux/amd64
provenance: false
tags: ${{ env.IMAGE_URI }}
結果は以下の通り。キャッシュを保存していないため、どのシナリオも変わらず210s程度になっています。
No cache | Use cache, no changes | Use cache, code changes | Use cache, package changes | |
---|---|---|---|---|
Multi-stage build, no cache | 211s | 208s | 209s | 209s |
レイヤーキャッシュを導入
次に、Dockerのレイヤーキャッシュを導入してみます。 レイヤーキャッシュ有効にすることで、コンテナイメージのビルドの命令ごとに、コマンド・関連するファイルに差分がなければそのレイヤーの成果物を流用できます。
GitHub Actionsでレイヤーキャッシュを扱う方法は、Docker公式のCache management with GitHub Actionsで紹介されている4つが挙げられます。
- Inline cache
- 最終成果物のコンテナイメージをキャッシュにも利用
-
mode=min
しか利用できない(=ビルド途中のレイヤーがキャッシュとして利用できない)
- Registry cache
- 最終成果物のコンテナイメージとは別に、キャッシュ用のコンテナイメージを保存
- GitHub cache
- GitHub Actionsのキャッシュに保存
- 2024年12月時点でExperimental
- Local cache
- キャッシュをホストのファイルシステムにエクスポート、それをGitHub Actionsのキャッシュに保存
それぞれ、GitHub Actionsのstepsにcache-from
,cache-to
を設定します。
- uses: docker/build-push-action@v6
with:
context: .
file: multistage-copy.Dockerfile
push: true
platforms: linux/amd64
provenance: false
tags: ${{ env.IMAGE_URI }}
# Inline cache
cache-from: type=registry,ref=${{ env.IMAGE_URI }}
cache-to: type=inline
# Registry cache
# 参考: https://aws.amazon.com/jp/blogs/news/announcing-remote-cache-support-in-amazon-ecr-for-buildkit-clients/
cache-from: type=registry,ref=${{ env.IMAGE_URI }}-buildcache
cache-to: type=registry,ref=${{ env.IMAGE_URI }}-buildcache,mode=max,image-manifest=true,oci-mediatypes=true
# GitHub cache
cache-from: type=gha
cache-to: type=gha,mode=max
# Local cache
# これに加えて、cacheの準備・移動のstepも追加する必要あり
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
これら4つのシナリオを試した結果が次の通り。
No cache | Use cache, no changes | Use cache, code changes | Use cache, package changes | |
---|---|---|---|---|
Multi-stage build, no cache | 211s | 208s | 209s | 209s |
Multi-stage build, use layer cache (inline) | 213s | 27s | 214s | 243s |
Multi-stage build, use layer cache (registry) | 246s | 32s | 245s | 257s |
Multi-stage build, use layer cache (gha) | 261s | 30s | 240s | 254s |
Multi-stage build, use layer cache (local) | 250s | 66s | 260s | 264s |
いずれも一切変更がない場合は高速な一方で、Inline cache以外のキャッシュなし、コード変更、外部パッケージ変更のシナリオは30秒以上遅くなってしまいました。
外部パッケージ変更のシナリオはgo.mod, go.sumのハッシュ値も変更になり、レイヤーキャッシュが効かなくなるのは頷けますが、それ以外のシナリオはなぜ遅くなったのでしょう?
答えは、キャッシュの保存・準備に時間を要してしまったためです。mode=max
ですべてのレイヤーキャッシュをいずれかのストレージに保存するとき、それなりのIOの時間が取られます。
例えばRegistry cacheの場合、コンテナレジストリに本体の45MBのコンテナイメージと別に、552MBのキャッシュ用コンテナイメージがアップロードされます。これを保存時、ビルド時それぞれで大きめのIO負担がかかってしまいます。
ECRのコンテナイメージ一覧より。Registry cache利用の場合、本体に加えて、キャッシュ用のコンテナイメージも保存される
CIのログを確認すると、キャッシュ用コンテナイメージのダウンロードに13s、エクスポート・アップロードには33s程度かかっており、ビルド時間の短縮を超えたデメリットになってしまっています。これはGitHub cache, Local cacheの場合でも同等のコストが確認できます。
実際のユースケースとして、CIでコンテナイメージのビルド・プッシュを走らせる時はコード変更を含む場合のほうが多いと思います。特に考えずキャッシュを入れただけで実は逆効果、という可能性もありますので気を付けておきましょう。
BuildKitのオプションでGoのキャッシュも使う
次に、マルチステージビルドを維持しつつ、BuildKitのオプションを活用してGo自体のモジュールキャッシュ・ビルドキャッシュを利用してみます。
RUN --mount=type=cache...
といったオプションを使うことで、レイヤーにファイルをコピーが不要になる・ホストのキャッシュをそのまま利用できるなど多くの利点を享受できます。より詳細な解説はフューチャーさんのこちらの記事で詳しく解説されていますのでぜひご参照ください。
Dockerfileは次のようになります。
FROM golang:1.23-bullseye AS builder
WORKDIR /go/src/github.com/micin-jp/chicken-api
RUN \
go mod download
RUN \
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /dist/app .
FROM public.ecr.aws/lambda/provided:al2023 AS runner
COPY /dist/app ./app
ENTRYPOINT ["./app"]
GitHub Actionsのstepsは次のようになります。レイヤーキャッシュは設定が容易なGitHub cacheを採用しています。
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-west-2
- uses: aws-actions/amazon-ecr-login@v2
- uses: docker/setup-buildx-action@v3
- uses: actions/cache@v4
with:
path: |
go-mod-cache
go-build-cache
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
- uses: reproducible-containers/buildkit-cache-dance@v3.1.2
with:
cache-map: |
{
"go-mod-cache": "/go/pkg/mod",
"go-build-cache": "/root/.cache/go-build"
}
- uses: docker/build-push-action@v6
with:
context: .
file: multistage-mount.Dockerfile
push: true
platforms: linux/amd64
provenance: false
tags: ${{ env.IMAGE_URI }}
cache-from: type=gha
cache-to: type=gha,mode=max
actions/cache
に加えて、reproducible-containers/buildkit-cache-dance
という見慣れないstepが登場します。これはDocker公式のCache management with GitHub Actionsでも紹介されている通り、デフォルトではGitHub ActionsキャッシュにBuildKitのキャッシュマウントを保存しないため、それを保存・再利用させるためのワークアラウンドとなっています。
こちらの記事で詳しく解説されていたのでご参照ください。
4つのシナリオを動かしてみた結果がこちら。
No cache | Use cache, no changes | Use cache, code changes | Use cache, package changes | |
---|---|---|---|---|
Multi-stage build, no cache | 211s | 208s | 209s | 209s |
Multi-stage build, use layer cache (gha) | 261s | 30s | 240s | 254s |
Multi-stage build, use layer cache (gha), go cache | 294s | 140s | 146s | 285s |
レイヤーキャッシュのみの場合に比べて、コード変更のみのシナリオで約100s短縮になりました。それ以外のシナリオは30s〜100sと伸びてしまっていますが、コード変更のみというケースが実際の開発では多いので採用するならこちらかなと思います。
一方で、stepごとの実行時間を見てみると、reproducible-containers/buildkit-cache-dance
の実行・後処理に25s, 54s費やしています。
GitHub Actionsのログ。reproducible-containers/buildkit-cache-dance
の処理に時間がかかる
ログを見た限り、キャッシュのコピーに多くの時間が取られてしまうようです。
マルチステージビルドは不要?
ここまでマルチステージビルドで、レイヤーキャッシュ・Goのキャッシュを取り入れる工夫をしてきました。改善する点はあるものの、IOに関するところが無視できないレベルで負担がかかっていることがわかりました。
そこでそもそもなのですが、マルチステージビルドをやめてみる、という選択肢も考えられるのではないでしょうか。特にGoの場合はクロスコンパイルが簡単に実行できるので、ホストとターゲットのコンテナイメージの環境が違えどホストでのビルドが可能です。
実際に試してみます。Dockerfileは次の通り。
FROM public.ecr.aws/lambda/provided:al2023 AS runner
COPY ./dist/app ./app
ENTRYPOINT ["./app"]
GitHub Actionsのstepsは次のようになります。
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-west-2
- uses: aws-actions/amazon-ecr-login@v2
- uses: docker/setup-buildx-action@v3
- uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: Build go app
run: |
mkdir -p ./dist
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o ./dist/app .
- uses: docker/build-push-action@v6
with:
context: .
file: runneronly.Dockerfile
push: true
platforms: linux/amd64
provenance: false
tags: ${{ env.IMAGE_URI }}
cache-from: type=gha
cache-to: type=gha,mode=max
actions/setup-go
はデフォルトでGoのモジュールキャッシュ・ビルドキャッシュが保存・再利用されます。run:
でホストでGoをビルドし、docker/build-push-action
では成果物をコピーするだけになります。
結果はこちら。
No cache | Use cache, no changes | Use cache, code changes | Use cache, package changes | |
---|---|---|---|---|
Multi-stage build, no cache | 211s | 208s | 209s | 209s |
Multi-stage build, use layer cache (gha) | 261s | 30s | 240s | 254s |
Multi-stage build, use layer cache (gha), go cache | 294s | 140s | 146s | 285s |
Build in host, use layer cache (gha), go cache | 227s | 43s | 45s | 211s |
コード変更のシナリオで45sと、かなり短縮できました。それ以外のシナリオもそこそこです。 Dockerfileだけでビルドを完結できないデメリットこそあれ、十分実用的な選択肢ではないでしょうか。
Dockerfileも不要?
最後に、Docker-lessなアプローチも試してみます。ko はGoのためのコンテナイメージビルドツールで、Dockerfileを書くことなく、シンプルに高速にコンテナイメージをビルドできます。
こちらの紹介記事もご参照ください。
早速試してみます。Dockerfileは不要で、代わりに以下の設定ファイルを用意します。
defaultBaseImage: public.ecr.aws/lambda/provided:al2023
defaultPlatforms:
- linux/amd64
builds:
- id: main
dir: .
main: .
env:
- CGO_ENABLED=0
- GOOS=linux
- GOARCH=amd64
GitHub Actionsのstepsは次のようになります。
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-west-2
- uses: actions/setup-go@v5
with:
go-version: '1.23'
- uses: ko-build/setup-ko@v0.7
- name: Build and push image
run: |
KO_DOCKER_REPO=${IMAGE_NAME} ko build --bare --tags ${IMAGE_TAG} .
koのコマンドについてはやや癖がある印象でした。ひとまず--bare
コマンドでこれまでのstepsに合わせることができました。
結果はこちら。
No cache | Use cache, no changes | Use cache, code changes | Use cache, package changes | |
---|---|---|---|---|
Multi-stage build, no cache | 211s | 208s | 209s | 209s |
Multi-stage build, use layer cache (gha) | 261s | 30s | 240s | 254s |
Multi-stage build, use layer cache (gha), go cache | 294s | 140s | 146s | 285s |
Build in host, use layer cache (gha), go cache | 227s | 43s | 45s | 211s |
Build with ko, use go cache | 189s | 25s | 23s | 189s |
いずれのシナリオもかなり高速!コード変更のシナリオで、ひとつ前の結果より半分の時間に短縮できました。Docker-lessでGo特化であるが故の成せる技なのかもしれません。
一方で、koを使うとDockerfileの細かなカスタムは難しそうという印象でした。筆者の扱うプロダクトでは以下のようにDatadog Lambda Extensionを差し込む変更をしているため、選択肢からは外れてしまいます。
FROM public.ecr.aws/lambda/provided:al2023 AS runner
COPY ./dist/app ./app
+COPY /opt/. /opt/
ENTRYPOINT ["./app"]
特にDockerfileのカスタムが不要な場合、有力な選択肢になるのでぜひご検討ください。
まとめ
- マルチステージビルドで教科書通りにレイヤーキャッシュを設定しても速度改善しない、むしろ悪化することもある
- BuildKitのオプション(
--mount=type=cache
など)を活用してGoのキャッシュを利用させても、IOに時間がかかるstepが外せない - GitHub Actionsホストで直接ビルド→成果物を
COPY
するとシンプルかつ高速 - Dockerfileのカスタム不要ならkoを使うのもあり
計測結果のまとめもこちらに貼っておきます。
No cache | Use cache, no changes | Use cache, code changes | Use cache, package changes | |
---|---|---|---|---|
Multi-stage build, no cache | 211s | 208s | 209s | 209s |
Multi-stage build, use layer cache (inline) | 213s | 27s | 214s | 243s |
Multi-stage build, use layer cache (registry) | 246s | 32s | 245s | 257s |
Multi-stage build, use layer cache (gha) | 261s | 30s | 240s | 254s |
Multi-stage build, use layer cache (local) | 250s | 66s | 260s | 264s |
Multi-stage build, use layer cache (gha), go cache | 294s | 140s | 146s | 285s |
Build in host, use layer cache (gha), go cache | 227s | 43s | 45s | 211s |
Build with ko, use go cache | 189s | 25s | 23s | 189s |
また、まとめる前の雑な状態ですが細かな結果はこちらに置いてます。
おわりに
GitHub ActionsでGoのコンテナイメージをビルド・プッシュする方法をいくつか比較検討し、自分なりの答えを出してみました。教科書通りの設定でなんとなく設定していたところですが、直感に反するような結果もあり計測してみることの大切さを実感しました。
もちろん、コードの特性によって結果がかなり変わってくるものだと思われるので、導入の際は試してみて、環境に適した選択をしましょう。
MICINではメンバーを大募集しています。
「とりあえず話を聞いてみたい」でも大歓迎ですので、お気軽にご応募ください!
Discussion