こんにちは、かつコーチです。
Dockerfileの基本を押さえてイメージを作れるようになると、次にぶつかる壁が「イメージサイズが想像以上に大きい」という問題です。
コンパイラやビルドツール、開発用の依存パッケージまで本番用イメージに残ってしまい、数百MB〜1GB超えのイメージになっているケースをよく見かけます。
この記事は、Dockerfileの基本命令(FROM・COPY・RUN・CMD)を理解している前提で、マルチステージビルドを使ってイメージを軽量化する方法を、比較検証を交えて解説します。
マルチステージビルドは、料理でいう「下ごしらえと本番調理を分ける」工程に似ています。
野菜の皮むきや骨抜きに使った包丁やまな板を、そのままお客様に出す皿には乗せませんよね。
Dockerのビルドも同じで、ビルドに使った道具(コンパイラやビルドツール)は最終的な成果物(実行用イメージ)に持ち込まない、という考え方がマルチステージビルドの本質です。
マルチステージビルドの仕組み
従来のシングルステージの課題
まず、ビルドと実行を1つのステージで完結させたDockerfileを見てみます。
# シングルステージ版
FROM golang:1.23
WORKDIR /app
COPY . .
RUN go build -o server .
CMD ["./server"]
Goはコンパイル言語なので、本来は単一の実行バイナリさえあれば動きます。
しかし上記のDockerfileでは、Goのコンパイラやソースコード一式を含んだ golang:1.23 イメージ(1GB近くになることも珍しくありません)が、そのまま本番用イメージになってしまいます。
実行に不要なビルドツールチェーンが残り続けることは、イメージサイズの肥大化だけでなく、攻撃対象領域(脆弱性が入り込む余地)が増えるという点でもデメリットです。
複数のFROMでステージを分ける書き方
マルチステージビルドでは、1つのDockerfileの中に複数の FROM を書き、それぞれを「ステージ」として扱います。
# ステージ1:ビルド用
FROM golang:1.23 AS builder
WORKDIR /app
COPY . .
RUN go build -o server .
# ステージ2:実行用
FROM debian:bookworm-slim
WORKDIR /app
COPY --from=builder /app/server .
CMD ["./server"]
AS builder でステージに名前を付け、後半の実行用ステージから COPY --from=builder で必要なファイルだけを取り出しています。
最終的に生成されるイメージは、実行用ステージ(debian:bookworm-slim ベース)の内容だけになり、ビルド用ステージの中身は破棄されます。
実装手順
ビルド用ステージと実行用ステージの設計
マルチステージビルドを設計するときの基本方針は、「ステージごとに役割を1つに絞る」ことです。
- ビルド用ステージ:コンパイラ・ビルドツール・ソースコード一式を含む重量級のベースイメージ
- 実行用ステージ:実行に最低限必要なランタイムだけを含む軽量なベースイメージ(
alpine・slim・distrolessなど)
Node.jsのようにビルド成果物(dist ディレクトリなど)だけを本番で使う構成でも、同じ考え方が使えます。
# ステージ1:ビルド用
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# ステージ2:実行用
FROM node:20-slim
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY package*.json ./
RUN npm ci --omit=dev
CMD ["node", "dist/index.js"]
COPY –fromでステージ間ファイルを受け渡す
COPY --from=<ステージ名> が、マルチステージビルドの要となる命令です。
ステージ名の代わりに番号(--from=0 など、FROM の登場順)でも指定できますが、Dockerfileの途中でステージの順番を入れ替えると壊れやすいため、AS で名前を付ける書き方を基本にすることをおすすめします。
また、他のイメージをそのままビルド用ステージとして使うこともできます。
FROM golang:1.23 AS builder
# ...ビルド処理...
FROM gcr.io/distroless/base-debian12
COPY --from=builder /app/server /server
CMD ["/server"]
distroless イメージはシェルすら含まない極小イメージで、実行に必要な最低限のライブラリしか持たないため、さらなる軽量化・セキュリティ強化が図れます。
よくあるつまずきポイント・エラー対処
不要なビルドツールが最終イメージに残ってしまう
実際に私が担当案件でRustのAPIサーバーをコンテナ化した際、最初はシングルステージで組んでいました。
❌ Before:ビルドイメージをそのまま本番用として使う
FROM rust:1.80
WORKDIR /app
COPY . .
RUN cargo build --release
CMD ["./target/release/server"]
docker images で確認すると、このイメージは1.2GB近くありました。
Rustのビルドツールチェーン一式が本番環境にそのまま乗っていることに、レビューで指摘されて初めて気づきました。
✅ After:マルチステージ化して実行用イメージを分離する
FROM rust:1.80 AS builder
WORKDIR /app
COPY . .
RUN cargo build --release
FROM debian:bookworm-slim
WORKDIR /app
COPY --from=builder /app/target/release/server .
CMD ["./server"]
同じアプリケーションで比較したところ、イメージサイズは1.2GBから約80MBまで縮小できました。
docker images の出力を見比べたときの削減幅の大きさに、マルチステージビルドの効果を実感した経験です。
応用・ベストプラクティス
イメージサイズを比較検証する
マルチステージ化の効果は、実際に数値で比較するのが一番説得力があります。
docker build -t my-app:single -f Dockerfile.single .
docker build -t my-app:multistage -f Dockerfile.multistage .
docker images | grep my-app
CIパイプラインにこの比較をログとして残しておくと、チームでイメージサイズへの意識を共有しやすくなります。
さらに踏み込むなら、実行用ステージのベースイメージを slim → alpine → distroless の順に変えて、サイズと互換性のトレードオフを検証してみるとよいでしょう。
alpine はmusl libc採用による互換性の落とし穴があるため、既存のバイナリが動かない場合は slim 系に戻す判断も必要です。
まとめ
この記事のポイント
- マルチステージビルドは、ビルド用と実行用でステージ(FROM)を分ける手法
COPY --from=<ステージ名>で必要な成果物だけを実行用ステージに持ち込む- ビルドツールチェーンを本番イメージから排除でき、サイズ削減とセキュリティ向上を両立できる
slim・alpine・distrolessなど実行用ベースイメージの選択でさらに軽量化できる
次に読むべき記事
- よく使うDockerfile命令(FROM・COPY・RUN・CMD)
- ボリュームでデータを永続化する
タグ: Docker, 上級者向け, Dockerfile