11
可观测性:tracing、OpenTelemetry 与优雅停机
Observability · tracing + OpenTelemetry + Graceful Shutdown
第 4 章你已经见过 tracing 的 info!。但日志"能打出来"和"出故障时能在 3 分钟内定位"是两码事。真实的生产事故排查需要三样东西:一条请求的完整调用链(trace)、带上下文的日志(log with span)、能看趋势的指标(metric)。这一章把这三样串起来,并且补上第 7 章没细讲的优雅停机——它看起来和可观测性无关,实际上"有没有优雅停机"决定了你的日志里有多少假错误。
tracing 的三个概念:span、event、subscriber
tracing 不是"更好的 log",它是结构化事件 + 嵌套上下文。理解这三个概念的关系,后面所有用法都是推论。
| 概念 | 是什么 | 关键点 |
Event 事件 |
某个时间点上发生的事,带一组结构化字段 |
info!(user_id = %id, "登录成功")。字段是键值对而不是拼接好的字符串,所以能按字段过滤/聚合。 |
Span 跨度 |
一段时间区间,有开始和结束,可以嵌套 |
一个 span 表示"一次操作"(一次请求、一次查询)。进入 span 之后打的所有 event 都会自动带上它的字段,这是 tracing 相对 log 最大的价值。 |
Subscriber 订阅者 |
消费 span/event 并输出的后端,可叠加多层(layer) |
tracing-subscriber 负责输出到 stdout、tracing-opentelemetry 负责导出到 OTLP、tracing-jaeger 负责发到 Jaeger。同一份 trace 数据可以同时给多个 layer。 |
// 心智模型对比:log crate 与 tracing 的差别在哪
// 老办法:把上下文拼进字符串,然后想按 user_id 过滤日志 —— 做不到
// log::info!("用户 {} 下单成功,订单 {}", user_id, order_id);
// tracing 的写法:上下文是字段,而不是字符串的一部分
use tracing::{info, instrument, warn};
#[instrument(skip(db), fields(user_id = %user_id))]
async fn create_order(user_id: i64, db: &sqlx::PgPool) -> anyhow::Result<i64> {
// 这个 event 会自动带上 user_id 字段(因为它在当前 span 上)
// 也不会打印 db 参数(被 skip 掉了)
info!("开始创建订单");
let id: i64 = sqlx::query_scalar("INSERT INTO orders(user_id) VALUES($1) RETURNING id")
.bind(user_id).fetch_one(db).await?;
// 结构化字段用 key = value 的形式,值是"字段"而不是字符串片段
info!(order_id = id, "订单创建成功");
Ok(id)
}
// 好处一:日志可以按字段查询 —— 在 Loki/ES 里搜 user_id=42 就能捞出这个用户的所有操作
// 好处二:span 会变成一个"上下文容器",函数内部所有日志自动继承 user_id
// 好处三:同一份 span 可以同时导出成 trace(给 Jaeger)和日志(给 stdout)
论为什么"结构化日志"是排查效率的分水岭
① 字符串日志只能靠正则搜。"用户 42 下单成功" 这行日志里,42 是字符串的一部分。想统计"每个用户下了多少单"或者"过滤订单量小于 10 的用户",你得写正则、还要处理数字格式变化。把 user_id 变成字段之后,这些查询是数据库的基本操作。
② span 解决了"日志之间没有关联"的问题。一个请求会穿过七八个函数,每个函数打几行日志。没有 span 时,这些日志是散的,尤其并发高的时候完全对不上——你根本不知道哪三行日志属于同一个请求。有了 span,一次请求的所有日志天然被同一组字段串起来,而且这个串联是自动的,不用你在每个函数签名上传 context 对象。
③ 最后一层是"跨服务串联"。单体里 span 够用了,但微服务下请求会跨进程。这时需要把 span 的标识(trace_id / span_id)通过 HTTP 头传出去,让下游服务接着往同一个 trace 里写。这就是 OpenTelemetry 要解决的问题,也是后面 trace_id 那一段的内容。
初始化 subscriber:一行 EnvFilter,两种输出格式
Cargo.toml
[dependencies]
tracing = "0.1"
# json feature 让日志以 JSON 行输出,便于 Loki/ES 采集
tracing-subscriber = { version = "0.3", features = ["env-filter", "json", "fmt"] }
tracing-opentelemetry = "0.29"
opentelemetry = "0.29"
opentelemetry_sdk = { version = "0.29", features = ["rt-tokio"] }
opentelemetry-otlp = { version = "0.29", features = ["grpc-tonic"] }
# 提醒:opentelemetry 系列 crate 的 API 在 0.2x 小版本之间会有调整,
# 升级时请以该版本 docs.rs 上的示例为准;但 tracing 那侧的用法是稳定的
开发用 pretty、生产用 JSON:用环境变量切换
use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
pub fn init_tracing(json: bool) {
// EnvFilter 从 RUST_LOG 读取;解析失败时给一个安全的默认值
// 默认值建议"全局 info,本 crate debug",避免第三方库刷屏
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info,my_crate=debug,sqlx=warn,hyper=warn"));
if json {
// 生产:一行一个 JSON 对象,字段独立可查询
let layer = fmt::layer()
.json()
// 把 span 的字段也打进每条日志,否则 JSON 日志里看不到 user_id
.with_current_span(true)
// 同时带上父 span 的字段(嵌套场景必需)
.with_span_list(true)
// 时间戳统一成 RFC3339,跨时区对齐时不会抓瞎
.with_timer(fmt::time::UtcTime::rfc_3339());
tracing_subscriber::registry().with(filter).with(layer).init();
} else {
// 开发:带颜色和缩进的层叠展示,看 span 嵌套关系最直观
let layer = fmt::layer().with_target(true).with_level(true).pretty();
tracing_subscriber::registry().with(filter).with(layer).init();
}
}
// main.rs 最开头就调用,别等连接数据库之后再初始化(会丢掉启动期的日志)
// init_tracing(std::env::var("LOG_JSON").is_ok());
RUST_LOG 的语法:按模块名精细控制,不用改代码
# 只调本项目的某个模块,其余保持 warn(线上临时排障最常用)
RUST_LOG=warn,my_crate::api::orders=debug ./order-api
# 全局 debug,但把最吵的几个库压到 error
RUST_LOG=debug,sqlx=error,hyper=error,reqwest=error ./order-api
# 打开 SQLx 的语句日志(能看到实际执行的 SQL 与绑定参数,排查慢查询利器)
RUST_LOG=info,sqlx::query=debug ./order-api
# 拿到一个 trace 的全部细节(配合 trace_id 过滤,见下一节)
RUST_LOG=info ./order-api 2>&1 | jq 'select(.trace_id=="4bf92f3577b34da6a3ce929d0e0e4736")'
坑:生产环境误开 debug,日志把磁盘写满
这是非常经典的生产事故:为了排查一个问题把 RUST_LOG 调成 debug,然后忘了改回来。sqlx、hyper、reqwest 在 debug 级别下会输出每次查询、每个连接、每个请求头,QPS 1000 的服务一天能产出几十 GB 日志,直接把日志盘写满,然后整个节点挂掉——排障变成了制造故障。
三条纪律:① 默认值里就把第三方库压到 warn(写在代码里,而不是靠部署时记得设置);② 需要 debug 时用"按模块"的方式开(my_crate::orders=debug),别开全局;③ 日志采集侧配置速率限制与单条长度上限,并在磁盘水位加告警。另外 RUST_LOG 是唯一不用重启也能生效的关键配置之一(配合 reload layer 甚至能运行期改),值得写进运维手册。
#[instrument] 与 span 嵌套:写对了是神器,写错了是性能杀手
#[instrument] 是属性宏(第 22 章讲过它的展开原理),它做的事是"在整个函数体外包一层 span,并把参数记录成字段"。默认行为是记录所有参数——这就是坑的来源。
默认行为 vs 显式控制
use tracing::{instrument, Span};
// 危险:默认会把 req 和 db 都记进 span 字段
// req 是一个几百字段的结构体,db 是连接池(Debug 输出巨大)
// 结果:每条日志都要格式化一遍这些对象,CPU 白烧,日志里全是噪音
#[instrument]
async fn bad_handler(req: CreateOrderReq, db: &sqlx::PgPool) { /* ... */ }
// 正确:skip 掉不该记录的值,只挑真正有用的字段
#[instrument(
// name 控制 span 名字(默认是函数名),跨 crate 排查时统一命名很有用
name = "http.create_order",
// skip 大对象:连接池、请求体、大数组、任何 Debug 输出很长的东西
skip(db, req),
// fields 精确说明要记哪些字段;% 是 Display,? 是 Debug
fields(user_id = %user_id, item_count = req.items.len()),
// level 指定 span 级别,配合 EnvFilter 可以整体关掉这类 span
level = "info",
// ret 记录返回值(要 Debug);err 记录错误(要 Display)
err,
)]
async fn create_order(
user_id: i64,
req: CreateOrderReq,
db: &sqlx::PgPool,
) -> anyhow::Result<i64> { /* ... */ }
// 不想用宏时,手动写 span(更灵活,适合"只想包一段代码"的场景)
let span = tracing::info_span!("db.query", sql = %sql_preview, rows = tracing::field::Empty);
let _guard = span.enter(); // 同步代码用 enter()
// 异步代码里【不要】跨 .await 持有 _guard,见下面的坑
span 嵌套:父子关系是自动建立的
// 调用链:handle_request -> create_order -> reserve_stock -> db.query
// 每一层的日志都会带上自己这一层以及所有祖先 span 的字段
#[instrument(name = "http.request", skip(state), fields(trace_id = %trace_id))]
async fn handle_request(state: axum::extract::State<AppState>, trace_id: String) {
// 这里 info! 会带上 trace_id
tracing::info!("收到请求");
create_order(42).await; // 子 span 自动挂在当前 span 下面
}
#[instrument(name = "order.create")]
async fn create_order(user_id: i64) {
// 这行日志同时带 user_id(本层)与 trace_id(祖先层)
tracing::info!("创建订单");
reserve_stock(user_id).await;
}
// 把 span 句柄"塞"进异步任务:spawn 出来的任务不会继承父 span,必须手动带
let span = tracing::Span::current(); // 拿到当前 span
tokio::spawn(async move {
// enter / in_scope / instrument 三选一,异步任务里推荐 instrument
let _enter = span.enter();
do_background_work().await;
});
// 更好的写法:让 future 自己带着 span 上下文(不会因为跨 await 而丢失)
// tokio::spawn(do_background_work().instrument(tracing::Span::current()));
坑:在异步函数里跨 .await 持有 span.enter() 的守卫
span.enter() 返回的是一个 Entered 守卫,它依赖线程局部状态来记录"当前 span 是哪个"。而在异步运行时里,同一个线程会在多个任务之间切换——你持有的守卫会让别人的任务也被算进你的 span,日志就会出现"张冠李戴":A 请求的日志被挂到 B 请求的 trace 上。
正确做法有三条:① 异步函数用 #[instrument],它生成的是基于 Future::poll 的正确上下文,不是线程局部的;② 只想包一段代码时用 span.in_scope(|| { ... }),作用域结束自动退出;③ 要跨 .await 就用 future.instrument(span)(来自 tracing::Instrument trait)而不是 enter()。
另一个高频坑:tokio::spawn 出来的任务丢上下文。子任务里打的日志没有 trace_id,导致异步处理的那段流程在排查时是"断掉"的。解法就是上面代码里的 .instrument(Span::current()),形成肌肉记忆。还有一条:#[instrument] 加在 async fn 上是没问题的,但如果加在返回 Future 的普通函数上,span 会在函数返回时就结束(只覆盖"创建 future",不覆盖"执行 future"),这时必须改用 .instrument() 手动包。
把 trace_id 打进日志:跨服务串联的关键
三个服务串成的一条链路,你需要在任何一个服务里搜 trace_id,就能把三个服务的相关日志全捞出来。做法是两部分:入站时从请求头提取并放进 root span、出站时把当前 trace 上下文写回请求头。
入站:用 tower 中间件建立 root span(axum 的标准做法)
use axum::{extract::Request, middleware::Next, response::Response};
use tracing::info_span;
use tracing_opentelemetry::OpenTelemetrySpanExt;
/// 从入站请求头提取 trace 上下文,创建 root span,并把 trace_id 暴露出来
pub async fn trace_middleware(mut req: Request, next: Next) -> Response {
// 1) 优先用上游传来的 traceparent(W3C 标准头),没有就生成一个新的
let trace_id = req
.headers()
.get("traceparent")
.and_then(|v| v.to_str().ok())
// traceparent 格式:00-<trace_id>-<span_id>-01,第 2 段就是 trace_id
.and_then(|v| v.split('-').nth(1).map(|s| s.to_string()))
.unwrap_or_else(|| uuid::Uuid::new_v4().simple().to_string());
// 2) 建 root span,把 HTTP 元信息与 trace_id 都放进字段
let span = info_span!(
"http.request",
// otel.name / otel.kind 是两个约定字段,OTel 后端会读它们
otel.name = %format!("{} {}", req.method(), req.uri().path()),
http.method = %req.method(),
http.route = %req.uri().path(),
// trace_id 是自定义字段,可以按它直接过滤日志
trace_id = %trace_id,
// 生产里通常交给 OTel propagator 生成/续接,这里手写是为了讲清原理
user_agent = %req.headers().get("user-agent").and_then(|v| v.to_str().ok()).unwrap_or("-"),
);
// 3) 用 instrument 把 span 挂到请求处理链上(不要用 enter)
use tracing::Instrument;
let resp = next.run(req).instrument(span).await;
// 4) 把状态码补进 span:span 已经结束就不能再写字段,所以用事件补
tracing::info!(status = resp.status().as_u16(), "请求处理完成");
resp
}
// 装配:middleware::from_fn 包在最外层,保证连 404 也进 trace
let app = axum::Router::new()
.route("/api/orders", axum::routing::post(create_order))
.layer(axum::middleware::from_fn(trace_middleware));
出站:调下游时把 trace 上下文带上
use tracing_opentelemetry::OpenTelemetrySpanExt;
pub async fn call_inventory(http: &reqwest::Client, sku: &str) -> anyhow::Result<Stock> {
// 从当前 span 里取出 OTel 上下文,注入到 HTTP 头
let cx = tracing::Span::current().context();
let mut headers = reqwest::header::HeaderMap::new();
opentelemetry::global::get_text_map_propagator(|p| {
// 全局 propagator 默认是 W3C TraceContext,会写出 traceparent 头
p.inject_context(&cx, &mut HeaderInjector(&mut headers));
});
// 给这次出站调用建一个子 span,Jaeger 里就能看到"HTTP 调用"这一段耗时
let span = tracing::info_span!("http.client", peer = "inventory", endpoint = %"/stock");
let resp = http
.get(format!("{}/stock?sku={sku}", INVENTORY_URL))
.headers(headers)
.send()
.instrument(span)
.await?
.error_for_status()?;
Ok(resp.json().await?)
}
// 一个最小的 HeaderInjector:把 OTel 的 Injector trait 适配到 reqwest 的 HeaderMap
struct HeaderInjector<'a>(&'a mut reqwest::header::HeaderMap);
impl<'a> opentelemetry::propagation::Injector for HeaderInjector<'a> {
fn set(&mut self, key: &str, value: String) {
if let Ok(v) = reqwest::header::HeaderValue::from_str(&value) {
if let Ok(name) = reqwest::header::HeaderName::from_bytes(key.as_bytes()) {
// SAFETY 说明:这里不是 unsafe,只是借用检查的常规处理
self.0.insert(name, v);
}
}
}
}
论为什么"日志里有 trace_id"比"日志分级"更重要
① 分级只解决"看多少",不解决"看哪条"。把日志从 info 调到 error 能减少噪音,但故障时你要的是"这一个用户、这一次请求"的全部线索,而不是"所有 error"。
② trace_id 把"请求"变成了可检索的主键。有了它,排障流程从"猜时间点、翻日志、靠肉眼对齐"变成"拿到用户报的 trace_id(放在错误响应体里给前端)→ 搜一次 → 看到完整调用链"。这是从分钟级到秒级的差别。
③ 所以工程上有一条强建议:把 trace_id 返回给客户端。在响应头加 X-Trace-Id,或让统一错误响应体里带 trace_id 字段。用户截个图给你,你直接就能定位到那一次请求——没有这一步,再好的可观测性也得多花半小时在"哪一次请求"上。
导出到 OpenTelemetry:OTLP 到 Jaeger / Tempo
tracing 负责采集,OpenTelemetry 负责标准化的导出。OTLP 是 OTel 的传输协议,Jaeger、Tempo、Datadog、阿里云 ARMS 都支持,所以接一次就能换后端。
初始化 OTel provider 并叠加到 tracing subscriber 上
use opentelemetry::{trace::TracerProvider as _, KeyValue};
use opentelemetry_otlp::WithExportConfig;
use opentelemetry_sdk::{trace::SdkTracerProvider, Resource};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
pub fn init_telemetry(service_name: &'static str, otlp_endpoint: &str)
-> anyhow::Result<SdkTracerProvider>
{
// 1) 建 exporter:批量发送到 OTLP gRPC 端点(Jaeger 默认 4317)
let exporter = opentelemetry_otlp::SpanExporter::builder()
.with_tonic()
.with_endpoint(otlp_endpoint) // 如 http://tempo:4317
.with_timeout(std::time::Duration::from_secs(5))
.build()?;
// 2) Resource 描述"这条 trace 来自哪个服务、哪个版本、哪个实例"
// 没有它,Jaeger 里全是 "unknown_service",多服务时无法区分
let resource = Resource::builder()
.with_service_name(service_name)
.with_attributes([
KeyValue::new("service.version", env!("CARGO_PKG_VERSION")),
KeyValue::new("deployment.environment", std::env::var("ENV").unwrap_or_else(|_| "dev".into())),
KeyValue::new("service.instance.id", uuid::Uuid::new_v4().to_string()),
])
.build();
// 3) Provider:批量导出 + 采样策略
let provider = SdkTracerProvider::builder()
// batch 会攒一批再发,比 simple(每条都发)高效得多;
// 但进程退出前必须 shutdown(),否则缓冲区里的 span 全丢
.with_batch_exporter(exporter)
.with_resource(resource)
// 采样:高 QPS 服务不可能 100% 上报,通常 10%~20% 或按错误优先采样
.with_sampler(opentelemetry_sdk::trace::Sampler::ParentBased(Box::new(
opentelemetry_sdk::trace::Sampler::TraceIdRatioBased(0.1),
)))
.build();
// 4) 把 OTel layer 叠加到 tracing subscriber 上
// 注意顺序:fmt 放前面(日志优先输出),otel 放后面
let tracer = provider.tracer(service_name);
tracing_subscriber::registry()
.with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info,my_crate=debug")))
.with(tracing_subscriber::fmt::layer().json().with_current_span(true))
.with(tracing_opentelemetry::layer().with_tracer(tracer))
.init();
// 设置全局 propagator:跨服务传递用 W3C TraceContext 标准
opentelemetry::global::set_text_map_propagator(
opentelemetry_sdk::propagation::TraceContextPropagator::new(),
);
Ok(provider)
}
本地起一套 Jaeger 验证(docker compose 三行搞定)
# docker-compose.yml
services:
jaeger:
image: jaegertracing/all-in-one:latest
ports:
- "16686:16686" # UI
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP
# 启动应用并指向 Jaeger
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 ./order-api
# 打开 http://localhost:16686 就能按 service 名搜 trace
# 用环境变量切换后端(代码不用改,这是 OTLP 标准化的价值)
# Tempo: OTEL_EXPORTER_OTLP_ENDPOINT=http://tempo:4317
# Datadog: OTEL_EXPORTER_OTLP_ENDPOINT=http://datadog-agent:4317
指标:先想清楚"四个黄金信号",再决定用什么库
| 信号 | 看什么 | Rust 侧怎么采 |
| 延迟 | P50/P95/P99 响应时间 | tower-http 的 TraceLayer 打点 + 直方图指标;或用 metrics crate 手工记 histogram |
| 流量 | QPS、按路由/状态码分组 | counter,维度用 route 与 status(别用 user_id 当维度,基数爆表) |
| 错误 | 错误率、按错误码分组 | counter + 在统一错误处理里自增;配合 {span} 关联 trace |
| 饱和度 | 连接池使用率、CPU、内存、队列长度 | gauge,定期从连接池/运行时拿 |
// 方案 A:metrics crate + Prometheus exporter(轻量、生态广、和 tracing 解耦)
use metrics::{counter, gauge, histogram};
fn record_http(route: &str, status: u16, elapsed: std::time::Duration) {
// 维度尽量少:route + status 就够了,加上 user_id 会让时间序列爆炸
counter!("http_requests_total", "route" => route.to_string(), "status" => status.to_string()).increment(1);
histogram!("http_request_duration_seconds", "route" => route.to_string()).record(elapsed.as_secs_f64());
}
// 初始化:把 Prometheus recorder 装成全局,然后暴露 /metrics 端点
// use metrics_exporter_prometheus::PrometheusBuilder;
// let handle = PrometheusBuilder::new().install_recorder()?;
// .route("/metrics", get(move || async move { handle.render() }))
// 方案 B:OTel metrics —— 和 trace 共用一套 SDK 与导出通道
// let meter = provider.meter("order-api");
// let counter = meter.u64_counter("http_requests_total").build();
// counter.add(1, &[KeyValue::new("route", route.to_string())]);
健康检查与优雅停机:日志质量的隐形前提
这一节常被当成"运维的事",但它直接影响你排障时的信噪比:没有优雅停机,每次发布都会在日志里造出一批假错误(连接被重置、请求被中断),K8s 还会因为探针配置不当反复重启健康的 Pod。
两类探针:liveness 与 readiness 必须分开
use axum::{extract::State, http::StatusCode, Json};
/// 存活探针:只回答"进程还活着吗",【不要】检查下游依赖
// 如果这里查数据库,数据库抖一下 K8s 就会杀掉所有 Pod,雪崩
async fn healthz() -> StatusCode { StatusCode::OK }
/// 就绪探针:回答"现在能接流量吗",【要】检查关键依赖,但必须带超时
async fn readyz(State(state): State<AppState>) -> (StatusCode, Json<serde_json::Value>) {
// 关键:给依赖检查加超时,否则依赖卡住会让探针也卡住 → 被判定不健康
let db_ok = tokio::time::timeout(
std::time::Duration::from_millis(500),
sqlx::query_scalar::<i32>("SELECT 1").fetch_one(&state.db),
)
.await
.map(|r| r.is_ok())
.unwrap_or(false);
let redis_ok = tokio::time::timeout(
std::time::Duration::from_millis(300),
redis::cmd("PING").query_async::<String>(&mut state.redis.clone()),
)
.await
.map(|r| r.is_ok())
.unwrap_or(false);
let status = if db_ok && redis_ok { StatusCode::OK } else { StatusCode::SERVICE_UNAVAILABLE };
(status, Json(serde_json::json!({ "db": db_ok, "redis": redis_ok })))
}
优雅停机:先停止接收新请求,跑完在途请求,最后 flush 遥测数据
use opentelemetry_sdk::trace::SdkTracerProvider;
/// 等一个"该下班了"的信号:Ctrl+C 或 SIGTERM(K8s 滚动更新发的是 SIGTERM)
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c().await.expect("注册 Ctrl+C 失败");
tracing::info!("收到 Ctrl+C");
};
// K8s 删除 Pod / docker stop 发的是 SIGTERM,必须单独处理
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("注册 SIGTERM 失败")
.recv()
.await;
tracing::info!("收到 SIGTERM");
};
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
}
pub async fn serve(app: axum::Router, addr: std::net::SocketAddr) -> anyhow::Result<()> {
let provider: SdkTracerProvider = init_telemetry("order-api", &otlp_endpoint())?;
let listener = tokio::net::TcpListener::bind(addr).await?;
tracing::info!(%addr, "服务已启动");
// with_graceful_shutdown:收到信号后不再接受新连接,并等待在途请求跑完
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
// 关键一步:把缓冲区里的 span 和 metric 冲出去
// 忘了这行,最后 5 秒的 trace 全部丢失 —— 而"正在关停时的异常"恰恰最值得看
provider.shutdown()?;
tracing::info!("已优雅退出");
Ok(())
}
// K8s 侧要配合的配置(这段是 deployment.yaml,不是 Rust 代码)
// terminationGracePeriodSeconds: 30 # 给在途请求留足时间,要大于最长请求耗时
// preStop: ["/bin/sh", "-c", "sleep 5"] # 等 Service 从 Endpoints 摘掉再开始停
// 顺序很重要:先摘流量、再发 SIGTERM,否则新请求会打到正在关闭的 Pod 上
论为什么"优雅停机"属于可观测性问题
① 强杀进程会制造大量假错误。收到 SIGTERM 立刻退出,所有在途请求会被连接重置。这些错误与业务无关,但会出现在指标的错误率曲线上、出现在日志里,让你在发布后花时间确认"这些 502 是不是真问题"。
② 更糟的是丢遥测数据。OTLP 的 batch exporter 会把 span 攒在内存里等一批再发。直接 std::process::exit 或者不调用 provider.shutdown(),缓冲区里的 trace 就全丢了——而"服务关停时发生了什么"恰恰是最需要 trace 的时刻。
③ 顺带说清探针的语义:liveness 失败 = K8s 重启容器;readiness 失败 = 只是把它从负载均衡摘掉。所以 liveness 里绝不能查数据库——数据库抖一下,所有 Pod 会被同时判死并重启,本该是"降级"的故障被放大成"全站不可用"。这就是探针配置与可观测性直接相关的原因。
常见坑清单
| 现象 | 原因 | 修法 |
| 异步任务里的日志没有 trace_id |
tokio::spawn 出来的任务不继承父 span |
tokio::spawn(fut.instrument(Span::current())),或先存 span 再 in_scope |
| 日志里 A 请求的字段跑到了 B 请求上 |
在 async fn 里跨 .await 持有 span.enter() 的守卫 |
改用 #[instrument] 或 .instrument(span);只包同步代码段时用 in_scope |
| CPU 飙高、日志体积暴涨,但没改业务逻辑 |
#[instrument] 默认记录了所有参数,包括大结构体/连接池 |
显式 skip(...),只用 fields(...) 留下真正需要的字段 |
Jaeger 里服务名是 unknown_service |
没设置 Resource 的 service.name |
Resource::builder().with_service_name("order-api"),并补 service.version / environment |
| 关停后最后几秒的 trace 全没了 |
batch exporter 缓冲区没 flush,进程就退了 |
在 with_graceful_shutdown 之后调 provider.shutdown() |
| 发布期间错误率尖刺(其实不是 bug) |
没有优雅停机;或探针配置不当导致 Pod 反复重启 |
处理 SIGTERM + preStop 延迟 + terminationGracePeriodSeconds 调够 |
| 指标时间序列成千上万,Prometheus 快撑不住 |
把 user_id、order_id 这类高基数字段当成了指标维度 |
维度只留 route、status、method 这类有限集合;高基数信息放 trace/日志 |
日志里 span 字段缺失,看不到 user_id |
fmt layer 没开 with_current_span(true) / with_span_list(true) |
JSON 日志务必显式打开这两个开关 |
| 采样太狠,出问题时恰好没采上 |
纯 TraceIdRatioBased(0.01) 随机采样 |
用 ParentBased + 错误优先采样(error span 全采),或按路由区分采样率 |
坑:把 span 当成"打点工具",最后 span 数量爆炸
一个常见的用力过猛:给每个函数都加 #[instrument],包括那些纯计算的 getter、clone、Display 实现。结果是一次请求产生几百个 span,Jaeger 里点开一条 trace 要加载十几秒,采样率也被迫降到 1% 以下——你有可观测性框架,但看不到任何东西。
正确的粒度原则:span 的边界应该是"一个可能变慢或者会失败的操作"。数据库查询、外部 HTTP 调用、消息队列收发、文件 IO、以及"业务上可独立计时的阶段"(比如"风控校验""库存扣减")值得建 span;纯内存计算和工具函数不需要。
另外三条实操建议:① 用 level = "debug" 给细粒度 span,生产环境默认 info 级别就不上报,需要时再打开;② 每个服务的 span 命名用统一前缀(http.* / db.* / mq.*),不然全是一堆同名的函数名;③ 本地开发时用 pretty 输出看嵌套关系,比直接开 Jaeger 快得多。
记
本章小结
① 三件套各管一段:metric 看趋势(有没有出问题)、trace 看链路(问题出在哪一段)、log 看细节(具体什么错)。trace_id 是三者串联的钥匙。
② span 是"上下文容器",进入 span 后打的日志自动带上它的字段——这是 tracing 相对 log 的核心增量。
③ EnvFilter 默认值里就要把第三方库压到 warn,并支持按模块开 debug;生产用 JSON、开发用 pretty。
④ #[instrument] 默认记录所有参数,必须 skip 大对象;异步场景用 #[instrument] 或 .instrument(),不要跨 .await 持有 enter() 守卫。
⑤ 跨服务串联靠 W3C traceparent:入站提取建 root span、出站 inject 到请求头;并把 trace_id 返回给客户端。
⑥ OTLP 一次接入、多后端可换;记得配 Resource 的 service.name,否则 Jaeger 里全是 unknown_service。
⑦ 指标维度避开高基数(别用 user_id),观测四黄金信号:延迟、流量、错误、饱和度。
⑧ liveness 不查依赖、readiness 才查且要超时;优雅停机三步:摘流量 → 处理 SIGTERM → provider.shutdown() flush。
小练习 · 五道可观测性自测题(点开看答案)
1.(排错题)排查发现"同一个异步任务里打的日志没有 user_id,但主流程有",最可能的原因?
查看答案
该任务是用 tokio::spawn 起的,而 spawn 出来的任务不继承父 span。需要在创建时显式带上上下文:tokio::spawn(work().instrument(tracing::Span::current())),或者先把 Span::current() 存下来、在任务里 span.in_scope(...)。这是最常见的"链路断掉"原因。
2.(性能题)加了 #[instrument] 之后接口 P99 从 8ms 涨到 25ms,怎么查?
查看答案
先看有没有 skip 大参数。#[instrument] 默认把所有参数按 Debug 记录下来,如果参数里有大 Vec、完整请求体、连接池或任何 Debug 输出很长的对象,每次调用都要格式化一遍。修法:skip(...) 掉它们,只用 fields(...) 记需要的字段。另外也要检查 fmt layer 是否开了 with_span_list(true)(会有额外开销)、以及 span 数量是否失控。
3.(概念题)为什么 liveness 探针里不能查数据库?
查看答案
因为 liveness 失败会让 K8s 重启容器。如果探针里查数据库,数据库抖一下,所有 Pod 会同时被判定不健康并重启,把"依赖暂时不可用"放大成"整个服务挂掉"(还会引发重启风暴)。正确做法:liveness 只回答"进程活着吗"(直接返回 200),readiness 才检查依赖并带短超时,让 K8s 只是把实例从负载均衡里摘掉。
4.(工程题)发布一次之后错误率曲线出现一个尖刺,但业务代码没改。可能是什么?
查看答案
缺少优雅停机 / preStop,导致在途请求被强杀。K8s 滚动更新时旧 Pod 直接收到 SIGTERM 退出,连接被重置,客户端看到 502/connection reset。修法:处理 SIGTERM + with_graceful_shutdown;加 preStop: sleep 5 让 Service 先从 Endpoints 摘掉;terminationGracePeriodSeconds 设为大于最长请求耗时。
5.(设计题)为什么指标维度不能带 user_id?
查看答案
基数爆炸。Prometheus 这类时序库为"每一组维度组合"维护一条时间序列。维度值是 route(十几条)+ status(几条)时,组合数可控;一旦加上 user_id(几十万),时间序列数量就是几十万 × 路由数,内存与查询开销直接压垮 Prometheus。高基数信息属于 trace 和日志的领域:用 metric 看"整体趋势",用 trace_id 去日志里定位"具体是谁"。