Docker イメージのレイヤーとビルドキャッシュの仕組み - キャッシュが効かない原因をOverlayFSとOCIから理解する

Docker イメージのレイヤーとビルドキャッシュの仕組み - キャッシュが効かない原因をOverlayFSとOCIから理解する

作成日:
読了:約58分
更新日:

当サイトは 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上下を合成した見え方。コンテナからはこれが/に見える
workdirOverlayFSが内部で使う作業用ディレクトリ

カーネルのドキュメントには、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 0

lowerdirのl/HP4LS...のような短い名前は、各レイヤーのdiffディレクトリへのシンボリックリンクです。レイヤーが増えるとmountに渡す文字列が長くなりすぎるので、短い名前に置き換えています。docker inspectで見ると、元のパスが分かります。

コンテナのGraphDriverの情報を見る
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 diffで差分を見る
docker exec ovl-demo sh -c 'echo hi >> /etc/motd; rm /etc/issue; mkdir /data && echo x > /data/new.txt'
docker diff ovl-demo
実行結果
C /etc
C /etc/motd
D /etc/issue
A /data
A /data/new.txt

Cは変更、Dは削除、Aは追加です。書き込み層(upperdir)の中身を直接のぞくと、これがどう表現されているかが見えます。Docker DesktopではLinuxはVMの中で動いているので、特権コンテナからVMのマウント名前空間に入って確認しました。

書き込み層の中身を見る(Docker Desktopの場合)
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 motd

motdはcopy-upされて上の層に丸ごと存在し、issueは先頭がc(キャラクターデバイス)でデバイス番号が0, 0のwhiteoutになっています。/etc/issueの本体は下の層にそのまま残っていて、上から隠されているだけです。この「消したのではなく隠しただけ」という性質が、後で説明する「rmしても容量が減らない」問題の根っこです。

ファイルの実体やinodeの話はファイルシステムの記事で詳しく扱っています。

後片付け
docker rm -f ovl-demo

OCIイメージの中身: index、manifest、config、layer

docker saveで中身を取り出す

イメージを配布するときの形式は、OCI(Open Container Initiative)のimage-specで決まっています。Docker Engine 29.8.0のdocker saveは、OCIのレイアウトでイメージを書き出しました。後で使うlayer-demo:goodイメージを保存して展開してみます。

イメージをtarに書き出して展開する
mkdir -p save/x
docker save layer-demo:good -o save/img.tar
tar -xf save/img.tar -C save/x
ls save/x
実行結果
blobs
index.json
manifest.json
oci-layout
repositories

blobs/sha256/の下に、すべてのデータが中身のsha256をファイル名にして入っています。入口になるのがindex.jsonです。

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です。

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と、この順番のレイヤーでできている」という目録です。ファイル名とダイジェストが一致していることは、自分で計算すれば確かめられます。

manifestのダイジェストを計算する
shasum -a 256 save/x/blobs/sha256/24556f01fe539c1d45e7b7c56c15877e8597accf8bd33450f428cc9c6e0100e9
実行結果
24556f01fe539c1d45e7b7c56c15877e8597accf8bd33450f428cc9c6e0100e9  save/x/blobs/sha256/24556f01...

3種類のデータの役割

データ中身何で識別されるか
manifestconfigとレイヤーのダイジェスト、サイズ、メディアタイプの一覧manifest自身のsha256
config環境変数、CMD、ENTRYPOINT、作業ディレクトリ、ビルド履歴、rootfs.diff_idsconfig自身のsha256。Dockerの「イメージID」はこれ
layerファイルシステムの差分をtarにしたもの(gzipやzstdで圧縮されることもある)tarのsha256

configの中身を見ると、ENVやCMD、WORKDIRの設定は、ファイルではなくこのJSONに書かれていることが分かります。

config(抜粋)
"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:good
実行結果
IMAGE          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が付いています。

configのhistory(抜粋)
{"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に丸められています。

WORKDIRが作ったレイヤーの中身
tar -tvf save/x/blobs/sha256/bd10069479fa1b056481371109369e4c78cf94c5da555773d78a2bf461f6c3a8
実行結果
drwxr-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の命令はすべて新しく実行され、キャッシュは使われない」ことが書かれています。

途中で1つ外れると、それより下は全部作り直し
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=secretsecretのIDとマウント先secretの値

COPYとADDはファイルの内容で判定する

COPYとADDは、コピーするファイルのチェックサムで判定します。公式ドキュメントには「ファイルの更新日時(mtime)はキャッシュのチェックサムの計算に含まれない」と明記されています。

実際に、touchで更新日時だけを変えた場合と、中身を書き換えた場合を比べます。

mtimeだけ変えた場合と、中身を変えた場合
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 .
実行結果(touchだけ)
#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.5s

git 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は、その引数を参照していなくても無効化されたことです。

Dockerfile(ARGの位置の実験)
FROM alpine:3.22
RUN echo step-before-arg
ARG APP_VERSION=1.0
COPY Dockerfile /tmp/df
RUN echo "not using the arg"
APP_VERSIONを1.0でビルドした後、2.0に変えてビルド
docker buildx build --build-arg APP_VERSION=1.0 -t argdemo .
docker buildx build --progress=plain --build-arg APP_VERSION=2.0 -t argdemo .
実行結果(2回目)
#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.1s

ARGより前の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 . . を先に置く

Dockerfile(悪い例)
FROM node:24-alpine
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "app.js"]

app.jsを1行直してビルドすると、次のようになります。

実行結果(app.jsを変更した後)
#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.5s

COPY . .はプロジェクト全体をコピーするので、どのファイルを変えてもこの層が変わります。その結果、依存パッケージは何も変わっていないのにnpm installが再実行されます。この例は依存が1つなので0.5秒ですが、実際のアプリでは数分かかることも珍しくありません。

良い例: 依存の定義を先にCOPYする

Dockerfile(良い例)
# 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"]
.dockerignore
node_modules
.git
*.log
.env

依存パッケージを決めるファイル(package.jsonとpackage-lock.json)だけを先にコピーしてインストールし、アプリのコードはその後でコピーします。app.jsを直してビルドすると、次のようになります。

実行結果(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.1s

npm 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がある状態でビルドしてみます。

実行結果(.dockerignoreなし)
#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のままです。

実行結果(.dockerignoreあり、debug.logに追記した後)
#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にしている

キャッシュが効きすぎて困る代表例です。

Dockerfile(悪い例)
FROM debian:trixie-slim
RUN apt-get update
RUN apt-get install -y curl

1回目のビルドでapt-get updateの結果(パッケージの一覧)が層として保存されます。その後、何か月か経ってからRUN apt-get install -y curl gitのように2行目だけを変えても、1行目は文字列が同じなのでCACHEDになり、何か月も前のパッケージ一覧のままinstallが走ります。リポジトリ側では古いバージョンのファイルが消えていることがあり、404で失敗したり、古いバージョンが入ったりします。

Dockerfileのベストプラクティスでも、apt-get updateとapt-get installは同じRUNにまとめるよう勧めています。

Dockerfile(良い例)
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.cachemount
実行結果
ID                           RECLAIMABLE   SIZE      LAST ACCESSED
lxol1bhsntnyty5lg0cvdt1pi*   true          5.258kB   About a minute ago

ビルダーのローカルに保存されるものなので、毎回新しい環境で動くCIのランナーでは、そのままでは前回の中身がありません。CIでは次の節以降で説明するレイヤーのキャッシュの共有を優先して考えるのが現実的です。

マルチステージビルド: 道具を最終イメージに入れない

コンパイラやビルドツールは、ビルドのときには必要でも、実行するときには要りません。マルチステージビルドでは、1つのDockerfileに複数のFROMを書き、前のステージの成果物だけを最後のステージにCOPY --fromで持ってきます。

Dockerfile(マルチステージ)
# 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"]
最終イメージと、buildステージだけのイメージを比べる
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-demo
実行結果
hello from static binary
IMAGE                 ID             DISK USAGE   CONTENT SIZE   EXTRA
layer-demo:ms         5f759ef65135        136kB             0B
layer-demo:ms-build   e98f48578c1f        244MB             0B

gccを含むbuildステージは244MBありますが、最終イメージは静的リンクしたバイナリ1つだけの136kBです。docker historyで見ても、最終イメージの層はCOPY /src/hello /helloの1枚だけです。

docker history layer-demo:ms
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で「隠す」だけです。そのため、ある層で作ったファイルを次の層で消しても、最初の層にはファイルが残ります。

Dockerfile(RUNを分けた場合)
FROM alpine:3.22
RUN dd if=/dev/urandom of=/big.bin bs=1M count=50
RUN rm /big.bin
Dockerfile.one(1つのRUNで作って消す場合)
FROM alpine:3.22
RUN dd if=/dev/urandom of=/big.bin bs=1M count=50 && rm /big.bin
docker history(RUNを分けた場合)
IMAGE          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.v0
docker image ls
IMAGE               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が入っていました。

rm /big.bin の層のtarの中身
-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する書き方です。トークンの中身はダミーです。

Dockerfile.bad(秘密情報をCOPYしてからrmする)
FROM alpine:3.22
COPY token.txt /tmp/token.txt
RUN wc -c /tmp/token.txt && rm /tmp/token.txt
コンテナから見た場合と、レイヤーから取り出した場合
docker 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; done
実行結果
dummy-token-1111-changed

ls /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>にマウントされ、層には入りません。

Dockerfile(secretマウント)
# syntax=docker/dockerfile:1
FROM alpine:3.22
RUN --mount=type=secret,id=api_token \
    wc -c /run/secrets/api_token && ls /run/secrets
secretを渡してビルドし、できたイメージを確認する
docker 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ローカルのディレクトリ
ghaGitHub Actionsのキャッシュ(ドキュメント上はベータ)
s3AWSのS3バケット(ドキュメント上は未リリース扱い)
azblobAzure 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のキャッシュサービスにビルドキャッシュを保存できます。公式ドキュメントの例をもとにすると、次のようになります。

.github/workflows/docker.yml
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で層の中身を見るところから始めてみてください。

Linuxコンテナの仕組み入門 - namespaceとcgroupsから理解するDocker

Linuxコンテナの仕組み入門 - namespaceとcgroupsから理解するDocker

約48分

コンテナは軽量な仮想マシンではありません。Linuxカーネルのnamespace・cgroups・capabilities・seccomp・overlayfsという既存機能を組み合わせた「普通のプロセス」です。man7.orgのmanページ、kernel.orgのcgroup-v2ドキュメント、OCI仕様、Docker公式ドキュメントを一次ソースに、8種類のnamespaceの役割、cgroup v1とv2の違い、unshareやlsnsで手を動かす確認手順、runcとcontainerdの関係、そしてコンテナがセキュリティ境界として弱い理由までを積み上げて整理します。

UNIXシグナルとプロセスのライフサイクル - fork/exec/waitからゾンビ、PID 1問題、graceful shutdownまで

UNIXシグナルとプロセスのライフサイクル - fork/exec/waitからゾンビ、PID 1問題、graceful shutdownまで

約46分

UNIXのプロセスが生まれてから刈り取られるまでを、fork/exec/wait、終了ステータス、ゾンビと孤児、SIGTERMやSIGKILLなどのシグナル、async-signal-safe、コンテナのPID 1問題とtini、Kubernetesの終了フロー、Node.jsとPythonのgraceful shutdown実装まで一次ソース付きで解説します。

Linux パーミッションの仕組み - chmod・chown・umask と Permission denied の調べ方

Linux パーミッションの仕組み - chmod・chown・umask と Permission denied の調べ方

約50分

ls -lのrwxの読み方と755・644・600・700の使い分けから、カーネルが所有者・グループ・その他のどのビットを見るかの判定順序、ファイルとディレクトリでrwxの意味が違う理由、chmodの数値表記と記号表記、chown、umaskのマスク計算、setuid・setgid・sticky bitまでを実行結果付きで説明します。パーミッションが正しく見えるのにPermission deniedになるときの、親ディレクトリ・ACL・noexec・immutable属性・SELinuxの切り分け手順も整理します。

楽天市場のおすすめPR / 楽天アフィリエイト