現場で即使える Go開発実践テクニック集 2

Goのdocker-imageを小さく・早く構築するためのTips「9選」

第2回の今回は、Goアプリケーションを含むdocker-imageのサイズを削減しながら、高速にビルドするためのTips9選を紹介します。

唐木 稜生

6:30

はじめに

株式会社サイバーエージェントでソフトウェアエンジニアをしている唐木稜生(@karamaru_alpha)です。前回の第1回では、Goバイナリを小さくビルドするための方法について紹介しました。

今回は、Go のアプリケーションを載せた docker-image を小さく・早く構築するための手法を紹介します。Dockerfile の最新記法や、Go アプリケーションを GitHubActions でビルドする際のチューニングTipsを網羅的にまとめ、皆様のCD改善のきっかけになれるような記事を目指します。

.dockerignore を適切に設定する

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

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

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

特に重い .git ディレクトリや、本番で使うことのないテスト・ドキュメントファイルは除外しておきたいところです。

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

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

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

dive ${image_tag} の結果を見ることで、本来ビルドに不要なディレクトリやファイルがレイヤーに紛れ込んでいないか確認できます。

以下の例では、testdata ディレクトリが余計に容量を圧迫していることが一目で分かります。.dockerignore で testdata ディレクトリをこのように除外することで対応しましょう。

定期的に docker-image の中身を dive で確認し、不要なファイルが混入していないか精査することをお勧めします。

BuildKit による自動 ignore

実は、.dockerignore を設定していなくてもビルドコンテキストの対象からビルドに関係のないファイルを除外する仕組みも存在します。

BuildKitDockerBuild の拡張機能で、現在は本体のデフォルト機能として取り込まれています。BuildKit には、明示的に COPY されないファイルをビルドコンテキストの対象から自動的に除外する機能があります。
> Detect and skip transferring unused files in your build context

例えば、 COPY dir_A . のように記述された Dockerfile がある場合、dir_A 以外のファイル群は BuildContext に送信されません。

ただし、dir_A の中にある不要ファイルの除外は行われませんし、COPY . . と記述していれば問答無用で全て転送されてしまいます。BuildContext の範囲を必要最小限にした上で、.dockerignore も適切に設定するのが理想的でしょう。

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

docker-image 参照時は最終ステージのみが配信されるため、ビルド環境と配信環境をステージで切り分け、成果物だけ最終ステージに残すことでイメージサイズを大幅に削減できます。

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

FROM golang:1.24
WORKDIR /src
COPY <<EOF ./main.go
package main

import "fmt"

func main() {
  fmt.Println("hello, world")
}
EOF
RUN go build -o /bin/hello ./main.go

FROM scratch
COPY --from=0 /bin/hello /bin/hello
CMD ["/bin/hello"]
$ docker images hoge --format "{{.Size}}"
# Before: 1.37GB
# After:  3.47MB

さらに、BuildKit には各ステージを並列にビルドする機能も備わっています。
> Parallelize building independent build stages

すでに開発が停止された kaniko などはステージ並列ビルドに未対応だったので、BuildKit へ移行するだけでビルド時間短縮の恩恵が得られるでしょう。

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

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

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

よって、変更頻度が低く実行時間のかかる処理を前半に、変更頻度が高い処理を後半に記述するのが鉄則になります。

したがって、Go アプリケーションであれば 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

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

蛇足ですが、pnpm でも同様のテクニックが使えます。package.json は変更されても pnpm-lock.yaml が変わらない場合(依存関係が変わらない場合)に、依存のダウンロードをスキップして store ディレクトリのキャッシュを活用できます。

COPY pnpm-lock.yaml .
RUN pnpm fetch --frozen-lockfile

COPY package.json .
RUN pnpm install --offline --frozen-lockfile

COPY . .
RUN pnpm build:hoge

cf. https://pnpm.io/ja/cli/fetch
> Fetch packages from a lockfile into virtual store, package manifest is ignored.

中間レイヤーの確認と圧縮方法について

後ほど docker-image の圧縮について扱うので、レイヤーの圧縮手法を確認する方法についてもここで確認しておきましょう。中間レイヤーのサイズや MediaType は crane で確認できます。

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

この出力から、このイメージの中間レイヤーが Gzip で圧縮されていることが分かります。

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

Bind mounts でレイヤーをスリムに保つ

COPY 命令でファイルを BuildContext へコピーすると、そのファイルはレイヤーに残り続けます。しかし本質的に欲しいのはビルドの成果物だけで、ソースファイル群はビルドが終われば不要です。

Bind mounts は、BuildContext のファイルを命令の実行中だけレイヤーにマウントする機能です。

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

右が Bind mounts を活用した image です。ビルド後、バイナリのソースとなり必要なくなったmain.go が中間レイヤーに残らないことが確認できました。

最終ステージでなくても、レイヤーサイズは小さいに越したことはありません。ランタイムでのファイル参照など特別な理由がない限り、Go アプリケーションの Dockerfile では COPYADD 命令は使わないのがモダンな書き方になりました。

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

レイヤーキャッシュが無効な状態でも、モジュールキャッシュやビルドキャッシュをレイヤーを跨いで個別に活用する方法についても紹介します。

Cache mounts は、ビルドを超えたキャッシュ置き場を指定する機能です。レイヤーキャッシュが効かなくても、2 回目以降のビルドで前回のキャッシュを再利用できます。

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' cmd/api/main.go

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

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

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

ベースイメージ自体のサイズを小さくすることも効果的です。

distroless は Google が提供している最小限の依存のみを含む image で、alpine と比較しても軽量かつセキュアです。内容物は以下の通りです。

  • gcr.io/distroless/static
    • ca-certificates
    • A /etc/passwd entry for a root user
    • A /tmp directory
    • tzdata
  • gcr.io/distroless/base
    • distroless/static の内訳
    • glibc
    • libssl

まず CGO_ENABLED=0distroless/static を試し、問題があれば distroless/base や他の選択肢を検討するのが良いでしょう 🙆

FROM golang:1.25.5-alpine3.21 AS builder
WORKDIR /src
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    --mount=source=go.mod,target=go.mod \
    --mount=source=go.sum,target=go.sum \
    --mount=source=main.go,target=main.go \
    CGO_ENABLED=0 go build -o api -trimpath -ldflags '-s' main.go

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}}"
# Before (alpine): 25MB
# After (distroless/static): 17.9MB

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

通常レイヤーは Gzip で圧縮されますが、zstdで圧縮することもできます。

zstd は Gzip と比べて圧縮率が高く、圧縮時間も短いです。展開速度も同程度なので、乗り換えによるデメリットはほとんどありません。

docker/build-push-action を使った GitHub Actions での設定例は以下になります。

- 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=.../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 のベストプラクティスに準拠し、圧縮レベル3を採用

この変更だけで、弊プロジェクトのある image は 20% サイズ削減されました。

Artifact Registry の画面からも、中間レイヤーが OCI 準拠で zstd 圧縮されていることを確認できます。

GitHub Actions のランナーを強化する(namespace.so)

最後に、実行環境を強化するシンプルかつ最も効果的なチューニング手法について解説します。

GitHub ホステッドランナーの性能を上げる、あるいはセルフホステッドランナーとして強いマシンを用意する方法もありますが、ここでは SaaS で手軽にハイスペックなランナーを使える namespace.so を紹介します。

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

namespace 専用 action に置き換えるだけで、cache の registry 設定が不要になります。

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: .
    outputs: type=registry,oci-mediatypes=true,compression=zstd,compression-level=3,force-compression=true
    tags: ...
    platforms: linux/amd64
    provenance: false
    sbom: false

この移行でプロジェクトの CI 実行時間が約半分に短縮されました。

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

他のセルフホステッドランナー SaaS としては Blacksmith なども候補に挙がります。年間契約でコストパフォーマンスが上がるケースもあるので、ぜひ比較検討してみてください。

その他 Tips と hadolint

apt / apk のキャッシュを削除してレイヤーサイズを削減することも有効です。

# apt
RUN apt-get update -q && apt-get -y install unzip && rm -rf /var/lib/apt/lists/*
# apk
RUN apk --no-cache add gcc

また、Dockerfile の品質を維持するために hadolint による静的解析を CI に組み込むことをお勧めします 🙆

- name: Lint Dockerfile
  uses: hadolint/hadolint-action@v3.1.0
  with:
    dockerfile: Dockerfile

まとめ

数字がちゃんと出るパフォーマンスチューニングはとても楽しいものです。この記事をきっかけに、皆様のプロジェクトのCD改善に貢献できていれば嬉しいです。

この記事をシェアしてください

人気記事トップ10

人気記事ランキングをもっと見る

企画広告も役立つ情報バッチリ! Sponsored