docker-buildのチューニングTips全部書く【Go×GitHubActions】

docker の最新記法や、GitHubActions でのビルドチューニングについて網羅的に書かれている記事って意外と少ない!

主に Go*GitHubActions での image ビルドについて、実行時間を短く・レイヤーを小さくする Tips を共有します 🚀

はじめに

QualiArts Advent Calendar 2025 の 4 日目の記事です!

最近 Go のビルド周りを改善するのが趣味で、情報が結構散らばっていて困った経験があるのでまとめます。

皆様の docker-build 改善のきっかけになれるような記事を目指します!どれか刺され!🔥

目次

1. .dockerignore を適切に設定する

まずどのように docker がファイルを扱うのかを確認しましょう。

docker build時、docker デーモンに指定ディレクトリ配下のファイルを tar アーカイブとして送信し、これがビルド時にCOPYADD経由でアクセスできるファイル群(BuildContext)になります。

引用

そして、.dockerignoreはビルドコンテキストの対象から外すディレクトリ/ファイル一覧を設定できる仕組みです。これを適切に設定することにより、docker デーモンに転送するファイルサイズが減るのはもちろん、レイヤーのサイズも削減できます。

特に重い.gitファイルや、本番で使うことのないテスト・ドキュメントファイルなどは特別必要ないのであれば除外しておきたいところですね 🙆

.*
# 再起的にファイルを除外するには*.mdだけでは足りず、**/指定が必要なので注意
**/*.md
**/*_test.go

これを活用して infracost という terraform からコスト差分を検出できる CLI へ OSS コミットできたりしました 👏

cf. https://github.com/infracost/infracost/pull/3480

1-1. レイヤーごとのファイル状況を目視する「dive」

diveはレイヤーごとに image のファイル状況を可視化できる神ツールです。

上記 PR もdive ${image_tag}の結果を参考にしたもので、COPY . .で本来必要のないtestdataディレクトリが余計に容量を圧迫していることが分かりますね。

定期的に image の中身を dive で確認し、必要ないものが入っていないか精査することをお勧めします 🙆

1-2. BuildKit による自動 ignore

私の.dockerignore薄すぎ…?」と思った人も大丈夫。救済があります。

BuildKitDockerBuildの拡張ツールで、現在は本体のデフォルト機能として取り込まれています。BuildKit には、明示的にアクセスがないファイルは自動的にビルドコンテキストの対象から除外する機能が導入されています 👏

Detect and skip transferring unused files in your build context

例えばCOPY dir_A .のみの Dockerfile なら、dir_A以外のディレクトリは BuildContext に送信しないようにしてくれる感じですね!私たちは恵まれた時代に生きています。

一方、dir_A中にある不要ファイルの除外は行ってくれませんし、COPY . .と記述していたら問答無用で全て転送されてしまいます。

BuildContext から取得するファイルの範囲を必要最小限に記述した上で、.dockerignoreも適切に設定するのが好ましいでしょう。

2. マルチステージビルドを行う

正直耳タコな話ですが一応記述します。

参照時は最終ステージのみが配信されるため、ビルド環境の配信環境はステージを切り分け、成果物だけ最終ステージに残すことでイメージサイズを落とすテクニックでしたね。

公式の例示では以下のような結果となりました。

 1FROM golang:1.24
 2WORKDIR /src
 3COPY <<EOF ./main.go
 4package main
 5
 6import "fmt"
 7
 8func main() {
 9  fmt.Println("hello, world")
10}
11EOF
12RUN go build -o /bin/hello ./main.go
13
14+ FROM scratch
15+ COPY --from=0 /bin/hello /bin/hello
16CMD ["/bin/hello"]
$ docker images hoge --format "{{.Size}}"
- // 1.37GB
+ // 3.47MB

ちなみに、BuildKitには各ステージを並列ビルドしてくれる機能も備わっています。

Parallelize building independent build stages

弊プロジェクトはかつて kaniko(2025/06 にアーカイブ)を使っており、これはステージごとの並列ビルドに対応していなかったため、乗り換えるだけでビルド時間短縮の恩恵が得られました。

3. レイヤーキャッシュがヒットしやすい構成にする

耳タコな話が続きますが、まずはレイヤーキャッシュについてのおさらいからです。

Dockerfile 内の各命令(RUNCOPYなど)はそれぞれ独立した「レイヤー」を作成し、ファイルシステムの変更差分(diff)として積み重ねられていく構造になっています。

ビルド実行時、Docker は各レイヤーに対して、命令とファイル内容のチェックサムが同じか判定し、同じならそのレイヤーは再実行されずキャッシュから取得される。 キャッシュが無効なレイヤーが挟まるとそれ以降のレイヤーは全て再実行される点が重要です。

引用

よって、変更の少ない && 実行時間がかかる処理を前半に記述し、変更の多い処理を後半に記述するのが鉄則になります。

Go 言語であれば、モジュールのダウンロードを先んじて切り出すことで、アプリケーションロジックのみの変更があった際、モジュールの DL レイヤーをスキップできます。

+ COPY go.mod go.sum .
+ RUN go mod download
  COPY . .
  RUN go build main.go

cf. https://docs.docker.com/build/cache/optimize/#order-your-layers

3-1. pnpm におけるレイヤーキャッシュ活用

蛇足ですが、pnpm の場合も同じようなことが言えます。

package.jsonファイルは変更されたがpnpm-lockファイルが変更されない(依存関係が変わらない)場合に、依存のダウンロードをスキップしてstoreディレクトリのcacheを用いることができます。

+ COPY pnpm-lock.yaml .
+ RUN pnpm fetch --frozen-lockfile
+ COPY package.json .
+ RUN pnpm install --offline --frozen-lockfile
  COPY . .
  RUN pnpm build:hoge

pnpm fetch、最近知りました。便利です。

cf. https://pnpm.io/ja/cli/fetch

3-2. 中間レイヤーの確認と tar 圧縮について

ちなみに、中間レイヤーのサイズや MediaType はcraneというツールを使うことで確認できます。

$ crane manifest --platform linux/amd64 nginx:1.29.3-alpine
{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.manifest.v1+json",
  "config": {
    "mediaType": "application/vnd.oci.image.config.v1+json",
    "digest": "sha256:d4918ca78576a537caa7b0c043051c8efc1796de33fee8724ee0fff4a1cabed9",
    "size": 10963
  },
  "layers": [
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "sha256:2d35ebdb57d9971fea0cac1582aa78935adf8058b2cc32db163c98822e5dfa1b",
      "size": 3802452
    },
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "sha256:8f6a6833e95d43ac524f1f9c5e7c1316c1f3b8e7ae5ba3db4e54b0c5b910e80a",
      "size": 1835502
    },
	...

ここから、この image の中間レイヤーが Gzip で圧縮されていることが分かったりします(後で使うから覚えておいて!)。

cf. https://github.com/opencontainers/image-spec/blob/v1.1.0/layer.md#gzip-media-types

4. Go バイナリのシンボルテーブル・デバッグ情報を削除する

少し寄り道して、Go バイナリを小さくする話もします。

go build時に ldflags-sを指定するとシンボルテーブルなどのデバッグ情報が削除されバイナリサイズを削減できます。 ビルド環境のローカルパス情報を隠蔽する-trimpathと並び、Gopher なら 2 億回目にするやつですね

Hello World で試すと以下のような差分になりました。

- go build main.go && du -h main // 2.3M
+ go build -trimpath -ldflags="-s" main.go && du -h main // 1.5M

ちなみに、昔はよく-s -wという記法を見ましたが、Go1.22 からは-s のみで完結するようになっています。

このような昔からある話でも意外と OSS commit チャンスがあったりするのでおすすめです 🎉

cf. https://github.com/infracost/infracost/pull/3479

4-1. UPX によるバイナリ圧縮とレイヤー圧縮

さらに脱線して、Go バイナリの圧縮について検討してみましょう。

UPXは実行ファイルを圧縮するツールです。 圧縮されたファイルは自身の展開プログラムもバイナリに含むため、ユーザーが外から展開処理を記述する必要がなく非常にスマートです。

Hello World のバイナリサイズは UPX を噛ませることでこんなに小さくなります。

- go build -trimpath -ldflags="-s" main.go && du -h main
- // 1.5M
+ go build -trimpath -ldflags="-s" main.go && upx -q --force-macos main && du -h main
+ // 676K

さて、UPX を Dockerfile 内で行う必要があるのかという議論が面白いのです。

先述した通り、レイヤーは通常 tar.gz (Gzip)で圧縮されるため、部分的に 2 重圧縮となり効率が悪くなるケースが存在します。 また、そもそも自身の展開プログラムが余分にバイナリに入っていたりする影響で、思うように image サイズが下がらない場合がままあるようです。

かなりハッキーなので、一旦は UPX は使わないでよさそうという結論に着地しましょう。

スーパー面白記事紹介コーナー

以下記事がとても勉強になりました、ありがとうございました。 @knqyf263

cf. コンテナイメージ内の実行ファイルを upx で圧縮するべきか | フューチャー技術ブログ

5. Bind mounts でレイヤーをスリムにたもつ

さて、Docker に話を戻しましょう。

今まではCOPY命令で必要なファイルを BuildContext に移植した上でビルドしていましたが、本質的にはレイヤーの成果物として欲しいのはビルドの成果物だけで、それに必要なファイル群はビルドが終わってしまえばレイヤーに保持しておく必要ありません。

Bind mountsは、BuildContext のファイルを命令の間だけ image にマウントする機能です。

- COPY main.go .
- RUN go build -o /bin/hello ./main.go
+ RUN --mount=source=main.go,target=main.go \
+  	go build -o /bin/hello ./main.go

右が Bind mounts を活用した image です。

ビルドのみに必要なmain.goというファイル が中間レイヤーに入り込まないことを確認できます 👏

たとえ最終ステージでなくても、レイヤーのサイズは小さいに越したことはないです。

ランタイムでファイルの参照があるなど特別な理由がない限り、Go 言語では COPYADDを使わなくていい時代になったのは少しだけ大きな転換点でしたね 🚀

6. Cache mounts でレイヤーを跨いでキャッシュする

次に、レイヤーキャッシュが無効な状態でもビルドキャッシュなどを個別に活用する方法についてです。

Cache mountsは、ビルドを超えたキャッシュ置き場フォルダを指定する機能です。たとえレイヤーキャッシュが効かなくても、2 回目以降のビルドであれば前回の module/build キャッシュを活用することができます。

+ RUN --mount=type=cache,target=/go/pkg/mod \
      --mount=source=go.mod,target=go.mod \
      --mount=source=go.sum,target=go.sum \
      go mod download

+ RUN --mount=type=cache,target=/root/.cache/go-build \
+     --mount=type=cache,target=/go/pkg/mod \
      --mount=source=go.mod,target=go.mod \
      --mount=source=go.sum,target=go.sum \
      --mount=source=cmd/api,target=cmd/api \
      CGO_ENABLED=0 go build -o api -trimpath -ldflags '-s -w' cmd/api/main.go

私が所属するプロジェクトのある image では、Bind/Cache mounts を導入したことでビルド時間が大幅に改善しました。

ちなみに、BuildKit はデフォルトで Cache mounts のキャッシュを GitHubActions で使うことができないため、CI 環境では専用の action「buildkit-cache-dance」を使う必要があることに注意が必要です。

スーパー面白記事紹介コーナー

とても勉強になりました、ありがとうございました。@shibu_jp

cf. 2024 年版の Dockerfile の考え方&書き方 | フューチャー技術ブログ

7. distroless などの軽量イメージ を base-image に選択する

次に、そもそものベースイメージのサイズを小さくします。

distroless は Google が提供している必要最小限の依存のみが含まれる image で、alpine と比較しても軽量且つセキュアであることが特徴です。内容物は以下の通りです。

とりあえずCGO_ENABLED=0distroless/staticに載せてビルドしてみて、ダメだったら base や他の選択肢考える心持ちで良きだと思います 🙆

FROM golang:1.25.5-alpine3.21 AS builder
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o api ./main.go

- FROM alpine:3.22.2
+ FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /usr/src/
COPY --from=builder /src/api .
CMD ["/usr/src/api"]

単純な http サーバーで検証したところ、以下のようなサイズ削減ができました 👏

$ docker images hoge --format "{{.Size}}"
- 25MB
+ 17.9MB

8. BuildKit の圧縮を Gzip → zstd に変更する

次に、レイヤーの圧縮手法についてです。 先述した通り、通常レイヤーは Gzip 圧縮されますが、実は zstd で圧縮することもできます

圧縮レベルにもよりますが、zstd は Gzip に比べ比較的圧縮率が高く、必要時間も短い、展開速度も同程度ということで、乗り換えることによるデメリットはあまりなさそうでした。

docker/build-push-actionは GitHubActions で docker-build する際のデファクトスタンダードで、こちらで検証してみましょう。

  - name: Build and push Docker image
    uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
    with:
      context: .
      file: ${{ needs.init.outputs.docker_file }}
-     push: true
+     outputs: type=registry,oci-mediatypes=true,compression=zstd,compression-level=3,force-compression=true
      tags: ${{ inputs.location }}-docker.pkg.dev/${{ env.GOOGLE_CLOUD_PROJECT }}/${{ inputs.repository_name }}/${{ inputs.target }}:${{ needs.init.outputs.image_tag_name }}
      platforms: linux/amd64
      cache-from: |
        type=registry,ref=${{ inputs.location }}-docker.pkg.dev/${{ env.GOOGLE_CLOUD_PROJECT }}/${{ inputs.repository_name }}/${{ inputs.target }}/cache:latest
      cache-to: |
-       type=registry,ref=${{ inputs.location }}-docker.pkg.dev/${{ env.GOOGLE_CLOUD_PROJECT }}/${{ inputs.repository_name }}/${{ inputs.target }}/cache:latest,mode=max
+       type=registry,ref=${{ inputs.location }}-docker.pkg.dev/${{ env.GOOGLE_CLOUD_PROJECT }}/${{ inputs.repository_name }}/${{ inputs.target }}/cache:latest,mode=max,oci-mediatypes=true,compression=zstd,compression-level=3,force-compression=true
      provenance: false
      sbom: false

oci-mediatypes=true

OCI 準拠でビルドする。

force-compression=true

中間レイヤーも強制的に圧縮する

compression-level=3

AWS-fagate のベストプラクティスに準拠した圧縮レベルを採用。

compression-level=3 – zstd には 22 段階の圧縮レベルがあります。圧縮レベルが高いほど、コンテナイメージのサイズは小さくなりますが、イメージレイヤーを解凍するための CPU リソースも増加します。AWS Fargate の起動時間を短縮するには、ワークロードの開始前にイメージレイヤーをダウンロードして解凍する必要がありますが、最高レベルの圧縮が最速の AWS Fargate の起動時間をもたらすとは限りません。最適な圧縮レベルを見つけるために、自身のコンテナイメージで検証すると良いでしょう。私たちのテストでは、圧縮レベル 3 が最適でした。

私の所属するプロジェクトのとある image では、この圧縮方法の変更だけで 20%サイズが削減されました 🚀

また、ArtifactRegisty の画面から中間レイヤーが OCI 準拠で zstd されていることを確認できました。

9. GitHubActions のランナーを強化する(namespace.so)

最後に、実行環境を強くするというシンプルだけど最も効果的な方法です 🚀

GitHub ホステッドランナーの性能をあげてもいいし、自前で CloudRunWorkerPool などでセルフホステッドランナーを立ててもいいでしょう。

今回は、お得でハイスペックなセルフホステッドランナーを SaaS で建てられるnamespace.soを紹介します。

namespace の最も素晴らしい機能はCache Volumesです。 キャッシュデータをネットワーク越しにアップロード・ダウンロードするのではなく、ボリュームを物理的にアタッチすることで、キャッシュの I/O による遅延が大幅に改善されます。

namspace 専用 action に置き換えると以下になります。cache の registry 設定が必要なくなっていることに注目です。

  - name: Set up Docker Buildx
-   uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
+   uses: namespacelabs/nscloud-setup-buildx-action@7020d7d8e659afecbfec162ab4693c7e56278311 # v0.0.19

  # (中略)

  - name: Build and push Docker image
    uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
    with:
      context: .
      file: ${{ needs.init.outputs.docker_file }}
      outputs: type=registry,oci-mediatypes=true,compression=zstd,compression-level=3,force-compression=true
      tags: ${{ inputs.location }}-docker.pkg.dev/${{ env.GOOGLE_CLOUD_PROJECT }}/${{ inputs.repository_name }}/${{ inputs.target }}:${{ needs.init.outputs.image_tag_name }}
      platforms: linux/amd64
-     cache-from: |
-       type=registry,ref=${{ inputs.location }}-docker.pkg.dev/${{ env.GOOGLE_CLOUD_PROJECT }}/${{ inputs.repository_name }}/${{ inputs.target }}/cache:latest
-     cache-to: |
-       type=registry,ref=${{ inputs.location }}-docker.pkg.dev/${{ env.GOOGLE_CLOUD_PROJECT }}/${{ inputs.repository_name }}/${{ inputs.target }}/cache:latest,mode=max,oci-mediatypes=true,compression=zstd,compression-level=3,force-compression=true
      provenance: false
      sbom: false

弊プロジェクトでは、この移行で CI の実行時間が訳半分に減少しました 👏

cf. https://namespace.so/docs/solutions/github-actions/docker-builds#skip-github-actions-caching

セルフホステッドランナーの SaaS は blacksmithなども候補ですかね。 namespace 程の最適化があるのかは存じ上げませんが、ぜひ色々検討してみてください 🙆

年間契約などで性能が上がったのにお得になるみたいな減少が起こるかもしれません ☺️

10. その他細かいやつと hadolint

他にもレイヤーサイズの削減として、apt のキャッシュを削除したり

- RUN apt-get update -q && apt-get -y install unzip
+ RUN apt-get update -q && apt-get -y install unzip && rm -rf /var/lib/apt/lists/*

apk の cache を削除したり

- RUN apk add gcc
+ RUN apk --no-cache add gcc

色々なテクニックがありそうですね。

Dockerfile の品質を保つためにも、定期的にhadolintで静的解析を行うことをお勧めします 🙆

まとめ

いかがだったでしょうか?

数字がちゃんと出るパフォーマンスチューニングって楽しいですよね!🚀

このブログをきっかけに、皆様のプロジェクトの改善のきっかけになれば幸いです。

ではまた!