Others

私有仓库 CI 耗时 12 分钟,缩短并改为发布时才触发

2306 字约 8 分钟

摘要Job Market Monitor 的一次发布要等 12 分钟 CI,大半时间花在测试反复建库上。先把测试库改成复制模板,CI 缩短到 4 分钟;之后发现私有仓库的 Actions 免费额度被快速消耗,又把 CI 改成发布时才触发,并清理了一批重复的测试。

Job Market Monitor 的发布流程是这样的:代码推到 GitHub 的私有仓库,Actions 跑测试并构建前端产物,发布脚本再把产物下载到生产服务器,离线构建镜像、切换服务、做健康检查。

有一阵子我几乎每天都在发布,越来越觉得这个流程拖沓。有一天我对助手说,部署太繁琐,花的时间太久,CI 也应该精简,现有的测试看着也有点多,可能不少是“为了测试而测试”,让它大幅提高速度。

先量一遍

助手没有直接动手删,先把上一次 CI 的日志拉下来,把每个阶段的耗时量了一遍。

整个 CI 用了 12 分多钟,其中 Python 测试一项就占了约 9 分钟。同一套测试在本机跑只要 2 分 38 秒,说明慢的不是测试逻辑本身。

另外还找到两处明显的重复:

  • 采集相关的 399 项测试,在主任务里跑一遍,在另一个独立任务里又跑了一遍。
  • 没有按改动范围区分,哪怕只是改了一个 logo 或者前端文案,也要等整套后端测试跑完,前端构建还排在 Python 后面。

对于“测试太多”的怀疑,助手的态度比我谨慎。它统计了一遍,有一百多个测试文件,近千个测试函数,抽查之后认为大部分验证的是真实行为。比如用 AST 扫描或者读取文件的那一批测试,乍看像是在检查源码写法,实际上绝大部分是在读取解析器的样例数据、核对落盘的文件,并不是无用测试。所以第一步不删测试,先看时间花在哪里。

每个测试都在重新建库

测试的耗时分布一看就清楚了:大量测试用例在开始前,都要重新创建一个空数据库,并把全部迁移从头执行一遍。测试里真正的业务断言很快,时间大多花在这个准备动作上。

通俗比喻:每次考试都重新盖一栋考场

每场考试都要一间干净的教室。原来的做法是每次从打地基开始盖,盖好再考,考完拆掉。

改成先盖好一间标准的空教室留作样板,每场考试复印一间一模一样的,用完就扔,样板不动。考试之间依然互不干扰,只是不用每次都盖房子了。

方案是把迁移只执行一次,得到一个已经建好所有表的空数据库作为模板,每个测试从模板复制出一份属于自己的副本。真正需要验证迁移本身的测试保持原样,仍然从头迁移。这样每个测试仍然独占一个数据库,隔离性没有变,业务断言也基本没动。

配合这一项,CI 也做了调整:前端和后端分成互不依赖的两个任务并行跑,采集测试不再重复,纯前端的改动可以跳过整套 Python 测试。发布也收敛成一条命令,它会自动等待 CI、下载产物、更新服务并验收,同时记录各阶段耗时。

有一个边界值得记下来。如果某次提交的 CI 失败了,随后又单独推了一次只改文档的提交,这次 CI 不能因为“只改了文档”就跳过,否则前面那个没通过的代码改动就被放过了。所以只改文档时,CI 仍会检查最近尚未通过验证的代码。

项目优化前优化后
本机完整测试158 秒67 秒
完整 CI12 分 16 秒4 分 16 秒
只改文档的 CI全量执行16 秒

CI 通过之后,发布本身大约 50 秒完成。

免费额度的数字

CI 变快之后又过了三天,我在 GitHub 的账单页面看到一个让我紧张的截图:项目名下显示 $6.33,Actions 已经用了 1,084 分钟,而免费额度是 2,000 分钟。这个项目是私有仓库,我暂时也不打算公开,于是问助手该怎么办。

助手没有一上来就改,先把截图里的数字解释清楚:

  • 项目下的 6.33是抵扣免费额度之前的用量金额,∗∗实际账单仍是6.33 是抵扣免费额度之前的用量金额,**实际账单仍是 0**。
  • 1,084 分钟是整个账户的用量,我名下几个私有仓库共用,不能全算到这个项目头上。
  • 此时用了约 54%,两天后额度重置,本月并没有紧迫的问题。

它又统计了实际的运行记录。9 月 26 日优化之后,这个项目一共跑了 32 次 CI,其中 13 次只是文档提交。更早的旧流程每次都要 8 到 12 分钟,也占了不少本月额度。每次运行里,后端任务约 6 分半,其中测试 5 分半,前端任务不到 2 分钟,其中构建只有 10 秒。

另一个容易误解的地方是并行。把任务并行跑能缩短等待时间,但计费看的是每个任务各自的分钟数加在一起,并行并不能省额度。

改成发布时才跑 CI

问题其实有两层:单次运行太慢,和运行得太频繁。第一层已经改善了,第二层需要换个思路。我习惯把一件事拆成几个独立的提交,每推一次就跑一遍 CI,其实并不需要,我真正需要的是“发布之前代码是验证过的”。

助手的方案是把 CI 的触发时机放到发布上:

  • 普通推送不再触发 CI,可以随便提交、推送备份。
  • 执行发布命令时,脚本检查目标提交有没有已经通过或者正在运行的 CI,有就复用,没有才触发一次,等它通过再发布。
  • 失败或取消的检查会明确挡住发布,而不是自动重跑。这样不会悄悄重复消耗额度,需要修复或主动重跑。
  • 新增只检查 CI、不连接生产服务器的入口,想提前确认时可以先跑,之后发布直接复用结果。

验证的时候,助手故意同时启动了两条检查命令,最后它们拿到的是同一次 CI 记录,只产生了一次运行。追加一个纯文档提交,也没有新开 CI。

同时,助手发现审计类测试仍然在每个用例里重新做整套迁移,仅准备独立数据库就要四五秒,也改成了复制模板。后端测试步骤从 331 秒降到 269 秒,没有删任何测试。

再清理一遍测试

速度提上来之后,我又提出,测试是不是真的太多了,没什么用的可以大刀阔斧地清理。

助手按“这个测试能防住什么真实问题”逐类审查,先找出的是最浪费的几处:

  • 搜索测试把一组条件做了全排列,每种组合都跑一遍。改为分别验证各自的边界。
  • 正常浏览限流的测试,原来要重复几十轮,现在只跑到阈值刚被跨过为止。
  • 有些测试只检查源码里有没有某段文字,或者某个配置值是不是原样返回,这类维护价值很低。
  • 有一项并发回归为了复现线程池瓶颈,创建了 40 个岗位、启动 40 个线程。同样的缺陷用“线程池只有 2 个线程,却要求同时执行 3 个任务”就能复现。
  • 采集测试里的重试等待用的是真实时间,改为跳过等待,但重试次数、间隔和失败结果的断言还在。

净结果是少了 583 行测试代码,后端测试步骤从 269 秒降到 218 秒,采集测试从约 8 秒降到约 2.5 秒。权限、扣次、采集完整性、迁移和真实数据库的回归全部保留。

自托管 runner 不是首选

额度真的不够时,GitHub 的自托管 runner 不收 Actions 运行费,但机器和维护要自己承担。助手也特别提醒,不要放在生产服务器上,否则会和线上业务抢资源。目前改成按发布触发之后,这个方案暂时用不上。

回头看

这两件事前后只隔了三天,却是两种不同的瓶颈。第一次是“单次太慢”,慢在反复建库这种本该只做一次的重复劳动。第二次是“次数太多”,每推一次就跑一次,对只有我一个人维护的私有项目来说没有必要。

这个办法有一个前提。它依赖发布命令作为唯一的门禁,如果有人绕过发布脚本直接部署,就没有 CI 把关了。对一个单人维护的项目这没有问题,多人协作时就要重新考虑。

评论