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。
問題はどこにあるのか? 4 つの言葉で表現できる:残すべきものが残っておらず、捨てるべきものが捨てられていない。
具体的には:
- ベースイメージが大きすぎる:
ubuntu:20.04自体が 77MB で、Go ツールチェーンをインストールすると 900MB を超える - ビルドツールが残っている:gcc、make、git などのビルドツールは本番環境では全く必要ない
- キャッシュがクリーンアップされていない:apt/apk のパッケージ管理キャッシュがイメージレイヤーに残っている
- 依存関係が重複している:開発用依存関係やテストフレームワークも一緒に含まれている
例えるなら、旅行に行くのにスーツケース、寝袋、テント、調理器具……全部持って行ったのに、実際はホテルに泊まるだけ。マルチステージビルドは、本当に必要なものだけを持っていく——服と洗面用具だけで、他は全部家に置いていく。
Docker 公式ドキュメントのデータによると、典型的な Go アプリケーションでは、最適化されていないイメージは約 800MB-1GB、最適化後は 10-20MB に圧縮できる。これほどの差が出るのだ。
マルチステージビルドの核心原理
マルチステージビルドの核心思想はシンプルだ:ビルド環境と実行環境を分離する。
従来の Dockerfile は、コンパイル、パッケージング、実行をすべて 1 つのイメージに詰め込んでいた。マルチステージビルドでは、複数の FROM 命令を定義でき、各 FROM が新しいビルドステージを開始する。
最もシンプルな例を見てみよう:
# 第一段階:ビルド
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY . .
RUN go build -o myapp
# 第二段階:実行
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 命令は独立したビルドコンテキストを起動し、任意のステージからファイルを後続のステージにコピーできるが、無関係なファイルは最終イメージに入らない。
これは家のリフォームに例えられる。第一段階は工事チームで、ドリル、ハンマー、ノコギリを持っている。第二段階はあなたが入居する段階で、家具と家電だけを持っていく。工事チームが去ると、道具も一緒に去り、あなたの家には必要なものだけが残る。
実践例: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 API を呼び出す必要がある場合、証明書ファイルをコピーする必要がある
- 依存関係キャッシュの最適化:まず 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 vs alpine:ビルドステージでは
slimを使い(互換性が良い)、本番ステージではalpineを使う(サイズが小さい) - 仮想環境:依存関係が複雑な場合、
--userではなく venv を検討できる
実測データ:FastAPI + SQLAlchemy を使用したプロジェクトでは、元のイメージは約 300MB、マルチステージビルド後は約 100MB。
ベースイメージの選択:Alpine vs Distroless vs Slim
実行ステージのベースイメージを選択することは、トレードオフが必要な決断だ。
3 つの主要なアプローチを比較表にまとめた:
| 特徴 | 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 の一部の依存関係には問題が生じる可能性がある。
私はこの落とし穴に遭遇したことがある。ある Python プロジェクトで numpy を使っていたが、Alpine で動かず、ImportError: cannot import name 'random' というエラーが出た。調べてみると、musl と glibc の互換性の問題だとわかった。
解決策は 2 つある:
libc6-compatをインストールする:apk add libc6-compat- または、
alpineではなくslimを使う
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 を書いてきて、私が踏んだ落とし穴はプール一杯になりそうだ。最もよくある 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% 削減)
まだマルチステージビルドを使っていないなら、今すぐ試してみよう。一つのプロジェクトを選んで、上記のテンプレートを参考に 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 にはどのような注意点があるか?
6 min read · 公開日: 2026年4月19日 · 更新日: 2026年4月19日
Docker 実践ガイド
検索からこのページに来た場合は、前後の記事もあわせて読むと同じテーマの理解がかなり早く深まります。
前の記事
Docker Compose 本番デプロイ:ヘルスチェック、再起動ポリシー、ログ管理
Docker Compose 本番環境の実践ガイド:ヘルスチェック設定、再起動ポリシーの詳細解説、ログ管理の完全なソリューション。コンテナのフェイルオーバーから自動復旧まで、ログによるディスク容量圧迫を防ぐ実用的な設定方法を解説します。
第 4 / 32 記事
次の記事
Docker Compose マルチサービスオーケストレーション:ローカル開発環境を一発起動
Docker Compose でマルチサービスをオーケストレーションし、Web、API、MySQL、Redis のローカル開発環境を一発起動。手動インストールの煩雑さ、バージョン競合、ポート競合の問題を完全解決。チームの新メンバーはリポジトリを clone して 5 分で開発開始、プロジェクト切り替えも秒単位で完了。
第 6 / 32 記事
関連記事
Dockerfile入門ガイド:ゼロから作る最初のDockerイメージ(実例付き)
Dockerfile入門ガイド:ゼロから作る最初のDockerイメージ(実例付き)
Docker vs 仮想マシン:5分でわかる性能差と選び方
Docker vs 仮想マシン:5分でわかる性能差と選び方
Dockerインストール完全ガイド2025:Permission Deniedから成功までの全手順

コメント
GitHubアカウントでログインしてコメントできます