Docker 入門

Docker マルチステージビルド

Docker のマルチステージビルド(Multi-stage builds)は非常に強力な機能であり、「ビルド時依存関係」と「ランタイム依存関係」を分離することで、高度に最適化された Docker イメージの作成を可能にします。このプロセスでは主に、単一の Dockerfile 内で複数の FROM インストラクションを使用します。各 FROM ステートメントは、ビルドプロセスにおける異なるステージ(段階)を表します。初期のステージで生成されたアーティファクト(Artifacts)を、後続のより軽量なステージへ選択的にコピーすることで、アプリケーションとそのコアなランタイムコンポーネントのみを含む、大幅にサイズが縮小された最終イメージを得ることができます。このアプローチにより、不要なビルドツールやライブラリが含まれることによるイメージの肥大化や、アタックサーフェス(攻撃対象領域)の増加といった一般的なペインポイントを直接的に解決できます。

1. マルチステージビルドの概念を理解する

従来、アプリケーションの Docker イメージを作成するということは、必要なすべてのビルドツール、コンパイラ、および依存関係を単一のイメージに直接インストールすることを意味していました。例えば、Go アプリケーションをビルドする場合、Dockerfile は Go のベースイメージから始まり、コードリポジトリをクローンするために git をインストールし、アプリケーションをコンパイルします。その結果得られるイメージには、Go コンパイラや git など、ランタイムには全く不要なビルドツールが含まれてしまいます。これにより、イメージが肥大化し、ディスク容量をより多く消費し、プル(pull)にかかる時間が長くなり、さらにより大きなセキュリティリスクにさらされることになります。

マルチステージビルドは、その使命(コードのコンパイルなど)を終えた後に破棄される「中間イメージ」を活用することで、この問題を解決します。最終イメージは極小のベースイメージに基づいて構築され、ビルドステージからコンパイル済みのアーティファクトのみをコピーします。

シンプルなアプリケーションを例に見てみましょう:

  • ビルドステージ (Build Stage): このステージでは、必要なすべてのビルドツール(例:Go コンパイラ、フロントエンドの静的アセットを処理するための Node.js、Java 開発キット JDK など)を含むベースイメージを使用します。ここでは、ソースコードのコンパイル、テストの実行、および最終的な実行可能ファイルや静的アセットの生成を担当します。
  • ランタイムステージ (Runtime Stage): このステージでは、はるかにサイズの小さいベースイメージを使用します。通常は scratch(空のイメージ)や、サイズが最適化された OS イメージ(alpine など)です。そして、ビルドステージでコンパイルされたアーティファクト(実行可能ファイル、静的ファイル、設定ファイル)のみを、この軽量イメージにコピーします。

2. マルチステージビルドのコアとなる利点

  • イメージサイズの大幅な縮小: これが最も顕著なメリットです。ビルドツールや不要なライブラリを排除することで、最終イメージのサイズを劇的に縮小できます。例えば、Go アプリケーションのイメージは数百メガバイトから数十メガバイトに縮小され、scratch をベースにビルドした場合はわずか数メガバイトになることもあります。
  • セキュリティの向上: イメージが小さいということは、アタックサーフェスが小さいことを意味します。インストールされているソフトウェアが少ないほど、エクスプロイト(悪用)される可能性のある潜在的な脆弱性も少なくなります。
  • イメージビルドの高速化(最終ステージ向け): 全体的なビルドプロセスにはより多くのステップが含まれるかもしれませんが、最終イメージのレイヤー数が少なく、ランタイムステージのベースイメージが通常小さいため、初回のフルビルドが完了した後の後続のビルドスピードが向上する可能性があります。
  • イメージのクリーンな状態の維持: ランタイムイメージにはアプリケーションの実行に絶対に必要なものだけが含まれるため、より特化された、管理しやすい実行環境を提供します。
  • 関心の分離 (Separation of Concerns): ビルドロジックとランタイムロジックが完全に分離されるため、Dockerfile がより明確になり、メンテナンスも容易になります。

3. マルチステージビルドの仕組み

Dockerfile 内の各 FROM インストラクションは、新しいビルドステージを開始します。AS <stage-name> を使用して、各ステージに名前を付けることができます。その後、COPY --from=<stage-name> インストラクションでその名前を参照することができます。

基本的な例:

# ステージ 1:アプリケーションのビルド
FROM golang:1.20 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/my-app ./cmd/server

# ステージ 2:最終的な軽量イメージの作成
FROM alpine:3.18
WORKDIR /app
# 'builder' ステージからコンパイル済みの実行可能ファイルをコピー
COPY --from=builder /app/my-app .
EXPOSE 8080
CMD ["./my-app"]

この例では:

  1. 最初の FROM golang:1.20 AS builderbuilder ステージを定義しており、比較的サイズの大きい Go 開発環境イメージを使用しています。
  2. go build コマンドが Go アプリケーションをコンパイルします。
  3. 2つ目の FROM alpine:3.18 は最終ステージを定義しており、極めて軽量な Alpine Linux イメージを使用しています。
  4. COPY --from=builder /app/my-app . が最も重要な部分です。これは builder ステージでコンパイルされた my-app 実行可能ファイルのみを現在の(最終)ステージにコピーします。builder ステージの Go コンパイラや開発ツールは、最終的な alpine イメージには一切パッケージングされません。

4. 実践アプリケーションとコード例

異なる技術スタックの例をいくつか用いて、マルチステージビルドの適用方法を深く探求してみましょう。

4.1 例1:Goアプリケーションのビルド

Go はプログラムを単一の静的バイナリファイルにコンパイルできるため、マルチステージビルドの最も典型的なユースケースです。

シナリオ: コンテナ化する必要があるシンプルな Go HTTP サーバーアプリケーション。

アプリケーションコード (main.go):

package main

import (
	"fmt"
	"log"
	"net/http"
)

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "こんにちは、Goコンテナから!")
	})
	fmt.Println("サーバーを 8080 ポートで起動しています...")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

マルチステージビルドを使用した Dockerfile:

# ステージ 1:ビルドステージ (The Build Stage)
# アプリケーションをコンパイルするために Go 開発イメージを使用します。
FROM golang:1.20 AS builder

# コンテナ内部でビルドプロセス用の作業ディレクトリを設定します。
WORKDIR /app

# 依存関係をキャッシュするために、まず go.mod と go.sum ファイルをコピーします。
# これにより、ソースコードのみが変更された場合に、依存関係が再ダウンロードされるのを防ぎます。
COPY go.mod go.sum ./
RUN go mod download

# 残りのアプリケーションソースコードをコピーします。
COPY . .

# Go アプリケーションをコンパイルします。
# CGO_ENABLED=0 は、C の依存関係がない静的リンクされたバイナリファイルを作成することを保証します。
# GOOS=linux は、それが Linux(コンテナのターゲット OS)向けにコンパイルされることを保証します。
# -o /app/my-app は、出力される実行可能ファイルの名前とパスを指定します。
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/my-app ./

# ステージ 2:最終(ランタイム)ステージ (The Final Runtime Stage)
# 最小化された Alpine Linux イメージを使用します。
FROM alpine:3.18

# 最終的なアプリケーション用の作業ディレクトリを設定します。
WORKDIR /app

# 'builder' ステージからコンパイル済みの実行可能ファイルのみを最終イメージにコピーします。
# これがマルチステージビルドの核心です。
COPY --from=builder /app/my-app .

# アプリケーションがリッスンするポートをエクスポーズします。
EXPOSE 8080

# コンテナ起動時にアプリケーションを実行するためのコマンドを定義します。
CMD ["./my-app"]

ビルドと実行:

docker build -t go-app-optimized .
docker run -p 8080:8080 go-app-optimized

ブラウザで http://localhost:8080 にアクセスすると、「こんにちは、Goコンテナから!」と表示されます。golang:1.20 を直接ランタイムとして使用するシングルステージビルドと比較して、最終的に生成されるイメージサイズははるかに小さくなります。

4.2 例2:フロントエンドを含むNode.jsアプリケーションのビルド (React/Vue/Angular)

このシナリオではバックエンドとフロントエンドアセットのビルドが含まれますが、フロントエンドのビルドツールはランタイムには不要です。

シナリオ: コンパイルされた React フロントエンドの静的ファイルを提供する Node.js バックエンド。

バックエンドコード (server.js):

const express = require('express');
const path = require('path');
const app = express();
const port = 3000;

// 'build' ディレクトリから静的ファイルを提供する
app.use(express.static(path.join(__dirname, 'build')));

app.get('/api/hello', (req, res) => {
    res.json({ message: 'こんにちは、Node.js APIから!' });
});

// その他のすべてのリクエストに対しては、React アプリの index.html を返す
app.get('*', (req, res) => {
    res.sendFile(path.join(__dirname, 'build', 'index.html'));
});

app.listen(port, () => {
    console.log(`サーバーが http://localhost:${port} でリスニングしています`);
});

フロントエンドコード(React アプリの例 package.json):

{
  "name": "my-react-app",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-scripts": "5.0.1"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build"
  }
}

(React アプリは標準の src/App.jspublic/index.html ディレクトリ構造を持っていると仮定します)

マルチステージビルドを使用した Dockerfile:

# ステージ 1:React フロントエンドのビルド
FROM node:18-alpine AS frontend_builder
# 作業ディレクトリの設定
WORKDIR /app/frontend
# フロントエンドの package.json と package-lock.json をコピーして依存関係をインストール
COPY frontend/package*.json ./
RUN npm install
# フロントエンドの残りのソースコードをコピー
COPY frontend .
# React アプリケーションをビルドします。これにより、静的ファイルを含む 'build' ディレクトリが生成されます。
RUN npm run build

# ステージ 2:Node.js バックエンドのビルド
FROM node:18-alpine AS backend_builder
WORKDIR /app/backend
# バックエンドの package.json と package-lock.json をコピー
COPY backend/package*.json ./
RUN npm install --production # プロダクション環境の依存関係のみをインストール
# バックエンドのソースコードをコピー
COPY backend .

# ステージ 3:最終的なランタイムイメージの作成
FROM node:18-alpine
WORKDIR /app
# 'frontend_builder' ステージからビルドされたフロントエンドの静的アセットをコピー
COPY --from=frontend_builder /app/frontend/build ./build
# 'backend_builder' ステージからプロダクション依存関係とバックエンドコードをコピー
COPY --from=backend_builder /app/backend/node_modules ./node_modules
COPY --from=backend_builder /app/backend/server.js .
COPY --from=backend_builder /app/backend/package.json . # npm start などの実行に必要

EXPOSE 3000
CMD ["node", "server.js"]

原理の解説:

  • frontend_builder: React アプリをコンパイルし、/app/frontend/build に静的アセットを生成します。
  • backend_builder: Node.js バックエンド用のプロダクション環境依存関係をインストールします。
  • 最終ステージでは、frontend_builder が出力したフロントエンドの静的ファイルと、backend_builder が出力したバックエンドコードおよびプロダクション依存関係を結合します。最終イメージには、npm install 用の開発依存関係や npm run build に関連するビルドツールは一切含まれません。

4.3 例3:Java Spring Bootアプリケーションのビルド

Java アプリケーションには通常、大量の依存関係があります。Maven/Gradle のビルド環境と JRE ランタイムを分離することで、マルチステージビルドはイメージサイズの大幅な削減に役立ちます。

シナリオ: シンプルな Spring Boot Web アプリケーション。

マルチステージビルドを使用した Dockerfile:

# ステージ 1:Java アプリケーションのビルド
FROM maven:3.8.7-openjdk-17 AS builder
# 作業ディレクトリの設定
WORKDIR /app
# ビルドキャッシュを活用するために、Maven プロジェクトファイル (pom.xml) を優先してコピー
COPY pom.xml .
# 依存関係のダウンロード - pom.xml に変更がなければ、このステップはキャッシュされます
RUN mvn dependency:go-offline -B
# アプリケーションのソースコードをコピー
COPY src ./src
# アプリケーションをビルドし、target/ ディレクトリ下に JAR ファイルを生成します
RUN mvn package -DskipTests

# ステージ 2:最終ランタイムイメージの作成
FROM openjdk:17-jre-slim-buster
# 作業ディレクトリの設定
WORKDIR /app
# 'builder' ステージからコンパイル済みの JAR ファイルをコピー
COPY --from=builder /app/target/*.jar app.jar
# Spring Boot が通常稼働するポートをエクスポーズ
EXPOSE 8080
# JAR ファイルを実行するためのコマンドを定義
ENTRYPOINT ["java", "-jar", "app.jar"]

原理の解説:

  • builder: OpenJDK 17 を含む Maven イメージを使用します。pom.xml をコピーして依存関係をダウンロードした後、ソースコードをコピーして JAR ファイルをビルドします。
  • 最終ステージでは、はるかに小さい openjdk:17-jre-slim-buster イメージ(JDK(開発ツールキット)は含まれず、JRE のみが含まれます)を使用し、builder ステージからコンパイル済みの app.jar のみをコピーしています。