目次
Docker マルチステージビルド入門:イメージを小さくする方法
多くのプログラムはビルド時にコンパイラー、開発用パッケージ、ビルドツールが必要ですが、実行時に必要なのはコンパイル済みの成果物だけです。すべてを同じイメージに入れると、最終イメージに使わないものが大量に含まれてしまいます。
マルチステージビルドでは、1 つの Dockerfile に複数の FROM を記述します。それぞれの FROM がステージです。最後のステージの内容だけが残り、それより前のステージはファイルを生成するために使います。
実験:単一ステージとマルチステージの比較
簡単な Go の Web プログラム(main.go)で比較します。
package main
import (
"fmt"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello from a distroless container!")
})
fmt.Println("Listening on :8080")
http.ListenAndServe(":8080", nil)
}
単一ステージ(Dockerfile.single):Go 公式イメージ内でコンパイルし、そのまま実行にも使います。
FROM golang:1.27
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /server .
CMD ["/server"]
マルチステージ(Dockerfile):
# ステージ 1:コンパイル
FROM golang:1.27 AS builder
WORKDIR /src
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/server .
# ステージ 2:実行
FROM gcr.io/distroless/static-debian13
COPY --from=builder /out/server /server
USER nonroot
ENTRYPOINT ["/server"]
AS builderで最初のステージに名前を付けます。COPY --from=builderでそのステージからファイルをコピーします。gcr.io/distroless/static-debian13は Google がメンテナンスする最小構成のイメージです。シェルやパッケージ管理ツールはなく、静的プログラムの実行に必要な最小限のファイルだけが含まれます。
マルチステージ版をビルドします。
$ docker build -t go-multi .
[+] Building 51.7s (14/14) FINISHED docker:default
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 326B 0.0s
=> [internal] load metadata for gcr.io/distroless/static-debian13:latest 2.1s
=> [internal] load metadata for docker.io/library/golang:1.27 0.4s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [builder 1/6] FROM docker.io/library/golang:1.27@sha256:e432b43af23a9328d56a7c499be047681 0.0s
=> => resolve docker.io/library/golang:1.27@sha256:e432b43af23a9328d56a7c499be0476810aa344ac 0.0s
=> [stage-1 1/2] FROM gcr.io/distroless/static-debian13:latest@sha256:58133991db06659feaabe0 3.4s
=> => resolve gcr.io/distroless/static-debian13:latest@sha256:58133991db06659feaabe0f4e97a35 0.0s
=> => … (ダウンロードと各レイヤーの展開を省略)
=> [internal] load build context 0.0s
=> => transferring context: 120B 0.0s
=> CACHED [builder 2/6] WORKDIR /src 0.0s
=> [builder 3/6] COPY go.mod ./ 0.0s
=> [builder 4/6] RUN go mod download 0.5s
=> [builder 5/6] COPY . . 0.0s
=> [builder 6/6] RUN CGO_ENABLED=0 go build -o /out/server . 47.6s
=> [stage-1 2/2] COPY --from=builder /out/server /server 0.0s
=> exporting to image 1.1s
=> => exporting layers 0.6s
=> => exporting manifest sha256:8b506df2e51c2cbf7df7b452669cde4652ac2d83d924e8e5b67ed8f37b6a 0.0s
=> => exporting config sha256:f1efccd3fdd70ffd1054b9ed0ff4154e3be0d93324c196bd46438e12a4b372 0.0s
=> => exporting attestation manifest sha256:4cf5049d1a21697ca953ccd67da3874da40c466c9db43ac9 0.0s
=> => exporting manifest list sha256:463874676a48cc064e399fd1968bcd5e301d77f20909360c40fff1c 0.0s
=> => naming to docker.io/library/go-multi:latest 0.0s
=> => unpacking to docker.io/library/go-multi:latest 0.4s
2 つのステージの手順が [builder …] と [stage-1 …] と表示され、BuildKit が両方のステージで必要なベースイメージを同時にダウンロードしていることがわかります。2 つのイメージのサイズを比較します。
$ docker images --filter reference=go-single --filter reference=go-multi
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
go-multi:latest 463874676a48 20.2MB 5.77MB
go-single:latest c6c844f9076e 1.43GB 343MB
同じプログラムでも、単一ステージは 1.43 GB、マルチステージは 20.2 MB で、70 倍の差があります。単一ステージのイメージには Go コンパイラー一式と Debian システムが含まれます。マルチステージの最終イメージにあるのは、コンパイル済みの実行ファイルと distroless の基本ファイルだけです。
イメージが小さいほどダウンロードとデプロイが速くなり、脆弱性のあるパッケージも少なくなる可能性があります。実行してみましょう。
$ docker run -d --name goapp -p 8080:8080 go-multi
d474e2de30d65e21a1f6395aac76c185fcfc8d4d6caa1240ab6ef3ebd7406e25
$ curl -s http://localhost:8080
Hello from a distroless container!
$ docker exec goapp sh
OCI runtime exec failed: exec failed: unable to start container process: exec: "sh": executable file not found in $PATH
$ docker rm -f goapp
goapp
distroless イメージには sh さえありません(executable file not found)。攻撃者がプログラムに侵入しても、コンテナ内でできることは限られます。代わりにデバッグが難しくなるため、必要なときはデバッグツールを含む :debug 版を使えます。
ほかの言語でのマルチステージ例
Node.js フロントエンド:ビルド後に Nginx に渡す
React、Vue、Astro などのフロントエンドプロジェクトは、ビルド後に静的ファイルだけが残るため、実行環境に Node.js は必要ありません。
FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.30-alpine
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
ビルドツールと node_modules 全体は最終イメージに含まれません。
Node.js バックエンド:本番環境用のパッケージだけを残す
FROM node:24-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json .
USER node
CMD ["node", "dist/index.js"]
deps と build の 2 つのステージは互いに独立しているため、BuildKit はそれらを並列にビルドします。
Java:JDK でコンパイルし、JRE で実行
FROM maven:3.9-eclipse-temurin-25 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn -q dependency:go-offline
COPY src ./src
RUN mvn -q package -DskipTests
FROM eclipse-temurin:25-jre
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
CMD ["java", "-jar", "app.jar"]
特定のステージだけをビルドする:--target
ステージを使うと、同じ Dockerfile から開発版と本番版を作成できます。
FROM node:24-alpine AS base
WORKDIR /app
COPY package.json ./
FROM base AS dev
ENV NODE_ENV=development
COPY . .
CMD ["npm", "run", "dev"]
FROM base AS prod
ENV NODE_ENV=production
COPY . .
CMD ["node", "server.js"]
$ docker build -q --target dev -t target-demo:dev .
sha256:3d0fdd2a77233426c90dfbc575aafaef652a1393cb2730d066e627362860ec09
$ docker build -q --target prod -t target-demo:prod .
sha256:9e14e3363d26d46ed628e49b4cd806a29e9699a5be2e6e4078976bd380632568
$ docker inspect --format '{{.Config.Env}} {{.Config.Cmd}}' target-demo:dev
[PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin NODE_VERSION=24.21.0 YARN_VERSION=1.22.22 NODE_ENV=development] [npm run dev]
$ docker inspect --format '{{.Config.Env}} {{.Config.Cmd}}' target-demo:prod
[PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin NODE_VERSION=24.21.0 YARN_VERSION=1.22.22 NODE_ENV=production] [node server.js]
--target dev は dev ステージまでをビルドし、--target prod は dev をスキップします。2 つのイメージでは環境変数と起動コマンドが異なります。-q を指定すると、docker build はイメージ ID だけを出力します。
外部イメージからコピーする
COPY --from では、このファイル内のステージに限らず、任意のイメージを直接指定できます。
COPY --from=nginx:1.30-alpine /etc/nginx/nginx.conf /etc/nginx/nginx.conf
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
2 行目はよく使われる方法です。uv の公式イメージから実行ファイルを直接コピーすれば、別途インストールする必要はありません。Python アプリをご覧ください。
まとめ
- 各
FROMが新しいステージを開始し、AS 名前で名前を付けます。 - 最後のステージが出力イメージになり、
COPY --fromでコピーしたものだけが含まれます。 - コンパイルツール、ソースコード、開発用パッケージを前のステージに残すことで、イメージを数十倍小さくできます。
--targetを使うと、テスト用ステージなど指定したステージまでだけをビルドできます。
練習問題
練習 1:マルチステージ Dockerfile にテストを実行する FROM base AS test ステージを追加してください。CI でテストだけを実行し、最終イメージはビルドしないようにするにはどうしますか?
練習 2:docker history を使って、本章の単一ステージとマルチステージの Go イメージを比較してください。最終イメージに Go コンパイラーのレイヤーはありますか?
ヒント
マルチステージの最終イメージには distroless のベースレイヤーと COPY --from=build のレイヤーだけがあります。Go ツールチェーンのレイヤーは見当たりません。
よくある質問
マルチステージビルドにするとビルドが遅くなりますか?
いいえ。BuildKit は依存関係のないステージを並列にビルドし、最後のステージに必要なステージだけをビルドします。実測結果は [Node.js アプリ](/ja/docker/nodejs/) の TypeScript の例をご覧ください。
中間ステージのイメージはパソコンに残りますか?
中間ステージから名前付きイメージは生成されませんが、そのレイヤーはビルドキャッシュに残ります。docker builder prune で削除できます。
COPY --from で別のイメージを指定できますか?
できます。たとえば COPY --from=nginx:1.30-alpine /etc/nginx/nginx.conf . のように、外部イメージから直接ファイルをコピーできます。本章で例を紹介します。
更新日 2026年10月14日