「ローカルでは動いたのに」が消えた日——BuildKit 2026年版でチーム開発が変わった話
新メンバーのオンボーディングで2時間溶かした経験から整理したDockerのリアルな知見。キャッシュ戦略・本番同等環境の作り方など、実際に効果があったものだけ紹介します。
ローカル環境と本番が微妙に違う問題、もう嫌になりませんか?
「ローカルでは動いたのに本番でコケる」——これ、本当に何度経験しても嫌になりますよね。先月も、うちのチームで新しいメンバーがオンボーディング中に「自分のMacだけなぜかビルドが死ぬ」という状況になって2時間溶かしました。原因はDockerfileの RUN --mount=type=cache の書き方がBuildKitのバージョンによって微妙に挙動が違う、というやつでした。
2026年時点でDockerのエコシステムはだいぶ成熟してきて、ビルド速度も開発体験も2〜3年前と比べると正直別物になっています。ただ、その進化についていけていないまま古い書き方を惰性で使っているプロジェクトも多い。今回は、最近うちのチームで整理し直したDockerのベストプラクティスを、実際に効果があったものに絞って書いていきます。
セキュリティ周りの話(SBOM、eBPFによるランタイム保護など)はコンテナセキュリティ完全ガイド2026で詳しく書いたので、今回はビルド効率化と開発環境の本番同等性にフォーカスします。
BuildKit 2026年の実力、マジで別次元になっていた
Docker 27.x(2026年4月時点のlatestはv27.3)ではBuildKitがデフォルト有効どころか、内部エンジンが刷新されています。特に効いているのが以下の3点です。
キャッシュマウントの挙動が安定した
以前は --mount=type=cache がCI環境で微妙に効いたり効かなかったりして「本当に使えるのか?」という感じでしたが、2026年現在は各主要CIサービス(GitHub Actions、GitLab CI)とのキャッシュバックエンド統合がちゃんと整備されています。
実際にGoのプロジェクトで試した結果:
# syntax=docker/dockerfile:1.8
FROM golang:1.25-alpine AS builder
WORKDIR /app
# go.mod/go.sumのキャッシュを分離することで依存取得をスキップ
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -o /app/server ./cmd/server
# 本番イメージ:ディストロレスで最小構成
FROM gcr.io/distroless/static:nonroot
COPY --from=builder /app/server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]
これで何が変わったか、数字で見てみましょう:
xychart-beta
title "Goプロジェクトビルド時間比較(秒)"
x-axis ["初回", "依存変更なし", "ソースのみ変更"]
y-axis "ビルド時間(秒)" 0 --> 300
bar [240, 180, 210]
bar [240, 18, 32]
左のバー(青)が旧来の書き方、右のバー(オレンジ)がキャッシュマウント活用後です。依存変更なしのケースで180秒 → 18秒。地味に毎日のCIサイクルにじわじわ効いてくる数字で、積み上げると月単位でけっこうな時間になります。
--secret でビルド時の認証情報を安全に渡す
これ、2024年頃から使えるようになっていたんですが、ちゃんと活用しているプロジェクトがまだ少ない気がします。.env をADD/COPYしてビルドすると認証情報がイメージレイヤーに残るのは有名な問題ですが、2026年はちゃんと --secret を使いましょう。
# ビルド実行時
docker build \
--secret id=npm_token,env=NPM_TOKEN \
--secret id=github_token,env=GITHUB_TOKEN \
-t myapp:latest .
# syntax=docker/dockerfile:1.8
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
# --secretでマウント。ビルド後にはレイヤーに残らない
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) \
npm ci --ignore-scripts
これをやっていない状態でイメージを docker history してみると、環境変数がそのまま見えることがある。正直、初めて見たとき結構ゾッとしました。まだ直していないDockerfileがあるなら、今日直してほしいです。
Docker Compose V2、2026年の実践構成
watch モードが本番と乖離しない開発の鍵になった
Docker Compose V2の watch 機能(docker compose watch)、最初は「bind mountでよくない?」と思ってあまり使っていなかったんですが、ちゃんと向き合ったら意外と良かった。
bind mountの問題って、ホストのファイルシステムをそのままコンテナに突っ込むので「本番イメージでは動かない依存関係がローカルに入っていて気づかない」という事故が起きやすいんですよね。watch モードはファイル変更を検知して特定のアクション(sync か rebuild)を実行するので、本番のイメージビルドフローを維持しながらホットリロードができます。
# compose.yaml(V2ではdocker-compose.ymlよりcompose.yamlが推奨)
services:
api:
build:
context: .
dockerfile: Dockerfile
target: development # マルチステージの開発用ターゲット
cache_from:
- type=gha # GitHub Actionsのキャッシュを再利用
develop:
watch:
- action: sync
path: ./src
target: /app/src
ignore:
- node_modules/
- action: rebuild
path: package.json
- action: rebuild
path: go.mod
environment:
- ENV=development
ports:
- "8080:8080"
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
db:
image: postgres:17-alpine
environment:
POSTGRES_DB: myapp_dev
POSTGRES_USER: dev
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
healthcheck:
test: ["CMD-SHELL", "pg_isready -U dev -d myapp_dev"]
interval: 5s
timeout: 5s
retries: 5
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7.4-alpine
command: redis-server --save 60 1 --loglevel warning
secrets:
db_password:
environment: DB_PASSWORD
volumes:
postgres_data:
# 起動
docker compose up --watch
# ファイル変更を検知して自動でsync/rebuildが走る
この構成に切り替えてから「開発環境のみに存在するパッケージがあって本番でクラッシュした」問題はほぼなくなりました。bind mountからの移行は若干面倒ですが、やる価値は確実にあります。
マルチステージビルドで環境ごとの分岐を整理する
開発・テスト・本番でDockerfileを別々に持つのはもう古い。2026年はマルチステージで全部一本化するのが定番です。個人的には「Dockerfile.dev があるリポジトリを見たらそっと声をかけてあげてほしい」くらいには思っています。
# syntax=docker/dockerfile:1.8
# ──── 共通ベース ────
FROM node:22-alpine AS base
WORKDIR /app
COPY package.json package-lock.json ./
# ──── 依存インストール ────
FROM base AS deps
RUN --mount=type=cache,target=/root/.npm \
npm ci --ignore-scripts
# ──── 開発用 ────
FROM deps AS development
ENV NODE_ENV=development
CMD ["npm", "run", "dev"]
# ──── テスト用 ────
FROM deps AS test
COPY . .
RUN --mount=type=cache,target=/root/.npm \
npm test
# ──── ビルド ────
FROM deps AS build
COPY . .
RUN npm run build
# ──── 本番 ────
FROM node:22-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
# 本番依存のみ
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev --ignore-scripts
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/index.js"]
Dockerfileのステージと各環境のマッピングを整理するとこんな感じです:
| ステージ | 用途 | イメージサイズ目安 |
|---|---|---|
development | ローカル開発(watch使用) | 〜500MB |
test | CI上のユニット・統合テスト | 〜500MB |
build | アーティファクト生成 | 〜500MB |
production | 本番デプロイ | 〜120MB |
本番イメージが120MB台に収まると、コールドスタートの体感速度がかなり変わります。ECSやKubernetesでのデプロイ時間でも効いてきますね。
開発チームでのCI/CD統合、こう設計した
アーキテクチャ全体のフローはこんな感じです:
flowchart TB
subgraph Developer["開発者ローカル"]
DC["docker compose watch"]
DL["docker buildx bake"]
end
subgraph CI["GitHub Actions"]
BUILD["Build\n(BuildKit + GHAキャッシュ)"]
TEST["Test Stage\n(--target test)"]
SCAN["Image Scan\n(Trivy + SBOM生成)"]
PUSH["ECR Push"]
end
subgraph Registry["レジストリ"]
ECR["Amazon ECR"]
CACHE["Cache Layer\n(type=gha)"]
end
subgraph Deploy["デプロイ"]
ECS["ECS Fargate"]
K8S["EKS"]
end
DC --> DL
DL --> BUILD
BUILD --> CACHE
CACHE --> BUILD
BUILD --> TEST
TEST --> SCAN
SCAN --> PUSH
PUSH --> ECR
ECR --> ECS
ECR --> K8S
GitHub Actions側の設定も載せておきます。ポイントはキャッシュバックエンドとbuildx bakeの組み合わせです:
# .github/workflows/build.yml
name: Build & Push
on:
push:
branches: [main, develop]
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write # OIDC
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
version: latest
driver-opts: image=moby/buildkit:v0.17.0 # 2026年4月時点のstable
- name: Configure AWS Credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ap-northeast-1
- name: Login to ECR
uses: aws-actions/amazon-ecr-login@v2
- name: Build and push (test stage)
uses: docker/build-push-action@v6
with:
context: .
target: test
cache-from: type=gha
cache-to: type=gha,mode=max
push: false
- name: Build and push (production)
uses: docker/build-push-action@v6
with:
context: .
target: production
tags: ${{ secrets.ECR_REGISTRY }}/myapp:${{ github.sha }}
cache-from: type=gha
push: ${{ github.ref == 'refs/heads/main' }}
sbom: true # SBOM自動生成(BuildKit v0.15+)
provenance: true
sbom: true は最近デフォルトにしています。SBOM生成がビルドフローにここまでスムーズに統合されているのは2〜3年前には想像できなかった。インシデント対応のベストプラクティス2026でも触れていますが、サプライチェーン攻撃の文脈でSBOMは「あると便利」から「ないとまずい」くらいの位置づけになりつつあります。
実際に困った問題と、2026年時点での回答
M1/M2/M3 MacとLinux本番環境のアーキテクチャ差異
正直、これは2026年になってもたまにハマります。特にネイティブライブラリを含むイメージ(OpenSSL依存のPythonパッケージなど)で linux/amd64 と linux/arm64 の挙動差が出ることがある。
チームでの対策はシンプルで、ビルドを必ず --platform linux/amd64 で明示するか、CI上でマルチプラットフォームビルドを通すかのどちらかにしました:
# ローカルでamd64向けにビルドして検証
docker buildx build \
--platform linux/amd64,linux/arm64 \
--tag myapp:multiarch \
--push .
# compose.yamlでもplatformを明示
services:
api:
build:
context: .
platforms:
- linux/amd64
- linux/arm64
マルチプラットフォームビルドのビルド時間は単独プラットフォームの約1.6〜2倍になります。それでも「M3 MacでOKだったのにECSでコケる」という無駄なデバッグ時間よりずっとましなので、割り切って入れるのが正解だと思っています。
イメージが肥大化する問題
「気づいたら本番イメージが2GBになってた」という話、あるある過ぎます。これはCIにチェックを仕込むのが一番確実です:
# compose.yamlに診断ツールを追加
services:
dive:
image: wagoodman/dive:latest
volumes:
- /var/run/docker.sock:/var/run/docker.sock
command: ["myapp:latest"]
environment:
DIVE_CI: "true" # 効率しきい値以下だとexit 1
# CIで使う場合
docker run --rm \
-e DIVE_CI=true \
-v /var/run/docker.sock:/var/run/docker.sock \
wagoodman/dive:latest \
myapp:${{ github.sha }}
DIVE_CI=true にするとイメージ効率が設定値(デフォルト0.9)を下回るとビルド失敗にできます。地味に便利で、一度入れたらもう外せなくなりました。「なぜか太った」を未然に防げるのが個人的には一番の価値だと思っています。
DockerfileのLintを自動化する
Terraformで3年分の失敗から学んだ話と同じように、インフラ定義のLintは早めに自動化しておくと後が楽です。Hadolintをpre-commitとCIの両方で動かしています:
# .hadolint.yaml
failure-threshold: warning
ignore:
- DL3008 # apt-get install --no-install-recommends(プロジェクト方針で許容)
- DL3009 # Delete apt-get lists(キャッシュマウント使用時は不要)
trusted-registries:
- docker.io
- gcr.io
- public.ecr.aws
# .pre-commit-config.yaml
repos:
- repo: https://github.com/hadolint/hadolint
rev: v2.13.0
hooks:
- id: hadolint-docker
args: [--config, .hadolint.yaml]
CI側にも入れてPRで検知できるようにすると、レビューで「FROM ubuntu使うなよ」と言わずに済む。これも地味に便利で、レビュアーの精神衛生にやさしいです。
まとめ
2026年時点のDocker活用、チームで整理してみてわかったことをまとめます。
- BuildKitのキャッシュマウントは今すぐ使うべき。依存変更なしのビルドが10分の1になることも普通にある。
syntax=docker/dockerfile:1.8を先頭に書くだけでBuildKitの最新機能が使えます --secretを使っていないDockerfileは今日直してほしい。docker historyでパスワードが見える状態のままにしておくのはもう言い訳できない- Compose V2の
watchモードで「ローカルのみ動く問題」が激減した。bind mountからの移行は若干面倒だけど、やる価値は確実にある - マルチステージビルドで環境別Dockerfileはやめる。一本化すると管理が楽になるし、本番イメージのサイズも自然に減っていく
- SBOMとDiveのCI統合は最低限やっておく。セキュリティ要件が厳しくなっている2026年では「入れていない」がそもそもリスクになってきている
次にやるべきアクションとしては、まず既存のDockerfileに syntax=docker/dockerfile:1.8 を追加してキャッシュマウントを導入するのが一番効果が出やすいです。ビルド時間が半分以下になったら、その次にDive導入でイメージサイズを見直す、という順番で進めると成果が見えやすくて良いと思います。
皆さんのチームではDockerfileどう管理してますか?「もっとこっちの方が良かった」みたいな知見があればぜひ聞かせてください。正直まだ全部が完璧に回っているわけではなくて、特にマルチプラットフォームビルドの時間最適化はまだ改善の余地があると感じています。