跳转到内容
123xiao | 无名键客

《Docker 镜像构建提速实战:利用多阶段构建、BuildKit 与缓存策略优化中型项目 CI/CD 流程》

字数: 0 阅读时长: 1 分钟

Docker 镜像构建提速实战:利用多阶段构建、BuildKit 与缓存策略优化中型项目 CI/CD 流程

中型项目做 CI/CD 时,最容易被忽略、但又最“烧时间”的环节,往往不是测试本身,而是 Docker 镜像构建

很多团队一开始都能跑通流水线,但跑着跑着就会出现这些问题:

  • 每次提交都要重新安装依赖,构建时间越来越长
  • CI 节点是临时机器,本地有缓存,CI 没缓存
  • Dockerfile 能用,但层次设计混乱,一改代码就全量失效
  • 镜像体积大,推送慢,拉取也慢
  • 明明只是改了业务代码,结果从系统依赖到应用打包全部重来

我自己在做中型 Node.js / Java / Go 项目容器化时,最常见的提速方式,基本都绕不开三件事:

  1. 多阶段构建
  2. BuildKit
  3. 缓存策略设计

这篇文章不只是讲概念,而是会带你做一套 可运行 的方案:从一个“能跑但慢”的 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"]

这个写法的问题很集中:

  1. COPY . . 太早
    任何代码变更都会让后续 npm install 缓存失效。

  2. 构建环境和运行环境混在一起
    最终镜像包含编译工具、缓存、源码、临时文件,体积大。

  3. 没有利用 BuildKit 的缓存挂载
    即便依赖版本没变,CI 中仍可能重复下载包。

  4. 没有远程缓存
    临时 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.jsonpackage-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

你可以做个简单实验验证缓存是否生效:

  1. 第一次构建
  2. 修改 src/ 中一个业务文件
  3. 重新构建
  4. 观察 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 cigo mod downloadmvn 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,但没有效果

排查方向:

  1. 是否已登录镜像仓库
  2. 缓存引用地址是否正确
  3. 是否有写权限
  4. 仓库是否支持缓存介质
  5. 构建器是否使用 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. 使用更小、更明确的基础镜像

优先考虑:

  • alpine
  • slim
  • 官方维护镜像

不要随手用来源不明的第三方基础镜像。


2. 固定依赖版本,确保缓存稳定

如果你的依赖文件经常漂移,缓存命中率会很差。
建议:

  • Node.js 用 package-lock.json
  • Python 用锁文件
  • Java 用固定依赖版本

这不只是为了可复现,也是为了更稳地命中缓存。


3. 生产镜像中不要保留构建工具

比如 gccmakegit、测试工具等,都应尽量留在构建阶段。
最终镜像只保留:

  • 应用产物
  • 运行时依赖
  • 必要配置

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 为临时节点
  • 构建频率较高
  • 团队希望兼顾速度与可维护性

推荐组合

  1. Dockerfile 使用 多阶段构建
  2. 依赖安装前只复制锁文件
  3. 使用 BuildKit cache mount
  4. CI 使用 远程 Registry 缓存
  5. 配好 .dockerignore
  6. 最终运行镜像裁剪为生产依赖
  7. 优先选官方 slim/alpine 基础镜像
  8. 生产镜像使用非 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

总结

如果把这篇文章压缩成几条最关键的行动建议,那就是:

  1. 先拆层:依赖描述文件和业务代码分开复制
  2. 再分阶段:构建环境和运行环境分离
  3. 启用 BuildKit:用缓存挂载减少重复下载
  4. 把缓存搬到 CI 可复用的位置:尤其是远程 Registry 缓存
  5. 控制构建上下文.dockerignore 不是可选项
  6. 关注边界条件alpine 不一定适合所有原生依赖场景

如果你的项目还停留在“一个 Dockerfile 从头跑到尾”的阶段,先做完上面前三步,通常就能看到明显收益。
如果你已经用了多阶段构建,但 CI 仍然很慢,那大概率问题出在 远程缓存缺失层设计不合理

一句更务实的话:
镜像构建提速,不是靠一个神奇参数,而是靠 Dockerfile 分层设计 + BuildKit 能力 + CI 缓存体系三者配合。

把这三件事做好,中型项目的 CI/CD 体验会顺很多。


分享到:

上一篇
《面向中型业务的集群架构设计实战:从高可用部署、流量调度到故障切换的落地方案》
下一篇
《Java开发踩坑实战:排查并修复 Spring 事务失效的 8 个高频场景》