·约 8 分钟阅读·DeepSeek

慢速构建诊断清单:构建性能、构建缓存与 CI 速度

慢速构建几乎从来不是编译器的锅。这是一份清单:先测量冷构建与热构建,再找出真正的元凶——反复重装依赖、没有构建缓存、镜像过大、单线程运行——最后用缓存、增量构建与更短的关键路径把它们修掉。

devopslinux

我修过的每一个慢速构建,开头都一样:有人先有了一个猜测。「是 webpack。」「是 Docker pull。」「是 CI 机器,永远是 CI 机器。」然后我们真的去计时,猜测全是错的。一个 慢速构建几乎从来不是你能猜中的某一件东西——它是一叠小税,而构建性能的活儿大 部分其实是记账。所以这是一份清单,不是一篇吐槽:先测量,再搬大石头,然后在「优化一个 根本没人等的东西」之前停手。

为什么构建性能本质是一个测量问题

构建是一条流水线,不是一条命令。典型形状是:

安装依赖 → 类型检查 → 编译/打包 → 打镜像 → 推送 → 发布

整条流水线的墙钟时间不是各步骤之和,而是「和」再加上关键路径上的东西——也就是 最长的那条依赖链。一个十分钟的步骤如果和别的并行跑,它只花十分钟一次;一个两分钟的步 骤如果挡住后面三个,它就要了你六分钟。这就是「缓存一下不就行了」并不总是管用的原因, 也是为什么第一个诚实的问题从来不是我怎么把它弄快,而是时间到底花在哪儿。

冷构建与热构建:两个数都要报

冷构建什么都没有缓存:新克隆、空的模块缓存、没有 Docker 层。热构建复用本地 已有状态。两者可以差一个数量级,而把它们混为一谈,正是人们鸡同鸭讲的原因。

  • 你的笔记本通常是热的。你的 CI 默认是冷的——除非你配了缓存。
  • 「这个构建要四分钟」如果不说明是哪一种,等于什么都没说。
  • 冷与热之间的差距,恰好就是你的构建缓存的价值。它同时也度量了缓存能帮你省多少。

先测量:墙钟时间到底花在哪

在看到每一步的墙钟数字之前,别急着上 profiler。一个逐步骤的 time 循环通常就够了:

#!/usr/bin/env bash
# measure-build.sh —— 输出每一步的墙钟时间,最后按从慢到快排序。
set -euo pipefail

step() {                       # step NAME CMD...
  local name="$1"; shift
  local t0=$(date +%s.%N)
  "$@" >/dev/null 2>&1
  printf '%.1fs\t%s\n' \
    "$(echo "$(date +%s.%N) - $t0" | bc)" "$name"
}

{ step "install deps" npm ci
  step "typecheck"    npm run typecheck
  step "bundle"       npm run build
  step "docker image" docker build -q -t app .
} | sort -rn

跑两遍:一遍在干净检出上(冷),一遍在缓存已热时。这个差就是你的预算。当某一 步占了大头,就用工具自己的 profiler 往下钻——cargo build --timings、 docker build --progress=plain、webpack 的 --profile,或者干脆用 hyperfine 包住 某个子命令。流水线的发布那一侧也是同样的纪律,正如零停机部署里 讲的:度量那个卡住发布的步骤,而不是那个看起来很忙的步骤。

常见的元凶

大致按咬人的频率排序:

  1. 每次都重装依赖。 在冷缓存上跑 npm ci、pip install -r、go mod download—— 同样的字节,再取一遍。
  2. 完全没有缓存。 没有层缓存、没有依赖缓存、没有编译器缓存、没有测试结果缓存。构建 在重做它已经做过的事。
  3. 一个巨大的容器镜像。 一个 2 GB 的基础镜像,在你的代码还没开始之前就要拉 90 秒。
  4. 所有东西都单线程。 打包器、linter 或测试运行器在一台 16 核机器上只用一个核。
  5. 在 CI 里跑开发构建。 未压缩的输出、没人看的 source map、watch 模式的开销、忘了关 的 debug 断言。
  6. 不必要的步骤。 只改了文档却跑全套测试;只改了一个包却重建所有包。
  7. 受 I/O 限制的图片/资源处理。 一个一个地缩放、压缩、转码成千上万个文件,还压在慢 盘上。
  8. 关键路径上的网络取数。 每一次构建都同步地拉基础镜像、工具链或 registry。

这些大多不是「编译器」的问题。它们是管道的问题。

修复:构建缓存、增量构建与关键路径

自上而下地做工。先修占大头的步骤;在三分之一的步骤消失之前,别管那三秒的。

用你信得过的键来缓存层

构建缓存的好坏取决于它的键。按真正会变的输入来打键——lockfile 的哈希、源码哈希、 编译器版本——绝不要按 latest:

# 依赖很少变,源码一直在变。按这个顺序摆放,让昂贵的层在寻常改动下存活。
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run build

现在改一个源文件只会让最后一层失效。在 CI 里再加一对 cache-from/cache-to,让一次 性 runner 继承上一次构建的层,而不是从零开始。缓存里已有的东西,永远别重新下载。

让它增量

增量构建只重算变了的部分。这正是缓存变现的地方:tsc --incremental、cargo 的 target 目录、Vite 的依赖缓存、Gradle 的 up-to-date 检查、Bazel/Turborepo/Nx 的内容哈 希。代价是输入必须诚实——如果你的缓存键忽略了编译器会读的某个环境变量,你会得到一个很 快但错误的构建。失效逻辑要有意设计,别让它给你惊喜。我在版本控制这一侧的刻意做法,就是 单人开发者的 Git 工作流里的那套论点。

缩短关键路径

  • 并行化独立步骤:lint、类型检查、测试可以同时跑,多数打包器也接受线程数参数。
  • 把活儿挪出路径:后台拉镜像、用定时任务预热缓存、按定时器预构建基础镜像。
  • 砍掉依赖图:更少的包是永久提速,而不是一次性技巧。拿掉一个沉重的传递依赖,可能比 任何编译选项都省得多。
  • 换更快的 I/O:在本地 NVMe 上构建,别放在网络卷上。在 NFS 上搬几万个碎文件,往往就 是「编译器很慢」背后真正的瓶颈。
  • 跳过没变的东西:路径过滤、受影响包检测,以及 [skip ci] 一类的护栏,能省掉你根本 不必付的那些开销。

前后对比:一次构建,两组数字

这是我修剪过的一个真实 CI 任务。同一个提交、同一个 runner,只改了管道:

BEFORE (冷)                         AFTER (热缓存)
  安装依赖        214s                安装依赖        27s
  lint+类型检查    38s                lint+类型检查    38s
  打包           142s                打包            46s
  docker 镜像     189s                docker 镜像     34s
  推送镜像         47s                推送镜像         12s
  发布            61s                发布             9s
  ----------------------------------  ----------------------------------
  合计           691s                合计           166s

691 秒降到 166 秒——大约 4 倍,而最大的几笔进账都很朴素:缓存依赖步骤、调整 Docker 层 顺序、换个更小的基础镜像、让打包器吃满所有核。没什么花招。剩下的是关键路径上排队的工作。

BEFORE — 691s,一条长路径 安装依赖 打包 docker 镜像 AFTER — 166s,缓存 + 重叠 依赖(已缓存) 打包 + 镜像,并行 ← 每个方块是墙钟时间;宽度在各区块内部相对 →
同样的工作,两种形状。缓存把方块缩短;并行化和更小的基础镜像让剩下的工作离开关键路径。

CI 速度:那些不属于你编译器的部分

CI 速度是另一个问题,本地构建速度替代不了,它有自己的翻车方式。一次性 runner 永远 是冷的,所以缓存必须显式配置,而且每一次缓存上传和下载也要花墙钟时间。并发上限意味着你 的任务排在别人后面。一个被限到 1 个 vCPU 的 runner,会让所有并行构建参数形同虚设。当构 建在你笔记本上很快、在 CI 里很慢时,差异几乎总是缓存状态、核数或磁盘——在动配置之前, 先查这三样。

陷阱:优化一个根本没人等的构建

目标不是最快的构建。目标是没人会注意到的构建。一条夜里跑、从不挡人的发布流水线已经完成 了;把它从九分钟削到八分钟,什么都没买到。要优化的是压在人的关键路径上的那些构建 ——你在「改一处」和「跑测试」之间跑的那个,卡住部署的那个。改个文件在 CI 里等 40 秒?那 值得投入。一个你启动了就走开的每周任务?让它慢着吧。

所以这份清单结束在它开始的地方:测量,找到占大头的步骤,缓存它,把它增量 化,并行化剩下的——然后停手。 最好的构建性能,就是你不再关心它、因为它从不让你等待 的那个构建。