Storforge
编码预计 45 分钟

可观测性:给自己的系统装仪表盘

你运维时抱怨过的「这系统什么都看不见」,现在轮到你来还债:prometheus 指标、tracing 链路、慢 IO 日志,一次配齐。

学完这节你能做到

  • 设计 forge 的指标体系:USE + RED 两套视角
  • 接入 tracing,一条写请求全链路可见
  • 搭出 Grafana 大盘与三条告警规则

还债的时刻到了

运维时你一定骂过某些系统:出问题只会吐一句含糊的日志,想看内部状态没有接口, 延迟高了不知道卡在哪一层,最后只能重启了事。「这系统什么都看不见」—— 这句抱怨你说过多少遍,自己心里有数。

现在你是那个「系统的作者」了。forgefs 在 L4 已经能挂载、能扛节点故障, 但此刻它对外就是个黑盒:P99 毛刺来了,你能回答「时间花在哪」吗?不能, 就轮到未来的运维(可能还是你)骂你。这节课把债还清:指标、链路、慢 IO 日志,一次配齐。

好消息是,这是整条路线里你最有主场优势的一课:别人是学着设计指标, 你是把十年看盘经验反着写进系统。

指标设计:USE + RED 两套视角

别上来就 emit 指标,先想清楚谁在什么场景下看什么。两套经典框架正好覆盖存储系统的两侧:

  • USE(Utilization / Saturation / Errors) —— 看资源,回答「机器还有没有余量」。 对 forge:每块盘的带宽利用率、IO 队列深度(饱和度)、EIO 计数; 每个节点的内存 buffer 池占用、tokio 任务积压。
  • RED(Rate / Errors / Duration) —— 看请求,回答「用户体验怎么样」。 对 forge:每类 RPC 的 QPS、错误率、延迟直方图;FUSE 层每类操作 (read/write/lookup)同样一套。

按这两套视角过一遍 forge 的架构,得到第一版指标清单(节选):

指标类型视角你当年想看却看不到的
forge_rpc_duration_secondshistogram,按 method/node 分 labelRED「慢在网络还是慢在盘」
forge_store_io_duration_secondshistogram,按 read/write/fsync 分RED「fsync 到底多慢」
forge_store_disk_errors_totalcounter,按 kind 分USE「坏盘前有没有前兆」
forge_rebuild_bytes_total / forge_rebuild_backlog_bytescounter / gaugeUSE「重建还要多久」
forge_raft_leader_changes_totalcounterRED「刚才是不是切主了」
forge_placement_versiongauge「各节点放置表一不一致」

两条设计纪律,都是你被烂指标坑出来的经验:

  1. 延迟必须是直方图,不是平均值。 平均延迟 1ms 的系统可以有 2 秒的 P99, 你被这种「均值很美」的大盘骗过。用 histogram,bucket 边界按对数分布铺到秒级。
  2. label 基数要管住。 按 method、node、盘分 label 没问题(几十个组合); 按 inode、客户端 IP 分就是灾难 —— 百万级序列能把 Prometheus 拖死, 这个事故你可能亲手救过。

metrics crate 与 prometheus exporter

Rust 侧用 metrics 门面 + metrics-exporter-prometheus,埋点一行,导出全自动:

use metrics::{counter, histogram};

pub async fn handle_put(&self, req: PutRequest) -> Result<PutReply, RpcError> {
    let start = self.clock.now();               // 注意:走 Clock trait,模拟测试里也能跑
    let result = self.do_put(req).await;

    let elapsed = self.clock.now().duration_since(start);
    histogram!("forge_rpc_duration_seconds", "method" => "put").record(elapsed.as_secs_f64());
    if result.is_err() {
        counter!("forge_rpc_errors_total", "method" => "put").increment(1);
    }
    result
}

exporter 在 forge-node 启动时拉起,直接暴露 /metrics:

use metrics_exporter_prometheus::PrometheusBuilder;

pub fn init_metrics(listen: SocketAddr) -> Result<(), MetricsError> {
    PrometheusBuilder::new()
        .with_http_listener(listen)
        // 延迟 bucket:0.1ms 到 10s,对数铺开 —— P99 毛刺就藏在高位 bucket 里
        .set_buckets(&[1e-4, 5e-4, 1e-3, 5e-3, 1e-2, 5e-2, 0.1, 0.5, 1.0, 5.0, 10.0])?
        .install()?;
    Ok(())
}

然后就是你闭着眼都会的部分:Prometheus 加一条 scrape job 抓所有 forge 节点, 15s 间隔。第一次 curl <你的节点>:9100/metrics 看到自己系统吐出的指标流, 这一刻和 L4 第一次 mount 成功同样值得截图。

tracing:一条写请求的全链路

指标回答「整体怎么样」,回答不了「这一个请求为什么慢」。 一次 FUSE write 在 forge 里要穿过:FUSE 层 → 客户端条带化 → 多个节点的 RPC → EC 编码 → forge-store 落盘 → fsync。任何一段都可能是那 200ms 的元凶。

tracing crate 用 span 把这条链串起来:

use tracing::{info_span, instrument, Instrument};

#[instrument(skip(self, data), fields(ino, offset, len = data.len()))]
pub async fn write(&self, ino: u64, offset: u64, data: &[u8]) -> Result<u32, FsError> {
    let stripes = self.map_to_stripes(ino, offset, data)?;
    for stripe in stripes {
        // 子 span:每个条带的提交单独计时
        self.commit_stripe(stripe)
            .instrument(info_span!("commit_stripe", stripe_id = stripe.id.0))
            .await?;
    }
    // ...
    Ok(data.len() as u32)
}

跨节点的部分靠上下文传播:客户端把 trace id 塞进 tonic 请求的 metadata, 服务端取出来接上,于是一条链路横跨三台机器也是完整的一棵 span 树。 输出接 tracing-subscriber,开发时打人类可读日志,联调时导出到 Jaeger 看瀑布图 —— 「这 200ms 里 fsync 占了 180ms」一眼可见。

日常运行不可能全量记 trace(开销和存储都受不了),用你配采样时的老规矩: 慢的全留,快的抽样。forge 里做成一个简单的 tail 采样:span 树完成时 总耗时超过阈值(比如 50ms)则整树落盘到慢 IO 日志,否则按 1% 抽样。 慢 IO 日志是给三个月后深夜排障的自己留的现场。

Checkpoint单选

P99 写延迟告警触发了。指标和 tracing 各自回答什么问题?

大盘与告警:把运维经验反哺给自己

Grafana 大盘怎么排,你比这节课有发言权。给 forge 1.0 的大盘定一个最小结构:

  • 第一行,服务视角(RED):集群总 QPS、错误率、读/写 P50/P99 —— 值班的人 5 秒钟判断「有没有事」
  • 第二行,资源视角(USE):每盘带宽与队列深度热力图、节点内存、EIO 计数
  • 第三行,forge 特有状态:Raft leader 与切主次数、重建 backlog 与速率、放置表版本一致性

告警只配三条,宁缺毋滥 —— 你太清楚告警疲劳是怎么把人训练成「看到告警先划掉」的:

  1. forge_rpc_errors_total 错误率 5 分钟均值大于 1% —— 用户已经受影响,P1
  2. 写 P99 大于 100ms 持续 10 分钟 —— 恶化趋势,工作时间处理
  3. forge_rebuild_backlog_bytes 持续 30 分钟不下降 —— 重建卡住了,冗余度在裸奔,这条最凶险
每条告警配一段 runbook

你收过最气人的告警是只有一句 "threshold exceeded" 的那种。给自己的三条告警各写一段 runbook:这条告警意味着什么、先看哪个面板、常见原因和处置命令。 写 runbook 的过程往往还能反过来发现指标缺口 ——「处置时我需要看 X」而 X 还没埋点。

AI 结对:给 forge 织入可观测性pair with ai

review 的重点不在代码而在设计遗漏:拿着你自己的运维经验过一遍指标清单, 「当年排障时我还想看什么」—— 至少补一条 AI 没想到的指标。 另外盯住 label 基数(让它数给你看)和 histogram bucket 上限 (10s 封顶,不然真出事时毛刺会被最后一个 bucket 吃掉)。

为 forge 接入完整的可观测性,分四步:

1. 在 forge-util 新增 obs 模块:封装 metrics-exporter-prometheus 的初始化(可配监听地址,delay bucket 从 0.1ms 对数铺到 10s),以及 tracing-subscriber 初始化(EnvFilter 控制级别,JSON 与人类可读两种格式可选);
2. 在 forge-node 与 forge-fs 埋点,指标清单:forge_rpc_duration_seconds(histogram, label: method/node)、forge_rpc_errors_total(counter, label: method)、forge_store_io_duration_seconds(histogram, label: op, op 取 read/write/fsync)、forge_store_disk_errors_total(counter)、forge_rebuild_bytes_total(counter)、forge_rebuild_backlog_bytes(gauge)、forge_raft_leader_changes_total(counter);label 组合总数写进注释并保证少于 200;
3. 给写路径接 tracing:从 FUSE write 到条带提交到 store 落盘的 span 链,tonic 客户端把 trace id 放进请求 metadata,服务端 interceptor 恢复上下文;span 树总耗时超过 50ms 时以 JSON 整树写入慢 IO 日志文件,否则按 1% 采样;
4. 生成 Grafana dashboard 的 JSON(三行:RED 总览、每盘 USE 热力图、重建与 Raft 状态)和 Prometheus 告警规则 YAML(错误率大于 1% 持续 5 分钟、写 P99 大于 100ms 持续 10 分钟、rebuild backlog 30 分钟不降),每条告警的 annotation 里附一句处置提示。

验收:3 节点起集群,跑 5 分钟 forge-bench 负载,curl 任一节点 /metrics 能看到全部指标且直方图有数据;人为给一个节点注入 200ms 磁盘延迟后,慢 IO 日志里能找到完整 span 树。计时打点一律通过现有 Clock trait 取时间,不要直接调 Instant::now,保持模拟测试可用。

小结

  • 可观测性是还债:你骂过的黑盒系统什么样,你的系统就不许什么样
  • 指标设计用两套视角:USE 看资源(盘、内存、队列),RED 看请求(QPS、错误、延迟); 延迟一律直方图,label 基数管在百级
  • metrics + prometheus exporter 一行埋点;tracing span 树串起跨节点链路, 慢请求全留、快请求抽样,慢 IO 日志是留给未来的现场
  • 大盘三行:RED 总览、USE 热力图、forge 状态;告警只配三条且各带 runbook, 别把自己训练成划告警的人
  • 系统看得见了,下一节就敢动刀:带着 flamegraph 做一轮真正的性能调优