把 cargo build --release 理解成「打开优化」会在上线后立刻失效。Release profile 决定内联、LTO、codegen 切分、panic 策略、符号剥离——每一项都改生成代码的形状,从而改体积、改启动、改尾延迟。Workspace 决定的是另一张图:crate 边界、依赖统一、feature 如何被无意打开。这两张图是架构,不是个人口味。CI 时间和二进制大小一样,都是产品参数。
这篇文章只讲构建:workspace 如何把多 crate 收进同一份依赖解析、feature 统一如何把 tokio 的多余能力编进你以为很瘦的二进制、profile 里每一旋钮买什么。读完你应该能独立回答三个问题:为什么内部库改一行会导致看似无关的二进制变慢、thin LTO 和 codegen-units = 1 各砍的是什么、库 crate 为什么不该把 panic = abort 强加给下游。
1. 构建图就是架构图
Cargo 的单元是 crate。crate 边界决定编译单元、可见性和重编译范围。一个 20 万行的 src/lib.rs 改一行,整库重编。拆成 core / net / api / bin,改 api 不必重编 net 的机器码——除非你让它们的类型缠在一起,或开了 LTO 把边界又焊上。
Workspace 把多个 crate 放进同一份 Cargo.lock(对虚拟 workspace 和 resolver 版本要看你用的 Cargo 契约)和同一次 cargo test --workspace。内部依赖用 path,版本号在发布时才有意义。workspace.dependencies 把 tokio、bytes、serde 的版本写在一个地方,避免 A crate 用 tokio 1.36、B crate 用 1.37 被解析成两份或一份但 feature 并集爆炸。
[workspace]
members = ["crates/core", "crates/net", "crates/api", "crates/cli"]
resolver = "2"
[workspace.dependencies]
tokio = { version = "1.40", features = ["rt-multi-thread", "macros", "net", "time"] }
bytes = "1"
anyhow = "1"
thiserror = "2"
[workspace.package]
edition = "2021"
rust-version = "1.82"
license = "MIT"
rust-version 统一 MSRV。CI 用这一个版本编,文档写这一个版本。每个 crate 自己写不同 edition 可以,但 rustc 版本分叉会让「我这边编过」在另一台机器上变成语法错误。
虚拟 workspace(根包没有 [package])适合纯多 crate 仓。根包同时是二进制时,根会参与 feature 统一,根 Cargo.toml 随手加一个 feature 会改变所有依赖的并集。这是「我只是在根上试了一个 feature」导致发布体积涨的原因。
2. Feature 统一:并集,不是你写的那一行
Cargo 对同一份依赖的多份引用,会把 feature 取并集。net crate 声明 tokio = { features = ["net"] },cli 声明 features = ["full"],最终这份 tokio 带着 full 编进两个 crate。full 里的 process、fs、signal、io-util 就这样进了你以为只做网络的二进制。体积、编译时间、甚至 cfg 代码路径都会变。
打开 tokio/fs 只因为某测试读了一次文件,生产二进制就会带上阻塞池相关代码路径。dev-dependency 在 resolver 2 下不该泄漏 feature,但仍可能通过 workspace 里另一个 crate 的正常依赖泄漏。查泄漏用 cargo tree -e features -i tokio,把每一行 feature 的来源看完。来源是测试或示例,就给它们单独的 feature 或单独的 crate,不要污染默认图。
macros feature 看起来无害,它拉进 tracing 或 syn 的重量取决于版本。rt-multi-thread 和 rt 决定二进制里有没有 work-stealing 代码。嵌入式或 sidecar 若只想 current_thread,被并集打开 multi-thread,体积和线程名都会变。这些不是微优化,是你在调度那篇里做的选择会不会被构建图悄悄改掉。构建评审要问:这份二进制里的 tokio feature 列表,和我们设计的 runtime 是否一致。
CI 里把 cargo tree -e features -i tokio 的输出存成快照,feature 集一变 PR 就显示 diff。人不一定记得「谁打开了 fs」,diff 会记得。体积和编译时间的回归,经常第一次出现在这份 diff 里,而不是在基准里。
resolver = "2" 改善了「dev-dependencies 的 feature 泄漏进正常构建」等旧行为,但并集规则仍在。生产纪律:
- 禁止对常用依赖写
features = ["full"]。列出你用到的。 - workspace.dependencies 里给出默认最小集,个别 crate 用
features = ["parking_lot"]追加。 - 可选功能用 crate 自己的 feature 往下传:
rt-tokio = ["dep:tokio", "tokio/rt-multi-thread"],不要让叶子直接打开上游的大包。 cargo tree -d看重复;cargo tree -e features -i tokio看谁打开了什么。
default-features = false 对 tokio、reqwest、image 这类默认很肥的库几乎是必须。忘记关默认,image 可能把 png/jpeg/gif 全编进来,而你只解码一种。
3. 内部 crate 怎么切:按变化率和依赖方向
切 crate 不是按文件夹美感,是按:
变化率、依赖方向、编译时间、发布边界这四条里,编译时间是最容易在仓变大之后突然炸开的。core 一旦依赖一个重过程宏,所有下游的增量编译都要等宏。把宏留在叶子:生成 protobuf 的 crate 只被 net 依赖,cli 不碰它。反向依赖用抽出共享类型解决,不要用 feature 互开后门形成「逻辑循环、编译勉强通过」。
内部 crate 的 pub 默认应当比你想象的更少。pub(crate) 和 pub(super) 能把可改面收小。pub use 把内部路径稳定成外部 API,等于承诺以后不改。workspace 内部互相 path 依赖时,这个承诺看起来无害,一旦要拆仓发布就会变成 semver 地狱。现在少导出,以后少 major。
default-members 控制你在根目录敲 cargo build 会编谁。把重的示例、基准、fuzz 从 default 拿掉,日常循环才快。它们仍在 members 里,CI 显式 -p 或 --workspace 才编。exclude 给确实不属于这张图的目录(生成物、第三方 vendored 源)。不要 exclude 只是为了暂时编过,那会让「workspace 测试」变成假门禁。
依赖方向还可以用架构测试固定:core 的 Cargo.toml 里不允许出现 axum、reqwest、tonic。用 cargo metadata 扫一遍比 code review 眼睛扫可靠。破了就 CI 红。这是构建图版的「领域层不认识 HTTP」,和错误处理那篇的反腐败层是同一条边界在不同阶段的投影。
profile 与 crate 切分叠加:LTO 会把你精心切的编译防火墙在发布时焊回去,这是要的——运行时内联。开发时防火墙仍在,增量才快。所以「为了运行时性能把所有东西放进一个 crate」是在用每次敲键的时间换 LTO 已经会做的事。保持切分,发布开 thin LTO,两边都拿到。
Docker 构建用 cargo-chef 一类方案缓存依赖层,把 Cargo.lock 和 Cargo.toml 的拷贝放在源码之前,依赖没变时不重编 crates.io 的包。缓存 key 含 rustc 版本和 profile。忘了 rustc 版本,升工具链后用旧 LLVM 产物链接,错误会非常难看。构建图的时间维(缓存)和空间维(crate 边界)要一起设计。
- 变化率。 稳定的协议类型、错误枚举放
core,少改,大家依赖它。常改的 HTTP 适配放api。 - 依赖方向。
core不依赖axum。领域错误不认识 HTTP。反向依赖是循环,workspace 编不过或逼你抽出第三个 crate。 - 编译时间。 过程宏、生成代码、重依赖(如
tonic-build)放在叶子,不要放进被十个 crate 依赖的core。core一碰过程宏,全家重编。 - 发布边界。 要独立发 crates.io 的,版本和 API 稳定性单独谈。只在仓内用的,
publish = false。
循环依赖用抽出共享类型来拆,不要用 features 互开后门。core 里放 Bytes 和新错误枚举,net 和 api 都依赖 core,彼此不依赖。这和运行时的「依赖图」是同一张图在编译期的投影。
测试 crate:重集成测试放 tests/ 或单独 crate-tests,免得 dev-dependencies 的肥库影响 IDE 在库代码上的分析时间。[dev-dependencies] 里的 tokio features 在 resolver 2 下不应泄漏,但仍会让 cargo test 更慢——测试也是产品,值得用 workspace 的 profile.test 管。
4. Release profile:每一项都是交换
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
panic = "abort"
strip = "debuginfo"
debug = 1
opt-level。 3 偏速度,s/z 偏体积。服务端默认 3。对体积敏感的 wasm 或嵌入式才 z。opt-level = 0 的 --release 没有意义。注意依赖可以用 [profile.release.package.huge_crate] opt-level = 2 做例外,很少需要。
LTO。 链接时优化让内联和消死码跨 codegen unit、跨 crate。false:最快编译,最差跨 crate 内联。thin:并行 LTO,通常是服务端的甜点,体积和速度都明显好于不开,CI 时间可接受。fat / true:更彻底,链接更慢,收益递减。开 LTO 之后,crate 边界作为「编译防火墙」变弱——改 core 的一个内联函数,下游的优化后代码也会变。这是用更好的代码换更重的链接。
codegen-units。 默认大于 1,并行生成,优化视野变窄。1 让 LLVM 看见整个 crate(再叠加 LTO 看见更多),更好优化,更慢编译。发布制品用 1;日常开发不要放在 dev profile 里。
panic。 unwind 保留展开表和 drop 路径,二进制更大,能 catch_unwind。abort 去掉展开,体积更小,panic 即死。服务端二进制常用 abort:panic 已经是不变量破坏,展开过程本身可能再坏。库 crate 不要在自己的 Cargo.toml 里写 panic = abort——这会强加给下游,让下游无法 catch。panic 策略是最终二进制的选择。
strip / debug。 完全剥符号,崩溃时栈不可读。debug = 1 或 line-tables-only 保留行号,体积增加有限,换回能读的 panic 栈。线上用独立 debuginfo 文件(split debuginfo)是更完整的做法:二进制瘦,出事故时拿符号表来符号化。不要在「体积」名义下把栈变成 0x1234。
incremental。 release 默认关。不要为了 CI 缓存强行打开 release incremental,产物可复现性更差,收益不稳定。
5. Dev、CI、发布是三条 profile,不要混
dev:快编译,opt-level = 0,debug 全开。跑功能测试。不要拿它压吞吐。
release:上面那套,产出部署制品。
bench / profiling 自定义 profile:
[profile.profiling]
inherits = "release"
debug = true
strip = "none"
火焰图用 profiling profile。用纯 release 剥干净的二进制,perf 只能看到 unknown。用 dev,你看到的热点是未优化的噪音。
CI 里 cargo test 默认 dev,太慢就 [profile.ci] inherits = "dev" opt-level = 1 或对依赖 opt-level = 2([profile.dev.package."*"] opt-level = 2)让依赖优化、自己的代码保持可调试。这能把测试时间砍一大截,代价是依赖的断点不好下。选一边。
--locked 在 CI 必须:按 Cargo.lock 编,拒绝解析漂移。应用仓提交 lock;纯库仓是否提交 lock 有争论,但 workspace 里有二进制,就提交。
6. 依赖审计、deny、供应和重复
cargo deny / cargo audit 查许可证、漏洞、重复 crate。同一份 bytes 出现 0.4 和 1.x 是两份代码、两份类型,不能互传。cargo tree -d 列重复。重复常常来自没有 workspace.dependencies、或某个传递依赖钉死旧版本。
vendor 和 git 依赖:git 依赖让可复现变差(tag 被 force push)。生产钉 crate.io 版本或精确 git rev。[patch] 用来临时修上游,不要变成永久的第二条依赖图。
links 原生库:同一进程不能链两份 openssl。workspace 统一 vendored 或统一系统库。FFI 那篇的符号冲突,很多是构建图没统一。
过程宏和 build.rs 是隐藏的编译时间税。build.rs 里跑重活(编译 C、生成 protobuf)要做增量:输入没变不要重跑。CI 缓存 target 时按 profile、按 Cargo.lock 的哈希分 key,避免 debug/release 互踩。
7. 体积:从并集和死码开始,不是从 LLVM 开始
二进制胖,先看:
cargo bloat/cargo llvm-lines:哪类单态化炸了。泛型HashMap<K,V,S>的很多实例、async状态机每个 spawn 一份。- feature 并集:tokio full、reqwest native-tls + rustls 双栈、serde 的多余格式。
- 静态链接的原生库。
- 未 strip 的 debug。
单态化是 Rust 性能的来源,也是体积的来源。热路径上的泛型保持单态;边界上用 dyn Error / dyn Future(Box)收口,少生成 50 份只调用一次的状态机。这和运行时分配是交换:多一次间接,少一份机器码。测了再收。
RUSFLAGS=-C target-cpu=native 让本机更快,制品不能发到旧 CPU。发布用基线 CPU 或分发多份。Docker 里 native 等于「构建机是什么就编什么」,CI 机器一换,线上 illegal instruction。
8. 工作区 lints、格式、MSRV
统一 [workspace.lints.rust] / clippy:unsafe_code 在叶子允许、在 core 拒绝;unwrap_used 在库路径警告。各 crate [lints] workspace = true。没有统一 lints,就会出现有的 crate 满地 unwrap、有的 crate 禁 unsafe,代码评审没有共同语言。
格式:rustfmt.toml 放根上。edition、imports 顺序、换行宽度是团队合同。CI cargo fmt --check。不要每个 crate 一份 rustfmt。
MSRV:rust-version 写了就要在 CI 用那个版本编一次。只用 nightly 的功能必须 cfg 或单独 crate,不能让默认构建依赖 nightly。依赖升级时看它们的 MSRV,被依赖拖到比你声明更新的编译器,是在静默提高门槛。
9. 取舍:拆 crate vs 单 crate、LTO vs CI 时间、abort vs unwind
单 crate 简单,编译图简单,改一行重编全世界。适合一个人、几万行以下。多人、多二进制、编译已过分钟级,拆。拆过头(三十个一百行的 crate)让 IDE 和 Cargo 解析本身变慢,类型满世界 pub use。目标是「改这里不必重编那里」对得上真实变化率。
LTO thin + codegen-units=1 是发布甜点。开发者本机 cargo build --release 若因此慢到不想用,提供 profile.release-fast(无 LTO、默认 codegen-units)给本地验证,CI 和真正发布走完整 profile。两套制品不要混着部署。
abort 给最终服务二进制;需要在进程内隔离 panic 的(插件、教学、某些 FFI 宿主)留 unwind。选择写在根二进制的 profile,不写在库。
10. 排障:编不过、编太慢、体积暴涨、运行变慢
- feature 冲突 / 重复类型。
A和B各带一份rand主版本。workspace.dependencies 钉住,cargo update -p有意识地做。 - 改一行编三分钟。 过程宏在
core;LTO 开在 dev;没开增量;target在慢盘。把重依赖下移,dev 关掉 LTO。 - 体积翻倍。
cargo bloat看新进来的 crate 和单态。检查是否不小心full了。 - release 比 dev 还慢(运行时)。 极罕见,通常是测错了 profile,或
opt-level=z加无 LTO 把热循环弄碎。确认cargo build --release的target/release,不要跑到target/debug。 - 线上非法指令 / 缺符号。 target-cpu=native 或 strip 了不该剥的动态符号。容器里用和构建一致的 glibc。
- CI 不可复现。 没
--locked,或缓存了错误的target。缓存 key 必须含 lock 哈希和 profile 名。
cargo build -Z timings(或稳定后的 timings)看哪个 crate 占编译时间。优化编译时间先打最重的那几个,不要平均用力。
11. 和运行时行为如何互相决定
codegen-units 和 LTO 改变内联,从而改变异步状态机大小、从而改变分配和缓存。panic=abort 改变错误路径能不能 catch_unwind,从而改变 FFI 边界策略。feature 并集可能把 tokio 的 rt-multi-thread 编进一个你想保持 current_thread 的嵌入场景。构建旋钮不是在「编译器设置」页面里孤立存在,它们会漏进调度、错误处理、FFI。
反过来,crate 边界会引诱人把本该是模块的东西做成动态分发,只为了少重编。先量编译时间,再决定是否值得用 dyn 换增量编译。多数服务端二进制,发布时 LTO 会把 dyn 的「编译防火墙」部分再焊回去,只留下运行时间接。不要为了编译时间牺牲热路径;为了编译时间牺牲冷路径和适配层,可以谈。
Workspace 里的版本升级是一次显式的架构动作。cargo update 会在 lock 允许的范围内浮动。大版本(tokio 1 → 2,或某个 0.x 的 minor)要单独 PR,跑测试、看 cargo tree -d、看 bloat。把 cargo update 放进无人看的 Dependabot 自动合并,等于让 feature 并集和 MSRV 在你睡觉时变。依赖的 changelog 里「opened default feature foo」对你的体积可能比「fixed panic」影响更大。升级 rustc 同样:新的 LLVM 会改内联和向量化,release 性能可能变好也可能某个热循环变差。升编译器后跑一遍基准,不要假设单调变快。
缓存是构建图的时间维。本地 target/、CI 的 sccache / 缓存目录、registry 缓存,三层。target 按 job 的 profile 和 lock 哈希隔离。两份 job 共享同一 target 目录,debug 和 release 会互相弄脏增量状态,表现为「偶发编不过」或「增量编译比全量还慢」。sccache 对 build.rs 产出和过程宏的缓存命中不稳定,命中率要度量,不要假设开了就快。CARGO_INCREMENTAL=0 在 CI 的 release 构建是常态,避免增量状态进制品。
文档和示例也是构建图的一部分。[[example]] 若依赖 full feature,会在 cargo test --all-targets 时把那份并集拉进来,把你的库测试时间翻倍。示例用单独 feature examples 或放在 examples/ 并在 CI 里显式 --features。cargo test --workspace --all-features 会打开所有 feature 的笛卡尔积式并集,能抓到互斥 feature 的问题,也会把编译时间推到峰值。CI 分两列:默认 feature 的测试,和 all-features 的编译检查。两列都要,但不要都当每次提交的热路径。
交叉编译是构建图的空间维。--target aarch64-unknown-linux-gnu 从 x86 CI 产出 ARM 制品,链接器、sysroot、原生依赖(openssl、protobuf)都要按目标来。build.rs 里用 CARGO_CFG_TARGET_OS 判断,不要用 std::env::consts——那是宿主。
// build.rs
fn main() {
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap();
println!("cargo:rerun-if-changed=build.rs");
if let Ok(hash) = std::env::var("GIT_HASH") {
println!("cargo:rustc-env=GIT_HASH={hash}");
}
println!("cargo:rustc-cfg=target_os_is_{target_os}");
}
```FFI 的 `cc` 编译 C 代码必须用目标编译器。Docker 里 qemu 跑测试可以,跑基准不行。发布矩阵:x86_64 和 aarch64 的 musl 或 gnu 要明确,musl 静态编体积和启动路径都不同,某些 DNS / 用户库行为和 glibc 有差。选 musl 是为了容器里少依赖,不是为了「更 Linux」。
`[patch.crates-io]` 和 `[replace]` 是临时绕过上游的刀。patch 在整个 workspace 生效,能把一个 git 分支替进所有依赖。忘记拿掉就会让生产钉在你的 fork 上,fork 的 CI 和安全更新都不会自动来。纪律:patch 必须带 issue 链接和移除条件;`cargo deny` 可以扫 git 依赖。内部 fork 不如给上游发 PR 或把真正需要改的部分用封装绕过。
二进制的确定性:同一份 lock、同一份 rustc、同一份 profile,应得到可比较的制品。LTO 和并行链接在部分平台上仍可能有非确定性。需要可复现构建时,钉 rustc 版本(`rust-toolchain.toml`)、关增量、固定 `SOURCE_DATE_EPOCH`、小心 `build.rs` 把时间戳编进代码。安全团队要哈希制品时,这些才开始值钱。日常服务端不必从第一天可复现,但不要在 `build.rs` 里 `SystemTime::now()` 写进二进制,那会让每次编都不同,缓存全废。
工作区变大之后,`cargo test --workspace` 会变成三十分钟。分层:`core` 的测试每次提交跑;集成测试和端到端按路径过滤或 nightly 跑。`cargo test -p api --lib` 比全仓快十倍,预提交钩子用它。CI 全仓是门禁,开发者循环不是。`cargo-nextest` 并行和重试不稳定测试,能把墙钟时间压下去,但不该用来掩盖顺序依赖的脏测试。测试要自己有界、自己不抢固定端口,否则并行只是把偶发失败变成必发。
最后,profile 和运行时选项要对着看。`panic=abort` 的二进制里,库文档写的 `catch_unwind` 恢复策略是空的,FFI 篇的隔离要改成「panic 即进程死,所以对外函数更不能让 Rust 侧 assert 落到用户输入上」。`lto=thin` 可能把一个你以为跨 crate 的虚调用内联掉,热路径变了,基准要在发布 profile 下跑。构建图改了,运行时文章里的数字都可能改。把「用哪份 profile 跑的基准」写进基准报告,否则你在比较两种算法,实际上在比较有没有 LTO。
链接器选择会改 CI 时间和体积。`lld` / `mold` 比默认的 bfd 快一截,尤其在开 LTO 时。`RUSTFLAGS="-C link-arg=-fuse-ld=lld"` 或 `[target.*] linker` 写在 `.cargo/config.toml`。这是工程参数,进仓库,不要只在某台开发者机器上生效。split debuginfo(`split-debuginfo = "packed"` 或平台对应选项)让二进制瘦、调试文件单独走制品库。崩溃时用 build id 把符号对上。没有 build id 的 strip 二进制,等于放弃事后分析。
`cargo hakari` 一类 workspace hack 把公共依赖的 feature 并集收成一个中间 crate,减少「每个 crate 一份不同并集导致重复编译」。工作区到几十个 crate、tokio 的 feature 组合开始爆炸时,它能把 CI 从几十分钟拉回来。代价是多一个生成的 crate 和一套约定。先用 `cargo build -Z timings` 确认痛点在重复编译,再上 hack。更早的步骤仍是:不要 `full`,workspace.dependencies 给出最小集。
发布顺序和 semver:内部 path 依赖在 workspace 里无所谓版本,要 publish 时必须先发被依赖的 crate,版本号对得上。`core` 改了公开类型,`net` 和 `api` 都是 breaking。能保持 `core` 的公开面小,发布才不至于每次全家 major。`pub use` 把内部模块全部倒出去,等于没有边界。公开的类型应当能用一页纸列完。
`rust-toolchain.toml` 把 rustc 版本钉在仓库里,开发者和 CI 用同一份。nightly 只给需要的 job(miri、bench 的某个 flag),默认 job 走 stable。混用而不钉,会出现「我这边编过」——对方 rustc 旧一个点,edition 或库 API 不同。工具链文件是构建图的根配置,和 profile、lock 并列,缺一不可复现。
链接器用 lld 或 mold,写进仓库的 `.cargo/config.toml`,不要只在某台笔记本上快。split debuginfo 让二进制瘦、符号单独存,崩溃用 build id 对上符号。没有 build id 的 strip 等于放弃事后分析。
过程宏和 build.rs 用 `build-override` 较低优化即可,它们只在编译期跑;生成出来的代码跟最终 profile。不要给过程宏 crate 开 fat LTO。`incremental` 留在本地 dev,CI release 关掉,避免脏增量表现为「删 target 就好了」。
`cargo metadata` 上写机器可执行的约束:禁止叶子依赖 axum、禁止 `full`、强制 workspace.dependencies。约束只在 wiki 上三个月后必破。sys crate 的 vendored 与系统库必须全仓统一,否则最后链两份原生库。对 LLVM 极慢的巨型生成 crate 单独降 `opt-level`,例外写注释。
制品注入 git sha 和 profile 名。事故时「这是哪一次编译」必须能答。交叉编译看 `CARGO_CFG_TARGET_*` 不要看宿主 consts。`[patch]` 必须带移除条件和 issue 链接。hakari 一类 hack 只在 timings 证明重复编译是痛点之后再上,更早的步骤仍是不要 `full`。
`rust-toolchain.toml` 与 lock、profile 并列,是可复现的第三根柱子。nightly 只给 miri 那些 job。发布顺序按依赖图:`core` 公开面越小,全家 major 越少。`pub use` 倒出内部模块等于没有边界。基准报告写明 profile,否则比较的是有没有 LTO,不是两种算法。
分层测试:预提交只跑被改 crate 的 `--lib`,CI 跑 workspace。nextest 不能用来掩盖抢固定端口的脏测试。`all-features` 当编译检查,不当每次提交的热路径。
## 12. 小结
Workspace 管边界和依赖并集,profile 管生成代码的形状。feature 取并集,所以 `full` 是传染病;LTO 和 codegen-units 是发布性能的正经旋钮,不是神话;panic 策略属于最终二进制。把构建图当架构来评审:谁依赖谁、谁打开哪些 feature、发布制品用哪份 profile。运行时的所有权、调度、零拷贝、错误、FFI、背压、争用,最终都要被编进这个二进制里——编的方式会改写它们在机器上的成本。
一份最小发布检查单:`cargo tree -d` 无意外重复;tokio/reqwest 的 feature 快照无 `full`;profile 是 thin LTO + 你选好的 panic 策略;制品带 git sha 和 debuginfo 的对应关系;CI 用 `--locked` 和声明的 rust-toolchain。五项里任何一项漂移,前面七篇的结论都会在机器上变味。构建图不是收尾步骤,是那七篇文字变成 CPU 指令时必须经过的关口。把检查单放进 CI,而不是放进发版前夜的 wiki。
还可以再加两条很容易漏的:`RUSTFLAGS` 里有没有只在某台机器存在的 `target-cpu=native`;Docker 缓存有没有按 rustc 版本分 key。前者会让线上旧 CPU 非法指令,后者会让升工具链后链到旧 LLVM 产物。它们不在 Cargo.toml 里,所以 cargo deny 看不见。写进构建脚本的自检:拒绝 native,拒绝不明 rustc 的 cache hit。构建图的边界在仓库外还有半截,半截也要管。
本地 `cargo build --release` 若慢到没人愿意跑,提供一份不带 LTO 的 `release-fast` 给开发者验证逻辑,完整 profile 留给 CI 和真正发布。两套制品严禁混部。混部之后的「我本机是好的」没有任何信息量,因为本机根本不是那份二进制。把「CI 的 release 制品」当成唯一真相,本机的 release-fast 只用来看逻辑是否通。性能数字、体积数字、panic 行为,一律以 CI 那一份为准。否则构建图上的所有旋钮都会在口头上存在、在机器上缺席。发版记录里贴上这次制品的 profile 名、toolchain 哈希、feature 快照,事故时才能把「代码」和「真正跑着的指令」对上号。对不上号的优化讨论,都是在讨论另一份二进制。把制品哈希写进发布单,讨论才有对象。没有哈希的发布,等于没有构建图。