Dockerマルチステージビルド入門|本番イメージを軽量化する実践テクニック

kento_morota 21分で読めます

Dockerイメージが肥大化し、デプロイに時間がかかる――こうした課題に悩んだことはありませんか?マルチステージビルドを活用すれば、ビルド環境と実行環境を分離し、本番イメージを劇的に軽量化できます。

この記事では、Dockerマルチステージビルドの基本概念から実践テクニックまで、具体的なコード例とともに解説します。Node.js・Go・Pythonといった主要言語での適用方法も紹介するので、すぐにプロジェクトに取り入れられるでしょう。

マルチステージビルドとは?基本概念を理解する

マルチステージビルドとは、1つのDockerfileの中に複数のFROM命令を記述し、ビルドプロセスを複数のステージに分割する手法です。各ステージは独立したビルド環境を持ち、最終ステージには本番実行に必要なファイルだけをコピーすることで、イメージサイズを大幅に削減できます。

なぜイメージの軽量化が重要なのか

Dockerイメージの肥大化は、開発・運用の多くの場面で悪影響を及ぼします。

  • デプロイ時間の増加:イメージが大きいほど、レジストリへのプッシュやプルに時間がかかる
  • ストレージコストの増大:コンテナレジストリの使用容量が増え、クラウドコストが膨らむ
  • セキュリティリスク:不要なツールやライブラリが含まれるほど、攻撃対象領域が広がる
  • 起動時間の遅延:CI/CDパイプラインでの処理時間が長くなる

マルチステージビルドを使えば、ビルドに必要なコンパイラやパッケージマネージャーは中間ステージに閉じ込め、最終イメージには実行バイナリと最小限の依存関係だけを含めることができます。

従来のDockerfileとの違い

マルチステージビルドを使わない従来のDockerfileでは、ビルドツールと実行ファイルが同一イメージに含まれてしまいます。

# 従来の方法(シングルステージ)
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# ビルドツール・devDependencies・ソースコードがすべて残る
EXPOSE 3000
CMD ["node", "dist/index.js"]

このアプローチでは、node_modulesのdevDependenciesやビルドツール、TypeScriptのソースコードなど、本番では不要なファイルがすべてイメージに含まれます。結果として、数百MBから1GB以上のイメージになることも珍しくありません。

マルチステージビルドの基本構文と書き方

マルチステージビルドの構文はシンプルです。複数のFROM命令を記述し、ASキーワードでステージに名前を付け、COPY --fromで必要なファイルだけを次のステージにコピーします。

基本構文

# ステージ1: ビルドステージ
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# ステージ2: 実行ステージ
FROM node:20-slim AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
EXPOSE 3000
CMD ["node", "dist/index.js"]

ポイントは以下の3つです。

  • FROM node:20 AS builderで、最初のステージに「builder」という名前を付ける
  • FROM node:20-slim AS runnerで、最終ステージのベースイメージを軽量なものに変更
  • COPY --from=builderで、builderステージから必要なファイルだけをコピー

ステージの命名と参照

ステージはASキーワードで名前を付けることが推奨されますが、番号(0から始まるインデックス)でも参照できます。

# 名前で参照(推奨)
COPY --from=builder /app/dist ./dist

# 番号で参照(非推奨)
COPY --from=0 /app/dist ./dist

可読性とメンテナンス性の観点から、必ず名前を付けるようにしましょう。チームでDockerfileを共有する場合、意味のある名前があると理解しやすくなります。

Node.js(TypeScript)プロジェクトでの実践

TypeScriptプロジェクトはマルチステージビルドの効果が特に大きい典型例です。ビルド時にはTypeScriptコンパイラや型定義ファイルが必要ですが、実行時にはコンパイル済みのJavaScriptだけで十分です。

Express + TypeScript の例

# ステージ1: 依存関係のインストールとビルド
FROM node:20-alpine AS builder
WORKDIR /app

# 依存関係のインストール(キャッシュ活用のため先にコピー)
COPY package.json package-lock.json ./
RUN npm ci

# ソースコードのコピーとビルド
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build

# 本番用依存関係のみ再インストール
RUN npm ci --omit=dev

# ステージ2: 本番用の軽量イメージ
FROM node:20-alpine AS runner
WORKDIR /app

# セキュリティ対策: root以外のユーザーで実行
RUN addgroup --system appgroup && adduser --system appuser --ingroup appgroup

# ビルド成果物と本番依存関係のみコピー
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./

USER appuser
EXPOSE 3000
CMD ["node", "dist/index.js"]

この構成により、TypeScriptコンパイラ(typescript)、型定義ファイル(@types/*)、テストフレームワーク(jest等)、ESLint・PrettierなどのdevDependenciesがすべて最終イメージから除外されます。

サイズ比較

実際にサイズを比較してみましょう。

# ビルドして確認
docker build -t myapp-single -f Dockerfile.single .
docker build -t myapp-multi -f Dockerfile.multi .

# サイズ確認
docker images | grep myapp
# myapp-single  latest  abc123  1.2GB
# myapp-multi   latest  def456  180MB

典型的なTypeScriptプロジェクトでは、マルチステージビルドにより80〜90%のサイズ削減が実現できます。

Go言語プロジェクトでの実践

Go言語はコンパイルして単一バイナリを生成するため、マルチステージビルドとの相性が特に良い言語です。最終イメージをscratch(空のイメージ)にすることで、極限まで軽量化できます。

Go + scratchイメージの例

# ステージ1: ビルド
FROM golang:1.23-alpine AS builder
WORKDIR /app

# 依存関係のダウンロード
COPY go.mod go.sum ./
RUN go mod download

# ソースコードのコピーとビルド
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server ./cmd/server

# ステージ2: 実行(scratchベース)
FROM scratch
# SSL証明書をコピー(HTTPS通信に必要)
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# タイムゾーン情報をコピー
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
# バイナリをコピー
COPY --from=builder /app/server /server

EXPOSE 8080
ENTRYPOINT ["/server"]

このDockerfileの重要なポイントを解説します。

  • CGO_ENABLED=0:Cのライブラリに依存しない静的バイナリを生成
  • -ldflags="-s -w":デバッグ情報を除去してバイナリサイズを削減
  • FROM scratch:OSすら含まない完全に空のイメージをベースに使用
  • SSL証明書のコピー:外部APIとHTTPS通信するために必須

この方法により、Goのビルド環境(約800MB)から、わずか10〜20MB程度の極小イメージを生成できます。

distrolessイメージという選択肢

scratchイメージはシェルが含まれないため、デバッグが困難です。Google提供のdistrolessイメージは、最低限のランタイムを含みつつ軽量性を保ちます。

# distrolessベースの場合
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]

Pythonプロジェクトでの実践

Pythonはインタープリタ言語のため、Goのようにバイナリだけをコピーすることはできません。しかし、マルチステージビルドにより、ビルドツール(gcc、python3-devなど)を除外し、最終イメージを軽量化できます。

FastAPI + Poetry の例

# ステージ1: 依存関係のビルド
FROM python:3.12-slim AS builder
WORKDIR /app

# ビルドに必要なシステムパッケージ
RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc \
    libpq-dev \
    && rm -rf /var/lib/apt/lists/*

# Poetryのインストール
RUN pip install --no-cache-dir poetry==1.8.3

# 依存関係の定義ファイルをコピー
COPY pyproject.toml poetry.lock ./

# virtualenvを作成せず、システムにインストール
RUN poetry config virtualenvs.create false \
    && poetry install --no-dev --no-interaction --no-ansi

# ステージ2: 実行用イメージ
FROM python:3.12-slim AS runner
WORKDIR /app

# ランタイムに必要なシステムライブラリのみ
RUN apt-get update && apt-get install -y --no-install-recommends \
    libpq5 \
    && rm -rf /var/lib/apt/lists/*

# Pythonパッケージをコピー
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin

# アプリケーションコードをコピー
COPY app/ ./app/

# セキュリティ対策
RUN useradd --create-home appuser
USER appuser

EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

Pythonの場合、ビルドステージでgcclibpq-dev(PostgreSQLクライアントのヘッダーファイル)を使ってCエクステンションをコンパイルし、実行ステージではlibpq5(ランタイムライブラリ)だけを含めます。この分離により、開発ツールが最終イメージから除外されます。

キャッシュ最適化のテクニック

マルチステージビルドの効果を最大化するには、Dockerのレイヤーキャッシュを意識した記述が重要です。適切なキャッシュ戦略により、ビルド時間を大幅に短縮できます。

依存関係ファイルを先にコピーする

最も基本的かつ効果的なテクニックは、依存関係の定義ファイルを先にコピーしてインストールし、その後にソースコードをコピーすることです。

# 良い例: キャッシュが効く
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# 悪い例: ソースコード変更のたびに依存関係もインストールし直し
COPY . .
RUN npm ci
RUN npm run build

ソースコードを変更しても、package.jsonが変わらなければnpm ciのレイヤーはキャッシュから再利用されます。

BuildKitのマウントキャッシュを活用する

Docker BuildKitでは、--mount=type=cacheを使ってパッケージマネージャーのキャッシュを永続化できます。

# syntax=docker/dockerfile:1
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./

# npmキャッシュをマウントして再利用
RUN --mount=type=cache,target=/root/.npm \
    npm ci

COPY . .
RUN npm run build
# Goの場合
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./

# Goモジュールキャッシュとビルドキャッシュをマウント
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    go mod download

COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 go build -o /app/server ./cmd/server

マウントキャッシュは通常のレイヤーキャッシュと異なり、Dockerfileの変更やイメージの再ビルドでも保持されるため、大規模な依存関係を持つプロジェクトで特に有効です。

.dockerignoreファイルの設定

ビルドコンテキストから不要なファイルを除外することも重要です。.dockerignoreを適切に設定しましょう。

# .dockerignore
node_modules
dist
.git
.github
*.md
.env*
.vscode
coverage
__tests__
Dockerfile*
docker-compose*

3ステージ以上のビルドパターン

より複雑なプロジェクトでは、3つ以上のステージを使うことで、さらに柔軟な構成が可能です。

テストステージを含むパターン

# ステージ1: 依存関係のインストール
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

# ステージ2: テスト実行
FROM deps AS tester
COPY . .
RUN npm run lint
RUN npm run test

# ステージ3: ビルド
FROM deps AS builder
COPY . .
RUN npm run build
RUN npm ci --omit=dev

# ステージ4: 本番実行
FROM node:20-alpine AS runner
WORKDIR /app
RUN addgroup --system appgroup && adduser --system appuser --ingroup appgroup

COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./

USER appuser
EXPOSE 3000
CMD ["node", "dist/index.js"]

この構成では、テストステージとビルドステージがdepsステージから分岐しています。テストとビルドが並列に実行可能であり、テストの失敗がビルドプロセスを中断できます。

特定のステージだけをビルドする

--targetフラグを使うと、特定のステージまでビルドを実行できます。

# テストだけ実行
docker build --target tester -t myapp-test .

# 本番イメージをビルド
docker build --target runner -t myapp .

# 開発用イメージ(依存関係まで)
docker build --target deps -t myapp-dev .

CI/CDパイプラインでは、テストステージをCIで実行し、本番ステージをデプロイ時にビルドするといった使い分けが可能です。

実践的なTipsとトラブルシューティング

マルチステージビルドを実践する中で遭遇しやすい問題とその解決策を紹介します。

ベースイメージの選び方

最終ステージのベースイメージ選択は、イメージサイズに大きく影響します。

# イメージサイズの目安
# node:20           → 約1.1GB
# node:20-slim      → 約200MB
# node:20-alpine    → 約130MB
# gcr.io/distroless/nodejs20 → 約120MB

alpineイメージは軽量ですが、muslベースのため、一部のネイティブモジュール(bcryptなど)でビルドエラーが発生する場合があります。その際はslimイメージを選択するか、alpine用のビルドオプションを指定しましょう。

デバッグ方法

マルチステージビルドで問題が発生した場合、中間ステージのコンテナに入って調査できます。

# 中間ステージで止めてシェルに入る
docker build --target builder -t myapp-debug .
docker run -it myapp-debug /bin/sh

# 特定のステージのファイルを確認
docker run --rm myapp-debug ls -la /app/dist/

# ビルドログの詳細表示
DOCKER_BUILDKIT=1 docker build --progress=plain .

よくある失敗パターン

マルチステージビルドで発生しやすいミスをまとめます。

# 失敗1: ネイティブモジュールのコピー漏れ
# node_modulesにネイティブモジュールが含まれる場合、
# ベースイメージのOSを揃える必要がある
FROM node:20-alpine AS builder  # alpineでビルド
# ...
FROM node:20-slim AS runner     # debianで実行 → ネイティブモジュールが動かない!

# 解決策: ビルドと実行のベースOSを揃える
FROM node:20-alpine AS builder
# ...
FROM node:20-alpine AS runner
# 失敗2: 実行時に必要なファイルのコピー漏れ
# 設定ファイルやテンプレートファイルを忘れがち
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
# → views/やpublic/ディレクトリのコピーを忘れている

# 解決策: 必要なディレクトリを明示的にコピー
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/views ./views
COPY --from=builder /app/public ./public

イメージサイズの分析

docker historyコマンドやdiveツールを使って、イメージの各レイヤーのサイズを分析しましょう。

# レイヤーごとのサイズ確認
docker history myapp --human --no-trunc

# diveツールでインタラクティブに分析
# インストール: https://github.com/wagoodman/dive
dive myapp

まとめ:マルチステージビルドを導入するためのチェックリスト

Dockerマルチステージビルドは、本番イメージの軽量化に最も効果的な手法です。最後に、導入時のチェックリストをまとめます。

  • ステージを明確に分離する:ビルド用・テスト用・実行用のステージを設計する
  • ベースイメージを最適化する:最終ステージにはalpineやslim、distrolessを使用する
  • 依存関係ファイルを先にコピー:レイヤーキャッシュを最大限活用する
  • .dockerignoreを設定する:不要なファイルをビルドコンテキストから除外する
  • 非rootユーザーで実行する:セキュリティのベストプラクティスを守る
  • BuildKitを活用する:マウントキャッシュや並列ビルドで高速化する
  • ベースOSを揃える:ネイティブモジュールの互換性問題を回避する

マルチステージビルドの導入は、既存のDockerfileに対しても段階的に適用できます。まずはビルドステージと実行ステージの2段構成から始め、必要に応じてテストステージや依存関係ステージを追加していくとよいでしょう。

イメージサイズの削減は、デプロイ速度の向上、ストレージコストの削減、セキュリティリスクの低減と、多くのメリットにつながります。ぜひ今日から自分のプロジェクトで試してみてください。

#Docker#マルチステージビルド#最適化
共有:
無料メルマガ

週1回、最新の技術記事をお届け

AI・クラウド・開発の最新記事を毎週月曜にメールでお届けします。登録は無料、いつでも解除できます。

プライバシーポリシーに基づき管理します

AI活用のヒントをお探しですか?お気軽にご相談ください。

まずは話だけ聞いてもらう