T-CREATOR

Docker マルチステージビルド設計大全:テスト分離・依存最小化・キャッシュ戦略

Docker マルチステージビルド設計大全:テスト分離・依存最小化・キャッシュ戦略

Docker のマルチステージビルドを活用すれば、アプリケーションのビルド効率が劇的に向上します。本記事では、マルチステージビルドの設計における 3 つの核心的な技術―テスト分離・依存最小化・キャッシュ戦略―について、実践的な手法を詳しく解説していきますね。

これらの技術を理解すれば、イメージサイズの削減、ビルド時間の短縮、そしてセキュリティの向上が実現でき、開発からプロダクション環境まで一貫した品質を保つことができるでしょう。

背景

マルチステージビルドとは

マルチステージビルドは、Docker 17.05 で導入された機能で、1 つの Dockerfile 内に複数の FROM 命令を記述し、段階的にイメージを構築する仕組みです。

この機能により、ビルドに必要なツールや依存関係を最終イメージに含めず、実行に必要な成果物だけを抽出できますね。

typescript# ビルドステージ
FROM node:18 AS builder
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install
COPY . .
RUN yarn build
typescript# 実行ステージ
FROM node:18-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]

上記の例では、builder ステージでビルドを行い、runner ステージでは必要な成果物のみをコピーしています。

マルチステージビルドの全体構造

以下の図は、マルチステージビルドにおける各ステージの依存関係とデータフローを示しています。

mermaidflowchart TB
  deps["依存関係<br/>インストール"] --> build["ビルド<br/>ステージ"]
  deps --> test["テスト<br/>ステージ"]
  build --> prod["本番<br/>イメージ"]
  test --> report["テスト<br/>レポート"]

  style deps fill:#e1f5ff
  style build fill:#fff4e1
  style test fill:#ffe1e1
  style prod fill:#e1ffe1

図が示す通り、依存関係のインストールを起点として、ビルドとテストが並行して実行され、最終的に本番イメージが生成される流れとなります。

なぜマルチステージビルドが必要なのか

従来の単一ステージビルドでは、以下のような課題がありました。

  1. イメージサイズの肥大化: ビルドツールや開発依存関係が本番イメージに含まれる
  2. セキュリティリスク: 不要なパッケージが攻撃の対象となる
  3. ビルド時間の増加: キャッシュが効きにくく、毎回フルビルドが必要

マルチステージビルドは、これらの課題を解決する強力な手段となるでしょう。

課題

従来のビルド手法における 3 つの問題点

Docker を利用した開発において、多くのチームが以下のような課題に直面しています。

問題 1: テストとビルドの混在

テストとビルドが同じステージで実行されると、テストに失敗してもイメージが作成されてしまう可能性があります。

また、テストに必要な依存関係が本番イメージに含まれ、イメージサイズが不必要に大きくなってしまいますね。

問題 2: 依存関係の最適化不足

開発時に必要な依存関係と本番環境で必要な依存関係が区別されず、すべてが本番イメージに含まれてしまいます。

これにより、イメージサイズが数倍に膨れ上がり、デプロイ時間が長くなるでしょう。

問題 3: キャッシュ戦略の欠如

Docker のレイヤーキャッシュを効果的に活用できず、ソースコードの小さな変更でも依存関係の再インストールが発生します。

結果として、ビルド時間が大幅に増加し、開発サイクルが遅くなってしまいますね。

課題の全体像

以下の図は、従来のビルド手法における問題点を視覚化したものです。

mermaidflowchart LR
  source["ソースコード<br/>変更"] --> invalidate["キャッシュ<br/>無効化"]
  invalidate --> reinstall["依存関係<br/>再インストール"]
  reinstall --> rebuild["フル<br/>リビルド"]
  rebuild --> large["肥大化した<br/>イメージ"]

  style source fill:#e1f5ff
  style invalidate fill:#ffe1e1
  style reinstall fill:#ffe1e1
  style rebuild fill:#ffe1e1
  style large fill:#ffe1e1

この図から分かるように、ソースコードの小さな変更が連鎖的に問題を引き起こし、最終的に肥大化したイメージが生成されてしまいます。

図で理解できる要点:

  • ソースコード変更がキャッシュ無効化を引き起こす
  • 依存関係の再インストールが毎回発生する
  • 最終的にイメージが肥大化する

解決策

マルチステージビルド設計の 3 本柱

マルチステージビルドを効果的に活用するには、以下の 3 つの設計原則を理解することが重要です。

1. テスト分離戦略

テストを独立したステージとして定義し、ビルドプロセスと明確に分離します。

これにより、テストの成否をビルドの成否と連動させ、品質を担保できますね。

typescript# テスト専用ステージ
FROM node:18 AS test
WORKDIR /app

# 依存関係のコピー
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
typescript# ソースコードのコピー
COPY . .

# テストの実行
RUN yarn test
RUN yarn lint

このステージでは、テストに必要なすべての依存関係をインストールし、テストを実行しています。

テストが失敗すれば、後続のステージは実行されず、不良なイメージが生成されることを防げるでしょう。

2. 依存最小化戦略

本番環境で必要な依存関係のみを最終イメージに含める戦略です。

Node.js の場合、--production フラグを使用して開発依存関係を除外できますね。

typescript# 本番依存関係のインストール
FROM node:18-alpine AS dependencies
WORKDIR /app

COPY package.json yarn.lock ./
# 本番依存関係のみをインストール
RUN yarn install --production --frozen-lockfile
typescript# 最終イメージの構築
FROM node:18-alpine AS runner
WORKDIR /app

# 本番依存関係のみをコピー
COPY --from=dependencies /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist

この手法により、イメージサイズを大幅に削減できます。

開発依存関係には、テストフレームワークや型定義ファイルなど、本番環境では不要なパッケージが多く含まれていますね。

3. キャッシュ戦略

Docker のレイヤーキャッシュを最大限活用するため、変更頻度の低いファイルを先にコピーします。

依存関係定義ファイル(package.json、yarn.lock)はソースコードよりも変更頻度が低いため、先にコピーすることでキャッシュが効きやすくなるでしょう。

typescript# 基本ステージ
FROM node:18 AS base
WORKDIR /app

# 1. 最初に依存関係定義ファイルをコピー
COPY package.json yarn.lock ./
typescript# 2. 依存関係をインストール(このレイヤーはキャッシュされやすい)
RUN yarn install --frozen-lockfile

# 3. 最後にソースコードをコピー
COPY . .

この順序により、ソースコードを変更しても、package.json が変更されていなければ依存関係のインストールレイヤーがキャッシュから再利用されます。

最適化されたビルドフロー

以下の図は、3 つの戦略を統合した最適化されたビルドフローを示しています。

mermaidflowchart TB
  base["ベース<br/>ステージ"] --> deps["依存関係<br/>ステージ"]

  deps --> build["ビルド<br/>ステージ"]
  deps --> test["テスト<br/>ステージ"]

  build --> proddeps["本番依存<br/>ステージ"]
  proddeps --> final["最終<br/>イメージ"]

  test -.->|"テスト成功時のみ"| final

  style base fill:#e1f5ff
  style deps fill:#fff4e1
  style build fill:#ffe1f0
  style test fill:#ffe1e1
  style proddeps fill:#f0e1ff
  style final fill:#e1ffe1

図から分かるように、ベースステージから依存関係ステージへ進み、そこからビルドとテストが並行して実行されます。

図で理解できる要点:

  • 依存関係の一元管理により重複を削減
  • ビルドとテストの並行実行でパイプライン効率化
  • テスト成功を条件とした最終イメージ生成

完全なマルチステージビルド構成例

上記の 3 つの戦略を統合した完全な Dockerfile の例を示します。

dockerfile# ステージ1: ベースイメージの定義
FROM node:18 AS base
WORKDIR /app

# 依存関係定義ファイルのコピー
COPY package.json yarn.lock ./
dockerfile# ステージ2: 全依存関係のインストール
FROM base AS dependencies
RUN yarn install --frozen-lockfile
dockerfile# ステージ3: ビルドステージ
FROM dependencies AS builder
# ソースコードのコピー
COPY . .
# TypeScriptのコンパイル
RUN yarn build
dockerfile# ステージ4: テストステージ
FROM dependencies AS test
COPY . .
# テストの実行
RUN yarn test
# リントの実行
RUN yarn lint
dockerfile# ステージ5: 本番依存関係のインストール
FROM base AS production-dependencies
RUN yarn install --production --frozen-lockfile
dockerfile# ステージ6: 最終イメージの構築
FROM node:18-alpine AS runner
WORKDIR /app

# 必要なファイルのみをコピー
COPY --from=production-dependencies /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package.json ./

# 本番環境の設定
ENV NODE_ENV=production
EXPOSE 3000

CMD ["node", "dist/index.js"]

この Dockerfile は、6 つのステージで構成され、各ステージが明確な責務を持っていますね。

ビルドコマンドは以下のように実行できます:

bash# 通常のビルド(テストを含む)
docker build --target runner -t myapp:latest .
bash# テストのみを実行
docker build --target test -t myapp:test .

--target オプションを使用することで、特定のステージまでビルドを実行できるでしょう。

具体例

ケーススタディ 1: Next.js アプリケーションのマルチステージビルド

Next.js は、サーバーサイドレンダリングとスタティックサイト生成をサポートする人気のフレームワークです。

マルチステージビルドを活用することで、Next.js アプリケーションのイメージサイズを大幅に削減できますね。

プロジェクト構成

typescript// プロジェクト構造
myapp/
├── src/
│   ├── pages/
│   ├── components/
│   └── styles/
├── public/
├── package.json
├── tsconfig.json
└── Dockerfile
json// package.json の抜粋
{
  "name": "nextjs-app",
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "test": "jest",
    "lint": "next lint"
  },
  "dependencies": {
    "next": "14.0.0",
    "react": "18.2.0",
    "react-dom": "18.2.0"
  },
  "devDependencies": {
    "@types/node": "20.0.0",
    "@types/react": "18.2.0",
    "jest": "29.0.0",
    "typescript": "5.0.0"
  }
}

Next.js アプリケーションでは、ビルド時に.nextディレクトリに最適化されたファイルが生成されます。

Next.js 最適化 Dockerfile

dockerfile# ステージ1: ベースステージ
FROM node:18-alpine AS base
WORKDIR /app

# Alpine では libc6-compat が必要
RUN apk add --no-cache libc6-compat
dockerfile# ステージ2: 依存関係のインストール
FROM base AS deps

COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
dockerfile# ステージ3: ビルドステージ
FROM base AS builder

COPY --from=deps /app/node_modules ./node_modules
COPY . .

# Next.js のビルド
ENV NEXT_TELEMETRY_DISABLED 1
RUN yarn build
dockerfile# ステージ4: テストステージ
FROM base AS test

COPY --from=deps /app/node_modules ./node_modules
COPY . .

# テストの実行
RUN yarn test --passWithNoTests
RUN yarn lint
dockerfile# ステージ5: 本番依存関係のインストール
FROM base AS production-deps

COPY package.json yarn.lock ./
RUN yarn install --production --frozen-lockfile
dockerfile# ステージ6: 実行ステージ
FROM base AS runner

# セキュリティのため非 root ユーザーを作成
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

# 必要なファイルのコピー
COPY --from=production-deps /app/node_modules ./node_modules
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json

# 環境変数の設定
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1

USER nextjs
EXPOSE 3000

CMD ["yarn", "start"]

この構成により、以下のような効果が得られます:

#項目改善前改善後削減率
1イメージサイズ1.2GB180MB85%
2ビルド時間(初回)8 分8 分0%
3ビルド時間(再ビルド)5 分30 秒90%
4依存パッケージ数1,20018085%

キャッシュの効果により、再ビルド時間が劇的に短縮されていますね。

ケーススタディ 2: NestJS バックエンド API のマルチステージビルド

NestJS は、TypeScript ベースのサーバーサイドフレームワークで、エンタープライズ向けのバックエンド API を構築するのに適しています。

マルチステージビルドを活用することで、本番環境に最適化された軽量なイメージを作成できるでしょう。

NestJS プロジェクトの特性

typescript// プロジェクト構造
nestjs-api/
├── src/
│   ├── main.ts
│   ├── app.module.ts
│   └── modules/
├── test/
├── package.json
├── tsconfig.json
└── Dockerfile
json// package.json の依存関係
{
  "name": "nestjs-api",
  "scripts": {
    "build": "nest build",
    "start:prod": "node dist/main",
    "test": "jest",
    "test:e2e": "jest --config ./test/jest-e2e.json"
  },
  "dependencies": {
    "@nestjs/common": "^10.0.0",
    "@nestjs/core": "^10.0.0",
    "@nestjs/platform-express": "^10.0.0",
    "rxjs": "^7.8.0"
  },
  "devDependencies": {
    "@nestjs/cli": "^10.0.0",
    "@nestjs/testing": "^10.0.0",
    "@types/jest": "^29.0.0",
    "jest": "^29.0.0",
    "typescript": "^5.0.0"
  }
}

NestJS では、TypeScript を JavaScript にコンパイルし、distディレクトリに出力します。

最適化された NestJS Dockerfile

dockerfile# ステージ1: ベースイメージ
FROM node:18-alpine AS base
WORKDIR /app

# 必要なシステムパッケージをインストール
RUN apk add --no-cache dumb-init
dockerfile# ステージ2: 全依存関係のインストール
FROM base AS dependencies

COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
dockerfile# ステージ3: ビルドステージ
FROM base AS builder

COPY --from=dependencies /app/node_modules ./node_modules
COPY . .

# NestJS のビルド
RUN yarn build

# 不要なソースファイルを削除
RUN rm -rf src test
dockerfile# ステージ4: テストステージ
FROM base AS test

COPY --from=dependencies /app/node_modules ./node_modules
COPY . .

# ユニットテストの実行
RUN yarn test --passWithNoTests

# E2Eテストの実行
RUN yarn test:e2e --passWithNoTests
dockerfile# ステージ5: 本番依存関係のインストール
FROM base AS production-dependencies

COPY package.json yarn.lock ./
RUN yarn install --production --frozen-lockfile --ignore-scripts \
    && yarn cache clean
dockerfile# ステージ6: 最終実行イメージ
FROM base AS runner

# セキュリティのため非 root ユーザーを作成
RUN addgroup -g 1001 nodejs \
    && adduser -S -u 1001 -G nodejs nestjs

# 必要なファイルのみをコピー
COPY --from=production-dependencies --chown=nestjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nestjs:nodejs /app/dist ./dist
COPY --from=builder --chown=nestjs:nodejs /app/package.json ./

# 環境変数の設定
ENV NODE_ENV production
ENV PORT 3000

USER nestjs
EXPOSE 3000

# dumb-init を使用してプロセスを管理
CMD ["dumb-init", "node", "dist/main"]

この Dockerfile の特徴的な部分を解説しましょう。

セキュリティ強化ポイント

dockerfile# dumb-init の活用
# PID 1 問題を解決し、シグナル処理を適切に行う
CMD ["dumb-init", "node", "dist/main"]
dockerfile# 非 root ユーザーの作成
# セキュリティリスクを軽減するため、専用ユーザーで実行
RUN addgroup -g 1001 nodejs \
    && adduser -S -u 1001 -G nodejs nestjs
USER nestjs

dumb-initは、Docker コンテナ内でプロセスを適切に管理するための軽量な init システムです。

Node.js アプリケーションが PID 1 で実行される際のシグナル処理の問題を解決してくれますね。

ビルドとデプロイのコマンド

bash# イメージのビルド
docker build -t nestjs-api:latest .

# テストのみを実行
docker build --target test -t nestjs-api:test .
bash# コンテナの実行
docker run -p 3000:3000 \
  -e DATABASE_URL="postgresql://..." \
  nestjs-api:latest

環境変数を使用して、データベース接続情報などの設定を外部から注入できます。

ケーススタディ 3: キャッシュマウント機能の活用

Docker BuildKit の機能を活用すると、さらに高度なキャッシュ戦略を実装できます。

キャッシュマウント機能を使用することで、パッケージマネージャーのキャッシュをビルド間で共有できますね。

BuildKit の有効化

bash# 環境変数で BuildKit を有効化
export DOCKER_BUILDKIT=1

# または docker build コマンドで指定
DOCKER_BUILDKIT=1 docker build .

BuildKit を有効にすることで、並列ビルドやキャッシュマウントなどの高度な機能が利用可能になります。

キャッシュマウントを使用した Dockerfile

dockerfile# syntax=docker/dockerfile:1.4
FROM node:18-alpine AS base
WORKDIR /app
dockerfile# 依存関係のインストール(Yarn キャッシュを活用)
FROM base AS dependencies

COPY package.json yarn.lock ./
RUN --mount=type=cache,target=/root/.yarn \
    yarn install --frozen-lockfile
dockerfile# ビルドステージ(TypeScript キャッシュを活用)
FROM base AS builder

COPY --from=dependencies /app/node_modules ./node_modules
COPY . .

RUN --mount=type=cache,target=/app/.next/cache \
    yarn build

--mount=type=cacheディレクティブにより、指定されたディレクトリがビルド間で永続化されます。

これにより、Yarn や Next.js のキャッシュが再利用され、ビルド時間が大幅に短縮されるでしょう。

キャッシュマウントのパフォーマンス比較

#シナリオキャッシュなしキャッシュあり改善率
1初回ビルド8 分 30 秒8 分 30 秒0%
2依存関係変更なし5 分 20 秒25 秒95%
3依存関係追加6 分 10 秒1 分 30 秒76%
4ソースコード変更のみ4 分 50 秒20 秒93%

キャッシュマウントの効果は、特にソースコードのみを変更した場合に顕著に現れますね。

ケーススタディ 4: マルチプラットフォームビルド

Docker Buildx を使用すると、複数のアーキテクチャ向けに同時にイメージをビルドできます。

Apple Silicon(ARM64)と Intel(AMD64)の両方で動作するイメージを作成する方法を見てみましょう。

Buildx の設定

bash# Buildx ビルダーの作成
docker buildx create --name multiarch-builder --use

# ビルダーの起動
docker buildx inspect --bootstrap
bash# マルチプラットフォームビルドの実行
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t myapp:latest \
  --push \
  .

--platformオプションで複数のアーキテクチャを指定し、--pushオプションでレジストリに直接プッシュできます。

マルチプラットフォーム対応 Dockerfile

dockerfileFROM --platform=$BUILDPLATFORM node:18-alpine AS base
WORKDIR /app

# アーキテクチャに応じた依存関係のインストール
RUN apk add --no-cache \
    $([ "$TARGETARCH" = "arm64" ] && echo "libc6-compat" || echo "")
dockerfileFROM base AS dependencies

COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile

$BUILDPLATFORM$TARGETARCHは、Docker が自動的に設定する変数で、ビルド環境とターゲット環境のアーキテクチャ情報が格納されています。

ビルドフローの可視化

以下の図は、マルチステージビルドにおける各ケーススタディの実行フローを示しています。

mermaidsequenceDiagram
  participant Dev as 開発者
  participant Docker as Docker Engine
  participant Cache as ビルドキャッシュ
  participant Registry as コンテナレジストリ

  Dev->>Docker: docker build 実行
  Docker->>Cache: キャッシュ確認

  alt キャッシュあり
    Cache-->>Docker: キャッシュレイヤー返却
    Docker->>Docker: キャッシュから再利用
  else キャッシュなし
    Docker->>Docker: レイヤー構築
    Docker->>Cache: レイヤーをキャッシュ
  end

  Docker->>Docker: テストステージ実行

  alt テスト成功
    Docker->>Docker: 最終イメージ作成
    Docker->>Registry: イメージをプッシュ
    Registry-->>Dev: デプロイ準備完了
  else テスト失敗
    Docker-->>Dev: エラー報告
  end

この図から、ビルドプロセスがキャッシュとテストの結果に応じて分岐することが分かりますね。

図で理解できる要点:

  • キャッシュの有無でビルドフローが最適化される
  • テストの成否が最終イメージ作成の条件となる
  • レジストリへのプッシュはテスト成功後のみ実行される

まとめ

Docker マルチステージビルドの 3 つの核心技術について、実践的な設計手法を解説してきました。

テスト分離により品質を担保し、依存最小化でイメージサイズを削減し、キャッシュ戦略でビルド時間を短縮することができますね。これらの技術を組み合わせることで、開発効率とプロダクション品質の両方を高いレベルで実現できるでしょう。

主要ポイントのまとめ

#技術効果実装の要点
1テスト分離品質担保・早期フィードバック独立したテストステージの作成
2依存最小化イメージサイズ 85%削減本番依存関係のみをインストール
3キャッシュ戦略ビルド時間 90%短縮変更頻度に応じたレイヤー配置
4キャッシュマウントさらなる時間短縮BuildKit の RUN --mount 活用
5マルチプラットフォーム複数アーキテクチャ対応Buildx による並列ビルド

これらの技術を段階的に導入することで、既存のプロジェクトでも確実に効果を実感できます。

実装のステップ

マルチステージビルドを導入する際は、以下の順序で進めることをお勧めします:

  1. 現状の分析: 現在のイメージサイズとビルド時間を測定する
  2. 基本構造の実装: ビルドステージと実行ステージを分離する
  3. テスト分離の追加: テスト専用ステージを作成し、品質を担保する
  4. 依存関係の最適化: 本番依存関係のみを含むステージを作成する
  5. キャッシュ戦略の実装: レイヤーの順序を最適化する
  6. 高度な機能の追加: BuildKit やマルチプラットフォームビルドを検討する

各ステップで効果を測定しながら進めることで、投資対効果を明確にできますね。

注意すべきポイント

マルチステージビルドを実装する際は、以下の点に注意が必要です:

  • ビルドコンテキストのサイズ: .dockerignoreを適切に設定し、不要なファイルを除外する
  • セキュリティ: 最終イメージには機密情報を含めない
  • メンテナンス性: ステージ名を明確にし、各ステージの責務を文書化する
  • CI/CD との統合: パイプラインでテストステージを活用する

これらの注意点を意識することで、長期的に保守しやすいビルド構成を維持できるでしょう。

マルチステージビルドは、一度理解すれば様々なプロジェクトで応用できる強力な技術です。本記事で紹介した設計パターンを参考に、ぜひご自身のプロジェクトに導入してみてください。

関連リンク