【Docker】マルチステージビルドでイメージを軽量化する

Docker

こんにちは、かつコーチです。

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つに絞る」ことです。

  • ビルド用ステージ:コンパイラ・ビルドツール・ソースコード一式を含む重量級のベースイメージ
  • 実行用ステージ:実行に最低限必要なランタイムだけを含む軽量なベースイメージ(alpineslimdistroless など)

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パイプラインにこの比較をログとして残しておくと、チームでイメージサイズへの意識を共有しやすくなります。

さらに踏み込むなら、実行用ステージのベースイメージを slimalpinedistroless の順に変えて、サイズと互換性のトレードオフを検証してみるとよいでしょう。

alpine はmusl libc採用による互換性の落とし穴があるため、既存のバイナリが動かない場合は slim 系に戻す判断も必要です。

まとめ

この記事のポイント

  • マルチステージビルドは、ビルド用と実行用でステージ(FROM)を分ける手法
  • COPY --from=<ステージ名> で必要な成果物だけを実行用ステージに持ち込む
  • ビルドツールチェーンを本番イメージから排除でき、サイズ削減とセキュリティ向上を両立できる
  • slimalpinedistroless など実行用ベースイメージの選択でさらに軽量化できる

次に読むべき記事

  • よく使うDockerfile命令(FROM・COPY・RUN・CMD)
  • ボリュームでデータを永続化する

タグ: Docker, 上級者向け, Dockerfile

タイトルとURLをコピーしました