Docker マルチステージビルド実践:本番イメージを 1GB から 10MB へ
「イメージのプッシュが失敗した。タイムアウトだ。」
去年のある金曜の午後、CI/CD パイプラインが真っ赤になりました。私は画面に映る 980MB の Go アプリイメージを見つめ、胸がざわつきました。インフラ担当の同僚がやってきて、ため息をつきました。「君のイメージ、昼にダウンロードした映画より大きいぞ。」
その後、私はマルチステージビルドを使いました。
10MB。同じアプリ、同じ機能で、イメージサイズは 980MB から 10MB へ。99% の容量が消え、CI/CD のプッシュは 3 分から 3 秒になりました。
この記事では、マルチステージビルドの実践テクニックを共有します。Go・Node.js・Python の 3 言語分の完全な Dockerfile テンプレートと、私が落とし穴を踏み抜きながらまとめた 5 つのよくあるミスを含みます。自分の本番イメージを「肥大」から「スリム」にしたいなら、続きを読んでみてください。
なぜあなたのイメージはこんなに肥大しているのか?
正直なところ、ほとんどの Docker イメージが肥大化する原因はだいたい同じです。
私は以前、こんな Dockerfile を書いていました:
FROM ubuntu:20.04
RUN apt-get update && apt-get install -y golang
COPY . /app
WORKDIR /app
RUN go build -o myapp
CMD ["./myapp"]
普通に見えますよね?でも docker images で見てみると——なんと 980MB。
問題はどこにあるのか?一言でいえば、残すべきものを残さず、捨てるべきものを捨てていないことです。
具体的には:
- ベースイメージが大きすぎる:
ubuntu:20.04自体が 77MB あり、Go ツールチェーンを入れると一気に 900MB を超える - コンパイルツールの残骸:gcc、make、git といったビルドツールは本番環境では一切使わない
- キャッシュ未削除:apt/apk のパッケージ管理キャッシュがすべてイメージレイヤーに残る
- 冗長な依存関係:開発依存やテストフレームワークまで一緒に同梱されている
たとえるなら、旅行に行くのにスーツケース、寝袋、テント、調理器具まで持っていったのに、実際はホテルに泊まるだけ——という状態です。マルチステージビルドは、本当に必要なもの(着替えと洗面用具)だけを持っていき、それ以外は全部家に置いておくようなものです。
Docker 公式ドキュメントのデータによれば、典型的な Go アプリは未最適化のイメージで約 800MB〜1GB、最適化後は 10〜20MB まで圧縮できます。差はそれほど極端なのです。
マルチステージビルドの核となる原理
マルチステージビルドの核となる考え方はとてもシンプルです。ビルド環境と実行環境を分離することです。
従来の Dockerfile は、コンパイル・パッケージ化・実行をすべて 1 つのイメージに詰め込みます。マルチステージビルドでは複数の FROM 命令を定義でき、それぞれの FROM が新しいビルドステージを開始します。
最もシンプルな例を見てみましょう:
# 第 1 ステージ:ビルド
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY . .
RUN go build -o myapp
# 第 2 ステージ:実行
FROM alpine:3.18
WORKDIR /app
COPY --from=builder /app/myapp .
CMD ["./myapp"]
核となる構文は 2 行だけです:
FROM ... AS builder:このステージに名前を付けるCOPY --from=builder:builder ステージからファイルをコピーする
原理としては、Docker は各ステージを順番に実行しますが、最終イメージには最後のステージの内容だけが含まれます。前段の肥大なコンパイルツールや依存キャッシュはすべて破棄されます。
iximiuz Labs の 2026 年のチュートリアルによれば、マルチステージビルドの本質は Docker のレイヤー機構を活用することにあります。各 FROM 命令が独立したビルドコンテキストを起動し、任意のステージから後続ステージへファイルをコピーできますが、関係のないファイルは決して最終イメージに入りません。
これは家のリフォームのようなものです。第 1 ステージは施工チームで、電動ドリルやハンマー、のこぎりを持ち込みます。第 2 ステージはあなたの入居で、家具と家電だけを持ち込みます。施工チームが去ればツールも一緒に去り、あなたの家には必要なものだけが残ります。
実践事例:3 言語のマルチステージビルドテンプレート
Go 言語:980MB から 10MB へ
Go は静的バイナリにコンパイルできるため、マルチステージビルドに最も適した言語です。
完全な Dockerfile:
# ビルドステージ
FROM golang:1.21-alpine AS builder
WORKDIR /app
# 先に go.mod と go.sum をコピーしてキャッシュを活用
COPY go.mod go.sum ./
RUN go mod download
# ソースをコピーしてコンパイル
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -ldflags '-extldflags "-static"' -o myapp .
# 実行ステージ
FROM scratch
# builder からバイナリをコピー
COPY --from=builder /app/myapp /myapp
# CA 証明書をコピー(HTTPS 通信が必要な場合)
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8080
ENTRYPOINT ["/myapp"]
ここにはいくつかのテクニックがあります:
FROM scratch:空のイメージで、0 バイトの起点。あなたのバイナリだけが入るCGO_ENABLED=0:CGO を無効化し、純粋な静的バイナリを生成する- CA 証明書:アプリが HTTPS インターフェースを呼び出す必要があるなら、証明書ファイルをコピーする必須がある
- 依存キャッシュ最適化:先に go.mod/go.sum をコピーしてから go mod download することで、ソース変更で依存を再ダウンロードしない
ビルド完了後、イメージサイズは約 10MB。元の 980MB と比べて 99% 削減です。
scratch が極端すぎる(シェルがなくデバッグが難しい)と感じるなら、alpine を使えます:
FROM alpine:3.18
RUN apk --no-cache add ca-certificates
COPY --from=builder /app/myapp /myapp
ENTRYPOINT ["/myapp"]
イメージは少し大きくなり約 15MB ですが、docker exec で中に入ってデバッグできる環境が手に入ります。
Node.js:900MB から 120MB へ
Node.js のマルチステージビルドは、node_modules を扱う必要があるため少し複雑です。
完全な Dockerfile:
# ビルドステージ
FROM node:18-alpine AS builder
WORKDIR /app
# package.json をコピー
COPY package*.json ./
# すべての依存をインストール(devDependencies を含む)
RUN npm ci
# ソースをコピー
COPY . .
# ビルド手順がある場合(TypeScript のコンパイルなど)
RUN npm run build
# 本番ステージ
FROM node:18-alpine
WORKDIR /app
# Node の環境変数を設定
NODE_ENV=production
# 本番依存のみをインストール
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
# ビルド成果物をコピー
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
EXPOSE 3000
CMD ["node", "dist/index.js"]
ポイント:
npm ci --only=production:dependenciesのみをインストールしdevDependenciesをスキップ。すぐにサイズが半分になるnpm cache clean --force:npm キャッシュを削除しないと、イメージレイヤーに残ってしまう- ビルドと実行の分離:TypeScript のコンパイルは builder ステージで完了し、本番イメージには JS ファイルだけが入る
Oak Oliver Engineering の実測データによれば、典型的な Express アプリは未最適化で約 900MB、マルチステージビルド後は約 120MB。削減率は約 87% です。
Python:300MB から 100MB へ
Python の状況はより特殊です——コンパイル手順はありませんが、巨大な依存パッケージ(numpy、pandas はすぐに数百 MB)があります。
完全な Dockerfile:
# ビルドステージ
FROM python:3.9-slim AS builder
WORKDIR /app
# 依存をユーザーディレクトリにインストール
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
# 本番ステージ
FROM python:3.9-alpine
WORKDIR /app
# 依存をコピー
COPY --from=builder /root/.local /root/.local
ENV PATH=/root/.local/bin:$PATH
# アプリコードをコピー
COPY . .
EXPOSE 8000
CMD ["python", "app.py"]
ここでは pip install --user を使い、依存を /root/.local にインストールしてから、ディレクトリごと本番イメージにコピーしています。
核となるテクニック:
--no-cache-dir:pip はデフォルトでダウンロードしたパッケージをキャッシュするので、このパラメータでキャッシュの残留を防ぐ- slim と alpine:ビルドステージは
slim(互換性が良い)、本番ステージはalpine(サイズが小さい) - 仮想環境:依存が複雑なら、
--userではなく venv の利用を検討する
実測データ:FastAPI + SQLAlchemy を使うプロジェクトで、元のイメージは約 300MB、マルチステージビルド後は約 100MB でした。
ベースイメージの選定:Alpine と Distroless と Slim
実行ステージのベースイメージ選びは、トレードオフが必要な判断です。
主流の 3 つの選択肢の比較を 1 枚の表にまとめました:
| 特性 | Alpine | Distroless | Slim |
|---|---|---|---|
| ベースサイズ | 3-5MB | 20-65MB | 50-100MB |
| セキュリティ | 中程度 | 極めて高い | 中程度 |
| デバッグの難易度 | 低(シェルあり) | 高(シェルなし) | 低(シェルあり) |
| 互換性 | 落とし穴あり(glibc) | 良い | 良い |
| 適用シーン | Go 静的バイナリ | 高セキュリティ要件 | Node.js/Python |
Alpine:サイズ最小、ただし glibc に注意
Alpine Linux は標準の glibc ではなく musl libc を使います。Go にとっては問題ありません(静的コンパイルできるため)が、Python や Node.js の一部の依存では問題が起きることがあります。
私はこの落とし穴を踏みました。numpy を使う Python プロジェクトが Alpine で動かず、ImportError: cannot import name 'random' というエラーが出たのです。あれこれ調べて、ようやく musl と glibc の互換性問題だと分かりました。
解決策は 2 つあります:
libc6-compatをインストールする:apk add libc6-compat- あるいはいっそ
slimをalpineの代わりに使う
Distroless:セキュリティの基準、ただしデバッグが面倒
Distroless は Google が出しているイメージシリーズで、シェルもパッケージマネージャーもなく、アプリの実行に必要なものだけがあるのが特徴です。
danieldemmel.me の分析によれば、Distroless は攻撃者がシェル経由でコマンドを実行できないため、大半の高危険度 CVE 脆弱性を排除できます。
ただしその代償として、問題が起きたときに docker exec で中に入ってログを見たりデバッグしたりできません。ログ出力と監視に頼るしかありません。
究極のセキュリティを求めるなら、Distroless は最良の選択肢です:
FROM gcr.io/distroless/static-debian11
COPY --from=builder /app/myapp /
ENTRYPOINT ["/myapp"]
Slim:バランスの取れた選択
公式の -slim イメージ(node:18-slim、python:3.9-slim など)は、Alpine と完全イメージの中間にあたる折衷案です。
サイズは Alpine より少し大きいですが、互換性が良く、デバッグできるシェルがあります。musl/glibc の問題で苦労したくないなら、slim は手間のかからない選択肢です。
私のおすすめ:
- Go アプリ:
scratchまたはalpineを優先 - Node.js/Python:まず
slimを使い、問題ないと確認してからalpineを試す - 高セキュリティ要件:
distrolessを使い、デバッグ方針を事前に用意する
落とし穴ガイド:5 つのよくあるミスと解決策
これだけ Dockerfile を書いてきて、踏んだ落とし穴はプール 1 つ分くらいになります。最もよくある 5 つを挙げてみます。
ミス 1:COPY —from=0 で全量コピー
初心者がやりがちなミス:前ステージからそのまま全量コピーする。
# 誤った例
FROM builder
COPY --from=0 /app /app
これは builder ステージのディレクトリ全体をコピーしてしまい、Go ツールチェーン、npm キャッシュ、一時ファイルまで含まれます。イメージはすぐに膨れ上がります。
正しいやり方:必要なファイルだけをコピーする。
# 正しい例
COPY --from=builder /app/myapp /myapp
COPY --from=builder /app/dist /dist
ミス 2:キャッシュ未削除
apt/apk のパッケージ管理キャッシュは、削除してもイメージレイヤーに残ります。
# 誤った例(キャッシュが前のレイヤーに残る)
RUN apt-get update && apt-get install -y curl
RUN apt-get clean
正しいやり方:削除コマンドとインストールコマンドを同じレイヤーで実行する。
# 正しい例
RUN apt-get update && apt-get install -y curl && apt-get clean && rm -rf /var/lib/apt/lists/*
または --no-cache パラメータを使う:
RUN apk add --no-cache curl
ミス 3:Alpine の glibc 互換問題
前述のとおり、Alpine は musl libc を使うため、一部の Python/Node.js 依存が非互換です。
典型的なエラー:
ImportError: cannot import name 'random' from 'numpy.random'
解決策:libc6-compat をインストールするか、slim に切り替える。
ミス 4:非 root ユーザーを設定していない
デフォルトでは、コンテナは root ユーザーで実行され、セキュリティリスクが比較的大きくなります。
ベストプラクティス:専用ユーザーを作成する。
RUN adduser -D appuser
USER appuser
こうすれば、たとえコンテナが攻撃されても、攻撃者は一般ユーザー権限しか持てません。
ミス 5:.dockerignore を無視する
.dockerignore は Dockerfile の「引き算リスト」です。設定しないと、COPY . . がプロジェクトディレクトリ全体(.git、node_modules、テストファイルなど)をコピーしてしまいます。
.dockerignore を作成します:
.git
.gitignore
node_modules
npm-debug.log
Dockerfile
.dockerignore
*.md
.env
これでビルドコンテキストのサイズを減らし、イメージビルドの速度を上げられます。
結論
マルチステージビルドは、Docker イメージのスリム化で最も実用的なテクニックです。
核となる考え方は一言です。ビルド環境にはコンパイルツールを残し、実行環境にはアプリだけを置く。
データを振り返ってみましょう:
- Go:980MB → 10MB(99% 削減)
- Node.js:900MB → 120MB(87% 削減)
- Python:300MB → 100MB(67% 削減)
まだマルチステージビルドを使ったことがないなら、今すぐ試してみてください。プロジェクトを 1 つ選び、上のテンプレートを参考に Dockerfile を書き直し、docker images で前後のサイズを比較してみましょう。
きっと驚きが待っています——少なくとも、CI/CD のプッシュがタイムアウトすることはなくなるはずです。
Docker マルチステージビルドによるイメージ最適化
肥大化した Docker イメージを最小サイズまで削減する完全な手順
⏱️ 目安時間: 30 分
- 1
ステップ1: 現在のイメージ構成を分析する
`docker history` コマンドで各レイヤーのサイズを確認します:
```bash
docker history your-image:tag
```
最も容量を占めるレイヤーを特定します。通常は次のとおりです:
• ベースイメージそのもの
• ビルドツールとコンパイル依存関係
• パッケージ管理のキャッシュ - 2
ステップ2: マルチステージ Dockerfile を書く
ビルドステージと実行ステージを含む Dockerfile を作成します:
```dockerfile
# ビルドステージ
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o myapp .
# 実行ステージ
FROM alpine:3.18
COPY --from=builder /app/myapp /myapp
ENTRYPOINT ["/myapp"]
```
ポイント:
• AS でステージに名前を付ける
• COPY --from=builder で必要なファイルだけをコピーする - 3
ステップ3: ビルドしてイメージサイズを比較する
新しいイメージをビルドし、サイズの変化を比較します:
```bash
docker build -t myapp:optimized .
docker images | grep myapp
```
最適化前後のサイズ差を比較します。 - 4
ステップ4: アプリの動作を検証する
コンテナを起動してアプリをテストします:
```bash
docker run -d -p 8080:8080 myapp:optimized
curl http://localhost:8080/health
```
機能が完全で、依存関係の欠落がないことを確認します。 - 5
ステップ5: 本番環境へデプロイする
CI/CD フローを新しいイメージを使うよう更新します:
• イメージレジストリへプッシュ
• Kubernetes Deployment または docker-compose.yml を更新
• デプロイの成功を検証
FAQ
マルチステージビルドはビルド速度に影響しますか?
Alpine と Distroless はどう選べばよいですか?
マルチステージビルドはどの言語に向いていますか?
マルチステージビルドで設定ファイルはどう扱いますか?
マルチステージビルドでイメージはどれくらい小さくなりますか?
FROM scratch を使うときの注意点は?
4分で読めます · 公開日: 2026年4月19日 · 更新日: 2026年6月8日
Docker 実践ガイド
検索からこのページに来た場合は、前後の記事もあわせて読むと同じテーマの理解がかなり早く深まります。
前の記事
Docker Compose 本番デプロイ:ヘルスチェック、再起動ポリシー、ログ管理
Docker Compose 本番環境の実践ガイド:ヘルスチェック設定、再起動ポリシーの詳細解説、ログ管理の完全なソリューション。コンテナのフェイルオーバーから自動復旧まで、ログによるディスク容量圧迫を防ぐ実用的な設定を紹介します。
第 4 / 37 記事
次の記事
Docker Compose 複数サービス連携:ローカル開発環境をワンコマンドで起動
Docker Compose で複数サービスを連携し、Web・API・MySQL・Redis のローカル開発環境をワンコマンドで起動。手動インストールの煩雑さ、バージョン衝突、ポート競合を一掃し、新メンバーは clone から 5 分で開発開始、プロジェクト切り替えも数秒で完了します。
第 6 / 37 記事
関連記事
Dockerfile入門:ゼロから最初の Docker イメージを作る(実例付き)
Dockerfile入門:ゼロから最初の Docker イメージを作る(実例付き)
Docker vs 仮想マシン:5分で理解する性能差とシーン別選び方ガイド
Docker vs 仮想マシン:5分で理解する性能差とシーン別選び方ガイド
Docker インストールの落とし穴ガイド 2025:permission denied から正常起動までの完全解決策
コメント
GitHubアカウントでログインしてコメントできます