3.5 Dockerマルチステージビルド完全ガイド|イメージサイズを1/10に削減する実践テクニック

【第3章】Dockerfileによるイメージ構築

Dockerマルチステージビルド完全ガイド|イメージサイズを1/10に削減する実践テクニック

「Goの静的バイナリなのに、なぜイメージが1GBもあるの?」「Node.jsアプリのビルドに必要なgccpython3が本番イメージに残っている」——こうした問題の解決策が マルチステージビルドです。

マルチステージビルドを使うと、ビルド用と実行用のステージを分離でき、本番イメージに必要なものだけを残せます。Go なら 900MB → 15MB、Node.js なら 1.2GB → 150MB といった劇的な削減が可能です。この記事では仕組みから実践パターンまで徹底解説します。


目次

  1. なぜマルチステージビルドが必要か
  2. 基本構文:FROM … AS … と COPY –from
  3. 実践① Goアプリ(900MB→15MB)
  4. 実践② Node.jsアプリ(1.2GB→180MB)
  5. 実践③ Pythonアプリ
  6. ステージの命名と参照テクニック
  7. ビルドターゲット(–target)で途中ステージを取り出す
  8. 外部イメージからの COPY --from
  9. マルチステージの落とし穴
  10. まとめ

1. なぜマルチステージビルドが必要か

アプリの ビルド時実行時で必要なツールは違います。

フェーズ 必要なもの
ビルド時 コンパイラ・ビルドツール・開発ライブラリ・devDependencies gcc、make、npm、webpack、TypeScript、テストフレームワーク
実行時 最終成果物とランタイムのみ バイナリ、main.js、python app.py、nginx

シングルステージで書くと、この 両方が最終イメージに混ざります。結果、イメージは肥大化し、セキュリティリスク(攻撃者が利用できるツールが残る)も増えます。

【シングルステージの問題】
最終イメージ(肥大化)
✅ アプリ本体(5MB)
❌ gcc / make(200MB)
❌ node_modules (devDep含む)
❌ ソースコード
❌ テストファイル・ドキュメント

↑ 本番で不要なものが同居 → サイズ・セキュリティの両面で非効率

マルチステージの発想

「ビルド作業は別の大きなイメージで行い、完成品だけをスリムなイメージにコピーする」——これがマルチステージビルドの本質です。

【マルチステージのイメージ】
Stage 1:ビルダー
golang:1.22
gcc / make / ソース
→ 成果物: app(5MB)

Stage 2:本番
alpine(7MB)
+ app(5MB のみ)
合計: 12MB


2. 基本構文:FROM … AS … と COPY –from

マルチステージビルドは、1つのDockerfileに複数の FROM を書くだけで実現できます。

# Stage 1: ビルダー(名前を "builder" と付ける)
 FROM golang:1.22-alpine AS builder
 WORKDIR /src
 COPY . .
 RUN go build -o /bin/app
 
 # Stage 2: 本番(最終イメージになる)
 FROM alpine:3.19
 COPY --from=builder /bin/app /usr/local/bin/app
 CMD ["app"]

ポイントは2つ:

  • FROM <image> AS <name>:ステージに名前を付ける
  • COPY --from=<name> <src> <dst>:別ステージからファイルをコピー
💡 最終イメージ=最後のFROM
docker build が最終的に出力するイメージは、Dockerfileの一番下の FROM のステージだけです。途中のステージはビルドの過程で使われるだけで、最終成果物には含まれません。

3. 実践① Goアプリ(900MB→15MB)

Goは静的バイナリにコンパイルできる言語のため、マルチステージの効果が最も劇的です。

シングルステージ版(悪い例)

FROM golang:1.22
 WORKDIR /app
 COPY . .
 RUN go build -o /app/server
 CMD ["/app/server"]
 
 # サイズ: 約 900MB(Go toolchain 全部含む)

マルチステージ版(良い例)

# syntax=docker/dockerfile:1.4
 
 # ===== ビルドステージ =====
 FROM golang:1.22-alpine AS builder
 WORKDIR /src
 
 # 依存ダウンロード(レイヤーキャッシュ活用)
 COPY go.mod go.sum ./
 RUN go mod download
 
 # ソースコピー&静的ビルド
 COPY . .
 RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/server
 
 # ===== 実行ステージ =====
 FROM alpine:3.19
 RUN apk add --no-cache ca-certificates
 COPY --from=builder /bin/server /usr/local/bin/server
 EXPOSE 8080
 CMD ["server"]
 
 # サイズ: 約 15MB(60分の1)
💡 CGO_ENABLED=0 の意味
Go のビルド時にCGO(Cライブラリ呼び出し)を無効化すると、完全な静的バイナリが生成されます。これにより、alpine(musl libc)でも glibc でも同じバイナリが動きます。さらに軽くしたい場合は scratch(空の最小イメージ)も使えます。

4. 実践② Node.jsアプリ(1.2GB→180MB)

Node.js は実行時にもランタイムが必要ですが、devDependencies(TypeScript・webpack・テストツール)を本番から除外できます。

# ===== ビルドステージ =====
 FROM node:20-alpine AS builder
 WORKDIR /app
 
 # devDependencies も含めてインストール(ビルドに必要)
 COPY package.json package-lock.json ./
 RUN npm ci
 
 # ソースコピー&ビルド(TypeScript → JS 変換など)
 COPY . .
 RUN npm run build
 
 # ===== 本番依存ステージ =====
 FROM node:20-alpine AS deps
 WORKDIR /app
 COPY package.json package-lock.json ./
 RUN npm ci --omit=dev
 
 # ===== 実行ステージ =====
 FROM node:20-alpine
 WORKDIR /app
 # ビルド成果物(dist)と本番依存のみコピー
 COPY --from=builder /app/dist ./dist
 COPY --from=deps /app/node_modules ./node_modules
 COPY package.json ./
 EXPOSE 3000
 CMD ["node", "dist/server.js"]
 
 # サイズ: 約 180MB(1.2GB → 1/7)

このパターンでは 3つのステージを使っています:

  • builder:TypeScript/webpackのビルド(devDep使用)
  • deps:本番用依存のみを別ステージでインストール
  • 最終:distnode_modules(本番版)だけ持ってくる
⚠️ ビルドと本番依存を別ステージにする理由
同じ node_modules でも、npm cinpm ci --omit=dev では中身が違います。ビルドには前者が必要、実行には後者で十分。分けないと本番イメージに不要なdevDependenciesが混入します。

5. 実践③ Pythonアプリ

Pythonはコンパイル不要ですが、gccを要求するパッケージ(numpy、psycopg2等)をビルドする場面で効果があります。

# ===== ビルドステージ =====
 FROM python:3.12-slim AS builder
 WORKDIR /app
 
 # gcc などのビルドツールをインストール
 RUN apt-get update && apt-get install -y --no-install-recommends \
  gcc \
  libpq-dev \
  && rm -rf /var/lib/apt/lists/*
 
 COPY requirements.txt .
 # wheel を作って /wheels に出力
 RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
 
 # ===== 実行ステージ =====
 FROM python:3.12-slim
 WORKDIR /app
 
 # 実行に必要な最小限のライブラリ
 RUN apt-get update && apt-get install -y --no-install-recommends \
  libpq5 \
  && rm -rf /var/lib/apt/lists/*
 
 # ビルド済み wheel を使ってインストール(gcc 不要)
 COPY --from=builder /wheels /wheels
 RUN pip install --no-cache-dir /wheels/*
 
 COPY . .
 CMD ["python", "app.py"]

ビルドステージで pip wheel を使い、コンパイル済みの .whl ファイルを生成 → 実行ステージでは gcc 不要でインストール、という戦略です。最終イメージから gcc・libpq-dev(開発用ヘッダ)が除外されます。


6. ステージの命名と参照テクニック

ステージは何個でも書ける

FROM node:20 AS frontend-builder
 # フロントエンド(React)ビルド
 RUN npm run build
 
 FROM golang:1.22 AS backend-builder
 # バックエンド(Go)ビルド
 RUN go build -o /api
 
 FROM alpine:3.19 AS runtime
 COPY --from=frontend-builder /app/dist /www
 COPY --from=backend-builder /api /usr/local/bin/
 # フロント・バックを1つのイメージにまとめる

前のステージを相対参照できる

ステージ名を付け忘れた場合、番号(0, 1, 2…)で参照できます。

FROM golang:1.22
 RUN go build -o /app
 
 FROM alpine
 COPY --from=0 /app /app # ステージ0を参照
 # ただし名前を付けた方が可読性が高い

7. ビルドターゲット(–target)で途中ステージを取り出す

docker build --target <stage> で、任意のステージまでビルドして終了できます。開発環境・テスト環境・本番環境を1つのDockerfileで管理できる強力な機能です。

# ===== 共通ベース =====
 FROM node:20-alpine AS base
 WORKDIR /app
 COPY package.json package-lock.json ./
 RUN npm ci
 
 # ===== 開発(ホットリロード対応)=====
 FROM base AS development
 COPY . .
 CMD ["npm", "run", "dev"]
 
 # ===== テスト(テスト実行して失敗したらビルド停止)=====
 FROM base AS test
 COPY . .
 RUN npm run lint && npm run test
 
 # ===== 本番(最適化ビルド)=====
 FROM base AS production
 COPY . .
 RUN npm run build && npm prune --omit=dev
 CMD ["node", "dist/server.js"]
# 開発環境でビルド
 docker build --target development -t myapp:dev .
 
 # テストだけ実行(失敗したら exit 1)
 docker build --target test -t myapp:test .
 
 # 本番ビルド(省略時は最後のステージ)
 docker build --target production -t myapp:prod .
 # または
 docker build -t myapp:prod .
✅ CI/CDでの活用
--target test で CI でテストを走らせ、成功したら --target production で本番イメージをビルド、という流れが定石です。詳しくは第12章「CI/CDパイプラインへの組み込み」で扱います。

8. 外部イメージからの COPY --from

COPY --from の参照先は、Dockerfile 内のステージだけでなく 既存のイメージ名も指定できます。

FROM alpine:3.19
 
 # 公式 nginx イメージからバイナリを拝借
 COPY --from=nginx:1.25-alpine /usr/sbin/nginx /usr/sbin/nginx
 COPY --from=nginx:1.25-alpine /etc/nginx /etc/nginx
 
 # Go のバイナリを外部ビルド済みイメージから持ってくる例
 COPY --from=registry.example.com/go-builder:v1.0 /bin/app /usr/local/bin/

これは ビルドキャッシュを別リポジトリで管理したい時や、公式イメージのビルド済みバイナリを使い回したい時に便利です。


9. マルチステージの落とし穴

落とし穴 原因 対策
COPY –from で 動的リンクのバイナリが動かない ビルド環境と実行環境の glibc/musl が違う Goなら CGO_ENABLED=0、Cなら静的ビルド
alpine と debian を混在 libc が違う(musl vs glibc) ベースを揃える or 静的バイナリにする
環境変数が引き継がれない ENVステージごとに再定義必要 必要な環境変数は実行ステージでも定義
ユーザー・権限が引き継がれない USER もステージごと 実行ステージで USER を改めて指定
ARG が見えない ARG は定義したステージ内でのみ有効 各ステージの先頭で再宣言
.dockerignore を使っていない ビルドコンテキストが大きいと遅い 3-3 .dockerignoreで対処
⚠️ ステージ間でキャッシュの効き方が違う
マルチステージでは、ステージごとに独立してキャッシュが評価されます。あるステージの COPY が壊れても、他のステージには影響しません。これを利用して並列性の高いDockerfileを書けば、BuildKitはステージを並列ビルドできます。

10. まとめ

マルチステージビルドは、「ビルドと実行を分離する」というシンプルな発想ですが、その効果はイメージサイズ・セキュリティ・CI効率すべてに及ぶ、本番運用の必須テクニックです。

観点 シングルステージ マルチステージ
最終サイズ(Go) 約 900MB 約 15MB
最終サイズ(Node.js) 約 1.2GB 約 180MB
ビルドツールの残存 あり(セキュリティリスク) なし
開発/本番の切り替え Dockerfile を別に書く必要あり –target で切替
並列ビルド 不可 BuildKitが自動並列化
Dockerfile の行数 少ない やや増える
✅ 次のステップ
3-3「.dockerignore の活用」では、ビルドコンテキストを絞ってビルド時間をさらに短縮するテクニックを解説します。マルチステージと組み合わせることで、本番Dockerfileの完成度が一段上がります。

参考リンク


Dockerの基礎を動画で体系的に学びませんか?

実務で使う基礎だけを3時間に凝縮。環境構築から丁寧に解説しています。

Udemy Docker入門講座 クーポン割引で講座を見る →

コメント

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