Dockerイメージが1GBを超えていると、CI/CDのビルド時間・プルにかかるネットワーク転送量・レジストリのストレージコストが雪だるま式に膨れ上がります。本番環境でスケールアウトするたびに数百MBのイメージをダウンロードする状況は、デプロイ速度のボトルネックです。

Dockerイメージの軽量化とは、実行に必要なファイルだけを含む最小構成のイメージを設計することです。単にベースイメージをAlpineに差し替えるだけでなく、レイヤー構造の理解・ビルドキャッシュ戦略・セキュリティ対策を組み合わせることで、1GB超のイメージを100MB以下まで削減できます。

Dockerイメージが肥大化する3つの原因

ベースイメージの選択ミス

公式のpython:3.12node:20をそのまま使うと、OS全体のパッケージが含まれるためイメージサイズが800MB〜1GBになります。開発用ツール(gcc、make、git等)やマニュアルページも同梱されており、実行時に不要なファイルが大半を占めています。

レイヤーにゴミが残る「ゾンビレイヤー」問題

Dockerイメージはレイヤーの積み重ねで構成されています。あるレイヤーでファイルを追加し、次のレイヤーで削除しても、追加されたレイヤーの中にファイルデータは残り続けます。米国のセキュリティ企業GitGuardianはこれを「ゾンビレイヤー」と呼んでおり、イメージサイズが期待通りに減らない主要因です。

# 悪い例: 別レイヤーで削除してもサイズは減らない
RUN apt-get update && apt-get install -y build-essential
RUN make && make install
RUN apt-get purge -y build-essential && rm -rf /var/lib/apt/lists/*

上記の場合、build-essentialのデータは1つ目のレイヤーに残存します。インストールと削除は同じRUN命令内で実行する必要があります。

ビルドコンテキストの肥大化

docker buildはビルドコンテキスト(通常はカレントディレクトリ全体)をDockerデーモンに送信します。.gitディレクトリ、node_modules、テストデータなどが含まれると、ビルド開始時点で数百MBの転送が発生します。

テクニック1: ベースイメージを正しく選定する

ベースイメージの選択はイメージサイズに最も大きく影響します。同じNode.jsアプリでも、ベースイメージの違いだけで10倍以上の差が出ます。

ベースイメージサイズ目安特徴適したケース
node:20 (Debian)約1.1GBOS全体を含む、互換性が最も高い開発環境、デバッグ用途
node:20-slim約200MBDebianの最小構成、glibc搭載glibcが必要なアプリ
node:20-alpine約130MBmusl libc、BusyBox同梱依存関係が少ないアプリ
gcr.io/distroless/nodejs20約120MBシェルなし、OS不要部分を排除本番環境特化

Alpine選択時の注意点

Alpine Linuxはmusl libcを使用しているため、glibcに依存するネイティブモジュール(例: Python のnumpy、Node.jsのbcrypt、sharp等)で互換性問題が発生する場合があります。ビルドは通過しても実行時にSegmentation Faultが起きるケースもあるため、テストを十分に実施してください。

glibcが必要な場合は-slimバリアント(Debianベースの最小構成)が安全な選択肢です。

distrolessで最小構成を実現する

Googleが公開しているdistrolessイメージは、アプリケーションの実行に必要なランタイムだけを含む超軽量イメージです。シェル(bash/sh)すら含まれていないため、攻撃対象領域(アタックサーフェス)を最小化できます。

# Go アプリをdistrolessで動かす例
FROM golang:1.23 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /server .

FROM gcr.io/distroless/static-debian12
COPY --from=builder /server /server
CMD ["/server"]

distrolessにはデバッグ用の:debugタグも用意されています。トラブルシューティング時にはgcr.io/distroless/static-debian12:debugを使うとBusyBoxのシェルが利用できます。

米国のDevOpsCube社の検証では、Node.jsアプリでAlpineベースの171MBがdistrolessで118MBまで削減されたと報告されています。

テクニック2: マルチステージビルドでビルド環境と実行環境を分離する

マルチステージビルドは、ビルドに必要なツール(コンパイラ、パッケージマネージャ等)を最終イメージに含めない手法です。

Python アプリの例

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

# 実行ステージ: ランタイムのみ
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /root/.local /root/.local
COPY . .
ENV PATH=/root/.local/bin:$PATH
CMD ["python", "main.py"]

--userフラグでパッケージを/root/.localにインストールし、実行ステージでそのディレクトリだけをコピーしています。ビルドに使ったpipのキャッシュやヘッダファイルは最終イメージに含まれません。

Node.js + Nginx の例(フロントエンドアプリ)

FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production=false
COPY . .
RUN npm run build

FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

フロントエンドSPAの場合、ビルド成果物(HTML/CSS/JS)だけをNginxイメージにコピーすることで、Node.jsのランタイムごと除外できます。1GB超のビルド環境から20〜30MB程度のイメージに仕上がります。

テクニック3: RUN命令を統合してレイヤー数を削減する

Dockerfileの各RUN命令は新しいレイヤーを生成します。パッケージのインストールとキャッシュ削除を同じRUN内で行うことで、ゾンビレイヤーを防止できます。

# 良い例: 1つのRUNでインストールとクリーンアップを完結
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
        curl \
        ca-certificates && \
    rm -rf /var/lib/apt/lists/*

--no-install-recommendsフラグは推奨パッケージの自動インストールを抑止します。本番環境で不要なドキュメントやサジェストパッケージが除外され、数十MBの削減になることがあります。

BuildKit heredoc構文で可読性を維持する

Docker BuildKitでは、RUN命令にheredoc構文が使えます。&&\の連結で可読性が下がる問題を解消できます。

# syntax=docker/dockerfile:1
RUN <<EOF
apt-get update
apt-get install -y --no-install-recommends curl ca-certificates
rm -rf /var/lib/apt/lists/*
EOF

heredoc構文では各行が独立して書けるため、長いインストール手順でもメンテナンスしやすくなります。

テクニック4: .dockerignoreでビルドコンテキストを最小化する

.dockerignoreファイルをプロジェクトルートに配置し、ビルドに不要なファイルを除外します。

.git
.gitignore
node_modules
dist
*.md
.env
.env.*
docker-compose*.yml
Dockerfile*
.vscode
.idea
__pycache__
*.pyc
.pytest_cache
coverage
.nyc_output

特に.gitディレクトリはリポジトリの規模によっては数百MBに達します。.envファイルの除外はセキュリティ上も重要です。

テクニック5: ビルドキャッシュを最大限に活用する

Dockerは各命令の結果をキャッシュし、変更がない命令はキャッシュから再利用します。変更頻度が低いものを先に、高いものを後に記述することがキャッシュヒット率を高めるコツです。

# 良い例: 依存定義ファイルを先にコピー
COPY package.json package-lock.json ./
RUN npm ci
# ソースコードは後(変更頻度が高い)
COPY . .
RUN npm run build
# 悪い例: ソースコード変更のたびにnpm ciが再実行される
COPY . .
RUN npm ci
RUN npm run build

キャッシュの仕組みとして、COPY命令はファイル内容のチェックサムで変更を判定し、RUN命令はコマンド文字列の一致で判定します。package.jsonが変更されていなければnpm ciはキャッシュから取得されるため、ビルド時間が大幅に短縮されます。

BuildKitのマウントキャッシュでさらに高速化する

BuildKitの--mount=type=cacheを使うと、パッケージマネージャのキャッシュをビルド間で永続化できます。

# syntax=docker/dockerfile:1
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

pipnpmのダウンロードキャッシュがビルドごとに消えず、再ダウンロードを回避できます。CI/CDパイプラインでの効果が特に大きく、ビルド時間を数十秒単位で削減可能です。

テクニック6: 言語別の軽量化パターン

Python

構成イメージサイズ目安
python:3.12 そのまま約1.0GB
python:3.12-slim + マルチステージ約150〜200MB
python:3.12-slim + venv分離約120〜180MB

Pythonで注意すべきなのは、Alpineベースではネイティブ拡張のビルドに時間がかかることです。numpyやpandasなどの科学計算ライブラリは、Alpine上ではWheelバイナリが提供されず、ソースからのコンパイルが必要になります。ビルド時間が数十分に膨れ上がることもあるため、Pythonの場合は-slim(Debianベース)が推奨です。

Node.js

FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --production

FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
CMD ["node", "dist/index.js"]

npm prune --productionで開発用依存関係を削除し、node_modulesのサイズを縮小しています。

Go

Goは静的リンクバイナリを生成できるため、最終ステージをscratch(空イメージ)にできます。

FROM golang:1.23 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server .

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

-ldflags="-s -w"でデバッグ情報とDWARF情報を除去し、バイナリサイズを20〜30%削減しています。scratchイメージは0Bのため、最終イメージはバイナリ+証明書のサイズだけ(通常10〜20MB程度)になります。

Rust

Rustもシングルバイナリを生成できるため、Goと同様にscratchdistrolessと相性が良い言語です。

FROM rust:1.84-slim AS builder
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs && cargo build --release && rm -rf src
COPY src ./src
RUN cargo build --release

FROM gcr.io/distroless/cc-debian12
COPY --from=builder /app/target/release/myapp /myapp
CMD ["/myapp"]

ビルドステージでは先にダミーのmain.rsで依存クレートだけをビルドし、キャッシュを活用しています。ソースコード変更時も依存クレートの再ビルドがスキップされ、ビルド時間を短縮できます。

テクニック7: Diveでイメージの中身を可視化する

Diveは、Dockerイメージの各レイヤーに含まれるファイルを可視化するOSSツールです。

# インストール(macOS)
brew install dive

# イメージを分析
dive myapp:latest

Diveを実行すると、レイヤーごとのサイズ・追加ファイル・削除ファイルがインタラクティブに表示されます。「どのレイヤーが大きいのか」「不要なファイルが含まれていないか」を視覚的に確認でき、軽量化の方針を立てやすくなります。

CI/CDパイプラインに組み込む場合は、CIモードが用意されています。

# CI/CDでの利用: イメージ効率が基準を下回ると失敗させる
CI=true dive myapp:latest --lowestEfficiency 0.95

効率値(0〜1.0)が指定した閾値を下回るとコマンドが非ゼロで終了するため、品質ゲートとして使えます。

テクニック8: セキュリティスキャンと軽量化を同時に進める

イメージの軽量化はセキュリティ強化と表裏一体です。不要なパッケージを削減すれば、既知の脆弱性(CVE)を含むコンポーネントの数も減ります。

Trivyで脆弱性をスキャンする

# Trivyでイメージをスキャン
trivy image myapp:latest

TrivyはAqua Security社が開発するOSSの脆弱性スキャナーです。OSパッケージとアプリケーション依存関係の両方を検査し、CVE情報を一覧表示します。

非rootユーザーでコンテナを実行する

FROM node:20-alpine
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules
USER appuser
CMD ["node", "dist/index.js"]

USER命令でコンテナ内の実行ユーザーをroot以外に切り替えます。コンテナエスケープ攻撃を受けた場合のリスクを低減できます。distrolessイメージにはデフォルトで非rootユーザー(nonroot、UID 65532)が設定されています。

軽量化の効果をまとめた比較表

以下は、1つのNode.jsアプリに対して各テクニックを段階的に適用した場合のサイズ推移です。

node:20                       → 約 1.1GB
node:20-slim                  → 約 200MB  (▲82%減)
+ マルチステージビルド           → 約 170MB  (▲85%減)
+ .dockerignore + RUN統合       → 約 150MB  (▲86%減)
+ node:20-alpine + prune       → 約  80MB  (▲93%減)
+ distroless/nodejs20          → 約  60MB  (▲95%減)

同様に、Pythonアプリの場合は以下のような推移になります。

python:3.12                   → 約 1.0GB
python:3.12-slim + multi-stage → 約 180MB  (▲82%減)
+ --no-cache-dir + RUN統合     → 約 150MB  (▲85%減)

CI/CDパイプラインでの実践ポイント

GitHub ActionsでBuildKitキャッシュを有効化する

- name: Build and push
  uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: myapp:latest
    cache-from: type=gha
    cache-to: type=gha,mode=max

type=ghaでGitHub Actionsのキャッシュにビルドレイヤーを保存します。2回目以降のビルドでは変更のないレイヤーがキャッシュから取得され、ビルド時間が大幅に短縮されます。

イメージサイズの監視を自動化する

- name: Check image size
  run: |
    SIZE=$(docker image inspect myapp:latest --format='{{.Size}}')
    MAX_SIZE=209715200  # 200MB
    if [ "$SIZE" -gt "$MAX_SIZE" ]; then
      echo "Image size ${SIZE} exceeds limit ${MAX_SIZE}"
      exit 1
    fi

CIでイメージサイズの上限チェックを行うことで、意図しない肥大化をプルリクエストの時点で検知できます。

軽量化で陥りやすい落とし穴

Alpine + Pythonネイティブ拡張のビルド時間爆発

Alpine上でnumpy・pandas・scikit-learn等をインストールすると、Wheelバイナリ(事前コンパイル済みパッケージ)が提供されていないため、ソースコンパイルが走ります。ビルド時間が30分以上に膨れ上がることがあり、CI/CDのコスト増に直結します。Pythonで科学計算ライブラリを使う場合は-slimバリアントを選んでください。

distrolessでデバッグできない

distrolessイメージにはシェルが含まれないため、docker exec -it container shでコンテナに入れません。本番環境ではセキュリティ上のメリットですが、開発・ステージング環境ではデバッグの妨げになります。開発用とプロダクション用で異なるDockerfileやビルドターゲットを使い分ける運用を検討してください。

COPY –linkの活用

Docker BuildKitではCOPY --linkフラグが使えます。通常のCOPYは前のレイヤーに依存しますが、--linkを付けるとレイヤーの独立性が高まり、キャッシュの再利用効率が向上します。

COPY --link --from=builder /app/dist /app/dist

前のレイヤーが変更されても、コピー元のファイルが同じならキャッシュが使われます。マルチステージビルドと組み合わせると特に効果的です。

まとめ

Dockerイメージの軽量化は、ベースイメージの選定・マルチステージビルド・レイヤー最適化・ビルドキャッシュ戦略という4つの軸で進めるのが効率的です。加えて、Diveによるレイヤー分析・Trivyによる脆弱性スキャンを組み合わせることで、サイズとセキュリティの両面で最適なイメージを維持できます。

1GBを超えるイメージでも、本記事で紹介した8つのテクニックを適用すれば100MB以下への削減が現実的な目標になります。まずはdocker image lsで現在のイメージサイズを確認し、ベースイメージの見直しとマルチステージビルドの導入から着手してみてください。