实习日志 / 中文 / CI 治理 / CI 续篇:一个月 77,000 credits,以及一张"哪一行被哪个测试覆盖"的表
CI 续篇:一个月 77,000 credits,以及一张"哪一行被哪个测试覆盖"的表
中文 | English
上一篇写的是 Actions 额度烧光的那个晚上,最后落脚在 CircleCI 上。迁过去以后,事情并没有结束:两周后我们发现,CircleCI 的额度也在以同样的速度被吃掉。这一篇记的是之后一个月里,我们怎样弄清楚钱花在了哪,以及最后是怎么让 CI 既省钱、又真的守得住质量的。
第一次:半个月 30,000 credits
9 月上旬,团队大约半个月就用掉了 30,000 CircleCI credits。仓库里没有按 job、按 PR 的账单明细,所以我没法把这个数拆成某一个任务的精确贡献,但能确认的是几件叠在一起的事。
CI 的拓扑比产品还大。 流水线里同时挂着后端、报案端、员工端、管理端、自动化、文档、PR 规则几类任务,其中三个前端任务对应的还是旧的前端表面,有的是早期的单文件 HTML 原型,它们早就不再是产品的运行入口,却每次都在安装依赖、构建、测试。多几个 job 看起来更"严格",可每个 job 都是真金白银,一个废弃的表面只要还挂在流水线上,就会在每次触发时继续收费,还让人误以为它仍是受支持的产品。
同一件事被验证了好几遍。 一个 PR 的改动可能同时满足 GitHub Actions 和 CircleCI 的触发条件;一个后端小改动会让完整的后端测试、格式检查、类型检查和契约检查全部启动;CI 配置本身的改动被当成后端行为改动,触发整套后端测试。还有一种最隐蔽:job 里虽然最后判断"跳过"了,但 job 已经被创建、拉了镜像、启动了,额度已经花掉了。
所以我当时写下的第一条结论是:在 job 里 if skip 不等于省钱,真正的预筛必须发生在 job 被创建之前。消耗可以用一个乘法来理解:
总消耗 ≈ 每个 job 的启动和执行成本
× 每次流水线创建的 job 数
× 流水线的触发次数
× 依赖安装和测试的时长
优化的顺序也就跟着定了:先减掉不该存在的 job,再减掉不该被创建的流水线和 job,最后才去微调单个测试的速度。
两步:去重,然后预筛
第一步是去重。旧的三个前端质量任务移进归档,从正式的工作流里拿掉;每个保留下来的 job 都要能说清楚自己在验证哪个产品或治理行为;PR 规则检查是协作的安全边界,照常普遍运行,不被路径过滤掉;main 上的完整质量集合一项不删。
第二步是预筛。CircleCI 用 setup pipeline 加路径过滤:先读出相对 main 改了哪些路径,设置 backend_changed、docs_changed、ci_changed 这类参数,再只继续创建对应的工作流。进了后端质量任务以后,再由一个选择脚本按改动挑出相关的测试文件:普通的后端行为改动只跑受影响的测试;共享的领域模型、仓储协议、运行时组装、依赖清单这类改动跑全量;文档、CI 配置和选择器自己的改动,不自动升级成整套后端测试,只走一个解析 YAML、跑选择器自测的轻量检查。
我也给"为了省钱不能做的事"列了一张清单:不能拿掉 main 上的全量质量套件;不能把业务测试变成只检查 YAML 能不能解析;不能用 fixture 代替真实的接口和契约验证;不能让 job 先启动、再用 shell 条件假装"没运行";不能因为自建 runner 准备好了,就把没隔离的任意 PR 代码发到长期驻留、带敏感配置的主机上;不能在没有远端证据时,把本地通过写成 CI 通过。
一份执行策略
紧接着我把策略写成了八条,核心是"最快的反馈、最低的消耗、不牺牲测试的粒度":
- PR 门禁收窄:非后端改动不触发后端测试;普通后端 PR 只跑受影响模块的单测、受影响接口的契约和鉴权测试、权限和报案人可见性这类不能延后的核心测试,以及受影响文件的 lint 和类型检查。PR 默认跑全量是当时额度消耗的主因,禁止。
- 全量测试降频,不取消:只在合并到 main、每晚定时、以及改到共享核心文件时跑。
- 分片只用于 main 和每晚的全量。分片能缩短墙钟时间,但会增加 credits:4 片会从约 56 涨到约 200 credits,用在 PR 上等于既没省钱、也没解决根本问题。
- 用静态的 import 依赖图做影响分析,代替手写的文件映射表。
- 测试去重是治本的一项,删除标准只有一条:证明不了新增了行为覆盖的用例才删,不能因为数量多就砍。
- flaky 测试进隔离要同时满足几个条件,而权限边界、报案人可见性、幂等、版本冲突、审计只追加、外部服务授权这几类,无论如何都不能因为 flaky 被降级或跳过。
- 依赖缓存按锁文件的哈希缓存整个环境,预计只省几十秒到一两分钟,不指望它解决主要问题。
- 同一分支有新提交时,自动取消旧的排队和运行。零成本、零风险,最先做。
因为团队里人人都在用 AI 编程工具,测试的生成速度远快于人工审查,我还写了一份给 Agent 看的测试编写约定:写新测试前先问自己,这是一条新的业务行为,还是同一段逻辑换了个输入值;单个测试函数的参数化用例不超过 8 到 10 个,超过就要说明每个参数在验证哪个不同的业务合同;单元测试默认 mock 外部依赖,直连真实依赖的必须标 @pytest.mark.integration;不要用 _1、_2 编号区分测试。
第二次:一个月 77,000 credits
10 月 3 日,我对 CI 做了一次完整的审计。数据来自 CircleCI 最近 30 天的统计、job 的逐步耗时和 pytest 输出、最近 40 个合并 PR 的状态,以及在本机对 main 上全部 269 个测试文件逐个计时。
结果让我有点意外:最近 30 天,quality 工作流跑了 2,091 次,用掉约 77,000 credits,是上一次危机的 2.5 倍。
| job | 运行次数 | credits | 占比 | 中位耗时 | p95 |
|---|---|---|---|---|---|
| backend-quality | 2,068 | 46,225 | 54.9% | 0.8 分钟 | 16.0 分钟 |
| github-automation-quality | 2,066 | 9,474 | 11.3% | 0.8 分钟 | 1.1 分钟 |
| customer-quality | 1,872 | 7,973 | 9.5% | 0.5 分钟 | 2.6 分钟 |
| workbench-quality | 1,737 | 7,608 | 9.0% | 0.2 分钟 | 2.8 分钟 |
| p12-browser-evidence | 670 | 4,694 | 5.6% | 1.6 分钟 | 2.1 分钟 |
| docs-quality | 2,067 | 3,291 | 3.9% | 0.2 分钟 | 0.4 分钟 |
| admin-quality | 1,589 | 2,452 | 2.9% | 0.2 分钟 | 0.6 分钟 |
| pr-policy | 2,086 | 2,441 | 2.9% | 0.2 分钟 | 0.3 分钟 |
审计找出的问题,有一半是 9 月那次就已经想清楚、却没守住的。
预筛退回去了
9 月 9 日试过 setup pipeline 的方案,可当时 main 上的配置已经退回到"每次 push 都创建全部 8 个 job,在 job 里面再判断要不要跳过"。github-automation-quality 没有任何路径过滤,每次都执行 npm ci、检查和一次 wrangler 打包,而最近 30 天 main 上 1,687 个提交里只有 2 个碰过它对应的目录,它一项就用了 9,474 credits。项目也没开 "Only build pull requests":22% 的流水线跑在 PR 还没开或已经关掉的分支上,另有 4% 的分支从来就没有 PR。Agent 习惯每做一步就 push 一次,这部分开销就是这么来的。大约四成的额度,花在了根本不该存在的 job 上。
慢的主因是每个测试白白多花 2 秒
backend-quality 几乎所有时间都在跑 pytest:一个 PR 中位要等 4.9 分钟,p90 是 20.4 分钟,最长 34.1 分钟。选中 600 到 1,400 个测试的 PR 要 9.5 到 33 分钟,折算下来 CI 上大约 0.85 秒一个测试。
逐文件计时之后,原因很清楚了。3,860 个测试里,2,710 个跑得飞快,不到 0.1 秒,总共只占约 1% 的测试时间;另外约 1,150 个每个要 1 到 3 秒,占了 98%。这一档几乎都是用了 client、app fixture 或自己 create_app(...) 的测试,拆开一次 app 的生命周期:
| 环节 | 耗时 | 原因 |
|---|---|---|
create_app() | 约 1 秒 | 每个测试都重新注册 29 个路由模块、192 条路由,FastAPI 给 919 个响应字段逐个生成 pydantic schema;其中还有 0.25 秒是给种子账号算了 3 次 scrypt 密码哈希 |
| TestClient 进入和退出 | 约 1 秒 | 退出时实时分发器要 join 后台线程,而内存仓储的事件监听在没有事件时 wait(timeout=1.0);停止时只 set 了一个 Event,没有 notify 这个 condition,所以每次都要等满 1 秒 |
第二个 1 秒纯属浪费。我写了一个 pytest 插件,只把这个等待改成 0.02 秒,业务代码一行没动:一次 TestClient 的生命周期从 1.01 秒降到 0.03 秒,test_account_data_api.py 从 90 秒降到 36 秒,test_claim_api.py 从 156 秒降到 73 秒。按 1,150 个慢测试估算,这一处就能让后端测试总时长减少一半左右。
选择器在逼 Agent 写新测试
这一条是审计里最有意思的发现。选择器的规则是:改动的文件命中手写的映射表,就选对应的测试;没命中的后端文件,只选选择器自己的自测,68 个,和改动毫无关系。而 CI 又要求,被选中的测试必须覆盖本次改动行的 85%。
我拿 main 上 251 个后端文件逐个模拟:140 个根本没写进映射表,改了它们 CI 只跑那 68 个选择器自测;37 个一改就拉进 469 到 671 个测试;backend/domain/models.py 是共享的核心模型,按策略应该触发全量,实际只拉了 148 个。
放到 Agent 的工作方式里,事情是这样发生的:Agent 改了一个不在映射表里的服务文件,CI 只跑选择器自测,改动行覆盖率立刻报 0%。它最省事的办法,就是新建一个测试文件把改动行走一遍,这个新文件因为"在改动里"被选中,覆盖率就过了。仓库里可能早就有三个测试文件覆盖了同样的逻辑,但它们没被选中,所以不算数。
测试代码和后端代码一起线性增长,原因就在这里:
| 日期 | 测试文件数 | 测试代码 | 后端代码 |
|---|---|---|---|
| 08-20 | 42 | 364 KB | 388 KB |
| 08-31 | 85 | 874 KB | 696 KB |
| 09-07 | 128 | 1,483 KB | 1,570 KB |
| 09-14 | 149 | 2,123 KB | 2,163 KB |
| 09-21 | 203 | 3,052 KB | 3,213 KB |
| 10-03 | 260 | 3,957 KB | 4,180 KB |
到 10 月初,tests/ 下的 Python 有 111,109 行,后端 112,612 行,差不多 1 比 1。
我当时在审计报告上批注了一句:85% 的改动行覆盖率不是标准做法吗?问题应该出在 AI 应对它的方式,不在门槛本身吧?核实下来,门槛本身没错,错在它只认"本次被选中的测试"。所以真正要改的是选择器,还有给 AI 的规则:复用优先。
还有一个反直觉的发现:照抄式的重复其实很少。把测试函数体做 AST 归一化以后比较,结构完全相同、长度不少于 8 行的只有 19 组、45 个函数。低质量测试主要是另外几类:给没人调用的死代码保活的测试;按"哪次 PR、哪一周、哪个 issue"命名的测试文件,比如 test_p17_*、test_week5_*、test_issue_928_*;放在 tests/fixtures/ 目录里、会被 pytest 收集却永远不会被选择器选中的测试;同一个主题拆成很多文件,比如未完成报案恢复的测试有 6 个文件、保留策略的有 8 个。这些都是同一个机制的产物:每个 PR 新开一个文件来满足覆盖率。
main 上已经没有全量测试了
最后这一条,问题出在我自己。9 月 17 日有一次提交删掉了 CI 的 full 模式,main 也改成只测 HEAD^1..HEAD 的差异。删掉 full 是我的决定,当时为了省额度。结果是 main 上再也没有任何一次运行会跑全部测试。这次逐文件跑,一共查出 8 个在 main 上失败的测试文件,其中一个恰好是断言"CI 里必须有 full pytest 命令"的那个测试,它从 9 月 17 日起就一直在失败,只是从来没被选中过。
"只测差异"有一个前提:总要有地方在跑全量。我自己在 9 月写的两份文档里都写着"main 仍然运行完整的质量套件,不能为了省额度削弱",当时的配置和这两份文档已经不一致了。
审计里还有两条,我的看法和报告的建议不一样。报告说 main 没有任何 required status check,CI 只是参考意见;我的看法是 CI 本来就不应该是门禁。报告说 GitHub Actions 那边有 5 个工作流指向不存在的 self-hosted runner,最近 200 次运行里 138 次启动失败,是纯噪音;那些是额度耗尽时的备用方案,没被触发很正常,所以这一项我选择忽略。
落地:一张"哪一行被哪个测试覆盖"的表
审计当天就开了一个治理 PR,加一个补丁,合并后的变化是这样的:
只建需要的 job。 .circleci/config.yml 改成 setup pipeline,由 scripts/ci_plan.py 先算出该建哪些工作流,再续接 continue_config.yml。github-automation-quality 只在它自己的目录或 .github/scripts/ 改动时才跑。项目设置里打开了 "Only build pull requests",main 例外。
那 1 秒等待。 内存仓储的事件监听改成 50 毫秒轮询。
按覆盖率选测试。 这是最核心的一处改动。main 合并后和每天定时,跑一次全量测试,分 4 片,带上 --cov-context=test,从覆盖率数据里生成一张"哪一行被哪个测试文件覆盖"的表,缓存起来;PR 读这张表选测试。手写的映射表和那个"哨兵"自测一起删掉了。拿 main 的真实表试了两次:改 config.py 里一个函数的一行,选出 7 个测试文件,旧逻辑只会跑 68 个选择器自测;改 messages.py 一行,选出 11 个文件,旧逻辑要 16 个文件、667 个测试。这样一来,改动行的覆盖率就能由仓库里已有的测试满足,PR 不必再为了过门槛去新写测试,灌测试的激励从根上被切断了。
全量的结果。 PR 上 3,846 个测试通过、0 个失败,每片 8.5 到 11.4 分钟,总覆盖率 89%;main 上的流水线也都通过了。每日全量定在 UTC 14
,也就是新西兰凌晨 3 点。全量失败会自动开一个 issue,我用一个故意失败的测试在自测流水线上验证过一次,CircleCI 的 token 确实有开 issue 的权限。原本失败的测试。 那 8 个文件里,除了 1 个以外都是断言过时:设计改了、版本号变了、schema 加了新字段、中间件换了。剩下那 1 个疑似真 bug,另开 issue,并标成 xfail(strict=True)。还顺手发现一个测试全局替换了 sqlite3.connect,开启覆盖率上下文以后会把后面所有测试拖垮,也修了。
PR 规则合一。 之前 PR 规则在 Cloudflare Worker 和 CircleCI 两边各跑一遍。commit 格式检查移进了 Worker,CircleCI 的 pr-policy job 删掉了。
规则改成"复用优先"。 仓库的 Agent 规则、治理 skill 和 PR 模板都改了:新测试优先放进所测模块已有的测试文件,新文件按模块或行为命名,CI 只给建议、不阻断;参数化的上限也从硬规则降成了建议。
落地的过程中踩了三个坑:pyproject.toml 里 90% 的总覆盖率下限会作用到每一片上,分片和 PR 选测里都要关掉它;定时触发会带一个 nightly 参数传进续接的配置,没声明就直接报错,所以又补了一个 PR;Worker 收到 PR 的 opened 和 edited 事件可能乱序,PR 刚建的时候会短暂显示规则失败,编辑一次正文就恢复了。
还没做的几件:让测试共享一个 app(每个测试还剩约 1 秒的 create_app)、按主题合并测试文件、删掉给死代码保活的测试,后者要等另一份死代码审计一起处理。
两次危机之间
把两次放在一起看,第一次的结论其实都对:预筛要在 job 创建之前,main 要保留全量,不能为省额度削弱证据。问题在于它们只写在文档里,配置一改、一个为省钱做的决定,就悄悄地退回去了,而没有任何东西会报警。
第二次让我多学到的是关于激励的事。85% 的改动行覆盖率是一个合理的门槛,Agent 为了过门槛新写测试,也是一个合理的反应;两个合理的东西叠在一个不准的选择器上,就造出了一个和业务代码一样大的测试库。所以这一次的修法是让 CI 本身变"准":让它知道每一行代码到底被哪些已有的测试覆盖。规则只是写给人和 Agent 看的说明,CI 量得准,才不会把人往错的方向推。