AI 编程让 CI 成为瓶颈,Linear 如何重构 CI 跟上节奏
年初,我打开 Linear 发现 CTO Tuomas 给我分配了一个标题为“CI 成本过高”的任务。与此同时,他还希望我能让 CI 运行得更快。
AI 智能体让代码交付速度呈指数级增长,但验证这些变更的速度未能同步跟上。每个 PR 依然必须通过 CI,因此随着开发速度加快,CI 成了瓶颈,不仅推高了基础设施成本,还让开发者和智能体等待反馈的时间变长。
为了让 Linear 的 CI 表现更好,我们将优化目标定为缩短 PR 在 CI 上的等待时间,并降低其消耗的 Runner 时间。尽管年初至今测试套件规模已扩大近 四倍,我们仍将 PR 等待时间从 6 分钟以上缩短至 5 分钟出头,同时让每个测试的 Runner 时间减少了一半。
总体而言,我们通过四个方面改进了 CI:
- 升级基础设施和工具链
- 优化阻碍其他工作的关键任务(Gate jobs)
- 减少重复初始化操作
- 提升测试执行效率
Linear 的代码库主要基于 TypeScript,但上述许多优化适用于多种语言和工具链。
升级基础设施和工具链
最初的一些收益几乎无需对 CI 本身进行优化。将工作负载从 GitHub Actions 迁移到第三方 Runner 上,利用更快的 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 runner 分钟数。
优化那些阻塞其他工作的任务
在底层基础设施和单个检查的运行速度都提升后,我们退后一步,从系统层面审视 CI。这让我们注意到那些置于所有其他工作之前的小型任务。每次运行都始于检查 PR 触及的路径,以及针对相同输入是否已有通过的测试记录。我们在任务层面进行门禁控制,确保被跳过的任务不占用 runner,但这也使它们直接位于关键路径上。在它们完成之前,八组 API 测试分片都无法启动,因此哪怕微小的延迟影响也成倍放大。
各任务只拉取所需内容
我们的多个工作流以变更检测任务开始,该任务决定下一步运行什么;例如,它检查 diff 中是否包含数据库迁移,并输出信号用于调度相关的数据库 CI 检查。这些任务尽管只需要工作区的一小部分,却拉取了完整的工作树。我们限制了 fetch 深度,其中最慢的一个门禁任务从 94 秒降至 20 秒;对于那些根本不需要工作区的任务,我们直接移除 checkout 步骤,将其耗时从 27 秒降至 7 秒。对于需要 diff 路径的 commit push 和 merge-queue 事件,我们发现采用带有限历史记录的稀疏、无 blob checkout 就足够了,又节省了约 11 秒。


让 checkout 更可靠
更换底层 runner 基础设施后,我们发现任务里的 checkout 步骤(使用 actions/checkout)耗时变长,而且偶尔会卡住。由于第三方 runner 位于 GitHub 网络之外,只能通过直接的 IP 链路访问 GitHub。服务商排查后确认,卡顿源于这条链路的间歇性劣化。我们的不少 workflow 都以 checkout 开头,一旦 fetch 卡住,整个 CI 流程都会被拖慢。
为了应对网络不稳定,我们用自己写的 composite action 替换了 actions/checkout:它支持带退避的重试,并设置 GIT_HTTP_LOW_SPEED_LIMIT 和 GIT_HTTP_LOW_SPEED_TIME,让停滞的连接在约 30 秒后自动中断而不是无限挂起;同时还启用了 checkout 缓存,在粘性磁盘上维护一个持久的 git mirror。结果是关键路径上的任务因等待 checkout 而空转的情况大幅减少。
尽量精简关键路径
关键路径上并非每个任务都必须留在那里。我们原本在合并前的最终检查中写缓存标记,这意味着即使测试已经通过,pull request 也可能继续滞留在 merge queue 里。我们把这个写入操作移到了一个在测试分片完成后运行、但不阻塞任何流程的任务中,这样每个 API pull request 和 merge queue 条目在合并路径上就能节省 42 秒。
综合这些改动,在缓存未命中时,API pull request 的必需检查时间缩短了约一分钟,runner 的启动次数也随之减少。
减少重复的初始化配置
接下来,我们着手解决每个任务都在重复承担的初始化开销,例如启动 runner、安装软件包以及配置构建依赖。这些额外开销导致即使任务本身的有效工作只持续几秒钟,却可能消耗数分钟的基建时间。为了应对这个问题,我们采取了以下几个措施:
在 CI 基础镜像中预装共享依赖
我们的 API 测试分片每次运行时都要花 7 到 8 秒通过 apt 安装同一个 Postgres 客户端。我们把它移入了一个包含 Node 和该客户端的 CI 基础镜像中,这样每个分片启动时就已处于可运行的就绪环境。后来,我们发现下载原生构建头文件在初始化阶段偶尔会挂起,于是将这些必需的构建头文件也添加到了镜像中,从而缩短了长尾时间。
只安装任务所需的依赖
Linear 的代码库是一个 monorepo,使用 pnpm workspace 进行管理。我们的 API 测试工作流之前每次都安装整个 workspace,尽管它实际上只需要 API 包及其依赖项。将安装范围限制在 API 包上后,pnpm install 的时间从 44-73 秒缩短到了 16-18 秒。我们将同样的模式应用到了与 API 相邻的任务中,它们之前每次都安装整个仓库,并上传一个后续运行几乎从未命中的依赖缓存。
重新构建比缓存更快时就不要用缓存
我们还测试了对 node_modules 进行缓存,结果发现重新构建反而更快。由于缓存键依赖于一个频繁变动的锁文件,且即便命中缓存,恢复操作也需要约 28 秒,而经过过滤的安装只需约 7.5 秒。这个缓存增加了保存时间的开销和不确定性,却没有任何可感知的优势。
综合这三项改动,单个分片的初始化时间减少了大约 44%,从 110-140 秒降至 67-73 秒。


除此之外,我们还能完全避免其他形式的重复设置。
跳过未变更的设置环节
某些初始化工作仅在输入发生变化时需要重新执行。例如,我们的 API 容器在每次运行时都会重放完整的数据库迁移历史,即便 PR 并未修改 schema。针对这种情况,我们改为加载生成的 schema 快照和引导文件,将每个容器的数据库初始化时间从大约 12 秒缩短至 1-2 秒。
将短检查项合并至少量任务中
此前,七个独立的检查项各自启动 runner、检出代码仓库并安装依赖,但实际有效工作仅耗时数秒。我们将它们整合为两个任务,并在其中并行执行这七项工作。这将重复支付初始化开销的次数从七次降至两次。根据六月的使用数据,这一改动每月节省约 87,000 个 runner-分钟,相当于总 CI 使用量的 11.8%。
提升测试执行效率
随着每个测试分片的固定成本降低,我们有条件更激进地并行化 API 测试套件。它是整个工作流中规模最大且执行频率最高的部分之一,因此在此处的优化对合并耗时的改善效果尤为显著。
按测试执行器视角平衡负载
我们在 TypeScript 测试套件中使用的测试运行器 Vitest 是按文件而非按单个测试的耗时来分配任务的。这意味着几个异常庞大的测试文件就可能拖慢一个分片,进而拖住整个测试套件的完成时间,即使其他分片早已跑完。
我们把那些大文件拆分成更小、更聚焦的文件,同时保持测试结构不变,然后尝试了不同的分片数和 runner 配置。今年早些时候我们已经把分片数从三个增加到四个;这次改为八个后,初步基准测试显示关键任务快了约 19%,成本也降低了约 19%。改动一周后,最慢的分片耗时从 5.25 分钟降到了 4.33 分钟。
只有在严格隔离规则下才共享模块状态
Vitest 默认会隔离每个测试文件,对我们来说,这意味着每个测试分片都要重新构建 entity、GraphQL 和 decorator 图。我们引入了一个 isolate: false 的 opt-in vitest project,让安全的文件可以在同一个 worker 内共享模块注册表。
这是我们单项收益最大的性能优化,按我们的使用量计算,每月可节省约 17%。最慢的分片从大约 300-379 秒降到约 195 秒,API 分片的 runner 总耗时也从每次约 32.8 分钟降到 22 分钟。
但这同时也是正确性风险最高的优化。我们通过在每个文件上添加 opt-in 注释来显式声明其适用资格,并为共享状态补上了必要的 teardown。少数文件使用了 fake timers 或以无法安全解耦的方式共享状态,我们便把它们留在隔离项目中。另外,由于现在大部分测试都是 agent 生成的,我们也相应更新了各 agent 的 skills,把这项性能 opt-in 纳入其中,确保生成的测试默认遵守同样的约束。
分片性能受限于 setup 开销
进一步增加分片数量只有在单个分片的固定成本较低时才划算,因为分片数翻倍意味着初始化阶段的总耗时也会翻倍。前面提到的各项初始化优化措施,正是让 8 个分片具备可行性的关键。在此之前,每个分片的初始化耗时在 110 到 140 秒之间,若采用 8 分片,仅初始化就需占用 15 到 19 分钟的 Runner 时间,远超测试本身的耗时。如今初始化耗时已压缩至约 40 秒,因此 8 分片的总初始化时间甚至少于此前 4 分片的水平,同时测试并行度翻了一倍。


系统层面的复合改进效应
如果我们在今年早些时候没有有意识地改善 CI 流程,如今的测试套件运行时间将约为 11 分钟,几乎是当前开发者等待时间的两倍。并且这些工作并没有结束。很明显,代码库将持续增长,我们目前每周新增约 2,000 个测试。在此期间保持 CI 的高速运行需要持续投入,我们将把在这一过程中学到的经验应用于新的瓶颈问题上。


