
Docker イメージのレイヤーとビルドキャッシュの仕組み - キャッシュが効かない原因をOverlayFSとOCIから理解する
当サイトは Amazon.co.jp を宣伝しリンクすることで紹介料を得る手段を提供する、Amazonアソシエイト・プログラムの参加者です。価格・在庫はリンク先の最新情報をご確認ください。
Dockerfileのアプリのコードを1行直しただけなのに、npm installやpip installが毎回最初から走ってビルドに数分かかる。逆に、apt-get installで入れたパッケージがいつまでも古いまま更新されない。どちらも「Dockerのキャッシュがおかしい」と言われがちですが、実際にはキャッシュは決まったルールどおりに動いています。
そのルールは、Dockerイメージがレイヤーの積み重ねでできていることと、ビルダー(BuildKit)が命令ごとに「前回と同じか」を判定していることから決まります。この2つが分かると、キャッシュが効かない理由も、効きすぎる理由も、rmしたのにイメージが小さくならない理由も、同じ考え方で説明できます。
NOTE
仕様は2026年11月11日に、Docker公式ドキュメントのBuild cache invalidation、OverlayFS storage driver、Cache storage backends、GitHub Actions cache、LinuxカーネルのOverlay Filesystem、OCI image-specで確認しました。実行結果は、macOS(Apple Silicon)上のDocker Desktop 4.91.0、Docker Engine 29.8.0、Buildx v0.37.0、BuildKit v0.33.0(ビルダーは既定のdockerドライバー)、ストレージドライバーoverlay2で実際に取得したものです。ログは読みやすさのため、関係する行だけを抜き出しています。
先に結論
- Dockerイメージは読み取り専用のレイヤーを積み重ねたものです。コンテナを起動すると、その上に書き込み可能な層が1枚載り、OverlayFSで1つのファイルシステムに見せます。
- レイヤーは差分のtarアーカイブで、sha256のダイジェストで識別されます。中身が同じなら同じダイジェストになり、別のイメージ間でも共有されます。
- ビルドキャッシュはDockerfileの上から順に照合されます。1つでも一致しない命令があると、それ以降の命令はすべて作り直しになります。
COPYとADDはコピーするファイルの内容(チェックサム)で判定します。更新日時(mtime)だけが変わっても無効化されません。RUNはコマンド文字列で判定します。コマンドが同じなら、外の世界(パッケージリポジトリなど)が変わっていても前回の結果を使い続けます。- 後のレイヤーで
rmしても、前のレイヤーにはファイルが残ります。容量も減らず、秘密情報も抜き出せます。秘密情報はRUN --mount=type=secretで渡します。
キャッシュが効かない、または効きすぎるときの典型的な原因は次のとおりです。
| 症状 | よくある原因 | 対処 |
|---|---|---|
| コードを直すたびに依存パッケージのインストールが走る | COPY . .をインストールより前に置いている | 依存の定義ファイルだけ先にCOPYし、インストール後に残りをCOPYする |
何も変えていないのにCOPY . .が毎回走る | ログやnode_modules、.gitがビルドコンテキストに入っている | .dockerignoreで除外する |
| パッケージが古いまま、または404で失敗する | apt-get updateを別のRUNに分けている | apt-get update && apt-get installを1つのRUNにまとめる |
| ビルド引数を変えると後ろが全部作り直しになる | ARGを冒頭で宣言している | ARGは使う直前で宣言する |
| CIでは毎回キャッシュが効かない | ランナーが毎回まっさらで、ローカルのキャッシュが無い | --cache-fromと--cache-toでレジストリやGitHub Actionsのキャッシュを使う |
イメージはレイヤーの積み重ね
読み取り専用のレイヤーと、コンテナの書き込み層
Dockerイメージは、ファイルシステムの差分を順番に積み重ねたものです。ベースイメージ(例えばalpine)のファイル一式が一番下の層になり、RUN apk add ...で増えたファイルが次の層、COPYでコピーしたファイルがさらに次の層、というように重なっていきます。
イメージのレイヤーはすべて読み取り専用です。コンテナを起動すると、Dockerはその上にコンテナ専用の書き込み層を1枚追加します。コンテナの中でファイルを作ったり書き換えたりすると、その変更はすべてこの書き込み層に入ります。
+---------------------------------------+
| container layer (read-write) | <- コンテナごとに1枚。削除すると消える
+---------------------------------------+
| layer 4: COPY . . |
+---------------------------------------+
| layer 3: RUN npm ci | <- ここから下は読み取り専用
+---------------------------------------+ 同じイメージから起動した
| layer 2: COPY package.json ... | すべてのコンテナで共有される
+---------------------------------------+
| layer 1: base image (alpine + node) |
+---------------------------------------+同じイメージから10個のコンテナを起動しても、読み取り専用の層は1組だけで、増えるのは各コンテナの書き込み層だけです。コンテナの起動が速く、ディスクもあまり消費しないのはこのためです。
コンテナそのものがLinuxのnamespaceとcgroupsで作られていることはコンテナの仕組みの記事で扱いました。この記事は、そのコンテナに見せる「ファイルシステムの中身」の話です。
OverlayFS: lowerdir、upperdir、merged
複数の層を1つのファイルシステムに見せているのが、LinuxカーネルのOverlayFSです。Dockerのoverlay2ドライバーのドキュメントによると、役割は次のように分かれています。
| ディレクトリ | 役割 |
|---|---|
lowerdir | 下の層。イメージの読み取り専用レイヤー。コロン区切りで複数指定できる |
upperdir | 上の層。コンテナの書き込み層 |
merged | 上下を合成した見え方。コンテナからはこれが/に見える |
workdir | OverlayFSが内部で使う作業用ディレクトリ |
カーネルのドキュメントには、lowerdirを複数指定したときは右から順に積み上がる(一番右が一番下の層になる)ことと、workdirはupperdirと同じファイルシステム上に置く必要があることが書かれています。overlay2ドライバーは、下の層を最大128個まで扱えます。
実際にコンテナの中から/のマウント情報を見てみます。
docker run -d --name ovl-demo alpine:3.22 sleep 300
docker exec ovl-demo sh -c 'grep " / " /proc/mounts | tr "," "\n"'overlay / overlay rw
relatime
lowerdir=/var/lib/docker/overlay2/l/HP4LS3LHAW7B36PP6D24U7ELDF:/var/lib/docker/overlay2/l/JRFM5NQDO736FCSMDB3O6US2HZ
upperdir=/var/lib/docker/overlay2/c68ffcd3dfa2.../diff
workdir=/var/lib/docker/overlay2/c68ffcd3dfa2.../work
nouserxattr 0 0lowerdirのl/HP4LS...のような短い名前は、各レイヤーのdiffディレクトリへのシンボリックリンクです。レイヤーが増えるとmountに渡す文字列が長くなりすぎるので、短い名前に置き換えています。docker inspectで見ると、元のパスが分かります。
docker inspect ovl-demo --format '{{json .GraphDriver}}'{
"Data": {
"LowerDir": "/var/lib/docker/overlay2/c68ffcd3dfa2...-init/diff:/var/lib/docker/overlay2/dce5c58964b5.../diff",
"MergedDir": "/var/lib/docker/overlay2/c68ffcd3dfa2.../merged",
"UpperDir": "/var/lib/docker/overlay2/c68ffcd3dfa2.../diff",
"WorkDir": "/var/lib/docker/overlay2/c68ffcd3dfa2.../work"
},
"Name": "overlay2"
}alpine:3.22はレイヤーが1枚のイメージなので、LowerDirはそのレイヤー(dce5c...)と、Dockerがコンテナごとに付け足す-initという層の2つです。UpperDirがこのコンテナの書き込み層です。
NOTE
Docker Engineで「containerdイメージストア」を有効にしている場合、イメージの保存にはoverlay2ドライバーではなくcontainerdのoverlayfsスナップショッターが使われ、ディスク上のパスが変わります。OverlayFSで層を重ねる考え方は同じです。本記事の実行環境はdocker infoのStorage Driverがoverlay2の構成です。
copy-upとwhiteout
読み取り専用の層にあるファイルを、コンテナの中で書き換えたり消したりするとどうなるでしょうか。
書き換えるときは、OverlayFSがまずファイルを下の層から上の層(upperdir)へ丸ごとコピーし、コピーした方を書き換えます。これをcopy-upと呼びます。Dockerのドキュメントには「copy_upは、ファイルが大きくてもファイル全体をコピーする」とあります。数GBのデータベースファイルの1バイトを書き換えただけでも、最初の1回は数GBのコピーが発生します。コンテナの中で頻繁に書き換えるデータをボリュームに置くべき理由の1つです。
消すときは、下の層のファイルそのものは消せないので、上の層にwhiteoutという目印を置いて「このファイルは無いことにする」と表現します。カーネルのドキュメントによると、whiteoutはデバイス番号0/0のキャラクターデバイスとして作られます。ディレクトリを丸ごと消した場合は、trusted.overlay.opaqueという拡張属性をyにしたopaqueディレクトリを置き、下の層の中身をまとめて隠します。
実際に、コンテナの中でファイルを書き換え、消し、追加してみます。
docker exec ovl-demo sh -c 'echo hi >> /etc/motd; rm /etc/issue; mkdir /data && echo x > /data/new.txt'
docker diff ovl-demoC /etc
C /etc/motd
D /etc/issue
A /data
A /data/new.txtCは変更、Dは削除、Aは追加です。書き込み層(upperdir)の中身を直接のぞくと、これがどう表現されているかが見えます。Docker DesktopではLinuxはVMの中で動いているので、特権コンテナからVMのマウント名前空間に入って確認しました。
UP=$(docker inspect ovl-demo --format '{{.GraphDriver.Data.UpperDir}}')
docker run --rm --privileged --pid=host alpine:3.22 \
nsenter -t 1 -m -- ls -lR "$UP".../diff/data:
-rw-r--r-- 1 root root 2 Nov 10 20:05 new.txt
.../diff/etc:
c--------- 2 root root 0, 0 Nov 10 20:05 issue
-rw-r--r-- 1 root root 287 Nov 10 20:05 motdmotdはcopy-upされて上の層に丸ごと存在し、issueは先頭がc(キャラクターデバイス)でデバイス番号が0, 0のwhiteoutになっています。/etc/issueの本体は下の層にそのまま残っていて、上から隠されているだけです。この「消したのではなく隠しただけ」という性質が、後で説明する「rmしても容量が減らない」問題の根っこです。
ファイルの実体やinodeの話はファイルシステムの記事で詳しく扱っています。
docker rm -f ovl-demoOCIイメージの中身: index、manifest、config、layer
docker saveで中身を取り出す
イメージを配布するときの形式は、OCI(Open Container Initiative)のimage-specで決まっています。Docker Engine 29.8.0のdocker saveは、OCIのレイアウトでイメージを書き出しました。後で使うlayer-demo:goodイメージを保存して展開してみます。
mkdir -p save/x
docker save layer-demo:good -o save/img.tar
tar -xf save/img.tar -C save/x
ls save/xblobs
index.json
manifest.json
oci-layout
repositoriesblobs/sha256/の下に、すべてのデータが中身のsha256をファイル名にして入っています。入口になるのがindex.jsonです。
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": [
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:24556f01fe539c1d45e7b7c56c15877e8597accf8bd33450f428cc9c6e0100e9",
"size": 1462,
"annotations": {
"org.opencontainers.image.ref.name": "good"
}
}
]
}digestが指す先がmanifestです。
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": {
"mediaType": "application/vnd.oci.image.config.v1+json",
"digest": "sha256:d30789f00943e7d2b4586fdaa4774e68ca9a4ab4cc88761ec5a1814ccab62b84",
"size": 7638
},
"layers": [
{ "mediaType": "application/vnd.oci.image.layer.v1.tar",
"digest": "sha256:3d971ddbca23...", "size": 8939008 },
{ "mediaType": "application/vnd.oci.image.layer.v1.tar",
"digest": "sha256:fb95c70bc045...", "size": 155090432 },
...
{ "mediaType": "application/vnd.oci.image.layer.v1.tar",
"digest": "sha256:7d5cd10aada9...", "size": 6656 }
]
}manifestは「このイメージは、このconfigと、この順番のレイヤーでできている」という目録です。ファイル名とダイジェストが一致していることは、自分で計算すれば確かめられます。
shasum -a 256 save/x/blobs/sha256/24556f01fe539c1d45e7b7c56c15877e8597accf8bd33450f428cc9c6e0100e924556f01fe539c1d45e7b7c56c15877e8597accf8bd33450f428cc9c6e0100e9 save/x/blobs/sha256/24556f01...3種類のデータの役割
| データ | 中身 | 何で識別されるか |
|---|---|---|
| manifest | configとレイヤーのダイジェスト、サイズ、メディアタイプの一覧 | manifest自身のsha256 |
| config | 環境変数、CMD、ENTRYPOINT、作業ディレクトリ、ビルド履歴、rootfs.diff_ids | config自身のsha256。Dockerの「イメージID」はこれ |
| layer | ファイルシステムの差分をtarにしたもの(gzipやzstdで圧縮されることもある) | tarのsha256 |
configの中身を見ると、ENVやCMD、WORKDIRの設定は、ファイルではなくこのJSONに書かれていることが分かります。
"config": {
"Env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"NODE_VERSION=24.22.2",
"YARN_VERSION=1.22.22"
],
"Entrypoint": ["docker-entrypoint.sh"],
"Cmd": ["node", "app.js"],
"WorkingDir": "/app"
},
"rootfs": { "type": "layers", "diff_ids": [ "sha256:3d971ddbca23...", ... ] }docker image lsに出るイメージID d30789f00943は、configのダイジェストの先頭12桁です。
DiffIDとレイヤーのダイジェストの違い
configのrootfs.diff_idsには、各レイヤーの圧縮していないtarのダイジェスト(DiffID)が並びます。一方、manifestのlayersに書かれるダイジェストは、実際に配布されるデータ(圧縮されていれば圧縮後のデータ)のものです。config.mdも、この2つを混同しないよう注意しています。
今回のdocker saveはレイヤーを非圧縮のtar(application/vnd.oci.image.layer.v1.tar)で書き出したので、両者は同じ値になりました。レジストリにdocker pushするときは通常gzipで圧縮されるので、manifestのダイジェストとDiffIDは別の値になります。
content-addressableであることの意味
中身のハッシュ値で物を識別する方式を、content-addressableと呼びます。Gitがコミットやファイルをsha1やsha256で管理しているのと同じ考え方で、Gitの内部構造の記事で扱った仕組みとよく似ています。manifestがconfigとレイヤーのハッシュを並べ、そのmanifestもハッシュで指されるという構造は、マークルツリーと同じです。
この方式から、次の性質が生まれます。
- 中身が1バイトでも違えばダイジェストが変わるので、ダウンロードしたレイヤーが壊れていないか、すり替えられていないかを検証できる
- 中身が同じレイヤーは同じダイジェストになるので、
node:24-alpineをベースにしたイメージが10個あっても、ベース部分のレイヤーはディスクに1組だけ置かれ、docker pullでも取得済みのレイヤーはAlready existsとして飛ばされる image@sha256:...のようにダイジェストで指定すれば、タグが別のイメージに付け替えられても、同じ中身を確実に取得できる
レイヤーのtarの中身
レイヤーは、その命令で増えた・変わった・消えたファイルだけを入れたtarです。layer.mdでは、削除を表すwhiteoutは、消したファイル名に.wh.を付けた空ファイルとして記録すると決められています。ディレクトリの中身をまとめて隠すopaque whiteoutは.wh..wh..opqです。OverlayFSのキャラクターデバイスとは表現が違いますが、意味は同じで、イメージを展開するときに各ランタイムが自分の表現に変換します。
この.wh.ファイルは、後の「rmしても容量が減らない」の節で実物を見ます。
どの命令がレイヤーを作るか
Dockerfileの命令には、ファイルシステムを変える命令と、configのメタデータだけを変える命令があります。docker historyで、各命令が作った層のサイズを見られます。
docker history layer-demo:goodIMAGE CREATED CREATED BY SIZE COMMENT
d30789f00943 9 seconds ago CMD ["node" "app.js"] 0B buildkit.dockerfile.v0
<missing> 9 seconds ago COPY . . # buildkit 870B buildkit.dockerfile.v0
<missing> 10 seconds ago RUN /bin/sh -c npm ci # buildkit 1.4MB buildkit.dockerfile.v0
<missing> 10 seconds ago COPY package.json package-lock.json ./ # bui… 595B buildkit.dockerfile.v0
<missing> 27 seconds ago WORKDIR /app 0B buildkit.dockerfile.v0
<missing> 5 days ago CMD ["node"] 0B buildkit.dockerfile.v0
<missing> 5 days ago ENTRYPOINT ["docker-entrypoint.sh"] 0B buildkit.dockerfile.v0
<missing> 5 days ago COPY docker-entrypoint.sh /usr/local/bin/ # … 388B buildkit.dockerfile.v0
<missing> 5 days ago RUN /bin/sh -c apk add --no-cache --virtual … 5.37MB buildkit.dockerfile.v0
<missing> 5 days ago ENV YARN_VERSION=1.22.22 0B buildkit.dockerfile.v0
<missing> 5 days ago RUN /bin/sh -c addgroup -g 1000 node && … 153MB buildkit.dockerfile.v0
<missing> 5 days ago ENV NODE_VERSION=24.22.2 0B buildkit.dockerfile.v0
<missing> 5 days ago CMD ["/bin/sh"] 0B buildkit.dockerfile.v0
<missing> 5 days ago ADD alpine-minirootfs-3.24.4-aarch64.tar.gz … 8.66MB buildkit.dockerfile.v0下から読むと、node:24-alpine自体がAlpineのrootfsをADDし、RUNでNode.jsを入れたイメージだと分かります。IMAGE列の<missing>は、BuildKitでビルドしたイメージでは途中の層に個別のイメージIDが付かないという意味で、データが欠けているわけではありません。
configのhistoryを見ると、メタデータだけの命令には"empty_layer": trueが付いています。
{"created_by": "CMD [\"node\"]", "empty_layer": true}
{"created_by": "WORKDIR /app"}
{"created_by": "COPY package.json package-lock.json ./ # buildkit"}
{"created_by": "RUN /bin/sh -c npm ci # buildkit"}
{"created_by": "COPY . . # buildkit"}
{"created_by": "CMD [\"node\" \"app.js\"]", "empty_layer": true}まとめると次のようになります。
| 命令 | レイヤーを作るか | 補足 |
|---|---|---|
RUN | 作る | 実行後のファイルシステムの差分が層になる。何も変えなければ0Bの層 |
COPY、ADD | 作る | コピーしたファイルが層になる |
WORKDIR | ディレクトリが無ければ作る | 今回の実行環境では/appディレクトリ1つだけを含む1536バイトの層ができた |
ENV、ARG、CMD、ENTRYPOINT、EXPOSE、LABEL、USERなど | 作らない | configのJSONを書き換えるだけ |
WORKDIRの行は、docker historyでは0Bと表示されますが、manifestにはこの層が含まれていました。保存したtarの中身を見ると、app/というディレクトリのエントリが1つだけ入っています。中身がディレクトリ1つなので、表示上は0Bに丸められています。
tar -tvf save/x/blobs/sha256/bd10069479fa1b056481371109369e4c78cf94c5da555773d78a2bf461f6c3a8drwxr-xr-x 0 0 0 0 Nov 11 05:02 app/レイヤーの数を減らすためにRUNを&&でつなぐ書き方がよく紹介されますが、本当に効くのは「数」より「中身」です。後で見るように、1つのRUNの中で作って消したファイルはどの層にも残りませんが、別々のRUNに分けると前の層に残ります。
BuildKitのキャッシュ判定
上から順に照合し、外れたら以降はすべて作り直し
docker buildは、現在の既定のビルダーであるBuildKitがビルドを行います。BuildKitは、Dockerfileの各命令について「前回のビルドと同じ入力か」を調べ、同じならその命令を実行せずに前回の結果の層を再利用します。ビルドログにCACHEDと出るのがこの状態です。
Build cache invalidationには、ベースイメージから順に命令を照合し、一致するキャッシュ層が無ければそこでキャッシュが無効になること、そして「キャッシュが無効になると、それ以降のDockerfileの命令はすべて新しく実行され、キャッシュは使われない」ことが書かれています。
FROM node:24-alpine CACHED
WORKDIR /app CACHED
COPY . . <- app.jsが変わったので一致しない
RUN npm install <- 内容は前回と同じでも再実行される
CMD ["node", "app.js"] <- メタデータも作り直しある層は「その下の層の上で、その命令を実行した結果」なので、下の層が変われば上の層も前回の結果を使えません。だからこそ、変わりにくいものを上に、変わりやすいものを下に書くのがキャッシュを効かせる基本になります。
命令ごとの判定の基準は次のとおりです。
| 命令 | キャッシュの判定に使うもの | 判定に使わないもの |
|---|---|---|
RUN | コマンド文字列(ビルド引数の値も含む) | コマンドが外部から取ってくるデータ |
COPY、ADD(ローカルファイル) | コピーするファイルの内容とメタデータのチェックサム | ファイルの更新日時(mtime) |
RUN --mount=type=bind | マウントするファイルのチェックサム(COPYと同じ) | mtime |
RUN --mount=type=secret | secretのIDとマウント先 | secretの値 |
COPYとADDはファイルの内容で判定する
COPYとADDは、コピーするファイルのチェックサムで判定します。公式ドキュメントには「ファイルの更新日時(mtime)はキャッシュのチェックサムの計算に含まれない」と明記されています。
実際に、touchで更新日時だけを変えた場合と、中身を書き換えた場合を比べます。
touch app.js
docker buildx build --progress=plain -t layer-demo:bad .
sed -i '' 's/hello/hello v2/' app.js
docker buildx build --progress=plain -t layer-demo:bad .#6 [2/4] WORKDIR /app
#6 CACHED
#7 [3/4] COPY . .
#7 CACHED
#8 [4/4] RUN npm install
#8 CACHED#6 [2/4] WORKDIR /app
#6 CACHED
#7 [3/4] COPY . .
#7 DONE 0.0s
#8 [4/4] RUN npm install
#8 0.444 added 1 package, and audited 2 packages in 335ms
#8 DONE 0.5sgit cloneし直したりCIでチェックアウトしたりすると、ファイルの更新日時はすべて変わります。それでも中身が同じならキャッシュが効くのは、mtimeを見ていないからです。
RUNはコマンド文字列で判定する
RUNのキャッシュは、コマンドの文字列で判定します。公式ドキュメントにも「RUN命令のキャッシュは、ビルドの間で自動的に無効化されない」とあります。BuildKitは、コマンドが中で何をするかまでは見ていません。
そのため、RUN apt-get updateやRUN git clone https://...、RUN curl -o tool.tar.gz https://.../latestのような命令は、外のデータが更新されていても、コマンドの文字列が同じで、上の層も同じならCACHEDになり、前回取ってきた古いデータのまま使われます。
逆に言えば、コマンドの文字列を少しでも変えれば再実行されます。インストールするパッケージを1つ追加しただけで、そのRUN以降は全部作り直しになります。
ARGは後ろのRUNをすべて無効化する
ビルド引数(--build-arg)の値を変えると、その値を使う命令のキャッシュが無効になります。ここで注意したいのは、ARGを宣言した後のRUNは、その引数を参照していなくても無効化されたことです。
FROM alpine:3.22
RUN echo step-before-arg
ARG APP_VERSION=1.0
COPY Dockerfile /tmp/df
RUN echo "not using the arg"docker buildx build --build-arg APP_VERSION=1.0 -t argdemo .
docker buildx build --progress=plain --build-arg APP_VERSION=2.0 -t argdemo .#6 [2/4] RUN echo step-before-arg
#6 CACHED
#7 [3/4] COPY Dockerfile /tmp/df
#7 CACHED
#8 [4/4] RUN echo "not using the arg"
#8 DONE 0.1sARGより前のRUNと、ARGの後でもCOPYはキャッシュが使われました。一方、最後のRUNはAPP_VERSIONを一切使っていないのに再実行されています。ARGで宣言した値は、それ以降のRUNの実行時に環境変数として渡されるため、値が変わるとRUNの入力が変わったと判定されます。
対策は、ARGをファイルの冒頭にまとめて書かず、本当に使う命令の直前で宣言することです。ビルド日時やGitのコミットハッシュのように、毎回変わる値をビルド引数で渡す場合は特に効きます。
secretの値は無効化の対象にならない
RUN --mount=type=secretで渡したsecretの値は、キャッシュの判定に使われません。公式ドキュメントには「secretの値を変えてもキャッシュは無効化されない」とあり、実際に値を変えてビルドしてもCACHEDになりました。secretのIDやマウント先を変えた場合は無効化されます。
secretを更新したら必ず再実行したい場合は、ドキュメントではCACHEBUSTのようなビルド引数を用意し、その値を変えて無効化する方法が紹介されています。
悪いDockerfileと良いDockerfile
Node.jsの小さなアプリで、ありがちな書き方と、キャッシュが効く書き方を比べます。依存パッケージはmsを1つだけ入れています。
悪い例: COPY . . を先に置く
FROM node:24-alpine
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "app.js"]app.jsを1行直してビルドすると、次のようになります。
#4 [1/4] FROM docker.io/library/node:24-alpine
#6 [2/4] WORKDIR /app
#6 CACHED
#7 [3/4] COPY . .
#7 DONE 0.0s
#8 [4/4] RUN npm install
#8 0.444 added 1 package, and audited 2 packages in 335ms
#8 DONE 0.5sCOPY . .はプロジェクト全体をコピーするので、どのファイルを変えてもこの層が変わります。その結果、依存パッケージは何も変わっていないのにnpm installが再実行されます。この例は依存が1つなので0.5秒ですが、実際のアプリでは数分かかることも珍しくありません。
良い例: 依存の定義を先にCOPYする
# syntax=docker/dockerfile:1
FROM node:24-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
CMD ["node", "app.js"]node_modules
.git
*.log
.env依存パッケージを決めるファイル(package.jsonとpackage-lock.json)だけを先にコピーしてインストールし、アプリのコードはその後でコピーします。app.jsを直してビルドすると、次のようになります。
#8 [stage-0 2/5] WORKDIR /app
#8 CACHED
#9 [stage-0 3/5] COPY package.json package-lock.json ./
#9 CACHED
#10 [stage-0 4/5] RUN --mount=type=cache,target=/root/.npm npm ci
#10 CACHED
#11 [stage-0 5/5] COPY . .
#11 DONE 0.1snpm ciがCACHEDになり、作り直されるのは最後のCOPY . .だけです。package-lock.jsonを変えたときだけnpm ciが再実行されます。Pythonならrequirements.txtやpyproject.toml、Goならgo.modとgo.sum、PHPならcomposer.jsonとcomposer.lockというように、どの言語でも同じ考え方が使えます。
先頭の# syntax=docker/dockerfile:1は、Dockerfileの構文を解釈するフロントエンドのバージョンを指定する行です。RUN --mountのような比較的新しい構文を使うときに書いておくと、手元のDockerに同梱されたフロントエンドより新しいものを使えます。
キャッシュが効かない・効きすぎる典型的な原因
.dockerignoreが無く、ビルドコンテキストが大きい
docker build .の.はビルドコンテキストで、このディレクトリの中身がビルダーに送られます。COPY . .はこのコンテキストをまるごとコピーするので、ログファイルのように毎回変わるファイルが入っていると、それだけでキャッシュが外れます。
20MBのログファイルdebug.logがある状態でビルドしてみます。
#5 [internal] load build context
#5 transferring context: 20.98MB 0.2s done
#6 [2/4] WORKDIR /app
#6 CACHED
#7 [3/4] COPY . .
#8 [4/4] RUN npm install.dockerignoreに*.logを書くと、ログはコンテキストに含まれなくなります。ログに追記してからビルドし直しても、COPY . .はCACHEDのままです。
#5 [internal] load build context
#5 transferring context: 119B 0.0s done
#6 [2/4] WORKDIR /app
#6 CACHED
#7 [3/4] COPY . .
#7 CACHED
#8 [4/4] RUN npm install
#8 CACHED送るデータが20.98MBから119Bに減り、キャッシュも効くようになりました。.git、node_modules、ビルドの成果物、.envなどは、ほぼ必ず除外しておくべきものです。.envを除外しておくことは、秘密情報をイメージに入れてしまう事故の予防にもなります(環境変数と.envの記事も参照してください)。
apt-get updateを別のRUNにしている
キャッシュが効きすぎて困る代表例です。
FROM debian:trixie-slim
RUN apt-get update
RUN apt-get install -y curl1回目のビルドでapt-get updateの結果(パッケージの一覧)が層として保存されます。その後、何か月か経ってからRUN apt-get install -y curl gitのように2行目だけを変えても、1行目は文字列が同じなのでCACHEDになり、何か月も前のパッケージ一覧のままinstallが走ります。リポジトリ側では古いバージョンのファイルが消えていることがあり、404で失敗したり、古いバージョンが入ったりします。
Dockerfileのベストプラクティスでも、apt-get updateとapt-get installは同じRUNにまとめるよう勧めています。
FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
git \
&& rm -rf /var/lib/apt/lists/*こうすると、インストールするパッケージを変えたときはupdateも一緒に再実行されます。最後のrm -rf /var/lib/apt/lists/*はパッケージ一覧を消してイメージを小さくするためのもので、同じRUNの中で消しているので層に残りません。
毎回変わる値を早い段階で使っている
ARGの節で見たとおり、ビルド日時やコミットハッシュを冒頭のARGで受け取ると、それ以降のRUNはすべて毎回作り直しになります。同じことは、ビルドのたびに中身が変わるファイル(ビルド日時を埋め込んだ設定ファイルなど)を早い段階でCOPYした場合にも起きます。毎回変わるものは、Dockerfileのできるだけ後ろに置きます。
なお、公式ドキュメントによるとWORKDIRはSOURCE_DATE_EPOCHを参照するので、この値を変えるとWORKDIR以降のキャッシュが無効になります。再現可能なビルドのためにSOURCE_DATE_EPOCHを設定する場合は、毎回同じ値になるようにしておく必要があります。
ベースイメージのタグが更新された
FROM node:24-alpineのタグは、新しいパッチリリースが出ると別のイメージに付け替えられます。手元にあるイメージを使い続ける限り結果は変わりませんが、docker build --pullで最新を取得したり、CIで毎回取得したりすると、ベースイメージの層が変わり、全部が作り直しになります。
これは「キャッシュが効かない」というより正しい動作です。ベースイメージを固定したいならnode:24-alpine@sha256:...のようにダイジェストで指定し、更新は意図したタイミングで行います。
わざと作り直したいとき
逆に、キャッシュを使わずに作り直したい場合は次の方法があります。
| 方法 | 効果 |
|---|---|
docker build --no-cache | すべての命令をキャッシュなしで実行する |
docker build --no-cache-filter <stage名> | 指定したステージだけキャッシュを使わない |
| 前の層を変える(ビルド引数を変えるなど) | その層から後ろを作り直す |
docker builder prune | ビルドキャッシュそのものを削除する |
RUN --mount=type=cache: 層とは別のキャッシュ
レイヤーのキャッシュは「層ごと使うか、全部作り直すか」の二択です。package-lock.jsonに1行足しただけでも、npm ciは全パッケージをダウンロードし直します。
RUN --mount=type=cacheは、パッケージマネージャーのダウンロードキャッシュ用のディレクトリを、イメージのレイヤーとは別にビルダーの中に保存しておく仕組みです。先ほどの良い例の--mount=type=cache,target=/root/.npmがこれで、npm ciを再実行することになっても、以前ダウンロードしたパッケージは/root/.npmから取り出されます。
- マウントしたディレクトリの中身は、イメージの層には入りません(イメージが大きくならない)
- 層のキャッシュが外れて
RUNが再実行されたときに効きます targetには、ツールごとのキャッシュの場所を指定します。npmなら/root/.npm、rootで動かすpipなら/root/.cache/pipです
キャッシュマウントはビルダーのストレージに残ります。docker buildx duで種類を絞ると確認できます。
docker buildx du --filter type=exec.cachemountID RECLAIMABLE SIZE LAST ACCESSED
lxol1bhsntnyty5lg0cvdt1pi* true 5.258kB About a minute agoビルダーのローカルに保存されるものなので、毎回新しい環境で動くCIのランナーでは、そのままでは前回の中身がありません。CIでは次の節以降で説明するレイヤーのキャッシュの共有を優先して考えるのが現実的です。
マルチステージビルド: 道具を最終イメージに入れない
コンパイラやビルドツールは、ビルドのときには必要でも、実行するときには要りません。マルチステージビルドでは、1つのDockerfileに複数のFROMを書き、前のステージの成果物だけを最後のステージにCOPY --fromで持ってきます。
# syntax=docker/dockerfile:1
FROM alpine:3.22 AS build
RUN apk add --no-cache build-base
WORKDIR /src
COPY hello.c .
RUN gcc -static -O2 -o hello hello.c
FROM scratch
COPY --from=build /src/hello /hello
ENTRYPOINT ["/hello"]docker buildx build -t layer-demo:ms .
docker buildx build --target build -t layer-demo:ms-build .
docker run --rm layer-demo:ms
docker image ls layer-demohello from static binary
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
layer-demo:ms 5f759ef65135 136kB 0B
layer-demo:ms-build e98f48578c1f 244MB 0Bgccを含むbuildステージは244MBありますが、最終イメージは静的リンクしたバイナリ1つだけの136kBです。docker historyで見ても、最終イメージの層はCOPY /src/hello /helloの1枚だけです。
IMAGE CREATED CREATED BY SIZE COMMENT
5f759ef65135 3 seconds ago ENTRYPOINT ["/hello"] 0B buildkit.dockerfile.v0
<missing> 3 seconds ago COPY /src/hello /hello # buildkit 136kB buildkit.dockerfile.v0キャッシュの面でも、BuildKitは最終ステージに必要なステージだけを実行し、関係のないステージはスキップします。ステージごとにキャッシュが分かれるので、テスト用のステージと本番用のステージを分けておくと、片方の変更がもう片方を巻き込みにくくなります。詳しくはMulti-stage buildsにまとまっています。
後のレイヤーでrmしても容量は減らない
実験: 50MBのファイルを作って消す
前半で見たとおり、レイヤーは差分の積み重ねで、削除はwhiteoutで「隠す」だけです。そのため、ある層で作ったファイルを次の層で消しても、最初の層にはファイルが残ります。
FROM alpine:3.22
RUN dd if=/dev/urandom of=/big.bin bs=1M count=50
RUN rm /big.binFROM alpine:3.22
RUN dd if=/dev/urandom of=/big.bin bs=1M count=50 && rm /big.binIMAGE CREATED CREATED BY SIZE COMMENT
927454ca438d 1 second ago RUN /bin/sh -c rm /big.bin # buildkit 0B buildkit.dockerfile.v0
<missing> 1 second ago RUN /bin/sh -c dd if=/dev/urandom of=/big.bi… 52.4MB buildkit.dockerfile.v0
<missing> 5 days ago CMD ["/bin/sh"] 0B buildkit.dockerfile.v0
<missing> 5 days ago ADD alpine-minirootfs-3.22.8-aarch64.tar.gz … 8.54MB buildkit.dockerfile.v0IMAGE ID DISK USAGE CONTENT SIZE EXTRA
layer-demo:rm 927454ca438d 61MB 0B
layer-demo:rm-one d4bc9f211ecb 8.54MB 0Bコンテナから見えるファイルはどちらも同じ(/big.binは存在しない)なのに、RUNを分けた方は61MB、1つのRUNで作って消した方はベースイメージと同じ8.54MBです。1つのRUNの中で作って消したファイルは、そのRUNの実行前と実行後の差分に現れないので、どの層にも入りません。
docker saveでrmの層の中身を見ると、OCIのwhiteoutが入っていました。
-rw------- 0 0 0 0 Nov 11 05:04 .wh.big.bin
drwxr-xr-x 0 0 0 0 Nov 11 05:04 etc/この層が持っているのは「big.binは無いことにする」という0バイトの目印だけで、52.4MBの本体は1つ下の層に残っています。
秘密情報も同じように残る
容量の問題で済めばまだよいのですが、同じことが秘密情報でも起きます。プライベートなパッケージを取るためのトークンをCOPYし、使い終わったらrmする書き方です。トークンの中身はダミーです。
FROM alpine:3.22
COPY token.txt /tmp/token.txt
RUN wc -c /tmp/token.txt && rm /tmp/token.txtdocker run --rm layer-demo:leak ls /tmp
docker save layer-demo:leak -o img.tar
mkdir x && tar -xf img.tar -C x
for f in x/blobs/sha256/*; do tar -xOf "$f" tmp/token.txt 2>/dev/null; donedummy-token-1111-changedls /tmpは何も表示しない(コンテナからは消えて見える)のに、COPYの層のtarからはトークンがそのまま取り出せました。このイメージをレジストリにpushすれば、pullできる人は誰でも同じことができます。ENVやARGで渡した値も、configのEnvやビルド履歴に記録されることがあるので、秘密情報の受け渡しには使いません。
RUN --mount=type=secretで渡す
秘密情報は、Build secretsの--secretフラグとRUN --mount=type=secretで渡します。secretはそのRUNの実行中だけ/run/secrets/<id>にマウントされ、層には入りません。
# syntax=docker/dockerfile:1
FROM alpine:3.22
RUN --mount=type=secret,id=api_token \
wc -c /run/secrets/api_token && ls /run/secretsdocker buildx build --progress=plain --secret id=api_token,src=token.txt -t layer-demo:sec .
docker run --rm layer-demo:sec ls /run/secrets#8 [stage-0 3/3] RUN --mount=type=secret,id=api_token wc -c /run/secrets/api_token && ls /run/secrets
#8 0.106 17 /run/secrets/api_token
#8 0.106 api_token
#8 DONE 0.1s
ls: /run/secrets: No such file or directoryビルド中のRUNからはsecretを読めていますが、完成したイメージには/run/secrets自体がありません。前述のとおり、secretの値を変えてもキャッシュは無効にならないので、値を変えたときに再実行したい場合はビルド引数などで明示的に無効化します。
確認と掃除のコマンド
| コマンド | 分かること |
|---|---|
docker history <イメージ> | 各命令と、その命令で増えた層のサイズ |
docker image inspect <イメージ> | RootFS.Layers(DiffIDの一覧)、Configの中身 |
docker diff <コンテナ> | コンテナの書き込み層に入った変更(A/C/D) |
docker buildx du | ビルドキャッシュの一覧とサイズ、再利用可能かどうか |
docker system df | イメージ、コンテナ、ボリューム、ビルドキャッシュのディスク使用量 |
docker builder prune | 使われていないビルドキャッシュを削除する |
docker image inspect <イメージ> --format '{{json .RootFS}}'を実行すると、前半でmanifestから読み取ったのと同じ8個のダイジェスト(DiffID)がLayersに並びます。
ビルドキャッシュは、何もしないと増え続けます。筆者の環境でもdocker system dfのBuild Cacheが数十GBになっていました。docker builder pruneは使われていないキャッシュを削除し、--allを付けるとすべてを対象にします。--filter until=24hのように期間で絞ることもできます。オプションの詳細はdocker builder pruneとdocker buildx duのリファレンスを確認してください。キャッシュを消した直後のビルドは、当然すべて作り直しになります。
CIでビルドキャッシュを使う
なぜCIではキャッシュが効かないのか
ここまでのキャッシュは、すべてビルダーのローカルのストレージにあります。GitHub Actionsのホステッドランナーのように、ジョブのたびに新しい仮想マシンで動く環境では、前回のキャッシュが残っていないので、毎回すべて作り直しになります。
これを解決するのが、ビルドキャッシュを外部に書き出す--cache-toと、外部から読み込む--cache-fromです。Cache storage backendsによると、次の保存先があります。
| 種類 | 保存先 |
|---|---|
inline | イメージそのものにキャッシュの情報を埋め込む |
registry | イメージとは別のイメージとしてレジストリに置く |
local | ローカルのディレクトリ |
gha | GitHub Actionsのキャッシュ(ドキュメント上はベータ) |
s3 | AWSのS3バケット(ドキュメント上は未リリース扱い) |
azblob | Azure Blob Storage(ドキュメント上は未リリース扱い) |
既定のdockerドライバーで使えるのはinline、local、registry、ghaで、ドキュメントによるとそれにはcontainerdイメージストアが必要です。それ以外の構成では、docker buildx create --driver docker-containerなどで別のドライバーのビルダーを作って使います。
registryにキャッシュを置く
docker buildx build --push -t registry.example.com/sample-app:latest \
--cache-to type=registry,ref=registry.example.com/sample-app:buildcache,mode=max \
--cache-from type=registry,ref=registry.example.com/sample-app:buildcache \
.registry.example.com/sample-appは説明用のダミーです。modeには次の2つがあります。
| mode | 書き出す範囲 | 特徴 |
|---|---|---|
min(既定) | 最終イメージに含まれる層だけ | キャッシュが小さく、読み書きが速い |
max | 途中のステージを含むすべての層 | キャッシュは大きいが、ヒットしやすい |
マルチステージビルドでは、minだとbuildステージの層(依存パッケージのインストールなど)が書き出されず、CIで一番効いてほしい部分がキャッシュされません。マルチステージならmode=maxを検討します。また、ドキュメントには、1つのキャッシュの場所は上書きでしか書き込めないので、Gitのブランチごとなどスコープを分けたい場合は別の場所を使うよう書かれています。
GitHub Actionsのキャッシュを使う
GitHub Actionsでは、type=ghaでActionsのキャッシュサービスにビルドキャッシュを保存できます。公式ドキュメントの例をもとにすると、次のようになります。
name: ci
on:
push:
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build and push
uses: docker/build-push-action@v7
with:
push: true
tags: user/app:latest
cache-from: type=gha
cache-to: type=gha,mode=maxドキュメントには、いくつか注意点が書かれています。
- 使えるのはGitHub CacheサービスのAPI v2だけで、Buildx 0.21.0以上、BuildKit 0.20.0以上が必要
type=ghaは、URLやトークンの環境変数が用意されるGitHub Actionsのワークフローの中でしか動かない- トリガーによってはキャッシュへの書き込み権限が無く、
failed to reserve cacheで失敗することがある。その場合はcache-fromだけを残す
キャッシュの共有を入れても、Dockerfileの命令の順番が悪ければ、共有されたキャッシュもすぐに外れます。CIの設定より先に、この記事の前半で見た「変わりにくいものを上に」を整えるのが近道です。
まとめ
- Dockerイメージは読み取り専用のレイヤーの積み重ねで、コンテナはその上に書き込み層を1枚載せ、OverlayFSの
lowerdir、upperdir、mergedで1つに見せている - 書き換えるとファイルは丸ごとcopy-upされ、削除はwhiteoutで隠すだけ。下の層のファイルは残る
- イメージはmanifest、config、layerの3種類のデータでできていて、すべてsha256のダイジェストで識別されるcontent-addressableな構造になっている
- ファイルシステムを変えるのは
RUN、COPY、ADD(と、ディレクトリを作る場合のWORKDIR)で、ENVやCMDはconfigを書き換えるだけ - BuildKitは上から順にキャッシュを照合し、1つ外れたらそれ以降はすべて作り直す。
COPYはファイルの内容(mtimeは見ない)、RUNはコマンド文字列で判定する - 依存の定義ファイルを先に
COPYする、.dockerignoreを書く、apt-get updateはinstallと同じRUNにする、ARGは使う直前で宣言する - 後の層で
rmしても容量は減らず、秘密情報も残る。作って消すなら1つのRUNで、秘密情報はRUN --mount=type=secretで渡す - CIでは
--cache-fromと--cache-toでキャッシュを共有し、マルチステージならmode=maxを検討する
Dockerfileを書くときに「この行の入力が変わったら、ここから下は全部作り直しになる」と意識するだけで、ビルドの待ち時間はかなり変わります。キャッシュの挙動に迷ったら、--progress=plainでログのCACHEDの位置を確認し、docker historyで層の中身を見るところから始めてみてください。


