Dockerfileのレイヤキャッシュとは?

Dockerfileのレイヤキャッシュとは?

DockerのビルドはDockerfileの命令ごとに「レイヤ(差分)」を積み重ね、同じ入力なら過去の結果を再利用して高速化します。逆に、どこかの命令の入力が変わると、その命令以降のキャッシュは崩れて作り直しになります。本記事では「何が入力になるのか」「どこで崩れやすいのか」「ログで崩れ方を見抜く方法」「効かせるDockerfileの型」まで、実務で迷わない形に整理します。

BuildKit自体の全体像(buildxやremote cacheを含む)を先に押さえたい場合は、前回の BuildKitとは?docker buildとの違いと使い方 を先にご覧いただくと理解がスムーズです。

1. レイヤキャッシュとは(結論)

1-1. Dockerイメージは“レイヤの積み重ね”

Dockerイメージは、1枚の巨大ファイルではなく、レイヤ(差分)の積み重ねでできています。Dockerfileの各命令(FROM, COPY, RUNなど)を実行するたびに、前の状態との差分がレイヤとして追加され、最終的にそれらを重ね合わせたものがイメージになります。

この「命令ごとに差分が積まれる」構造があるからこそ、Dockerはビルドの途中結果を再利用できます。たとえば、依存インストールまでのレイヤが同じなら、そこまでは作り直さずに次の命令から進められます。これがレイヤキャッシュの土台です。

1-2. キャッシュが効く/効かないの基本

レイヤキャッシュの基本ルールはとてもシンプルで、同じ入力なら再利用、入力が変わると以降が作り直しです。ある命令の入力が前回と一致していれば、その命令で作られたレイヤを再利用できます。

逆に言うと、どこか1つでも入力が変わると、その命令のレイヤが作り直しになり、さらにそれ以降の命令も全部作り直しになります。よくある「ちょっとソースを変えただけなのに、毎回依存インストールが走る」は、Dockerfileの順序やCOPYの範囲が原因で“早い段階の入力”が変わっている状態です。

1-3. BuildKitでも本質は同じ(+追加のキャッシュ)

BuildKitを使うと高速化やキャッシュ共有が強化されますが、レイヤキャッシュの本質は変わりません。Dockerfile命令ごとにレイヤが作られ、「入力が同じなら再利用、変われば崩れる」という基本は同じです。

ただしBuildKitでは、レイヤキャッシュに加えて、--mount=type=cache のような「ツールのキャッシュ」をビルドの外側に保持したり、remote cacheでCI間共有したりできます。つまり、レイヤキャッシュを理解した上で、BuildKitの追加機能でさらに強化する、という順序で覚えるのが一番分かりやすいです。

2. 何が「入力」になる? キャッシュキーの考え方

2-1. FROM(ベースイメージ)変更で全部飛ぶ

FROM はビルドの土台です。ベースイメージが変わると、その上に積む全レイヤの前提が変わるため、基本的に全部作り直しになります。たとえば FROM node:20FROM node:22 に変えたら、依存インストールもビルドもすべて再実行になります。

また、タグは同じでも中身が更新されることがあります(例:node:20 の中身が更新)。CIで --pull を付けると毎回最新ベースを取りに行くため、ベース更新が入ったタイミングでキャッシュが大きく崩れることがあります。セキュリティ上は良い判断でも、ビルド時間が不安定になる原因になります。

2-2. COPY/ADD の入力=コピー対象ファイルの内容

COPYADD の入力は、「コピー対象ファイルの内容」です。タイムスタンプよりも内容(バイト列)が主で、どれか1ファイルでも変われば、その COPY 命令のキャッシュは崩れます。

ここで重要なのが、COPY . . のように範囲が広いコピーを早い段階に置くと、少しの変更でも「巨大な入力が変わった」扱いになりやすいことです。結果として依存インストールなども巻き込んでキャッシュが崩れます。後半の章で説明する「依存ファイルだけ先にCOPYする」型が効く理由はここにあります。

2-3. RUN の入力=コマンド文字列+前段レイヤ+(BuildKitなら追加要素)

RUN の入力は「実行するコマンド文字列」+「直前までのレイヤ状態」です。つまり、コマンド文字列が1文字でも変わればキャッシュは崩れますし、前段の COPY などで状態が変わっていても崩れます。

BuildKitの場合はさらに、--mount=type=cache のようなマウント指定やシークレットなど、追加の要素もビルド結果に影響します。ただし根っこは同じで、「そのRUNが同じ条件で再現できるならキャッシュできるし、条件が変われば崩れる」と考えると迷いにくいです。

3. キャッシュが崩れる典型パターン(原因別)

3-1. 依存インストール前に COPY . . している

一番多い失敗がこれです。依存インストール(npm cipip install)の前に COPY . . でソース全体をコピーしてしまうと、ソースを1行変更しただけでも「COPYの入力が変わった」扱いになり、依存インストールのレイヤまで無効化されます。

結果として、毎回パッケージダウンロードが走り、ビルドが遅くなります。依存関係は毎回変わるわけではないので、依存定義ファイル(package-lockやrequirements)だけを先にコピーして依存だけ先に固めるのが基本の対策になります。

3-2. .dockerignore が弱い(成果物/node_modules/.gitが混ざる)

.dockerignore が弱いと、ビルドコンテキストに不要なファイルが混ざり、毎回どこかが変わってしまいます。代表例は node_modules、ビルド成果物(distなど)、.git ディレクトリ、IDEの一時ファイルです。

これらが混ざると、COPY . . の入力が常に変化しやすくなり、キャッシュヒット率が下がります。特にCIでは、チェックアウトの状態や生成物がジョブごとに微妙に違い、キャッシュが安定しません。.dockerignore を整えるだけで、体感速度が大きく変わることがあります。

3-3. 外部状態に依存する RUN(apt-get update、curl最新版、git clone)

RUN apt-get updatecurl で最新版を取得、git clone でブランチを取る、などは「外部の状態に依存」します。Dockerfile上は同じコマンドでも、実行タイミングによって取得物が変わり、結果が再現しにくくなります。

この手の命令は、キャッシュが効いたとしても「古いパッケージ情報を使い続ける」リスクがありますし、逆にキャッシュが効かない運用(--no-cacheやベース更新)だと毎回遅くなります。再現性と更新頻度のバランスを考え、バージョン固定やベースイメージ側での更新運用に寄せるのが現実解です。

4. “崩れ方”を目で見る:ビルドログの読み方

4-1. --progress=plain でどこが再実行されたかを見る

キャッシュが崩れているかを調べる一番簡単な方法は、ビルドログを“詳細表示”で見ることです。BuildKitを使っている場合、--progress=plain を付けると、どのステップがキャッシュヒットしたか(または再実行されたか)が分かりやすく表示されます。

単に「遅い」と感じたら、まずこのオプションでログを取り、「どの命令が再実行されているか」を確認します。目視できるようになるだけで、「COPYが原因で崩れてるな」「RUNのこの行が毎回走ってるな」という当たりがつけられるようになります。

4-2. どの命令から無効化されたかを特定する手順

切り分けはシンプルで、Dockerfileの上から順に見ていき、「最初にキャッシュが外れた命令」を探します。そこが原因箇所であり、そこ以降が全部巻き添えで再実行されていることが多いです。

原因箇所が COPY なら「コピー範囲が広すぎないか」「.dockerignoreが弱くないか」を疑います。原因箇所が RUN なら「外部依存がないか」「コマンド文字列に毎回変わる値(ビルド日時など)を混ぜていないか」を疑う、という形で原因に辿りつけます。

4-3. 計測のコツ:コンテキストサイズ・依存解決・ネットワーク待ちを分ける

ビルド時間を改善するときは、「どこに時間が使われているか」を分けて見るのがコツです。特に重要なのは、コンテキスト送信(ビルドコンテキストの転送)と、依存解決(npm/pip/apt)と、ネットワーク待ちです。

ローカルでは速いのにCIで遅い場合、コンテキスト転送が原因のこともあります(巨大なリポジトリを毎回送っているなど)。依存解決が原因ならDockerfileの順序や--mount=type=cacheが効きます。ネットワーク待ちが原因なら、ミラーやキャッシュ、レジストリ配置の工夫が必要になります。

5. キャッシュを効かせるDockerfileの型(言語別に応用できる)

5-1. 原則:「変わりにくいものを先、変わりやすいものを後」

キャッシュを効かせる基本は「変わりにくいものを先に、変わりやすいものを後に」です。依存定義ファイルは変更頻度が低いので先にコピーし、依存インストールを先に済ませます。アプリのソースコードは変更頻度が高いので後でコピーします。

この順序にすることで、「ソースだけ変えた」ケースでは依存インストールのレイヤが再利用され、ビルドがかなり速くなります。逆にこの順序が逆だと、ソース変更が依存インストールまで巻き込んで崩れます。

5-2. Node例:package*.jsonnpm ciCOPY . .

FROM node:20

WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci

COPY . .
RUN npm run build

このDockerfileでは、まず依存定義(package.jsonpackage-lock.json)だけをコピーしてnpm ciを実行し、その後にソース全体をコピーしています。こうしておくと、ソースを変更しても依存定義が変わらない限り、npm ciのレイヤキャッシュが再利用されるため、ビルドが安定して速くなります。

さらに改善するなら、.dockerignorenode_modulesdistを除外し、COPY . .の入力を安定させるのが効果的です。依存を入れた後に余計なファイルが混ざると、それだけでキャッシュが崩れやすくなります。

5-3. Python例:requirements.txtpip installCOPY . .

FROM python:3.12

WORKDIR /app

COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

COPY . .
CMD ["python", "app.py"]

このDockerfileでは、依存ファイル(requirements.txt)だけを先にコピーしてインストールし、あとからソースをコピーしています。これにより、アプリコードの変更ではpipインストールがやり直しになりにくくなります。

Pythonは依存が重いプロジェクトほど効果が出やすいです。逆に依存の指定が緩いと、外部状態に依存してビルド結果が変わりやすくなるので、バージョン固定やロックファイル運用もセットで考えると再現性が上がります。

5-4. Go例:go.mod go.sumgo mod downloadCOPY . .

FROM golang:1.22 AS builder

WORKDIR /src

COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN go build -o app ./...

FROM gcr.io/distroless/base-debian12
COPY --from=builder /src/app /app
CMD ["/app"]

このDockerfileでは、go.mod/go.sumで依存を先に固定し、go mod downloadをキャッシュしやすい位置に置いています。その後にソースをコピーしてビルドするため、コード変更だけなら依存ダウンロードが再実行されにくくなります。

また、マルチステージでbuilderとruntimeを分けることで、最終イメージを小さくしつつ、キャッシュ境界も作りやすくしています。Goはビルド成果物が単体バイナリになりやすいので、マルチステージのメリットが出やすい代表例です。

6. BuildKitで強化する:レイヤ以外のキャッシュ

6-1. --mount=type=cacheでパッケージ/ビルドキャッシュを保持

BuildKitを使えるなら、レイヤキャッシュだけでなく「ツールのキャッシュ」を保持できるのが強みです。RUN --mount=type=cacheで、npm/pip/aptなどのキャッシュディレクトリを永続化できます。

# syntax=docker/dockerfile:1.7-labs
FROM ubuntu:24.04

RUN --mount=type=cache,target=/var/cache/apt \
    apt-get update && apt-get install -y curl

このDockerfileでは、aptのキャッシュをビルド間で保持することで、次回以降のダウンロード量を減らす狙いがあります。レイヤキャッシュが崩れたとしても、ツールキャッシュが残っていれば「再ダウンロードの時間」を短縮できるため、CIなどで効果が出やすいです。

ただし、キャッシュがあることで「古い状態が残る」リスクもあるため、更新ポリシー(いつ更新を強制するか)とセットで運用するのが現実的です。

6-2. CIで効かせる remote cache(概念だけ押さえる)

CIで「毎回ゼロから」になりがちなのを解決するのが remote cache です。BuildKit/buildxではcache-tocache-fromを使い、キャッシュをレジストリやCIのキャッシュに保存して、次回のビルドで復元できます。

ローカルでは速いのにCIで遅い場合、だいたい「CIがキャッシュを持ってない」ことが原因です。まずはCIがキャッシュを保存できる設定になっているかを確認し、必要ならレジストリキャッシュやGitHub Actionsのキャッシュを導入するのが近道です。

6-3. “キャッシュと再現性”のバランス(運用ルール)

キャッシュを効かせるほどビルドは速くなりますが、再現性・セキュリティとのバランスも必要です。例えば、ベースイメージや依存パッケージの脆弱性対応を考えると、どこかのタイミングでキャッシュを捨てて更新を取り込みたい場面が出てきます。

現場では「通常ビルドはキャッシュ優先」「定期的なセキュリティビルドは--pullでベース更新」など、ルールを分けることが多いです。キャッシュを“効かせる”だけでなく、“いつ壊すか”も運用として決めておくと、速度と安全性を両立しやすくなります。

7. まとめ

7-1. レイヤキャッシュの本質:どの命令の入力が変わったか

レイヤキャッシュの本質は「どの命令の入力が変わったか」を特定できるかに尽きます。入力が変わった命令が見つかれば、そこから先が崩れる理由も、対策の方向性も見えてきます。

「なぜ毎回npm installが走る?」と悩んだら、まずはビルドログを見て、最初にキャッシュが外れた命令を探す。これが最短ルートです。

7-2. 効かせる最短手:Dockerfile順序+.dockerignore+外部依存RUN削減

最短で効く改善は次の3つです。

  • Dockerfileの順序を直す(依存→ソース)
  • .dockerignoreを強くする(余計な変化を入れない)
  • 外部状態に依存するRUNを減らす(固定する、レイヤを分ける)

これだけで、多くのプロジェクトで「毎回遅い」状態から脱出できます。

7-3. CI最適化は次:remote cacheで“毎回ゼロから”を終わらせる

ローカルの改善ができたら、次はCIです。CIは毎回クリーンなので、remote cacheを入れない限り「毎回ゼロから」の傾向が残ります。buildxやCIのビルドアクションを使ってキャッシュ保存先を用意し、次回以降のビルドで再利用できるようにすると、効果が大きいです。

レイヤキャッシュを理解して、崩れ方を読めるようになれば、BuildKitの機能も狙い通りに使えるようになります。まずは「崩れる原因を特定できる」状態を作るところから始めてみてください。

8. 参考リンク