はじめに
株式会社サイバーエージェントでソフトウェアエンジニアをしている唐木稜生(@karamaru_alpha)です。前回の第1回では、Goバイナリを小さくビルドするための方法について紹介しました。
今回は、Go のアプリケーションを載せた docker-image を小さく・早く構築するための手法を紹介します。Dockerfile の最新記法や、Go アプリケーションを GitHubActions でビルドする際のチューニングTipsを網羅的にまとめ、皆様のCD改善のきっかけになれるような記事を目指します。
.dockerignore を適切に設定する
まず、どのように docker がファイルを扱うのかを確認しましょう。
docker build 時、dockerデーモンに指定ディレクトリ配下のファイルを tar アーカイブとして送信し、これがビルド時に COPY や ADD 経由でアクセスできるファイル BuildContext になります。
そして、.dockerignore はビルドコンテキストの対象から外すディレクトリ / ファイル一覧を設定できる仕組みです。これを適切に設定することにより、docker デーモンに転送するファイルサイズが減るのはもちろん、レイヤーのサイズも削減できます。
特に重い .git ディレクトリや、本番で使うことのないテスト・ドキュメントファイルは除外しておきたいところです。
.*
# 再帰的にファイルを除外するには *.md だけでは足りず、**/ 指定が必要なので注意
**/*.md
**/*_test.goレイヤーごとのファイル状況を目視する「dive」
dive は docker-image のレイヤーごとのファイル状況を可視化できるツールです。
dive ${image_tag} の結果を見ることで、本来ビルドに不要なディレクトリやファイルがレイヤーに紛れ込んでいないか確認できます。
以下の例では、testdata ディレクトリが余計に容量を圧迫していることが一目で分かります。.dockerignore で testdata ディレクトリをこのように除外することで対応しましょう。
定期的に docker-image の中身を dive で確認し、不要なファイルが混入していないか精査することをお勧めします。
BuildKit による自動 ignore
実は、.dockerignore を設定していなくてもビルドコンテキストの対象からビルドに関係のないファイルを除外する仕組みも存在します。
BuildKit は DockerBuild の拡張機能で、現在は本体のデフォルト機能として取り込まれています。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.gocf. 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:hogecf. 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 では COPY や ADD 命令は使わないのがモダンな書き方になりました。
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=0 で distroless/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.9MBBuildKit の圧縮を 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改善に貢献できていれば嬉しいです。
