「ローカルでは動いた」が消えた日|Docker Compose V2 + BuildKitをチームで1年使って分かったこと

「自分のMacだけビルドが通らない」に悩んだことありませんか?チーム6人・マイクロサービス構成で1年運用した実体験から、キャッシュ戦略・secrets管理・ハマりポイントを正直に書きました。

「ローカルでは動いた」が消えた日から1年

去年の春、チームの新メンバーが「自分のMacでだけビルドが通らない」と言ってきた。調べてみたら原因はDockerfileのキャッシュ挙動の差異で、ローカルとCI環境でレイヤー順序の解釈が微妙に異なっていた。「またこれか……」というため息が出たのを今でも覚えている。

そこから本格的にDocker Compose V2とBuildKitの設定を見直し始めて約1年。チーム6人でマイクロサービス構成を運用する中で、「これは地味に効いた」という知見がかなり溜まってきた。今回はその実体験をそのまま書いていく。教科書的な話は最小限にして、実際にハマったポイントと「こう設定したら改善した」という話を中心にしたい。

なお、コンテナセキュリティ全般についてはすでに別の記事でまとめているので(コンテナセキュリティ完全ガイド2026|eBPF・SBOM・サプライチェーン対策)、ここでは純粋に開発効率と運用品質に絞る。


Docker Compose V2 + BuildKit 2026年時点の「現在地」

2026年現在、Docker Engine 28.x系がリリースされており、BuildKit 0.19以降がデフォルトのビルドバックエンドとして統合されている。Compose V2(docker composeコマンド)もDocker Desktop 4.x以降では完全にV1のdocker-composeを置き換えている。

一応確認しておくと、バージョン状況はこんな感じだ。

コンポーネント2024年初頭2026年6月現在主な変化
Docker Engine25.x28.xcontainerd image store がデフォルト
BuildKit0.120.19cache mount の安定化、attestation 強化
Compose V22.242.32include ディレクティブの安定化
Docker Desktop4.274.40Resource Saver モード搭載

BuildKitが「実験的機能」から「完全に普通のもの」になったのがここ2年の大きな変化で、今更DOCKER_BUILDKIT=1を環境変数で指定しているDockerfileやCIスクリプトを見かけると「古いな」と正直思う。


マルチステージビルド + cache mount で実現したビルド時間改善

正直、最初はキャッシュマウントの効果に懐疑的だった。「どうせCIではキャッシュが効かないでしょ」という先入観があったからだ。でも実際に設定してみると、予想以上に変わった。

うちのチームのPythonサービスのDockerfileを例に書くと、変更前後でこう変わった。

変更前(典型的なよくないパターン)

FROM python:3.13-slim as builder
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
RUN python -m pytest

FROM python:3.13-slim
WORKDIR /app
COPY --from=builder /app .
CMD ["uvicorn", "main:app"]

変更後(BuildKit cache mount + マルチステージ最適化)

# syntax=docker/dockerfile:1.7
FROM python:3.13-slim AS base
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
    build-essential \
    && rm -rf /var/lib/apt/lists/*

FROM base AS deps
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install --user -r requirements.txt

FROM base AS test
COPY --from=deps /root/.local /root/.local
COPY . .
ENV PATH=/root/.local/bin:$PATH
RUN --mount=type=cache,target=/root/.cache/pip \
    --mount=type=cache,target=/tmp/pytest_cache \
    python -m pytest --tb=short -q

FROM python:3.13-slim AS runtime
WORKDIR /app
COPY --from=deps /root/.local /root/.local
COPY --from=test /app/src ./src
COPY --from=test /app/main.py .
ENV PATH=/root/.local/bin:$PATH
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

ポイントは--mount=type=cacheでpipのダウンロードキャッシュをビルド間で共有している点だ。requirements.txtが変わらない限り、2回目以降のビルドではパッケージのダウンロードがほぼゼロになる。

CIでもこのキャッシュを活かすために、GitHub ActionsではBuildKit remote cacheを使ってECRにキャッシュを保存するように設定した。

# .github/workflows/build.yml(抜粋)
- name: Build and push
  uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPO }}:${{ github.sha }}
    cache-from: type=registry,ref=${{ env.ECR_REGISTRY }}/${{ env.ECR_REPO }}:buildcache
    cache-to: type=registry,ref=${{ env.ECR_REGISTRY }}/${{ env.ECR_REPO }}:buildcache,mode=max
    build-args: |
      BUILDKIT_INLINE_CACHE=1

これで実際に計測したビルド時間の変化がこれ。

xychart-beta
  title "Dockerビルド時間の改善(秒)"
  x-axis ["依存変更なし(初回)", "依存変更なし(2回目)", "依存変更あり(初回)", "依存変更あり(2回目)"]
  y-axis "ビルド時間(秒)" 0 --> 300
  bar [245, 245, 245, 245]
  bar [240, 38, 210, 45]

(青が変更前、橙が変更後)

依存関係が変わらないケースで245秒→38秒。マジで助かった。CI費用にも地味に効いてくる数字だ。


Docker Compose V2 の include ディレクティブと Profiles でチーム構成を整理した話

うちのリポジトリはモノレポ構成なんだけど(詳しくはモノレポ運用ガイド|2026年ベストプラクティスと導入戦略を参照)、それに合わせてCompose設定も分割管理するようになった。

Compose V2のincludeディレクティブが2025年後半から安定して使えるようになって、これが思ったより便利だった。構成はこんな感じ。

.
├── compose.yml              # ルート(開発環境エントリポイント)
├── compose.prod.yml         # 本番向けオーバーライド
├── infra/
│   └── compose.infra.yml   # DB・Redis・Kafka等のインフラ層
├── services/
│   ├── api/
│   │   └── compose.api.yml
│   └── worker/
│       └── compose.worker.yml
# compose.yml
include:
  - path: infra/compose.infra.yml
  - path: services/api/compose.api.yml
  - path: services/worker/compose.worker.yml

# ローカル開発用の共通設定
x-common-env: &common-env
  LOG_LEVEL: debug
  OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318
# infra/compose.infra.yml
services:
  postgres:
    image: postgres:17-alpine
    volumes:
      - postgres_data:/var/lib/postgresql/data
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 5

  redis:
    image: redis:7.4-alpine
    command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru

secrets:
  db_password:
    file: ./secrets/db_password.txt

volumes:
  postgres_data:

secretsの扱いが2026年現在もたまに混乱するポイントで、environmentでパスワードを直書きしているDockerfileをコードレビューで見かけることがある。POSTGRES_PASSWORD_FILEを使ってファイル経由で渡すのが正解だ。個人的には、これを最初に説明しておくだけで後のセキュリティレビューがだいぶ楽になる。

Profilesも組み合わせると、「本番模擬環境だけ追加サービスを起動する」ような使い方ができる。

# services/api/compose.api.yml
services:
  api:
    build:
      context: ../../
      dockerfile: services/api/Dockerfile
      target: runtime
    ports:
      - "8000:8000"
    depends_on:
      postgres:
        condition: service_healthy
    profiles:
      - default
      - full

  api-debug:
    extends:
      service: api
    build:
      target: debug
    environment:
      DEBUGPY_ENABLE: "1"
    ports:
      - "5678:5678"  # debugpy
    profiles:
      - debug
# 通常起動
docker compose up

# デバッグモード
docker compose --profile debug up

# フル構成(負荷テスト用)
docker compose --profile full up

これを導入してから「あのサービスどうやって起動するんでしたっけ」問題がかなり減った。新メンバーが来たときの混乱が一番目に見えて減った気がする。


ローカル開発環境の全体構成と運用フロー

現在のチームの開発環境構成をざっくり図で表すとこうなる。

flowchart TB
  subgraph DEV["開発者ローカル環境"]
    direction TB
    subgraph COMPOSE["Docker Compose V2"]
      API["api\n(FastAPI)"]
      WORKER["worker\n(Celery)"]
      subgraph INFRA["インフラ層"]
        PG[("PostgreSQL 17")]
        REDIS[("Redis 7.4")]
        KAFKA["Kafka 3.8"]
      end
      subgraph OBS["観測性層"]
        OTEL["OTel Collector"]
        JAEGER["Jaeger UI\n:16686"]
        PROM["Prometheus\n:9090"]
      end
    end
    DEVTOOLS["Cursor / VS Code\n+ Dev Containers"]
  end

  subgraph CI["GitHub Actions"]
    BUILD["BuildKit Build\n+ cache to ECR"]
    TEST["pytest / vitest"]
    SCAN["Trivy SBOM Scan"]
  end

  subgraph REGISTRY["ECR"]
    IMAGE["コンテナイメージ"]
    CACHE["BuildKit Cache"]
  end

  DEVTOOLS -->|docker compose up| COMPOSE
  API <-->|healthcheck| PG
  API <-->|cache| REDIS
  WORKER <-->|consume| KAFKA
  API -->|traces/metrics| OTEL
  OTEL --> JAEGER
  OTEL --> PROM

  COMPOSE -->|push| CI
  CI -->|cache-from/cache-to| CACHE
  CI -->|push image| IMAGE

OTelコレクターをローカルにも入れてJaegerとPrometheusを繋いでいるのは、「本番で初めて遅いことに気づく」問題への対策だ。ローカルでもトレースを見ながら開発できると、パフォーマンス問題の発見が格段に早くなる。正直まだObservabilityの観点で改善余地はあるんだけど、ゼロよりは全然マシな状態になってきた。


1年運用してわかった「これが地味に効いた」設定集

Docker Desktop の Resource Saver を切る

Docker Desktop 4.35以降でデフォルト有効になったResource Saverモードが、ローカル開発で思わぬ待ちを生んでいた。一定時間操作がないとVMを休止させるんだけど、朝一番にdocker compose upすると再起動に10〜15秒かかる。地味にストレスで、チーム全員で無効化した。

# settings.json または Docker Desktop GUIで
# "resourceSaver": false

.dockerignore の見直し

Dockerfileより.dockerignoreの方が重要かもしれない、というのが1年の感想だ。特にNode.jsプロジェクトでnode_modulesを除外し忘れているケースが多い。

# .dockerignore(Pythonプロジェクト例)
**/__pycache__
**/*.pyc
**/*.pyo
.git
.github
.venv
venv
*.egg-info
dist
build
.pytest_cache
.mypy_cache
.ruff_cache
node_modules
*.md
docs/
tests/  # テストはビルドステージでのみ使う
.env
.env.*
secrets/

.envファイルを除外し忘れてイメージにAPIキーが入ってしまうインシデントは、うちのチームでも過去に一度あった。あれは本当に焦った。CIでdocker historyを使ってシークレットが混入していないか確認するステップを入れるのを強くお勧めしたい。

compose.override.yml でチームの衝突を防ぐ

地味に効いた設定がもう一つ。compose.override.ymlをgitignoreに入れることで、各自がローカルで追加設定を持てるようにした。

# compose.override.yml(gitignoreに追加、各自自由に編集)
services:
  api:
    environment:
      - DEBUG_SQL=1
    volumes:
      # ホットリロード用にローカルのコードをマウント
      - ./services/api/src:/app/src

これがないと「ホットリロード設定を入れたらCIが壊れた」みたいなことが起きる。チームに新しいメンバーが来たときに「override.ymlの存在を説明する」をオンボーディングに組み込んでいる。たった5行の説明で、後の混乱がかなり減る。

ビルド引数とシークレットの使い分け

ここは好み分かれるかもしれないけど、うちのチームでの使い分けルールを書いておく。

種別用途方法
ARGビルド時のバージョン指定・フラグ--build-arg
ENVコンテナ実行時の設定値compose environment
--secretビルド時のみ必要な認証情報(private pip, npmレジストリ等)--mount=type=secret
secret(compose)実行時に必要なパスワード・証明書ファイル経由マウント
# private PyPIを使うケース
FROM python:3.13-slim AS deps
RUN --mount=type=secret,id=pip_config,dst=/root/.config/pip/pip.conf \
    --mount=type=cache,target=/root/.cache/pip \
    pip install --user -r requirements.txt
# ビルド実行
docker buildx build \
  --secret id=pip_config,src=./secrets/pip.conf \
  -t myapp:latest .

このやり方なら認証情報がイメージレイヤーに残らない。CI/CDパイプラインでの実装については、CodePipeline V2とCodeBuild Fleetの記事も参考になるかもしれない。

ヘルスチェックの充実

depends_oncondition: service_healthyを使いたいのに、ヘルスチェックが設定されていないサービスがある、というのはよくある問題だ。特にデータベース系はちゃんと設定しておかないと起動順序で毎回ハマる。

services:
  kafka:
    image: confluentinc/cp-kafka:7.7.0
    healthcheck:
      test: |
        kafka-topics --bootstrap-server localhost:9092 --list
      interval: 10s
      timeout: 10s
      retries: 10
      start_period: 30s

  api:
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_started  # redisは起動確認だけで十分なことが多い
      kafka:
        condition: service_healthy

start_periodは見落としがちだけど、KafkaやCassandraのような起動に時間がかかるサービスには必須だ。これを設定していないと、起動中のヘルスチェック失敗でコンテナが再起動ループに入る。一度ハマると地味に時間を溶かすので、最初から入れておいた方がいい。


まとめ

1年間のDocker Compose V2 + BuildKit運用で本当に効いた知見をまとめると、こういうことだった。

  • BuildKit cache mountはCIでも効く:ECRへのremote cacheを組み合わせると、依存変更なしのビルドを80%以上短縮できた
  • includeディレクティブ + Profilesで構成管理が劇的に楽になる:モノレポ構成との相性が特に良い
  • compose.override.ymlをgitignoreに入れる:チームメンバーが自由にローカル設定を持てる仕組みを最初に作るだけで、衝突が激減する
  • .dockerignoreとsecretsの取り扱いは最優先で整備する:後から直すより最初から正しくやる方がコストが低い
  • ヘルスチェックはサボらない:start_period含めてちゃんと設定すると、起動順序問題のデバッグに費やす時間がゼロになる

次のアクション候補:

  • 既存プロジェクトの.dockerignoreを今日見直してみる(5分でできる)
  • docker buildx build --progress=plainでビルドの各ステップを可視化して、どのレイヤーが時間を食っているか確認する
  • ローカル環境にOTel Collectorを追加してトレースを入れてみる

皆さんのチームではCompose設定はどうしてますか?「こんな工夫してる」というのがあればぜひ教えてほしい。インシデント対応との連携についてはインシデント対応の最新ベストプラクティス2026も参考にどうぞ。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事