Docker 镜像构建提速实战:利用多阶段构建、BuildKit 与缓存策略优化中型项目 CI/CD 流程
中型项目做 CI/CD 时,最容易被忽略、但又最“烧时间”的环节,往往不是测试本身,而是 Docker 镜像构建。
很多团队一开始都能跑通流水线,但跑着跑着就会出现这些问题:
- 每次提交都要重新安装依赖,构建时间越来越长
- CI 节点是临时机器,本地有缓存,CI 没缓存
- Dockerfile 能用,但层次设计混乱,一改代码就全量失效
- 镜像体积大,推送慢,拉取也慢
- 明明只是改了业务代码,结果从系统依赖到应用打包全部重来
我自己在做中型 Node.js / Java / Go 项目容器化时,最常见的提速方式,基本都绕不开三件事:
- 多阶段构建
- BuildKit
- 缓存策略设计
这篇文章不只是讲概念,而是会带你做一套 可运行 的方案:从一个“能跑但慢”的 Dockerfile 出发,一步步优化到更适合 CI/CD 的版本。
背景与问题
先看一个很典型的场景:
- 项目规模:中型 Web 服务
- 技术栈:Node.js + 前端静态资源构建
- CI:GitLab CI / GitHub Actions / Jenkins 均可类比
- 痛点:
- 单次构建 8~15 分钟
- 安装依赖经常重复执行
- 镜像 800MB+
- 推镜像耗时明显
- 分支构建之间几乎不能共享缓存
一个常见但低效的 Dockerfile 大概长这样:
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
这个写法的问题很集中:
-
COPY . .太早
任何代码变更都会让后续npm install缓存失效。 -
构建环境和运行环境混在一起
最终镜像包含编译工具、缓存、源码、临时文件,体积大。 -
没有利用 BuildKit 的缓存挂载
即便依赖版本没变,CI 中仍可能重复下载包。 -
没有远程缓存
临时 Runner 每次都像“第一次构建”。
所以我们要解决的,不是单点提速,而是 把 Docker 构建过程变成“可缓存、可复用、可裁剪” 的流水线资产。
前置知识与环境准备
建议你先确认以下环境:
- Docker 20.10+
- 优先使用
docker buildx - 启用 BuildKit
- 一个示例 Node.js 项目,目录类似:
.
├── Dockerfile
├── package.json
├── package-lock.json
├── src/
├── public/
└── .dockerignore
启用 BuildKit 的方式:
export DOCKER_BUILDKIT=1
export COMPOSE_DOCKER_CLI_BUILD=1
如果你使用 buildx,一般可以直接:
docker buildx version
创建 builder:
docker buildx create --use --name mybuilder
docker buildx inspect --bootstrap
核心原理
这部分很重要。你如果理解了原理,后面写 Dockerfile 就不容易“拍脑袋”。
1. Docker 分层缓存的本质
Dockerfile 每一条指令都会形成一层。
如果某一层输入发生变化,后续层通常都要重建。
比如:
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
这比 COPY . . 再 npm ci 更好,因为:
- 只改业务代码时
package.json和package-lock.json没变npm ci那一层就能复用
2. 多阶段构建的作用
多阶段构建的核心目标不是“炫技”,而是:
- 构建阶段:安装编译依赖、执行打包
- 运行阶段:只保留运行所需产物
这样做的直接收益:
- 最终镜像更小
- 安全面更小
- 推送、拉取更快
- 运行环境更干净
3. BuildKit 的增益点
BuildKit 相比传统构建器,优势主要有:
- 更高效的构建 DAG 执行
- 更灵活的缓存机制
- 支持
RUN --mount=type=cache - 支持远程缓存导入导出
- 更适合 CI 的无状态构建场景
4. 缓存分层设计
在 CI/CD 里,缓存不要只理解成“有或没有”,更要看 缓存什么:
- 依赖缓存:如 npm/pnpm/maven/go mod 下载缓存
- 构建层缓存:Docker layer cache
- 远程缓存:Registry/本地目录/GHA cache
- 基础镜像缓存:避免反复拉取
一个合理的策略是:
- 依赖安装层尽量稳定
- 业务代码层允许频繁变化
- 构建缓存可上传到远端供下次复用
一图看懂优化思路
flowchart TD
A[代码提交] --> B[CI 拉取源码]
B --> C[BuildKit 读取远程缓存]
C --> D{依赖文件是否变化}
D -- 否 --> E[复用依赖层]
D -- 是 --> F[重新安装依赖]
E --> G[复制业务代码]
F --> G
G --> H[执行构建]
H --> I[多阶段复制产物到运行镜像]
I --> J[推送最终镜像]
H --> K[导出构建缓存到远程]
从慢到快:逐步改造 Dockerfile
下面我们以一个 Node.js 中型项目为例,演示从基础版到优化版的过程。
实战代码(可运行)
第 1 步:准备 .dockerignore
这一步很基础,但收益很高。很多人忽略了,结果把 node_modules、.git、日志文件全打进构建上下文。
node_modules
.git
dist
coverage
npm-debug.log
Dockerfile*
.dockerignore
README.md
如果你的项目需要在构建时读取某些文档或配置,再按需放开。
第 2 步:基础优化版 Dockerfile
先把依赖安装和源码复制拆开:
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
这个版本已经比最原始写法快很多了。
因为只改 src/ 时,npm ci 这一层可以复用。
但它仍然有几个问题:
- 构建工具和运行环境未分离
- 镜像仍然偏大
- CI 中依赖下载还可能重复
第 3 步:改造成多阶段构建
这是中型项目的推荐基础版。
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/index.js"]
这个写法的好处:
deps阶段单独处理开发依赖builder专门产出构建结果runner只保留生产运行所需内容
但它仍然会在 runner 阶段执行一次 npm ci --omit=dev。
这通常是可接受的,但如果你特别追求构建效率,还可以继续优化。
第 4 步:使用 BuildKit 缓存挂载
先在 Dockerfile 顶部加语法声明:
# syntax=docker/dockerfile:1.7
完整示例:
# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-alpine AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package.json ./
EXPOSE 3000
CMD ["node", "dist/index.js"]
这里的关键点是:
RUN --mount=type=cache,target=/root/.npm npm ci
它不会把 npm 缓存真正写进最终镜像层,而是作为 BuildKit 的缓存目录存在。
这对 CI 场景非常友好:减少重复下载依赖,但不污染镜像。
构建阶段关系图
flowchart LR
A[deps: npm ci] --> B[builder: npm run build]
C[prod-deps: npm ci --omit=dev] --> D[runner]
B --> D
D --> E[最终运行镜像]
第 5 步:在本地验证构建效果
本地执行:
docker buildx build \
--load \
-t myapp:dev .
运行容器:
docker run --rm -p 3000:3000 myapp:dev
查看镜像大小:
docker images | grep myapp
查看镜像层历史:
docker history myapp:dev
你可以做个简单实验验证缓存是否生效:
- 第一次构建
- 修改
src/中一个业务文件 - 重新构建
- 观察
npm ci是否跳过或明显加快
如果你修改的是 package-lock.json,那依赖层重新执行是正常现象。
CI/CD 中的缓存优化方案
本地快不算真正快,CI 里快 才是生产力提升。
下面给一个基于 GitHub Actions 的可运行示例。GitLab CI、Jenkins 原理类似。
GitHub Actions 示例
name: docker-build
on:
push:
branches: ["main"]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push with cache
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
tags: ghcr.io/example/myapp:latest
cache-from: type=registry,ref=ghcr.io/example/myapp:buildcache
cache-to: type=registry,ref=ghcr.io/example/myapp:buildcache,mode=max
这段配置解决了什么?
cache-from:从远程 Registry 拉取已有构建缓存cache-to:把本次构建缓存推回远程mode=max:尽可能保留更多层信息,适合追求构建速度
对于 临时 Runner 而言,这一点尤其重要。
没有远程缓存的话,每次都像“新机器第一次构建”。
CI 构建时序图
sequenceDiagram
participant Dev as 开发者
participant CI as CI Runner
participant Reg as 镜像仓库/缓存仓库
Dev->>CI: 提交代码触发流水线
CI->>Reg: 拉取 build cache
Reg-->>CI: 返回可复用缓存层
CI->>CI: BuildKit 执行构建
CI->>Reg: 推送业务镜像
CI->>Reg: 推送最新 build cache
Reg-->>CI: 完成
逐步验证清单
如果你想确认优化不是“心理安慰”,我建议按下面的清单验证:
验证 1:构建上下文是否缩小
执行:
docker buildx build --progress=plain --load -t myapp:test .
观察日志中的 transferring context。
如果上下文有几百 MB,通常说明 .dockerignore 还没配好。
验证 2:依赖层是否复用
只修改业务代码,例如:
echo "// test change" >> src/index.js
docker buildx build --progress=plain --load -t myapp:test .
看 npm ci 是否直接命中缓存,或明显更快。
验证 3:最终镜像是否变小
对比优化前后的镜像大小:
docker images
通常多阶段构建会显著减少镜像体积。
验证 4:CI 第二次构建是否更快
连续触发两次流水线,对比第二次构建时间。
如果远程缓存设置正确,第二次通常会有明显改善。
常见坑与排查
这部分非常重要。我当时踩过的坑,基本都集中在这里。
坑 1:BuildKit 没真正启用
现象:
RUN --mount=type=cache报错- 构建日志里看不出缓存挂载效果
排查:
docker buildx version
docker buildx inspect --bootstrap
echo $DOCKER_BUILDKIT
如果还是不确定,直接用:
DOCKER_BUILDKIT=1 docker build -t myapp:test .
坑 2:COPY . . 太早导致缓存雪崩
现象:
- 只改一个源码文件,依赖层也重跑
npm ci、go mod download、mvn dependency:go-offline每次都执行
修复方式:
先复制依赖描述文件,再安装依赖,最后再复制源码。
错误示例:
COPY . .
RUN npm ci
正确示例:
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
坑 3:.dockerignore 配置不当
现象:
- 构建上下文特别大
- 本地
node_modules被复制进去 - 导致缓存层混乱甚至平台不兼容
建议最少排除:
node_modules
.git
dist
coverage
特别是 Node.js 项目,不要把宿主机的 node_modules 直接带进镜像。
坑 4:远程缓存推不上去或拉不下来
现象:
- CI 每次都是冷构建
- 明明写了
cache-from/cache-to,但没有效果
排查方向:
- 是否已登录镜像仓库
- 缓存引用地址是否正确
- 是否有写权限
- 仓库是否支持缓存介质
- 构建器是否使用 buildx
比如先确认登录:
docker login ghcr.io
然后再检查 ref 是否一致:
ghcr.io/example/myapp:buildcache
别一边写 my-app,一边写 myapp,这种低级错误很常见。
坑 5:基础镜像过大,优化收益被抵消
如果你使用:
FROM node:20
和:
FROM node:20-alpine
最终效果通常差很多。
当然,alpine 也不是绝对最优,有些原生模块可能会遇到兼容性问题。
排查建议:
- 如果依赖包含原生编译模块,先验证是否适配 musl
- 不兼容时可考虑
slim版本
例如:
FROM node:20-slim
这是一个很现实的边界条件:不要为了镜像小,牺牲可维护性和兼容性。
安全/性能最佳实践
提速不是唯一目标。中型项目进入 CI/CD 后,安全性和可维护性同样重要。
1. 使用更小、更明确的基础镜像
优先考虑:
alpineslim- 官方维护镜像
不要随手用来源不明的第三方基础镜像。
2. 固定依赖版本,确保缓存稳定
如果你的依赖文件经常漂移,缓存命中率会很差。
建议:
- Node.js 用
package-lock.json - Python 用锁文件
- Java 用固定依赖版本
这不只是为了可复现,也是为了更稳地命中缓存。
3. 生产镜像中不要保留构建工具
比如 gcc、make、git、测试工具等,都应尽量留在构建阶段。
最终镜像只保留:
- 应用产物
- 运行时依赖
- 必要配置
4. 使用非 root 用户运行
即便这和“构建提速”无直接关系,也强烈建议加上。
例如:
# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package.json ./
RUN addgroup -S app && adduser -S app -G app
USER app
EXPOSE 3000
CMD ["node", "dist/index.js"]
5. 缓存不是越多越好
这是很多团队后面才意识到的问题。
缓存策略需要平衡:
- 更多缓存:更快
- 更多缓存:也可能占更多存储、带来过期数据、排障更复杂
建议:
- 活跃项目开启远程缓存
- 定期清理旧缓存
- 对主分支和发布分支保留更稳定的缓存策略
6. 关注“整体耗时”而不是只盯 Dockerfile
有时候你优化了 Dockerfile 2 分钟,但测试阶段浪费了 10 分钟。
所以建议把构建拆成指标看:
- 拉代码耗时
- Docker build 耗时
- 推送镜像耗时
- 测试耗时
- 部署耗时
这样你能判断瓶颈到底在构建、网络、仓库还是测试本身。
一套适合中型项目的推荐方案
如果你不想在选型上绕圈,我给一个比较稳妥的建议组合:
适用场景
- 中型 Web/API 项目
- CI Runner 为临时节点
- 构建频率较高
- 团队希望兼顾速度与可维护性
推荐组合
- Dockerfile 使用 多阶段构建
- 依赖安装前只复制锁文件
- 使用 BuildKit cache mount
- CI 使用 远程 Registry 缓存
- 配好
.dockerignore - 最终运行镜像裁剪为生产依赖
- 优先选官方
slim/alpine基础镜像 - 生产镜像使用非 root 用户
这个组合通常不是“理论最强”,但对中型团队来说,已经足够实用,而且维护成本可控。
完整示例汇总
Dockerfile
# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-alpine AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package.json ./
RUN addgroup -S app && adduser -S app -G app
USER app
EXPOSE 3000
CMD ["node", "dist/index.js"]
.dockerignore
node_modules
.git
dist
coverage
npm-debug.log
Dockerfile*
.dockerignore
README.md
本地构建命令
docker buildx build --load -t myapp:dev .
GitHub Actions
name: docker-build
on:
push:
branches: ["main"]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push with registry cache
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
tags: ghcr.io/example/myapp:latest
cache-from: type=registry,ref=ghcr.io/example/myapp:buildcache
cache-to: type=registry,ref=ghcr.io/example/myapp:buildcache,mode=max
总结
如果把这篇文章压缩成几条最关键的行动建议,那就是:
- 先拆层:依赖描述文件和业务代码分开复制
- 再分阶段:构建环境和运行环境分离
- 启用 BuildKit:用缓存挂载减少重复下载
- 把缓存搬到 CI 可复用的位置:尤其是远程 Registry 缓存
- 控制构建上下文:
.dockerignore不是可选项 - 关注边界条件:
alpine不一定适合所有原生依赖场景
如果你的项目还停留在“一个 Dockerfile 从头跑到尾”的阶段,先做完上面前三步,通常就能看到明显收益。
如果你已经用了多阶段构建,但 CI 仍然很慢,那大概率问题出在 远程缓存缺失 或 层设计不合理。
一句更务实的话:
镜像构建提速,不是靠一个神奇参数,而是靠 Dockerfile 分层设计 + BuildKit 能力 + CI 缓存体系三者配合。
把这三件事做好,中型项目的 CI/CD 体验会顺很多。