Docker 构建缓存总失效?我翻了 127 次 CI 日志,整理出 6 个排查步骤

🔑 关键词:Docker构建缓存,BuildKit cache mount,CI缓存失效,pnpm monorepo,多阶段构建

📖 摘要:从一次 14 包 pnpm monorepo 的 CI 排查讲起,缓存命中率从 23% 到 81%,构建从 18 分 40 秒到 4 分 12 秒;对比 actions/cache、BuildKit cache mount、COPY 分层和多阶段构建,给出可复制步骤。

Docker 构建缓存总失效?我翻了 127 次 CI 日志,整理出 6 个排查步骤

图片

去年冬天我帮朋友看一个 14 个包的 pnpm monorepo,CI 每次跑 18 分 40 秒,Docker 构建缓存命中率只有 23%。一开始我以为是 GitHub Actions runner 太小,从 2C 升到 4C 只少了 1 分 12 秒。后来我把 127 次构建日志按阶段切开,才发现大头不是编译,是构建上下文传输和 COPY . . 把锁文件层冲掉了。这篇文章不推销缓存插件,只讲我怎么从日志里定位,以及为什么我不建议再缓存 node_modules。

先给结论:缓存失效通常不是缓存开关的问题

很多人一看到 CACHED 少,就去加 actions/cache 缓存 node_modules。我试过,在 14 个包的 pnpm 工作区里,这个做法命中率很低:只要 pnpm-lock.yaml 改一行,整个 node_modules 缓存就废了。更麻烦的是恢复 1.2GB 缓存本身要 38 到 52 秒,解压还要 20 多秒。后来我把缓存对象换成 pnpm store,再用 BuildKit 的 cache mount,命中率从 23% 拉到 81%,构建时间降到 4 分 12 秒。独立观点:CI 缓存不是越大越好,缓存粒度应该贴着包管理器的内容寻址存储,而不是贴着项目目录。

图片

步骤 1:先用 --progress=plain 看哪一层没命中

不要只看 CI 界面上的绿勾。用 docker buildx build --progress=plain --load -t app:test .,日志里会打印 CACHED [stage 3/5] RUN pnpm install 或 [stage 3/5] RUN pnpm install。我那次发现 COPY package.json pnpm-lock.yaml ./ 这一层经常变,因为 root 的 package.json 里脚本版本号被工具自动改了。把日志丢到文件里,执行 grep -E 'CACHED|RUN|COPY' build.log > layers.txt,手动数 127 次里每层命中次数。这个笨办法比看缓存命中率百分比有用,因为它能告诉你到底是哪一层在拖后腿。

步骤 2:量构建上下文,别让 .git 和 dist 混进去

图片

在项目根目录执行 docker buildx du --verbose . 或者旧版 docker build -t app . 时看 Sending build context 大小。我那个项目一开始是 1.8GB,因为 .dockerignore 只写了 node_modules,忘了 .git、dist、coverage、playwright-report、.next/cache。补上后构建上下文降到 24MB。注意 node_modules 要写,但不要写 pnpm-lock.yaml,否则步骤 3 的分层复制就没法做。我通常的 .dockerignore 第一段是:.git、.github、node_modules、dist、coverage、*.log、*.tmp、.env*。这一项改动在我们 CI 上单独省了 47 秒,因为每次构建都要把 1.8GB 上传给 daemon。

步骤 3:把锁文件和源码分开 COPY,顺序不能反

错误写法是 COPY . . 然后 RUN pnpm install --frozen-lockfile。只要源码里任意一个文件变了,这一层缓存就失效。正确写法是先 COPY package.json pnpm-lock.yaml ./,如果 monorepo 有 pnpm-workspace.yaml 也一起复制。然后 RUN pnpm fetch --prod=false,再 COPY . .,最后 RUN pnpm install --frozen-lockfile --offline。这里的 --offline 前提是 store 已经通过 fetch 填好。参数上 --frozen-lockfile 在 CI 里必须开,本地开发可以不开。对比下来,同样改一个 README.md,错误写法会让 pnpm install 重跑 71 秒,正确写法只重跑最后的 COPY . . 和 build 阶段,大约 9 秒。这个变化比换更贵的 runner 明显。

图片

步骤 4:用 BuildKit cache mount 缓存 pnpm store,别再缓存 node_modules

BuildKit 的 cache mount 写法:RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile。id 要稳定,不要带 random 或 CI 的 job id。GitHub Actions 里还要配 docker buildx build --cache-from type=gha --cache-to type=gha,mode=max,这一步管的是镜像层缓存,不是 pnpm store,两者别混。我们最终用了双缓存:actions/cache 只缓存 ~/.cache/pnpm 下按 lockfile 哈希的 store,key 写成 ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }},restore-keys 用 ${{ runner.os }}-pnpm-。BuildKit 那边用 --cache-to type=gha,mode=max 保存所有中间层。实测 pnpm install 从 71 秒降到 12 到 19 秒,取决于是否新增包。注意 mode=max 会增加缓存导出时间,第一次可能多 30 秒,后面才回本。

图片

步骤 5:多阶段构建不一定更小,小心把 devDependencies 带进 runtime

我见过一个 Dockerfile 用 COPY --from=builder /app/node_modules ./node_modules,镜像 1.2GB,因为 builder 里的 devDependencies 全进来了。对比正确做法:如果 pnpm 版本够,用 RUN pnpm deploy --filter app --prod /out,或者 RUN pnpm prune --prod 后再复制。我们那个 Node 服务切到 pnpm deploy --prod /out 后,runtime 镜像从 1.2GB 降到 218MB。另一个参数坑是 NODE_ENV=production 要在 install 阶段就设置,不能等到 runtime 才设,否则 pnpm 可能按 dev 逻辑装包。还有 --platform=linux/amd64,我在 M1 Mac 本地加这个参数跑 QEMU,pnpm install 从 42 秒变成 6 分 11 秒,差点误判 CI 缓存坏了;CI 的 amd64 runner 上只有 51 秒。

步骤 6:用 --no-cache-filter 做局部失配排查,不要直接 --no-cache

图片

当你怀疑某一层污染,但不想全量重跑,可以用 docker buildx build --no-cache-filter builder -t app:test .,只跳过 builder 阶段的缓存。我第一次见这个参数是在排查 apt-get update 缓存过期问题,直接 --no-cache 会让 18 分钟全部重来,而 --no-cache-filter 只重跑指定阶段。另一个容易忽略的是 ARG 放在 FROM 前后会影响缓存。ARG NODE_VERSION=20.11.1 如果在 FROM node:${NODE_VERSION} 之前,改这个 ARG 会让后续所有层失效;如果只是想传构建参数,用 ENV 或只在 RUN 里用。最后,检查 CI 是不是每次都拉最新基础镜像,FROM node:20-alpine 可能今天和明天 digest 不同,缓存自然断。要稳定就用 node:20.11.1-alpine3.19@sha256:... 这种带 digest 的写法,或者内部镜像仓库锁版本。

我最后留下的配置和取舍

我把最终方案拆成三层:宿主机/CI 缓存 pnpm store,BuildKit 缓存镜像层,源码层尽量往后放。效果是 14 个包 monorepo 冷构建 4 分 12 秒,热缓存 1 分 46 秒,缓存命中率 81%,不是 100%。为什么不是 100%?因为 pnpm-lock.yaml 变了、基础镜像 digest 变了、CI runner 换系统都会让缓存按设计失效。我不追求 100%,只追求“该失效的才失效”。如果你现在还在用 COPY . . 后直接 npm install,先别加缓存,把顺序改对,通常就能省掉一半时间。

🏷️ 标签: