ビルドキャッシュはなぜその場所で壊れるのか
一言でいうと
各レイヤーのキャッシュキーは、親レイヤーのダイジェスト+自身の命令で計算されます。そのため、上で1つ壊れると、その下は内容に関係なくすべて再実行されます。
なぜ必要なのか
FROM node:22-bookworm-slim # 1
WORKDIR /app # 2
COPY . . # 3 <- 소스 한 글자만 바뀌어도 여기서 깨진다
RUN npm ci # 4 <- 그래서 여기도
RUN npm run build # 5 <- 여기도
コメントを1行直しただけでも、依存関係のインストールが最初からやり直しになります。Dockerは、命令の意味を知らず、その位置にあるレイヤーの親が変わったという事実だけを知っています。
どう動くのか
命令の種類によって、キャッシュキーの計算方法が違います。この違いを知らないと、診断がずっと的外れになります。
RUNのキャッシュキーは、命令の文字列そのものです。何をするのかは見ません。そのため、RUN apt-get updateの1行は、何か月経っても永遠にキャッシュにヒットします。apt-get updateとinstallを同じRUNにまとめる必要がある本当の理由は、サイズではなくキャッシュです。COPY/ADDのキャッシュキーは、対象ファイルの内容ハッシュです。
ここで、古い誤解を1つ正しておく必要があります。「ファイルを開いて保存(touch)しただけでキャッシュが壊れる」という話は、旧ビルダーの時代の話です。クラシックビルダーは、ファイルのメタデータに更新時刻を含めていましたが、BuildKitは内容ハッシュとモード、所有権だけを見ます。CIでgit cloneを新しく実行し、すべてのファイルのmtimeが現在時刻になっても、キャッシュには影響しません。それでもキャッシュが壊れるなら、原因はmtimeではなく、別の場所にあります。
そのため、解決策は1つしかありません。頻繁に変わるものを下へ移すことです。
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
現場での姿
CIでだけ、やけに遅い場合があります。ローカルでは2回目のビルドが4秒なのに、CIは90秒から8分に増え、ログを見るとRUN npm ciの1つで287.4秒を使っています。理由は単純で、キャッシュはビルダーのローカルストレージにあるのに、CIのランナーは実行のたびに新しいマシンだからです。そのため、レジストリにキャッシュを書き出す設定が別に必要で、このときmode=maxを外すと最終ステージだけがキャッシュされ、肝心のコストの高いビルダーステージが毎回再実行されます。
.dockerignoreも誤解の多いものです。これはイメージサイズの最適化ツールではなく、転送量の最適化ツールです。実測では、コンテキストの転送が612.44MB / 18.3秒から3.71MB / 0.4秒に減りました。ただし、COPY . .を使うならイメージサイズにも影響し、.envを除外する理由はサイズではなくセキュリティです。
キャッシュが壊れるルール
レイヤーキャッシュは、直前のレイヤーが同じで、命令も同じときに再利用されます。1行が壊れると、その下はすべて作り直されます。そのため、変更の少ないものを上に置きます。
# ❌ 소스가 한 글자만 바뀌어도 의존성을 다시 받는다
COPY . /app
RUN npm ci
# ✅ package.json 이 안 바뀌면 npm ci 는 캐시에서
COPY package*.json /app/
RUN npm ci
COPY . /app
COPYはファイル内容のチェックサムで判断し、RUNは命令の文字列だけを見ます。そのため、RUN apt-get update && apt-get install -y curlは、命令が同じならキャッシュを使い、その間にリポジトリが更新されていても、古いパッケージリストを使います。これが、「ビルドはできたのに古いバージョンが入った」の原因です。
.dockerignoreがキャッシュを守る仕組み
COPY . /appのチェックサムには、ビルドコンテキストのすべてのファイルが含まれます。.git、node_modules、ログファイルが変わるたびに、キャッシュが壊れます。
.git
node_modules
*.log
.env*
__pycache__
副次的な効果がもう1つあります。コンテキストが小さくなると、デーモンに転送する量が減り、ビルドの開始が速くなります。.gitを除くだけで、数百MBも減ることがあります。そして、.envを除くことは、セキュリティの問題でもあります。
BuildKitのマウントキャッシュ
パッケージのキャッシュをレイヤーに入れず、ビルド間で共有できます。イメージサイズを増やさずに、リビルドが速くなります。
# syntax=docker/dockerfile:1
RUN --mount=type=cache,target=/root/.npm npm ci
RUN --mount=type=cache,target=/var/cache/apt --mount=type=cache,target=/var/lib/apt/lists apt-get update && apt-get install -y --no-install-recommends curl
機密情報も同じ方式で渡します。--mount=type=secretで渡すと、レイヤーに残りません。
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
docker build --secret id=npmrc,src=$HOME/.npmrc .で渡します。ファイルをCOPYしてから削除する方式と違い、レイヤーに痕跡が残りません。
マルチステージで最終イメージを小さくする
ビルドツールは、実行には必要ありません。別のステージで作成し、ビルド結果だけを移します。
FROM golang:1.23 AS build
WORKDIR /src
COPY go.* ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app ./cmd/app
FROM gcr.io/distroless/static:nonroot
COPY --from=build /out/app /app
USER nonroot
ENTRYPOINT ["/app"]
最終イメージには、コンパイラーもソースもシェルもありません。サイズが小さくなり、攻撃対象領域も小さくなります。その代わり、デバッグするときはシェルがないため、ネームスペースを共有するツール用のコンテナを接続します。
次のラボですること
同じソースで2回ビルドしてイメージIDが同じかを確認し、前のレイヤーをわざと壊して、その下がすべて再実行されるのを確かめ、削除ではイメージが小さくならないことを、2つのイメージのサイズの差で証明します。