Docker BuildKit 缓存优化:让增量构建真正快起来
一、改一行代码,重新构建 20 分钟
Docker 镜像构建慢,最根本的原因不是网络、不是 CPU,而是缓存未命中后的级联重建。你在package.json里加了一个依赖,然后npm install层失效了——但紧接着COPY . .层也失效了,整个构建从零开始。
BuildKit 比传统 Docker build 多了三种缓存机制:--mount=type=cache、--mount=type=bind和RUN --mount=type=secret。但真正让增量构建飞跃的是cache mount + layer 共享 + 远程缓存的组合拳。
核心思路:把易变层推到最上层,让不变的依赖层始终命中缓存。这要求你必须理解 Dockerfile 的层序与缓存失效链。
flowchart TD A[源码变更] --> B{Dockerfile 指令} B -->|FROM| C[基础镜像层 - 极少变] B -->|RUN apt-get| D[系统依赖层 - 偶尔变] B -->|COPY package.json| E[npm install 层 - 偶尔变] B -->|COPY . .| F[源码层 - 经常变] B -->|RUN build| G[构建产物层 - 经常变] C --> H{缓存检查} D --> H E --> H F --> H G --> H H -->|命中| I[复用层] H -->|未命中| J[重建当前层及之后所有层] I --> K[镜像导出] J --> K二、Cache Mount 的底层机制
--mount=type=cache不是把目录打进镜像,而是在构建过程中挂载一个持久化的缓存目录,构建结束后不随中间层消失。
这是它与传统 layer cache 的根本区别:
- Layer cache:整个
RUN指令的产物被保存为一个 layer,node_modules被永久嵌入镜像。 - Cache mount:
node_modules/.cache等中间产物存在构建缓存里,最终镜像不包含这些文件。
这意味着你可以在 CI 中跨构建复用缓存,且不会污染最终镜像体积。
三、多阶段构建 + Cache Mount 实战
# syntax=docker/dockerfile:1.4 # 必须声明 syntax 版本,否则不支持 RUN --mount=type=cache # ==== 阶段一:依赖安装 ==== FROM node:20-alpine AS deps WORKDIR /app # 先只复制依赖文件,让它形成独立的缓存层 # 这样 package.json 不变时,这一层始终命中 COPY package.json package-lock.json ./ # cache mount 关键点: # id 全局唯一,sharing=locked 防止并发写入 # 跨构建复用的缓存加速效果远比 layer cache 显著 RUN --mount=type=cache,target=/root/.npm,id=npm_cache,sharing=locked \ npm ci --prefer-offline --no-audit --no-fund # ==== 阶段二:构建 ==== FROM node:20-alpine AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . # TypeScript 编译缓存也用 cache mount,跨构建复用 RUN --mount=type=cache,target=/root/.npm,id=npm_cache \ npm run build # ==== 阶段三:生产运行时 ==== FROM node:20-alpine AS runner WORKDIR /app # 锁定 NODE_ENV,避免 devDependencies 干扰 ENV NODE_ENV=production # 创建非 root 用户,安全基线 RUN addgroup --system app && adduser --system --ingroup app app # 只复制必要产物 COPY --from=builder --chown=app:app /app/dist ./dist COPY --from=deps --chown=app:app /app/node_modules ./node_modules COPY --chown=app:app package.json ./ USER app # Node.js 推荐参数: # --max-old-space-size 限制内存,防止 OOM 被 K8s kill # --enable-source-maps 保留堆栈可读性 CMD ["node", "--max-old-space-size=512", "--enable-source-maps", "dist/server.js"]四、远程缓存的落地问题
只在单机构建,本地 BuildKit 缓存足够。但在 CI 中(每次 Job 在新 Runner 上执行),必须使用远程缓存。
两种方式:
- Registry cache:
--cache-to type=registry,ref=myregistry/cache:latest,推送到镜像仓库。缺点是 cache 本身也是镜像层,占仓库空间。 - S3/GCS cache:
--cache-to type=s3,region=us-east-1,bucket=my-cache,直接推对象存储。延迟高但成本低。
选择策略:CI 构建量大(>100 次/天)→ S3 降低成本;构建量小 → Registry 更简单。
真正拖慢构建的往往是这些事
npm install没有用--prefer-offline,每次都全量下载COPY . .包含了.git、node_modules等不必要的文件——没配.dockerignoreapt-get update每次执行,没有做 layer cache
五、总结
Docker 增量构建的关键不在于 BuildKit 本身,而在于层序设计 + 缓存挂载 + 远程缓存三板斧。先排好不可变层、再上 cache mount 消除中间产物膨胀、最后用远程缓存打通 CI。三件事做到位,改一行代码的构建时间从 20 分钟压缩到 30 秒是可达的。