Dockerイメージサイズ削減ガイド:高速ビルドへの実践的アプローチ

マルチステージビルド、キャッシュ効率の良いDockerfile、クリーンなビルドコンテキストでDockerイメージを小さくし、ビルドを高速化します。

Dockerイメージサイズ削減ガイド:高速ビルドへの実践的アプローチ

Dockerイメージサイズの削減は、docker image lsで最小の数値を追い求めることではありません。本当の目標は、高速で再現性があり、修正が容易で、CIやデプロイ、ローカル開発を遅くしない程度に小さなビルドを実現することです。

肥大化したイメージは、いくつかの一般的な習慣から生じます:リポジトリ全体を早い段階でコピーする、ランタイムイメージにビルドツールをインストールする、パッケージマネージャーのキャッシュを残す、latestタグを使用する、巨大なビルドコンテキストをDockerに送信する。これらの習慣を修正すれば、ほとんどのチームは特別なことをしなくてもクリーンなビルドを実現できます。

まずは現在のイメージを測定することから始めましょう:

docker image ls my-app
docker history --no-trunc my-app:latest

docker historyは、どのDockerfile命令がサイズを増加させたかを示します。1つのRUNステップが大量のサイズを追加している場合、他のすべてを変更する前にそのステップを調査してください。より詳細な分析にはdiveのようなツールがあり、各レイヤーで追加・削除されたファイルを表示できますが、最初のパスでは組み込みコマンドで十分です。

互換性を優先し、サイズは二の次でベースイメージを選ぶ

ベースイメージの選択は重要ですが、最小のイメージが常に最良とは限りません。Alpineはmusl libcと最小限のユーザースペースを使用するため小さいです。これは多くのGo、Node、Python、ユーティリティイメージでうまく機能しますが、ネイティブ拡張、プリビルドバイナリ、DNS動作、glibcを期待するパッケージとの摩擦を生む可能性があります。

実用的な優先順位は以下の通りです:

  1. ランタイム用の公式イメージを使用する。
  2. python:latestよりもpython:3.12-slimのようなバージョンタグを優先する。
  3. アプリにネイティブ依存関係がある場合、Alpineに飛びつく前にslimを試す。
  4. ランタイムに必要なものを正確に把握している場合は、distrolessやscratchを使用する。

例えば、psycopg、画像ライブラリ、暗号化パッケージを使用するPythonサービスは、python:3.12-alpineよりもpython:3.12-slimで管理しやすい場合があります。Alpineイメージは最初は小さくても、依存関係をビルドするためにコンパイラや互換性パッケージを追加すると、最終結果は単純でも小さくもならないかもしれません。

Goサービスの場合、scratchやdistrolessは優れた選択肢です。なぜなら、静的にコンパイルされたバイナリは実行時にほとんど必要としないからです。ただし、TLS証明書、タイムゾーンデータ、非rootユーザーを忘れないでください。HTTPSリクエストができない小さなイメージは、実用的な本番イメージではありません。

マルチステージビルドを使用してビルドツールを本番環境から分離する

マルチステージビルドは、コンパイルされたアプリやフロントエンドビルドのDockerイメージサイズを削減する最も信頼性の高い方法です。ビルダーステージは大きくても構いません。コンパイラ、パッケージマネージャー、テストツール、ソースファイルを含むことができます。最終ステージには、アプリの実行に必要なものだけを含めるべきです。

Goの例:

FROM golang:1.22 AS builder
WORKDIR /src

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

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/server ./cmd/server

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

重要なのは正確なベースイメージではありません。重要なのは境界です:ソースコード、モジュールキャッシュ、コンパイラはbuilderに残り、最終イメージにはバイナリのみが含まれます。

Nodeアプリも同様の形状ですが、開発依存関係と本番依存関係を分離する必要があります:

FROM node:22-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM deps AS build
COPY . .
RUN npm run build

FROM node:22-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]

nginxで提供されるフロントエンドアセットの場合、最終イメージにNodeはまったく必要ありません:

FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

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

このパターンは、node_modules、ソースツリー、ビルドツールをランタイムイメージから削除します。

Dockerキャッシュを活用する

Dockerキャッシュの無効化は単純ですが容赦ありません。命令が変更されると、Dockerはそのレイヤーとそれ以降のすべてのレイヤーを再ビルドします。依存関係をインストールする前にプロジェクト全体をコピーすると、コード編集のたびに依存関係の再インストールが強制される可能性があります。

これは遅いです:

FROM python:3.12-slim
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD ["python", "app.py"]

これは通常高速です:

FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]

依存関係レイヤーはrequirements.txtが変更されたときのみ再ビルドされます。ソースの変更は後のCOPY . .レイヤーのみを再ビルドします。

同じルールがMaven、Gradle、npm、pnpm、Cargo、Goモジュールにも適用されます。最初に依存関係マニフェストをコピーし、依存関係をインストールまたはダウンロードしてから、残りのソースをコピーします。

.dockerignoreでビルドコンテキストを小さく保つ

Dockerはビルドを開始する前にビルドコンテキストをデーモンに送信します。コンテキストに.git、ローカルのnode_modules、カバレッジレポート、動画、スクリーンショット、テストデータベース、ビルドアーティファクトが含まれている場合、Dockerfileが実行される前からビルドが遅くなります。

基本的な.dockerignoreは次のようになります:

.git
.gitignore
.env
.env.*
node_modules
coverage
dist
build
target
.pytest_cache
__pycache__
*.log
.DS_Store

広範な無視には注意してください。distを無視してもDockerfileがプリビルドされたdistをコピーすることを期待している場合、ビルドは失敗します。.envを無視するのは通常シークレットには良いですが、ビルドがそれに依存すべきではありません。ビルドは明示的なビルド引数やCI設定を通じて必要な値を受け取るべきです。

コンテキストサイズは、従来のDockerビルド出力の開始時に確認でき、BuildKit出力でも大きなコンテキスト転送が可視化されます。最初のステップがDockerfile命令の実行前に長時間かかる場合は、.dockerignoreを確認してください。

同じレイヤーでパッケージマネージャーのキャッシュをクリーンアップする

よくある間違いは、あるレイヤーでパッケージをインストールし、後のレイヤーでキャッシュを削除することです。ファイルは最終的なファイルシステムビューからは消えますが、イメージ履歴には残っている可能性があります。

DebianおよびUbuntuイメージの場合:

RUN apt-get update &&     apt-get install -y --no-install-recommends ca-certificates curl &&     rm -rf /var/lib/apt/lists/*

Alpineの場合:

RUN apk add --no-cache ca-certificates curl

Pythonの場合:

RUN pip install --no-cache-dir -r requirements.txt

npmの場合:

RUN npm ci --omit=dev && npm cache clean --force

クリーンアップコマンドを盲目的にコピーしないでください。パッケージマネージャーとベースイメージに一致するクリーンアップを使用してください。また、ほとんどのアプリケーションDockerfileではapt-get upgradeを避けてください。ビルドの予測可能性が低下し、意図した以上の変更が発生する可能性があります。メンテナンスされたベースイメージから定期的に再ビルドし、期待するイメージファミリーを固定することでパッチを適用します。

必要な場合を除きADDよりもCOPYを優先する

COPYはビルドコンテキストからファイルをコピーします。ADDには追加の動作があります:ローカルのtarアーカイブを展開したり、場合によってはリモートURLを取得したりできます。この追加動作はメンテナーを驚かせ、キャッシュ動作の理解を難しくする可能性があります。

通常のアプリケーションファイルにはCOPYを使用します:

COPY ./src ./src

アーカイブ展開動作を意図的に利用したい場合のみADDを使用してください。リモートダウンロードの場合は、RUNステップでcurlwgetを使用し、チェックサムを検証して明確に失敗できるようにすることをお勧めします。

削除するだけでなく、インストールするものを減らす

最良のキャッシュクリーンアップは、最初から不要なファイルをインストールしないことです。apt-getでは--no-install-recommendsを使用してください。理由がない限り、エディタ、シェル、パッケージマネージャー、デバッグツールを本番イメージにインストールしないでください。

ただし、運用が困難になる場合はすべてのツールを削除しないでください。本番イメージは最小限でありながら観測可能であることが可能です。一部のチームでは、オンコールデバッグが簡単になるため、シェル付きのslimイメージがdistrolessイメージよりも良いトレードオフです。ログ、トレーシング、一時的なデバッグコンテナが充実している他のチームでは、distrolessが適しています。

正直な答えはワークロードに依存します。小さなイメージは通常プルが速く、パッチ適用が必要なパッケージが少なくなりますが、保守性も重要です。

依存関係ダウンロードにBuildKitキャッシュマウントを使用する

BuildKitはパッケージキャッシュを最終イメージの外に保持し、ビルド間で再利用できます。これはキャッシュをイメージ内に残すのとは異なります。

npmの場合:

# syntax=docker/dockerfile:1.7
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .

aptの場合、キャッシュマウントは同じビルダーで繰り返しビルドが行われるCI環境で役立ちますが、多くのCIシステムは永続キャッシュを設定しない限り新しいワーカーを使用します。BuildKit機能は強力ですが、保守するチームにとってDockerfileが読みやすい状態を保ってください。

最終イメージの内容を確認する

変更後、改善を証明します:

docker build -t my-app:optimized .
docker image ls my-app
docker history my-app:optimized

その後、本番と同じ方法でイメージを実行します。証明書、ロケールデータ、タイムゾーンファイル、書き込み可能なディレクトリがないために失敗する小さなイメージは改善ではありません。

有用なスモークテストには以下が含まれます:

docker run --rm my-app:optimized --version
docker run --rm -p 8080:8080 my-app:optimized
curl -f http://localhost:8080/health

アプリがファイルを書き込む場合は、そのパスをテストします。HTTPSサービスを呼び出す場合はTLSをテストします。非rootで実行する場合は権限をテストします。

実用的な最適化の順序

過剰に大きなDockerイメージをレビューする際、私は以下の順序を使用します:

  1. .dockerignoreを追加または修正する。
  2. 依存関係のインストールがキャッシュ可能になるようにDockerfileを並べ替える。
  3. マルチステージビルドを導入する。
  4. フルベースイメージから互換性のあるslim、Alpine、distroless、scratchに切り替える。
  5. パッケージマネージャーのキャッシュと開発依存関係を、それらが作成されたのと同じレイヤーで削除する。
  6. docker historydiveで検査し、証拠がある場合のみ繰り返す。

この順序は時期尚早な巧妙さを避けます。また、Dockerfileを理解しやすく保ちます。これは最後の数メガバイトを削り出すことよりも重要です。