通过替换 actions/setup-go 扩展 Golang CI
摘要
CloudX 已开源一个替代 GitHub actions/setup-go 的工具,通过优化缓存机制,可将 Golang CI 工作流速度提升最高 69%。
暂无内容
查看缓存全文
缓存时间: 2026/09/16 15:06
# 通过替换actions/setup-go实现Golang CI扩展
来源:https://www.cloudx.ai/posts/setup-go
我们发现了一种新方法,通过利用Golang构建缓存来加速并行的Golang持续集成工作流。将GitHub官方的actions/setup-go action替换为一个功能等效的替代品,使我们的测试作业运行时间缩短了69%。我们已将cloudx-io/setup-go(在新标签页中打开)(https://github.com/cloudx-io/setup-go)开源,以便你也能实现同样的效果。
GitHub官方的actions/setup-go步骤会导致并行作业相互干扰性能,并且会持续加载过时的缓存值。在我们的monorepo中进行回测(这里存在一种常见情况:有几个并行的Golang测试作业——一个用于代码检查,一个用于测试,一个用于构建)表明,默认action所做的86%的工作是完全不必要的。
如果你管理一个中等复杂度的Go项目,你可以期待类似的性能提升;请参阅我们的CI测量方法(https://www.cloudx.ai/posts/setup-go#measuring-performance),或者直接自己尝试。
## 我们重视快速的CI
我们一直在发布许多新产品和功能,而且我们这样做的速度实际上在不断加快。这并非偶然——我们投入大量资源在使这成为可能的工具和流程上。每个“软件工厂”的核心都是测试套件和持续集成(CI)工作流,它们确保代码变更不会在生产环境中引发故障。如果我们的测试在每次变更时都能可靠且快速地运行,我们就能以惊人的速度构建产品,而不必担心为客户带来问题。
这对我们很重要,因此我们衡量并投入资源提升CI作业的速度。如果你向CloudX仓库推送代码,我们的目标是让你在90秒内获得明确的结论——代码是否可接受、是否能够构建、测试是否通过以及是否符合我们的代码检查规则。
速度可以通过多种方式实现,但归根结底,如果你想让事情变快,就必须进行算法层面的改进。我们已经在使用Warp Build(在新标签页中打开)(https://www.warpbuild.com/)在快速且高性价比的机器上运行我们的CI作业。随着测试套件随产品范围扩大而增长,我们意识到actions/setup-go并未为我们铺平成功之路。
## actions/setup-go如何在并行作业中失效
GitHub的actions/setup-go(在新标签页中打开)(https://github.com/actions/setup-go)是在GitHub Actions中安装和运行Go的官方推荐方式。它内部使用actions/cache来保存和恢复本地Go模块缓存和构建缓存目录。原则上,这应该使一次作业运行中下载的模块源代码以及构建/测试产物可供你仓库中所有后续的作业运行使用。以下是默认的actions/setup-go缓存键构建方式:
这个缓存键非常不完整:在一个处于活跃开发的典型产品中,只有一小部分代码变更会修改目标操作系统、架构、Go版本或`go.mod`文件。
当一个作业首次计算此哈希键时,它会将最终的缓存状态持久化到GitHub缓存服务。直到下一次修改这些关键元素的变更发生之前,每次CI运行都将加载那个首次的值。随着你应用的变更,从首次运行恢复的`go build`模块归档会变得薄弱——每次后续的`build`都需要从头开始做更多工作。恢复的`go test`输出也会过时,因此每个后续作业都会重新运行更多测试。CI性能会下降,直到你更新`go.mod`为止!
此外,多个并行运行actions/setup-go的作业会竞争将不同的本地缓存状态写入GitHub缓存服务——之所以不同,是因为运行器上的最终Go缓存状态取决于源代码和运行的命令。例如,你可能并行运行单独的代码检查和测试作业:
两个作业都解析到相同的默认缓存键,然后竞争写入其值。假设`lint`作业先完成:它保存了一个不包含更新测试缓存状态的值。后续的`test`作业将继续使用那个过时的值,直到缓存键更改,从而导致不必要的测试重新运行。
代码检查、构建和测试代码库是记忆化的理想对象:它们的输出(分别是代码检查信息、构建的二进制文件和测试结果)应该是源代码的纯函数。只要输入没有改变,你就可以存储输出并重用它们,而不是重新计算。
Go标准工具链的多个部分会将输出保存到文件系统,并检查是否可以重用现有输出,而不是从头重新计算新输出:
缓存 | 控制环境变量 | 默认Linux位置
---|---|---
模块缓存 | `GOMODCACHE` | `$GOPATH/pkg/mod`
构建缓存 | `GOCACHE` | `~/\.cache/go-build`
测试缓存 | `GOCACHE` | `~/\.cache/go-build`
Go的*模块缓存*节省了下载模块依赖源代码的时间,你可以通过`go mod download`显式触发,也可以通过`go build`隐式触发。这里没什么神秘的,只是按你`go.mod`中的包标识符组织的源代码:
当你更改`go.mod`时(例如添加新依赖或升级现有依赖),你会触发新的下载。
Go的*构建缓存*和*测试缓存*实际上共同位于`GOCACHE`目录中,并共享一个通用结构。构建和测试过程都会哈希其完整输入用作缓存键。这些哈希按前缀组织到子目录中,并用作可重用过程输出的文件名:
后缀为`-d`的文件是数据载荷,后缀为`-a`的文件用作索引。当然,构建和测试过程会产生不同的数据载荷:
- `go build`存储包归档,这些归档是链接到最终二进制文件中的中间产物。
- `go test`存储测试执行的`stdout`、`stderr`和最终退出代码。
Go测试运行器会监视测试进程,自动检测它读取了哪些文件,并将这些文件的内容作为输入纳入缓存键。
这些工具缓存背后的原则是相同的:它们通过使键基于*完整但最小*的依赖集来最大化命中率,因此只有在绝对必要时才会发生未命中。每当发生未命中时,新结果总是被持久化到缓存中,以便未来的进程可以重用它。
这在单个持久化文件系统中效果极佳,但CI运行器没有单一持久化文件系统的便利。在GitHub Actions中,这些工具链缓存通过将它们塞进另一个缓存——一个设计优先级非常不同的缓存——从一个临时运行器“走私”到下一个。
## GitHub Actions缓存
GitHub的基础actions/cache(在新标签页中打开)(https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching)只知道键和文件路径。你提供自己设计的键给GitHub的缓存服务。如果缓存服务识别出这个键,它会将相应的缓存文件加载到你的运行器中;否则,它什么也不加载。*仅当*这个主键查找未命中时,actions/cache才会在你的CI作业完成后将这些文件保存到缓存服务。
actions/cache仅在作业成功且最初没有该键的精确匹配时,才会向GitHub Actions缓存服务写入新的数据块。一旦写入,Actions缓存中的键值对就是不可变的。*一旦你使用某个键将对象写入GitHub缓存服务,该键值对就是不可变的。任何后续试图为该键持久化不同值的调用都会被拒绝。*
这些都没有问题。事实上,actions/cache是不可或缺的,它使用GitHub的缓存访问限制(在新标签页中打开)(https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching#restrictions-for-accessing-a-cache)来防止缓存投毒。
困难的部分在于选择好的键。
## 改进setup-go
cloudx-io/setup-go实际上是actions/setup-go的即插即用替代品;以下是我们实际上在我们的Web monorepo中更偏好它的原因:
- 我们很乐意付出溢价来确保工程师和编码代理不受阻塞。这意味着可并行化的工作*必须*并行运行(即使这可能会因重复设置工作而增加计费的运行器时间),并且我们很乐意每月多花几美元来获得额外的缓存空间。
- 当我们的构建、测试和代码检查工作负载能够重用之前的缓存值时,它们会快得多。如果我们的所有测试都速度惊人(也许有一天会这样!)或者所有的代码检查规则都很宽松,我们就不会在意`GOCACHE`的命中率。
我们的主要见解只是,Go工具链真的非常出色;一个好的CI缓存策略必须在众多临时运行器实例中保留该工具链最重要的属性,同时在GitHub的约束内工作——即仍然依赖actions/cache。
让我们逐一重温重要的属性。
> 它们通过使键基于*完整但最小*的依赖集来最大化命中率,因此只有在绝对必要时才会发生未命中。
GitHub默认的`setup-go`键是不完整的,因为它没有捕获给定作业实际*执行*了什么。这就是为什么上面示例中的测试和代码检查作业会竞争写入单个、部分的缓存条目。
cloudx-io/setup-go通过将作业标识(或任何任意的`cache-key-prefix`输入)作为Actions缓存键的一部分来解决这个问题。代码检查作业和测试作业可以保存和恢复独立的缓存而不会冲突。
> 每当发生未命中时,新结果总是被持久化到缓存中,以便未来的进程可以重用它。
GitHub默认的`setup-go`仅在`go.mod`更改时才保存新的缓存条目,即使每次你构建或测试源代码的新版本时,都有新数据写入运行器的本地缓存目录。
cloudx-io/setup-go没有丢弃这些增量工作,而是*每次*都写入一个缓存条目:其键中的最后一个元素是GitHub Actions运行ID。完全限定的缓存键包含其他几个元素,以一种git感知的方式鼓励前缀匹配:
通过使精确键匹配变得不可能,cloudx-io/setup-go确保每个作业结束时都会在GitHub Actions缓存服务中写入一个新数据块。## 测量性能
去年年底,当我们仍在使用默认action时,我们遇到了上面讨论的精确竞态条件:我们并行的`lint`作业保存了一个不包含测试结果的Go缓存,这使得我们的`test`作业中位运行时间从76秒恶化到不可接受的180秒。记住,这种减慢完全没有带来价值:作业变慢是为了重新测试与上次运行相比完全没有改变的逻辑。
通过为我们的不同作业分离缓存来消除竞争,立即解决了这个问题:我们引入了cloudx-io/setup-go,`test`作业恢复加载适当的缓存,中位作业运行时间降至41秒,提升了69%。
cloudx-io/setup-go立即将测试作业运行时间缩短了69%。
来自CloudX monorepo的`main`和功能分支的GitHub Actions测试作业时长。
图例 | actions/setup-go | cloudx-io/setup-go
---|---|---
测试作业时长(按连续运行索引) | 0秒 | 45秒 | 90秒 | 135秒 | 180秒
2025年10月16日 | 2025年10月29日 | 2025年11月21日 | 2026年1月2日 | 2025年10月29日
actions/setup-go | 代码检查缓存取代测试缓存 | 并行的代码检查作业在共享缓存键竞争中胜出,保存了一个对测试来说非常陈旧的构建缓存状态。后续的测试运行反复恢复该代码检查形状的缓存。
2025年11月21日 | cloudx-io/setup-go | 将测试缓存与代码检查缓存分离 | 中位测试运行时间从131秒降至41秒,减少了69%。
即使我们串行运行代码检查和测试,cloudx-io/setup-go也会优于默认设置,因为它会在每次运行后保存更新的缓存状态。使用GitHub默认设置,在键更改之间(`go.mod`更改),加载的缓存会逐渐过时。使用我们的新策略,加载的缓存*总是*来自上次运行的最新数据;一次提交的`test`运行仅测试该提交真正修改的测试包。
总而言之,由于我们加载了更新鲜的缓存,现在我们等待运行的测试包减少了86%。为了在真实数据上运行反事实比较,我们取了一系列4000次真实提交,计算每个快照中测试包的action ID,并模拟了旧键构造和新键构造下的缓存命中率。
86%的actions/setup-go测试运行是不必要的。
CloudX monorepo `main`分支提交的测试包运行次数。
显示测试包总数百分比
图例 | actions/setup-go | cloudx-io/setup-go
---|---|---
测试包总数 | 连续提交的测试包运行次数 | 0 | 400
测试包总数 | 提交索引
未缓存测试包运行的选定范围摘要 | GitHub Action | 测试包运行次数
---|---|---
actions/setup-go | 526,166
cloudx-io/setup-go | −86% | 71,928
所有提交。数据来自CloudX monorepo最近的4011次提交。为清晰起见,图表显示25次提交的平均值。
当然,你的结果会有所不同(取决于你更改`go.mod`的频率)。坦率地说,我们发现了切换的两个缺点,都是因为我们保存了更多的缓存对象:
1. 最初我们的缓存数据块随每次运行线性增长;最终它们变得如此之大,以至于缓存加载时间成为我们总体CI时间的一个主要因素。这个问题在actions/setup-go默认行为中也存在:Go的缓存不会自行清理;它会一直增长直到你清除它。我们保存缓存的频率更高,所以它增长得更快。我们通过自动清理解决了这个问题。
2. 你可能需要扩展GitHub Actions缓存容量。这会被作业运行变快所抵消——运行器按分钟计费——但在GitHub中找到必要的设置很麻烦。
cloudx-io/setup-go(在新标签页中打开)(https://github.com/cloudx-io/setup-go)自去年11月以来在内部一直很稳定。我们希望它能为你的团队节省一些时间,并期待听到你的想法!
xkcd 303: 编译中(在新标签页中打开)(https://xkcd.com/303/)你是在等待CI完成时阅读这篇文章的吗?探索CloudX的职业机会!(https://www.cloudx.ai/careers)
相似文章
Fast Haskell Scripts on GitHub Actions
The article explains how to speed up running Haskell scripts with Magix on GitHub Actions by caching dependencies and compiled artifacts, reducing full build times from over 100 seconds to near-instant reruns.
将你的 GitHub CI 迁移至 Hugging Face Jobs
本文提供了一份逐步指南,教你如何将 GitHub Actions CI 迁移到 Hugging Face Jobs 上运行,从而为类似 Trackio 的项目启用基于 GPU 的测试套件,并将 CPU CI 时间缩短 30%。
以缓存友好方式在 GitHub Actions 中使用 uvx
Simon Willison 分享了一种以缓存友好的方式在 GitHub Actions 中使用 uvx 的方法,通过设置环境变量并将其纳入缓存键,以避免重复下载 PyPI。
为编码代理构建云环境(13分钟阅读)
Cursor 解释了它如何为编码代理设置云环境,包括一个名为 anydev 的 CLI 来简化构建命令,并指出云代理现在负责其单体仓库中超过一半的合并拉取请求。
20倍的CI流量而不变慢:我们如何在Datadog重建Git服务
Datadog使用名为gitretriever的自定义镜像重建了他们的Git服务基础设施,处理了20倍的CI流量,同时保持了低延迟并减少了CPU使用率。