Docker 中級 #1 マルチステージビルドとイメージスリミング
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 — 全てビルド成果物だけあれば終わるのに、前段階のツールがイメージにそのまま固まっています。
| 種類 | ビルド時 | ランタイム |
|---|---|---|
コンパイラ (gcc、tsc、go) | 必要 | 不要 |
パッケージマネージャ (pip、npm) | 必要 | 普通は不要 |
ビルド成果物 (dist/、build/) | 作る | 必要 |
ランタイムライブラリ (libssl、libpq) | 必要 | 必要 |
| アプリコード | 必要 (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 は静的バイナリを作る言語なのでマルチステージの効果が最も明確です。
FROM golang:1.23
WORKDIR /app
COPY . .
RUN go build -o myapp
CMD ["./myapp"]
# 最終イメージ: ~900MB (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"]
# 最終イメージ: ~15MB900MB → 15MB。60 倍の差。解きほぐすと:
CGO_ENABLED=0— C ライブラリ依存を切る。静的バイナリだけ作るgcr.io/distroless/static-debian12— シェルもパッケージマネージャもない最小イメージ (後で扱う)- 最終ステージにはビルドされたバイナリ一つとその依存 OS ファイルだけ
Go コンテナを運用環境で見るとほぼ常にこのパターンです。
Node.js — tsc 結果だけ持ってくる
#
TypeScript プロジェクトは tsc でビルドして dist/ を作って、運用にはその成果物だけあれば良い。
# 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 含む) インストール。ビルドとキャッシュに使うbuilder—tscでdist/を作るprod-deps— 運用依存だけ別途インストール。dev 依存を結果イメージに刻まないため- 最終 —
dist/と prod node_modules だけコピー。ビルドツールは全て捨てられる
USER node は非特権ユーザに落とす一行。node 公式イメージがこのユーザを事前に作ってあるので一行で終わります。
Python — wheel でビルド依存を分離
#
Python は Go ほど劇的ではないですが、C 拡張ビルドツール (gcc、headers) を分離すると効果が大きい。
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 を使います。
docker build --target builder -t myapp:builder .テストステージを別途置いて CI でそのステージだけビルドするパターンがよく見られます。
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 が作った、アプリ実行に絶対に必要なものだけ 入っているイメージです。
| イメージ | 入っているもの | 入っていないもの |
|---|---|---|
static | glibc、ca-certificates、/etc/passwd | シェル、パッケージマネージャ、coreutils |
base | static + 動的リンカ | 上 + |
cc | base + libgcc | 上 + |
nodejs20-debian12 | Node ランタイム | ビルドツール |
python3-debian12 | Python ランタイム | pip、venv ツール |
長所: 小さく、攻撃面が狭い (シェル / coreutils エクスプロイトがない)。
短所: docker exec -it ... sh ができません。 シェル自体がないからです。デバッグが難しい。
運用コンテナのセキュリティが重要なケース、またはコンテナの中にシェルがある理由がない場合に適しています。一般的なスタート地点は distroless より slim バリアントが無難です。
Scratch — 本当に空のイメージ #
scratch は Docker が提供する 完全に空 の仮想イメージです。ファイルが 0 個。そこに静的バイナリを一つだけ刻む形で使います。
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"]
# 最終イメージ: ~7MBscratch は ca-certificates、タイムゾーンデータ、/etc/passwd のようなファイルもないので、必要なものを明示的に一緒にコピーする必要があります。distroless の static が実はこの作業を事前にやっておいたベースイメージです。純粋静的 Go バイナリ のような非常に単純なアプリでなければ distroless から行く方が安全です。
一箇所に整理 — スリミングチェックリスト #
イメージサイズを減らす道具を一箇所にまとめると:
- ベースイメージ —
slimバリアントから。(alpine は musl 互換性に注意) - マルチステージ — ビルドツールを最終ステージで剥がす
- ビルド成果物だけコピー — Go の単一バイナリ、Node の
dist/、Python のwheels - 開発依存分離 —
npm ci --omit=dev、pip install --no-depsなど apt-get clean/rm -rf /var/lib/apt/lists/*— 一つの RUN の中で掃除.dockerignore— ビルドコンテキスト自体を小さく (基礎 #6)- distroless / scratch — 本当にスリムが大事なとき
小さな Go API が ~15MB、Node API が ~120MB、Python API が ~150MB くらいになるとよく削れた方です。
実践例 — 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.js で output: '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 / レジストリ) でビルドをもっと速くする方法です。