マルチステージビルドでDockerイメージを最適化する:包括的ガイド

Dockerのマルチステージビルドを習得して、イメージサイズを劇的に縮小し、デプロイを高速化し、セキュリティを強化しましょう。この包括的なガイドでは、GoやNode.jsの実践的な例とともに、ステップバイステップの手順と重要なベストプラクティスを提供します。ビルド依存関係を分離し、必要なコンポーネントだけを最終的なランタイムイメージに含めることで、Dockerfileを最適化する方法を学びます。効率的で安全なコンテナ化アプリケーションを構築したいすべての人にとって必読の内容です。

マルチステージビルドでDockerイメージを最適化する:包括的ガイド

マルチステージビルドは、Dockerのごく一般的な問題を解決します。アプリケーションをビルドするために必要なツールは、通常、それを実行するために必要なツールとは異なります。

Goコンパイラ、Nodeパッケージキャッシュ、Mavenリポジトリ、テストフレームワーク、ビルドヘッダーは、イメージのビルド中は役立ちます。しかし、ランタイムイメージでは無駄な重荷です。プルが遅くなり、パッチを適用する必要があるソフトウェアの量が増え、本番環境で実際に何が実行されているかを理解するのが難しくなります。

マルチステージDockerfileを使用すると、1つのステージでビルドし、完成したアーティファクトだけをより小さなランタイムステージにコピーします。最終イメージは、明示的にファイルをコピーしない限り、ビルドステージを継承しません。

シングルステージイメージの問題点

典型的なGoアプリケーションを考えてみましょう。コンパイルするにはGoツールチェーンが必要です。Linuxバイナリができたら、コンパイラはもう必要ありません。シングルステージイメージは、それをそのまま保持します。

FROM golang:1.21-alpine

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o myapp

EXPOSE 8080
CMD ["./myapp"]

これは実行できますが、最終イメージにはGoツールチェーンとビルドキャッシュがまだ含まれています。同じパターンは、Node、Java、Rust、ネイティブ拡張機能を持つPythonパッケージ、およびフロントエンドビルドでも見られます。

その代償は現実的です。

  • イメージサイズの増加: レイヤーが増え、プルと保存のデータ量が増加します。
  • デプロイ時間の延長: イメージが大きいと転送に時間がかかります。
  • セキュリティリスクの導入: 不要なソフトウェアによる攻撃対象領域の拡大。
  • ランタイム環境の不明瞭化: 実際に何が必要かを理解するのが難しくなります。

小さいイメージが自動的にランタイムで高速になるわけではありませんが、CI、レジストリ、デプロイシステムを通過するのは高速です。また、セキュリティレビューがよりノイズの少ないものになります。

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

FROM命令は新しいステージを開始します。ステージに名前を付け、後でそこからファイルをコピーできます。

FROM golang:1.21-alpine AS builder
# ここでファイルをビルド

FROM alpine:3.20
COPY --from=builder /app/myapp /app/myapp

2番目のステージは新しく開始されます。/usr/local/go、ソースファイル、パッケージキャッシュ、または最初のステージからのビルドツールは、コピーしない限り含まれません。

クリーンなGoの例

以下は小さなアプリケーションです。

package main

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

func handler(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintf(w, "Hello from optimized Docker image!")
}

func main() {
	http.HandleFunc("/", handler)
	log.Println("Server starting on :8080...")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

マルチステージDockerfile:

FROM golang:1.21-alpine AS builder

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags='-w -s' -o myapp

FROM alpine:3.20

WORKDIR /app
COPY --from=builder /app/myapp /app/myapp

EXPOSE 8080
CMD ["/app/myapp"]

go.modgo.sumファイルは、完全なソースツリーの前にコピーされるため、アプリケーションコードのみが変更された場合にDockerが依存関係のダウンロードレイヤーを再利用できます。CGO_ENABLED=0は、静的バイナリが必要な場合に便利です。アプリケーションがCライブラリに依存している場合は、静的ビルドを強制する代わりに、それらのライブラリを含むランタイムイメージが必要になる場合があります。

ビルドして比較します。

docker build -t go-app:multi-stage .
docker images go-app:multi-stage
docker history go-app:multi-stage

ブログの例のサイズに依存しないでください。自分のイメージを確認してください。依存関係の選択、ベースイメージのバージョン、デバッグシンボル、証明書、タイムゾーンデータ、ネイティブライブラリはすべて結果に影響します。

ランタイムベースイメージの選択肢

alpineは小さいため人気がありますが、小さいことが常に互換性があるとは限りません。Alpineはmusl libcを使用しますが、一般的なLinuxディストリビューションの多くはglibcを使用します。ほとんどのGo静的バイナリは問題なく動作します。一部のPython、Node、Java、またはネイティブパッケージは動作が異なります。

一般的なランタイムオプション:

ランタイムベース 適している用途 トレードオフ
alpine 小さなイメージ、シンプルなバイナリ musl互換性の違い
debian:bookworm-slim 幅広いLinux互換性 Alpineより大きい
Distrolessイメージ ツールが少ない本番ランタイム コンテナ内でのデバッグが難しい
scratch 静的バイナリのみ シェル、CA証明書、パッケージマネージャーなし(コピーしない限り)

アプリがHTTPSエンドポイントを呼び出す場合は、最終イメージにCA証明書が含まれていることを確認してください。証明書のないscratchイメージは、ネットワーク問題のように見える形で失敗する可能性があります。

FROM alpine:3.20 AS certs
RUN apk add --no-cache ca-certificates

FROM scratch
COPY --from=builder /app/myapp /myapp
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
CMD ["/myapp"]

他の言語/フレームワーク向けのマルチステージビルド

ビルドステップがある場所ならどこでも同じ考え方が機能します。

Nodeフロントエンドの場合:

FROM node:20-alpine AS builder

WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:1.27-alpine
COPY --from=builder /app/dist /usr/share/nginx/html

Node APIの場合、開発依存関係が含まれている場合は、node_modulesを開発インストールからコピーしないでください。

FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY . .
CMD ["node", "server.js"]

Javaの場合:

FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /src
COPY pom.xml .
RUN mvn -q -DskipTests dependency:go-offline
COPY src ./src
RUN mvn -q -DskipTests package

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=builder /src/target/app.jar /app/app.jar
CMD ["java", "-jar", "/app/app.jar"]

ビルドキャッシュも重要

マルチステージビルドは最終イメージサイズを削減しますが、Dockerfileの順序は依然としてキャッシュ動作を制御します。安定した依存関係ファイルを、変動しやすいソースファイルの前に配置します。再現可能なビルドではnpm installの代わりにnpm ciを使用します。本番環境ではlatestに依存する代わりに、ベースイメージのバージョンを固定します。

BuildKitを使用すると、キャッシュマウントを使用して、キャッシュを最終イメージに焼き付けることなくパッケージのダウンロードを高速化できます。

# syntax=docker/dockerfile:1.7
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=linux go build -o myapp

そのキャッシュはビルドマシン用であり、ランタイムイメージ用ではありません。

何をコピーし、何をコピーしないか

最小限の完全なランタイムセットをコピーします。コンパイルされたサービスの場合、それは1つのバイナリと設定テンプレート、CA証明書かもしれません。フロントエンドの場合、distディレクトリかもしれません。Javaの場合、jarとJREかもしれません。

ソースコード、パッケージマネージャーのキャッシュ、テストフィクスチャ、ローカルの.envファイル、SSHキー、または実行しないビルド出力はコピーしないでください。.dockerignoreファイルを使用して、これらのファイルがそもそもビルドコンテキストに入らないようにします。

.git
node_modules
coverage
dist
*.log
.env

.dockerignoreファイルは慎重なCOPY命令に取って代わるものではありませんが、偶発的なコンテキストの肥大化やシークレットの漏洩を防ぎます。

マルチステージビルドのデバッグ

ステージに名前を付けます。名前付きステージはターゲットにしやすくなります。

docker build --target builder -t app-builder .
docker run --rm -it app-builder sh

これは、ビルドは成功したが、ファイルが間違ったパスにコピーされたか、ランタイムライブラリが不足しているために最終イメージが失敗する場合に便利です。

最終イメージにコピーされたファイルを検査することもできます。

docker run --rm -it --entrypoint sh my-image

イメージにシェルがない場合は、診断中に最終ステージを一時的にデバッグしやすいベースに切り替え、その後本番ベースに戻します。

実用的なルール

個別のジョブごとに1つのステージを使用します:依存関係、ビルド、テスト、ランタイム。ランタイムステージはシンプルに保ちます。誰かが最終的なDockerfileステージを開いたとき、1つの質問にすぐに答えられるようにする必要があります:このコンテナが実際に実行するために必要なファイルは何か?

それがマルチステージビルドの真の価値です。小さいイメージは素晴らしいです。明確なランタイム境界はさらに優れています。