WASM 可观测性:指标、追踪、日志与运行时监控

系统讲解 WASM 的可观测性建设:为什么 WASM 的可观测性比常规服务更难、运行时指标采集、手动与自动插桩、分布式追踪的上下文传递与 span 边界、结构化日志、性能剖析与火焰图、内存与资源监控、告警与 trap 分析,以及多租户观测隔离与生产落地清单。

导语:让 WASM 模块可观测

WASM 模块跑在宿主进程里,但它对宿主来说是「黑盒」:没有 stdout、没有文件句柄、没有系统调用,连一次函数调用花了多少 CPU 都只能由宿主估算。这意味着传统可观测手段(打日志到 stdout、用 eBPF 抓系统调用、看 /proc 内存)全部失效——你必须显式设计一条观测通道,把指标、日志与追踪从沙箱里「引」出来。

同时 WASM 又恰好提供了传统方案没有的能力:运行时能精确计量燃料(fuel)、能拿到每个实例的线性内存用量、能在 trap 时给出结构化堆栈。把这些能力用好,WASM 的可观测性反而可以比容器更细粒度。本文按「指标 / 追踪 / 日志」三支柱展开,再讲剖析、告警与多租户隔离,最后给出生产采集管线。

前置:WASM 基础、Wasmtime 运行时、调试与剖析工具。


目录


1. 可观测性三大支柱

1.1 为什么 WASM 更棘手

常规服务:stdout 直接可读、eBPF 抓 syscall、/proc 看内存、进程级堆栈
WASM:无 stdout(需宿主注入 log)、无 syscall 可抓、只能看宿主进程、
      trap 堆栈需开 backtrace 才有符号、实例崩溃需显式上报

结论很明确:WASM 的可观测性必须在宿主侧建,而不是模块侧。模块只能「提供钩子」,真正的采集、聚合、导出全在宿主。

1.2 三支柱在 WASM 的落点

指标(Metrics):宿主采集调用次数、延迟、燃料、内存水位、trap 次数;
                模块通过导入的 emit_metric 上报业务计数器
追踪(Traces):宿主生成 span(调用边界、能力调用边界);
                模块通过导入函数读写 trace context
日志(Logs):模块调用导入的 log(level, ptr, len);
              宿主负责加时间戳、插件 ID、trace ID 后写出

一句话总结:WASM 的观测通道必须由宿主显式提供;指标与追踪以宿主采集为主、模块补充,日志完全依赖宿主注入的 log 函数。


2. 运行时指标采集

2.1 宿主侧指标

// Wasmtime:调用前后采集,得到延迟与结果分布
let t0 = Instant::now();
let result = instance.get_typed_func::<(i32, i32), i32>(&mut store, "handle")?
    .call(&mut store, (a, b));
let dt = t0.elapsed();

metrics.histogram("wasm_call_duration_seconds", dt, &[("plugin", pid)]);
metrics.counter("wasm_call_total", 1, &[("plugin", pid),
    ("status", if result.is_ok() { "ok" } else { "trap" })]);

2.2 燃料与内存水位

// 每次调用后读取剩余燃料,差值就是本次消耗
let before = store.get_fuel()?;
let _ = call(&mut store);
let used = before - store.get_fuel()?;
metrics.histogram("wasm_fuel_used", used as f64, &[("plugin", pid)]);

// 线性内存当前页数 → 字节数
let mem = instance.get_memory(&mut store, "memory").unwrap();
let bytes = mem.data_size(&store) as u64;
metrics.gauge("wasm_memory_bytes", bytes as f64, &[("plugin", pid)]);
必备指标清单(按插件实例维度):wasm_call_total(按 status 分)、
wasm_call_duration_seconds(延迟直方图)、wasm_fuel_used(燃料直方图)、
wasm_memory_bytes(内存占用)、wasm_trap_total(按原因分)、
wasm_instantiate_seconds(实例化耗时)。

一句话总结:宿主侧用「调用前后取差」采集延迟、燃料与内存;六项基础指标(调用数/延迟/燃料/内存/trap/实例化耗时)按插件维度打标签即可覆盖大部分问题。


3. 插桩与计数器

3.1 手动计数器

// 模块侧:通过导入的 emit_metric 上报业务计数
#[link(wasm_import_module = "host")]
extern "C" { fn emit_metric(name_ptr: *const u8, name_len: usize, value: f64); }

pub fn count_cache_hit() {
    let name = b"cache_hit_total";
    unsafe { emit_metric(name.as_ptr(), name.len(), 1.0) };
}
// 宿主侧实现:校验指标名白名单,避免插件刷爆指标系统
fn emit_metric(name: &[u8], value: f64) {
    let name = std::str::from_utf8(name).unwrap_or("");
    if !ALLOWED_METRICS.contains(name) { return; }   // 白名单
    registry.counter(name).inc_by(value);
}

3.2 自动插桩

自动插桩两条路线:编译期插桩(wasm-opt 的 --instrument-locals /
--instrument-memory,在函数入口出口插计数)与宿主侧 wrapping(把每个导出
函数包一层计时计数,零侵入、不依赖插件配合,是生产首选)。
# 检查插桩后的体积与指令增长
wasm-opt --instrument-memory --instrument-locals in.wasm -o out.wasm
wasm-tools dump out.wasm | head -20

自动插桩的代价是体积与性能:内存插桩会让每次 load/store 都多几条指令,实测热点函数性能下降 20%~50%,因此只在内测环境开启。

一句话总结:业务指标靠模块主动上报(必须白名单过滤),通用观测靠宿主 wrapping 零侵入实现;自动插桩性能代价大,只在内测用。


4. 分布式追踪集成

4.1 trace context 传递

WASM 追踪的核心问题:模块内没有网络栈,无法自动传播 traceparent。
解决方案:宿主在调用前把 context 注入模块,模块透传即可 ——
宿主从上游请求头取 traceparent → 调用前用 set_trace_context(ptr, len) 注入
→ 模块发起下游能力调用时宿主自动附加 context → 模块可选地创建子 span。
// 宿主:为每次插件调用创建一个 span
let span = tracer.start_span_with_parent("wasm.handle", &incoming_ctx);
span.set_attribute("plugin.id", plugin_id);
let _guard = span.enter();
let r = call_plugin(&mut store, &bytes);
span.record_result(&r);

4.2 span 边界怎么划

建议的 span 边界:一次插件调用 → wasm.handle;一次宿主能力调用(http)→
wasm.cap.http 子 span;插件内部业务阶段由插件显式创建(可选)。
禁忌:不要为每次内存读写创建 span(量级爆炸),不要在 span 属性里放插件
传入的任意字符串(基数爆炸)。

span 属性要严格控制基数:plugin.id、plugin.version、status 是低基数的好属性;把请求 URL、用户 ID 放进属性会让追踪后端直接崩掉。

一句话总结:宿主负责创建 span 与传播 traceparent,模块只透传 context;span 边界划在「插件调用」与「能力调用」两级,属性必须低基数。


5. 日志与结构化输出

5.1 日志通道

// 模块侧:调用导入的 log(level, ptr, len)
#[link(wasm_import_module = "host")]
extern "C" { fn log(level: u32, ptr: *const u8, len: usize); }

pub fn warn(msg: &str) {
    unsafe { log(2, msg.as_ptr(), msg.len()) };
}
// 宿主侧:补齐上下文字段后写出结构化日志
fn host_log(store: &mut Store<HostState>, level: u32, msg: &[u8]) {
    let st = store.data();
    tracing::event!(target: "wasm", Level::INFO,
        plugin_id = %st.plugin_id, trace_id = %st.trace_id,
        level = level, message = %String::from_utf8_lossy(msg));
}

5.2 结构化字段

宿主必须补齐:ts(宿主时钟,模块时钟不可信)、plugin_id、plugin_version、
instance_id(热加载后变化)、trace_id/span_id、level;
模块自己只提供 message(纯文本或 JSON 字符串)。

不要让模块自己带时间戳——模块时钟可以被伪造,且可能被墙钟跳变影响。时间戳一律由宿主在写出时打。

一句话总结:日志由模块提供 message、宿主补齐全部上下文字段;时间戳必须用宿主时钟,字段设计要能关联到插件版本与 trace。


6. 性能剖析与火焰图

6.1 采样剖析

# 方案一:把 wasm 转成原生可剖析的形式(wasmtime 的 profiling)
wasmtime run --profile=perf app.wasm

# 方案二:用 wasm-tools 保留 name 段 + 采样栈
wasm-opt --keep-section=name app.wasm -o app.prof.wasm
// 方案三:宿主侧粗粒度剖析(记录每个导出函数的调用耗时分布)
profiler.record("handle", dt);
profiler.record("transform", dt2);

6.2 火焰图

生成火焰图三步:采样(perf record / wasmtime profiling 收集栈样本)→
符号化(用 name 段或 DWARF 把地址映射回函数名)→ 渲染(flamegraph.pl)。
关键前提:必须有 name 段或 DWARF,否则火焰图全是 func[1234]。
典型发现:某字符串处理函数占 40% CPU → 改用批量 API;某跨边界调用被调用
200 万次 → 合并为一次批量调用;某分配路径触发大量内存增长 → 预分配 + 对象池。

火焰图对 WASM 尤其有价值,因为「跨边界调用过多」与「分配器热点」这两类问题在常规指标上完全看不出来。

一句话总结:火焰图的前提是保留 name 段或 DWARF;它最能暴露「跨边界调用过多」与「分配器热点」这两类指标看不见的问题。


7. 内存与资源监控

7.1 内存水位

// 宿主侧周期采样,超过阈值告警
let bytes = mem.data_size(&store);
let peak = state.peak_memory.max(bytes);
if bytes > state.limit * 8 / 10 {
    tracing::warn!(plugin_id = %pid, used = bytes, "wasm memory above 80%");
}
内存监控三个关键值:current(当前线性内存字节数)、peak(历史峰值)、
limit(配置的 MAXIMUM_MEMORY)。current / limit 持续大于 0.8 → 上调限额或修插件。

7.2 资源异常检测

内存单调上涨不回落 → 泄漏(分配未释放);内存锯齿状剧烈波动 → 频繁增长收缩,
应预分配;燃料消耗突然翻倍 → 输入规模变化或算法退化;实例化耗时突增 →
模块变大或 AOT 缓存未命中。

这些模式在宿主侧都能算出来,不需要插件配合。把它们做成自动检测规则,就能在用户投诉前发现问题。

一句话总结:内存监控看 current/peak/limit 三值,80% 水位告警;单调上涨是泄漏、锯齿是抖动、燃料突增是算法或数据问题,都能在宿主侧自动识别。


8. 告警与异常检测

8.1 告警规则

推荐的四条基础告警:插件错误率 > 1% 持续 5 分钟、P99 延迟 > 基线 3 倍持续
10 分钟、trap 次数 5 分钟内 > 10 次、内存水位 > 80% 持续 5 分钟。
每条告警都要带 plugin_id 与 plugin_version,便于快速定位到具体版本。

8.2 trap 分析

// 开启详细 backtrace,让 trap 堆栈可读
config.wasm_backtrace_details(WasmBacktraceDetails::Enable);

// trap 分类统计,区分「插件 bug」与「配额耗尽」
match err.downcast_ref::<Trap>() {
    Some(Trap::OutOfFuel)      => metrics.inc("trap_fuel", ...),
    Some(Trap::MemoryOutOfBounds) => metrics.inc("trap_oob", ...),
    Some(Trap::UnreachableCodeReached) => metrics.inc("trap_unreachable", ...),
    _ => metrics.inc("trap_other", ...),
}
trap 原因与责任归属:OutOfFuel → 配额问题,调额度或优化插件;
MemoryOutOfBounds → 插件越界 bug,插件侧修;UnreachableCode → 通常是
panic/abort,看插件堆栈;调用超时 → 可能是死循环,需熔断。

区分「配额耗尽」与「插件 bug」至关重要:前者要调配置,后者要退插件版本,处置动作完全不同。

一句话总结:四条基础告警都带 plugin_id 与版本;trap 必须按原因分类,区分配额问题与插件 bug,否则处置方向会错。


9. 多租户观测隔离

9.1 标签与隔离

多租户观测的三个要求:
  1. 租户只能看到自己的数据(查询侧隔离)
  2. 一个租户的指标基数不能拖垮全局(写入侧限流)
  3. 成本可归因(按租户统计采集量)

实现手段:
  所有指标/日志/span 都带 tenant_id 标签
  每租户设置指标基数上限与日志速率上限
  超出上限时降采样而不是丢弃全部

9.2 成本控制

控制指标基数的手段:
  禁止把请求参数放进标签(只允许枚举型标签)
  高基数维度(user_id、request_id)只进日志与追踪,不进指标
  日志按租户限速,超限后只保留 warn 以上级别
  追踪按比例采样(1% ~ 10%),错误全采

指标基数是可观测性成本的第一杀手。一个不小心把 URL 当标签,就能让时间序列数量从几千涨到几百万,直接把监控后端打爆。

一句话总结:所有观测数据带 tenant_id,写入侧限基数与速率;高基数维度只进日志与追踪,追踪按比例采样、错误全采。


10. 生产落地清单

10.1 采集管线

WASM 宿主 → (指标聚合) → Prometheus / OTLP
          → (日志)     → stdout JSON → 采集器 → 日志后端
          → (追踪)     → OTLP exporter → 追踪后端

管线上的三个关键点:
  1. 导出必须是异步的(批量 + 后台线程),不能阻塞调用路径
  2. 背压时降采样,绝不反压到业务调用
  3. 每个插件实例的观测开销单独计量,纳入插件配额

10.2 上线清单

[ ] 六项基础指标按 plugin_id 维度采集
[ ] 燃料与内存水位每次调用后采样
[ ] 日志由宿主补齐 ts/plugin_id/version/trace_id
[ ] span 边界划在调用与能力调用两级,属性低基数
[ ] 保留 name 段,trap 堆栈可符号化
[ ] 四条基础告警就绪,trap 按原因分类
[ ] 多租户标签齐全,写入侧有基数与速率上限
[ ] 导出异步化,观测开销纳入插件配额

一句话总结:采集管线必须异步、可降采样、开销可归因;上线前逐项核对指标、日志字段、span 边界、trap 符号化与多租户标签。


延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「wasm」更多文章

  1. WASM 模块测试与模糊测试:从单元测试到差分验证
  2. 浏览器扩展中的 WASM:MV3 约束、CSP 与生命周期实践
  3. WASM 流式编译与实例化优化:从首字节到可执行