Dockerマルチステージビルドとdistrolessイメージ:コンテナを数MBまで圧縮する

DevOps tutorial - IT technology blog
DevOps tutorial - IT technology blog

私が最初に作った本番用Dockerイメージは1.2 GBありました。その中のアプリ?JSONを返すだけのNode.js APIで、実際のコードは50 KBほど。残りはビルドツール、パッケージマネージャー、シェルユーティリティ、そして本番コンテナに存在する理由が皆無なOSの肥大化したレイヤーでした。

distrolessベースイメージを使ったマルチステージビルドに切り替えたところ、同じアプリが68 MBまで縮小しました。セキュリティスキャナーが警告を出さなくなり、デプロイも速くなりました。これはニッチな最適化ではなく、コンテナを本番環境で動かすための基本中の基本です。

具体的な方法を説明します。

クイックスタート — 5分で軽量イメージを作る

理論は後回しにして、まず結果を見てみましょう。以下は最終イメージが10 MB以下になるGoアプリ用のマルチステージDockerfileです:

# ステージ1:ビルド
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o server .

# ステージ2:最終イメージ
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]

ビルドしてサイズを確認します:

docker build -t myapp:optimized .
docker image ls myapp:optimized
# REPOSITORY   TAG         IMAGE ID       SIZE
# myapp        optimized   a3b1c2d4e5f6   8.4MB

golang:1.22をベースにした単純なシングルステージビルドでは800 MB以上になります。同じバイナリなのに、結果は天と地ほど違います。

詳細解説 — なぜこれが効くのか

従来のDockerfileの問題点

シングルステージのDockerfileは、ビルドツールのインストール、コードのコンパイル、アプリの実行をすべて1つのイメージ内で行います。コンパイラ、パッケージマネージャー、デバッグユーティリティなど取り込んだパッケージはすべて最終イメージに永続的に焼き込まれます。

余分なバイナリは潜在的なCVEです。Goのコンパイラはアプリがリクエストをさばくのに何の役にも立ちません。攻撃者に探られる箇所が増えるだけです。シェルがあれば、エクスプロイトを見つけた攻撃者に足がかりを与えてしまいます。

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

マルチステージビルドでは、1つのDockerfile内で複数のFROM命令を使えます。各FROMは独自のファイルシステムを持つ新しいステージを開始します。ステージ間ではCOPY --from=<stage>を使って必要なファイルだけを選択的にコピーします。

最後のステージだけが出荷されます。コンパイラ、ビルドツール、中間成果物はビルド完了後にすべて破棄され、最終イメージには一切含まれません。

# ステージ1:ビルド環境(ビルド後に破棄)
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci                          # devDependenciesを含むすべての依存関係をインストール
COPY . .
RUN npm run build                   # TypeScriptのコンパイル、バンドルなど

# ステージ2:本番環境(これが出荷される)
FROM node:20-alpine AS production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev               # 本番依存関係のみ
COPY --from=build /app/dist ./dist  # コンパイル済み出力のみ
USER node
CMD ["node", "dist/index.js"]

distrolessイメージとは

Googleが管理するdistrolessイメージは、Alpineよりさらにミニマリズムを追求しています。アプリケーションとそのランタイム依存関係のみを含み、シェルもパッケージマネージャーもユーティリティバイナリも一切含まれていません。

シェルがなければ、エクスプロイトを見つけた攻撃者もコンテナにexecで入って任意のコマンドを実行できません。aptcurlwgetもなく、攻撃の足がかりになるものが何もないのです。

gcr.io/distroless/で利用できるバリアント:

  • static-debian12 — 静的コンパイルバイナリ(Go、Rust)向け
  • base-debian12 — glibcを追加、動的リンクCバイナリ向け
  • cc-debian12 — libstdc++を追加、C++アプリ向け
  • java21-debian12 — JREのみ、JDKなし
  • nodejs22-debian12 — Node.jsランタイム、npmもシェルもなし
  • python3-debian12 — Pythonランタイムのみ

応用編 — 各スタック向けの実例

Python FastAPIアプリケーション

# ステージ1:依存関係のインストール
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt

# ステージ2:distroless Pythonランタイム
FROM gcr.io/distroless/python3-debian12
WORKDIR /app
# builderからインストール済みパッケージをコピー
COPY --from=builder /root/.local /root/.local
COPY . .
ENV PATH=/root/.local/bin:$PATH
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

Java Spring Bootアプリケーション

Javaイメージは悪名高いほど重くなります。OpenJDKを使ったデフォルトのSpring Bootアプリは、何もしなくても500 MBに達します。以下の3ステージアプローチでは150 MB以下まで抑えられます。さらにレイヤード jarを使うことで、後続のビルドが大幅に速くなります:

# ステージ1:Mavenでビルド
FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -q  # 依存関係を別途キャッシュ
COPY src ./src
RUN mvn package -DskipTests -q

# ステージ2:レイヤーの抽出(Spring Bootレイヤードjar)
FROM eclipse-temurin:21-jre AS extractor
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract

# ステージ3:distroless JRE
FROM gcr.io/distroless/java21-debian12
WORKDIR /app
COPY --from=extractor /app/dependencies/ ./
COPY --from=extractor /app/spring-boot-loader/ ./
COPY --from=extractor /app/snapshot-dependencies/ ./
COPY --from=extractor /app/application/ ./
ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]

変更頻度でレイヤーを分割するのが重要なポイントです。依存関係はpom.xmlが変わったときだけ再ビルドされます。これは滅多にありません。頻繁に変わるアプリケーションコードのレイヤーは小さく保たれ、ビルドも速くなります

フロントエンド + バックエンドのマルチステージ

# ステージ1:Reactフロントエンドのビルド
FROM node:20-alpine AS frontend-build
WORKDIR /frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ .
RUN npm run build

# ステージ2:Goバックエンドのビルド
FROM golang:1.22-alpine AS backend-build
WORKDIR /backend
COPY backend/go.* ./
RUN go mod download
COPY backend/ .
COPY --from=frontend-build /frontend/dist ./static  # フロントエンドを埋め込む
RUN CGO_ENABLED=0 go build -o app .

# ステージ3:最終distroless
FROM gcr.io/distroless/static-debian12
COPY --from=backend-build /backend/app /app
ENTRYPOINT ["/app"]

実践的なヒント — 失敗から学んだこと

ヒント1:distrolessでも非rootで実行する

distrolessには専用の非rootユーザーが含まれています。活用しましょう:

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app/server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]

:nonrootタグはUID 65532で実行されます。コンテナから脱出されても、ホスト上では非特権ユーザーとして着地します。rootではありません。魔法ではなく多層防御ですが、意味があります。

ヒント2:最適化前後をスキャンして差を確認する

# trivyのインストール
brew install aquasecurity/trivy/trivy  # macOS
# または
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh

# 最適化前後のスキャン
trivy image myapp:before
trivy image myapp:optimized

# 通常、200件以上のCVEが0〜5件に減る

一度やってみてください。247件のCVEが3件に激減するのを見れば、どんなベンチマークよりも説得力があります。

ヒント3:シェルなしでdistrolessコンテナをデバッグする

シェルがないため、最初はデバッグが難しく感じます。実際に使える方法が2つあります:

# distrolessはローカルトラブルシューティング用にbusyboxシェル付きの:debugタグを提供しています
FROM gcr.io/distroless/static-debian12:debug
# ローカルのみで使用 — 本番に:debugを出荷しないこと

# Kubernetesではエフェメラルコンテナを使用:
kubectl debug -it mypod --image=busybox --target=mycontainer

ヒント4:本番環境ではイメージダイジェストをピン留めする

:latest:nonrootのようなタグは気づかないうちに更新される可能性があります。再現性のあるビルドのためにダイジェストでピン留めしましょう:

# 現在のダイジェストを取得
docker inspect --format='{{index .RepoDigests 0}}' gcr.io/distroless/static-debian12:nonroot
# gcr.io/distroless/static-debian12@sha256:abcd1234...

# DockerfileでダイジェストをFROM句で使用
FROM gcr.io/distroless/static-debian12@sha256:abcd1234...

ヒント5:.dockerignoreを忘れずに

マルチステージビルドは肥大化したビルドコンテキストからあなたを守ってくれません。単純なCOPY . .は、node_modulesディレクトリ全体(数ギガバイトになることも)を、1つのレイヤーがビルドされる前にDockerデーモンに送り込みます。

node_modules/
.git/
*.log
dist/
.env
.env.*
tests/
docs/
*.md
.DS_Store

サイズ比較リファレンス

典型的なGo REST APIでの数値。同じバイナリ、異なるベースイメージ:

  • golang:1.22(シングルステージ):約850 MB、200件以上のCVE
  • golang:1.22-alpine(シングルステージ):約300 MB、15〜30件のCVE
  • マルチステージ + alpine最終イメージ:約12 MB、5〜10件のCVE
  • マルチステージ + distroless/static:約6〜10 MB、0〜2件のCVE

マルチステージビルドとdistrolessの組み合わせは微小な最適化ではありません。これが基準です。イメージが小さければKubernetesでのプル速度が上がり、コンテナレジストリのコストが下がり、攻撃者に与える余地もほぼなくなります。

まず上のGoの例から始めてみてください。自分のスタックに合わせてアレンジしましょう。最初のスキャンがクリーンな結果を返したとき、肥大化したイメージが許容できないものになります。

Share: