← 返回全部文章

那次失败的并行拆分

发布于:
candyrushci-cdtoolingevidence

我们的 Unity 构建流水线,在单个 ci.yml 里长到了 3,744 行。一个文件跑测试套件、四 个平台的构建、产物发布、跨产物的确定性校验、性能回归闸门、Discord 通知,以及三条 Steam depot 通道。这是把它拆开的故事——包括那次让事情变慢的并行化,以及我们 最后为什么会有四个各自独立的 workflow linter。

单体真正的代价

这个文件是以那种典型方式长起来的。八月初 1,039 行。19 号 2,082 行。22 号 3,547 行, 同一天峰值 3,744 行。

体积不是真正的问题,重复才是:

  • rclone 的准备步骤——十份逐字复制
  • Unity 的互斥锁 + 启动 + 结果检查块——七份
  • R2 的环境变量块——十四份

而且它们已经漂移了。有些 rclone 副本会静默回退到 tool cache,有些会打日志,macOS 那几份还带着一个 Windows 版本没有的架构分支。七个 Unity 构建块里,只有一部分会 检查播放器是不是真的产出了——这很要紧,因为 Unity 以退出码 0 结束却什么都没构建 出来的频率,比谁都希望的要高。

真正的推动力不是整洁,是第二款游戏。写下来的目标是:复制 .github/、设一小把仓库 变量,就能把下一个项目立起来,不用改一行 YAML。每一个硬编码的 Steam app id、depot 编号、builder 命名空间和 bucket 前缀,都是挡在这个目标前面的障碍。

第一波:那次失败的并行拆分

套件当时是一个 job:先 EditMode,再 PlayMode,再构建——墙钟 72 分钟。显而易见的 一步是拆成三个并行 job。

算术看着没问题。每个 job 的前置(工作区清理、vendor pack 播种、包清单、编辑器解析) 实测 21 秒。付三遍大约多花 42 秒,换关键路径上少掉约十分钟。

然后我们量了一下:

方案EditModePlayMode构建墙钟
单体(一个 job)10m14s15m31s~46m72m09s
三路拆分24m25s18m27s69m23s69m
两路(套件 ∥ 构建)合计 46m03s~55m

三路拆分换来大约 4%。退回两路换来 25%。

EditMode——那个 CPU 密集的套件——受创最重,在三路拆分下比它在单体里慢了 2.4 倍。

原因事后看来很尴尬,但值得直说:那三个 runner slot 是同一台物理机上的实例。 第 三个并发的 Unity 编辑器没有带来任何算力,它只是把同一台机器切得更碎。

那套为三路拆分背书的前置耗时算术是对的,而且无关紧要。问题从来不是拆分的成本, 而是这台机器有没有余量可供拆入。

退回去还顺手删掉了一堆只为服务拆分而存在的机械结构:一个完整的 report job(两份 结果 XML 又回到同一个工作区了,合并它们重新变成一个 step)、这些 XML 的产物传递, 以及两个按套件分的汇总 step。这次测量现在就写在 workflow 自己的头部注释里,作为给下 一个冒出同样想法的人的常设禁令。

在这次回退中唯一活下来的,是当初为拆分而抽出的那个复合 action——重复的前置再也没 回来。

第二波:拆解

三天后才是真正的重构:

 .github/actionlint.yaml      |   27 +
 .github/workflows/_tests.yml |  419 +++++
 .github/workflows/cd.yml     | 2532 ++++++++++++++++++++++++++++++
 .github/workflows/ci.yml     | 3530 ++++--------------------------------------
 4 files changed, 3319 insertions(+), 3189 deletions(-)

ci.yml 从 3,273 行变成 425 行。

按触发器拆,而不是串成链

直觉是用 workflow_run 把 CI 串到 CD。别这么做。那个触发器跑的是默认分支上那一份 workflow,并且会丢掉触发它的 ref,这会让所有「从特性分支手动派发」的场景全部失效。

我们改成按触发器拆。ci.yml 在 pull request 上跑;cd.yml 在推到 dev/qa/main 以及手动派发时跑。同一个事件下只有一个会触发。两者调用同一个可复用 的 _tests.yml,所以一次 push 仍然只跑一遍套件、而且跑在最前面,部署通道再依据它的 输出决定是否放行。

三条通道,一份实现

git dev  -> Steam "development"       自动
git qa   -> Steam "qualityassurance"  自动——合并进 qa 本身就是那道闸
git main -> 默认分支                   只上传;上线由人来点

Steam 的分支名没法和 git 的对齐:Steamworks 强制 4–32 个字符,所以 dev 和 qa 在 创建时都会被拒。我们是在接好一张根本不可能成立的映射之后才发现这一点的。

为什么这三条通道不能就写成一个 job:Actions 里的 needs: 是静态的。 一个 job 没 法等「跑了的那一对」。单个部署 job 会需要挂上全部五个上游,再加一堆分支逻辑去判断自 己身处哪条通道——那正是「看着还行、然后以无法阅读的方式崩掉」的形状。于是那个 452 行的部署 job 变成了一个 694 行的可复用 workflow,外加四个只在参数上不同的瘦调用方。

策略差异是输入,不是代码分支。dev 通道刻意不由测试套件把关——套件红了不该连产物 也一起赔进去。prod 通道则要把关,因为一个坏的 dev 构建代价是重发一次,而 qa 或 main 上一个坏的 Steam 构建是永久的。

永远不要 always()

有一次清扫,把 job 图里五处 always() 全部拿掉了。理由很具体:always() 在整个 run 被取消时也返回 true,而 CD 的并发组是「取消进行中」的。于是:dev 的 push A 两个 构建都绿了,push B 顶掉并取消了 run A,然后 run A 的部署照样触发——把一个已被取代的 提交推上 Steam 并设为上线。Steam 构建是删不掉的。 替代写法是 !cancelled()。

五个复合 action

每一个的存在,都是因为重复已经开始漂移:

  • unity-workspace(272 行)—— checkout 到启动编辑器之间的全部工作。它刻意 不做 checkout,因为本地 action 是从工作区里加载的。
  • unity/build-player(144 行)—— 在宿主全局锁下跑一次 Unity 批处理构建,并给出 诚实的结果:非零退出是红的,而退出码为 0 却没产出播放器同样是红的。它自己永远 以 0 退出、通过 output 汇报,这样 Linux 构建挂掉不会连累 Windows 的压缩包。
  • rclone/setup(134 行)—— 把 rclone 解析到 tool cache,每台 runner 只下一次。 它刻意不把 R2 凭据作为输入:那会为了省六行,把凭据的暴露面从一个 step 扩大到 整个 job。
  • git-bash(76 行)—— 在自托管的 Windows runner 上,PATH 里的 bash 是 WSL 的 启动器,它会把 Windows 脚本路径搅坏。
  • unsparse-workspace(51 行)—— 见下文。

有两条约束塑造了它们全部:复合 action 跑在调用方的 job 和工作区里(可复用 workflow 则是跑在自己 runner 上的整个 job),以及复合 action 读不到 secrets 上下文。第二 条咬了我们两次。

四个 linter,四个已经上线的缺陷

一句文档字符串放倒了所有 Unity job

抽出 unity-workspace 之后的第一次运行:三个 Unity job 全部在一分钟内死掉,报 Unrecognized named-value: 'secrets'。位置是 action.yml 第 19 行——那是输入的 description,内容写着「调用方必须传 ${{ secrets.UTOOLS_PAT }}」。GitHub 会对 description 字段里的模板表达式求值,而 action 没有 secrets 上下文。

然后同一类问题又来了一次,这次在 shell 注释里

三天后,某个 run: 块里一句解释性的注释写着「调用方会传 ${{ vars.VENDOR_PACK_HOST_ROOT }}」。shell 注释对 runner 来说不是注释——这个表达式 在 action 被加载时就会参与校验。它在合并后第一次 CD 运行时放倒了 dev 的 CD。

为什么没有东西拦住它:actionlint 根本不检查 action.yml;我们的语法检查器会在解析 之前把每个 ${{ }} 换成一个裸 token,所以它看的时候表达式早就没了;而我们的 workflow linter 只遍历 doc['jobs']。三道闸,三个不同的盲区,同一个缺陷。

那个先打印 PASSED、然后失败的退出码

GitHub 会给每个 shell: powershell 的 step 追加 exit $LASTEXITCODE。而 PowerShell 的 cmdlet 不会重置 $LASTEXITCODE。于是一个 step 跑了原生命令、判定它的失败无关紧要、 然后以 cmdlet 收尾——它照样会失败,带着它刚刚才原谅的那个命令的退出码。

一段真实的运行日志:

tar entry for the player: -rwxr-xr-x  0 root root 4672 linux/CandyRush
Linux executable bit PASSED (0755 in the ustar stream).
##[error]Process completed with exit code 1.

根因:tar.exe ... | Where-Object {...} | Select-Object -First 1 在拿到第一个匹配的 瞬间就终止了管道,把通往 tar.exe 的管子掐断,留下一个非零的 $LASTEXITCODE。这个 step 继承了一个它故意掐掉的原生命令的退出码——而此时它自己的逻辑已经成功、并且 把成功打印出来了。

有必要说准确,因为这一点反直觉:这是日志里带着 PASSED 的假红,不是假绿。镜像方 向的隐患也上线过——一个只该警告的上传,本会把整个 builds job 弄挂,其中一次还是在 写完 ok=true 之后,于是结论和 job 结果自相矛盾。

这一类缺陷上线过四次。每一次都是靠人读 diff 发现的,而那不是一套流程。现在它是 一条 linter 规则。

把每个 run: 块当成真代码去解析

一次机械替换把硬编码列表换成了变量驱动的循环,却把旧块的收尾 } 留在了原地。YAML 合法。actionlint:零。我们的 workflow linter:零。一个必然会在 runner 上出现的解析 错误,而且要等 job 排队几分钟之后才炸。

于是 check-step-syntax.py 会抽出每个 run: 块,把表达式换成裸 token(这才是 shell 真正拿到的东西),再丢给对应语言的真解析器——PowerShell 自己的 AST 解析器,以及 bash -n。

有两个细节值得抄。第一,它是推断 shell,而不是相信标注,因为 shell: bash 并不 是个空操作:隐式默认是 bash -e,而显式写出 shell: bash 是 bash --noprofile --norc -eo pipefail。把它写出来,会让现有那些带管道的 step 新获得 pipefail,可能把本来通过的 job 变红。一道语法闸不能为了装上自己而改变运行时行为。

第二,第一版是每个 step 起一个解释器——77 次 PowerShell 启动,约 9 秒纯 fork 开销,去 做大概 200 毫秒的解析。合并成一次调用后,从 9.7 秒降到 0.95 秒。

追踪 job 图

第四道闸会拿每个 job 的 if: 去跑十一种真实触发形态,只要有 job 在所有形态下都不可 达就判失败。二十个 job 乘十一种形态是 220 个答案;改一个条件会悄悄挪动其中一部分, 而这些在 diff 里一点都看不见。

它上来就找到一个 builds job:跑起来了、把整套前置执行完了,然后跳过了每一个构建 step——而且偏偏发生在那个会产出永久 Steam 构建的派发上。原因是四层叠起来的否定, 每一层都是在新增一个派发开关时加的,没有人能靠读把它算明白。

那些一边通过、一边什么都没验的闸

最好的教训来自 linter 自己的一次失败。

一次运行报错 could not read ".github/workflows/*.yml"——那个字面量通配符之所以能 传到 actionlint,是因为 bash 没有任何东西可以展开。工作区被裁剪过:actions/checkout 会打开 sparse-checkout,而且从不把它关回去,而我们那台工具 runner 的工作区是共享且 持久的。有一个 job 做 sparse checkout 已经做了好几年——在它跑在一次性托管 runner 上 时,这是正确的。把它搬到自托管机器上,改变了前提,却没有改 checkout。

但下半段更要紧。在同一次被裁剪的运行里,我们三个自研闸里有两个会找到零个文件、 循环零次,然后在什么都没验证的情况下报告成功。只有第三个带了输入守卫,而这正是 那次失败以「一个红色 step」而不是「三个静默变绿的闸」浮出水面的唯一原因。

现在它们在输入缺失时都会失败,并且是在空目录里做过反向验证的。又因为这个修法救不 了一个已经被污染的工作区,现在还有一个小 action,负责把 sparse 模式关掉、然后 断言这棵树是完整的。

可以泛化的部分

去量机器,别去算算术。 三路并行是靠一套关于前置耗时的正确算术、和一套关于硬件 的错误假设背书的。同一台机器上的三个 runner slot 不是三台机器。

一道依赖「记得」的闸不是闸。 四个各自独立的缺陷,全都是靠人读 diff 抓到的。这 招能用,直到它不能用。现在每一个都是一个会以非零码退出的检查。

一个什么都没找到的检查,必须证明它确实找过。 一个静默地校验了空集合的 linter, 比没有 linter 更糟,因为它产出一个毫无意义的绿勾。

重复的代价是漂移,不是行数。 十份 rclone 准备步骤成为问题,不是因为它们长,而是 因为其中只有一部分还是对的——而在 Unity 构建块里,只有一部分会检查构建到底有没有 产出东西。

现在这条流水线是六个文件、5,588 行 workflow,而不是一个 3,744 行的文件,另加 677 行 复合 action 和 2,741 行脚本。这不叫更小。这叫可分离、可参数化、有闸把关——而且下一 款游戏应该能靠设仓库变量、而不是改 YAML,把它用起来。这才是重点。