如何缓存构建脚本输出?

解读

在 Rust 项目里,build.rs 被称为“构建脚本”,它在 cargo build 过程中先于主 crate 被编译并执行,用来生成代码、链接 C 库、探测系统特征等。
国内 CI 环境(如 GitHub Actions 缓存、阿里云 Flow、腾讯蓝盾、Gitee Go)为了节省时间和费用,会把 target/ 整体缓存下来;然而构建脚本的 stdout、stderr 以及它通过 cargo:rustc-cfg=... 等指令产生的“元数据”并不会自动落盘到任何文件,下次编译 Cargo 仍需重新运行 build.rs。
面试官问“如何缓存构建脚本输出”,本质想考察两点:

  1. 你是否理解 Cargo 的重运行决策机制(rerun-if-changed、rerun-if-env-changed);
  2. 你能否把“脚本执行结果”本身变成可校验、可复现、可缓存的制品,让 CI 与本地开发都能“一次运行,到处命中”。

知识点

  1. CARGO_TARGET_DIRCARGO_BUILD_TARGET_DIR:控制产物目录,CI 里通常指向共享缓存盘。
  2. rerun-if-changed=PATH / rerun-if-env-changed=KEY:告诉 Cargo 只有指定文件或环境变量变化时才重新跑 build.rs;不写则默认“永远重跑”
  3. OUT_DIR:构建脚本唯一被允许写文件的目录,生成的代码或标记文件必须放在这里,才能随 crate 一起被编译缓存。
  4. 构建脚本副作用隔离:任何在 OUT_DIR 之外写文件、改系统状态的行为都会使缓存失效。
  5. 指纹机制(fingerprint):Cargo 把 build.rs 的哈希、环境变量、rerun-if 列表等写入 target/debug/.fingerprint/<pkg>-<hash>/命中指纹即跳过脚本执行
  6. sccache / rust-cache:国内常用 GitHub Action Swatinem/rust-cache@v2,它把 ~/.cargo/registry/index~/.cargo/registry/cachetarget/ 一并缓存,但对构建脚本输出的缓存仍需你自己把“结果”落到磁盘
  7. 构建脚本结果序列化:把探测到的 cfg、路径、版本号写成一个 JSON/Markdown/TOML 落盘到 OUT_DIR,主代码用 include!(concat!(env!("OUT_DIR"), "/meta.rs")) 引入,这样元数据随 rlib 一起被缓存

答案

分四步落地,可在 99% 的国内 Rust 工程里直接复用:

  1. 在 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");
    }
    
  2. 主代码通过 include_bytes! / include_str! 读取缓存值,编译期即内联到二进制,无需运行时 IO。

    const OPENSSL_VERSION: &str = include_str!(concat!(env!("OUT_DIR"), "/openssl.version"));
    
  3. CI 层利用 rust-cachetarget/ 整体缓存;
    若使用自研 Jenkins,可把 CARGO_TARGET_DIR 指向 /cache/cargo-target 并挂载分布式缓存盘,确保节点间指纹目录一致

  4. 若探测逻辑极重(如联网拉取 Git tag),可在 build.rs 里再加一层**“离线模式”**:

    • 先读 OUT_DIR/artifacts.json
    • 若文件存在且校验和通过,直接 return
    • 否则执行重逻辑并写回文件。
      这样首次运行后,任何无依赖变更的构建都会瞬间跳过脚本

做到以上四点,即可实现“构建脚本输出”在本地与 CI 的双重缓存,clean build 时间可从分钟级降到秒级

拓展思考

  1. 多 crate 工作区场景
    把公共探测逻辑抽成独立 crate,其 build.rs 把结果写入自身 OUT_DIR,并通过 links = "probe" 强制 Cargo 全局唯一化;下游 crate 用 DEP_PROBE_KEY 环境变量读取,整个工作区只跑一次探测

  2. 交叉编译缓存隔离
    国内嵌入式团队常用 cargo build --target riscv32imac-unknown-none-elf,不同 target 的指纹目录天然隔离;若把 CARGO_BUILD_TARGET_DIR 设成 /cache/target-${TARGET}可在同一条 CI 流水线里并发跑多个 target 而互不污染

  3. 分布式编译加速
    使用 Bazel + rules_rustBuck2 可把构建脚本声明为纯函数,输出作为“action cache” key 的一部分;国内大厂内网部署 BuildFarm 集群,一次执行、全局共享,把 Rust 缓存能力从单机扩展到数据中心级别。

  4. 安全合规
    金融与车企对“可重复构建”要求极高,必须把构建脚本依赖的探测命令(pkg-config、llvm-config)版本也写入指纹;否则可能出现“同一 Git Commit 在不同容器里编出不同二进制”的尴尬。
    推荐把探测命令的 --version 输出一起哈希,真正做到 byte-to-byte 可复现

掌握这些高阶玩法,面试时把“缓存构建脚本输出”从“省几秒”聊到“企业级构建系统”,可瞬间拉开与其他候选人的差距