为什么你的 CI 缓存总在“演我”?Rust 增量编译失效深度诊断
在 Rust 社区中,有一句著名的自嘲:“我写代码用了 5 分钟,但编译它用了半小时。”
为了解决这个痛点,Cargo 提供了增量编译(Incremental Compilation)机制。然而,许多团队在将项目接入 GitHub Actions、GitLab CI 或 Jenkins 后,会发现一个诡异的现象:明明已经在 CI 配置中加上了 target 目录的缓存,但每次编译耗时依然像从零开始一样。
本文将从 Rust 编译器的底层逻辑出发,带你拆解为什么 CI 环境中的增量编译总是“失效”,并提供一套生产环境可用的加速方案。
一、 核心痛点:为什么 target 缓存不生效?
在本地开发时,增量编译工作良好,是因为 target 目录被持久化保留。但在 CI 环境中,每次 Pipeline 运行往往是在隔离的容器或虚拟机中。即便你通过 CI 工具(如 actions/cache)恢复了 target 目录,编译器往往还是会决定重新编译。
主要原因有以下三点:
1. 绝对路径的“背叛”
Rustc 在编译过程中,会将源文件的绝对路径编码到 Metadata 和 Debug 信息中。
- 本地环境:项目始终在
/Users/name/project。 - CI 环境:GitHub Actions 默认路径可能是
/home/runner/work/repo/repo,而 GitLab Runner 可能在/builds/group/project。
如果两次 CI 运行的路径不完全一致(或者在不同宿主机上运行),Rustc 会发现 Metadata 里的路径指引失效,从而触发重绘。
2. mtime(修改时间)的陷阱
Cargo 的构建跟踪很大程度上依赖于文件系统的元数据。在 CI 检出(Checkout)代码时,文件的 mtime 会被更新为当前检出时间。如果你的缓存恢复策略不当,或者 CI 环境没有保持文件的原始时间戳,Cargo 会认为所有源文件都已被修改。
3. 环境变量与 Toolchain 的微小差异
Rustc 的 Fingerprint(指纹)计算包含了环境变量(如 RUSTFLAGS)、编译器版本、甚至依赖库的 feature 开启状态。如果 CI 配置中某个隐含的环境变量发生了变化,整个缓存树就会失效。
二、 深度解析:Rustc 判定“失效”的逻辑
Cargo 的增量编译将构建过程拆分为许多小的“Query”。每个 Query 的结果会被缓存到 target/debug/incremental 目录下。
判定一个 Query 是否可复用的公式近似于:Hash(Source Code + Compiler Version + Environment Variables + File Path + Dependencies)
在 CI 中,File Path 是最容易被忽视的变量。当你在 Docker 容器中编译时,如果每次容器 ID 或挂载路径不同,Hash 就会彻底改变。
三、 治标更要治本:生产级解决方案
如果直接缓存 target 目录效果不佳,你应该尝试以下组合拳:
1. 终极杀招:sccache
比起缓存巨大的 target 目录,sccache(Mozilla 出品)是更适合 CI 的方案。
- 原理:它是一个编译器包装器(Wrapper),将编译结果存储在远程后端(如 S3, GCS, Azure Blob)。
- 优势:它不依赖于本地文件系统的增量状态,而是基于输入文件的内容哈希。即使
target被清空,只要代码没变,sccache 就能直接命中缓存。 - 配置示例:
env: RUSTC_WRAPPER: sccache SCCACHE_BUCKET: my-rust-cache
2. 针对 Docker 环境:cargo-chef
如果你在 Docker 中构建 Rust 应用,传统的 COPY . . 会导致任何代码变动都破坏镜像缓存。
cargo-chef 专门解决这个问题:
- Prepare阶段:扫描项目生成一个不含代码、仅含依赖的
recipe.json。 - Cook阶段:根据 recipe 编译所有依赖。这一层镜像会被 Docker 缓存。
- Build阶段:最后才拷贝代码进行业务编译。
这样,只要Cargo.toml没动,依赖层就永远不会重排。
3. 路径重映射(Path Remapping)
为了消除绝对路径对缓存的影响,可以通过 RUSTFLAGS 强制统一路径映射:
export RUSTFLAGS="--remap-path-prefix=$(pwd)=/src"
这样无论你的 CI 运行在哪个目录下,编译器看到的路径永远是 /src。
4. 慎用 Incremental,改用共享 Cache
有一个违背直觉的结论:在 CI 环境中,有时关闭 CARGO_INCREMENTAL=0 反而更快。
- 原因:增量编译会产生大量零碎的小文件,这会导致 CI 在打包/解压缓存时(I/O 阶段)耗费极长时间。
- 建议:在 CI 中关闭 Rust 自带的增量编译,转而利用
sccache或对target/release/deps进行精细化的目录缓存。
四、 避坑指南:CI 缓存的最佳实践
- 锁定 Toolchain:在 CI 中务必使用
rust-toolchain.toml锁定版本,防止编译器静默升级导致缓存失效。 - 精简缓存体积:不要缓存整个
target。通常只需要缓存target/release/deps和~/.cargo/registry。 - 清理冗余:使用
cargo-sweep定期清理缓存中旧版本的依赖产物,防止 CI 缓存文件膨胀到几个 GB。
总结
CI 环境下的 Rust 编译优化,本质上是在确定性与存储开销之间做权衡。如果你的项目规模较小,优化 Cargo.lock 的缓存键值即可;如果是超大型单体仓库,引入 sccache 并统一编译路径映射则是必经之路。
记住,最好的优化是不编译。 通过合理拆分 Crate 和利用二进制缓存,你的 CI 耗时完全可以从“喝杯咖啡”缩短到“打个哈欠”。