マルチステージビルドでイメージサイズを半分にする
概要
Docker イメージのサイズは、ビルド時間・デプロイ時間・レジストリ転送量・コンテナ起動、そして攻撃対象領域(アタックサーフェス)に直結します。ビルドに必要なツール(コンパイラ・devパッケージ・ソース一式)を最終イメージに残すと、実行に不要なものまで抱え込み、簡単に数百MBが膨らみます。
本記事では、Docker の マルチステージビルドを使って、Go / Node.js / Python の3ケースでイメージサイズを実際に半分以下まで削る手順をハンズオン形式で示します。すべて公開情報に基づく一般的な構成です。
環境
- Docker Engine 24 以降(マルチステージビルドは 17.05 以降で利用可能)
- BuildKit 有効(Docker 23 以降はデフォルト有効)
DOCKER_BUILDKIT=1 docker build -t app:multi .
発生した問題
まず、よくある「素朴な」Dockerfile を見てみます。Go アプリを例にします。
# Bad: 単一ステージ FROM golang:1.22 WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN go build -o server . CMD ["./server"]
このビルドでは golang:1.22(Goツールチェーン・gcc・devパッケージを含む)がまるごと最終イメージに残り、実測でおよそ 800MB〜1GB 前後になります。実行に必要なのはビルド済みバイナリ1つだけです。
問題は3点です。
- サイズが大きい — 転送・保存コストとデプロイ時間が増える
- アタックサーフェスが広い — シェル・パッケージマネージャ・コンパイラが本番に残る
- 不要物の混入 — ソースコードや
.git、ビルド中間物が残りうる
原因
単一ステージの Dockerfile では、ビルドに使ったすべてのレイヤーが最終イメージに含まれるためです。RUN で入れた devパッケージを後から rm してもレイヤーは積み重なるだけで、サイズは減りません。「ビルド環境」と「実行環境」を同じイメージに同居させていることが根本原因です。
対応方法
マルチステージビルドでは、1つの Dockerfile に複数の FROM を書き、ビルド専用ステージで成果物を作り、実行専用ステージへ必要なファイルだけを COPY --from= でコピーします。実行ステージには最小限のベースイメージ(alpine / distroless / scratch)を使い、ビルドステージは最終イメージに含めません。
実装例
ケース1: Go(scratch で最小化)
# ---- build stage ---- FROM golang:1.22 AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o server . # ---- runtime stage ---- FROM scratch COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ COPY --from=builder /app/server /server EXPOSE 8080 ENTRYPOINT ["/server"]
ポイント:
CGO_ENABLED=0で静的バイナリにし、scratch(空のベース)でも動くようにする-ldflags="-s -w"でデバッグ情報を落としてバイナリ自体も小さくする- 最終イメージはバイナリ + 証明書のみで、実測 10〜20MB 前後(800MB からなら 95% 以上の削減)
ケース2: Node.js(devDependencies を持ち込まない)
# ---- build stage ---- FROM node:20 AS builder WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci COPY . . RUN npm run build # ---- deps stage: 本番依存だけを再インストール ---- FROM node:20 AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci --omit=dev # ---- runtime stage ---- FROM node:20-slim WORKDIR /app ENV NODE_ENV=production COPY --from=deps /app/node_modules ./node_modules COPY --from=builder /app/dist ./dist COPY package.json ./ EXPOSE 3000 CMD ["node", "dist/main.js"]
ポイント:
- ビルドに要る
devDependenciesは実行に不要。depsステージで--omit=devし本番依存だけを作り直す - 実行ベースは
node:20-slim。distだけを載せ、srcは入れない node:20(約1GB超)から移すと、アプリ次第で 半分〜1/3 になる
ケース3: Python(wheel をビルドして持ち込む)
# ---- build stage ----
FROM python:3.12 AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
# ---- runtime stage ----
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /wheels /wheels
COPY requirements.txt .
RUN pip install --no-cache-dir --no-index --find-links=/wheels -r requirements.txt \
&& rm -rf /wheels
COPY . .
CMD ["python", "main.py"]
ポイント:
- ネイティブ拡張(
numpy・psycopg2等)はビルドにgccが必要。builder 側でpip wheelしてコンパイル済み wheel を作る - runtime 側は
gcc無しで wheel からインストール。実行ベースはpython:3.12-slim
確認方法
docker build -f Dockerfile.single -t app:single . docker build -f Dockerfile.multi -t app:multi . docker images | grep app
出力例(Go のケース、値は環境により変動):
app single ... 1.02GB app multi ... 14.8MB
中身は docker history app:multi で、レイヤーの無駄取りは dive で可視化できます。
注意点
- scratch / distroless はシェルが無いため
docker exec sh不可。デバッグはdistroless:debugタグや通常イメージで再現する COPY --from=のパスは正確に。取り違えるとファイルが入らない.dockerignoreを必ず用意し、node_modules・.git・.envの混入を防ぐ- レイヤーキャッシュ順序を維持する。依存定義を先に COPY → インストール → ソースを後で COPY
docker build --target builderで特定ステージだけをビルド可能(CI のテスト用途に有効)
まとめ
- 単一ステージはビルド環境を本番に持ち込むためイメージが肥大する
- マルチステージビルドは「ビルド用」と「実行用」を分離し、成果物だけを軽量ベースに載せる
- Go は
scratch、Node は本番依存のみ +slim、Python は wheel 事前ビルド +slimが定石 - サイズ半減どころか、静的バイナリなら 90% 超の削減も現実的
- 副次効果としてアタックサーフェスが縮み、セキュリティ面でも有利
まずは手元の一番大きいイメージに、FROM ... AS builder を1行足すところから始めてみてください。
