言語を切り替える
テーマを切り替える

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 つの言葉で表現できる:残すべきものが残っておらず、捨てるべきものが捨てられていない

具体的には:

  1. ベースイメージが大きすぎるubuntu:20.04 自体が 77MB で、Go ツールチェーンをインストールすると 900MB を超える
  2. ビルドツールが残っている:gcc、make、git などのビルドツールは本番環境では全く必要ない
  3. キャッシュがクリーンアップされていない:apt/apk のパッケージ管理キャッシュがイメージレイヤーに残っている
  4. 依存関係が重複している:開発用依存関係やテストフレームワークも一緒に含まれている

例えるなら、旅行に行くのにスーツケース、寝袋、テント、調理器具……全部持って行ったのに、実際はホテルに泊まるだけ。マルチステージビルドは、本当に必要なものだけを持っていく——服と洗面用具だけで、他は全部家に置いていく。

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"]

ここにはいくつかのテクニックがある:

  1. FROM scratch:空のイメージ、0 バイトのスタート地点、バイナリファイルだけが含まれる
  2. CGO_ENABLED=0:CGO を無効化し、純粋な静的バイナリを生成
  3. CA 証明書:アプリケーションが HTTPS API を呼び出す必要がある場合、証明書ファイルをコピーする必要がある
  4. 依存関係キャッシュの最適化:まず 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"]

ポイント:

  1. npm ci --only=productiondependencies だけをインストールし、devDependencies をスキップすることで、サイズがすぐに半分になる
  2. npm cache clean --force:npm キャッシュをクリーンアップしないと、イメージレイヤーに残ってしまう
  3. ビルドと実行の分離: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 にインストールし、そのディレクトリ全体を本番イメージにコピーしている。

核心的なテクニック:

  1. --no-cache-dir:pip はデフォルトでダウンロードしたパッケージをキャッシュするが、このパラメータを追加することでキャッシュの残存を避ける
  2. slim vs alpine:ビルドステージでは slim を使い(互換性が良い)、本番ステージでは alpine を使う(サイズが小さい)
  3. 仮想環境:依存関係が複雑な場合、--user ではなく venv を検討できる

実測データ:FastAPI + SQLAlchemy を使用したプロジェクトでは、元のイメージは約 300MB、マルチステージビルド後は約 100MB。

ベースイメージの選択:Alpine vs Distroless vs Slim

実行ステージのベースイメージを選択することは、トレードオフが必要な決断だ。

3 つの主要なアプローチを比較表にまとめた:

特徴AlpineDistrolessSlim
ベースサイズ3-5MB20-65MB50-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-slimpython: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 . . はプロジェクトディレクトリ全体をコピーしてしまう。.gitnode_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

    ステップ1: 現在のイメージ構成を分析する

    `docker history` コマンドを使ってイメージの各レイヤーのサイズを確認する:

    ```bash
    docker history your-image:tag
    ```

    最もスペースを占有しているレイヤーを特定する。通常は:
    • ベースイメージ自体
    • ビルドツールとコンパイル依存関係
    • パッケージ管理キャッシュ
  2. 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

    ステップ3: ビルドしてイメージサイズを比較する

    新しいイメージをビルドしてサイズの変化を比較する:

    ```bash
    docker build -t myapp:optimized .
    docker images | grep myapp
    ```

    最適化前後のサイズの違いを比較する。
  4. 4

    ステップ4: アプリケーションの機能を検証する

    コンテナを実行してアプリケーションをテストする:

    ```bash
    docker run -d -p 8080:8080 myapp:optimized
    curl http://localhost:8080/health
    ```

    機能が完全で、依存関係が欠けていないことを確認する。
  5. 5

    ステップ5: 本番環境にデプロイする

    CI/CD フローを更新して新しいイメージを使用する:

    • イメージレジストリにプッシュ
    • Kubernetes Deployment または docker-compose.yml を更新
    • デプロイの成功を確認

FAQ

マルチステージビルドはビルド速度に影響するか?
マルチステージビルドはビルド時間を増加させる(2 つのステージをビルドする必要があるため)が、最終的なイメージサイズは大幅に削減され、デプロイと転送速度が大幅に向上する。CI/CD フロー全体では、所要時間は通常短縮される。
Alpine と Distroless はどちらを選ぶべきか?
Go の静的コンパイルアプリケーションは Alpine または scratch を優先。Node.js/Python はまず slim で互換性を確認し、その後 Alpine を試す。セキュリティ要件が非常に高いシナリオでは Distroless を選ぶが、事前にログとモニタリングのソリューションを準備する必要がある。
マルチステージビルドはどの言語に適用できるか?
ほぼすべてのプログラミング言語に適用できる。最も効果が顕著なのは Go(10MB まで削減可能)、Node.js(80% 以上削減)、Python(60% 以上削減)、Rust、Java など、コンパイルステップや依存関係管理がある言語だ。
マルチステージビルドで設定ファイルをどう扱うか?
設定ファイルは通常、実行ステージで別途マウントすることを推奨。イメージにパッケージ化することはお勧めしない。Docker volume または Kubernetes ConfigMap を使用できる。どうしてもパッケージ化する必要がある場合は、実行ステージで設定ファイルを COPY すればよい。
マルチステージビルドでイメージサイズはどれくらい削減できるか?
言語とアプリケーションのタイプによる。Go アプリケーションは通常 90%-99% 削減できる(1GB から 10MB)。Node.js アプリケーションは 70%-90% 削減。Python アプリケーションは 50%-70% 削減。重要なのは、実行に必要なファイルだけを残すことだ。
FROM scratch にはどのような注意点があるか?
scratch は空のイメージで、シェル、パッケージマネージャー、CA 証明書がない。アプリケーションが HTTPS 呼び出しを必要とする場合、builder ステージから /etc/ssl/certs/ca-certificates.crt をコピーする必要がある。デバッグが困難なので、まず alpine で機能を検証することをお勧めする。

6 min read · 公開日: 2026年4月19日 · 更新日: 2026年4月19日

関連記事

コメント

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