3.2 Dockerレイヤーキャッシュの仕組みとビルド最適化|ビルド時間を10倍速くする実践テクニック

【第3章】Dockerfileによるイメージ構築

Dockerレイヤーキャッシュの仕組みとビルド最適化|ビルド時間を10倍速くする実践テクニック

docker build が毎回5分もかかる…」「ソースを1行直しただけなのに npm install から全部やり直される」——Dockerfileを書き始めた人の9割がぶつかる壁が、ビルドの遅さです。

この問題の正体は レイヤーキャッシュの使い方。Dockerfileの書き順を少し変えるだけで、ビルド時間が 5分→30秒 になることも珍しくありません。この記事では、レイヤーキャッシュの内部メカニズムから、明日から使える最適化テクニックまで、図解つきで徹底解説します。


目次

  1. なぜビルド時間を気にするのか
  2. レイヤーとキャッシュの関係
  3. キャッシュヒット/ミスの判定ルール
  4. キャッシュが「壊れる」仕組みを図解
  5. ビルド最適化の5原則
  6. 実践① COPYの順序テクニック(npm/pipの最適化)
  7. 実践② RUNをまとめてレイヤーを減らす
  8. BuildKitのキャッシュ機能(Docker 23以降)
  9. キャッシュを意図的に制御する(–no-cache / ARG)
  10. よくある落とし穴
  11. まとめ

1. なぜビルド時間を気にするのか

ビルド時間の差は、単なる「待ち時間」ではなく、開発体験・CI/CDコスト・チーム生産性すべてに直結します。

影響領域 最適化前 最適化後
ローカル開発サイクル 1回のビルド 5分 → 集中が切れる 差分ビルド 20秒 → 修正→確認が流れる
CI/CDパイプライン PRごとに5分×コミット数 → 待機列 30秒程度 → マージが高速化
GitHub Actions料金 無駄な分数課金が増える 課金分数を1/10に削減も可能

後ほど示す例では、COPYの順番を2行入れ替えるだけで Node.js アプリのビルドが300秒→15秒になります。最適化の鍵は「レイヤーキャッシュ」を味方にすること。まずはその仕組みから理解しましょう。


2. レイヤーとキャッシュの関係

Dockerイメージは複数のレイヤーが積み重なった構造です。Dockerfileの各命令(FROMRUNCOPY など)が、基本的に1レイヤーを生成します。

【Dockerfile と レイヤーの対応】
Dockerfile
FROM node:20
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .

イメージレイヤー(上が最新)
Layer 5: ソースコード
Layer 4: node_modules
Layer 3: package.json
Layer 2: /app 作成
Layer 1: Node.js 20 本体

各レイヤーにはハッシュIDが振られており、Dockerはビルドのたびに「このレイヤー、前回と同じ?」を上から順にチェックします。同じであればキャッシュから再利用、違えばそのレイヤー以降を再ビルドする仕組みです。

💡 ポイント
レイヤーの内部実装は OverlayFS によるコピーオンライト。各レイヤーは読み取り専用で、差分だけが新しいレイヤーとして積まれます。この「読み取り専用+ハッシュID」の性質が、キャッシュを安全に再利用できる理由です(詳しくは 2-1「イメージとコンテナの関係」を参照)。

サンプル記事の docker_sim type="layers" で、レイヤーが共有される様子を復習できます。


3. キャッシュヒット/ミスの判定ルール

「どういう時にキャッシュが使われ、どういう時に壊れるのか」——この判定ルールは命令の種類によって違います。

命令 キャッシュキーの計算方法 壊れる条件
FROM イメージ名+タグ+ダイジェスト ベースイメージが更新されたとき(latest注意)
RUN 命令文字列の完全一致 コマンドを1文字でも変えると壊れる
COPY / ADD コピー対象ファイルのチェックサム ファイル内容・権限・タイムスタンプが変わると壊れる
ENV / ARG キー+値の文字列 値が変わると壊れる(以降全滅)
WORKDIR / EXPOSE / CMD 命令文字列 文字列変更で壊れる(ただし影響は小さい)
⚠️ 最重要の鉄則
キャッシュは 上から順に一致するかチェック され、一度壊れたら以降すべて再ビルドされます。つまり、変更頻度が高い命令を上に書くと、毎回全部を作り直すハメになります。

COPYのキャッシュは「ファイル内容」で決まる

COPYで注意すべきは、Dockerがコピー対象ファイルのチェックサムを比較する点です。同じファイルを同じ内容でCOPYすればキャッシュヒット、1バイトでも変わればミスになります。

# ソース変更なしで docker build
 $ docker build -t myapp .
 ...
  => CACHED [1/5] FROM node:20 0.0s
  => CACHED [2/5] WORKDIR /app 0.0s
  => CACHED [3/5] COPY package.json . 0.0s
  => CACHED [4/5] RUN npm install 0.0s
  => CACHED [5/5] COPY . . 0.0s
 
 # 「CACHED」と表示されるのがキャッシュヒット

4. キャッシュが「壊れる」仕組みを図解

以下は、ソースコードを1行修正しただけで npm install が毎回走る悪い例です。

【悪い書き方】── ソース1行修正で全てやり直し
1回目のビルド(全部走る)
FROM node:20 ✅ pull
WORKDIR /app ✅
COPY . . ✅ 全ファイル
RUN npm install ⏱ 180秒

2回目(app.js を1行修正)
FROM node:20 💾 CACHED
WORKDIR /app 💾 CACHED
COPY . . ❌ 壊れる!
RUN npm install ⏱ 180秒(再実行)

COPY . . が壊れると、その下の RUN npm install も巻き添えで再実行される

原因は、COPY . .(ソース全体コピー)の直後RUN npm install を置いたことです。app.js を修正すると COPY のキャッシュが壊れ、その後のすべてのレイヤーが再ビルドされます。

改善版:依存解決とソースコピーを分ける

【良い書き方】── 依存関係は先にコピー&インストール
1回目のビルド(全部走る)
FROM node:20 ✅
WORKDIR /app ✅
COPY package*.json ./ ✅
RUN npm install ⏱ 180秒
COPY . . ✅

2回目(app.js を1行修正)
FROM node:20 💾 CACHED
WORKDIR /app 💾 CACHED
COPY package*.json ./ 💾 CACHED
RUN npm install 💾 CACHED ⚡
COPY . . ✅ ソースのみ

npm install が壊れない限り 180秒がまるごとスキップされる

ポイントは、「変更頻度の低いもの(=依存関係)を先に、頻度の高いもの(=ソースコード)を後に」という順序です。


5. ビルド最適化の5原則

プロジェクトが変わっても使える、普遍的な原則を5つにまとめます。

原則 内容 効果
① 変更頻度順で並べる ほぼ変わらないものを上、毎回変わるものを下へ 最大の効果。迷ったらこれ
② COPYを分割する 依存定義(package.json等)を先に、ソースを後に npm install / pip install のスキップ
③ RUNをチェーンする 関連コマンドは && で1行に レイヤー数削減+中間ファイル除去
④ .dockerignore を使う node_modules・.gitなどを除外 コンテキスト転送時間の削減(3-3で詳説)
⑤ 意図的にキャッシュを壊す --no-cacheARG で制御 最新パッチを強制適用したいとき

6. 実践① COPYの順序テクニック(npm/pipの最適化)

各言語のパッケージマネージャに応じた、キャッシュを活かすCOPY順序です。

Node.js(npm / yarn / pnpm)

FROM node:20-alpine
 WORKDIR /app
 
 # ① 依存定義だけ先にコピー
 COPY package.json package-lock.json ./
 
 # ② 依存解決(ここでキャッシュが効く)
 RUN npm ci --omit=dev
 
 # ③ ソースコードは最後にコピー
 COPY . .
 
 CMD ["node", "app.js"]

npm install ではなく npm ci を推奨。package-lock.json に忠実で、CIでの再現性が高く、高速です。

Python(pip)

FROM python:3.12-slim
 WORKDIR /app
 
 # ① requirements.txt だけ先にコピー
 COPY requirements.txt .
 
 # ② 依存インストール(キャッシュ対象)
 RUN pip install --no-cache-dir -r requirements.txt
 
 # ③ アプリコードは最後
 COPY . .
 
 CMD ["python", "app.py"]
💡 pip install --no-cache-dir の意味
これは「pipがダウンロードした.whlファイルをコンテナ内にキャッシュしない」という意味で、Dockerレイヤーキャッシュとは無関係です。最終イメージを軽くする目的で付けます。混同しないよう注意。

Go(go mod)

FROM golang:1.22-alpine
 WORKDIR /app
 
 # ① go.mod / go.sum だけ先に
 COPY go.mod go.sum ./
 
 # ② モジュールをダウンロード(キャッシュ対象)
 RUN go mod download
 
 # ③ ソースコピー&ビルド
 COPY . .
 RUN go build -o app
 
 CMD ["./app"]

7. 実践② RUNをまとめてレイヤーを減らす

RUN命令を分けるとレイヤー数が増え、イメージサイズも膨らみます。関連コマンドは && で1つに繋ぐのが基本です。

apt-get の鉄則

# ❌ 悪い例:3レイヤー、中間キャッシュが残る
 RUN apt-get update
 RUN apt-get install -y curl
 RUN rm -rf /var/lib/apt/lists/*
 
 # ✅ 良い例:1レイヤー、中間ファイルなし
 RUN apt-get update && \
  apt-get install -y --no-install-recommends curl && \
  rm -rf /var/lib/apt/lists/*
⚠️ apt-get update と install を分けてはいけない
別レイヤーにすると、update の結果がキャッシュされ続け、古いパッケージ情報のまま install が実行されてしまう悪名高いバグの原因になります。必ず同じ RUN 内で実行しましょう。

RUNをまとめる利点と欠点

観点 分ける まとめる
レイヤー数 多い 少ない
イメージサイズ 中間ファイルで膨張 スリム
キャッシュの粒度 細かく制御可 変更時に全部やり直し
ベストプラクティス デバッグ中のみ 本番向け

8. BuildKitのキャッシュ機能(Docker 23以降)

BuildKit は Docker の次世代ビルドエンジンで、Docker 23.0以降ではデフォルトで有効です。従来のキャッシュに加えて、強力な機能が使えます。

① 並列ビルド

依存関係のないステージを並列実行するため、マルチステージビルド(3-5で詳説)との相性が抜群です。

RUN --mount=type=cache(永続キャッシュ)

通常のレイヤーキャッシュは「Dockerfileを変更しない限り有効」という性質ですが、BuildKitの --mount=type=cacheビルド間で永続化されるキャッシュディレクトリを用意できます。

# syntax=docker/dockerfile:1.4
 FROM node:20-alpine
 WORKDIR /app
 COPY package.json package-lock.json ./
 
 # npm のキャッシュを /root/.npm に永続化
 RUN --mount=type=cache,target=/root/.npm \
  npm ci --omit=dev
 
 COPY . .
 CMD ["node", "app.js"]

これで、package.json を変更して npm ci が走る時でも、ダウンロード済みパッケージは再利用されます。レイヤーキャッシュより更に速いビルドが実現できます。

💡 ポイント
BuildKit の高度なキャッシュ戦略(--cache-from--cache-to、レジストリキャッシュ等)は 第9章 9-2「イメージビルドの高速化」で詳しく扱います。

9. キャッシュを意図的に制御する(–no-cache / ARG)

キャッシュは便利ですが、意図的に壊したい場面もあります。

docker build --no-cache:全部再実行

docker build --no-cache -t myapp .

セキュリティパッチを最新にしたい、ベースイメージの更新を取り込みたい時に使います。

② ARG を使った部分的リセット

FROM ubuntu:22.04
 
 # ここより上はキャッシュされる
 ARG CACHEBUST=1
 # この行の値が変わると、以降のレイヤーは全て再ビルドされる
 
 RUN apt-get update && apt-get install -y curl
 COPY . .
# ビルド時に値を変えればキャッシュをリセットできる
 docker build --build-arg CACHEBUST=$(date +%s) -t myapp .

--pull:ベースイメージだけ最新化

docker build --pull -t myapp .

FROM のイメージをレジストリから最新版で取り直します。latest タグを使っている場合に特に有効です。


10. よくある落とし穴

落とし穴 原因 対策
COPY . . を最初に置いてしまう 全変更でキャッシュ崩壊 依存定義を先にCOPY、ソースは最後
apt-get updateinstall の分離 古いパッケージインデックスがキャッシュされる 必ず同じRUNで && 連結
タイムスタンプ入りのファイルをCOPY 毎回チェックサムが変わる 生成ファイルを .dockerignore で除外
FROM node:latest を使っている タグ更新で突然キャッシュが無効化 バージョン固定(node:20.11-alpine 等)
環境変数を毎回違う値で渡す ARGENV で以降全滅 変更頻度の高いARGは下側に配置
ビルドコンテキストが巨大 転送だけで数秒〜数十秒 3-3 .dockerignore で除外
⚠️ キャッシュが「効いているのに効いていないように見える」時
ビルド出力に CACHED と表示されていてもビルドが遅い場合、原因はビルドコンテキスト転送の可能性があります。docker build は最初にカレントディレクトリ全体を daemon に送信するため、node_modules.git が含まれると数十秒かかることも。.dockerignore3-3)で対処しましょう。

11. まとめ

レイヤーキャッシュの最適化は、Dockerfile の書き順をほんの少し工夫するだけで、ビルド時間を劇的に短縮できる最もコスパの高いテクニックです。

テクニック 何を最適化するか 効果の大きさ
① 変更頻度順にレイヤーを並べる 再ビルド範囲 ★★★(最大)
② COPY分割(依存 → ソース) npm/pip install のスキップ ★★★
③ RUN を && でまとめる レイヤー数・イメージサイズ ★★
④ .dockerignore で除外 コンテキスト転送時間 ★★
⑤ BuildKit --mount=type=cache ビルド間のパッケージ再利用 ★★
--no-cache / ARG CACHEBUST 意図的なキャッシュ無効化
✅ 次のステップ
3-5「マルチステージビルド」では、ビルド用と実行用のステージを分けて、本番イメージを数百MB→数十MBまで削減するテクニックを解説します。レイヤーキャッシュと組み合わせると、ビルド時間もイメージサイズも両立できる、プロ仕様のDockerfileが書けるようになります。

参考リンク


Dockerの基礎を動画で体系的に学びませんか?

実務で使う基礎だけを3時間に凝縮。環境構築から丁寧に解説しています。

Udemy Docker入門講座 クーポン割引で講座を見る →

コメント

タイトルとURLをコピーしました