⛴️

GitHub ActionsでGoのコンテナイメージをビルド・プッシュする際のベストプラクティスを考える

2024/12/24に公開

この記事は MICIN Advent Calendar 2024 の 24日目の記事です。

https://adventar.org/calendars/10022

前回は菅原さんの、「MiROHAのエンジニアとして入社してみて」 でした。


はじめに

本記事では、GitHub ActionsでGoのコンテナイメージをビルド・プッシュする際のベストプラクティスを検討、紹介します。特に、キャッシュをどう設定するかに主軸を置いて展開していきます。

Goのコンテナイメージのビルド・プッシュに関する公式ドキュメント、記事などはたくさんある一方で、実際のプロダクト開発でどうCIを組めばベストなのか、計測まで行って比較検討したものは筆者の観測する限りほとんどありません。そのため、取り入れてみても思いの外改善しないな…となることが多いです。

そこで、2024年12月時点で考えうる手順をいろいろ計測しながら試してみて、ベストプラクティスを提示してみようと思います。

なお、筆者のプロダクト開発で採用し得るユースケースに絞っていますので、その点はご了承ください。また、先に結果を知りたい方はまとめからお読みください。

対象となるコード・環境

今回実験に使用したコードはこちらに置いています。

https://github.com/abekoh/go-ecr-deploy

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コマンドを叩きまくる力業で対応しています。

https://github.com/abekoh/go-ecr-deploy/tree/7f9c9e8dd89110940eac88d2db7cb30969a5a33e/measure

実験

まずはマルチステージビルド、キャッシュなし

まずはよくあるマルチステージビルドで、キャッシュなしでビルド・プッシュしてみます。
Dockerfileは以下の通りです。

multistage-copy.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 --from=builder /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利用の場合、本体に加えて、キャッシュ用のコンテナイメージも保存される
ECRのコンテナイメージ一覧より。Registry cache利用の場合、本体に加えて、キャッシュ用のコンテナイメージも保存される

CIのログを確認すると、キャッシュ用コンテナイメージのダウンロードに13s、エクスポート・アップロードには33s程度かかっており、ビルド時間の短縮を超えたデメリットになってしまっています。これはGitHub cache, Local cacheの場合でも同等のコストが確認できます。

実際のユースケースとして、CIでコンテナイメージのビルド・プッシュを走らせる時はコード変更を含む場合のほうが多いと思います。特に考えずキャッシュを入れただけで実は逆効果、という可能性もありますので気を付けておきましょう。

BuildKitのオプションでGoのキャッシュも使う

次に、マルチステージビルドを維持しつつ、BuildKitのオプションを活用してGo自体のモジュールキャッシュ・ビルドキャッシュを利用してみます。

RUN --mount=type=cache...といったオプションを使うことで、レイヤーにファイルをコピーが不要になる・ホストのキャッシュをそのまま利用できるなど多くの利点を享受できます。より詳細な解説はフューチャーさんのこちらの記事で詳しく解説されていますのでぜひご参照ください。

https://future-architect.github.io/articles/20240726a/

Dockerfileは次のようになります。

multistage-mount.Dockerfile
FROM golang:1.23-bullseye AS builder

WORKDIR /go/src/github.com/micin-jp/chicken-api

RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
    --mount=type=cache,target=/root/.cache/go-build,sharing=locked \
    --mount=type=bind,source=go.mod,target=go.mod \
    --mount=type=bind,source=go.sum,target=go.sum \
    go mod download

RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    --mount=type=bind,source=.,target=. \
    CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /dist/app .

FROM public.ecr.aws/lambda/provided:al2023 AS runner

COPY --from=builder /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のキャッシュマウントを保存しないため、それを保存・再利用させるためのワークアラウンドとなっています。

こちらの記事で詳しく解説されていたのでご参照ください。

https://zenn.dev/takamin55/articles/8c68349a069b4c

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のログ。の処理に時間がかかる
GitHub Actionsのログ。reproducible-containers/buildkit-cache-danceの処理に時間がかかる

ログを見た限り、キャッシュのコピーに多くの時間が取られてしまうようです。

マルチステージビルドは不要?

ここまでマルチステージビルドで、レイヤーキャッシュ・Goのキャッシュを取り入れる工夫をしてきました。改善する点はあるものの、IOに関するところが無視できないレベルで負担がかかっていることがわかりました。

そこでそもそもなのですが、マルチステージビルドをやめてみる、という選択肢も考えられるのではないでしょうか。特にGoの場合はクロスコンパイルが簡単に実行できるので、ホストとターゲットのコンテナイメージの環境が違えどホストでのビルドが可能です。

実際に試してみます。Dockerfileは次の通り。

runner-only.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を書くことなく、シンプルに高速にコンテナイメージをビルドできます。

https://github.com/ko-build/ko

こちらの紹介記事もご参照ください。

https://ymotongpoo.hatenablog.com/entry/2021/12/22/090000

早速試してみます。Dockerfileは不要で、代わりに以下の設定ファイルを用意します。

.ko.yaml
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を差し込む変更をしているため、選択肢からは外れてしまいます。

runneronly-datadog.Dockerfile
 FROM public.ecr.aws/lambda/provided:al2023 AS runner
 
 COPY  ./dist/app ./app
+COPY --from=public.ecr.aws/datadog/lambda-extension:66 /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

また、まとめる前の雑な状態ですが細かな結果はこちらに置いてます。

https://github.com/abekoh/go-ecr-deploy/blob/7f9c9e8dd89110940eac88d2db7cb30969a5a33e/measure/summary.md

おわりに

GitHub ActionsでGoのコンテナイメージをビルド・プッシュする方法をいくつか比較検討し、自分なりの答えを出してみました。教科書通りの設定でなんとなく設定していたところですが、直感に反するような結果もあり計測してみることの大切さを実感しました。

もちろん、コードの特性によって結果がかなり変わってくるものだと思われるので、導入の際は試してみて、環境に適した選択をしましょう。


MICINではメンバーを大募集しています。
「とりあえず話を聞いてみたい」でも大歓迎ですので、お気軽にご応募ください!

https://recruit.micin.jp/

株式会社MICIN

Discussion