本周在this week in rust上看到了好几篇有意思的文章,话题也比较接近,干脆整理在一起吧
原计划还有一篇,由于篇幅很长且重合度较高故只留网址不写了
防御性编程
indexing into a vector
if !matching_users.is_empty() {
let existing_user = &matching_users[0];
// ...
}
虽然看起来没问题,但是访问matching_user的合法性全系于前面的条件检查,一旦逻辑复杂就容易遗忘
match matching_users.as_slice() {
[] => todo!("What to do if no users found!?"),
[existing_user] => { // Safe! Compiler guarantees exactly one element
// No need to index into the vector,
// we can directly use `existing_user` here
}
_ => Err(RepositoryError::DuplicateUsers)
}
用as_slice()再match来强制错误处理,这是一种让编译器负责约束的模式
lazy use of Default
let foo = Foo {
field1: value1,
field2: value2,
..Default::default() // Implicitly sets all other fields
};
这样定义是坏的,假如为foo新增了一个字段,它会被悄悄默认,但是其默认值未必是想要的。
let foo = Foo {
field1: value1,
field2: value2,
field3: value3, // Explicitly set all fields
field4: value4,
// ...
};
显式声明就不会有这个问题,但是太笨了,完全浪费了Default的设计
let Foo { field1, field2, field3, field4 } = Foo::default();
let foo = Foo {
field1: value1, // Override what you need
field2: value2, // Override what you need
field3, // Use default value
field4, // Use default value
};
这样也是可以接受的
脆弱的特征实现
对于这样一个结构体:
struct PizzaOrder {
size: PizzaSize,
toppings: Vec<Topping>,
crust_type: CrustType,
ordered_at: SystemTime,
}
为了让此类型可以比较,需要手动实现PartialEq特征:
impl PartialEq for PizzaOrder {
fn eq(&self, other: &Self) -> bool {
self.size == other.size
&& self.toppings == other.toppings
&& self.crust_type == other.crust_type
// Oops! What happens when we add extra_cheese or delivery_address later?
}
}
然后就遇到了和刚才一样的问题,假如PizzaOrder又多了一个字段extra_cheese: bool, // New field added,PartialEq特征不会自动更新,代码会顺利地通过编译,留下一个bug
impl PartialEq for PizzaOrder {
fn eq(&self, other: &Self) -> bool {
let Self {
size,
toppings,
crust_type,
ordered_at: _,
} = self;
let Self {
size: other_size,
toppings: other_toppings,
crust_type: other_crust,
ordered_at: _,
} = other;
size == other_size && toppings == other_toppings && crust_type == other_crust
}
}
用结构体解构的方法提取里面的变量,然后比较。这样一来一旦结构体定义变化,编译就会报错
实际上是TryFrom的From
From把一个给定类型转化为另一个类型,TryFrom同理但是认为可能出错,所以返回一个Result
From不会出错固然好,但有时它是伪装的TryFrom:
impl From<&DetectorStartupErrorReport> for DetectorStartupErrorSubject {
fn from(report: &DetectorStartupErrorReport) -> Self {
let postfix = report
.get_identifier()
.or_else(get_binary_name)
.unwrap_or_else(|| UNKNOWN_DETECTOR_SUBJECT.to_string());
Self(StreamSubject::from(
format!("apps.errors.detectors.startup.{postfix}").as_str(),
))
}
}
在错误的时候返回一个特殊的默认值,这是不合适的,莫不如实现TryFrom让他尽早报错
missing match arms
match self {
Self::Variant1 => { /* ... */ }
Self::Variant2 => { /* ... */ }
_ => { /* catch-all */ }
}
同理,当self多了一个枚举可能时,会默认因而可能忘记处理
match self {
Self::Variant1 => { /* ... */ }
Self::Variant2 => { /* ... */ }
Self::Variant3 => { /* ... */ }
Self::Variant4 => { /* ... */ }
}
或者:
match self {
Self::Variant1 => { /* ... */ }
Self::Variant2 => { /* ... */ }
Self::Variant3 | Self::Variant4 => { /* shared logic */ }
}
迷惑占位符
像以下这样用对未使用的变量用占位符会很让人疑惑,他们到底占了什么的位置
match self {
Self::Rocket { _, _, .. } => { /* ... */ }
}
这种写法就清晰多了,即使用不到:
match self {
Self::Rocket { has_fuel: _, has_crew: _, .. } => { /* ... */ }
}
临时可变性
如果只想让一个变量临时具有可变性,比如初始化阶段,显式地写出来:
let mut data = get_vec();
data.sort();
let data = data; // Shadow to make immutable
// Here `data` is immutable.
也可以这样:
let data = {
let mut data = get_vec();
data.sort();
data // Return the final value
};
// Here `data` is immutable
而且不会让temp这样的常见变量名到处飞,严格限制其作用域:
let data = {
let mut data = get_vec();
let temp = compute_something();
data.extend(temp);
data.sort();
data // Return the final value
};
处理constructor
仅对库和需要应对未来变化的API适用
假设一个结构体提供一个构造器,每次初始化借助构造器来完成。
假设有这样一个结构体:
pub struct S {
pub field1: String,
pub field2: u32,
}
现在想要确保构造出来的S是合法的,可以在构造时返回一个Result:
impl S {
pub fn new(field1: String, field2: u32) -> Result<Self, String> {
if field1.is_empty() {
return Err("field1 cannot be empty".to_string());
}
if field2 == 0 {
return Err("field2 cannot be zero".to_string());
}
Ok(Self { field1, field2 })
}
}
但是总有人可以这样构建:
let s = S {
field1: "".to_string(),
field2: 0,
};
这种行为不应当能通过编译,解决方法是增加一个私有字段:
pub struct S {
pub field1: String,
pub field2: u32,
_private: (), // This prevents external construction
}
impl S {
pub fn new(field1: String, field2: u32) -> Result<Self, String> {
if field1.is_empty() {
return Err("field1 cannot be empty".to_string());
}
if field2 == 0 {
return Err("field2 cannot be zero".to_string());
}
Ok(Self { field1, field2, _private: () })
}
}
看起来有点抽象,用属性宏#[non_exhaustive]也是一样的:
#[non_exhaustive]
pub struct S {
pub field1: String,
pub field2: u32,
}
该用
_private还是#[non_exhaustive]?
#[non_exhaustive]的作用范围是crate之外,意味着当前crate内还可以直接构建;_private则是moduel范围,在这之外都访问不到也就不能直接构建
但是在同一个moduel内呢?
// Still compiles in the same module!
let s = S {
field1: "".to_string(),
field2: 0,
_private: (),
};
如果想把这也纳入到限制范围呢?可以用nested private modules:
mod inner {
pub struct S {
pub field1: String,
pub field2: u32,
_seal: Seal,
}
// This type is private to the inner module
struct Seal;
impl S {
pub fn new(field1: String, field2: u32) -> Result<Self, String> {
if field1.is_empty() {
return Err("field1 cannot be empty".to_string());
}
if field2 == 0 {
return Err("field2 cannot be zero".to_string());
}
Ok(Self { field1, field2, _seal: Seal })
}
}
}
// Re-export for public use
pub use inner::S;
结构体seal是private的,inner这个mod只对外暴露了S,不包括seal,这样里面的东西就密封好了
现在构造过程绝对安全了,但是构造之后仍然是可以修改的:
let s = S::new("valid".to_string(), 42).unwrap();
s.field1 = "".to_string(); // Still possible to mutate fields directly
这样以来还是有可能出现非法的S,解决方法是私有化这些字段,然后只提供getter
mod inner {
pub struct S {
field1: String,
field2: u32,
_seal: Seal,
}
struct Seal;
impl S {
pub fn new(field1: String, field2: u32) -> Result<Self, String> {
if field1.is_empty() {
return Err("field1 cannot be empty".to_string());
}
if field2 == 0 {
return Err("field2 cannot be zero".to_string());
}
Ok(Self { field1, field2, _seal: Seal })
}
pub fn field1(&self) -> &str {
&self.field1
}
pub fn field2(&self) -> u32 {
self.field2
}
}
}
对重要的类型使用#[must_use]
这是一个简单好用的防止调用者忽略重要的返回值的方法,但是可惜经常被忽视
比如这样一个类型:
#[must_use = "Configuration must be applied to take effect"]
pub struct Config {
// ...
}
impl Config {
pub fn new() -> Self {
// ...
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
}
with_timeout方法中,self不是引用,它返回一个改变后的Config,而不是原地修改
所以这种情况使用者就会得到编译器warn:
let config = Config::new();
// Warning: Configuration must be applied to take effect
config.with_timeout(Duration::from_secs(30));
// Correct usage:
let config = Config::new()
.with_timeout(Duration::from_secs(30));
apply_config(config);
标准库中的Result和Iterator就被贴上了#[must_use],因而不处理Result或者不消费迭代器就会被warn
Boolean 盲区
布尔变量参数对调用者可读性是一个灾难
// Too many boolean parameters
fn process_data(data: &[u8], compress: bool, encrypt: bool, validate: bool) {
// ...
}
// At the call site, what do these booleans mean?
process_data(&data, true, false, true); // What does this do?
不看签名每人知道这堆参数是干什么的。所以用枚举类型:
enum Compression {
Strong,
Medium,
None,
}
enum Encryption {
AES,
ChaCha20,
None,
}
enum Validation {
Enabled,
Disabled,
}
fn process_data(
data: &[u8],
compression: Compression,
encryption: Encryption,
validation: Validation,
) {
// ...
}
// Now the call site is self-documenting
process_data(
&data,
Compression::Strong,
Encryption::None,
Validation::Enabled
);
枚举类型可以承载更多信息,同时引入了类型系统来避免低级错误,但是代价是函数签名很不美丽。可以用一个参数结构体:
struct ProcessDataParams {
compression: Compression,
encryption: Encryption,
validation: Validation,
}
impl ProcessDataParams {
// Common configurations as constructor methods
pub fn production() -> Self {
Self {
compression: Compression::Strong,
encryption: Encryption::AES,
validation: Validation::Enabled,
}
}
pub fn development() -> Self {
Self {
compression: Compression::None,
encryption: Encryption::None,
validation: Validation::Enabled,
}
}
}
fn process_data(data: &[u8], params: ProcessDataParams) {
// ...
}
// Usage with preset configurations
process_data(&data, ProcessDataParams::production());
// Or customize for specific needs
process_data(&data, ProcessDataParams {
compression: Compression::Medium,
encryption: Encryption::ChaCha20,
validation: Validation::Enabled,
});
既简化了写法,还能预设一些模板,完美
Clippy
以上都可以在Clippy中设置
| Lint 规则 | Description (描述) |
|---|---|
clippy::indexing_slicing |
Prevents direct indexing into slices and vectors |
clippy::fallible_impl_from |
Warns about From implementations that can panic and should be TryFrom instead. |
clippy::wildcard_enum_match_arm |
Disallows wildcard _ patterns. |
clippy::unneeded_field_pattern |
Identifies when you’re ignoring too many struct fields with .. unnecessarily. |
clippy::fn_params_excessive_bools |
Warns when a function has too many boolean parameters (4 or more by default). |
clippy::must_use_candidate |
Suggests adding #[must_use] to types that are good candidates for it. |
在Cargo.toml中补充:
[lints.clippy]
indexing_slicing = "deny"
fallible_impl_from = "deny"
wildcard_enum_match_arm = "deny"
unneeded_field_pattern = "deny"
fn_params_excessive_bools = "deny"
must_use_candidate = "deny"
加固生产环境代码
防御性编程可以尽可能确保代码逻辑上的安全,但是代码在运行时还会遇到各种各样的问题
Panic 语义是API的一部分
一个rust程序在panic时会发生什么?这有很多种可能
Unwind (栈展开) 与 Abort (终止)
unwind模式触发一个闭包,它会捕获错误原因:
let result = panic::catch_unwind(|| {
panic!("oh no!");
});
但是著名教材Rustonomicon表示unwind建议仅作为备用,因为rust的当前实现假定不会unwind并基于此做了大量优化……所以别把它当成Result的替代使用
替代方法是Abort掉整个程序,在Cargo.toml中打开:
[profile.release]
panic = "abort"
即使不显式声明,遇到像栈溢出、out of memory这样的重大问题也会默认abort,因为unwinding在这种情况下会产生未定义行为。想在rust程序里栈溢出还是有难度的,一般发生在
- C FFI
- malloc 失败
这样的问题和常规panic完全不同,catch_unwind对他们无效,也就无法事后抢救,只能根据实际情况事先预防,比如对于malloc失效,
Thread-Level vs. Process-Level Failures
panic会结束整个进程是一个误解,在多线程程序中不总是这样的,举个例子的话
use std::{thread, time::Duration};
fn handle_request(id: u32) {
println!("request {id}: started");
if id == 2 {
panic!("request {id}: handler panicked");
}
thread::sleep(Duration::from_millis(100));
println!("request {id}: finished");
}
fn main() {
thread::scope(|s| {
let requests: Vec<_> = (1..=3)
.map(|id| (id, s.spawn(move || handle_request(id))))
.collect();
for (id, request) in requests {
match request.join() {
Ok(()) => println!("main: request {id} completed"),
Err(_) => println!("main: request {id} failed, but the process is still alive"),
}
}
});
println!("main: service keeps running");
}
输出是:
request 1: finished
main: request 1 completed
main: request 2 failed, but the process is still alive
request 3: finished
main: request 3 completed
main: service keeps running
一个线程崩溃了,但是其余线程还能正常工作,所以panic是有线程级隔离的
但是这也带来了风险,如果是局部违背比如请求参数错误,那这么做合情合理,但如果是全局状态问题,继续执行可能就是很危险的。
panic处理也是系统错误模型的一部分,对待panic和对待其他错误没什么不同。永远不要让panic脱离控制!
如果维护一个库,你无从得知使用者的使用方法,所以考虑开启更严格的clippy规则比如indexing_slicing和arithmetic_side_effects来捕获更多panic源
Observing Failures With Panic Hooks
理解panic的工作原理之后,就可以考虑加强panic处理了。默认情况下,只会打印一些信息到stderr,但在生产环境里还不太够。
于是需要panic hook来
use std::panic;
fn main() {
panic::set_hook(Box::new(|panic_info| {
eprintln!("panic occurred: {panic_info}");
// log to your monitoring system
// send crash reports
// clean up resources
}));
panic!("Something went wrong!");
}
主动上报、清理资源,不一而足。
比如一个实际一点的例子:
panic::set_hook(Box::new(|panic_info| {
let panic_data = serde_json::json!({
"message": panic_info.to_string(),
"location": panic_info.location().map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column())),
"timestamp": chrono::Utc::now().to_rfc3339(),
"version": env!("CARGO_PKG_VERSION"),
});
// Send to your crash reporting service
crash_reporter::report(panic_data);
}));
fn setup(&self, _cfg: &mut ClientOptions) {
INIT.call_once(|| {
let next = panic::take_hook();
panic::set_hook(Box::new(move |info| {
panic_handler(info);
next(info);
}));
});
}
发送错误信息,用take_hook保存原本的panic hook(一个闭包),然后next执行它
用一个全局的静态变量防止重复初始化
敏感信息消毒
Panic hooks 也是避免信息泄漏的最后机会。敏感信息来源于两个地方,panic_payload 和panic_location。
payload 是无论什么代码中传给panic! unwarp expect或一个断言,这意味着它可以包含用户输入,内部状态,请求头,token,身份信息等等,而location可以暴露源文件路径,CI/build 设备目录布局
解决的下策是用正则表达式兜底,看起来大概像这样:
panic::set_hook(Box::new(|panic_info| {
let sanitized_message = sanitize_panic_message(panic_info.to_string());
log::error!("Application panic: {sanitized_message}");
}));
而上策是根本不让敏感信息被输出,比如用一个uuid替代,用其他途径记录。
脱敏可以在代码中"配置"
use veil::Redact;
#[derive(Redact)]
pub struct Customer {
id: u64,
#[redact(partial)]
first_name: String,
#[redact(partial)]
last_name: String,
#[redact]
email: Option<String>,
#[redact(fixed = 2)]
age: u32,
#[redact(with = "[REDACTED]")]
address: String,
}
Cleanup
在进程终止前,可能想要刷新日志,关闭网络连接或通知其他系统当前实例即将关闭,设置一个hook是个很好的方法
但是要注意这一步中打交道的子系统可能造成panic!双重panic会立刻Abort,所以一定要保持清理操作的容错性,避免任何可能panic的情况
Stack Overflows And Runtime Behavior
除了panic,还有其它运行时错误需要考虑
fn factorial(n: u64) -> u64 {
if n == 0 {
1
} else {
n * factorial(n - 1)
}
}
像这样一个函数,如果参数很大,栈会很容易爆掉,而对于stable rust,编译器不保证尾递归优化。所以根本就不要写成递归的形式
fn factorial(n: u64) -> u64 {
let mut result = 1;
for i in 1..=n {
result *= i;
}
result
}
Release and Debug Builds Are Two Different Programs
本地运行的debug模式和release模式的很多行为是不一样的。不要假设他们逻辑等价。
最显而易见的是整数溢出处理,debug模式下会panic,但是release会静默。
同时,release会移除所有的debug_assert!检查,开启优化同时对于cfg(debug_assertions)产生不同的行为。Unsafe代码尤其要考虑这些。
fn apply_discount(price: u32, percent: u32) -> u32 {
debug_assert!(percent <= 100);
price - (price * percent / 100)
}
在release下,断言消失,下溢出就可能发生
所以需要
# Add this to your CI pipeline alongside regular `cargo test`
cargo test --release
供应链安全
其重要性自不必说,我们不是cpp
cargo-audit 它会读取项目中的Cargo.lock 文件然后将这些版本与 RustSec 漏洞数据库 进行比对。一旦发现你当前使用的某个 Crate 版本存在已被公开的漏洞(CVE),它就会发出警告甚至报错。
cargo-deny 在cargo-audit的检查已知漏洞的基础上,还检查开源协议,支持黑名单,限制依赖的下载源与去重
Secure Allocations With mimalloc
mimalloc是微软开发的可以无痛替换的堆分配器,用于在特定场景下获得更高的性能。他有一个secure模式,支持保护页,分配随机化等等手段,主要用于为unsafe代码兜底,想要使用只需要:
[dependencies]
mimalloc = { version = "0.1", features = ["secure"] }
再用它作为全局分配器
use mimalloc::MiMalloc;
#[global_allocator]
static GLOBAL: MiMalloc = MiMalloc;
由于安全是有性能代价的,所以测量性能后使用
限制运行环境攻击面
假设真的被getshell了,也可以做一些约束
最小化docker image
又是我为什么没早点看到系列
作者的推荐是 Google’s distroless images,但是仅供参考,他也不是这方面的专家。
这是一个剥离了几乎所有不必要的东西的镜像,同时保留了TLS证书和一个非root用户。对于一个典型的Rust web服务,可以从gcr.io/distroless/cc-debian13:nonroot开始,它包含了动态链接需要的C库但是没有shell也没有包管理器
一个用cargo-chef的例子:
# syntax=docker/dockerfile:1
ARG RUST_VERSION=1.92
FROM rust:${RUST_VERSION}-bookworm AS chef
RUN cargo install cargo-chef --locked
WORKDIR /app
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS builder
COPY /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
COPY . .
RUN cargo build --locked --release --bin myapp
FROM gcr.io/distroless/cc-debian13:nonroot AS runtime
COPY /app/target/release/myapp /bin/myapp
ENTRYPOINT ["/bin/myapp"]
cargo-chef,要理解它的作用,需要知道Docker image是层缓存的,每次改动代码都会使后续的命令失效,这意味着极慢的重新构建过程。而cargo-chef把项目代码和项目依赖分开,这样修改代码只会重建自己的很小一部分。这里又会涉及一大堆细节与实践知识。
Landlock
Landlock 是一个Linux下让程序主动限制文件访问能力的安全模块,程序只能访问显式声明的文件。特别注意的是很多底层些的文件比如/usr/bin /usr/share/zoneinfo SQLite 等等,也是需要显式声明的。
use landlock::{
Access, AccessFs, PathBeneath, PathFd, Ruleset, RulesetAttr,
RulesetCreatedAttr, ABI,
};
fn sandbox() -> Result<(), Box<dyn std::error::Error>> {
let abi = ABI::V3;
Ruleset::default()
.handle_access(AccessFs::from_read(abi))?
.create()?
// Allow read-only access to /etc for config files
.add_rule(PathBeneath::new(PathFd::new("/etc")?, AccessFs::from_read(abi)))?
// Allow read+write access to /var/data for your app's data
.add_rule(PathBeneath::new(
PathFd::new("/var/data")?,
AccessFs::from_all(abi),
))?
.restrict_self()?;
Ok(())
}
fn main() {
sandbox().expect("failed to apply landlock sandbox");
// Your service starts here.
// The service is now restricted to /etc (read) and /var/data (read/write)
// Any attempt to open /tmp, /home, /proc etc. will be denied!
}
限制会作用到整个进程
Drop Privileges and Capabilities
容器里的root也是root,需要小心容器被打穿。不要为了绑到80端口开root,用更高的端口。
Linux capabilities 把权限切细,为了绑定一个端口,可能只需要一个CAP_NET_BIND_SERVICE
Miri
此物在前段时间某篇文章中亦有提及,一个rust UB检测解释器
使用起来很简单:
rustup +nightly component add miri
cargo +nightly miri test
然后测试就会用miri运行
Graceful shutdown handling
粗暴的关进程有这么几个问题:
- 正在处理的请求中断
- 数据库写了一半,数据损坏了
- 部分资源没正确关闭
- 日志丢失
标准的处理模式是:监听信号,停止接收新请求,完成已有工作,退出。
axum有内置支持,可以直接使用。
一个参考实现,它引入了一个subsystem的概念可以并发运行并持续监听。
use tokio_graceful_shutdown::{SubsystemHandle, Toplevel};
async fn subsys1(subsys: &mut SubsystemHandle) -> Result<()>
{
log::info!("Subsystem1 started.");
subsys.on_shutdown_requested().await;
log::info!("Subsystem1 stopped.");
Ok(())
}
#[tokio::main]
async fn main() -> Result<()> {
Toplevel::new(async |s: &mut SubsystemHandle| {
s.start(SubsystemBuilder::new("Subsys1", subsys1))
})
.catch_signals()
.handle_shutdown_requests(Duration::from_millis(1000))
.await
.map_err(Into::into)
}
Circuit Breakers (熔断器) for External Dependencies
假设一个外部依赖突然宕机,发过去的请求全都得不到回应,整个系统就很有可能被他拖死。
合适的做法是失败次数(或超时)超过设定阈值后,就认为不可用,直接报错即可,然后在后续的时间里时不时发请求确认是死是活
failsafe 和 recloser 是相关实现的crate
Resource limits
没有边界的资源是failures之源
对一切都显式设置限制,通常来说这包括:
- 用户输入上界,文件大小、参数长度
- 请求体
- 外部调用超时
- 外部资源的并发连接数
- 后台任务数量
- 线程数和连接池大小
一些常用写法:
用户输入 axum DefaultBodyLimit
let app = Router::new()
.route("/", post(|request: Request| async {}))
.layer(DefaultBodyLimit::max(1024));
队列深度
use tokio::sync::mpsc;
let (tx, rx) = mpsc::channel::<Job>(1000); // bounded channel, max 1000 pending
超时设置
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(30))
.build()?;
Health Checks and Self-healing
理想情况下,一个系统应该能在不需要人为干预的情况下从错误中恢复,health check让load balancers 和 orchestrators 察觉问题并得以做出反应
一个典型的设计需要两个探针,一个验活,一个验功能。这又牵扯很多很多细节,一个简单的实现如下:
use axum::{routing::get, Router, Json};
use serde::Serialize;
/// Status can be "healthy", "degraded", or "unhealthy"
#[derive(Serialize)]
enum Status {
// Everything is good, all dependencies are healthy
Healthy,
// Some dependencies are degraded,
// but the service can still handle requests
Degraded,
// Critical dependencies are down
// Don't send any traffic
Unhealthy,
}
/// This is our health status struct,
/// which we will return as JSON from
/// the readiness probe
#[derive(Serialize)]
struct HealthResponse {
// Health status of the service
status: Status,
// Is the database connection healthy?
database: bool,
// Is the cache connection healthy?
cache: bool,
// What version of the service is running?
// (Useful for debugging and monitoring.)
version: &'static str,
}
// Liveness: "Is the process alive?"
// Should always return 200 if the server can respond at all
async fn liveness() -> &'static str {
"OK"
}
// Readiness: "Can you handle traffic?"
// Check dependencies before saying yes
async fn readiness(
db: Extension<DbPool>,
cache: Extension<CachePool>,
) -> Json<HealthResponse> {
let db_ok = db.ping().await.is_ok();
let cache_ok = cache.ping().await.is_ok();
let status = match (db_ok, cache_ok) {
(true, true) => Status::Healthy,
(false, false) => Status::Unhealthy,
_ => Status::Degraded,
};
Json(HealthResponse {
status,
database: db_ok,
cache: cache_ok,
version: env!("CARGO_PKG_VERSION"),
})
}
let app = Router::new()
.route("/health/live", get(liveness))
.route("/health/ready", get(readiness));
相应的kubernetes配置:
livenessProbe:
httpGet:
path: /health/live
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /health/ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
Runtime Hardening Tooling
最后,一些工具分享:
cargo-fuzz:针对 Rust 代码的模糊测试(Fuzz testing)工具。honggfuzz:另一款支持 Rust 生态的强大模糊测试器。cargo-geiger:用于统计和检测你的项目中(包括依赖树)unsafe代码的使用情况。cargo-valgrind:在 Rust 代码上运行 Valgrind,用于深度排查内存错误。cargo-llvm-cov:通过 rustc/LLVM 基于源码的插桩机制(-C instrument-coverage)来生成代码覆盖率报告。它支持精确到行和代码块区域的覆盖率统计,并且与cargo test和cargo nextest完美兼容,是新项目的首推默认选择。cargo-tarpaulin:一款较早期的 Rust 代码覆盖率工具,在 Cargo 和持续集成(CI)的易用性上做得非常好。 注意:在 Linux 上,它默认使用ptrace后端(仅支持 x86_64 架构);你也可以通过--engine llvm标志开启 LLVM 覆盖率(这在 macOS 和 Windows 上是默认选项)。如果它的报告格式契合你的工作流,它会非常顺手,但与cargo-llvm-cov相比,它在不同平台或不同的测试运行器下可能会遇到一些边缘情况(Edge cases)。