Rust + WebAssembly 全链路解析
第12章 WASI:WebAssembly 系统接口
"Least privilege: Every program and every user of the system should operate using the least set of privileges necessary to complete the job." — Jerome Saltzer & Michael Schroeder, The Protection of Information in Computer Systems(1975)
12.1 为什么 WASM 需要 WASI
WASM MVP 规范定义了四类值类型(i32/i64/f32/f64)、线性内存、表、函数导入导出——但没有定义任何 I/O 原语。WASM 模块不能打开文件、不能建立网络连接、不能读取时钟、不能获取随机数、不能向终端输出一行文字。
这不是规范的疏漏——这是刻意的设计选择。WASM 的目标是做一个安全的沙箱执行格式,而 I/O 是安全风险的源头。规范把 I/O 推到"宿主定义"的边界:浏览器用 JS API 提供能力,服务器端需要一个等价的标准化方案。
flowchart TD
subgraph "WASM MVP 规范定义"
A[值类型 i32/i64/f32/f64]
B[线性内存 memory]
C[表 table]
D[函数 import/export]
E[控制流 br/br_if/br_table]
F[结构化控制流 block/loop/if]
end
subgraph "规范未定义(需要宿主提供)"
G[文件 I/O]
H[网络 socket]
I[时钟 / 定时器]
J[随机数]
K[进程环境变量]
L[终端输出]
end
A --- B --- C --- D --- E --- F
G --- H --- I --- J --- K --- L
style G fill:#dc2626,color:#fff
style H fill:#dc2626,color:#fff
style I fill:#dc2626,color:#fff
style J fill:#dc2626,color:#fff
style K fill:#dc2626,color:#fff
style L fill:#dc2626,color:#fff
浏览器中的 WASM 通过 wasm-bindgen 调用 JS API——console.log、fetch、document.querySelector——JS 是宿主,提供一切能力。但当 WASM 跑在浏览器之外(服务器、边缘计算、CLI 工具、嵌入式),没有 JS——谁来提供文件 I/O、网络、时钟、随机数?
WASI(WebAssembly System Interface)的回答是:不是操作系统,而是一组标准化接口。WASI 定义 WASM 模块"可以请求什么能力"以及"宿主如何授权这些能力"。
WASI 和 POSIX 的本质区别:POSIX 是"进程能做什么"——一个 POSIX 进程默认有完整的系统调用权限(文件、网络、进程管理),权限通过操作系统级别的 uid/gid/capability 控制。WASI 是"模块被允许做什么"——一个 WASM 模块默认没有任何权限,每项能力都需要宿主显式授予。
这不是程度上的差异——这是模型上的根本不同。POSIX 的安全模型是"默认开放,事后限制"(进程可以 open 任何文件,除非 DAC/MAC 策略拒绝);WASI 的安全模型是"默认封闭,事先授权"(模块没有任何权限,除非宿主通过句柄传入)。
12.2 能力安全模型
WASI 的核心设计哲学是能力安全(capability security)——模块不能自己获取权限,只能通过宿主赋予的句柄(handle)访问资源。
能力安全的理论基础来自 Dennis 和 Van Horn 1966 年提出的 capability 模型,以及 Saltzer 1974 年提出的最小权限原则。在能力系统中,一个主体对客体的访问权由其持有的 capability 对象决定——capability 不可伪造、不可提升、只能通过已有 capability 的授权获得新 capability。
POSIX vs WASI 的对比
// 示意代码:Directory 代表"宿主交给你的目录句柄",不是某个具体 crate 的类型
// POSIX: 进程自己打开文件——隐式权限
fn read_config() -> String {
let mut f = File::open("/etc/config.json").unwrap(); // 任何进程都能尝试打开
let mut buf = String::new();
f.read_to_string(&mut buf).unwrap();
buf
}
// WASI: 必须从宿主获得文件句柄——显式授权
fn read_config(dir: &Directory) -> String {
let mut f = dir.open("config.json").unwrap(); // 只能访问 dir 下的文件
let mut buf = String::new();
f.read_to_string(&mut buf).unwrap();
buf
}
在 POSIX 中,File::open 的权限来自进程的 uid——如果进程以 root 运行,它可以打开任何文件。一个库函数内部偷偷打开 /etc/passwd,调用者无法阻止——除非使用 seccomp/AppArmor 等外部机制。在 WASI 中,dir.open 的权限来自宿主传入的 Directory 句柄——模块只能访问这个句柄允许的目录;宿主没有交出的目录,模块里的任何代码都不可能访问到。
要注意这道边界划在模块实例这一层,而不是模块内部的每个函数之间。Preview 1 的句柄就是一个 i32 整数,同一个模块里的任何代码都能拿 3 去调 path_open;Rust 的 std::fs::File::open("/data/x") 也不需要谁把句柄传进来,wasi-libc 会自己按路径前缀找到对应的预打开 fd。上面 read_config(dir) 那种"库函数只能碰到调用者递给它的句柄"的写法,是 cap-std 这类库在语言层面提倡的风格,WASI P1 本身并不在模块内部强制它。
graph LR
subgraph "POSIX:隐式权限"
A[进程] -->|uid = root| B[文件系统根 /]
A -->|uid = user| C[用户目录 ~/]
end
subgraph "WASI:显式授权"
D[WASM 模块] -->|fd 3| E["/data/ (只读)"]
D -->|fd 4| F["/tmp/ (读写)"]
D -.->|无句柄| G["/etc/ ✗"]
end
style G fill:#dc2626,color:#fff
style E fill:#10b981,color:#333
style F fill:#10b981,color:#333
能力安全的实际意义
能力安全模型在以下几个场景中尤其关键:
多租户服务器:一个 WASM 运行时同时执行多个用户的代码。在 POSIX 模型中,不同用户的代码共享进程的 uid——要么所有代码权限相同,要么用容器/进程隔离(代价大)。在 WASI 模型中,每个模块实例拥有独立的句柄集——用户 A 的模块只收到指向 /data/user-a/ 的句柄,物理上无法访问用户 B 的数据。
插件系统:应用程序加载第三方插件。在 POSIX 模型中,插件继承了应用进程的全部权限——恶意插件可以读取应用的所有文件。在 WASI 模型中,应用只为插件提供必要的句柄——图片处理插件只收到输入图片的流句柄和输出流的句柄,不能访问文件系统其他部分。
供应链安全:依赖库可能包含恶意代码。在 POSIX 模型中,恶意依赖可以 open("/etc/shadow") 或连接外部服务器泄露数据。在 WASI 模型中,如果应用没有为模块提供文件系统和网络句柄,恶意依赖的这些操作无论藏在哪条代码路径里、有没有被审计发现,执行到时都只会失败——越不过宿主划定的边界。
12.3 WASI Preview 1:fd-based API
WASI Preview 1(导入模块名 wasi_snapshot_preview1,如今官方也称 WASI 0.1)是第一个被广泛实现的版本。它的来历:2019 年 WASI 刚公布时的 API(当时叫 wasi-core)以 wasi_unstable 为导入模块名;2019 年 11 月,WASI 仓库把这一版冻结为 snapshot 0,同时创建了 snapshot 1,也就是 wasi_snapshot_preview1(WebAssembly/WASI 仓库 PR #147「Create snapshot 1」,2019-11-12)。它用 witx 描述接口,受 POSIX 和 CloudABI 影响;如今 P1 的 witx 与文档已从 WASI 仓库主分支移到 wasi-0.1 分支(PR #855,2025-12),主分支只维护 0.2/0.3 的 WIT。Wasmtime 44 至今仍同时实现两者:crates/wasi/src/p0.rs 对应 wasi_unstable,p1.rs 对应 wasi_snapshot_preview1。
P1 的 API 设计模仿 POSIX 的文件描述符(fd)模型——但做了关键的安全约束。
核心 API
| API | POSIX 等价 | 说明 |
|---|---|---|
fd_write(fd, iovs) |
writev() |
向 fd 写入数据(scatter-gather) |
fd_read(fd, iovs) |
readv() |
从 fd 读取数据(scatter-gather) |
fd_close(fd) |
close() |
关闭 fd |
fd_fdstat_get(fd) |
fcntl(F_GETFL) |
获取 fd 类型(文件/目录/socket) |
path_open(fd, dirflags, path, oflags, …) |
openat() |
相对于 fd 打开文件 |
fd_prestat_get(fd) |
无 | 查询 fd 是不是预打开目录、名字多长 |
fd_prestat_dir_name(fd, buf, len) |
无 | 获取预打开目录的路径名 |
fd_filestat_get(fd) |
fstat() |
获取文件元数据 |
args_get() |
argv |
获取命令行参数 |
environ_get() |
environ |
获取环境变量 |
clock_time_get(id, precision) |
clock_gettime() |
获取时间 |
random_get(buf, len) |
getrandom() |
获取随机数 |
poll_oneoff(in, out, n) |
poll() |
同时等待多个 fd 可读写或时钟到期 |
proc_exit(code) |
exit() |
退出进程 |
(完整函数表以 witx 为准,共 46 个,见后文 §12.7。)
关键设计:path_open 的第一个参数是 fd——一个已打开的目录文件描述符。模块不能直接打开 /etc/passwd,必须先有一个指向 /etc/ 的目录 fd,然后 path_open(dir_fd, ..., "passwd")。这就是能力安全的 fd 层实现——fd 不是全局的"打开任何文件"权限,而是"在这个目录下打开文件"的受限权限。
每个 P1 函数都返回一个 16 位的 errno(typenames.witx 里的 $errno 枚举,(@witx tag u16)),真正的结果通过调用方传入的指针写回线性内存。枚举值与 POSIX 同名但编号是 WASI 自己的:success 为 0,acces 2、badf 8、noent 44、perm 63,最后一个 notcapable(76)是 WASI 独有的"句柄权限不够"。Rust 的 io::Error 里看到的 Os { code: 44, .. } 就是这里的编号,而不是 Linux 的 ENOENT(2)。
Wasmtime 44 如何实现 P1
wasmtime-wasi 44 里的 P1 不是一套独立实现,而是架在 P2 实现之上的适配层(crates/wasi/src/p1.rs 模块文档开头就这么说)。wiggle::from_witx! 读入 witx/p1/wasi_snapshot_preview1.witx(p1.rs:858)生成 46 个导入函数的胶水;WasiP1Ctx(p1.rs:142)里装着 P2 的 WasiCtx、资源表,外加一个 WasiP1Adapter,后者维护 P1 语义独有的那张 fd 整数 → 描述符 表(Descriptors,p1.rs:351,一个 BTreeMap<u32, Descriptor>)。这张表在第一次调用时才建:先压入 stdin/stdout/stderr 占 0、1、2,再按顺序压入 P2 的 get_directories() 返回的预打开目录(p1.rs:358-432),所以第一个预打开目录是 fd 3。之后 path_open 这类调用把整数 fd 查成 P2 的 Resource<Descriptor>,转手调用 P2 的 open-at;新 fd 优先复用关闭后回收的编号,否则取当前最大编号加一(p1.rs:436-465)。
Wasmtime CLI 跑 core 模块时默认走的就是这条路径:src/commands/run.rs:1119-1140 里,除非显式 -S preview2=n 或开了 -S threads,都会把 wasmtime_wasi::p0 和 p1 的导入挂上去;只有那两种情况才退回老的 wasi-common 实现。
预打开目录(preopens)
模块启动时没有 fd——宿主通过预打开目录(preopens)机制赋予初始能力。Wasmtime 的 --dir 参数:
# 只写 HOST_DIR:宿主的 /data 以同名 /data 暴露给模块
wasmtime --dir /data my_module.wasm
# 多个目录
wasmtime --dir /data --dir /tmp my_module.wasm
# HOST::GUEST:宿主 /home/user/project/data 映射为模块内的 /data
wasmtime --dir /home/user/project/data::/data my_module.wasm
注意 CLI 的语法就是 --dir HOST_DIR[::GUEST_DIR](Wasmtime 仓库 src/common.rs:91 的 #[arg(long = "dir", value_name = ...)],解析函数 parse_dirs 在同文件 123 行),没有只读/读写的后缀——命令行给出的预打开目录是可读写的。要做只读授权必须走嵌入 API:WasiCtxBuilder::preopened_dir(crates/wasi/src/ctx.rs:291)的第三、四个参数就是 DirPerms 和 FilePerms。这条命令告诉 Wasmtime:"模块可以访问 /data 和 /tmp 这两个目录,此外什么都看不到。"模块启动时,fd 3 指向 /data,fd 4 指向 /tmp——模块通过 fd_prestat_dir_name 获取路径名,通过 path_open(3, ...) 在 /data 下打开文件。
fd_prestat_dir_name 返回预打开目录的路径字符串,模块用它知道"fd 3 对应 /data,fd 4 对应 /tmp"。这是模块获取文件系统访问能力的唯一入口——没有出现在 preopens 中的路径,模块无法访问。
其余几项授权在 CLI 上的默认值也值得记住(src/common.rs、src/commands/run.rs,44.0.0):环境变量默认一个都不传,--env NAME=VAL 设置单个变量、--env NAME 从宿主继承同名变量、-S inherit-env 才是整体继承;stdin/stdout/stderr 在 CLI 上默认继承(-S inherit-stdout=n 等可关),这一点和嵌入 API 里 WasiCtxBuilder 的默认值相反;命令行参数就是模块文件名加上它后面的参数,模块看到的 argv[0] 是 my_module.wasm 这样的文件名。
WASI Preview 1 的架构
flowchart TD
subgraph "WASM 模块"
A["path_open(3, READ, 'config.json')"]
B["fd_read(5, buf)"]
C["fd_write(1, 'hello')"]
end
subgraph "WASI Preview 1 宿主实现"
D[fd 3: 预打开目录 /data]
E[fd 5: 打开的文件 /data/config.json]
F[fd 1: stdout]
end
subgraph "操作系统"
G[文件系统]
H[终端]
end
A -->|"相对 fd 3 打开"| D
D -->|"返回 fd 5"| E
B -->|"读取 fd 5"| E
E -->|"系统调用 read()"| G
C -->|"写入 fd 1"| F
F -->|"系统调用 write()"| H
style D fill:#f59e0b,color:#333
style E fill:#10b981,color:#333
style F fill:#22d3ee,color:#333
wasm32-wasip1 编译目标(原 wasm32-wasi)
Rust 1.35(2019-05)的发布说明以 wasm32-unknown-wasi 之名加入了这个目标,之后通行的名字是 wasm32-wasi,让 Rust 代码可以编译到 WASI Preview 1 环境。注意这个名字已经改了:随着 Preview 2 的出现,旧名过于含糊,Rust 1.78(2024-05)新增了同义的 wasm32-wasip1(Tier 2)和 wasm32-wasip2(当时 Tier 3,1.82 升为 Tier 2),1.81 起使用 wasm32-wasi 会收到警告,1.84(2025-01)把 wasm32-wasi 彻底移除(均见 Rust 仓库 RELEASES.md)。本章凡写 Preview 1 目标的地方,在 1.96 上都是 wasm32-wasip1:
# 安装目标
rustup target add wasm32-wasip1
# 编译
cargo build --target wasm32-wasip1 --release
编译产物是 .wasm 文件,它的导入段引用 wasi_snapshot_preview1 命名空间下的函数——而且只导入实际用到的那些。用 1.96.0 编 §12.5 那个统计行数的小工具,导入段一共 13 项:args_get/args_sizes_get、environ_get/environ_sizes_get、fd_close、fd_fdstat_get、fd_filestat_get、fd_prestat_get、fd_prestat_dir_name、fd_read、fd_write、path_open、proc_exit。任何支持 WASI Preview 1 的运行时(Wasmtime、Wasmer、WAMR)都可以执行这个文件。
12.4 wasi-libc:C 语言的 WASI 运行时
WASI Preview 1 的 API 是低级的 fd 操作——C 程序不直接使用 fd_read/fd_write,它们使用 stdio.h 的 fopen/fread/fwrite/printf。wasi-libc 就是这个桥梁——基于 WASI 原语实现的 C 标准库。
wasi-libc 的主体来自 musl,与 WASI 系统调用打交道的底层取自 cloudlibc——它把 POSIX 的文件 I/O、字符串操作、数学函数、环境变量等功能映射到 WASI Preview 1 的系统调用。对于 WASI 不支持的功能,wasi-libc 要么干脆不提供(fork 这类在头文件里就不声明,编译期直接报错),要么只给需要显式开启的有限模拟(例如 signal 要加 -D_WASI_EMULATED_SIGNAL 并链接 -lwasi-emulated-signal)。
wasi-libc 的层次结构:
┌─────────────────────────────────────┐
│ C 应用代码 │
│ fopen / fread / printf / malloc │
├─────────────────────────────────────┤
│ wasi-libc │
│ fopen → openat(fd, path) │
│ fread → fd_read(fd, iovs) │
│ printf → fd_write(1, format) │
│ malloc → dlmalloc + memory.grow │
├─────────────────────────────────────┤
│ WASI Preview 1 系统调用 │
│ path_open / fd_read / fd_write │
│ clock_time_get / random_get │
├─────────────────────────────────────┤
│ WASM 线性内存 + 宿主 │
└─────────────────────────────────────┘
wasi-libc 对 Rust 的意义:Rust 在 wasm32-wasip1 上的标准库(std)底层依赖 wasi-libc。1.96 的 std 对 WASI 直接复用了 Unix 那套实现:library/std/src/sys/fs/mod.rs 的 any(target_family = "unix", target_os = "wasi") 分支让 std::fs 走 fs/unix.rs,stdio、时间、线程、全局分配器也一样走各自的 unix 实现——于是 std::fs::File::open 先调 wasi-libc 的 open,由 wasi-libc 把绝对路径匹配到某个预打开目录、再发出 path_open;std::io::stdout() 经 libc 的 write 落到 fd_write;堆分配走 wasi-libc 的 malloc(dlmalloc)。只有少数几处绕过 libc、用 wasi crate 直接发系统调用,例如 args/wasip1.rs 的 args_get、random/wasip1.rs 的 random_get。这个 libc 不需要你另装:rustup target add wasm32-wasip1 自带一份 self-contained 的 wasi-libc;只有编 C/C++ 时才需要 wasi-sdk。
12.5 Rust std 在 WASI 上的支持现状
Rust 标准库在 wasm32-wasip1 上的支持是渐进的——有些模块完整可用,有些部分可用,有些完全不可用。下面两张表按 1.96.0 实测(wasmtime 44 运行)。
完整可用的模块
| 模块 | 说明 |
|---|---|
std::fs |
文件读写、目录遍历——经 wasi-libc 落到 path_open/fd_read/fd_write |
std::io |
stdin()/stdout()/stderr()——fd 0/1/2 由宿主提供 |
std::time |
SystemTime/Instant——基于 clock_time_get |
std::env |
args()/vars()——基于 args_get/environ_get |
std::process |
exit()——基于 proc_exit |
std::thread::sleep |
基于 poll_oneoff 等待时钟事件 |
std::collections |
纯数据结构,无系统依赖 |
std::alloc |
全局分配器是 wasi-libc 的 malloc(dlmalloc),向上靠 memory.grow 要内存 |
std::string/std::vec |
纯堆分配,通过 std::alloc 工作 |
不可用的模块
| 模块 | 原因 |
|---|---|
std::net |
Preview 1 没有建连的 socket API——类型和方法都在,但 TcpStream::connect 之类一律返回 Unsupported 错误 |
std::thread::spawn |
wasm32-wasip1 没有创建线程的接口,spawn 失败(Os { code: 58, kind: Unsupported },spawn 随即 panic);Mutex/RwLock 本身可用,只是永远不会真正竞争。需要线程要换 wasm32-wasip1-threads 目标 |
std::process::Command |
Preview 1 没有 fork/exec,output() 等返回 Unsupported 错误 |
std::os::unix |
不存在(编译报 E0433)——WASI 不是 Unix;对应的是 std::os::wasi 和跨平台的 std::os::fd |
部分可用的模块
std::sync::atomic:原子类型照常可用。wasm32-wasip1 默认不开 atomics 目标特性,线性内存也不共享,所以原子操作被编成普通的 load/store,不需要任何内存屏障——单线程里本来也没有第二个观察者。Ordering 的区别只有在 wasm32-wasip1-threads 这类多线程目标上才有实际意义。
实际影响
一个典型的 Rust CLI 工具编译到 wasm32-wasip1:
use std::fs;
use std::io;
use std::env;
fn main() -> io::Result<()> {
let args: Vec<String> = env::args().collect();
if args.len() < 2 {
eprintln!("Usage: {} <file>", args[0]);
std::process::exit(1);
}
let content = fs::read_to_string(&args[1])?; // ✅ 基于 path_open + fd_read
let lines = content.lines().count();
println!("{} has {} lines", args[1], lines); // ✅ 基于 fd_write(1, ...)
Ok(())
}
这段代码在 wasm32-wasip1 上完全可用——它只用到了 std::fs、std::io、std::env、std::process,全部由 WASI Preview 1 支持。实跑(假设 crate 名为 lines):
$ wasmtime --dir . lines.wasm Cargo.toml
Cargo.toml has 6 lines
$ wasmtime lines.wasm Cargo.toml # 没给 --dir
Error: Os { code: 44, kind: NotFound, message: "No such file or directory" }
第二条值得细看:没有任何预打开目录能匹配 Cargo.toml,wasi-libc 在模块内部就以 noent(44)失败了,这次 open 根本没有变成一次发往宿主的 path_open(用 §12.15 的 trace 可以看到,只有 wasi-libc 探测预打开目录用的 fd_prestat_get)。
但如果尝试打开网络连接:
use std::net::TcpStream;
fn main() {
// ⚠️ 能编译,但运行时必然失败
let _stream = TcpStream::connect("127.0.0.1:8080").unwrap();
}
这里有个容易搞错的细节:它是能编译过的。Rust 标准库为 wasm32-wasip1 提供了一份 std::net 的实现(library/std/src/sys/net/connection/wasip1.rs),其中 TcpStream::connect 的函数体只有一句 unsupported()(同文件 62-64 行)。所以失败发生在运行时——unwrap() 在一个 Unsupported 错误上 panic(1.96 + wasmtime 44 实跑:Error { kind: Unsupported, message: "operation not supported on this platform" },随后以 unreachable trap 结束,CLI 退出码 134),而不是编译器报错。指望编译器帮你拦住网络调用是不成立的。
12.6 Wasmtime:从 Rust 宿主运行 WASI 模块
Wasmtime 是字节码联盟(Bytecode Alliance)的旗舰 WASM 运行时,用 Rust 编写。它不只是命令行工具——它提供完整的 Rust API,让任何 Rust 应用可以嵌入 WASM 执行能力。
最简运行
use wasmtime::{Engine, Linker, Module, Result, Store};
use wasmtime_wasi::p1::{self, WasiP1Ctx};
use wasmtime_wasi::{DirPerms, FilePerms, WasiCtxBuilder};
fn main() -> Result<()> {
let engine = Engine::default();
let module = Module::from_file(&engine, "my_module.wasm")?;
// WASIp1 的导入挂到普通的 core wasm Linker 上
let mut linker: Linker<WasiP1Ctx> = Linker::new(&engine);
p1::add_to_linker_sync(&mut linker, |t| t)?;
// 创建 WASI 上下文——控制模块的能力
let wasi = WasiCtxBuilder::new()
.args(&["my_module", "--verbose"])
.env("MODE", "production")
// 参数顺序:宿主路径、模块内看到的路径、目录权限、文件权限
.preopened_dir("./data", "data", DirPerms::READ, FilePerms::READ)?
.inherit_stdout()
.inherit_stderr()
.build_p1();
let mut store = Store::new(&engine, wasi);
let instance = linker.instantiate(&mut store, &module)?;
// 调用 _start(WASI 的 main 函数入口)
let start = instance.get_typed_func::<(), ()>(&mut store, "_start")?;
start.call(&mut store, ())?;
Ok(())
}
(Wasmtime 44 里 WASIp1 的入口是 wasmtime_wasi::p1 模块——add_to_linker_sync(crates/wasi/src/p1.rs:847)/ add_to_linker_async 加导入,WasiCtxBuilder::build_p1()(crates/wasi/src/ctx.rs:481)产出 WasiP1Ctx(crates/wasi/src/p1.rs:142)。本节和 §12.11 的宿主代码都已按 wasmtime/wasmtime-wasi 44.0.0 编译并实跑。更早版本里的 wasmtime_wasi::sync::*、wasmtime_wasi::add_to_linker 这些路径已经不存在了。)
WasiCtxBuilder 的每一行调用都赋予模块一项能力。没有列出的能力,模块就无法使用——这是能力安全在代码层面的直接体现。
能力控制示例
// 场景一:纯计算模块——不需要任何 I/O
let wasi = WasiCtxBuilder::new()
.args(&["compute"])
.inherit_stdout() // 只需要输出结果
.inherit_stderr() // 和错误信息
.build_p1();
// 模块无法访问文件系统——没有 preopened_dir
// 场景二:数据处理模块——需要读写特定目录
// 注意 DirPerms 只有 READ 和 MUTATE 两个位(MUTATE 才是"能在目录里新建/删除")
// 见 crates/wasi/src/filesystem.rs:92 的 bitflags!:const READ = 0b1; const MUTATE = 0b10;
let wasi = WasiCtxBuilder::new()
.preopened_dir("./input", "input", DirPerms::READ, FilePerms::READ)?
.preopened_dir(
"./output",
"output",
DirPerms::READ | DirPerms::MUTATE,
FilePerms::READ | FilePerms::WRITE,
)?
.build_p1();
// 模块只能读 input/ 目录,读写 output/ 目录
// 场景三:受限环境——连 stdout 都不给
let wasi = WasiCtxBuilder::new()
.args(&["silent-worker"])
.build_p1();
// WasiCtxBuilder 默认 stdout/stderr 是"吞掉一切"的空流(ctx.rs 50-53 行的默认值说明,实现是 cli.rs:94-95 的 `empty()`):
// 写入照样成功、内容直接丢弃——完全静默执行
cap-std 沙箱的实现原理
Wasmtime 使用 cap-std crate 实现文件系统的能力安全——cap-std 的 Dir 类型保证所有文件操作都在指定目录内,不能通过 ../ 或符号链接逃逸。
flowchart TD
A["WASM 模块: open('../../etc/passwd')"] --> B[WASI path_open 调用]
B --> C["cap-std: 相对预打开目录 fd 解析<br/>(openat2 RESOLVE_BENEATH / 逐段打开)"]
C --> D{解析过程是否越出该目录?}
D -->|是| E["返回 PermissionDenied ✗"]
D -->|否| F["返回 fd ✓"]
A2["WASM 模块: open('config.json')"] --> B2[WASI path_open 调用]
B2 --> C2["cap-std: 同一条解析路径"]
C2 --> D2{解析过程是否越出该目录?}
D2 -->|否| F2["返回 fd ✓"]
style E fill:#dc2626,color:#fff
style F fill:#10b981,color:#333
style F2 fill:#10b981,color:#333
⚠️ 这里要破除一个很常见的误解:cap-std 并不是"先 canonicalize 拿到绝对路径,再比较它是不是以预打开目录为前缀"。那种"解析完再比对字符串"的做法恰恰有 TOCTOU 窗口——检查和真正 open 之间,路径上的某一段可能被替换成符号链接。cap-std 的做法是把边界约束交给解析动作本身:
- 相对句柄解析:所有路径都相对预打开目录的那个 fd 解析,模块从来拿不到、也用不上绝对路径
- 解析期约束:Linux 5.6+ 上用
openat2的RESOLVE_BENEATH、FreeBSD 13.0+ 上用openat(O_RESOLVE_BENEATH),一次系统调用就让内核保证不越界;其余平台退回到逐段打开路径分量,自己专门处理..和符号链接。越界的访问返回PermissionDenied(见 cap-std 的 README 对这两条路径的说明),到了 P1 这一侧就是 errnoperm(63)——wasmtime --dir .::/data下打开/data/../../etc/passwd,trace 里是path_open(fd=3, "../../etc/passwd") -> Err(Perm),Rust 侧报Os { code: 63, kind: PermissionDenied, message: "Operation not permitted" } - 权限检查:即使路径合法,也要检查操作是否符合预打开时声明的权限——
DirPerms只有READ和MUTATE两个位,FilePerms只有READ和WRITE(crates/wasi/src/filesystem.rs:72的FilePerms与:92的DirPerms两组bitflags!;:103那组是PathFlags,不是权限位)
关键的不可绕过性质因此不是"算完再比对",而是:模块构造的任意路径字符串(/data/../../../etc/passwd、/data/symlink-to-root/etc/passwd)在解析的每一步都被限制在预打开目录之内,越界在解析当场就失败,不存在"先解析出宿主真实路径、再判断要不要拒绝"的中间状态。
12.7 Preview 1 的根本性局限
Preview 1 虽然可用,但有几个根本性问题——不是"缺少功能"的问题,而是"模型不适合扩展"的问题。
基于整数 fd 的类型不安全
fd 在 Preview 1 中是 i32 整数——fd 3 可能是文件、目录、socket——编译时无法区分。错误使用(对 socket 调用 fd_filestat_get)只能运行时检查。
fd 3 → 可能是 regular file
fd 4 → 可能是 directory
fd 5 → 可能是 socket(如果将来加网络 API)
所有 fd 都共享同一个整数空间,所有 fd 操作都可以对所有 fd 调用——类型错误在编译时不可检测。这和 POSIX 一样——但 POSIX 至少有 fstat() 在运行时区分 fd 类型。WASI Preview 1 也有 fd_fdstat_get,但这是运行时检查,不是编译时保证。
fd 模型不支持组合
两个模块想共享一个文件,只能传递 fd 数字——但 fd 数字只在它所属的那个实例里有意义:它是该实例 WASI 上下文里那张描述符表的下标(Wasmtime 里就是前面说的 Descriptors 表),不是宿主操作系统的 fd,而不同的模块实例有各自的 WASI 上下文。在组件模型中,组件之间传递的是有类型的资源句柄,由运行时负责在两边的句柄表之间转移,不再是裸整数。
没有网络 API
Preview 1 基本只有文件 I/O。witx 里确实有 sock_accept/sock_recv/sock_send/sock_shutdown 四个函数(Wasmtime 仓库 crates/wasi/witx/p1/wasi_snapshot_preview1.witx 的 478、490、503、515 行),但它们只能作用在宿主预先打开并传进来的 socket 上——创建 socket 的 socket、connect、bind、listen 一个都没有。主动建连只能靠各运行时自己的非标准扩展(例如 Wasmer 的 WASIX),P1 里没有标准方案。这意味着:
std::net在wasm32-wasip1上建不了连接:connect/bind一律返回Unsupported,只有对宿主预先交进来的 socket 做accept/读写才走得通(同文件 222 行起的accept调的就是sock_accept)- HTTP 客户端/服务器没有标准方案
- 数据库驱动(TCP 连接)无法工作
- 任何需要网络的功能都必须走自定义的导入导出约定
没有异步支持
所有 API 都是同步调用——fd_read 会阻塞调用它的那个宿主线程。P1 能做的只是 poll_oneoff:一次性等待若干 fd 就绪或时钟到期,外加 fdflags 里的 nonblock;它没有能和宿主事件循环协作的异步调用模型。模块本身是单线程的,阻塞意味着整个实例暂停,跑它的宿主线程也被占住。这对服务器场景很不利——一个处理 HTTP 请求的 WASM 模块如果同步等待上游响应,这段时间里它什么也做不了。
单体规范(monolith)
Preview 1 是一个整体版本化的规范:46 个函数挤在同一个 wasi_snapshot_preview1 命名空间里,只能整体冻结、整体换代,文件系统部分想改进,就得连随机数、时钟一起出一个新 snapshot。要澄清的是,这不意味着宿主必须为每个模块链接全部 46 个函数——模块只导入自己用到的那些:一个只调 random_get 的 cdylib,1.96.0 编出来的导入段就只有 wasi_snapshot_preview1::random_get 一项。问题在演进和组合的粒度上,不在链接上。
graph TD
subgraph "Preview 1: 单体规范"
A["wasi_snapshot_preview1<br/>(46 个函数)"] --> B["一个命名空间<br/>只能整体冻结、整体换代"]
end
subgraph "Preview 2: 模块化接口"
C["wasi:random"] --> D["只需 random 的模块<br/>只导入 wasi:random"]
E["wasi:clocks"] --> F["只需时钟的模块<br/>只导入 wasi:clocks"]
G["wasi:filesystem"] --> H["需要文件的模块<br/>导入 wasi:filesystem"]
end
style A fill:#dc2626,color:#fff
style C fill:#10b981,color:#333
style E fill:#10b981,color:#333
style G fill:#10b981,color:#333
(图里的 46 是数出来的:crates/wasi/witx/p1/wasi_snapshot_preview1.witx 里 @interface func 共 46 处。)
这些局限直接推动了 WASI Preview 2 的设计——下一章将详细拆解 Preview 2 如何用 WIT 接口定义和组件模型解决这些问题。
12.8 预打开目录的深层机制
预打开目录是 WASI 能力安全的核心实现机制,值得深入拆解其工作流程。
从宿主到模块:句柄的传递链
当 Wasmtime 处理 --dir /data::/data 参数时,背后执行了一系列操作:
第一步,宿主打开目录。CLI 把 --dir 交给 WasiCtxBuilder::preopened_dir(src/common.rs:327-334),后者调用 cap-std 的 Dir::open_ambient_dir("/data", ambient_authority())(crates/wasi/src/ctx.rs:298),获得一个受限制的目录句柄。这个句柄保证所有后续操作都在 /data 边界内。
第二步,注册到 preopens 列表。这个目录连同模块内看到的名字 "/data" 被记进 WASI 上下文的预打开列表;等模块第一次发起 P1 调用时,P1 适配层建起 fd 表,按顺序给它分配编号——从 3 开始,因为 0/1/2 固定给 stdin/stdout/stderr(p1.rs:358-432)。
第三步,模块查询 preopens。这一步由 wasi-libc 在第一次需要解析路径时完成(实测一个只调 thread::sleep 的程序根本不会扫描):从 fd 3 开始依次调用 fd_prestat_get,是目录就再调 fd_prestat_dir_name 取回名字 "/data",直到某个 fd 返回 badf 为止。trace 里看到的正是 fd_prestat_get(3) -> Dir { pr_name_len: 5 }、fd_prestat_dir_name(3)、fd_prestat_get(4) -> Err(Badf) 这三步。模块现在知道自己有一个指向 /data 的句柄。
第四步,模块使用句柄。Rust 代码里 File::open("/data/config.json"),wasi-libc 按最长前缀匹配到 /data,发出 path_open(3, symlink_follow, "config.json", ...)——这里的 3 就是预打开目录的 fd。Wasmtime 把这个调用转成 P2 的 open-at,最终由 cap-std::Dir 在 /data 目录下安全地打开 config.json,并把新描述符登记为 fd 4。
sequenceDiagram
participant H as 宿主 (Wasmtime)
participant C as cap-std
participant W as WASM 模块
H->>C: Dir::open_ambient_dir("/data")
C-->>H: Dir 句柄 (受限制)
H->>H: 注册 fd 3 → Dir("/data")
Note over W: 模块启动
W->>H: fd_prestat_get(3) / fd_prestat_dir_name(3)
H-->>W: "/data"
W->>H: path_open(3, READ, "config.json")
H->>C: dir.open("config.json")
C->>C: 检查路径在 /data 边界内
C-->>H: File 句柄
H->>H: 注册 fd 4 → File
H-->>W: fd 4
W->>H: fd_read(4, buffer)
H->>C: file.read(buffer)
C-->>H: 数据
H-->>W: 读取的字节数
路径映射的安全语义
--dir /host/path::/guest/path 语法中的路径映射有两层语义:左侧是宿主文件系统上的真实路径,右侧是模块内部看到的虚拟路径。模块只知道虚拟路径——它不知道 /data 实际上映射到宿主的 /home/user/project/data。
这种映射有几个重要的安全属性:
首先,模块无法探测宿主的目录结构。模块只能看到虚拟路径,无法通过任何 WASI API 获取宿主的绝对路径。即使模块尝试 path_open(3, ..., "../../etc/passwd"),cap-std 也会在解析过程中发现越界并拒绝(§12.6 已实测返回 perm)。
其次,不同的模块实例可以有不同的路径映射。同一个运行时中的两个模块实例,fd 3 可以指向完全不同的目录——互不干扰。这是多租户安全的基础。
再次,同一个宿主目录可以被映射多次。宿主可以把它以不同的虚拟路径、不同的权限交出去多次——CLI 的 --dir 只能给读写权限,这要用嵌入 API,例如 preopened_dir("/srv/data", "/ro", DirPerms::READ, FilePerms::READ) 加上 preopened_dir("/srv/data", "/rw", DirPerms::all(), FilePerms::all())。模块看到的是两个独立的预打开目录,无法判断它们是否指向同一个物理目录。
12.9 跨专栏关联:WASI 与 Rust 编译目标
WASI 引入的 wasm32-wasip1 和 wasm32-wasip2 编译目标,和《Rust 编译器与运行时揭秘》专栏第 16 章讨论的目标三元组、目标文件生成与链接机制直接相关:
- 三元组结构:
wasm32-wasip1中wasm32是架构(32 位 WASM),wasip1在目标规格里拆成os = "wasi"加env = "p1"(compiler/rustc_target/src/spec/targets/wasm32_wasip1.rs:21-22)。编译器据此选择标准库实现、链接器行为、条件编译cfg - std 实现分支:Rust 标准库把 WASI 特有的实现按子系统拆开放在
library/std/src/sys/下——args/wasip1.rs、env/wasi.rs、random/wasip1.rs、net/connection/wasip1.rs、io/error/wasi.rs、pal/wasi/;而std::fs、stdio、时间、线程则通过any(target_family = "unix", target_os = "wasi")这类条件复用 Unix 实现,经 wasi-libc 落到path_open/fd_read/fd_write(§12.4) - 条件编译:
target_os = "wasi"选中 WASI 的实现,再用target_env = "p1"/"p2"/"p3"区分代际(如sys/net/connection/mod.rs在 p1 上用wasip1.rs那份只能 accept 不能建连的实现,在 p2/p3 上走真正的 socket 实现)
从编译器视角看,WASI 不是一个"精简的 Linux"——它是一个全新的操作系统抽象层,有自己的文件描述符语义、自己的安全模型、自己的能力传递机制。Rust 标准库必须为它单独选择实现:凡是 WASI 语义与 Unix 不同的地方(参数、环境、随机数、网络、错误码)都有独立的 wasi/wasip1 文件,只有 wasi-libc 已经把语义抹平的部分才复用 unix 的代码。
12.10 与其他沙箱技术的对比
WASI 不是唯一的沙箱方案——Linux 有 seccomp/namespaces,容器有 Docker/Podman,语言级有 V8 Isolate。WASI 在这个谱系中的位置值得分析。
WASI vs Linux seccomp
seccomp 通过系统调用过滤限制进程的能力——进程以 BPF 程序的形式把过滤规则装进内核(设置了 no_new_privs 的普通进程也能装),规则一旦装上只能再叠加、不能撤销。WASI 的能力控制是应用级的——宿主在创建每个实例时为它单独配置,不同实例可以拿到完全不同的能力集。
seccomp 的一个根本局限:它只能看系统调用号和整数参数,看不到指针指向的路径字符串,因此无法区分"打开 /data/config.json"和"打开 /etc/passwd"——两者都调用 openat。要实现路径级的过滤,需要结合 landlock 或 AppArmor。WASI 天然支持路径级控制——预打开目录就是路径级白名单。
WASI vs 容器(Docker/Podman)
容器通过 Linux namespace 实现隔离——文件系统 namespace、网络 namespace、PID namespace。容器提供的是进程级隔离——容器内的进程有完整的 POSIX 环境,只是"看到"的文件系统和网络是虚拟的。
WASI 提供的是模块级隔离——WASM 模块没有完整的 POSIX 环境,只有 WASI 定义的有限接口。容器的隔离粒度是"进程",WASI 的隔离粒度是"模块实例"——同一个宿主进程里可以并存成百上千个互相隔离的实例。容器启动一个进程的开销是毫秒级(即使是最轻量的容器),WASI 实例化一个模块的开销是微秒级——因为不需要创建 namespace、不需要启动新进程、不需要加载动态链接库。
WASI vs V8 Isolate
V8 Isolate 是 Cloudflare Workers 使用的沙箱方案——每个 Isolate 是一个独立的 V8 执行环境,有独立的堆和栈。V8 Isolate 的优势是启动远快于进程和容器,局限是它原生只跑 JavaScript:Rust/C/Go 写的逻辑要先编译成 WASM,再在 isolate 里借 V8 的 WebAssembly 引擎运行(Workers 本身支持这样做),系统能力则由 JS 侧的 API 提供,而不是 WASI。
WASI 支持多种语言编译到 WASM——Rust、C、C++、Go 都可以,Python 也有 CPython 官方维护的 WASI 构建(Pyodide 走的是 Emscripten、面向浏览器,不是 WASI)。但 WASI 的启动要多做几件事:验证 WASM 二进制、分配并初始化线性内存与表、解析并类型检查全部导入。这些都可以提前做掉——Wasmtime 的 docs/examples-fast-instantiation.md 列出了三根杠杆:pooling allocator(预分配内存/表池,文档 11 行起)、Config::memory_init_cow(用写时复制映射初始化线性内存,35 行)、InstancePre(把导入解析和类型检查提到实例化之前,38 行起)。验证和编译属于 Module 的创建阶段,可以用预编译挪走(§12.11);实例化本身在默认配置下就已是亚毫秒级——本机(Xeon Gold 6240,Wasmtime 44.0.0 默认 Engine)对 §12.11 那个 158 KB 的模块实测约 0.11 ms,改用 InstancePre 后约 0.07–0.08 ms,比启动一个容器低几个数量级。
这些对比不是要分出胜负——而是帮助工程师根据场景选择合适的沙箱方案。如果需要完整的 POSIX 环境和系统级隔离,用容器。如果只需要 JavaScript 沙箱且追求极致启动速度,用 V8 Isolate。如果需要多语言支持、模块级能力控制、微秒级启动,用 WASI。
12.11 实战:用 Wasmtime 嵌入 WASI 执行
理论之外、看一个完整的端到端例子——从 Rust 源码到沙箱执行。这个例子展示 WASI 能力控制在代码层面的精确性。
待沙箱化的 Rust 模块
假设一个数据处理任务:从 input.json 读取 JSON、计算统计信息、写入 output.json。这个模块要在严格的沙箱里跑——不能访问其他文件、不能联网、不能调用 shell。
// guest/src/main.rs
use std::fs;
use serde_json::Value;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let raw = fs::read_to_string("/data/input.json")?;
let data: Value = serde_json::from_str(&raw)?;
let count = data.as_array().map(|a| a.len()).unwrap_or(0);
let result = serde_json::json!({"record_count": count});
fs::write("/data/output.json", result.to_string())?;
Ok(())
}
编译:
cargo build --target wasm32-wasip1 --release
# 产出: target/wasm32-wasip1/release/processor.wasm
嵌入式宿主代码
宿主程序加载 WASM 并精确控制它的能力:
// host/src/main.rs
use wasmtime::{Engine, Linker, Module, Result, Store};
use wasmtime_wasi::p1::{self, WasiP1Ctx};
use wasmtime_wasi::{DirPerms, FilePerms, WasiCtxBuilder};
fn main() -> Result<()> {
let engine = Engine::default();
let module = Module::from_file(&engine, "processor.wasm")?;
let mut linker: Linker<WasiP1Ctx> = Linker::new(&engine);
p1::add_to_linker_sync(&mut linker, |t| t)?;
// 关键:精确控制能力——只有这一个目录,读写权限也写死在这里
let wasi = WasiCtxBuilder::new()
.preopened_dir("./sandbox-data", "data", DirPerms::all(), FilePerms::all())?
.inherit_stdout()
.inherit_stderr()
.build_p1();
let mut store = Store::new(&engine, wasi);
let instance = linker.instantiate(&mut store, &module)?;
let start = instance.get_typed_func::<(), ()>(&mut store, "_start")?;
start.call(&mut store, ())?;
Ok(())
}
安全边界的验证
这个例子的安全边界严格:
- 模块只能读写
./sandbox-data目录、看不到宿主其他文件 - 网络:P1 本来就没有建连接口;
inherit_network()只影响 P2 的wasi:sockets,这里也没调用 - 没有
inherit_args()——模块拿不到宿主命令行参数 - 没有
inherit_env()——模块看不到宿主环境变量
如果模块代码尝试 fs::read_to_string("/etc/passwd"),得到的是 NotFound(errno 44)——它甚至没有到达宿主:/etc/passwd 匹配不上唯一的预打开目录 data,wasi-libc 在模块内部就失败了。要是写成 /data/../../etc/passwd,请求会以 path_open(3, "../../etc/passwd") 发到宿主,被 cap-std 拦下,得到 PermissionDenied(errno 63)。两种情况都不是 OS 权限在起作用。
把上面两段代码实跑一遍(guest 用 1.96.0 编 wasm32-wasip1、依赖 serde_json 1.x;sandbox-data/input.json 放 [1,2,3,4]),sandbox-data/output.json 得到 {"record_count":4}。guest 里写的是绝对路径 /data/...,而预打开目录名是 data,wasi-libc 做前缀匹配时两种写法都能对上(实测 data 与 /data 均可)。
启动开销的构成
上面这条流程的冷启动开销分布,量级关系是稳定的;绝对值随机器、模块大小、Wasmtime 配置差异很大。下表的数字是本机在宿主代码里加 Instant 计时量的(Xeon Gold 6240,Wasmtime 44.0.0 默认 Engine,guest 为上面 158 KB 的 processor.wasm,三次取中),只作量级参考:
| 阶段 | 相对量级 | 开销来源 |
|---|---|---|
Module::from_file 加载 + 验证 + 编译 |
最大的一块,约 46 ms | 解析、验证、Cranelift 编译到机器码 |
Linker::instantiate 实例化 |
约 0.11 ms,比加载阶段低两个多数量级 | 分配线性内存/表、解析导入 |
| 业务逻辑执行(小 JSON) | 取决于业务本身,本例约 4.5 ms | 纯计算 + 几次 WASI 调用 |
决定冷启动的几乎全是第一行。 把编译结果预先做好(Module::serialize 产出的 cwasm,之后用 Module::deserialize 加载),加载阶段就只剩反序列化和 mmap——同一模块实测从约 46 ms 降到约 0.63 ms;再叠上 docs/examples-fast-instantiation.md 里那三根杠杆(pooling allocator、Config::memory_init_cow、InstancePre),实例化还能从默认的约 0.1 ms 继续往下压。这是边缘平台把冷启动做到毫秒以内的通用做法。
12.12 WASI 在生产中的反模式
WASI 看起来直观、但实际部署中常踩坑。
反模式 1:把 inherit 当默认
// ❌ 反模式:图省事
let wasi = WasiCtxBuilder::new()
.inherit_stdio()
.inherit_args()
.inherit_env()
.preopened_dir("/", "/", DirPerms::all(), FilePerms::all())?
.build_p1();
这样配置等于让 WASM 模块拥有宿主的全部能力——根目录可读写、命令行参数可见、环境变量可见。失去了 WASI 的核心价值。默认应该是"什么都不给"、按需添加。
反模式 2:忽视 cap-std 的 ambient authority
// ❌ 反模式:宿主先用 std::fs 把文件打开,再想办法"塞"给模块
let f = std::fs::File::open("/data/file.txt")?; // 走宿主完整权限
let wasi = WasiCtxBuilder::new()
// 模块还是无法访问 f、但宿主已经用 ambient authority 打开了它
.build_p1();
正确做法:把路径交给 WasiCtxBuilder::preopened_dir,由它内部调用 cap_std::fs::Dir::open_ambient_dir 拿到受限目录句柄(见 crates/wasi/src/ctx.rs)。ambient_authority() 是 cap-std 里显式的"我承认这一步用了宿主的环境权限"标记——凡是出现它的地方都该被审计。
反模式 3:用大目录代替细粒度
// ❌ 给 /home 整个目录、希望模块只读其中一个子目录
.preopened_dir("/home", "home", DirPerms::READ, FilePerms::READ)?
模块得到 /home 后能访问 /home/.ssh/ 等敏感目录。应该精确到需要的子目录:
// ✅ 只给具体的工作目录
.preopened_dir("/home/alice/workspace", "workspace", DirPerms::READ, FilePerms::READ)?
反模式 4:误用同步 I/O 阻塞 runtime
WASI Preview 1 的所有 I/O 都是同步的——fd_read 会阻塞 WASM 模块。如果宿主用单线程 runtime 跑多个 WASM 实例、一个实例的阻塞会拖垮全部。
// ❌ 单线程 runtime + 多 WASM 实例
let store = Store::new(&engine, wasi);
// 多个 instance 共享 store——一个 fd_read 阻塞、全停
正确做法:每个 WASM 实例独立 store、独立线程或 async runtime(Wasmtime 的 async API + tokio)。
反模式 5:信任 WASM 模块的输入
WASM 模块自己说的话不可信——它可能是恶意编译的。常见错误:
- 模块返回的 path、宿主直接当真实路径用
- 模块要求的内存大小、宿主无限制地 grow
- 模块上报的"我执行完了"、没有 timeout 兜底
每一项都要宿主做防御性检查——WASI 边界只防文件系统、不防业务逻辑。
反模式 6:忘了 deterministic execution 不是默认
WASI 默认提供真实时间 (clock_time_get) 和真实随机数 (random_get)——这让相同输入的执行结果不一致。需要确定性执行(如区块链)的场景、要用 WasiCtxBuilder 的自定义 clock 和 random:
let wasi = WasiCtxBuilder::new()
.wall_clock(fixed_wall_clock()) // 固定墙上时间
.monotonic_clock(fixed_monotonic()) // 固定单调时钟
.secure_random(seeded_rng(42)) // 固定种子的 RngCore
.insecure_random(seeded_rng(43)) // 非安全随机源也要一并固定
.build_p1();
// fixed_wall_clock/fixed_monotonic 需自行实现 HostWallClock / HostMonotonicClock(crates/wasi/src/clocks.rs)
确定性是有意识的选择、不是 WASI 的默认行为。
反模式 7:不限制 fuel/instructions
WASM 模块可以无限循环——WASI 不会自动停止它。生产环境必须设置 fuel limit:
let mut config = Config::new();
config.consume_fuel(true);
let engine = Engine::new(&config)?;
let mut store = Store::new(&engine, wasi);
store.set_fuel(1_000_000)?; // 燃料上限(旧版的 add_fuel 已被 set_fuel 取代)
没有 fuel limit、一个恶意或 buggy 模块会让整个 runtime 卡死。
12.13 WASI 演化路径与生产选型
WASI 不是单一规范、而是持续演化的标准家族。不同版本对应不同生态成熟度——选型要看版本和需求匹配。
三个 WASI 版本的能力对比
flowchart LR
classDef p1 fill:#fed,stroke:#c60
classDef p2 fill:#def,stroke:#06c
classDef p3 fill:#efe,stroke:#080
P1[Preview 1<br/>2019]:::p1 --> P2[Preview 2 / 0.2<br/>2024]:::p2
P2 --> P3[Preview 3 / 0.3<br/>2026-06]:::p3
P1 --> P1A[fd-based API]
P1 --> P1B[文件 I/O]
P1 --> P1C[无网络]
P2 --> P2A[WIT 接口]
P2 --> P2B[组件模型]
P2 --> P2C[wasi:http / wasi:sockets]
P3 --> P3A[原生异步 / async]
P3 --> P3B[future / stream 取代 wasi:io 轮询]
生产选型矩阵
| 场景 | 推荐版本 | 理由 |
|---|---|---|
| CLI 工具 / 数据处理 | Preview 1 | 成熟、工具链完整、wasm32-wasip1 稳定 |
| 边缘函数 / Serverless | Preview 2 | wasi:http 标准化、平台支持好 |
| 插件系统 | Preview 2 | 组件模型让插件接口可演化 |
| 实验性 / 探索 | Preview 3(WASI 0.3) | 异步原生支持;Rust 的 wasm32-wasip3 仍是 Tier 3 |
| 区块链 / 智能合约 | 通常不用 WASI,走自定义宿主接口 | 需要严格控制确定性,WASI 的时钟、随机数本身就是不确定源 |
不同 runtime 的支持情况
| Runtime | Preview 1 | Preview 2 | 主用场景 |
|---|---|---|---|
| Wasmtime | ✓ 完整 | ✓ 完整 | 通用、参考实现 |
| Wasmer | ✓ 完整 | 有限 | 多语言嵌入 |
| WAMR | ✓ 完整(libc-wasi) | 有限 | 嵌入式 / IoT |
| Spin | 以组件为主 | ✓ 主推 | Serverless |
| wasmCloud | 以组件为主 | ✓ 主推 | 分布式 |
除 Wasmtime 一行可对照其源码外,其余几行的 P2 支持程度变化很快,选型前以各项目当期文档为准。选 runtime 不只看性能——看你需要的 WASI 版本是否支持成熟。
迁移路径建议
已经在 Preview 1 上的项目、迁移到 Preview 2 的渐进路径:
- 先包成组件,代码不动:用
wasm-tools component new配合 Wasmtime 仓库提供的wasi_snapshot_preview1适配器模块(crates/wasi-preview1-component-adapter),把现有 P1 模块包成一个 P2 组件——适配器把 P1 的 46 个函数翻译成 P2 接口调用 - 换编译目标:Rust 项目改用
wasm32-wasip2,std::fs等照常可用,std::net也能真正建连 - 新功能直接用 P2 接口:HTTP 等新需求用
wasi:http,这类接口只能在组件里导入,core 模块用不了 - 按需替换底层调用:直接使用
wasi:filesystem等接口的代码逐步取代 fd 风格的调用
过渡期里,P1 模块经适配器在 P2 宿主上运行、P2 组件原生运行,两种产物可以并存——这是 WASI 演化路径友好的体现。
当前阶段(2026)的实用建议
- 生产稳定项目:Preview 1 + Wasmtime——成熟度最高
- 新项目:Preview 2 + Wasmtime——面向未来、工具链已可用
- edge / serverless:跟随平台(Spin / Cloudflare Workers)的版本选择
- 观望者:保持关注、不必急着切——WASI 0.3.0 已于 2026-06-11 发布(WebAssembly/WASI 仓库
docs/Release.md与 v0.3.0 release,此后按两月一次发 0.3.x,0.3.1 于 2026-08-11 发布),但本专栏对照的 Wasmtime 44 里实现的还是发布前的候选版,WIT 包版本写作wasi:cli@0.3.0-rc-2026-03-15(crates/wasi/src/p3/wit/deps/cli.wit:1);Rust 侧wasm32-wasip3在 1.96 仍是 Tier 3(compiler/rustc_target/src/spec/targets/wasm32_wasip3.rs里tier: Some(3)),平台文档src/doc/rustc/src/platform-support/wasm32-wasip3.md注明在 WASIp3 获批前标准库不会真的去用 WASIp3 API(该说明写于 2025-10-01,1.96 未更新)
12.14 WASI 应用的安全审计
WASI 提供了能力安全的基础——但如何设计具体应用的能力授权才是工程实践。一个错误授权的 WASI 应用可能让"安全沙箱"变成纸糊的——必须有明确的审计流程。
12.14.1 能力清单驱动的审计
WASI 应用的安全审计起点是显式能力清单——明确列出应用需要的所有能力,运行时按清单授权:
graph TD A["WASI 应用"] --> B["申明的能力清单"] B --> C["文件系统:/tmp/data 读写"] B --> D["网络:example.com:443 只读"] B --> E["环境变量:APP_CONFIG"] B --> F["时钟:单调时间"] B --> G["随机数:仅 OsRng"] C --> H["运行时授权检查"] D --> H E --> H F --> H G --> H style B fill:#10b981,color:#333 style H fill:#f59e0b,color:#333
清单的格式各平台不同——例如 Spin 在 spin.toml 里按组件声明可访问的文件与外连主机。审计时核对:
- 清单是否最小化——能减能力的不要多授权
- 清单是否完整——应用实际需要的都列出
- 路径是否精确——
preopened_dir(/tmp, /)vspreopened_dir(/tmp/sandbox, /)安全等级差很多
12.14.2 威胁建模框架
每个 WASI 应用都应该做 STRIDE 威胁建模:
| 威胁类型 | WASI 场景 | 缓解 |
|---|---|---|
| Spoofing(身份冒充) | 应用伪造调用方身份 | WASI 不传递调用上下文,依赖宿主验证 |
| Tampering(篡改) | 应用修改超出权限的文件 | preopen 路径精确限制 + 只读权限 |
| Repudiation(否认) | 应用否认操作 | 宿主侧审计日志(不依赖 WASI) |
| Information Disclosure | 应用读到不该看的数据 | 严格 preopen + 不 inherit_env |
| DoS | 应用无限循环 | fuel / epoch 中断 |
| EoP(提权) | 沙箱逃逸 | runtime 漏洞修复 + 最新版本 |
实战中最容易出问题的是 Information Disclosure——开发者为了图方便用 inherit_env() 把宿主所有环境变量传给 WASI 应用,应用一旦把环境打进日志,AWS_SECRET_KEY 之类就跟着泄漏了。
12.14.3 审计清单实战
生产前的审计 checklist:
flowchart TD A["WASI 应用准备上线"] --> B["1. 列能力清单"] B --> C["2. 检查 preopen 路径最小化"] C --> D["3. 检查环境变量授权"] D --> E["4. 检查网络授权"] E --> F["5. 配置 fuel/epoch 限制"] F --> G["6. 配置内存上限"] G --> H["7. 检查 runtime 版本"] H --> I["8. 跑 fuzz 压测"] I --> J["9. 渗透测试"] J --> K["上线"] style K fill:#10b981,color:#333
每条都有可量化的检查点——例如"环境变量授权"应该写出明确的允许列表:
let allowed_env = ["APP_CONFIG", "LOG_LEVEL"];
let mut wasi = WasiCtxBuilder::new();
for key in allowed_env {
if let Ok(value) = std::env::var(key) {
wasi.env(key, &value);
}
}
// 不调用 inherit_env() —— 不传所有变量
let wasi_ctx = wasi.build_p1();
12.14.4 第三方 WASI 模块的信任评估
如果 WASI 模块来自第三方(市场上的插件、用户上传的代码),信任评估更严格:
| 维度 | 检查项 |
|---|---|
| 代码来源 | 签名验证(cosign / sigstore) |
| 二进制完整性 | SHA-256 校验 |
| 静态分析 | wasm-tools dump 看导入项 |
| 行为审计 | 在 staging 环境 trace 系统调用 |
| 版本固定 | 不接受隐式升级 |
对组件用 wasm-tools component wit my_module.wasm、对核心模块用 wasm-tools print my_module.wasm | grep '(import',都能列出它声明的全部导入——审计员检查这些导入是否合理。如果一个声称是"图像处理"的模块导入了 wasi:http/outgoing-handler,就该拒绝或要求解释——它具备往外发数据的能力。
12.15 WASI 系统调用追踪与调试
WASI 应用的调试比传统应用复杂——错误经常发生在 host 函数边界,而不是应用代码内。系统化的追踪手段是诊断关键。
12.15.1 strace 风格的 WASI 调用追踪
Wasmtime 的 CLI 日志由 WASMTIME_LOG 环境变量控制(docs/cli-logging.md),把 wasmtime_wasi 这个 target 调到 trace 就能看到每次 WASI 调用:
WASMTIME_LOG=wasmtime_wasi=trace wasmtime my_app.wasm 2>&1 | head -50
对 §12.5 的行数统计工具执行 WASMTIME_LOG=wasmtime_wasi=trace wasmtime --dir .::/data lines.wasm /data/Cargo.toml,实际输出节选如下(去掉了时间戳,长行截断):
TRACE wiggle abi{module="wasi_snapshot_preview1" function="fd_prestat_get"}: wasmtime_wasi::p1::wasi_snapshot_preview1: fd=Fd(3)
TRACE wiggle abi{module="wasi_snapshot_preview1" function="fd_prestat_get"}: wasmtime_wasi::p1::wasi_snapshot_preview1: result=Ok(Dir(PrestatDir { pr_name_len: 5 }))
TRACE wiggle abi{module="wasi_snapshot_preview1" function="path_open"}: wasmtime_wasi::p1::wasi_snapshot_preview1: fd=Fd(3) dirflags=Lookupflags(SYMLINK_FOLLOW) path=*guest 0x103230/10 oflags=Oflags(0x0) ...
TRACE wiggle abi{module="wasi_snapshot_preview1" function="path_open"}: wasmtime_wasi::p1::wasi_snapshot_preview1: result=Ok(Fd(4))
TRACE wiggle abi{module="wasi_snapshot_preview1" function="fd_read"}: wasmtime_wasi::p1::wasi_snapshot_preview1: fd=Fd(4) iovs=*guest 0xffd78/1
TRACE wiggle abi{module="wasi_snapshot_preview1" function="fd_read"}: wasmtime_wasi::p1::wasi_snapshot_preview1: result=Ok(76)
TRACE wiggle abi{module="wasi_snapshot_preview1" function="fd_close"}: wasmtime_wasi::p1::wasi_snapshot_preview1: fd=Fd(4)
每次调用两行:先是参数,后是结果(Ok(..) 或 Err(Error { inner: Perm }) 这样的 errno)。注意路径参数只打印成 guest 指针和长度,看不到字符串本身。
追踪揭示应用真实的访问模式——如果应用声称"只读 data.txt",trace 里却出现了别的 path_open,或者一连串 Err(Perm) 的越界尝试,能立即捕捉异常行为。
12.15.2 自定义 host 实现包装
更精细的追踪是包装 WasiCtx 的 host 实现——记录每次调用的参数、返回值、耗时:
use std::sync::{Arc, Mutex};
use wasmtime::component::ResourceTable;
use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView};
struct AuditingState {
ctx: WasiCtx,
table: ResourceTable,
log: Arc<Mutex<Vec<String>>>,
}
// Wasmtime 44 的 WasiView 只有一个方法,返回把 ctx 和资源表打包在一起的视图
impl WasiView for AuditingState {
fn ctx(&mut self) -> WasiCtxView<'_> {
WasiCtxView { ctx: &mut self.ctx, table: &mut self.table }
}
}
真正的"每调用一条日志"要包在具体接口的 Host trait 实现上(或者干脆先用上面的 WASMTIME_LOG)——WasiView 本身只负责把状态交出去。
生产中这种包装通常仅在 staging 环境启用——每个 WASI 调用都多出一次格式化与写日志,热路径上的相对开销会很可观。具体放大多少取决于日志后端和调用频率,要上生产先自己压一遍。
12.15.3 错误信息的解读
WASI 错误码对应特定语义——理解这些码有助于快速定位。P1 返回的是 typenames.witx 里的 errno,Rust 侧 io::Error 的 code 就是它的编号;P2 的 wasi:filesystem 换成了 error-code 枚举,名字不同但一一对应:
| P1 errno(编号) | P2 error-code(wasi:filesystem@0.2.6) |
常见原因 |
|---|---|---|
noent(44) |
no-entry |
文件不存在;或者路径根本匹配不上任何预打开目录(wasi-libc 在模块内就报这个,§12.5) |
perm(63) |
not-permitted |
cap-std 拦下了越出预打开目录的路径(§12.6) |
acces(2) |
access |
宿主操作系统层面的权限不足 |
notcapable(76) |
—(P2 去掉了 rights 机制) | P1 的 rights 位不允许该操作 |
badf(8) |
bad-descriptor |
fd 已关闭或从未分配 |
loop(32) |
loop |
符号链接成环 |
again(6) |
would-block |
非阻塞 fd 暂无数据,调用方应 poll_oneoff |
排查时先分清错误在哪一侧产生:noent 可能根本没到宿主,perm 则是宿主拒绝的——结合 trace 看有没有对应的 path_open 就能判断。
12.15.4 集成到生产可观测性
WASI 调用应该作为指标暴露——配合第 18 章的可观测性框架:
// 包装每个 WASI 调用,发送到 Prometheus(metrics 0.24 的宏写法)
fn instrumented_call<R>(name: &str, f: impl FnOnce() -> R) -> R {
let start = std::time::Instant::now();
let result = f();
metrics::histogram!("wasi_call_duration_seconds", "name" => name.to_string())
.record(start.elapsed().as_secs_f64());
metrics::counter!("wasi_call_total", "name" => name.to_string()).increment(1);
result
}
在 Grafana 看板中观察 WASI 调用的频率、延迟、错误率——异常模式(突然增多的 perm/noent、连接突然超时)能在用户感知前发现问题。
12.16 从 Linux 应用移植到 WASI
把现有 Linux/POSIX 应用移植到 WASI 是常见需求——CLI 工具、数据处理脚本、服务端 binary 等。但 WASI 不等于 Linux——理解差异和移植路径是关键工程技能。
12.16.1 移植难度的三档分类
graph TD A["Linux 应用类型"] --> B["简单:纯计算 + 文件 IO"] A --> C["中等:网络 + 子进程"] A --> D["困难:信号 + IPC + 系统调用"] B --> B1["sed / awk / 哈希工具<br/>基本编译即可"] C --> C1["简单 HTTP 服务 / curl<br/>需改用 wasi:http"] D --> D1["sshd / cron / docker<br/>WASI 不支持,重写"] style B fill:#10b981,color:#333 style C fill:#f59e0b,color:#333 style D fill:#dc2626,color:#fff
12.16.2 简单应用:cargo build 即可
C 写的工具(如 SHA-256 计算器):
# 假设有 sha256sum.c(需要 wasi-sdk 提供的 sysroot)
clang --target=wasm32-wasip1 --sysroot=$WASI_SYSROOT sha256sum.c -o sha256sum.wasm
wasmtime --dir=. sha256sum.wasm myfile.txt
Rust 类似:
cargo build --target wasm32-wasip1 --release
wasmtime --dir=. target/wasm32-wasip1/release/my_tool.wasm input.txt
注意 --dir=. 显式 preopen 当前目录——WASI 默认 deny-all。
12.16.3 中等应用:网络 + 异步
需要网络的应用必须用 WASI Preview 2 + wasi:http 或 wasi:sockets:
// Preview 2 的 HTTP client(wasi crate 0.14,目标 wasm32-wasip2;已编译通过)
use wasi::http::outgoing_handler;
use wasi::http::types::*;
fn fetch(authority: &str, path: &str) -> Result<Vec<u8>, String> {
let req = OutgoingRequest::new(Headers::new());
req.set_scheme(Some(&Scheme::Https)).map_err(|e| format!("{:?}", e))?;
req.set_authority(Some(authority)).map_err(|e| format!("{:?}", e))?;
req.set_path_with_query(Some(path)).map_err(|e| format!("{:?}", e))?;
req.set_method(&Method::Get).map_err(|e| format!("{:?}", e))?;
let future_resp = outgoing_handler::handle(req, None).map_err(|e| format!("{:?}", e))?;
// ... 在 future_resp 上 subscribe + 轮询,拿到 IncomingResponse 后读 body ...
todo!()
}
URL 要拆成 scheme、authority、path 三段分别设置;set_path_with_query 只接受路径加查询串。
子进程(fork/exec)在 WASI 中不支持——POSIX 这部分被故意排除。需要子进程功能的应用必须重新设计:
| 原 POSIX 模式 | WASI 替代 |
|---|---|
system("curl ...") |
用 wasi:http 直接调用 |
popen("grep ...") |
在 WASM 内用 regex crate |
fork() 后子进程处理 |
单线程内的异步任务,或拆成多个实例由宿主调度(线程要靠 wasm32-wasip1-threads) |
12.16.4 困难应用:放弃 WASI 的判定
某些 Linux 应用根本不能移植到 WASI——必须有标准说"不":
flowchart TD
A["要移植 Linux 应用"] --> B{"用了 fork/exec?"}
B -->|是| C["不能移植"]
B -->|否| D{"用了 signal handlers?"}
D -->|是| C
D -->|否| E{"用了 TCP/UDP socket?"}
E -->|是| F["改用 P2 的 wasi:sockets"]
E -->|否| G{"用了 mmap?"}
G -->|是| C
G -->|否| H{"用了 ioctl?"}
H -->|是| C
H -->|否| I["可以移植"]
style C fill:#dc2626,color:#fff
style I fill:#10b981,color:#333
不能移植的典型场景:
- shell:依赖 fork/exec 启动子进程
- 守护进程:依赖信号处理(SIGHUP 等)
- 网络代理:依赖 raw socket 控制 TCP 包
- 数据库引擎:依赖 mmap、O_DIRECT、fsync 语义
这些应用要么继续在传统容器中运行,要么重写架构(例如把 shell 的子进程模型改为单进程协程)。
12.16.5 常见移植坑
graph LR A["移植坑"] --> B["1. 默认无文件系统访问"] A --> C["2. 时区数据库缺失"] A --> D["3. argv[0] 行为不同"] A --> E["4. panic 退出码"] A --> F["5. 浮点精度差异"] style A fill:#f59e0b,color:#333
每个坑的快速诊断:
- 文件系统:报
NotFound(errno 44)而文件明明存在 → 多半是没有匹配的预打开目录,加--dir= - 时区:
chrono::Local::now()返回 UTC(chrono 0.4.45 实测,即使用--env TZ传了时区、预打开了/usr/share/zoneinfo也一样)→ 把时区作为参数显式传入,自己换算 - argv[0]:
wasmtimeCLI 传的是模块文件名(如my_tool.wasm),不是my_tool→ 帮助信息里的命令名直接写死,或用wasmtime --argv0指定 - exit code:panic 在
wasm32-wasip1上以unreachabletrap 结束,wasmtimeCLI 的退出码是 134(实测),不是普通的 1;main返回Err则是proc_exit(1)。想要可控的退出码,用Result或process::exit(n),别靠 panic - 浮点:
sin/cos这类超越函数在 WASI 上用的是 musl 系的数学库实现,与原生平台(如 glibc)的 libm 在末位可能不同 → 关键场景显式校验
12.16.6 移植决策树
flowchart TD
A["要把 Linux 应用搬到 WASI?"] --> B{"应用核心是计算还是 IO?"}
B -->|纯计算| C["✓ 强烈推荐"]
B -->|计算+文件| D["✓ 推荐"]
B -->|计算+网络| E{"网络模式?"}
B -->|系统编程| F["✗ 不推荐"]
E -->|HTTP 客户端/服务端| G["✓ 用 wasi:http"]
E -->|TCP/UDP socket| H["△ 用 P2 的 wasi:sockets"]
E -->|raw socket/IPv6 RAW| F
style C fill:#10b981,color:#333
style D fill:#10b981,color:#333
style G fill:#10b981,color:#333
style H fill:#f59e0b,color:#333
style F fill:#dc2626,color:#fff
按这套决策树先过一遍,能在投入之前就发现走不通的路。
12.17 WASI 在 IoT 与嵌入式场景
WASM + WASI 在嵌入式领域有独特的吸引力——比传统 OS 容器轻量、比裸机汇编更易开发、安全性比解释型语言(Lua)更强。Bytecode Alliance 的 WAMR(WebAssembly Micro Runtime)就是为这场景而生。
12.17.1 IoT/嵌入式的特殊约束
graph TD A["IoT 设备约束"] --> B["内存"] A --> C["存储"] A --> D["CPU"] A --> E["功耗"] B --> B1["64KB - 64MB RAM<br/>典型 256KB-1MB"] C --> C1["Flash 256KB - 16MB"] D --> D1["MHz 级 ARM Cortex-M<br/>or RISC-V"] E --> E1["电池供电常见<br/>每微秒都珍贵"] style B fill:#dc2626,color:#fff style D fill:#dc2626,color:#fff
这些约束让传统的"完整 Linux + 容器"路径不可行。WASM 是少数能同时满足轻量+安全+多语言的方案。
12.17.2 WAMR:嵌入式 WASM 运行时
WAMR 是 Bytecode Alliance 维护的轻量 WASM 运行时,专为嵌入式设计:
| 维度 | WAMR | Wasmtime |
|---|---|---|
| 二进制大小 | 几十 KB 级(见下) | MB 级 |
| 执行模式 | 解释 / AOT / JIT(Fast JIT、LLVM JIT) | Cranelift 或 Winch 编译(可 AOT 成 .cwasm)/ Pulley 解释器 |
| 目标平台 | RTOS / Linux / 裸机 | 主流 OS |
| WASI 支持 | Preview 1(libc-wasi) | Preview 1 + Preview 2 |
WAMR README 给出的体积(Cortex-M4F 上 core vmlib 的代码段):快速解释器约 58.9K、经典解释器约 56.3K、AoT 运行时约 29.4K,libc-wasi 库另加约 21.4K(2026-09-24 核对)。WAMR 的运行时二进制比 Wasmtime 小一到两个数量级(WAMR 主打的就是「footprint 小到能进 MCU」,而 Wasmtime 带着 Cranelift 这套完整编译后端——第 4 章 §4.13 数过 cranelift 的代码规模),这是 IoT 部署选它的主要原因。具体体积随构建配置(解释器/AOT/JIT、裁剪选项)差别很大,选型前按自己的配置量一遍。
12.17.3 实战:用 WAMR 跑 Rust WASM
# 1. 编译 Rust 到 wasm32-wasip1
cargo build --target wasm32-wasip1 --release
# 2. 用 WAMR 的 AOT 编译器预编译(设备上通常不做 JIT)
wamrc --target=arm \
--output=/tmp/my_app.aot \
target/wasm32-wasip1/release/my_app.wasm
# 3. 部署到设备(通常通过 OTA)
# 设备上的 WAMR 加载 AOT 文件并执行
设备端 C 代码:
// 示意:省略了错误检查;read_file 代表"把文件读进内存并返回长度"
#include "wasm_export.h"
int main(void) {
char error_buf[128];
wasm_runtime_init();
// 加载 AOT 文件
uint32_t file_size;
uint8_t *buf = read_file("/firmware/my_app.aot", &file_size);
wasm_module_t module = wasm_runtime_load(
buf, file_size, error_buf, sizeof(error_buf));
// WASI 模块要在实例化前给出预打开目录、环境变量和参数(这里全部为空)
wasm_runtime_set_wasi_args(module, NULL, 0, NULL, 0, NULL, 0, NULL, 0);
wasm_module_inst_t instance = wasm_runtime_instantiate(
module, 16384, 16384, error_buf, sizeof(error_buf));
// 执行 WASI 命令模块的 main
wasm_application_execute_main(instance, 0, NULL);
wasm_runtime_deinstantiate(instance);
wasm_runtime_unload(module);
wasm_runtime_destroy();
return 0;
}
(函数签名按 WAMR 仓库主分支的 wasm_export.h 头文件,2026-09-24 核对。)
12.17.4 IoT 场景的应用模式
graph TB
subgraph "传感器节点"
S1["温湿度传感器"] --> S2["WAMR + WASM"]
S2 --> S3["数据预处理"]
S3 --> S4["MQTT/CoAP 上传"]
end
subgraph "网关"
G1["WAMR + WASM"] --> G2["数据聚合"]
G2 --> G3["边缘 AI 推理"]
G3 --> G4["告警决策"]
end
subgraph "云端"
C1["数据接收"] --> C2["持久化"]
end
S4 --> G1
G4 --> C1
style S2 fill:#10b981,color:#333
style G1 fill:#10b981,color:#333
WASM 在三个层次都有价值:
- 传感器节点:用 Rust 写数据预处理(滤波、归一化),编译为 WASM 部署。比 C 安全,比 Python 轻量。
- 边缘网关:跑多个 WASM 模块(每个供应商的传感器一个模块),相互隔离。模块崩溃不影响其他。
- 远程更新(OTA):发新 .wasm 更新业务逻辑——比重刷整个镜像快,新模块也仍然跑在沙箱里。
12.17.5 嵌入式 WASM 的限制
不是所有嵌入式场景都适合 WASM:
| 不适合 | 理由 |
|---|---|
| 极致实时(< 1μs) | WASM 解释/JIT 都有开销 |
| 极致低功耗(μA 级) | 运行时本身耗电 |
| 只有几十 KB Flash 的设备 | WAMR 解释器本身就要五六十 KB 代码段,还得留给应用和数据 |
| 直接 GPIO 控制 | 需要 host 提供 syscall |
| 中断处理 | WASM 不支持中断 |
WASM 的甜点:256KB 内存以上的设备 + 软实时(ms 级延迟)+ 业务逻辑可隔离——这覆盖了消费 IoT 和工业 IoT 的大量场景。
12.17.6 OTA 更新的安全模型
WASM 的安全保证让 OTA 更新可靠:
flowchart LR A["云端发新 .wasm"] --> B["设备签名验证"] B --> C["WAMR 验证 + AOT 编译"] C --> D["新模块替换旧"] D --> E["运行时若崩溃<br/>沙箱限制损失"] E --> F["回滚到上一版本"] style B fill:#6366f1,color:#fff style E fill:#10b981,color:#333 style F fill:#f59e0b,color:#333
关键流程:
- 云端用 sigstore/cosign 签名 .wasm
- 设备验证签名后才加载
- WAMR 验证字节码合法性(拒绝畸形)
- 新模块运行——如果崩溃,沙箱限制损失到该模块
- 回滚到上一版本(保存最近 N 版本)
这套机制比传统 OTA 安全得多——传统镜像替换出问题可能砖机,WASM 模块崩溃只影响业务逻辑。
12.17.7 工程实践清单
flowchart TD A["IoT WASM 部署"] --> B["1. 选 WAMR 而非 Wasmtime"] A --> C["2. AOT 编译,避免设备端 JIT"] A --> D["3. 模块体积 < 100KB"] A --> E["4. 严格资源限制(fuel + 内存)"] A --> F["5. OTA 必须签名"] A --> G["6. 多模块隔离不同供应商"] A --> H["7. 监控异常指标"] style A fill:#6366f1,color:#fff
每条都对应嵌入式工程的特定需求——遵循这套清单能让 WASM 在 IoT 场景真正发挥价值。
12.18 WASI 应用的性能调优
WASI 给业务带来了能力安全 + 多语言互操作 + 跨平台——但也引入了独特的性能特征。理解 WASI 的开销来源和调优手段是生产部署的关键技能。
12.18.1 WASI 性能的关键瓶颈
graph TD A["WASI 应用的性能瓶颈"] --> B["host 调用开销"] A --> C["内存复制"] A --> D["边界检查"] A --> E["实例化时间"] B --> B1["每次 fd_read 都有固定开销<br/>小块读写累计可观"] C --> C1["数据进出 WASM 必须复制"] D --> D1["内存访问的边界检查<br/>(64 位宿主多靠保护页消除)"] E --> E1["冷启动的大头是编译"] style B fill:#f59e0b,color:#333 style E fill:#dc2626,color:#fff
每个瓶颈的优化角度不同——必须先用 profiler 定位,再针对性优化。
12.18.2 host 调用的批处理
频繁 host 调用是常见瓶颈。批处理消除单次调用的固定开销:
// 依赖 wasi = "0.11"(P1 的原始绑定,函数都是 unsafe、返回 Result)
// 反模式:每次读 1 字节
fn slow_read(fd: u32, n: usize) -> Vec<u8> {
let mut buf = Vec::with_capacity(n);
for _ in 0..n {
let mut byte = [0u8; 1];
let iov = wasi::Iovec { buf: byte.as_mut_ptr(), buf_len: 1 };
unsafe { wasi::fd_read(fd, &[iov]).expect("fd_read") };
buf.push(byte[0]);
}
buf
}
// 推荐:一次读全部(fd_read 可能少读,真实代码要循环到读满或读到 0)
fn fast_read(fd: u32, n: usize) -> Vec<u8> {
let mut buf = vec![0u8; n];
let iov = wasi::Iovec { buf: buf.as_mut_ptr(), buf_len: n };
let got = unsafe { wasi::fd_read(fd, &[iov]).expect("fd_read") };
buf.truncate(got);
buf
}
本机实测(Xeon Gold 6240,wasi 0.11.1、Rust 1.96.0 --release、wasmtime 44.0.0 CLI,从预打开目录里一个 10,000 字节的文件读):slow_read 约 7.5 ms,即每次 1 字节的 fd_read 约 750 ns;fast_read 只要 2–7 µs,差了三个数量级。差距来自每次调用都要跨一次 WASM/宿主边界、经过 P1→P2 适配层,并在宿主侧再做一次文件读取——批处理把这些固定开销摊掉了。
12.18.3 流式处理避免大块复制
读取大文件时,不要全读入内存:
// 片段:Stats / compute_stats 为示意类型与函数,Result 取 std::io::Result
use std::io::{Read, Result};
// 反模式:全读再处理
fn process_file(path: &str) -> Result<Stats> {
let data = std::fs::read(path)?; // 复制全文件到内存
compute_stats(&data)
}
// 1GB 文件需要 1GB 内存
// 推荐:流式读取
fn process_file_streaming(path: &str) -> Result<Stats> {
let mut file = std::fs::File::open(path)?;
let mut stats = Stats::default();
let mut buf = vec![0u8; 64 * 1024]; // 64KB 缓冲
loop {
let n = file.read(&mut buf)?;
if n == 0 { break; }
stats.update(&buf[..n]);
}
Ok(stats)
}
// 64KB 内存即可处理任意大小文件
Preview 2 把流做成了一等接口——wasi:io/streams 的输入/输出流是独立的资源类型,可以跨接口传递,比 fd-based API 更适合表达流式处理。
12.18.4 实例化时间优化
WASI 应用的冷启动主要由编译时间决定:
flowchart LR A["请求到达"] --> B["1. 加载 .wasm"] B --> C["2. 编译"] C --> D["3. WASI ctx 初始化"] D --> E["4. 实例化"] E --> F["5. 业务执行"] C --> G["AOT 缓存可绕过"] style C fill:#dc2626,color:#fff style G fill:#10b981,color:#333
各阶段的优化:
| 阶段 | 优化手段 | 收益量级 |
|---|---|---|
| 加载 | mmap 而非 read | 少一次全量拷贝 |
| 编译 | AOT 预编译 + Module::deserialize |
最大的一块:整个 Cranelift 编译阶段被挪到构建期 |
| WASI ctx | 复用 ctx 模板 | 省掉重复的 builder 构造 |
| 实例化 | InstancePre + pooling allocator + Config::memory_init_cow |
实例化降到"从池里取内存和表" |
AOT 是最大杀手锏——冷启动里占比最高的就是编译,把它挪走之后剩下的量级完全不同。四项对应的官方说明见 Wasmtime 仓库 docs/examples-fast-instantiation.md;具体数值随模块大小和机器变化,务必自己量。
12.18.5 内存预分配
WASI 应用通常需要大量临时内存——不预分配会触发频繁 grow:
// 反模式:边读边 grow
fn collect_lines(fd: u32) -> Vec<String> {
let mut lines = Vec::new(); // 容量 0
// ... 读取 + push ...
// 100k 行需要 ~17 次 grow
}
// 推荐:预估容量
fn collect_lines_pre(fd: u32, estimated: usize) -> Vec<String> {
let mut lines = Vec::with_capacity(estimated);
// ...
}
Vec::with_capacity 一次分配——避免多次 grow 的累积开销(§3.11)。
12.18.6 跨调用复用:复用 InstancePre,而不是复用实例
直觉上会想把 Instance 放进池子里反复用,但在 Wasmtime 里这条路走不通:Instance 只是它所属 Store 里的一个句柄,离开那个 Store 就没有意义;而一个用过的实例,线性内存、全局变量里都留着上一个请求的状态,Wasmtime 也没有"把实例重置回初始状态"的 API——把它交给下一个租户,就是把上一个租户的数据交给他。
Wasmtime 的复用单位是 InstancePre 加上 pooling allocator 的内存槽:导入解析和类型检查只做一次,每个请求新建一个 Store 和实例,用完整个丢掉,内存和表归还池子。
// 启动时做一次
let pre = linker.instantiate_pre(&module)?;
// 每个请求
let wasi = WasiCtxBuilder::new().build_p1();
let mut store = Store::new(&engine, wasi);
let instance = pre.instantiate(&mut store)?; // 本机实测约 0.07–0.08 ms(§12.10)
let start = instance.get_typed_func::<(), ()>(&mut store, "_start")?;
start.call(&mut store, ())?;
// store 在这里被丢弃,实例的全部状态随之消失
这样"每请求一个实例"的开销主要就只剩实例化本身,而实例之间天然隔离。
12.18.7 性能调优检查清单
flowchart TD A["WASI 性能调优"] --> B["1. host 调用是否批量?"] A --> C["2. 大文件流式处理?"] A --> D["3. AOT 缓存启用?"] A --> E["4. 内存预分配?"] A --> F["5. InstancePre + pooling?"] A --> G["6. fuel 限制合理?"] A --> H["7. 性能基准回归测试?"] style A fill:#6366f1,color:#fff
上线前过一遍这张清单,再结合 §13.16 对运行时差异与基准方法的分析,能避开大部分常见的性能问题。
12.19 WASI 在云原生生态的位置
WASI 不是孤立技术——它是云原生生态的一部分。理解 WASI 与其他 CNCF 项目的关系,有助于判断它在长期技术栈中的位置。
12.19.1 云原生生态全景
graph TD A["云原生生态"] --> B["编排层"] A --> C["运行时层"] A --> D["可观测层"] A --> E["服务网格层"] A --> F["函数计算层"] B --> B1["Kubernetes / Nomad"] C --> C1["containerd / Wasmtime"] D --> D1["Prometheus / OpenTelemetry"] E --> E1["Envoy / Istio (with proxy-wasm)"] F --> F1["Knative / Spin / OpenFaaS"] style C fill:#10b981,color:#333 style E fill:#6366f1,color:#fff style F fill:#f59e0b,color:#333
WASI 在每一层都有对应位置:
| 层 | WASI 集成点 | 主要项目 |
|---|---|---|
| 编排 | runtimeClassName: wasm | Kubernetes + runwasi |
| 运行时 | Wasmtime / WAMR | Bytecode Alliance |
| 可观测 | wasi-logging / wasi-otel 提案(均在 Phase 1) | 尚无定稿接口,见第 13 章 §13.15.4 |
| 服务网格 | proxy-wasm filter | Envoy + Istio |
| 函数计算 | Spin / SpinKube | CNCF 沙盒项目(Spin 2025-01 进入) |
12.19.2 CNCF 中的 WASM/WASI 项目
graph TD A["CNCF WASM 相关项目"] --> B["孵化中"] A --> C["沙盒"] A --> D["生态"] B --> B1["wasmCloud<br/>WASM 组件编排"] C --> C1["Spin<br/>轻量 FaaS"] C --> C2["WasmEdge<br/>云原生 WASM 运行时"] D --> D1["containerd-shim-wasm<br/>K8s 集成"] D --> D2["proxy-wasm<br/>Envoy 扩展"] style B fill:#10b981,color:#333
CNCF 里的 WASM 项目由来已久:WasmEdge(2021-04)、wasmCloud(2021-07)先后进入沙盒,wasmCloud 于 2024-11 升入孵化,Spin 于 2025-01 进入沙盒(日期取自 CNCF landscape 的项目元数据,2026-09-24 核对)。
12.19.3 WASI 与 Service Mesh
Service mesh 是 WASM 在生产中的重要部署场景之一(Envoy/Istio 走 proxy-wasm):
flowchart TB
subgraph "Service Mesh + WASM"
A["请求"] --> B["Envoy sidecar"]
B --> C["proxy-wasm filter 链"]
C --> D["filter 1: 认证(WASM)"]
D --> E["filter 2: 限流(WASM)"]
E --> F["filter 3: 自定义业务(WASM)"]
F --> G["上游服务"]
end
style C fill:#10b981,color:#333
Istio 提供 WasmPlugin API,把 WASM 作为 filter 扩展的标准方式——取代早期的 Mixer 适配器扩展模型。注意 proxy-wasm 是 Envoy 生态自己的一套 ABI,并不是 WASI。
12.19.4 WASM/WASI 在不同行业的落地
| 行业 | 典型案例 |
|---|---|
| CDN / 边缘计算 | Fastly Compute(Wasmtime + WASI)、Cloudflare Workers(在 V8 里跑 WASM,系统能力走 JS API 而非 WASI) |
| 服务网格 | Envoy、Istio(proxy-wasm) |
| 区块链 | Polkadot、NEAR、CosmWasm(用 WASM 执行合约,接口是各链自定义的,不走 WASI) |
表里多数例子用的是 WASM 本身而不是 WASI——共同点是"需要安全沙箱 + 多语言支持 + 轻量级"。
12.19.5 给工程团队的判断框架
flowchart TD
A["何时考虑 WASI"] --> B{"业务场景?"}
B --> C["插件系统 / 用户自定义"]
B --> D["边缘函数"]
B --> E["多语言后端"]
B --> F["传统单体 Web 服务"]
C --> G["✓ 考虑"]
D --> G
E --> G
F --> H["不必"]
style G fill:#10b981,color:#333
style H fill:#dc2626,color:#fff
不是所有项目都该用 WASI——只有上述特定场景才有清晰收益。把它放在"工具箱中的一种工具"位置——按场景选用,避免技术追新。
12.20 WASI 应用的测试策略
测试是 WASI 应用上线前的最后一道防线——但 WASI 的特殊性(沙箱、能力安全、跨平台)让测试比传统应用复杂。这一节整理 WASI 应用的完整测试策略。
12.20.1 WASI 测试的特殊性
graph TD A["WASI 测试挑战"] --> B["环境模拟"] A --> C["能力配置"] A --> D["跨运行时一致性"] A --> E["性能基准"] B --> B1["wasi-libc / Wasmtime mock"] C --> C1["不同能力组合的 deny/allow"] D --> D1["Wasmtime / Wasmer / WAMR 行为一致"] E --> E1["运行时性能差异"] style A fill:#f59e0b,color:#333
每条挑战都需要专门测试基础设施。
12.20.2 测试金字塔(WASI 版)
graph TD A["WASI 测试金字塔"] --> B["单元测试"] A --> C["集成测试"] A --> D["端到端测试"] A --> E["跨运行时测试"] B --> B1["纯 Rust,cargo test"] C --> C1["Wasmtime 嵌入测试"] D --> D1["真实 WASI 环境部署"] E --> E1["多 runtime 行为验证"] style B fill:#10b981,color:#333 style C fill:#6366f1,color:#fff style D fill:#f59e0b,color:#333 style E fill:#dc2626,color:#fff
各级别投入比例(经验性建议,不是定数):
- 单元测试 60%(最快,最便宜)
- 集成测试 25%
- E2E 10%
- 跨 runtime 5%(仅做核心场景)
12.20.3 单元测试:cargo test
// 业务逻辑(不依赖 WASI)
mod core {
pub fn process(input: &[u8]) -> Vec<u8> {
input.iter().map(|&b| b.wrapping_add(1)).collect()
}
}
#[cfg(test)]
mod tests {
use super::core;
#[test]
fn test_process_basic() {
assert_eq!(core::process(b"abc"), vec![b'b', b'c', b'd']);
}
#[test]
fn test_process_empty() {
assert_eq!(core::process(&[]), Vec::<u8>::new());
}
}
把业务逻辑与 WASI 调用分离——业务逻辑直接用原生 cargo test 跑,不必每次编成 wasm 再进运行时;需要在 WASI 上跑测试时,也可以 cargo test --target wasm32-wasip1,并把 CARGO_TARGET_WASM32_WASIP1_RUNNER 设为 wasmtime。
12.20.4 集成测试:Wasmtime 嵌入
// integration_test.rs
use wasmtime::{Engine, Linker, Module, Result, Store};
use wasmtime_wasi::p1::{self, WasiP1Ctx};
use wasmtime_wasi::{DirPerms, FilePerms, WasiCtxBuilder};
#[test]
fn test_wasi_app_processes_file() -> Result<()> {
let engine = Engine::default();
let module = Module::from_file(&engine, "target/wasm32-wasip1/release/my_app.wasm")?;
let wasi = WasiCtxBuilder::new()
.preopened_dir("test_data", "/data", DirPerms::all(), FilePerms::all())?
.build_p1();
let mut store = Store::new(&engine, wasi);
let mut linker: Linker<WasiP1Ctx> = Linker::new(&engine);
p1::add_to_linker_sync(&mut linker, |t| t)?;
let instance = linker.instantiate(&mut store, &module)?;
let main = instance.get_typed_func::<(), ()>(&mut store, "_start")?;
main.call(&mut store, ())?;
// 验证输出:模块写的 /data/output.txt 就是宿主上的 test_data/output.txt
let output = std::fs::read("test_data/output.txt")?;
assert_eq!(output, b"expected result");
Ok(())
}
这种测试在真实 Wasmtime 中跑——验证 WASI 调用的端到端正确性。
12.20.5 能力测试:deny/allow 矩阵
WASI 的能力配置是常见漏洞源——必须测试不同能力组合下的行为:
use wasmtime_wasi::I32Exit;
// run_wasi_app:按上一节的方式实例化并调用 _start,返回 wasmtime::Result<()>
#[test]
fn test_no_filesystem_access_fails() {
let wasi = WasiCtxBuilder::new().build_p1(); // 不给 preopen
let err = run_wasi_app(wasi).unwrap_err();
// 模块里 open 失败(noent)→ main 返回 Err → proc_exit(1)
assert_eq!(err.downcast_ref::<I32Exit>().map(|e| e.0), Some(1));
}
#[test]
fn test_only_read_filesystem() -> wasmtime::Result<()> {
let wasi = WasiCtxBuilder::new()
.preopened_dir(
"test_data",
"/data",
DirPerms::READ, // 仅读,没有 MUTATE
FilePerms::READ,
)?
.build_p1();
// 写文件被拒(perm)→ 同样以 proc_exit(1) 结束
let err = run_wasi_app_writes_file(wasi).unwrap_err();
assert_eq!(err.downcast_ref::<I32Exit>().map(|e| e.0), Some(1));
Ok(())
}
断言不能靠匹配错误字符串:模块内部的 io::Error 只会被打印到它自己的 stderr,宿主拿到的是 _start 的结果——main 返回 Err 时是 I32Exit(1),panic 时则是 unreachable trap(实测两个场景都返回 I32Exit(1),guest 的 stderr 分别是 NotFound(44)和 PermissionDenied(63))。
每个能力配置都要测——确保 deny 真的 deny、allow 真的 allow。
12.20.6 跨运行时测试
// WasiRuntime / WasmtimeRuntime 等是项目自己封装的测试抽象,不是现成的库
fn test_with_runtime<R: WasiRuntime>(runtime: &R) -> Result<()> {
let result = runtime.run("my_app.wasm", &test_input())?;
assert_eq!(result, expected_output());
Ok(())
}
#[test]
fn test_wasmtime() -> Result<()> {
let rt = WasmtimeRuntime::new();
test_with_runtime(&rt)
}
#[test]
fn test_wasmer() -> Result<()> {
let rt = WasmerRuntime::new();
test_with_runtime(&rt)
}
#[test]
fn test_wamr() -> Result<()> {
let rt = WamrRuntime::new();
test_with_runtime(&rt)
}
让同一份 .wasm 在多个 runtime 跑——保证行为一致。这对发布到不同平台的 WASI 应用至关重要。
12.20.7 性能基准测试
// #[bench] 需要 nightly 的 #![feature(test)];稳定版上可换 criterion
#[bench]
fn bench_wasi_file_io(b: &mut test::Bencher) {
let runtime = WasmtimeRuntime::new();
b.iter(|| {
runtime.run("io_benchmark.wasm", &test_input());
});
}
把性能 SLO 写进基准测试——任何回归 > 5% 在 CI 中报警。
12.20.8 模糊测试(fuzzing)
// fuzz/fuzz_targets/wasi_input.rs
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
let runtime = WasmtimeRuntime::new();
// 不应该 panic 或 trap,无论 input 是什么
let _ = runtime.run_with_input("my_app.wasm", data);
});
cargo fuzz 让 WASI 应用被自动测试——防御不可信输入引发的 trap。
12.20.9 测试覆盖率
flowchart TD A["WASI 测试覆盖率目标"] --> B["1. 业务逻辑 > 90%"] A --> C["2. WASI 调用路径 > 80%"] A --> D["3. 能力配置 100% 关键路径"] A --> E["4. 跨 runtime 100% 核心 API"] A --> F["5. fuzz 持续运行"] style A fill:#10b981,color:#333
能力配置错误、跨 runtime 行为不一致、未测试的输入边界,都是会让 WASI 应用在线上出问题的地方。
12.20.10 CI 集成模板
# .github/workflows/wasi-test.yml
- name: Unit tests
run: cargo test
- name: Integration tests with Wasmtime
run: cargo test --test integration
- name: Cross-runtime tests
run: |
cargo test --test integration --features wasmer
cargo test --test integration --features wamr
- name: Fuzz test (10 minutes)
run: timeout 600 cargo fuzz run wasi_input
- name: Benchmark + regression check(criterion)
run: cargo bench -- --baseline main
这套 CI 模板让 WASI 应用的质量可以被持续检查,而不是只靠上线前手动测一下。
下一章深入 WASI Preview 2 的 WIT 接口定义和组件模型——这是 WASI 走向工程化的关键一步。