Skip to content

필사 모드: 单体仓库 CI 缓存策略 — 别扩容缓存,要修的是缓存键

中文
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

引言 — 缓存扩容了,CI 时间却纹丝不动

每当有人反映单体仓库的 CI 太慢,最先冒出来的提议几乎总是同一句话:“缓存老是被挤掉,那就把容量做大。”于是团队调高存储配额、扩大 runner 磁盘、延长产物保留期限。可到了下周,CI 照样要跑 12 分钟。

原因通常不是容量。缓存其实写得进去,只是查的时候总是对不上。可能是每次构建都混进了一点不一样的环境变量、时间戳被烙进了产物里、runner 镜像一变工具链版本就跟着变,又或者缓存键里塞进了 commit SHA,从一开始就造出一把永远无法复用的键。在这种状态下把存储做大,只会让垃圾数据留得更久。

本文以 Turborepo、Nx、Bazel、GitHub Actions 的缓存为素材,围绕精确的缓存键与 hermetic(封闭性)输入这一条主线来梳理单体仓库的 CI 缓存。构建慢的一般性原因在为什么构建这么慢一文中已经讲过,内容寻址存储的原理则在内容寻址存储一文中。这里只看单体仓库 CI 特有的部分。

缓存命中是键的问题,不是大小的问题

所有构建缓存都建立在同一份契约之上:输入相同,输出就相同。缓存键就是把这份“输入”哈希后的值,命中率则和这把键捕捉输入的精确程度成正比。键比实际范围更宽(把不必要的东西也纳入了),就不会命中;键比实际范围更窄(漏掉了某个关键输入),就会复用错误的结果。

每种工具遵守这份契约的方式都不一样。

工具缓存单位哈希里包含什么封闭性保证远程缓存
Turborepo按包(package)划分的任务包源码、已声明的 env、内部依赖包的哈希、任务定义基于约定。未声明的环境变量在 strict 模式下会被拦截Vercel 托管,或自建的 OpenAPI 兼容服务器
Nx按项目(project)划分的任务通过 namedInputs 定义的文件集合、env、依赖项目的哈希基于约定。inputs 的定义就是契约本身Nx Cloud 或自托管缓存
Bazelaction(单条命令)已声明的输入文件、命令行、环境、整套工具链由沙箱强制执行。未声明的文件根本不可见gRPC 远程缓存协议
GitHub Actions cache任意目录打包成的 tarball用户手写的字符串键无。完全由编写者自行负责仓库范围、分支范围

这张表要读出来的不是性能排名,而是责任落在哪里。Bazel 用沙箱在物理层面挡住未声明的输入,所以封闭性是由工具本身强制的。Turborepo 和 Nx 立下“声明的就是全部”这条约定,指望用户自觉遵守。GitHub Actions 缓存的键字符串则完全由人手写——最灵活,也最常出错。

所以实践中的第一步诊断永远一样:去问工具为什么没命中。不先分清是缓存被挤掉了、键变了,还是这个任务压根就不在缓存范围内,任何应对措施都只是猜测。

收窄哈希输入的三个抓手

实践中,键出错的原因几乎总是这三种之一。

第一,文件输入范围太宽。默认值通常是“包内的所有文件”。哪怕只是改了 README,测试也会重新跑一遍,依赖这个包的整条下游子图都会被判失效。必须按任务只声明真正用到的输入。

// turbo.json — 按任务收窄输入范围
{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["src/**", "tsconfig.json", "package.json"],
      "outputs": ["dist/**"]
    },
    "test": {
      "inputs": ["src/**", "tests/**", "vitest.config.ts"],
      "outputs": []
    }
  }
}

Nx 用 namedInputs 做同一件事。定义一次、在各个项目里复用的结构,让大规模仓库的管理变得轻松。

// nx.json
{
  "namedInputs": {
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    "production": [
      "default",
      "!{projectRoot}/**/*.spec.ts",
      "!{projectRoot}/**/*.md"
    ],
    "sharedGlobals": ["{workspaceRoot}/tsconfig.base.json"]
  },
  "targetDefaults": {
    "build": { "inputs": ["production", "^production"], "cache": true }
  }
}

第二,环境变量渗了进来。这一点会在两个方向上出错。如果 CI 供应商注入的 GITHUB_RUN_ID 之类的值混进了哈希,就永远不会命中;反过来,如果真正会改变构建结果的 NODE_ENV 或 API 端点没有被算进哈希,开发环境的构建就会被当成生产缓存复用。

Turborepo 的 strict 环境模式 是这个问题的标准解法。默认就是 strict,凡是没有通过 envglobalEnv 显式声明的变量,在任务运行时根本不可见。如果构建突然失败了,恰恰证明那个变量原本就在偷偷被用。

{
  "globalEnv": ["NODE_ENV"],
  "globalPassThroughEnv": ["CI", "GITHUB_ACTIONS"],
  "tasks": {
    "build": {
      "env": ["NEXT_PUBLIC_API_URL", "SENTRY_*"],
      "outputs": [".next/**", "!.next/cache/**"]
    }
  }
}

passThroughEnv 会把值传给任务,但不会算进哈希。它只留给那些不会改变结果的变量,比如日志开关之类的标志位。一旦往里面塞了会改变结果的变量,缓存从那一刻起就开始说谎。

第三,产物里嵌入了不确定的值。构建时间戳、绝对路径、构建编号、随机的 chunk ID。这本质上不是键的问题而是输出的问题,但它最终会改变上游任务消费的输入,从而拖垮整条缓存链。可复现构建的标准做法,是把 SOURCE_DATE_EPOCH 固定为提交时间,并把路径改成相对路径。

# 把提交时间固定为构建时间戳
export SOURCE_DATE_EPOCH="$(git log -1 --pretty=%ct)"

# 用相同输入构建两次,检查产物是否逐字节相同
pnpm build && cp -r dist /tmp/build-a
rm -rf dist && pnpm build && cp -r dist /tmp/build-b
diff -r /tmp/build-a /tmp/build-b && echo "reproducible"

这三步检查是缓存工作第一天就该做的事。如果产物无法复现,建立在它之上的所有缓存讨论都没有意义。

affected 判定 — 不执行才是最快的执行

缓存命中也不是免费的。远程缓存查询要走一次网络往返,下载和解压产物同样要花时间。在有数千个任务的仓库里,“压根不在范围内、连查都不用查”比“全部查一遍、全部命中”要快得多。

# Turborepo — 只处理 origin/main 之后变更的包及其依赖方
turbo run build test --filter="...[origin/main]"

# Nx — 通过项目图和 git 历史判定 affected
nx affected -t build test --base=origin/main --head=HEAD

# 用肉眼确认选中了什么、为什么被选中
nx show projects --affected --base=origin/main
nx graph --affected --base=origin/main

# Bazel — 从变更的文件查询反向依赖目标
bazel query "rdeps(//..., set($(git diff --name-only origin/main)))" --output=label

这里常见的失误是选错了基准提交。在 PR 工作流里用 --base=origin/main,一旦 main 领先了,无关的改动也会被算进 affected。正确的基准点是合并基点(merge base)。

BASE="$(git merge-base origin/main HEAD)"
nx affected -t build --base="$BASE" --head=HEAD

而且如果是浅克隆(shallow clone),这个计算本身就无法进行。GitHub Actions 的 checkout action 默认 depth 为 1,所以用到 affected 的工作流必须拉取足够的历史记录。

- uses: actions/checkout@v4
  with:
    fetch-depth: 0 # 或者至少要包含合并基点

affected 判定在哪些地方会失去可信度,同样值得了解。项目图捕捉不到的隐性依赖——运行时用字符串拼出来的模块路径、生成代码、共享配置文件、Docker 基础镜像——一旦发生变化,也会被 affected 漏掉。更稳妥的做法,是把这些项显式写进 sharedGlobals 或 globalDependencies,声明为全局失效目标。命中率略微下降,也好过放过一个错误的结果。

共享远程缓存时出现的信任边界

远程缓存的收益和共享范围成正比。只在 CI runner 之间共享,只有重跑同一个 commit 时才划算;把开发者的机器也纳进来,早上拉了 main 的人就能不构建直接开工。

但共享范围一旦扩大,就会冒出一个问题:谁能写入缓存,谁就决定了别人的构建产物。从一台笔记本电脑上传一份被污染的产物,所有下载过它的人,构建都会用上这个结果。所以实践中的规则很简单。

  • 只有受信任的 CI 才能写入。开发者机器和 fork PR 一律只读。
  • 写入方只在可复现的环境里运行。固定的容器镜像,固定的工具链版本。
  • 给产物签名。下载时验证失败,就当作未命中处理。

Turborepo 支持签名。在 turbo.json 里开启,把密钥通过环境变量传进去,就会附上 HMAC-SHA256 签名;验证失败的产物会被忽略,按缓存未命中处理。

{ "remoteCache": { "signature": true } }
# CI:拥有写权限的受信任 job
export TURBO_API="https://cache.example.internal"
export TURBO_TEAM="platform"
export TURBO_TOKEN="***"
export TURBO_REMOTE_CACHE_SIGNATURE_KEY="***"
turbo run build

# 开发者机器 / fork PR:只分发只读令牌
export TURBO_TOKEN="read-only-***"

自托管也不难。Turborepo 公开了远程缓存 API 的 OpenAPI 规范,在兼容 S3 的存储前面搭一层薄薄的服务器就够了。Bazel 用的是 gRPC 远程缓存协议,并且有按 job 区分写权限的参数。

# 只读的使用方(开发者、fork PR)
bazel build //... \
  --remote_cache=grpcs://cache.example.internal \
  --noremote_upload_local_results

# 写入方(受信任的 CI)
bazel build //... \
  --remote_cache=grpcs://cache.example.internal \
  --remote_upload_local_results

为什么缓存投毒和封闭性是同一个问题

2026 年,“CI 缓存是一条供应链攻击路径”这件事被反复验证。在 5 月 11 日的 TanStack 事故中,据报告,一份被污染的缓存被写入了 main 分支的作用域,随后一连串恶意包版本被连锁发布了出去。GitHub 在 6 月 26 日的更新日志中把默认行为改成了对不受信任的触发器只签发只读缓存令牌

具体的变化是这样的。对于那些不需要仓库写权限就能触发的事件——pull_request_targetissue_comment、源自 fork PR 的 workflow_run——如果它们的执行上下文和缓存作用域来自默认分支的 SHA,缓存令牌就会变成只读。像 pushscheduleworkflow_dispatch 这样受信任的触发器,以及使用非默认分支作用域的 pull_requestrelease,仍然保留读写权限。因此,如果一个原本会写入缓存的工作流落进了上述条件,就需要拆分:把写入部分挪到由 push 触发的工作流里,其余部分只做恢复。

这里要注意的是,这只是一项访问控制措施,不是根本解决方案。根本问题在于,缓存条目并不能证明自己“是由什么构建出来的”。在 hermetic(封闭式)构建里,缓存键是整份输入的哈希,所以很难把被污染的产物塞进一把合法的键背后。反过来,如果键只是一段人手写的字符串——GitHub Actions 缓存正是如此——那么只要键对得上,塞进去什么内容都可以。

把实践中要守住的规则整理一下,是这样的。

  • 消灭跨信任边界的缓存共享。给键加上作用域,让 fork PR 和默认分支不再共用同一个键空间。
  • 绝不把用不受信任的输入构建出来的文件放进缓存。GitHub 提供了能捕捉这种模式的 CodeQL 查询——通过代码注入实现的缓存投毒缓存不可信文件
  • 把缓存条目的 TTL 设短。GitHub 的默认值是以最后访问时间为基准的 7 天滑动窗口,这意味着一次投毒可能存活一整周。
  • 把依赖缓存和构建产物缓存分开。前者靠锁文件哈希,是确定性的;后者是执行结果,需要的信任级别不一样。

整条 CI 流水线的信任边界,在CI 智能体与提示注入一文中有更全面的讨论。

老老实实地衡量命中率

缓存工作的成果汇报里,最常见的夸大手法有三种。

拿同一台机器上跑两次的时间做对比。第二次运行命中的是本地缓存,你根本无法知道远程缓存是否真的起作用。测量必须在全新的 runner、全新的 clone 上进行。

把 affected 跳过和缓存命中合并计算。这是两种不同的优化,失败的表现方式也不一样。affected 判断不足,会放过一次错误的构建;缓存命中不足,只是变慢而已。两者混在一起算,就分不清到底是哪一边出了问题。

只看按任务数量算出来的命中率。如果 900 个无关紧要的 lint 任务命中了,而 10 个繁重的构建任务没中,命中率会显示 98%,但 CI 时间纹丝不动。命中率还必须结合按节省的时间计算这个口径一起看。

测量本身,工具会帮你做好。

# Turborepo — 把运行摘要落成 JSON,汇总缓存状态
turbo run build --summarize
jq '[.tasks[] | {task: .taskId, status: .cache.status, ms: .execution.duration}]' \
  .turbo/runs/*.json

# 对比两次运行的摘要,找出哪个输入不一样
turbo run build --dry=json > /tmp/run-a.json
# (在另一个环境里重新运行)
diff <(jq -S . /tmp/run-a.json) <(jq -S . /tmp/run-b.json)
# Bazel — 对比两次运行的 action 日志,找出非封闭性的因素
bazel build //... --execution_log_compact_file=/tmp/exec-ci.log
bazel build //... --execution_log_compact_file=/tmp/exec-local.log
# action 键相同却没命中,是配置问题;键不同,则是输入不同

Bazel 文档里的诊断原则,不管用什么工具都同样适用——action 键不同,就是输入不同;action 键相同却没命中,就是缓存配置的问题。先把这两条分支分开,排查范围就能砍掉一半。

目标数值这件事,也老实说一下。在远程缓存运行正常的仓库里,相对 main 的小型 PR,命中率通常能到 90% 以上。如果反复运行下命中率跌破 80%,合理的猜测是键或存储层出了问题。不过这个数字会因仓库结构和任务分布的不同而剧烈波动,所以应该看自己仓库里两周的趋势来判断,而不是照搬别人的基准数据。

结语 — 缓存不是存储设备,是一份契约

把缓存调优当成存储容量问题来处理,几乎总是会失败。缓存是“输入相同,输出就相同”这份契约,命中率则是衡量你把这份契约描述得有多精确的指标。

  • 先确认可复现性。如果同样的输入构建两次得到不同的字节,缓存的讨论要等到那之后再说。
  • 按任务收窄 inputsenv,用 strict 环境模式把泄漏的变量揪出来。构建一旦坏了,就说明你找到了一个原本偷偷被用的输入。
  • affected 是排在缓存之前的优化。把隐性依赖显式声明为全局失效目标,而不是指望依赖图能自动捕捉到。
  • 把远程缓存的写入权限限制给受信任的 CI,打开签名,让不受信任的触发器只读。2026 年的那些事故,无一不是因为少了这道边界。
  • 测量要在全新的 runner、全新的 clone 上进行,把 affected 和缓存分开计算,并按节省的时间为基准。

没有哪个 CI 是靠缓存做大而变快的。只有精确了解自己输入的 CI,才会变快。

参考资料

현재 단락 (1/143)

每当有人反映单体仓库的 CI 太慢,最先冒出来的提议几乎总是同一句话:“缓存老是被挤掉,那就把容量做大。”于是团队调高存储配额、扩大 runner 磁盘、延长产物保留期限。可到了下周,CI 照样要跑 ...

작성 글자: 0원문 글자: 9,545작성 단락: 0/143