Docker本番運用3年で気づいた、ネットに載ってない地雷と対策
本番環境でDockerを3年運用してわかった、イメージサイズ1.2GB→280MBへの削減法、マルチステージビルド、セキュリティ対策まで。実装コード付きで解説します。
Docker本番運用3年で気づいた、ネットに載ってない地雷と対策
先日チームミーティングでうちのシステムのイメージサイズを集計したら、1.2GBあったんですよ。本番環境で毎回のデプロイが5分かかってるのはこれか…って気づいた瞬間、3年間の失敗が一気に蘇りました。
Docker使い始めたときは「コンテナ化したら終わり」くらいの気持ちでいたんですが、実際に本番で回し始めると、ネットのチュートリアルには載ってない地雷がいっぱい出てくるんですよね。イメージサイズ、マルチステージビルド、セキュリティスキャン…正直、最初は何も考えてなかった。
今日は、3年間の失敗と試行錯誤から見えた、本当に効く対策を書きます。教科書的じゃなく、実装で困ったこと、チームで導入したら劇的に変わったことを中心に。
イメージサイズが1.2GBだった話──最初の大きな失敗
当時、僕たちのアプリケーション用Dockerfileはこんな感じでした。
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y \
python3 \
python3-pip \
git \
curl \
wget \
build-essential
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["python3", "app.py"]
これで本番環境にデプロイしてたんですが、ECRに上げるたびに数分待たされる。デプロイは5分。ローカルのビルドも3分。チーム全体で毎日どれだけの時間をムダにしてるんだろう、って気づいたのは半年後でした。
正直、当時は「Dockerイメージのサイズなんて気にしなくていい」って思ってたんです。でもいざ本番で回し始めたら、ECRのストレージ、ECSのタスク起動時間、ネットワーク転送…いろんなところで効いてくるんですよね。
マルチステージビルドで1.2GB→280MBに
そこからマルチステージビルドに切り替えました。
# ビルドステージ
FROM python:3.11-slim as builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
# 実行ステージ
FROM python:3.11-slim
# 必要なパッケージだけをインストール
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
# ビルドステージから必要なものだけコピー
COPY --from=builder /root/.local /root/.local
COPY . .
ENV PATH=/root/.local/bin:$PATH
CMD ["python3", "app.py"]
これで280MBまで落ちました。約77%削減です。マジで変わります。特に注目してほしいのは:
- ベースイメージをubuntuじゃなくslimに:ubuntuは1.3GB、python:3.11-slimは150MB。これだけで90%削減
- —no-install-recommends:apt-getの推奨パッケージを入れない
- rm -rf /var/lib/apt/lists/*:パッケージ管理ファイルを削除
- ビルドステージで不要物を分離:node_modulesとか開発時の中間ファイルが本番に入らない
本番環境のデプロイ時間が5分から1.2分になりました。個人的にはこれが3年間で一番効いた改善ですね。
サイズ削減の可視化
| 項目 | サイズ | 削減率 |
|---|---|---|
| ubuntu:22.04ベース | 1.2GB | 基準 |
| slimベース導入 | 650MB | 46% |
| マルチステージ適用 | 280MB | 77% |
BuildKit 2026年版で変わったこと
うちのチームは2024年までDocker CLIのデフォルトビルダーを使ってたんですが、去年BuildKit Builderに切り替えて、今は心底よかったなって思ってます。
レイヤーキャッシング最適化
2026年のBuildKitは依存関係をより正確に判定できるようになってて、キャッシュヒット率が大幅に改善しました。
# docker-compose.yml
services:
app:
build:
context: .
dockerfile: Dockerfile
cache_from:
- type=registry,ref=my-registry/app:buildcache
cache_to:
- type=registry,ref=my-registry/app:buildcache,mode=max
この設定をしてから、GitHubActionsでのビルド時間が平均3.5分→1.1分になりました。キャッシュ効率って本当に大事。
セキュリティスキャンの統合
2026年のBuildKitは--sbomフラグでSBOM(Software Bill of Materials)を自動生成できるようになってます。
docker buildx build --sbom=true --sbom-dir=./sbom .
これで本番デプロイ前に脆弱性チェックを自動化できるようになった。以前は別途Trivyを走らせてたんですが、ビルド工程に統合されたおかげでパイプラインがシンプルになりました。
セキュリティが本番で火を噴いた話
去年の春、本番環境のECRスキャンで1000件以上のCVEが検出されたんです。絶望でした。
当時のDockerfileはこんな感じ:
FROM python:3.11
RUN pip install flask==2.0.1 requests==2.25.1 # 古いバージョン
pip installのバージョンを固定してたんですが、2年前のバージョンがそのまま入ってたんですね。脆弱性情報も溜まってた。
対策:ベースイメージの定期更新
2026年の我々のアプローチは:
- Dependabot統合:ベースイメージの更新をGitHubで自動検出
- Renovate設定:pip/npm依存関係を週1回自動更新のPR
- セキュリティスキャンのCI統合
# .github/workflows/security.yml
name: Container Security
on:
push:
branches: [main]
schedule:
- cron: '0 3 * * 0' # 週1回
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: my-registry/app:${{ github.sha }}
format: 'sarif'
output: 'trivy-results.sarif'
- name: Upload Trivy results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v2
with:
sarif_file: 'trivy-results.sarif'
これで本番環境のCVE件数を月1回のペースで自動チェック。去年の1000件から今は30件程度に落ち着きました。ただ、正直ゼロにはならないんですよね。完全に消すのか、許容リスクとするのか、その判断がチーム全体で必要です。
マルチアーキテクチャ対応──ARM64の地雷
今年の初め、M1 MacBook Proで開発してる人のローカルイメージと、本番のLinux/AMD64で動作が違う問題が出ました。Apple Silicon用にビルドしたイメージをそのまま本番に上げてたんです。恥ずかしい。
buildx使ったマルチプラットフォーム対応
# Dockerfile.multiarch
FROM --platform=$BUILDPLATFORM python:3.11 as builder
ARG TARGETARCH
ARG TARGETPLATFORM
WORKDIR /app
COPY requirements.txt .
# ビルド時に異なるアーキテクチャ向けに最適化
RUN if [ "$TARGETARCH" = "arm64" ]; then \
pip install --target=/app/lib --platform manylinux2014_aarch64 -r requirements.txt; \
else \
pip install --target=/app/lib -r requirements.txt; \
fi
FROM python:3.11-slim
COPY --from=builder /app/lib /usr/local/lib/python3.11/site-packages
COPY . .
CMD ["python3", "app.py"]
ビルドコマンド:
docker buildx build \
--platform linux/amd64,linux/arm64 \
--tag my-registry/app:latest \
--push .
これでARM64とAMD64の両方をビルド・プッシュできます。ローカル開発でM1使ってる人にも、本番のAMD64にも対応。
正直マルチアーキテクチャ対応は2026年ではほぼ必須ですね。フェイルセーフとしてちゃんと動く価値は大きい。
ローカル開発とCI/CDの一貫性を保つ工夫
以前は「ローカルではDockerで動くのに、本番で動かない」という悪夢がありました。こういう時間の無駄、ほんと多いんですよ。
docker-compose.devと本番環境の分離
# docker-compose.dev.yml - ローカル開発用
services:
app:
build:
context: .
dockerfile: Dockerfile.dev
volumes:
- .:/app
environment:
- DEBUG=1
- LOG_LEVEL=DEBUG
ports:
- "8000:8000"
# docker-compose.prod.yml - 本番に近い環境
services:
app:
image: my-registry/app:${IMAGE_TAG}
environment:
- DEBUG=0
- LOG_LEVEL=INFO
ports:
- "8000:8000"
使い分けはこんな感じ:
# ローカル開発
docker-compose -f docker-compose.dev.yml up
# 本番プレビュー(CI後の動作確認)
docker-compose -f docker-compose.prod.yml up
これでローカルと本番の環境差を最小限に抑えられます。個人的には「git pushする前にdocker-compose.prodで確認」を習慣化するのが大事だと感じてます。
コンテナイメージのバージョン管理と運用
今のうちのチームは、イメージタグを複数付けるルールにしてます:
my-registry/app:latest # 最新の本番イメージ
my-registry/app:v1.2.3 # セマンティック版番号
my-registry/app:git-abc123 # コミットハッシュ
my-registry/app:2024-08-07 # 日付ベース
なぜこんなに複数付けるかというと、運用上必要な場面が違うから。latestは緊急ロールバック時、v1.2.3はバージョン管理・ドキュメント、git-abc123はデバッグ時のコミット特定、2024-08-07はリリース日管理…って感じで、各タグが活躍する場面があるんですよね。
GitHubActionsパイプラインでは全部自動で付けてます。
- name: Build and push
run: |
IMAGE_TAG=${GITHUB_REF#refs/tags/}
COMMIT_SHA=$(echo ${{ github.sha }} | cut -c1-7)
docker buildx build \
--tag my-registry/app:latest \
--tag my-registry/app:${IMAGE_TAG} \
--tag my-registry/app:git-${COMMIT_SHA} \
--tag my-registry/app:$(date +%Y-%m-%d) \
--push .
これで本番障害の時に「あのバージョンだったら動いた」という逆引きが可能になります。
Dockerレイヤーキャッシングの落とし穴
レイヤーキャッシュって非常に便利なんですが、実装を誤ると思わぬバグの温床になります。
悪い例
FROM python:3.11-slim
COPY . /app # ← アプリコード全体をコピー
WORKDIR /app
RUN pip install -r requirements.txt # ← コードが変わるたびに依存関係をインストールし直す
CMD ["python3", "app.py"]
問題は、アプリコードをコピーする際にキャッシュが無効化されるため、毎回pip installが走っちゃうんですよね。
良い例
FROM python:3.11-slim
WORKDIR /app
# requirements.txtだけ先にコピー
COPY requirements.txt .
RUN pip install -r requirements.txt
# その後にアプリコードをコピー
COPY . .
CMD ["python3", "app.py"]
この順序が重要。依存関係はあまり変わらないけど、アプリコードは毎日変わるので、requirements.txtを先にコピーすることで、コード変更時はキャッシュを再利用できます。
僕たちの場合、これを守るだけでCIパイプラインの時間が10分短縮されました。地味だけど、積み重ねると本当に効く改善なんです。
本番運用3年で気づいた、セキュリティとのバランス
コンテナセキュリティって「完璧さ」を求めると沼にはまります。うちのチームが学んだのは「リスク許容度を決める」ということ。ECRのスキャンで見つかった30件のCVEのうち、実際の対応優先度は以下のように分類できます:
| 重大度 | 件数 | 対応期限 | 方針 |
|---|---|---|---|
| CRITICAL | 0件 | 即時 | 発見次第デプロイ停止 |
| HIGH | 2件 | 30日以内 | 最優先で対応 |
| MEDIUM | 10件 | 90日以内 | 次の定期更新で対応 |
| LOW | 18件 | 定期監視 | 月1回見直し |
こんな感じで優先度を決めて、本当に必要な対応だけに集中する。そっちのほうが現実的です。
# trivy-config.yaml
scanning:
severity: ["HIGH", "CRITICAL"]
ignore-unfixed: false
skip-db-update: false
完璧を目指そうとするとチーム全体が疲弊するんですよね。バランスが大事。
まとめ
3年間Dockerと向き合ってわかったのは、本当に大事なのは「ベストプラクティス」を知ることじゃなく、自分たちのチームと本番環境に合わせてカスタマイズすることなんですよね。
- イメージサイズ最適化:マルチステージビルド + 必要なパッケージだけ。1.2GB→280MBは体験済み
- BuildKit 2026年版:キャッシング効率とセキュリティスキャンが統合。デプロイ時間が体感で3倍速くなる
- セキュリティとのバランス:完璧を目指さず、リスク許容度を決める。月1回の自動チェックで十分
- マルチアーキテクチャ対応:M1・ARM64がもはや標準。buildxで両方対応が当たり前
- ローカル開発と本番の一貫性:docker-compose分離 + 事前確認が救世主
正直、最初の失敗がなければ今のレベルにはいなかったと思います。「ローカルでは動く」を何度も経験したからこそ、現在の堅牢な仕組みがある。地雷を踏むのは誰にでもあること。大事なのは、そこからどう改善するかなんです。
皆さんはうちのチームみたいな失敗してますか?もしイメージサイズやキャッシュで困ってたら、一度チェックしてみることをお勧めします。本当に効きます。
次はコンテナセキュリティ完全ガイド2026|eBPF・SBOM・サプライチェーン対策も読んでみてください。eBPFでのランタイムセキュリティ検証の話とか、本番で役立つ知見が詰まってます。