Skip to content

필사 모드: Docker 构建缓存为什么总是失效 — 层缓存键、ARG 与 BuildKit 缓存挂载

中文
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

引言 — 只改了一行注释,为什么 npm ci 又跑了一遍

CI 流水线一开始是 90 秒。现在是 8 分钟。看日志会发现,每次都把时间花在同一行上。

 => [builder 4/7] RUN npm ci                                            287.4s
 => [builder 5/7] RUN npm run build                                      41.2s

依赖已经三周没动过,却每次都要重装将近五分钟。本地第二次构建 4 秒就结束,于是就停留在"缓存好像是有效的吧"这种含糊状态里没人管。

原因几乎总是二者之一。要么是 Dockerfile 里的指令顺序每次都在改变缓存键,要么是 CI runner 上根本就不存在缓存。两个问题的解法完全不同,所以必须先分清是哪一种。

缓存失效只有一条规则

Docker 构建缓存是一条链。每一层的缓存键由父层的摘要和自身的指令计算得出。所以,父层一变,子层无论内容如何都会全部重新执行

FROM node:22-bookworm-slim   # 1
WORKDIR /app                 # 2
COPY . .                     # 3  <- 源码改一个字符就在这里断掉
RUN npm ci                   # 4  <- 所以这里也断
RUN npm run build            # 5  <- 这里也是

因为 COPY . . 排在第三位,只要修改一个源文件,第 3 步之后就全部失效。npm ci 会重新执行,跟 package-lock.json 有没有变毫无关系。Docker 不理解指令的含义,它只知道那个位置上的层的父层变了。

这条规则的推论就是优化的全部内容。把经常变的往下挪,把很少变的往上提。

COPY 的缓存键是文件内容,不是路径

每条指令计算缓存键的方式不同。不了解这个差别,就会去修错地方。

RUN 的缓存键就是命令字符串本身。它不看这条命令做了什么。所以下面这两行过几个月也永远命中缓存。

RUN apt-get update
RUN curl -fsSL https://get.example.com/install.sh | sh

远端仓库的软件包索引更新了也好,安装脚本改了也好,Docker 都不知道。这才是必须把 apt-get updateapt-get install 放进同一条 RUN 的真正理由。不是为了体积,是为了缓存。

COPYADD 的缓存键是目标文件们的内容哈希。这里有一个常见误解。"只是打开文件保存一下缓存也会失效"这种说法属于旧构建器时代。经典构建器把修改时间算进了文件元数据,所以 touch 一下缓存就没了。BuildKit 只看内容哈希、权限模式和属主。

DOCKER_BUILDKIT=1 docker build -t t1 . >/dev/null
touch src/index.ts
DOCKER_BUILDKIT=1 docker build -t t1 . 2>&1 | grep -E 'CACHED|RUN'
 => CACHED [2/5] WORKDIR /app
 => CACHED [3/5] COPY package.json package-lock.json ./
 => CACHED [4/5] RUN npm ci
 => CACHED [5/5] COPY . .

光靠 touch 是断不掉的。反过来,在 CI 里重新 git clone 会让所有文件的 mtime 变成当前时间,而在 BuildKit 上这对缓存没有影响。如果缓存仍然失效,原因就在 mtime 之外的别处。

依赖层的分离 — 标准写法与它的陷阱

标准做法是先只复制声明依赖的那些文件。

FROM node:22-bookworm-slim
WORKDIR /app

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

COPY . .
RUN npm run build

这样只有源码变动时才从 COPY . . 开始失效,npm ci 从缓存里出来。

各语言对应的文件组合是这样。

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

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

# Rust
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo 'fn main() {}' > src/main.rs && cargo build --release

# Java (Maven)
COPY pom.xml ./
RUN mvn -B dependency:go-offline

这里有两个陷阱。

第一,在 monorepo 里只复制 COPY package.json ./ 会漏掉工作区子包的清单文件,导致 npm ci 失败。必须用通配符在保留目录结构的前提下复制。

COPY package.json package-lock.json ./
COPY packages/api/package.json ./packages/api/
COPY packages/web/package.json ./packages/web/
COPY packages/shared/package.json ./packages/shared/
RUN npm ci

如果每加一个包就要更新这份清单太麻烦,BuildKit 1.7 以上可以用 COPY --parents

# syntax=docker/dockerfile:1.7-labs
COPY --parents package.json package-lock.json packages/*/package.json ./
RUN npm ci

第二,没有固定版本的依赖声明会让缓存处于不可复现的状态。如果 requirements.txt 里只写了 requests,那么在缓存还活着的期间会一直用着半年前的版本,而缓存一旦断掉的那一刻就跳到最新版本。这也是使用锁文件的理由之一。

ARG,以及那些每次都会打断缓存的东西

ARG 因为在不同构建器上缓存行为不同而引起混乱。在经典构建器上,ARG 声明本身就会进入之后所有层的缓存键。BuildKit 则不同:只有真正引用了该值的指令才会被无效化

# syntax=docker/dockerfile:1.7
FROM node:22-bookworm-slim
WORKDIR /app

ARG GIT_SHA
ARG BUILD_TIME

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

COPY . .
RUN npm run build

LABEL org.opencontainers.image.revision=$GIT_SHA
LABEL org.opencontainers.image.created=$BUILD_TIME

即便 GIT_SHA 每次提交都不同,npm ci 并不引用这个值,所以照样命中缓存。只有引用了它的那两行 LABEL 会重新计算。要是把这个顺序颠倒过来,把引用 ARGENV 放在上面,就全垮了。

# 反模式
ARG GIT_SHA
ENV APP_REVISION=$GIT_SHA   # 从这里往下,每次提交缓存全灭
COPY package.json ./
RUN npm ci

每次都打断缓存的典型原因有这些:把构建时间或提交哈希写进顶部的 ENV,把 COPY . . 放在依赖安装之前,没有 .dockerignore 导致 .git 目录的变化改变了上下文哈希,以及把基础镜像留在 latest 上,使得远端摘要一变最顶部就失效。

CI runner 上没有缓存 — 缓存挂载与镜像仓库缓存

就算把 Dockerfile 整理得完美无缺,CI 上照样每次都跑 npm ci。这很正常。缓存在构建器的本地存储里,而 GitHub Actions 的 runner 每次运行都是一台新虚拟机。本地没有缓存,就不会有缓存命中。

解决手段有两种,而且互相不能替代。

第一种是缓存挂载。把包管理器的下载缓存放到单独的卷里,而不是烙进层中。

# syntax=docker/dockerfile:1.7
FROM node:22-bookworm-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci

FROM python:3.12-slim AS py
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

FROM golang:1.23 AS go
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    go build ./...

这里最容易踩的坑必须说清楚。缓存挂载不会被导出到镜像仓库缓存。无论把 --cache-to type=registrytype=gha 配置得多好,RUN --mount=type=cache 里的内容都不会被包含进去。因为缓存挂载是绑定在构建器实例上的本地状态。在每次都新建的 runner 上,它没有任何效果。

缓存挂载真正能带来收益的地方,是开发者的本地机器,以及 BuildKit 守护进程持续存活的自托管 runner 或远程构建器。

# 连接到团队共享的远程构建器
docker buildx create --name shared --driver remote \
  tcp://buildkit.internal:1234 --use
docker buildx build -t api:dev --load .

第二种是镜像仓库缓存。把层缓存作为 OCI 制品上传到镜像仓库,下次构建时再拉下来。这一种即使 runner 每次都是新的也照样有效。

docker buildx build \
  --cache-from type=registry,ref=registry.example.com/api:buildcache \
  --cache-to   type=registry,ref=registry.example.com/api:buildcache,mode=max \
  --tag registry.example.com/api:v312 \
  --push .
 => importing cache manifest from registry.example.com/api:buildcache      1.4s
 => CACHED [deps 3/3] RUN npm ci                                           0.0s
 => [builder 5/5] RUN npm run build                                       38.7s
 => exporting cache to registry.example.com/api:buildcache                 6.2s

mode=max 是关键。默认值 mode=min 只导出最终阶段的层,于是在多阶段构建里,偏偏最昂贵的构建阶段没被缓存。那些"用了多阶段构建但缓存不生效"的反馈,绝大多数就是这个原因。

如果是 GitHub Actions,type=gha 更方便。

- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: ghcr.io/org/api:${{ github.sha }}
    cache-from: type=gha,scope=api-${{ github.ref_name }}
    cache-to: type=gha,scope=api-${{ github.ref_name }},mode=max

scope 按分支切分,但要注意 GitHub 的缓存每个仓库有 10GB 上限,并且从最旧的条目开始被驱逐。把作用域细分到每次提交,它们会互相挤掉对方,命中率反而下降。

方式存放位置mode=max在新 runner 上有效主要用途陷阱
本地层缓存构建器磁盘不适用开发者机器在 CI 上没用
type=inline镜像本身不支持单阶段多阶段缓存缺失
type=registry镜像仓库支持通用 CI存储成本,需要清理
type=ghaActions 缓存支持GitHub Actions每仓库 10GB 上限
type=local磁盘路径支持有条件自托管无限增长,需手动清理
RUN --mount=cache构建器本地无法导出常驻构建器runner 一新建就失效

在多架构构建里,要知道缓存是按平台分离的。linux/amd64linux/arm64 各有各的缓存链,所以用 mode=max 一起导出到同一个缓存 ref 在管理上更省事。

docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --cache-from type=registry,ref=registry.example.com/api:buildcache \
  --cache-to   type=registry,ref=registry.example.com/api:buildcache,mode=max \
  --tag registry.example.com/api:v312 --push .

用 QEMU 模拟构建 arm64,会比原生慢 5 倍到 20 倍。与其去调和缓存,不如按架构分到各自的原生 runner 上构建,最后只把 manifest 合并起来,多数情况下更快。

docker buildx imagetools create \
  --tag registry.example.com/api:v312 \
  registry.example.com/api:v312-amd64 \
  registry.example.com/api:v312-arm64

可复现性与缓存互相排斥

想把缓存最大化的压力,和想要可复现构建的压力,方向是相反的。必须承认这种张力,并划定边界。

FROM node:22 很方便,但远端摘要一更新,所有层就都失效了。而且昨天构建的镜像和今天构建的镜像会用上不同的基础镜像。用摘要固定住,两个问题会一起消失。

FROM node:22-bookworm-slim@sha256:9f3c1a5a4d1b7e2c8f0a6b9d3e5c7a1b4d8f2e6c0a9b3d5e7f1c4a8b2d6e0f31

更新这件事交给工具,而不是人。

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: docker
    directory: /
    schedule:
      interval: weekly

apt-get update 是同一个问题的另一张脸。放进同一条 RUN 里可复现性会变好,但每次 install 列表一变就要重新下载索引。把索引下载搬进缓存挂载,两者就能兼得。

# syntax=docker/dockerfile:1.7
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
    rm -f /etc/apt/apt.conf.d/docker-clean \
 && apt-get update \
 && apt-get install -y --no-install-recommends libpq5 ca-certificates

之所以需要 rm -f /etc/apt/apt.conf.d/docker-clean,是因为官方 Debian 镜像被配置成安装完立刻自动删除缓存。不删掉那份配置,缓存挂载每次都是空的。

如果需要按比特复现构建结果,BuildKit 0.13 以上可以固定时间戳。

SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct) \
docker buildx build \
  --output type=registry,name=registry.example.com/api:v312,rewrite-timestamp=true .

结语 — 缓存不生效时,先看它是在哪里断的

缓存问题靠猜是修不好的。用 --progress=plain 导出构建日志,找到 CACHED 消失的第一个位置,然后从确认那一行的缓存键是什么开始。

docker buildx build --progress=plain . 2>&1 | grep -E '^#[0-9]+ (CACHED|\[)'
#7 [deps 2/3] COPY package.json package-lock.json ./
#7 CACHED
#8 [deps 3/3] RUN npm ci
#8 CACHED
#9 [builder 4/6] COPY . .
#10 [builder 5/6] RUN npm run build

没有标上 CACHED 的第一个阶段是 #9,所以它上面的不用动,它下面的也没办法补救。要修的地方永远是那一行。

要记住的只有一点。缓存是一条链,上游只要断一次,下游就全部失去意义。所以 Dockerfile 要按变更频率升序编写,CI 上要用 mode=max 挂上镜像仓库缓存,而缓存挂载只应在构建器持续存活的环境里去指望。

현재 단락 (1/157)

CI 流水线一开始是 90 秒。现在是 8 分钟。看日志会发现,每次都把时间花在同一行上。

작성 글자: 0원문 글자: 7,346작성 단락: 0/157