CloudX 团队近日开源了 cloudx-io/setup-go,旨在解决 GitHub Actions 中 Go 语言持续集成(CI)工作流的性能瓶颈。通过以功能等效的替代品替换 GitHub 官方的 actions/setup-go Action,其测试作业的运行时间缩短了 69%。

为何追求极致 CI 速度

在快速交付新产品和功能的过程中,CI 工作流是确保代码变更不会引发生产故障的核心。CloudX 的目标是在开发者推送代码后的 90 秒内,提供关于构建、测试及 Linter 规则的明确反馈。为此,团队不仅在高性能机器上运行 CI,更致力于算法层面的优化。

然而,随着测试套件随产品覆盖面扩大,团队发现默认的 actions/setup-go 并未提供理想的基础支持。数据显示,在典型的单体仓库并行作业中,默认 Action 产生的 86% 工作量完全是多余的。

官方 Action 的并行陷阱

GitHub 推荐的 actions/setup-go 依赖 actions/cache 来保存和恢复 Go 模块及构建缓存。其默认缓存键构造方式仅包含操作系统、架构、Go 版本及 go.mod 文件的哈希值:

setup-go-${os}-${arch}-go-${goVersion}-${hashFiles('**/go.mod')}

这一缓存键存在严重缺陷。在活跃开发中,极少有代码更改会触发上述关键元素的变动。因此,首次作业计算并持久化缓存后,后续所有 CI 运行都会加载这一初始值。随着代码迭代,恢复的 go build 归档文件和 go test 输出逐渐失效,导致后续作业不得不重新执行大量本可复用的工作,直到 go.mod 更新才会重置。

更致命的是并行竞争问题。当 Lint 和 Test 作业并行运行时,它们解析相同的缓存键并竞相写入 GitHub 缓存服务。若 Lint 作业先完成,它保存的缓存将缺失测试状态;随后的 Test 作业加载该过期缓存,导致不必要的测试重跑。

重构缓存策略

Go 工具链本身具备优秀的记忆化机制,其模块缓存、构建缓存和测试缓存均基于完整输入哈希。问题在于 GitHub Actions 的临时运行器环境无法直接利用本地文件系统的持久性优势,必须通过 actions/cache 进行跨运行器传输。

cloudx-io/setup-go 的核心改进在于缓存键的设计。为解决并行冲突,它将作业身份(或自定义前缀)纳入缓存键,确保 Lint 和 Test 作业拥有独立的缓存空间。为解决缓存过期问题,它在每次运行后都写入新的缓存条目,并将 GitHub Actions 运行 ID 作为键的最终元素:

go-cache-${os}-${inputs.cache-key-prefix}-${goVersion}-${ref_name}-${hashFiles('**/go.sum')}-${run_id}

这种策略确保了加载的缓存始终是上一次运行的新鲜状态,仅针对真正修改的测试包执行测试。

性能实测与权衡

在去年年底的一次性能危机中,CloudX 的并行 Lint 作业因覆盖测试缓存,导致 Test 作业中位运行时间从 76 秒激增至 180 秒。引入 cloudx-io/setup-go 分离缓存后,Test 作业中位运行时间迅速降至 41 秒,性能提升 69%。

通过对 4,000 个真实提交的模拟对比,新方案使等待运行的测试包数量减少了 86%。即便串行执行,新方案也因持续更新缓存状态而优于默认值。

当然,更频繁的缓存写入也带来了两个副作用:一是缓存体积线性增长,可能导致加载时间变长,团队已通过自动修剪机制解决;二是可能需要扩展 GitHub Actions 的缓存容量。尽管这会增加少量存储成本,但鉴于运行器按分钟计费,更快的作业速度足以抵消这一开销。

目前,cloudx-io/setup-go 已在 CloudX 内部稳定运行数月。团队希望这一开源方案能帮助其他 Go 项目突破 CI 性能瓶颈。