AI 编程让 CI 成为瓶颈,因此我们重构了 CI 以跟上步伐

今年早些时候,我打开 Linear 发现我们的 CTO Tuomas 给我分配了一个问题,标题是“CI 成本很高”。同时,他还希望我让 CI 更快。
智能体让代码交付速度呈指数级提升,但验证这些变更的速度却没有跟上。每个 PR 仍然必须通过 CI,因此随着开发加速,CI 成为瓶颈,推高了基础设施成本,并让开发者和智能体等待反馈的时间更长。
在 Linear,为了让 CI 性能更高,我们针对 PR 等待 CI 的时间以及消耗的运行器时间进行了优化。尽管自今年年初以来我们的测试套件几乎翻了两番,我们将拉取请求等待时间从超过 6 分钟降至略高于 5 分钟,同时将每个测试的运行器时间大约减半。

总体而言,我们从四个方面改进了 CI:
- 升级基础设施和工具
- 优化作为其他工作门禁的任务
- 减少重复设置
- 提高测试执行效率
Linear 的代码库主要是 TypeScript,但其中许多优化适用于各种语言和工具链。
升级基础设施和工具
我们最早的一些收益几乎不需要对 CI 本身进行优化。将工作负载从 GitHub Actions 迁移到具有更快 CPU、更高性能存储和更好缓存基础设施的第三方运行器,让我们能在更快的机器上运行相同的流水线。在切换前后两天的同类比较中,任务平均运行速度提高了 34%,其中像 tsc 这样的工作负载下降了 52%。
另外,现代化我们的工具链也带来了回报。切换到 tsgo(原生 TypeScript 编译器)后,tsc 检查的每周中位数时间减少了 73%,足以将瓶颈完全从类型检查上移开。
无需类型检查器的 Lint
Lint 是另一个早期目标。我们的一些自定义 lint 规则依赖 TypeScript 类型信息,要么是为了强制执行限制,要么是为了应用自动修复。这意味着每次 lint 运行都必须在评估这些规则之前构建完整的类型图,使 lint 成为我们最消耗内存的 CI 任务之一。
我们重写了这些规则,改为对抽象语法树进行静态分析,在没有类型信息的情况下识别类函数结构和守卫模式。这让 ESLint 完全摆脱了 TypeScript,将 API lint 时间减少了 68%,全仓库 lint 时间减少了 55%。内存使用也大幅下降。
移除对类型信息的依赖也让我们后来迁移到 Oxlint 变得容易得多,因为纯粹基于语法的规则很容易移植。Oxlint 本身减少了用于 lint 的 CI 运行器分钟数。
优化作为其他工作门禁的任务
在底层基础设施和各项检查运行得更快之后,我们将视角拉远,把 CI 作为一个系统来审视。这让我们注意到那些位于所有其他环节之前的小任务。每次运行开始时,都会检查 PR 触及了哪些路径,以及这些测试是否已经针对相同的输入通过。我们在作业级别对这些检查进行门控,这样被跳过的工作就不会占用运行器,但这也将它们直接置于关键路径上。八个 API 测试分片中的任何一个都必须等它们完成后才能开始,这使得即使是微小的延迟也变得异常重要。
只获取每个作业所需的内容
我们的几个工作流以一个变更检测作业开始,该作业决定接下来运行什么;例如,它会检查 diff 中是否包含数据库迁移,并输出一个用于调度相关数据库 CI 检查的信号。这些作业会检出完整的工作树,尽管它们只需要其中的一小部分。我们限制了获取深度,将这之中最慢的门控从 94 秒缩短到 20 秒,并从那些从不需要工作树的作业中完全移除了检出,将这些作业的耗时从 27 秒减少到 7 秒。对于提交推送和合并队列事件,我们确实需要对比路径,我们发现使用稀疏、无 blob 且历史有限的检出就足够了,又节省了大约 11 秒。

让检出更具弹性
在我们更换底层运行器基础设施后,我们注意到作业中的检出时间(使用 actions/checkout
)变长了,有时还会挂起。由于第三方运行器位于 GitHub 网络之外,它们依赖直接 IP 链路来访问 GitHub。提供商将挂起问题追溯到该链路的间歇性降级。我们的几个工作流以检出开始,因此获取停滞可能会延迟整个 CI 运行。
为了应对网络不稳定,我们将 actions/checkout
替换为我们自己的复合操作,该操作会进行带退避的重试,并设置 GIT_HTTP_LOW_SPEED_LIMIT
和 GIT_HTTP_LOW_SPEED_TIME
,这样停滞的连接会在约 30 秒后中止,而不是挂起,同时还使用了检出缓存,它在粘性磁盘上保留一个持久的 git 镜像。结果是关键路径作业因等待检出完成而空闲的运行次数大大减少。
最小化关键路径上的内容
并非关键路径上的每个作业都需要在那里。我们在合并前的最终检查中写入缓存标记,这意味着即使测试已经通过,拉取请求也可能滞留在合并队列中。我们将该写入移到一个在测试分片完成后运行但不门控任何内容的作业中,为每个 API 拉取请求和合并队列条目从合并路径中节省了 42 秒。
总的来说,这些更改在缓存未命中的情况下,将 API 拉取请求所需检查的时间缩短了大约一分钟,同时还减少了运行器的启动次数。
减少重复设置
从那里开始,我们转向了每个作业中重复的设置成本,例如启动运行器、安装包和配置构建依赖项。这种开销意味着一个只做几秒钟有用工作的作业最终可能消耗数分钟的基础设施时间。以下是我们为解决该问题而采取的几个步骤:
我们的 API 测试分片每次运行都要花 7 到 8 秒用 apt 安装同一个 Postgres 客户端。我们把它移进了一个包含 Node 和该客户端的小型 CI 基础镜像,这样每个分片都能从一个准备就绪的环境中启动。后来我们发现,在安装过程中下载所需的原生构建头文件有时会卡住,于是把它们也加进了镜像,从而缩短了长尾延迟。
只安装每个任务所需的依赖
Linear 的代码库是一个用 pnpm workspace 管理的 monorepo。我们的 API 测试工作流虽然只需要 API 包及其依赖,却安装了整个 workspace。将安装范围限制在 API 包后,pnpm install 从 44-73 秒降到了 16-18 秒。我们对与 API 相邻的任务也采用了同样的模式,这些任务原本各自安装整个仓库,并上传一个后续运行几乎不会命中的依赖缓存。
当重新构建更快时,就不要用缓存
我们还测试了缓存 node_modules
,结果发现重新构建更快。缓存键依赖于频繁变化的 lockfile,而且即使缓存命中,恢复也要大约 28 秒,而过滤安装只需约 7.5 秒。缓存增加了保存时间和不稳定性,却没有带来任何明显优势。
这三项改动加起来,将每个分片的设置时间减少了约 44%,从 110-140 秒降至 67-73 秒。

除此之外,还有其他形式的重复设置可以完全避免。
避免重放未更改的设置
有些设置工作只有在输入变化时才需要重复。例如,我们的 API 容器每次运行都会重放完整的数据库迁移历史,即使 PR 没有更改 schema。对于这些情况,我们改为加载生成的 schema 快照和引导文件,将每个容器的数据库设置时间从大约 12 秒缩短到 1-2 秒。
将短检查批量合并到更少的任务中
七个独立的检查各自都要启动一个 runner、检出仓库、安装依赖,然后只做几秒钟的有用工作。我们将它们合并为两个任务,然后在这两个任务中并发运行这七个任务。这样,我们支付相同设置开销的次数从七次减少到两次。根据 6 月的使用量,这一改动每月节省了约 87,000 runner 分钟,相当于我们 CI 总使用量的 11.8%。

让测试执行更高效
随着每个测试分片的固定成本降低,我们可以更积极地并行化 API 测试套件。它是我们工作流中最大且执行最频繁的部分之一,因此这里的改进对合并时间产生了超乎比例的影响。
按照测试运行器看到的方式来平衡工作
Vitest 是我们用于 TypeScript 测试套件的测试运行器,它按文件而非单个测试的持续时间来分配工作。这意味着少数异常大的测试文件可能会主导一个分片,并实际上阻碍整个套件的完成,即使其他分片早已完成。
我们将这些大文件拆分成更小、更专注的文件,同时保留测试的结构,然后评估了不同的分片和运行器配置。今年早些时候,我们已经从三个分片增加到四个;在我们的初步基准测试中,增加到八个分片使关键任务大约快了 19%,成本降低了 19%。更改一周后,最慢的分片从 5.25 分钟降至 4.33 分钟。
Vitest 通常会隔离每个测试文件,对我们来说,这意味着在每个测试分片中重建实体、GraphQL 和装饰器图。我们引入了一个可选的 vitest 项目,使用 isolate: false
,允许安全的文件在每个 worker 内共享模块注册表。

这是我们最大的单项性能改进,在我们的规模下,每月节省约 17%。最慢的分片从大约 300-379 秒降至约 195 秒,而 API 分片运行器总时间从每次运行约 32.8 分钟降至 22 分钟。
这也是正确性风险最高的优化。我们通过在每个文件上添加可选注释来明确资格,并为共享状态添加了必要的清理。少数文件以我们无法安全解开的方式使用了假定时器或共享状态,因此我们将它们留在隔离项目中。而且,由于现在大多数测试由代理编写,我们更新了各自的代理技能,以考虑这种性能选择,因此生成的测试默认遵循相同的约束。
进一步分片只有在每个分片的固定成本较低时才有回报,因为分片数量加倍也会使工作流在设置上花费的时间加倍。我们之前提到的设置优化使得八个分片变得可行。如果每个分片需要 110-140 秒,八个分片将仅在设置上就花费 15-19 分钟的 runner 时间,比测试本身还多。现在设置大约需要 40 秒,因此八个分片的总设置时间比之前的四个分片更少,同时将测试并行化了两倍。

在系统中累积的改进
如果我们今年早些时候没有刻意努力改进 CI,今天的测试套件将需要大约 11 分钟,几乎是开发人员现在等待时间的两倍。而且工作并未结束。很明显,我们的代码库将继续增长;我们目前每周大约添加 2,000 个测试。在此过程中保持 CI 快速将是一项持续的努力,其中大部分将利用我们在此过程中学到的东西来应对新的瓶颈。

