
2025/12/12 19:23
Building small Docker images faster
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
要約
著者は、Python を中心とした $DAYJOB で Go の RCE‑as‑a‑service を構築しました。速度と人気を理由に Rust や Nix よりも Go を選択しました。
最初は Nix を使って OCI イメージを作成し、最小例(
pkgs.dockerTools.streamLayeredImage)で /hello だけが入った 45.8 MB のイメージを生成しました。既に Docker/Docker Compose が使用されていたため、それへ切り替え、Go バイナリを静的リンク(
CGO_ENABLED=0)し、マルチステージビルドで scratch まで減らすことで Goose マイグレーションツールのイメージを 15.9 MB に削減する方法を示しました。
投稿では実際のビルド時最適化も紹介しています:
- ビルドコンテキストを小さく保つ—ディレクトリ内全てが送られますが、
で除外できます。推奨エントリーは.dockerignore
、*
、Dockerfile*
(デフォルトではdocker-compose.yml
は無視されません)。.git - ソースファイルにはバインドマウントを使い、Go のビルドキャッシュはキャッシュマウントで共有し、コピーは最小限に。
- 複数アプリのステージを分けると BuildKit が並列で構築できます。
- 外部ダウンロードや Git リポジトリには
を使い、ローカルファイルにはADD
を使用します。COPY
最後に Docker Compose の watch モードはコンテキストを監視し、ファイルが変更されると自動でイメージを再構築して開発中の高速反復を可能にします。
イメージサイズを 45 MB から 16 MB 未満に削減することで、チームはストレージコストを節約し、ネットワーク転送時間と CI パイプラインの実行時間を短縮、クラウドデプロイメントコストも低減でき、開発者・DevOps 及び業界全体にメリットが広がります。
本文
Goを選んだ理由
$DAYJOB で初めて Go サービスを構築することになりました。社内はほぼ Python 専門ですが、なぜ Go を選択したかというと、同僚の中にはゴーファー(Gopher)好きもいるし、Go は簡潔で大手企業にサポートがあり、Python よりずっと高速なので、Rust や残念ながら Nix ではなく Go を推進しやすいという判断でした。
私の担当したプロジェクトは「RCE‑as‑a‑Service」的なものです。リモートでコードを実行できる言語の中で信頼できるのが Go の実装しか無く、いつも通りに「スネークロップ」をやらずに一息つける余地があると感じました。Nix に特化した環境を作ろうと短期的に試してみたものの、結局 Docker と Docker Compose(我々が通常使っているツール)へ切り替えました。
Nix で最小限のイメージ
OCI イメージ構築には Nix が得意です。以下はサービスだけを入れた極小イメージ(
/bin/sh は含まない)の例です。
{ pkgs ? import <nixpkgs> {} }: pkgs.dockerTools.streamLayeredImage { name = "someimage"; tag = "latest"; config.Cmd = [ "${pkgs.hello}/bin/hello" ]; }
nix-build docker-test.nix でビルドし、./result に生成されたスクリプトを実行してイメージをロードします。
./result | docker load docker image ls
結果は 45.8 MB。ほとんどが glibc のサイズです。
Docker で同等の構築
静的 Go バイナリ(
CGO_ENABLED=0)をビルドし、便利機能(例:coreutils)をやめれば、ほぼ同じ結果に仕上げられます。
サンプル:最小イメージ
データベースマイグレーションには goose を使っています。
docker-compose.yml では DB が起動した直後にそのコンテナを走らせています。
services: migrate: image: migrate:latest pull_policy: build build: context: https://github.com/pressly/goose.git#v3.26.0 dockerfile: $PWD/Dockerfile.migrate environment: GOOSE_DBSTRING: postgresql://AzureDiamond:hunter2@db:5432/bobby GOOSE_MIGRATION_DIR: /migrations GOOSE_DRIVER: postgres depends_on: db: condition: service_started volumes: - ./migrations:/migrations
build.context はイメージ構築時に Docker が利用できるファイルの集合です。通常は .(現在のディレクトリ)ですが、GitHub の URL とタグを指定すれば、そのコミットのリポジトリルートが使われます。Docker で可能なのに知られていないテクニックです。
build.dockerfile に $PWD を書く理由は、Compose がコンテキスト外ファイルを参照する際に絶対パスしか受け付けないためです。他のディレクトリから docker compose を実行すると壊れる可能性がありますが、ここでは問題ありません。
Dockerfile
FROM golang:1.25-alpine3.23 AS builder WORKDIR /build ARG CGO_ENABLED=0 ARG GOCACHE=/root/.cache/go-build ARG GOMODCACHE=/root/.cache/go-mod RUN --mount=type=cache,target=$GOCACHE \ --mount=type=cache,target=$GOMODCACHE \ --mount=type=bind,source=.,target=/build \ go build -tags='no_clickhouse no_libsql no_sqlite3 no_mssql no_vertica no_mysql no_ydb' \ -o /goose ./cmd/goose FROM scratch COPY --from=builder /goose /goose CMD ["/goose", "up"]
- Alpine をビルダーに使うことで軽量化。
で静的リンク済みバイナリを保証。CGO_ENABLED=0
により BuildKit が Go のビルドキャッシュを再利用。--mount=type=cache- ソースは
でコピーせずにマウントし、余計なコピーを防止。--mount=type=bind,source=.,target=/build - 最終ステージは
にしてイメージ内にバイナリだけ残す。scratch
結果は 15.9 MB の単層イメージで、ビルド・ロード・起動が瞬時です。
ビルドコンテキスト
Docker は
docker build を実行したディレクトリ以下の 全て をビルダーに送ります。不要なファイルが多いと転送量が増えるので、.dockerignore で除外します。
# .dockerignore .* Dockerfile* docker-compose.yml
重要点:コンテキストには
.dockerignore にリストされていない全てのファイルが含まれます。.git, ./.jj, Dockerfile 自体、ビルドアーティファクトなどはすべて送られます。
レイヤーを細分化
レイヤーを分割・並べ替えることでビルド時間とキャッシュ利用率が向上します。以下はもう少し複雑な例です。
FROM golang:1.25-alpine3.23 AS builder WORKDIR /build ARG CGO_ENABLED=0 ARG GOCACHE=/root/.cache/go-build ARG GOMODCACHE=/root/.cache/go-mod # 依存ツールを早めにインストール(例:orchestrion) RUN --mount=type=cache,target=$GOCACHE \ --mount=type=cache,target=$GOMODCACHE \ go install github.com/DataDog/orchestrion@latest # 依存関係のダウンロード(変更頻度が低い) RUN --mount=type=bind,source=go.mod,target=go.mod \ --mount=type=bind,source=go.sum,target=go.sum \ --mount=type=cache,target=$GOCACHE \ --mount=type=cache,target=$GOMODCACHE \ go mod download # バイナリのビルド RUN --mount=type=bind,source=go.mod,target=go.mod \ --mount=type=bind,source=go.sum,target=go.sum \ --mount=type=cache,target=$GOCACHE \ --mount=type=cache,target=$GOMODCACHE \ --mount=type=bind,source=internal,target=internal \ --mount=type=bind,source=cmd,target=cmd \ --mount=type=bind,source=orchestrion.tool.go,target=orchestrion.tool.go \ orchestrion go build -o server ./cmd/server FROM alpine:3.23 AS prod WORKDIR /app COPY --from=builder /build/server . ENTRYPOINT ["/app/server"]
- 変更頻度が低いツール(
等)を早期にインストール。orchestrion - 依存関係はコードより先にダウンロードしてキャッシュ化。
- 各ステップで必要なディレクトリだけをバインドマウントし、レイヤーサイズとキャッシュ効率を最適化。
結論
| テクニック | 何ができるか |
|---|---|
| キャッシュ活用 | Docker の「Optimize cache usage in builds」ページ参照。 |
| レイヤー順序 | , , を先に置き、頻繁に変わるステージは後ろへ。 |
| マルチステージ | 最終イメージを , , で縮小。 |
| アプリ別ビルド | 各アプリを独立したステージで構築し、BuildKit が並列実行。 |
| 等を除外してコンテキスト転送を高速化。 |
| キャッシュマウント | Go のビルドキャッシュを永続化(GitHub Actions で外部キャッシュ利用可)。 |
| バインドマウント vs COPY | ソースは を優先、外部ファイル取得には を使用。 |
| 小さなベースイメージ | パッケージマネージャが必要なら中間イメージを作り、ランナーが Debian ミラーにアクセスしないようにする。 |
| Compose watch | Docker Compose はファイル変更時に自動で再ビルドできるウォッチモード付き。 |
以上です。お役に立てれば幸いです。 Happy building!