マルチステージビルドでイメージサイズを半分にする

概要

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点です。

  1. サイズが大きい — 転送・保存コストとデプロイ時間が増える
  2. アタックサーフェスが広い — シェル・パッケージマネージャ・コンパイラが本番に残る
  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行足すところから始めてみてください。

\ 最新情報をチェック /

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください。