构建缓存为什么恰好在那里失效
一句话总结
每一层的缓存键由父层的摘要 + 本层命令计算得出。因此,只要上方有一层缓存失效,其下方的所有步骤无论内容是否改变,都会重新执行。
为什么需要了解它
FROM node:22-bookworm-slim # 1
WORKDIR /app # 2
COPY . . # 3 <- 소스 한 글자만 바뀌어도 여기서 깨진다
RUN npm ci # 4 <- 그래서 여기도
RUN npm run build # 5 <- 여기도
即使只修改一行注释,也会从头重新安装依赖。Docker 不理解命令的含义,只知道当前位置对应层的父层已经发生变化。
工作原理
不同种类的命令采用不同的缓存键计算方式。不了解这种差异,诊断方向就会一直跑偏。
RUN的缓存键就是命令字符串本身。 Docker 不会检查命令做了什么。因此,RUN apt-get update这一行即使过去几个月,仍可能一直命中缓存。必须把apt-get update与install放在同一个 RUN 中,真正的原因不是镜像大小,而是缓存。COPY/ADD的缓存键是目标文件的内容哈希。
这里需要纠正一个流传已久的误解:“仅仅打开文件并保存(touch)也会使缓存失效”是旧版构建器时代的说法。经典构建器会把修改时间纳入文件元数据,但 BuildKit 只查看内容哈希、权限模式和所有权。即使 CI 重新执行 git clone,使所有文件的 mtime 都变成当前时间,也不会影响缓存。如果缓存仍然失效,原因不在 mtime,而在其他地方。
因此,解决办法只有一个:把经常变化的内容放到后面。
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
实际现场中的情况
有时构建只在 CI 中格外缓慢。本地第二次构建只需 4 秒,CI 却从 90 秒增加到 8 分钟;查看日志会发现,仅 RUN npm ci 就耗时 287.4 秒。原因很简单:缓存存放在构建器的本地存储中,而 CI 运行器每次执行时都是一台新机器。 因此,需要另行配置把缓存导出到镜像仓库。此时如果省略 mode=max,只有最终阶段会被缓存,真正昂贵的构建阶段仍会每次重跑。
关于 .dockerignore 也存在很多误解。它不是优化镜像大小的工具,而是优化传输量的工具。 实测中,上下文传输从 612.44MB / 18.3 秒降至 3.71MB / 0.4 秒。不过,如果使用 COPY . .,它也会影响镜像大小;排除 .env 的原因则不是大小,而是安全。
缓存失效的规则
只有在紧邻的上一层相同且命令也相同时,层缓存才会被复用。只要一行失效,其下方所有层都会重建。因此,应把不常变化的内容放在前面。
# ❌ 소스가 한 글자만 바뀌어도 의존성을 다시 받는다
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__
它还有一个附带效果:上下文变小后,传输给守护进程的数据量也会减少,构建启动得更快。仅排除 .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"]
最终镜像中既没有编译器,也没有源代码和 shell。镜像更小,攻击面也随之缩小。代价是调试时没有 shell,因此需要附加一个共享命名空间的工具容器。
下一项实验要做什么
使用同一份源代码构建两次,确认镜像 ID 是否相同;然后故意使前面的镜像层缓存失效,观察其下方所有步骤全部重跑;最后通过比较两个镜像的大小,证明仅仅删除文件并不会缩小镜像。