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

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。

問題はどこにあるのか?一言でいえば、残すべきものを残さず、捨てるべきものを捨てていないことです。

具体的には:

  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 が新しいビルドステージを開始します。

最もシンプルな例を見てみましょう:

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

ここにはいくつかのテクニックがあります:

  1. FROM scratch:空のイメージで、0 バイトの起点。あなたのバイナリだけが入る
  2. CGO_ENABLED=0:CGO を無効化し、純粋な静的バイナリを生成する
  3. CA 証明書:アプリが HTTPS インターフェースを呼び出す必要があるなら、証明書ファイルをコピーする必須がある
  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 と alpine:ビルドステージは slim(互換性が良い)、本番ステージは alpine(サイズが小さい)
  3. 仮想環境:依存が複雑なら、--user ではなく venv の利用を検討する

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

ベースイメージの選定:Alpine と Distroless と Slim

実行ステージのベースイメージ選びは、トレードオフが必要な判断です。

主流の 3 つの選択肢の比較を 1 枚の表にまとめました:

特性AlpineDistrolessSlim
ベースサイズ3-5MB20-65MB50-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
  • あるいはいっそ slimalpine の代わりに使う

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 を書いてきて、踏んだ落とし穴はプール 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 . . がプロジェクトディレクトリ全体(.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% 削減)

まだマルチステージビルドを使ったことがないなら、今すぐ試してみてください。プロジェクトを 1 つ選び、上のテンプレートを参考に 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 で機能を確認することをおすすめします。

4分で読めます · 公開日: 2026年4月19日 · 更新日: 2026年6月8日

関連記事

コメント

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