2026年9月6日
2026年9月6日
Dockerのマルチステージビルドの使い方|イメージを小さく安全にする書き方と注意点
マルチステージビルドは、1つのDockerfileに複数の FROM を書き、ビルド用のステージで作った成果物だけを実行用のステージへコピーする手法です。コンパイラやnpm、Composerなどのビルド道具を本番イメージに残さずに済むため、イメージが小さくなり、攻撃に使える道具も減ります。この記事では基本の書き方から、PHP(WordPressテーマ)とGoの実例、キャッシュの効かせ方、秘密情報の扱い、よくあるつまずきまでを説明します。
前提はDocker Engine 23.0以降の標準ビルダーであるBuildKitです(2026年10月現在)。イメージのタグは執筆時点の例なので、実際に使うときは公式イメージのページで現行のタグを確認してください。
単一ステージのDockerfileで何が起きているか
マルチステージを使わずにNode.jsでアセットをビルドし、PHPで動かすDockerfileを書くと、Node.js本体、node_modules、ビルド前のソースがすべて最終イメージに残ります。RUN rm -rf node_modules を後から書いても、Dockerのイメージはレイヤーを積み重ねる構造なので、前のレイヤーに入ったファイルは消えずにサイズとして残ります。
マルチステージビルドでは、最終イメージに含まれるのは最後のステージ(または --target で指定したステージ)のレイヤーだけです。前のステージはビルド中の作業場として使い、必要なファイルを COPY --from=ステージ名 で取り出します。
基本の書き方
# syntax=docker/dockerfile:1
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app .
FROM gcr.io/distroless/static-debian12
COPY --from=build /out/app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]
ポイントは三つです。
FROM ... AS buildでステージに名前を付けます。番号(--from=0)でも参照できますが、ステージを並べ替えたときに壊れるので名前のほうが安全です。COPY --from=buildは、そのステージのファイルシステムから指定したパスだけをコピーします。- 最終ステージのベースイメージは、実行に必要なものだけを持つ小さいものを選びます。Goの静的バイナリなら distroless や
scratchが使えます。
CGO_ENABLED=0 を付けているのは、C言語のライブラリに依存しない静的バイナリにするためです。これを付けずに glibc に動的リンクされたバイナリを作ると、distroless/static や Alpine(musl)の上では「not found」というエラーで起動しないことがあります。ファイルはあるのに not found と出る場合は、まずリンクの種類を疑ってください。
なお scratch や distroless にはシェルがないため、docker exec で中に入って調べることができません。また scratch にはCA証明書もタイムゾーンデータも入っていないので、HTTPS通信をするアプリでは証明書をビルドステージからコピーする必要があります。調査のしやすさを優先するなら、最初は alpine や debian:bookworm-slim を最終ステージにして、慣れてから小さくしていくのも一つの方法です。
WordPressテーマをビルドする例
WordPressのテーマ開発では、Node.jsでCSS・JavaScriptをビルドし、Composerで依存ライブラリを入れ、実行はPHPのイメージで行う、という組み合わせがよくあります。それぞれを別ステージにすると、本番イメージにNode.jsもComposerも残りません。
# syntax=docker/dockerfile:1
# 1. フロントエンドのアセットをビルド
FROM node:22-alpine AS assets
WORKDIR /theme
COPY package.json package-lock.json ./
RUN npm ci
COPY src/ ./src/
COPY vite.config.js ./
RUN npm run build
# 2. PHPの依存ライブラリを本番用だけインストール
FROM composer:2 AS vendor
WORKDIR /theme
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader
# 3. 実行用イメージ
FROM wordpress:php8.3-apache AS production
COPY --chown=www-data:www-data theme/ /var/www/html/wp-content/themes/mytheme/
COPY --from=assets --chown=www-data:www-data /theme/dist/ /var/www/html/wp-content/themes/mytheme/dist/
COPY --from=vendor --chown=www-data:www-data /theme/vendor/ /var/www/html/wp-content/themes/mytheme/vendor/
npm run build の出力先(ここでは dist/)や vite.config.js は、プロジェクトのビルド設定に合わせて読み替えてください。
composer install に --no-dev を付けるのは、PHPUnitなどの開発用パッケージを本番に入れないためです。ビルド用の composer:2 イメージのPHPと実行用イメージのPHPのバージョンが違うと、依存関係のチェックで弾かれたり、本番で動かないパッケージが入ったりすることがあります。その場合は、実行環境のPHPバージョンを composer.json の config.platform.php に書いておくと、Composerはそのバージョンを前提に解決します。
外部のイメージから直接ファイルを取ってくることもできます。たとえば COPY --from=composer:2 /usr/bin/composer /usr/bin/composer と書けば、PHPイメージにComposerの実行ファイルだけを入れられます。
ビルドキャッシュを効かせる順番
Dockerは命令ごとにキャッシュを持ち、ある命令の入力が変わると、それ以降の命令はすべて再実行になります。上の例で package.json と package-lock.json を先にコピーして npm ci を実行し、ソースは後からコピーしているのはこのためです。こうしておくと、ソースだけを変更したときは npm ci のキャッシュが使われ、依存ライブラリの入れ直しが起こりません。
さらにBuildKitでは、キャッシュマウントでパッケージマネージャーのダウンロードキャッシュをビルド間で使い回せます。キャッシュはイメージには含まれません。
RUN --mount=type=cache,target=/root/.npm npm ci
.dockerignore も合わせて用意してください。node_modules、.git、ローカルの vendor などがビルドコンテキストに含まれると、転送に時間がかかるうえ、COPY . . でホストのファイルがイメージに紛れ込みます。
秘密情報をイメージに残さない
プライベートなnpmパッケージやComposerリポジトリの認証情報を ARG や COPY でビルドステージに渡すと、最終イメージには入らなくても、ビルドステージのレイヤーやキャッシュ、ビルド履歴に残ります。マルチステージにしたから安全、とは言えません。認証情報は BuildKit のシークレットマウントで渡します。
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
docker build --secret id=npmrc,src=$HOME/.npmrc -t mytheme:latest .
シークレットはその RUN の実行中だけファイルとして見え、レイヤーには書き込まれません。
特定のステージだけをビルドする
--target を使うと、指定したステージまででビルドを止められます。開発用のステージを用意しておき、テストやデバッグに使うといった使い方ができます。
# 本番用
docker build --target production -t mytheme:prod .
# 依存ライブラリのステージだけ確認したいとき
docker build --target vendor -t mytheme:vendor .
Docker Composeでも同じ指定ができます。
services:
wordpress:
build:
context: .
target: production
BuildKitは、最終ステージ(またはターゲット)が依存していないステージをビルドしません。そのため、テスト用のステージを並べておいても、本番ビルドの時間は増えません。逆に「テストのステージを書いたのに実行されない」と感じたら、ターゲットから参照されていないことが原因です。
よくあるつまずき
ARGがステージ内で空になる。 最初の FROM より前に書いた ARG は FROM 行でしか使えません。ステージの中で使うには、そのステージ内で ARG 名前 をもう一度書きます。また ARG と ENV はステージごとに独立しているので、前のステージで設定した環境変数は次のステージに引き継がれません。
コピーしたファイルの所有者がroot。 COPY --from でコピーしたファイルは、指定しなければrootの所有になります。WordPressのようにWebサーバーのユーザーが書き込む必要がある場合は、--chown を付けるか、権限を見直してください。
パスが見つからない。 COPY --from のコピー元は、そのステージの WORKDIR からの相対パスではなく、ステージのファイルシステム上のパスです。/theme/dist/ のように / から始まるパスで書いておくと迷いません。
効果を確かめる
ビルドしたら、サイズとレイヤーの中身を確認します。
docker image ls mytheme
docker history mytheme:prod
docker history で、最終イメージのレイヤーに npm ci や composer install の行が出てこなければ、ビルド道具は最終イメージから外れています。あわせて docker run --rm -it mytheme:prod sh -c 'which node composer' のように、本番イメージに不要なコマンドが残っていないかも確認しておくと安心です。
まとめ
- マルチステージビルドは、ビルド用ステージの成果物だけを
COPY --fromで実行用ステージに移す手法。レイヤーに残ったファイルは後から削除してもサイズが減らないため、ステージを分けることに意味がある。 - ステージには
ASで名前を付け、最終ステージは実行に必要なものだけのベースイメージにする。Goなどの静的バイナリはCGO_ENABLED=0を忘れない。 - 依存ファイルを先にコピーしてキャッシュを効かせ、
.dockerignoreでコンテキストを絞る。 - 認証情報は
ARGではなくシークレットマウントで渡す。 --targetで途中のステージだけをビルドでき、Composeのbuild.targetでも指定できる。docker image lsとdocker historyで効果を確認する。