Dockerビルドが遅い原因と対策 ― マルチステージDockerfile最適化の実践ガイド
Dockerビルドの待ち時間、辛いですよね。コードを1行直しただけなのに、CIが15分も回っているのをぼーっと眺めている。今日だけでもう5回目...という経験、ありませんか?
実は、遅いDockerビルドは開発現場でよくある問題なのに、「まあこんなものか」と放置されがちです。でも、積み重なると生産性への影響は無視できません。ちょっとしたバグ修正が1時間仕事になってしまう原因、考えたことはありますか?
朗報があります。ほとんどの遅いDockerビルドは改善できます。しかも、かなり劇的に。正しいテクニックを使えば、ビルド時間を80〜95%削減できることも珍しくありません。15分のビルドが1分で終わるようになる、と言ったら信じますか?
この記事では、Dockerビルドが遅くなる根本的な原因を解説し、実際にビルド時間を大幅に短縮する方法を順を追って紹介していきます。
遅いビルドのコストは意外と大きい
本題に入る前に、なぜビルド時間が重要なのか考えてみましょう。
数字で見るとこうなります
10人の開発チームを想定してみます:
- 1人あたり1日5回コミット
- 各コミットで15分のビルドが走る
- 1日の待ち時間は合計750分、つまり約12時間半
- 年間(250営業日)で3,125時間――フルタイムエンジニア1.5人分に相当
もしビルド時間を2分に短縮できたら? 3,125時間が417時間になります。採用なしでチームの生産性を大幅に向上させられる計算です。
集中力への影響も無視できない
ビルド待ちの間、何をしていますか? Slackをチェック? Twitterを眺める? 15分は新しい作業を始めるには短いけど、ただ待つには長い。この「中途半端な待ち時間」が厄介なんです。
研究によると、作業が中断されてから集中状態に戻るまで平均23分かかるそうです。15分のビルドが終わって戻ってきたころには、もう集中が切れている。これが1日に何度も繰り返されると、実際にコードを書いている時間はかなり少なくなります。
Dockerキャッシュの仕組みを理解する
ビルド最適化の鍵はキャッシュです。ここを理解しないと、どんな小手先のテクニックも効果が出ません。
Dockerビルドの動作原理
docker buildを実行すると、DockerはDockerfileを上から下へ1行ずつ処理していきます。ただし、毎回全部を実行するわけではありません:
- 各命令(FROM、RUN、COPYなど)を順番に読み取る
- 以前のビルドでキャッシュがあるか確認
- キャッシュが有効なら再利用、無効なら実行
- 一度キャッシュが無効になると、それ以降は全て再実行
4番目が重要です。Dockerのキャッシュは連鎖的に動作します。3行目でキャッシュが破棄されると、4行目以降は全て再ビルドになります。たとえ内容が変わっていなくても。
キャッシュが破棄される条件
| 命令 | キャッシュが破棄される条件 |
|---|---|
RUN | コマンド文字列自体が変わったとき |
COPY | コピー元ファイルの内容やパーミッションが変わったとき |
ADD | COPYと同様 + URLの内容が変わったとき |
ARG | 引数の値が変わったとき |
ENV | 環境変数の値が変わったとき |
最も問題になりやすいのがCOPYです。Dockerfileの最初にCOPY . .と書いてあると、READMEを1文字直しただけでも、その後のnpm installが最初からやり直しになります。
Dockerfileでよくある7つの失敗パターン
遅いDockerfileにはパターンがあります。以下を避けるだけでも、かなり改善できます。
パターン1:ソースを最初に全部コピーする
# ❌ これはNG FROM node:20 WORKDIR /app COPY . . # どんな変更でもキャッシュ破棄 RUN npm install # 毎回フルインストール RUN npm run build
README.mdの誤字を直しただけで、npm installが5分かかる。依存関係が数百あるプロジェクトでこれは致命的です。
# ✅ こうすべき FROM node:20 WORKDIR /app COPY package*.json ./ # 依存関係ファイルだけ先にコピー RUN npm install # 依存関係が変わらなければキャッシュ COPY . . # その後でソースをコピー RUN npm run build
package.jsonが変わっていなければnpm installはキャッシュから取得されます。これだけで体感速度が全く違います。
パターン2:devDependenciesを本番に持ち込む
# ❌ TypeScript、Jest等が本番イメージに入ってしまう FROM node:20 WORKDIR /app COPY package*.json ./ RUN npm install COPY . . RUN npm run build CMD ["node", "dist/server.js"]
ビルドにしか使わないパッケージが最終イメージに入っています。容量も増えるし、セキュリティ的にも良くありません。
# ✅ マルチステージで分離 FROM node:20 AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM node:20-slim AS production WORKDIR /app COPY package*.json ./ RUN npm ci --only=production COPY /app/dist ./dist CMD ["node", "dist/server.js"]
ビルドはbuilderステージで行い、本番イメージには実行に必要なものだけ入れます。
パターン3:.dockerignoreを使わない
.dockerignoreがないと、docker buildのたびにnode_modulesや.gitフォルダまで全部Dockerデーモンに送信されます。数百MBを毎回転送することになります。
# .dockerignore - 必ず作成してください
node_modules
.git
*.md
.env*
.vscode
coverage
dist
*.log
.DS_Store
特にnode_modulesは、どうせイメージ内で新しくインストールするので送る必要がありません。
パターン4:重いベースイメージを使う
# ❌ フルイメージは重い FROM node:20 # 1.1GB FROM python:3.11 # 1.0GB
# ✅ slimやalpineを使う FROM node:20-slim # 240MB FROM node:20-alpine # 140MB FROM python:3.11-slim # 150MB FROM python:3.11-alpine # 52MB
ただし、alpineはmusl libcを使っているため、一部のネイティブモジュールで問題が起きることがあります。本番導入前に必ずテストしてください。
パターン5:apt-getを個別に実行する
# ❌ レイヤーが無駄に増える RUN apt-get update RUN apt-get install -y curl RUN apt-get install -y git RUN apt-get clean
# ✅ 1つにまとめる RUN apt-get update && \ apt-get install -y --no-install-recommends curl git && \ apt-get clean && \ rm -rf /var/lib/apt/lists/*
レイヤー数が減るだけでなく、cleanを同じレイヤーで実行しないと実際には容量が減りません。
パターン6:BuildKitを使っていない
Docker 23.0からBuildKitがデフォルトですが、古い環境や設定で無効になっていることもあります。
# 環境変数で有効化 export DOCKER_BUILDKIT=1 docker build . # または daemon.json で { "features": { "buildkit": true } }
BuildKitを使うと:
- 独立したステージが並列でビルドされる
- キャッシュがより賢く動作する
- ビルドシークレットを安全に渡せる
- キャッシュマウントが使える(これが最重要)
パターン7:バージョンを固定しない
# ❌ 毎回最新版をダウンロード → キャッシュ効かない RUN curl -L https://example.com/latest/binary -o /usr/bin/binary
# ✅ バージョン固定でキャッシュ有効 ARG BINARY_VERSION=1.2.3 RUN curl -L https://example.com/v${BINARY_VERSION}/binary -o /usr/bin/binary
明示的なバージョンがあれば、Dockerは以前ダウンロードしたものを再利用できます。
BuildKitキャッシュマウント ― 本当に速くなるのはここから
ここまでは基本です。本当にビルド時間を劇的に短縮するには、キャッシュマウントを使います。
パッケージマネージャのキャッシュを維持する
# Node.js FROM node:20 WORKDIR /app COPY package*.json ./ RUN \ npm ci # Go FROM golang:1.21 WORKDIR /app COPY go.mod go.sum ./ RUN \ go mod download # Python FROM python:3.11 WORKDIR /app COPY requirements.txt ./ RUN \ pip install -r requirements.txt # Rust FROM rust:1.73 WORKDIR /app COPY Cargo.toml Cargo.lock ./ RUN \ cargo build --release
--mount=type=cacheは指定したディレクトリをビルド間で保持します。一度ダウンロードしたパッケージを次回以降も再利用できるので、npm、pip、Goモジュールのダウンロード時間がほぼゼロになります。
コンパイルキャッシュも活用できる
一部の言語はコンパイラキャッシュの恩恵を受けます:
# C/C++ with ccache FROM gcc:12 RUN apt-get update && apt-get install -y ccache ENV PATH="/usr/lib/ccache:${PATH}" WORKDIR /app COPY . . RUN \ make -j$(nproc)
ビルド間でキャッシュを共有する
デフォルトではキャッシュマウントはビルド単位で分離されていますが、共有することもできます:
RUN \ npm ci
sharing=sharedを使うと、同時に実行されるビルドが同じキャッシュを読み書きできます。
ビルド成果物のキャッシュも可能
# Next.jsのビルドキャッシュ RUN \ npm run build
Next.jsは.next/cacheに以前のビルド情報を保存します。これを維持すると、変更された部分だけ再ビルドされるので大幅に高速化します。
マルチステージビルドを正しく使う
マルチステージの本当の目的は、イメージサイズを小さくすることだけではありません。並列ビルドとキャッシュの分離が重要です。
並列ビルドパターン
# ステージ1: 依存関係インストール FROM node:20 AS deps WORKDIR /app COPY package*.json ./ RUN npm ci # ステージ2: ビルド(deps完了後) FROM deps AS builder COPY . . RUN npm run build # ステージ3: テスト(deps完了後、builderと並列) FROM deps AS tester COPY . . RUN npm test # ステージ4: リント(deps完了後、builder/testerと並列) FROM deps AS linter COPY . . RUN npm run lint # 最終: 本番イメージ FROM node:20-slim AS production WORKDIR /app COPY /app/dist ./dist COPY /app/node_modules ./node_modules CMD ["node", "dist/server.js"]
BuildKitはbuilder、tester、linterを自動的に並列実行します。3つが順番に実行されると6分かかるところ、並列なら2分で終わることもあります。
条件付きビルド
ARG BUILD_ENV=production FROM node:20 AS base WORKDIR /app COPY package*.json ./ RUN npm ci FROM base AS development COPY . . CMD ["npm", "run", "dev"] FROM base AS production-builder COPY . . RUN npm run build FROM node:20-slim AS production WORKDIR /app COPY /app/dist ./dist COPY /app/node_modules ./node_modules CMD ["node", "dist/server.js"]
ビルド方法:
docker build --target development -t myapp:dev . docker build --target production -t myapp:prod .
CI/CDでキャッシュを活かす
ローカル環境ではキャッシュが残りますが、CI環境は毎回新しいコンテナで実行されます。そのため、別途設定が必要です。
GitHub Actions
name: Build on: push jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Build and push uses: docker/build-push-action@v5 with: context: . push: true tags: myapp:latest cache-from: type=gha cache-to: type=gha,mode=max
type=ghaはGitHub Actions内蔵のキャッシュを使用します。ビルドレイヤーがキャッシュに保存され、次回のビルドで再利用されます。
GitLab CI
build: stage: build image: docker:24 services: - docker:dind variables: DOCKER_BUILDKIT: 1 script: - docker build --cache-from $CI_REGISTRY_IMAGE:cache --build-arg BUILDKIT_INLINE_CACHE=1 -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA -t $CI_REGISTRY_IMAGE:cache . - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA - docker push $CI_REGISTRY_IMAGE:cache
--cache-fromでレジストリからキャッシュを取得し、BUILDKIT_INLINE_CACHE=1でプッシュするイメージにキャッシュメタデータを埋め込みます。
ビルドキャッシュサービスの活用
チーム規模が大きい場合は、専用のキャッシュサービスも検討できます:
# BuildKitのリモートキャッシュバックエンド docker buildx build \ --cache-from type=registry,ref=myregistry/myapp:buildcache \ --cache-to type=registry,ref=myregistry/myapp:buildcache,mode=max \ -t myapp:latest .
言語別最適化戦略
Node.js / JavaScript
FROM node:20-alpine AS deps WORKDIR /app # npm ciで決定論的インストール COPY package*.json ./ RUN \ npm ci --prefer-offline FROM deps AS builder COPY . . # Next.jsビルドキャッシュ RUN \ npm run build FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production # 必要なファイルのみコピー COPY /app/public ./public COPY /app/.next/standalone ./ COPY /app/.next/static ./.next/static CMD ["node", "server.js"]
Next.jsは.next/cacheにビルドアーティファクトを保存するので、これを維持するとリビルドが大幅に高速化されます。
Python
FROM python:3.11-slim AS builder # ビルド依存関係のインストール RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ && rm -rf /var/lib/apt/lists/* WORKDIR /app # 仮想環境の作成 RUN python -m venv /opt/venv ENV PATH="/opt/venv/bin:$PATH" COPY requirements.txt . RUN \ pip install --no-compile -r requirements.txt FROM python:3.11-slim AS runtime COPY /opt/venv /opt/venv ENV PATH="/opt/venv/bin:$PATH" WORKDIR /app COPY . . CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0"]
仮想環境をbuilderで作成してruntimeにコピーすることで、ビルドツールなしのクリーンなイメージになります。
Go
Goは静的バイナリの生成に優れています:
FROM golang:1.21-alpine 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 /app/server /server COPY /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ ENTRYPOINT ["/server"]
最終イメージは20MB未満になることも多いです。バイナリとTLS証明書だけが含まれています。
Rust
FROM rust:1.73 AS planner WORKDIR /app RUN cargo install cargo-chef COPY . . RUN cargo chef prepare --recipe-path recipe.json FROM rust:1.73 AS cacher WORKDIR /app RUN cargo install cargo-chef COPY /app/recipe.json recipe.json RUN \ cargo chef cook --release --recipe-path recipe.json FROM rust:1.73 AS builder WORKDIR /app COPY /app/target target COPY /usr/local/cargo /usr/local/cargo COPY . . RUN cargo build --release FROM debian:bookworm-slim AS runtime COPY /app/target/release/myapp /usr/local/bin/ CMD ["myapp"]
cargo-chefを使って依存関係を別途コンパイルすることで、キャッシュ効率が大幅に向上します。
ビルドパフォーマンスの測定
測定しなければ最適化もできません。ビルドをプロファイルする方法を紹介します:
BuildKitタイミングの確認
# BuildKitでビルドしながらタイミング出力 DOCKER_BUILDKIT=1 docker build --progress=plain . 2>&1 | tee build.log # ログからタイミングを抽出 grep "DONE\|CACHED" build.log
docker historyコマンド
docker history myapp:latest --format "{{.Size}}\t{{.CreatedBy}}"
各レイヤーのサイズとコマンドが表示され、どこが肥大化しているか一目でわかります。
ビルド時間ベンチマークスクリプト
#!/bin/bash # benchmark-build.sh iterations=5 echo "Dockerビルドのベンチマーク中..." # キャッシュクリア docker builder prune -f >/dev/null 2>&1 # コールドビルド(キャッシュなし) start=$(date +%s.%N) docker build -t myapp:bench . >/dev/null 2>&1 end=$(date +%s.%N) cold_time=$(echo "$end - $start" | bc) echo "コールドビルド: ${cold_time}s" # ウォームビルド total=0 for i in $(seq 1 $iterations); do start=$(date +%s.%N) docker build -t myapp:bench . >/dev/null 2>&1 end=$(date +%s.%N) duration=$(echo "$end - $start" | bc) total=$(echo "$total + $duration" | bc) echo "ウォームビルド $i: ${duration}s" done avg=$(echo "scale=2; $total / $iterations" | bc) echo "平均ウォームビルド: ${avg}s"
実例:14分 → 47秒への最適化過程
実際にReactモノレポプロジェクトで適用した過程を紹介します。
最初の状態
FROM node:18 WORKDIR /app COPY . . RUN npm install RUN npm run build CMD ["npx", "serve", "-s", "build"]
- ビルド時間:14分23秒(10回の平均)
- イメージサイズ:1.8GB
問題点:
npm installの前に全ファイルをCOPY.dockerignoreがない- フルnodeイメージ(1.1GB)
- マルチステージなし(devDependenciesが本番に含まれる)
ステップ1:.dockerignore + コピー順序の修正
FROM node:18 WORKDIR /app COPY package*.json ./ RUN npm install COPY . . RUN npm run build ...
# .dockerignore
node_modules
.git
build
coverage
- ビルド時間:6分12秒(57%削減)
- コードのみ変更時:2分8秒
ステップ2:マルチステージ適用
FROM node:18-slim AS deps WORKDIR /app COPY package*.json ./ RUN npm ci FROM deps AS builder COPY . . RUN npm run build FROM node:18-slim AS runner WORKDIR /app RUN npm install -g serve COPY /app/build ./build EXPOSE 3000 CMD ["serve", "-s", "build"]
- ビルド時間:4分45秒(最初から67%削減)
- イメージサイズ:450MB
ステップ3:BuildKitキャッシュマウント
FROM node:18-slim AS deps WORKDIR /app COPY package*.json ./ RUN npm ci FROM deps AS builder COPY . . RUN npm run build FROM nginx:alpine AS runner COPY /app/build /usr/share/nginx/html EXPOSE 80
- ビルド時間:1分52秒(最初から87%削減)
- キャッシュが効いているとき:47秒(97%削減!)
- イメージサイズ:42MB
最終比較
| 項目 | 修正前 | 修正後 | 改善幅 |
|---|---|---|---|
| コールドビルド | 14分23秒 | 1分52秒 | 87%↓ |
| キャッシュビルド | 14分23秒 | 47秒 | 97%↓ |
| イメージサイズ | 1.8GB | 42MB | 98%↓ |
| 月間CI費用 | 約$180 | 約$12 | 93%↓ |
よくあるトラブルと対処法
「キャッシュが効かない」
- CI環境の問題:毎回新しいランナーで実行されるためキャッシュがない → レジストリベースのキャッシュを使用
- BuildKitが無効:
docker info | grep buildkitで確認 - ARGの値が変わっている:ARGが変わると以降のレイヤーは全てキャッシュ無効
- 時間ベースのコマンド:
RUN dateなどは毎回無効化される
# ❌ 毎回キャッシュが破棄される RUN echo "ビルド時刻: $(date)" > /build-info.txt # ✅ ビルド引数に分離 ARG BUILD_TIME RUN echo "ビルド時刻: $BUILD_TIME" > /build-info.txt
「マルチステージにしたら逆に遅くなった」
BuildKitが無効だと並列ビルドされません。確認してください:
docker info | grep -i buildkit
「CIでキャッシュマウントが効かない」
永続マウントはビルダーにローカルです。外部キャッシュを使ってください:
- name: Build run: | docker buildx build \ --cache-from type=gha \ --cache-to type=gha,mode=max \ -t myapp .
チェックリスト
最適化を始める前に確認してください:
-
DOCKER_BUILDKIT=1を設定している -
.dockerignoreがある(node_modules、.gitを除外) - 依存関係ファイルを先に、ソースは後でコピーしている
- slimまたはalpineベースイメージを使用している
- マルチステージでビルドとランタイムを分離している
-
--mount=type=cacheを使用している - CIでキャッシュ設定が完了している
- RUN命令を最小化・結合している
- 外部依存関係のバージョンを固定している
- ビルド時間を測定している
まとめ
Dockerビルドの最適化は、一度やっておけばずっと効果が続く投資です。この記事で紹介したテクニック―コピー順序の工夫、マルチステージ、BuildKitキャッシュマウント―を適用するだけで、ビルド時間は劇的に短縮できます。
速いビルドは単なる時間節約ではありません:
- フィードバックループが短くなり、開発速度が上がる
- CI費用が削減できる
- デプロイ頻度を増やせる文化につながる
- 開発者体験の向上 ― 待ちが減り、シッピングが増える
まずは簡単なところから始めてみてください。.dockerignoreを追加して、コピー順序を変えて、BuildKitを有効にする。それだけでも効果を実感できるはずです。その後、マルチステージとキャッシュマウントを追加していきましょう。各変更を測定することも忘れずに。
Dockerfileに隠れていた速度がどれだけあったか、驚くかもしれませんよ。
ビルドが速くなってこそ、本当の開発ですよね。
関連ツールを見る
Pockitの無料開発者ツールを試してみましょう