Docker 中級 #1 マルチステージビルドとイメージスリミング

読了 8分

Docker 基礎シリーズ で一つのコンテナを定義して動かす流れを掴みました。中級シリーズはその上に 運用段階の道具 を一段重ねます。最初の記事は最も大きな効果を素早く得られるテーマ — マルチステージビルド です。

Docker 中級 シリーズでこの記事の位置:

  • #1 マルチステージビルドとイメージスリミング ← この記事
  • #2 ビルドキャッシュ — レイヤー順序の最適化
  • #3 docker-compose 基礎 — web + db
  • #4 compose 深掘り — depends_on, healthcheck, profiles
  • #5 環境変数と secrets 管理
  • #6 ロギングとデバッグ

ビルド時にだけ必要なもの vs ランタイムに必要なもの #

基礎シリーズの 一行 Dockerfile の例 は小さく始まりますが、実際のプロジェクトに移すとすぐに膨れます。理由はほぼ常に同じです。

  • コンパイラ / ビルドツールがイメージに刻まれている
  • 開発依存 (テストランナー、リンター) が一緒に入っている
  • node_modules / .venv / ビルド成果物が二重に入っている
  • apt キャッシュが掃除されていない

特にコンパイラのようなビルドツールは ビルド時にだけ必要なのに イメージにそのまま残ることが多いです。Go の go build、Node の tsc、C の gcc — 全てビルド成果物だけあれば終わるのに、前段階のツールがイメージにそのまま固まっています。

種類ビルド時ランタイム
コンパイラ (gcctscgo)必要不要
パッケージマネージャ (pipnpm)必要普通は不要
ビルド成果物 (dist/build/)作る必要
ランタイムライブラリ (libssllibpq)必要必要
アプリコード必要 (Go のような言語ではビルド後は不要)言語による

この分離を一つの Dockerfile の中で自然にやる道具が マルチステージビルド です。

基本文法 — FROM ... AS name #

一つの Dockerfile に FROM を複数回書けます。各 FROM が新しい ステージ を始めます。

最も単純なマルチステージ
FROM python:3.14 AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --target=/build/deps -r requirements.txt

FROM python:3.14-slim
WORKDIR /app
COPY --from=builder /build/deps /app/deps
COPY app.py .
ENV PYTHONPATH=/app/deps
CMD ["python", "app.py"]
  • FROM python:3.14 AS builder — 最初のステージに builder という名前を付ける
  • 二つ目の FROM python:3.14-slim — 新しい綺麗なステージを開始
  • COPY --from=builder /build/deps /app/deps — 最初のステージの成果物だけ持ってくる

最終イメージには 最後の FROM 以降のステージだけ が入ります。builder の中のコンパイラ、ビルドツール、キャッシュは全て捨てられます。--from=builder で明示的に持ってきたものだけが移される構造です。

Go — 最も劇的な効果 #

Go は静的バイナリを作る言語なのでマルチステージの効果が最も明確です。

Go — 単純ビルド (膨れる)
FROM golang:1.23
WORKDIR /app
COPY . .
RUN go build -o myapp
CMD ["./myapp"]
# 最終イメージ: ~900MB (Go ツールチェーンまるごと)

同じアプリをマルチステージに:

Go — マルチステージ
FROM golang:1.23 AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o myapp

FROM gcr.io/distroless/static-debian12
COPY --from=builder /build/myapp /myapp
CMD ["/myapp"]
# 最終イメージ: ~15MB

900MB → 15MB。60 倍の差。解きほぐすと:

  • CGO_ENABLED=0 — C ライブラリ依存を切る。静的バイナリだけ作る
  • gcr.io/distroless/static-debian12 — シェルもパッケージマネージャもない最小イメージ (後で扱う)
  • 最終ステージにはビルドされたバイナリ一つとその依存 OS ファイルだけ

Go コンテナを運用環境で見るとほぼ常にこのパターンです。

Node.js — tsc 結果だけ持ってくる #

TypeScript プロジェクトは tsc でビルドして dist/ を作って、運用にはその成果物だけあれば良い。

Node TypeScript — マルチステージ
# 1) 依存ステージ — キャッシュをよく効かせる
FROM node:20-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

# 2) ビルドステージ — tsc 実行
FROM node:20-slim AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

# 3) 運用依存だけ別途
FROM node:20-slim AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

# 4) 最終イメージ
FROM node:20-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package.json .
USER node
CMD ["node", "dist/server.js"]

ステージが 4 つに増えましたが各々明確な役割があります。

  • deps — 全依存 (dev 含む) インストール。ビルドとキャッシュに使う
  • buildertscdist/ を作る
  • prod-deps — 運用依存だけ別途インストール。dev 依存を結果イメージに刻まないため
  • 最終 — dist/ と prod node_modules だけコピー。ビルドツールは全て捨てられる

USER node は非特権ユーザに落とす一行。node 公式イメージがこのユーザを事前に作ってあるので一行で終わります。

Python — wheel でビルド依存を分離 #

Python は Go ほど劇的ではないですが、C 拡張ビルドツール (gcc、headers) を分離すると効果が大きい。

Python — マルチステージ
FROM python:3.14 AS builder
WORKDIR /build
RUN pip wheel --no-cache-dir --wheel-dir /wheels \
    psycopg2 cryptography
# 上のパッケージは C 拡張があってビルドに gcc, libpq-dev, libssl-dev などが必要

FROM python:3.14-slim
WORKDIR /app
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir --no-index --find-links=/wheels \
    psycopg2 cryptography \
    && rm -rf /wheels

COPY app.py .
CMD ["python", "app.py"]

builder はフルイメージでコンパイルを回し、成果物 (.whl wheel) を作っておきます。最終イメージは slim ベースに wheel だけ持ってきてインストールするのでコンパイラが入りません。

運用でイメージスリミングが本当に大事なケースでだけこのパターンまで行き、一般的には python:3.14-slim + requirements.txt だけで十分小さくなります。

--from の他の使い方 #

--from は外部イメージからも持ってこられます。

外部イメージから直接
FROM ubuntu:24.04
COPY --from=ghcr.io/curtis/myapp:1.0 /myapp /usr/local/bin/myapp

別のイメージの特定のファイルだけ持ってくる用途。静的アセットイメージを別途作って運用イメージに合わせる構成でたまに使います。

--target — 特定ステージまでだけビルド #

開発 / CI 環境で一部ステージだけビルドしたいとき --target を使います。

builder ステージまでだけ
docker build --target builder -t myapp:builder .

テストステージを別途置いて CI でそのステージだけビルドするパターンがよく見られます。

test ステージ分離
FROM node:20-slim AS deps
# ...

FROM node:20-slim AS builder
COPY --from=deps /app/node_modules ./node_modules
# ...

FROM builder AS test
RUN npm run test
RUN npm run lint

FROM node:20-slim AS runner
COPY --from=builder /app/dist ./dist
# ...

CI が docker build --target test . でテストだけ、デプロイビルドは --target runner で (またはデフォルトで) 最後までビルド。二つのビルドが同じ依存キャッシュを共有します。

Distroless — Google の最小イメージ #

gcr.io/distroless/... というイメージ群があります。Google が作った、アプリ実行に絶対に必要なものだけ 入っているイメージです。

イメージ入っているもの入っていないもの
staticglibc、ca-certificates、/etc/passwdシェル、パッケージマネージャ、coreutils
basestatic + 動的リンカ上 +
ccbase + libgcc上 +
nodejs20-debian12Node ランタイムビルドツール
python3-debian12Python ランタイムpip、venv ツール

長所: 小さく、攻撃面が狭い (シェル / coreutils エクスプロイトがない)。 短所: docker exec -it ... sh ができません。 シェル自体がないからです。デバッグが難しい。

運用コンテナのセキュリティが重要なケース、またはコンテナの中にシェルがある理由がない場合に適しています。一般的なスタート地点は distroless より slim バリアントが無難です。

Scratch — 本当に空のイメージ #

scratch は Docker が提供する 完全に空 の仮想イメージです。ファイルが 0 個。そこに静的バイナリを一つだけ刻む形で使います。

Go + scratch
FROM golang:1.23 AS builder
WORKDIR /build
COPY . .
RUN CGO_ENABLED=0 go build -o myapp

FROM scratch
COPY --from=builder /build/myapp /myapp
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
ENTRYPOINT ["/myapp"]
# 最終イメージ: ~7MB

scratch は ca-certificates、タイムゾーンデータ、/etc/passwd のようなファイルもないので、必要なものを明示的に一緒にコピーする必要があります。distroless の static が実はこの作業を事前にやっておいたベースイメージです。純粋静的 Go バイナリ のような非常に単純なアプリでなければ distroless から行く方が安全です。

一箇所に整理 — スリミングチェックリスト #

イメージサイズを減らす道具を一箇所にまとめると:

  1. ベースイメージslim バリアントから。(alpine は musl 互換性に注意)
  2. マルチステージ — ビルドツールを最終ステージで剥がす
  3. ビルド成果物だけコピー — Go の単一バイナリ、Node の dist/、Python の wheels
  4. 開発依存分離npm ci --omit=devpip install --no-deps など
  5. apt-get clean / rm -rf /var/lib/apt/lists/* — 一つの RUN の中で掃除
  6. .dockerignore — ビルドコンテキスト自体を小さく (基礎 #6)
  7. distroless / scratch — 本当にスリムが大事なとき

小さな Go API が ~15MB、Node API が ~120MB、Python API が ~150MB くらいになるとよく削れた方です。

実践例 — Next.js standalone #

Next.js の standalone 出力を活用したマルチステージパターンはよく出会うので一度書いておきます。

Next.js standalone
FROM node:20-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM node:20-slim AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:20-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs && \
    adduser --system --uid 1001 nextjs
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]

Next.js の next.config.jsoutput: 'standalone' を有効にすると .next/standalone/ が作られて、そのディレクトリに運用に必要な最小依存だけが入ります。これをそのままコピーすれば小さなイメージになります。

まとめ #

この記事で掴んだ絵:

  • マルチステージビルドは ビルド依存とランタイム依存 を一つの Dockerfile の中で分離する道具
  • FROM ... AS name でステージを作り、COPY --from=name で成果物だけ持ってくる
  • Go は静的バイナリ + scratch/distroless で GB → 数十 MB
  • Node TypeScript は deps / builder / prod-deps / runner の 4 ステージが標準パターン
  • Python は wheel で C 拡張ビルドツールを剥がすところ
  • --target で一部ステージだけビルドして CI でテスト / デプロイを分岐
  • distroless / scratch はスリムが絶対に大事なケースで。デバッグが難しくなるのでトレードオフ

次の記事 (#2 ビルドキャッシュ — レイヤー順序の最適化) ではこのマルチステージの上にもう一段重ねます — BuildKit のキャッシュマウントCOPY --link、外部キャッシュ (GHA / レジストリ) でビルドをもっと速くする方法です。

X