Linear 分享 CI 提速实践:PR 等待时间从 6 分多降至 5 分出头,单测机器时间约减半
AI coding has made CI a bottleneck, so we reworked ours to keep up
Linear 工程团队详细介绍其 CI 优化实践:在测试套件今年年初以来近乎翻四倍的情况下,PR 等待时间从超过 6 分钟降到 5 分出头,每项测试的 runner 时间约减半。
Linear 自述在测试套件近乎翻四倍的情况下把 CI 等待时间和 runner 成本降下来,四类优化手法可迁移到其他工程团队。
今年早些时候,我打开 Linear,发现 CTO Tuomas 给我指派了一个 issue,标题是“CI 成本太高”。顺便,他还希望我把 CI 做得更快。
智能体让代码交付速度成倍提升,但验证这些改动却没能跟上同样的节奏。每个 PR 仍然必须通过 CI,所以随着开发提速,CI 就成了瓶颈,推高基础设施成本,也让开发者和智能体等反馈等得更久。
为了让 Linear 的 CI 性能更好,我们主要优化两个指标:PR 在 CI 上等待的时间,以及消耗的 runner 时间。尽管从年初到现在,我们的测试套件规模几乎翻了两番,但 PR 等待时间从 6 分钟以上降到了 5 分钟出头,同时每个测试的 runner 时间大约减半。

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

让检出更稳健
更换底层 runner 基础设施之后,我们发现任务中的检出耗时(使用 actions/checkout)变长了,有时还会挂起。由于第三方 runner 位于 GitHub 网络之外,它们依赖直连 IP 链路访问 GitHub。服务商排查后认为,挂起是该链路间歇性劣化导致的。我们有多个工作流以检出开头,所以一次拉取卡住就可能拖慢整个 CI 运行。
为了应对网络不稳定,我们用自己的复合 action 替换了 actions/checkout,它会带退避地重试,并设置 GIT_HTTP_LOW_SPEED_LIMIT 和 GIT_HTTP_LOW_SPEED_TIME,让卡住的连接在约 30 秒后中止而不是一直挂着,同时还使用检出缓存,在粘性磁盘上保留一个持久的 git 镜像。结果是关键路径任务空等检出完成的情况大幅减少。
尽量减少关键路径上的环节
关键路径上的任务并非都必须留在那里。我们原本在合并前的最后一项检查中写入缓存标记,这意味着一个 PR 即使测试已经通过,仍可能滞留在合并队列里。我们把这步写入移到一个在测试分片完成后运行、但不作为任何门禁的任务中,从而为每个 API PR 和合并队列条目在合并路径上省下 42 秒。
这些改动合在一起,让 API PR 在缓存未命中时的必需检查缩短了大约一分钟,同时也减少了 runner 的启动次数。
减少重复的环境准备
接着,我们转向每个任务都会重复付出的准备成本,比如启动 runner、安装软件包、准备构建依赖。这些开销意味着一个只做几秒钟有效工作的任务,可能消耗掉好几分钟的基础设施时间。以下是我们为绕开这个问题采取的几项措施:
在 CI 镜像中预装共享依赖
我们的 API 测试分片每次运行都要用 apt 安装同一个 Postgres 客户端,各花 7 到 8 秒。我们把它移进一个包含 Node 和该客户端的小型 CI 基础镜像,这样每个分片都能从一个开箱即用的环境启动。后来我们发现,在安装过程中下载所需的原生构建头文件偶尔会卡住,于是把这些头文件也加进了镜像,缩短了长尾耗时。
只安装每个任务真正需要的依赖
Linear 的代码库是一个用 pnpm workspace 管理的 monorepo。我们的 API 测试工作流原本会安装整个 workspace,尽管它只需要 API 包及其依赖。把安装范围限制在 API 包后,pnpm install 从 44-73 秒降到 16-18 秒。我们把同样的做法套用到与 API 相邻的任务上——这些任务原本各自安装整个仓库,还上传一份后续运行几乎从不命中的依赖缓存。
重建更快时就不要缓存
我们还测试了缓存 node_modules,结果发现重建更快。缓存键依赖一个频繁变动的 lockfile,而且即便命中缓存,恢复也要约 28 秒,而过滤安装只需约 7.5 秒。缓存只增加了保存时间和波动性,没带来任何明显好处。
这三项改动合起来,把每个分片的准备时间缩短了约 44%,从 110-140 秒降到 67-73 秒。

除此之外,还有一些重复的准备步骤可以彻底省掉。
避免重放未变化的准备步骤
有些准备工作只有在输入变化时才需要重做。比如我们的 API 容器,每次运行都会重放完整的数据库迁移历史,哪怕这个 PR 并没有改动 schema。对这类情况,我们改为加载生成的 schema 快照和 bootstrap 文件,把每个容器的数据库准备时间从约 12 秒降到 1-2 秒。
把零散的短检查合并成更少的任务
有七项独立检查各自都要启动 runner、检出仓库、安装依赖,然后只做几秒钟的实际工作。我们把它们合并成两个任务,再在这两个任务里并发执行这七项检查。这样,重复支付同一笔准备开销的次数从七次降到两次。按六月的用量计算,这项改动每月节省约 87,000 runner 分钟,相当于我们 CI 总用量的 11.8%。

让测试执行更高效
每个测试分片的固定成本降下来后,我们就有余力把 API 测试套件并行得更激进。它是我们工作流中规模最大、执行最频繁的部分之一,因此这里的改进对合并时间的影响格外大。
按测试运行器的视角来平衡工作
Vitest 是我们用于 TypeScript 测试套件的测试运行器,它按文件而非单个测试的时长来分配工作。这意味着少数异常大的测试文件可能主导一个分片,实际上拖住整个套件的完成,哪怕其他分片早就跑完了。
我们在保持测试结构不变的前提下,把这些大文件拆成更小、更聚焦的文件,然后评估了不同的分片和 runner 配置。今年早些时候我们已经从三个分片增加到四个;在初步基准测试中,增加到八个让关键任务快了约 19%,成本也低了约 19%。改动一周后,最慢的分片从 5.25 分钟降到 4.33 分钟。
只在严格的隔离规则下共享模块状态
Vitest 默认会隔离每个测试文件,对我们来说,这意味着每个测试分片都要重新构建实体、GraphQL 和装饰器图。我们引入了一个可选的 vitest 项目,配置了 isolate: false,让安全的文件可以在每个 worker 内共享模块注册表。

这是我们单项性能提升最大的一次,按我们的体量计算,每月大约节省 17%。最慢的分片从约 300-379 秒降到约 195 秒,API 分片的 runner 总耗时从每次运行约 32.8 分钟降到 22 分钟。
这也是正确性风险最高的一项优化。我们要求每个文件都显式加上 opt-in 注释来声明资格,并为共享状态补上了必要的清理逻辑。有少数文件使用了假定时器或共享状态,我们无法安全地拆解,就把它们留在了隔离项目里。另外,现在大部分测试由智能体编写,我们同步更新了各自的智能体技能,把这项性能 opt-in 也纳入其中,让生成的测试默认遵循同样的约束。
分片受限于初始化开销
只有当每个分片的固定成本足够低时,继续增加分片才有收益,因为分片数翻倍,工作流花在初始化上的时间也会翻倍。正是前面提到的初始化优化,才让 8 个分片变得可行。按每个分片 110-140 秒计算,8 个分片光初始化就要花掉 15-19 分钟的 runner 时间,比测试本身还多。现在初始化约 40 秒,8 个分片的总初始化时间比之前的 4 个分片还少,同时测试的并行度翻了一倍。

能在整个系统中叠加的改进
如果今年早些时候我们没有刻意去改进 CI,今天的测试套件大约要跑 11 分钟,接近开发者现在等待时间的两倍。这件事也还没结束。我们的代码库显然会继续增长,目前每周大约新增 2,000 个测试。在增长的同时保持 CI 快速将是一项持续的工作,其中很大一部分是把这次过程中学到的东西用到新的瓶颈上。

来源:Linear:Now · linear.app