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 で効果を確認する。

お気軽にご相談ください

お見積りへ お問い合わせへ
▲