TT Lab
はじめる
学ぶ 学習パス コース

イメージのビルド

ビルドキャッシュはなぜその場所で壊れるのか

TT Labで続きを見る

一言でいうと

各レイヤーのキャッシュキーは、親レイヤーのダイジェスト+自身の命令で計算されます。そのため、上で1つ壊れると、その下は内容に関係なくすべて再実行されます。

なぜ必要なのか

FROM node:22-bookworm-slim   # 1
WORKDIR /app                 # 2
COPY . .                     # 3  <- 소스 한 글자만 바뀌어도 여기서 깨진다
RUN npm ci                   # 4  <- 그래서 여기도
RUN npm run build            # 5  <- 여기도

コメントを1行直しただけでも、依存関係のインストールが最初からやり直しになります。Dockerは、命令の意味を知らず、その位置にあるレイヤーの親が変わったという事実だけを知っています。

どう動くのか

命令の種類によって、キャッシュキーの計算方法が違います。この違いを知らないと、診断がずっと的外れになります。

ここで、古い誤解を1つ正しておく必要があります。「ファイルを開いて保存(touch)しただけでキャッシュが壊れる」という話は、旧ビルダーの時代の話です。クラシックビルダーは、ファイルのメタデータに更新時刻を含めていましたが、BuildKitは内容ハッシュとモード、所有権だけを見ます。CIでgit cloneを新しく実行し、すべてのファイルのmtimeが現在時刻になっても、キャッシュには影響しません。それでもキャッシュが壊れるなら、原因はmtimeではなく、別の場所にあります。

ビルドキャッシュが壊れる位置の比較: COPY . . を先に書くと3行目で壊れ、npm ciとbuildまで再実行されます。package.jsonだけを先にCOPYすると、ソースが変わってもnpm ciはキャッシュにヒットします

そのため、解決策は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つのイメージのサイズの差で証明します。