如何缓存构建脚本输出?
解读
在 Rust 项目里,build.rs 被称为“构建脚本”,它在 cargo build 过程中先于主 crate 被编译并执行,用来生成代码、链接 C 库、探测系统特征等。
国内 CI 环境(如 GitHub Actions 缓存、阿里云 Flow、腾讯蓝盾、Gitee Go)为了节省时间和费用,会把 target/ 整体缓存下来;然而构建脚本的 stdout、stderr 以及它通过 cargo:rustc-cfg=... 等指令产生的“元数据”并不会自动落盘到任何文件,下次编译 Cargo 仍需重新运行 build.rs。
面试官问“如何缓存构建脚本输出”,本质想考察两点:
- 你是否理解 Cargo 的重运行决策机制(rerun-if-changed、rerun-if-env-changed);
- 你能否把“脚本执行结果”本身变成可校验、可复现、可缓存的制品,让 CI 与本地开发都能“一次运行,到处命中”。
知识点
- CARGO_TARGET_DIR 与 CARGO_BUILD_TARGET_DIR:控制产物目录,CI 里通常指向共享缓存盘。
- rerun-if-changed=PATH / rerun-if-env-changed=KEY:告诉 Cargo 只有指定文件或环境变量变化时才重新跑 build.rs;不写则默认“永远重跑”。
- OUT_DIR:构建脚本唯一被允许写文件的目录,生成的代码或标记文件必须放在这里,才能随 crate 一起被编译缓存。
- 构建脚本副作用隔离:任何在 OUT_DIR 之外写文件、改系统状态的行为都会使缓存失效。
- 指纹机制(fingerprint):Cargo 把 build.rs 的哈希、环境变量、rerun-if 列表等写入
target/debug/.fingerprint/<pkg>-<hash>/,命中指纹即跳过脚本执行。 - sccache / rust-cache:国内常用 GitHub Action
Swatinem/rust-cache@v2,它把~/.cargo/registry/index、~/.cargo/registry/cache、target/一并缓存,但对构建脚本输出的缓存仍需你自己把“结果”落到磁盘。 - 构建脚本结果序列化:把探测到的 cfg、路径、版本号写成一个 JSON/Markdown/TOML 落盘到 OUT_DIR,主代码用
include!(concat!(env!("OUT_DIR"), "/meta.rs"))引入,这样元数据随 rlib 一起被缓存。
答案
分四步落地,可在 99% 的国内 Rust 工程里直接复用:
-
在 build.rs 里只把结果写进 OUT_DIR,绝不污染源码树。
use std::{env, fs, path::PathBuf}; fn main() { let out = PathBuf::from(env::var("OUT_DIR").unwrap()); // 探测系统 openssl 版本 let v = pkg_config::probe_library("openssl").unwrap().version; fs::write(out.join("openssl.version"), &v).unwrap(); // 告诉 Cargo:只有 build.rs 自身变化才重跑 println!("cargo:rerun-if-changed=build.rs"); } -
主代码通过
include_bytes!/include_str!读取缓存值,编译期即内联到二进制,无需运行时 IO。const OPENSSL_VERSION: &str = include_str!(concat!(env!("OUT_DIR"), "/openssl.version")); -
CI 层利用 rust-cache 把
target/整体缓存;
若使用自研 Jenkins,可把CARGO_TARGET_DIR指向/cache/cargo-target并挂载分布式缓存盘,确保节点间指纹目录一致。 -
若探测逻辑极重(如联网拉取 Git tag),可在 build.rs 里再加一层**“离线模式”**:
- 先读
OUT_DIR/artifacts.json; - 若文件存在且校验和通过,直接
return; - 否则执行重逻辑并写回文件。
这样首次运行后,任何无依赖变更的构建都会瞬间跳过脚本。
- 先读
做到以上四点,即可实现“构建脚本输出”在本地与 CI 的双重缓存,clean build 时间可从分钟级降到秒级。
拓展思考
-
多 crate 工作区场景:
把公共探测逻辑抽成独立 crate,其 build.rs 把结果写入自身 OUT_DIR,并通过links = "probe"强制 Cargo 全局唯一化;下游 crate 用DEP_PROBE_KEY环境变量读取,整个工作区只跑一次探测。 -
交叉编译缓存隔离:
国内嵌入式团队常用cargo build --target riscv32imac-unknown-none-elf,不同 target 的指纹目录天然隔离;若把CARGO_BUILD_TARGET_DIR设成/cache/target-${TARGET},可在同一条 CI 流水线里并发跑多个 target 而互不污染。 -
分布式编译加速:
使用 Bazel + rules_rust 或 Buck2 可把构建脚本声明为纯函数,输出作为“action cache” key 的一部分;国内大厂内网部署 BuildFarm 集群,一次执行、全局共享,把 Rust 缓存能力从单机扩展到数据中心级别。 -
安全合规:
金融与车企对“可重复构建”要求极高,必须把构建脚本依赖的探测命令(pkg-config、llvm-config)版本也写入指纹;否则可能出现“同一 Git Commit 在不同容器里编出不同二进制”的尴尬。
推荐把探测命令的--version输出一起哈希,真正做到 byte-to-byte 可复现。
掌握这些高阶玩法,面试时把“缓存构建脚本输出”从“省几秒”聊到“企业级构建系统”,可瞬间拉开与其他候选人的差距。