本番用の Docker イメージが 1 GB を超えている、という話は珍しくありません。ビルドは通るのに pull に何分もかかる、デプロイのたびに待たされる、CI のキャッシュも効きにくい。
原因の多くは、コンパイルや npm install に使ったツールチェーンを、実行イメージに残したままにしていることです。Docker 17.05 から使えるマルチステージビルドを使うと、ビルド環境と実行環境を分け、成果物だけを最終イメージに残せます。
この記事では、イメージが大きくなる理由から、Node.js / Go / PHP の Dockerfile 例、サイズの確認方法、さらに小さくするための補足までをまとめます。
なぜ実行イメージが大きくなるのか
Docker イメージはレイヤの積み重ねです。RUN で入れたパッケージ、コピーしたソース、ビルドキャッシュは、後からファイルを消してもレイヤとしては残ります。
特に効いてくるのは次の 3 点です。
- コンパイラや SDK(
gcc、JDK、Go 本体、Node のビルド用パッケージ) - 開発用の依存(
node_modulesの devDependencies、Composer のrequire-dev) - ソースコードや中間成果物(
.git、テスト、ドキュメント)
実行時に必要なのは、バイナリか、本番用の依存と設定ファイルだけです。ビルド用の道具は、最終イメージに置く必要がありません。
マルチステージビルドとは
Dockerfile に FROM を複数書き、ステージごとに役割を分ける書き方です。後ろのステージから、前のステージのファイルを COPY --from= で受け取れます。
最終イメージになるのは、最後の FROM(または --target で指定したステージ)だけです。ビルド用ステージはビルド中に使われるだけで、成果物のレイヤには入りません。
イメージにすると、次のような流れです。
- builder ステージで依存を入れ、コンパイルやバンドルする
- runtime ステージは小さいベースイメージから始める
- 成果物だけを builder からコピーする
単一ステージとの違いは、「消す」のではなく「最初から入れない」ことです。削除しても、そのファイルを入れたレイヤは残ります。ステージを分けると、実行イメージ側のレイヤにビルド用ファイルが現れません。
単一ステージの例
Node.js の API を、よくある書き方でイメージ化するとこうなります。
FROM node:22
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
CMD ["node", "dist/server.js"]
これでも動きます。ただし node:22 は Debian ベースで約 1 GB あり、その上にビルド用の node_modules とソースが乗ります。本番で tsc もテストランナーも使いませんが、イメージには残ります。
docker images で見ると、1 GB 超になっていることがほとんどです。
Node.js: ビルドと実行を分ける
フロントエンドを静的ファイルにビルドして nginx で配信する場合の例です。
# syntax=docker/dockerfile:1
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine AS runtime
COPY --from=builder /app/dist /usr/share/nginx/html
builder で npm ci と npm run build を行い、runtime には dist だけをコピーしています。nginx の Alpine イメージは数十 MB で、Node 本体も node_modules も入りません。
API サーバのように Node 実行が必要な場合は、実行ステージも Node にします。入れる依存は本番用だけです。
# syntax=docker/dockerfile:1
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]
ポイントは次のとおりです。
- ビルドは
npm ci(lock ファイル固定、再現性が高い) - 実行ステージは
npm ci --omit=dev - ソースではなく
distだけをコピーする - 可能なら非 root ユーザで動かす
これだけで、フルの node:22 単一ステージより数百 MB 単位で小さくなることが多いです。
Go: 静的バイナリを scratch に載せる
Go は静的リンクしやすいので、マルチステージとの相性がよいです。
# syntax=docker/dockerfile:1
FROM golang:1.24-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/app
FROM gcr.io/distroless/static-debian12 AS runtime
COPY --from=builder /out/app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]
CGO_ENABLED=0 で C 依存を切ると、 glibc のない distroless / scratch にも載せられます。コンパイラもモジュールキャッシュも最終イメージには入りません。バイナリ単体なら 10 MB 前後、ベースを scratch にすればさらに小さくできます。
scratch はシェルも CA 証明書も持たないので、デバッグはしにくいです。HTTPS を使うサービスなら、distroless か、Alpine に CA だけ入れたイメージの方が無難です。
PHP: Composer の成果物だけを残す
PHP はインタプリタなので Go ほど劇的には減りません。それでも、Composer と拡張のビルドを実行ステージから外せます。
# syntax=docker/dockerfile:1
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install \
--no-dev \
--no-interaction \
--no-progress \
--prefer-dist \
--optimize-autoloader
FROM php:8.3-fpm-alpine AS runtime
WORKDIR /var/www/html
COPY --from=vendor /app/vendor ./vendor
COPY . .
composer:2 で vendor を作り、実行イメージは php:8.3-fpm-alpine です。ビルド用の PHP 拡張が必要なら、builder 側で docker-php-ext-install し、成果物だけをコピーする形にもできます。
Laravel なら、実行ステージにコピーするのは vendor、アプリケーションコード、公開ディレクトリです。.env や storage の中身はイメージに入れず、実行時にマウントするか、エントリポイントで用意します。
サイズを確認する
ビルド後は、タグを付けて比較すると効果が分かります。
docker build -t app:single -f Dockerfile.single .
docker build -t app:multi -f Dockerfile .
docker images app
より詳しくレイヤを見たい場合は dive が便利です。どのレイヤで肥大しているか、消したつもりで残っているファイルがないかを確認できます。
dive app:multi
BuildKit の出力でも、各ステージの差分は追えます。最終タグのイメージだけがレジストリに push される、という点を押さえておけば十分です。
さらに小さくするための補足
マルチステージは主手段ですが、単体では足りないことがあります。
.dockerignore を置く
ビルドコンテキストに .git、node_modules、テスト用フィクスチャを入れると、転送もキャッシュも無駄になります。最低限、次は除外します。
.git
node_modules
vendor
storage/logs
*.md
Dockerfile*
COPY . . を使うほど、dockerignore の効果は大きくなります。
ベースイメージを選ぶ
| ベース | 向いている用途 | 注意点 |
|---|---|---|
*-alpine |
汎用。パッケージが揃っている | musl なので一部の glibc バイナリは動かない |
| distroless | Go / Java など単一バイナリ | シェルがなく、デバッグしにくい |
scratch |
完全な静的バイナリ | CA 証明書や tzdata も自分で入れる |
「小さいこと」より「実行に足りること」を先に満たしてください。Alpine にしただけで動くとは限りません。
RUN はまとめて、キャッシュは消す
同じステージ内でも、パッケージキャッシュをレイヤに残さない方がよいです。Debian / Ubuntu 系なら、次のように 1 つの RUN で入れます。
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
Alpine なら apk add --no-cache を使います。--no-cache を付けると、インデックスをイメージに残しません。
キャッシュマウントでビルドを速くする
サイズとは別ですが、BuildKit のキャッシュマウントを使うと、go mod download や npm ci の再実行が速くなります。最終イメージには入りません。
RUN --mount=type=cache,target=/root/.npm npm ci
イメージサイズの話と混同しやすいので、「ビルド時間の話」と切り分けて使うとよいです。
運用上の注意
小さくしたイメージは、中に入って調査しにくくなります。distroless や scratch では docker exec でシェルが使えません。ログを標準出力に出す、ヘルスチェックを HTTP で用意する、デバッグ用に --target builder で中間ステージをビルドできるようにしておく、といった逃げ道を先に作っておくと安全です。
また、実行ユーザ、ポート、読み取り専用ルートファイルシステムはサイズとは独立した話です。小さくできても、root で特権コンテナのままでは運用上の利点が半分になります。
まとめ
- 実行イメージにビルドツールを残さない
- Dockerfile は builder と runtime に分ける
- runtime には成果物と本番依存だけを
COPY --from=する .dockerignoreと小さいベースイメージで、さらに削る- サイズは
docker imagesと dive で確認する
マルチステージビルドは特別な機能ではなく、Dockerfile の書き方の問題です。単一ステージで動いているイメージほど、分けたときの差が出ます。まずは Node の dist だけをコピーするところから始めると、効果を確認しやすいです。