通过替换 actions/setup-go 来扩展 Golang CI
Scaling Golang CI by Replacing actions/setup-go

原始链接: https://www.cloudx.ai/posts/setup-go

CloudX 发布了 `cloudx-io/setup-go`,这是 GitHub `actions/setup-go` 的直接替代品,旨在通过优化缓存管理来提升 Golang CI 的性能。 标准的 `actions/setup-go` 操作存在两个主要缺陷: 1. **缓存冲突**:在并行工作流(例如分别进行 lint、测试和构建的任务)中,并发任务会争抢并覆盖同一个共享的缓存键。这会导致缓存陈旧且不完整,从而引发不必要的重复测试。 2. **更新频率低**:默认操作仅在 `go.mod` 文件发生更改时才会刷新缓存。因此,随着代码库的发展,缓存会变得越来越陈旧,导致 CI 任务重复处理已完成的工作。 `cloudx-io/setup-go` 通过在缓存键中加入唯一的任务标识符和运行 ID 解决了这一问题。这确保了并行任务拥有独立的缓存,并保证缓存会在每次成功运行后更新。 在 CloudX 的单体代码库中,该方案使测试任务的运行时间缩短了 69%,并消除了 86% 的冗余测试执行。尽管这种策略会增加缓存存储的使用量,但所带来的速度提升显著降低了 CI 总账单费用并缩短了开发人员的等待时间。该工具现已开源,可供追求更快、更可靠 Golang CI 的项目使用。

这篇 Hacker News 讨论聚焦于 CloudX 团队针对其定制化方案 `cloudx-io/setup-go` 所发布的文章,该方案旨在优化 Golang CI 的性能。 作者指出,默认的 `actions/setup-go` 在运行多个并发 Go 任务时表现不足,导致了大量的时间浪费。他们的定制实现通过为各个任务设置缓存键前缀,并引入“修剪”机制来管理缓存增长,从而提高了效率。 讨论中强调了几个关键见解: * **“缓存膨胀”问题:** 用户指出,低效的缓存往往会导致 CI 变慢,原因是传输和解压了大量不必要的压缩包。 * **可维护性与上游更新:** 尽管官方 `actions/setup-go` 的维护者对这些改进表示了兴趣,但评论者普遍认为,由于官方组件的高风险性和关键性,将复杂的更改合并到上游非常困难。许多开发者更倾向于分享定制的专用替代方案,而不是等待上游更新。 * **替代方法:** 讨论还涉及使用自定义 Docker 镜像、每日缓存刷新,以及利用 Go 1.24 的 `GOCACHEPROG` 来实现更精细的控制。 最终,参与者一致认为,对于任何规模化的工程团队而言,优化 CI 都是一项影响深远且必须持续进行的工作。
相关文章

原文

We've found a new way to speed up parallel Golang continuous integration workflows by taking advantage of the Golang build cache. Replacing GitHub's official actions/setup-go action with a drop-in equivalent cut our test job runtimes by 69%. We're open-sourcing cloudx-io/setup-go (opens in a new tab) so you can do the same.

GitHub's official actions/setup-go step makes parallel jobs interfere with each other's performance, and it continuously loads stale cache values. Backtesting in our monorepo, which has the common situation of a few parallel Golang test jobs (one for lints, one for tests, one for builds), suggests that 86% of the work the default action does is completely unnecessary.

If you manage a moderately complex Go project, you can expect similar performance improvements; see our CI measurement methodology or just try it for yourself.

We Care About Fast CI

We've been shipping a lot of new products and features, and the pace at which we do it is actually increasing over time. This is no accident — we invest heavily in the tools and processes required to make this possible. At the center of every "software factory" are the test suites and Continuous Integration (CI) workflows that ensure code changes won't break in production. If our tests run reliably, and quickly, on every change, we can build at fantastic speed without worrying about breaking things for our customers.

This is important to us, so we measure and invest in the speed of our CI jobs. If you push code to a CloudX repository, our goal is that you get a clear answer as to its acceptability — whether it builds, its tests pass, and it abides by our linter rules — within 90 seconds.

Speed can be achieved in a number of ways, but at the end of the day if you want things to be fast you have to make algorithmic improvements. We're already using Warp Build (opens in a new tab) to run our CI jobs on fast, cost-efficient machines. As our test suite has scaled with our product surface area, we realized that actions/setup-go was not setting us up for success.

How actions/setup-go fails for parallel jobs

GitHub's actions/setup-go (opens in a new tab) is the GitHub-encouraged way to install and run Go in GitHub Actions. It uses actions/cache internally to save and restore the local Go module cache and build cache directories. In principle, that should make downloaded module source code and build/test artifacts from one job run available to all the subsequent job runs in your repo. Here's the default actions/setup-go cache key construction:

This cache key is woefully incomplete: in a typical product under active development, only a tiny minority of code changes modify the target operating system, architecture, Go version, or go.mod files.

The first time a job computes this hash key, it persists the final cache state to the GitHub cache service. Until the next change that modifies one of those key elements, every single CI run will load that first value. As you change your application, the restored go build module archives from this first run weaken — each subsequent build does more work from scratch. The restored go test outputs go stale too, so each subsequent job reruns more tests. CI degrades until you update go.mod!

Moreover, multiple parallel jobs running actions/setup-go race to write different local cache states to the GitHub cache service — different because the final Go cache state on a runner depends both on the source code and on the commands run. For example, you might run separate lint and test jobs in parallel:

Both jobs resolve the same default cache key, then race to write its value. Suppose the lint job finishes first: it saves a value without an updated test cache state. Subsequent test jobs will keep using that stale value until the cache key changes, and therefore re-run tests unnecessarily.

Linting, building, and testing a codebase are ideal candidates for memoization: their outputs (linter messages, built binaries, and test results respectively) should be pure functions of the source code. You can store outputs and reuse them rather than recomputing them, so long as the inputs haven't changed.

Several parts of the standard Go toolchain save their outputs to the filesystem and check if they can reuse an existing output instead of recomputing a new one from scratch:

CacheControlling env variableDefault Linux location
Module cacheGOMODCACHE$GOPATH/pkg/mod
Build cacheGOCACHE~/.cache/go-build
Test cacheGOCACHE~/.cache/go-build

Go's module cache saves time spent downloading source code for your module dependencies, which you can trigger explicitly with go mod download but also implicitly with go build. There's nothing mysterious here, just source code organized by the package identifiers in your go.mod:

You trigger fresh downloads when you change your go.mod, e.g. to add a new dependency or upgrade an existing one.

Go's build cache and test cache are actually located together in the GOCACHE directory and share a general structure. Both build and test processes hash their full inputs for use as a cache key. Those hashes are organized into subdirectories by prefix, and used as filenames for the reusable process outputs:

Files with the suffix -d are data payloads, and the -a-suffixed files serve as indexes. Of course, build and test processes yield different data payloads:

  • go build stores package archives, intermediates that are linked into a final binary.
  • go test stores stdout, stderr, and the final exit code of the test execution.

The Go test runner spies on the test process, automatically detects what files it reads, and incorporates their contents as inputs to the cache key.

The principles underlying these tool caches are the same: they maximize hit rates by making keys of complete but minimal sets of dependencies, so misses only occur when absolutely necessary. Whenever there's a miss, the new result is always persisted to the cache so future processes can reuse it.

This works brilliantly in a single persistent filesystem, but CI runners don't have the benefit of a single persistent filesystem. In GitHub Actions, these toolchain caches are smuggled from one ephemeral runner to the next by stowing them in yet another cache — one with very different design priorities.

The GitHub Actions cache

GitHub's base actions/cache (opens in a new tab) just knows keys and filepaths. You give GitHub's cache service a key of your own design. If the cache service recognizes the key, it loads the corresponding cached files into your runner; otherwise, it loads nothing. If and only if this primary-key lookup missed, actions/cache saves these files to the cache service after your CI job completes.

actions/cache only writes a fresh blob to the GitHub Actions cache service if the job succeeds and there was no exact match for that key initially. Once written, key-value pairs in the Actions cache are immutable.

Once you write an object to the GitHub cache service under a certain key, that key-value pair is immutable. Any subsequent calls that would persist a different value for that key are rejected.

There is nothing wrong with any of that. Indeed, actions/cache is indispensable, and it uses GitHub's cache access restrictions (opens in a new tab) to prevent cache poisoning.

The hard part is picking good keys.

Improving setup-go

cloudx-io/setup-go is effectively a drop-in replacement for actions/setup-go; here's why we actually prefer it for our web monorepo:

  • We're happy to pay a premium to keep engineers and coding agents unblocked. That means parallelizable work must run in parallel (even if this increases billed runner time by repeating setup work), and we gladly pay a few bucks per month for extra cache space.
  • Our build, test, and lint workloads are much faster when they can reuse prior cached values. If all our tests were wicked fast (maybe one day they will be!) or all our lint rules wimpy, we wouldn't sweat our GOCACHE hit rates.

Our main insight is just that the Go toolchain is really, really good; a good CI caching strategy has to preserve that toolchain's most important properties across lots of ephemeral runner instances, while working within GitHub's constraints — i.e. still adapting actions/cache.

Let's revisit the important properties one by one.

They maximize hit rates by making keys of complete but minimal sets of dependencies, so misses only occur when absolutely necessary.

GitHub's default setup-go keying is incomplete because it doesn't capture what a given job actually does. That's why the test and lint jobs in the example above race to write a single, partial cache entry.

cloudx-io/setup-go solves this by making the job identity (or any arbitrary cache-key-prefix input) part of the Actions cache key. The lint job and test job save and restore separate caches without conflicts.

Whenever there's a miss, the new result is always persisted to the cache so future processes can reuse it.

GitHub's default setup-go only saves a new cache entry when go.mod changes, even though there's new data written to the runner's local cache directories every time you build or test a new version of your source code.

Instead of discarding that incremental effort, cloudx-io/setup-go writes a cache entry every single time: the final element in its key is the GitHub Actions run ID. The fully-qualified cache key includes several other elements to encourage prefix-matching in a git-aware way:

By rendering exact key matches impossible, cloudx-io/setup-go ensures every job concludes with a freshly-written blob in the GitHub Actions cache service.

Measuring performance

Late last year, while we still used the default action, we encountered exactly the race condition discussed above: our parallel lint job saved a Go cache without test results, which slowed our test jobs from a 76-second median runtime to an unacceptable 180-second median. Remember, this slowdown represents exactly zero value: the jobs slowed down to re-test logic completely unchanged from the run before.

Eliminating the race by separating caches for our various jobs immediately solved this problem: we introduced cloudx-io/setup-go, test jobs resumed loading appropriate caches, and the median job runtime fell to 41 seconds, a 69% improvement.

cloudx-io/setup-go immediately cut test job runtimes by 69%.

GitHub Actions test job durations from the CloudX monorepo main and feature branches.

Chart legend
Test job duration by sequential run index0s45s90s135s180sOct 16, 2025Oct 29, 2025Nov 21, 2025Jan 2, 2026

The parallel lint job wins the shared cache-key race, saving a build-cache state that is very stale for tests. Subsequent test runs repeatedly restore that lint-shaped cache.

Median test runtime falls from 131 seconds to 41 seconds, a 69% reduction.

Even if we lint and test in series, cloudx-io/setup-go would outperform the default because it saves an updated cache state after every run. Using the GitHub default, the loaded cache grows progressively staler between key changes (go.mod changes). With our new strategy, the loaded cache is always fresh from the run before; the test run for a commit only exercises test packages genuinely modified by that commit.

In aggregate, we wait for 86% fewer test packages to run now that we load a fresher cache. To run the counterfactual comparison on real data, we took a sequence of 4,000 real commits, calculated the action IDs for each snapshot's test packages, and modeled cache-hit rates under the old and new key constructions.

86% of actions/setup-go test runs are unnecessary.

Count of test package runs for commits on the CloudX monorepo main branch.

Chart legend
Count of test package runs over consecutive commits0400Total test package countCommit index
Selected range summary for uncached test package runs
GitHub ActionTest package runs
actions/setup-go526,166
cloudx-io/setup-go86%71,928
All commits. Data from the 4,011 latest commits in the CloudX monorepo. Chart displays 25-commit averages for clarity.

Of course, your mileage will vary (according to how often you change go.mod). To be transparent, we've seen two downsides to the switch, both because we save so many more cache objects:

  1. Initially our cache blobs grew linearly with each run; eventually they grew so large that cache-load times became a major factor in our overall CI time. This is an issue present in the actions/setup-go default behavior too: Go's cache doesn't prune itself; it grows until you clear it. We save the cache more often, so it grows faster. We solved this with automatic pruning.
  2. You may need an expanded GitHub Actions cache capacity. This is offset by making your jobs faster — runners bill by the minute — but locating the necessary settings in GitHub is a pain.

cloudx-io/setup-go (opens in a new tab) has been stable internally since November of last year. We hope it saves your team some time, and we look forward to hearing what you think!

xkcd 303: Compiling (opens in a new tab)Did you read this while waiting for your CI to finish?
Explore careers at CloudX!

联系我们 contact @ memedata.com