マルチステージビルドで Docker の実行イメージを小さくする

phpnodejsDockerOSSGo
マルチステージビルドで Docker の実行イメージを小さくする

本番用の 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 で指定したステージ)だけです。ビルド用ステージはビルド中に使われるだけで、成果物のレイヤには入りません。

イメージにすると、次のような流れです。

  1. builder ステージで依存を入れ、コンパイルやバンドルする
  2. runtime ステージは小さいベースイメージから始める
  3. 成果物だけを 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 cinpm 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:2vendor を作り、実行イメージは 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 を置く

ビルドコンテキストに .gitnode_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 downloadnpm 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 だけをコピーするところから始めると、効果を確認しやすいです。

この記事をシェアする