本文处理一个具体问题:项目在 CI 中每次构建镜像都重新下载依赖,即使只改一行业务代码。排查时不要先加机器配置,先确认 Dockerfile 的缓存失效范围。

csdn 技术图解

Docker 按指令生成层。一层失效后,后面的层也要重新执行。下面写法会让任何源码变化都触发依赖安装:

# syntax=docker/dockerfile:1
FROM node:22
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build

第一步把变化频率不同的文件拆开。依赖清单通常比源码稳定,应先复制清单并安装依赖,再复制源码:

# syntax=docker/dockerfile:1
FROM node:22 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

连续构建两次,再只修改一个源码文件。第二次构建的 npm ci 应命中缓存;改源码后依赖层仍应命中。如果它重新执行,检查 lock 文件是否被工具改动,或构建参数是否参与该层。

第二步缩小构建上下文。创建 .dockerignore

node_modules
.git
coverage
dist
*.log
.env*

上下文是发送给 builder 的文件集合。排除无关目录能减少传输和缓存校验范围。规则要按项目调整,例如构建确实需要 .git 提供版本信息时不能照抄。密钥文件不应进入上下文,更不能指望“后面删掉”,因为它可能已留在旧层。

普通层缓存要求指令及依赖匹配。即使依赖层需要重建,也没必要重新下载所有包。BuildKit 的 cache mount 可以保留包管理器缓存:

RUN --mount=type=cache,target=/root/.npm npm ci

Go 项目可以分别缓存模块与编译缓存:

RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    go build -o /out/server ./cmd/server

Python 常用 /root/.cache/pip。要区分“缓存下载内容”和“把依赖写入最终镜像”。cache mount 不会自动成为镜像层,命令仍需把依赖安装到正确位置。不同包管理器还有不同的并发要求,Docker 官方示例中 Apt 使用 sharing=locked,避免并行构建同时修改目录。

本地缓存适合固定构建机,临时 runner 每次可能得到新 builder,此时要配置外部缓存:

docker buildx build `
  --cache-from type=registry,ref=registry.example.com/app:buildcache `
  --cache-to type=registry,ref=registry.example.com/app:buildcache,mode=max `
  --tag registry.example.com/app:commit-sha `
  --push .

缓存引用应与正式镜像标签分开,权限单独检查。多个不兼容分支写同一个引用会降低命中率,可按主分支、运行时版本或架构划分。

构建密钥是另一个边界。密钥不应通过 ARGCOPY 进入镜像,应使用 BuildKit secret mount。密钥值变化通常不会自动使缓存失效,若必须重跑某层,可额外传入不敏感的 cache-bust 参数。

验证效果至少记录构建上下文大小、各步骤命中情况、冷构建耗时、仅修改源码后的热构建耗时。不要只比较一次总耗时,网络抖动会掩盖变化。也不能为了命中率永久忽略基础镜像更新,安全补丁仍应通过明确流程触发。

排查顺序是:找到第一处失效层;调整 COPY 顺序;补充 .dockerignore;为包管理器添加 cache mount;临时 CI 再配置外部缓存。每步都用两次构建和一次源码改动验证。这样知道时间省在哪里,也能避免 Dockerfile 只在作者机器上显得很快。

多阶段构建时还要区分构建缓存和最终镜像体积。把编译器放在 build 阶段、只复制产物到 runtime 阶段,可以缩小发布镜像,但不自动提高缓存命中率。反过来,cache mount 能减少重复下载,也不会自动减少最终镜像。两个目标要分别测量:一个看构建步骤,另一个看镜像层与运行时内容。

如果缓存突然全部失效,先查看 builder 是否更换、平台架构是否变化、基础镜像摘要是否更新,再检查 Dockerfile。远端缓存导入失败时,构建通常仍会继续,只是退化为冷构建,因此流水线显示成功并不代表缓存工作正常。应把缓存导入日志与命中率作为可观察项。

清理缓存也要有边界。频繁执行 docker builder prune 会把刚建立的收益全部清空,不清理又可能耗尽磁盘。可依据 builder 的存储上限和缓存年龄制定回收策略,保留近期常用分支,淘汰长期未访问记录。优化的目标是稳定可预测,不是让某一次构建跑出最好看的数字。

更多推荐