効率的なDockerfileの書き方:現場で学んだ実践的なテクニック

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

CI/CDパイプラインが遅いと感じたとき、私が最初に確認するのはDockerfileの書き方です。本来3分以内に終わるべきビルドが15分かかっていたり、30MB以下で済むはずのイメージが900MBになっていたりするケースを何度も見てきました。これは特殊なケースではありません。一度書いたDockerfileをそのまま放置してしまった結果です。

このガイドでは、本番環境で実際に見てきたアプローチ、うまくいくものといかないもの、そして現在私がDockerfileを書く際の方法について紹介します。

アプローチの比較:ナイーブなDockerfileと最適化されたDockerfile

多くの開発者は「とりあえず動けばいい」Dockerfileから始めます。例えば次のようなものです:

FROM ubuntu:latest
RUN apt-get update && apt-get install -y python3 pip
COPY . /app
WORKDIR /app
RUN pip install -r requirements.txt
CMD ["python3", "app.py"]

動きはします。ただし問題はすぐに積み重なります。イメージは600〜900MBに膨らみ、1行のコード変更でもすべての依存関係が再インストールされます。そして本番環境には使われないツールやファイルまで含まれてしまいます。

最適化されたアプローチは、いくつかの技術を組み合わせます:

  • 軽量なベースイメージ(Alpine、slimバリアント、またはdistroless)
  • レイヤーキャッシュの意識 — 変更頻度の低い命令から高い命令へと順序付け
  • マルチステージビルド — ビルド時の依存関係とランタイムの依存関係を分離
  • .dockerignore — ビルドコンテキストを軽量に保つ

各アプローチのメリット・デメリット

ナイーブなアプローチ

  • メリット:書くのが速く、理解しやすい
  • デメリット:イメージサイズが大きい(500MB以上になることが多い)
  • デメリット:再ビルドが遅い — コード変更のたびに依存関係がフルインストールされる
  • デメリット:ビルドツール、コンパイラ、開発用パッケージが本番イメージに含まれてしまう
  • デメリット:セキュリティ脆弱性の攻撃対象領域が広くなる

最適化されたアプローチ

  • メリット:50〜150MB程度の小さなイメージ(distrolessでは20MB以下になることも)
  • メリット:レイヤーキャッシュの効果的な活用により再ビルドが速い
  • メリット:クリーンな本番イメージ — ランタイムの依存関係のみ
  • デメリット:初期設定がやや複雑
  • デメリット:Alpineベースのイメージではglibcに依存したバイナリで問題が起きることがある

良いDockerfileを書くことは、DevOpsワークフローにおいて最も費用対効果の高い習慣の一つです。CIの実行時間短縮、レジストリコストの削減、本番環境での予期しないトラブルの減少 — すべて一つのファイルから生まれます。

推奨セットアップ

適切なベースイメージを選ぶ

特定の環境が必要でない限り、ubuntu:latestdebian:latestは避けましょう。私が使用する優先順位はこちらです:

  • python:3.12-slim — 公式のslimイメージ、互換性とサイズのバランスが良い
  • python:3.12-alpine — 最も小さいが、glibcが必要なパッケージに注意
  • gcr.io/distroless/python3 — シェルなし、パッケージマネージャーなし、攻撃対象領域が最小(セキュリティが重要な本番環境に最適)

GoやRustのようなコンパイル言語では、マルチステージビルドを使用することで、フルイメージでコンパイルし、最終バイナリのみをdistrolessまたはscratchベースにコピーできます。

レイヤーキャッシュ:順序が重要

Dockerは各レイヤーをキャッシュします。あるレイヤーが変更されると、それ以降のすべてのレイヤーがゼロから再ビルドされます。ルールは単純:変更頻度の低い命令を上に置く。

誤った順序(コード変更のたびにキャッシュが無効になる):

COPY . /app
RUN pip install -r requirements.txt

正しい順序(依存関係が個別にキャッシュされる):

COPY requirements.txt /app/
RUN pip install -r /app/requirements.txt
COPY . /app

これにより、pip installrequirements.txtが変更されたときのみ再実行されます — コードを編集するたびではなく。

マルチステージビルドを使う

マルチステージビルドは、コンパイル型やビルド処理の重いアプリケーションにおいて最大の効果をもたらします。実際のGoサービスの例を見てみましょう:

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

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

最終イメージにはコンパイル済みバイナリのみが含まれます — Goツールチェーン、ソースコード、パッケージマネージャーは一切含まれません。このパターンにより、あるサービスのイメージサイズが900MBから20MB未満になりました。

.dockerignoreを必ず使う

Dockerはデフォルトで、ビルドのたびにプロジェクトディレクトリ全体をデーモンに送信します。node_modulesを含むNode.jsプロジェクトでは、ビルドコンテキストが数KBから数百MBに跳ね上がることがあります。.dockerignoreを使えばすぐに解決できます。

最小限の.dockerignore

.git
.gitignore
*.md
__pycache__
*.pyc
.env
.env.*
tests/
docs/
node_modules/

実装ガイド

Pythonアプリケーション — 完全な例

# syntax=docker/dockerfile:1
FROM python:3.12-slim AS base

# 環境変数を設定
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PIP_NO_CACHE_DIR=1

WORKDIR /app

# 依存関係を先にインストール(キャッシュされるレイヤー)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# アプリケーションコードをコピー
COPY src/ ./src/

# 非rootユーザーで実行
RUN adduser --disabled-password --gecos "" appuser
USER appuser

EXPOSE 8000
CMD ["python", "-m", "src.main"]

Node.jsアプリケーション — マルチステージ

# ステージ1:インストール&ビルド
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production

# ステージ2:最小限のランタイム
FROM node:20-alpine
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY src/ ./src/

RUN addgroup -S app && adduser -S app -G app
USER app

EXPOSE 3000
CMD ["node", "src/index.js"]

避けるべきよくあるミス

  • latestタグを使わないpython:3.12.3-slimのように特定のバージョンを固定する。latestは再現性を損なわせ、CIで必ず問題を引き起こします。
  • rootで実行しないCMD/ENTRYPOINTの前に非rootユーザーを作成して切り替える。
  • apt-get updateapt-get installを別々のRUNコマンドに分けない — DockerはUpdateレイヤーを独立してキャッシュするため、古いパッケージリストが残りインストールが壊れることがあります:
# 悪い例
RUN apt-get update
RUN apt-get install -y curl

# 良い例
RUN apt-get update && apt-get install -y --no-install-recommends curl \
    && rm -rf /var/lib/apt/lists/*
  • Dockerfileにシークレットを保存しない — ビルド引数は機密でないデータのみに使用する。実際のシークレットはランタイム時に環境変数で渡すか、Docker secretsを使用する。
  • HEALTHCHECKを省略しない — これがないと、KubernetesやDocker Swarmなどのオーケストレーターはコンテナが起動しているのか実際にトラフィックを受け付ける準備ができているのかを判断できません。
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD curl -f http://localhost:8000/health || exit 1

イメージサイズを確認する

ビルド後、実際のサイズを確認しましょう:

docker build -t myapp:latest .
docker images myapp:latest
docker history myapp:latest

より詳細な内訳を確認するには、diveを使用します:

dive myapp:latest

diveはレイヤーごとのスペース使用量を分析します。よくある落とし穴:あるRUNステップでファイルを追加し、後のステップで削除しても、最終イメージではそのスペースを消費し続けます。diveはこれを明確に表示してくれます。

クイックリファレンスチェックリスト

  • slimまたはAlpineベースイメージを使用する
  • イメージのバージョンを正確に固定する
  • ソースコードの前に依存関係ファイルをコピーする
  • コンパイル型またはビルド処理の重いアプリにはマルチステージビルドを使用する
  • 非rootユーザーを作成して使用する
  • .dockerignoreファイルを追加する
  • 同じRUNレイヤーでパッケージマネージャーのキャッシュをクリアする
  • HEALTHCHECKを追加する
  • シークレットをイメージに焼き込まない

これらは抽象的なベストプラクティスではありません — 私が実際に遭遇した問題にそれぞれ対応しています。これを正しく実践すれば、コンテナのビルドが速くなり、ストレージコストが下がり、本番環境でのリスクが減ります。適切にセットアップするための30分は十分に価値があります。

Share: