ARTICLE · 1208635
用 Rust 实现一个插件系统:动态库、WASM 与进程隔离怎么选
所属:Rust 从零造系统 / 系统边界 / 可靠性
聊插件系统时,大家经常把三个问题揉在一起:
• 怎样让主程序在不重新编译的情况下加载新功能; • 插件崩溃、死循环或越权时,主程序是否还能继续工作; • 插件升级后,旧主程序和新插件还能不能互相理解。
只看第一个问题,动态库几行代码就能跑起来:找到 .so/.dylib,用 libloading::Library::new 加载,再取出符号调用。可系统一旦上线,ABI 变化和插件可信度才是更容易出事故的地方。
这篇文章会实现一个小型插件系统,边界尽量收紧。动态库、子进程和 WASM 共用下面这份逻辑契约:
PluginRequest { abi, operation, input }
│
▼
PluginResponse { ok, output, error }动态库把 JSON 写进调用方提供的缓冲区,不跨边界释放 String。进程后端从 stdin/stdout 一行一条地收发消息,插件崩溃时只带走子进程。WASM 继续使用这套字节协议,再借助线性内存和宿主函数导入限制系统调用能力。
如果你正在选后端,可以先按运行环境排除:
• 插件来自同一团队,发布节奏一致,又要求极低延迟,可以用动态库。调用成本最低,代价是插件几乎等同于主进程代码。 • 要支持第三方扩展,而且能列清楚允许使用的能力,可以用 WASM。沙箱边界可控,跨平台分发也省事。 • 插件不可信,或者已经有现成的 CLI/脚本,还要求随时强制杀死,选子进程。地址空间和资源隔离由内核提供,协议也能独立演进。 • 连插件会做什么都不确定,就用子进程再加 OS 沙箱。能加载成功,离安全运行还很远。
一、加载代码之前,先定好契约
1. trait 留在主程序内部
进程内可以这样抽象:
trait Plugin {
fn run(&self, request: Request) -> Result<Response, PluginError>;
}这层 trait 很适合主程序内部的测试和动态分发。到了动态库、WASM 或外部进程边界,就不能直接照搬了。String、Vec<T> 和 trait object 都带着 Rust 私有布局信息。指针、长度、容量以及 vtable 指针的约定,可能随着编译器、目标平台或 crate 版本变化。
跨边界只传布局明确的值,包括整数、字节切片、长度和状态码。Demo 用 Serde JSON 表达逻辑消息,用 C ABI 定义边界结构:
#[repr(C)]
pub struct Buffer {
pub ptr: *const u8,
pub len: usize,
}
#[repr(C)]
pub struct OutputBuffer {
pub ptr: *mut u8,
pub cap: usize,
pub len: usize,
}
pub type PluginRun = unsafe extern "C" fn(
request: Buffer,
reserved: Buffer,
output: *mut OutputBuffer,
) -> i32;reserved 给 ABI 留了一个扩展槽。目前它是空的,以后可以拿来放上下文句柄或 feature flags,不用为了加一个参数就立刻改函数签名。
2. ABI 版本和消息版本是两层东西
Demo 同时带上:
pub const ABI_VERSION: u32 = 1;
#[derive(Serialize, Deserialize)]
pub struct PluginRequest {
pub abi: u32,
pub operation: String,
pub input: String,
}ABI_VERSION 检查指针、函数签名和缓冲区布局。JSON 里的 abi 检查消息字段与语义。它们管的不是同一层,少一个都不行。
第一版就要把兼容规则写下来:
• 新增 JSON 字段使用 Option或默认值,旧插件可以忽略;• 删除字段或改变含义时增加消息版本; • 改变 extern "C"函数签名时增加 ABI 主版本;• 主程序加载插件后先调用 plugin_abi_version,不匹配就拒绝加载;• 不让插件拿到主程序内部 &mut Context,否则任何小改动都会变成 ABI 事故。
3. 内存由谁释放,要写死
跨动态库边界最危险的写法是:
// 不要这样做:String 的分配器和所有权跨过了 ABI 边界
extern "C" fn plugin_run(input: String) -> String;Demo 采用“调用方分配、被调用方填充”的规则:
1. Host 把 JSON 序列化到自己的 Vec<u8>;2. Host 分配固定大小的输出缓冲区; 3. Plugin 只能读取输入、写入输出,不能释放 Host 的内存; 4. 返回值只表示状态码,实际长度写回 OutputBuffer.len;5. 输出太大返回 -4,Host 不会猜测或截断 JSON。
这套规则没那么灵活,好处是不会再纠结“插件返回的 Vec 该由哪个 allocator 释放”,也避开了对应的未定义行为。复杂协议也可以显式提供 plugin_alloc/plugin_free,代价是两边得严格使用同一套分配器,升级和跨平台发布都会麻烦不少。
二、动态库:最快,但隔离最弱
1. 最小可用的 cdylib 插件
插件 crate 必须声明:
[lib]
crate-type = ["cdylib"]实现里只导出两个符号:
#[no_mangle]
pub unsafe extern "C" fn plugin_abi_version() -> u32 {
plugin_api::ABI_VERSION
}
#[no_mangle]
pub unsafe extern "C" fn plugin_run(
request: Buffer,
_reserved: Buffer,
output: *mut OutputBuffer,
) -> i32 {
if request.ptr.is_null() || output.is_null() || (*output).ptr.is_null() {
return -1;
}
let bytes = std::slice::from_raw_parts(request.ptr, request.len);
let request: PluginRequest = match serde_json::from_slice(bytes) {
Ok(value) => value,
Err(_) => return -2,
};
let response = run_builtin(&request);
let bytes = match serde_json::to_vec(&response) {
Ok(value) => value,
Err(_) => return -3,
};
if bytes.len() > (*output).cap {
return -4;
}
std::ptr::copy_nonoverlapping(bytes.as_ptr(), (*output).ptr, bytes.len());
(*output).len = bytes.len();
0
}这里有三个容易踩坑的地方:
• #[no_mangle]保证符号名不会被 Rust 名字改编;• unsafe extern "C"明确告诉调用方,这个函数的指针和长度必须经过验证;• 插件不在边界上返回 Rust 对象,只写调用方拥有的内存。
2. Host 侧用 libloading 检查版本再调用
let library = unsafe { libloading::Library::new(path)? };
let version: libloading::Symbol<unsafe extern "C" fn() -> u32> =
unsafe { library.get(b"plugin_abi_version")? };
if unsafe { version() } != ABI_VERSION {
return Err(HostError::Plugin("ABI mismatch".into()));
}
let run: libloading::Symbol<PluginRun> = unsafe { library.get(b"plugin_run")? };
let mut output = vec![0u8; MAX_MESSAGE_BYTES];
let mut output_buffer = OutputBuffer {
ptr: output.as_mut_ptr(),
cap: output.len(),
len: 0,
};
let code = unsafe {
run(
Buffer { ptr: request_bytes.as_ptr(), len: request_bytes.len() },
Buffer { ptr: std::ptr::null(), len: 0 },
&mut output_buffer,
)
};
if code != 0 {
return Err(HostError::Plugin(format!("dylib returned {code}")));
}
let response: PluginResponse = serde_json::from_slice(&output[..output_buffer.len])?;Library 的生命周期必须覆盖所有从中取得的函数指针。动态库中的 panic、越界、死循环和非法系统调用也都发生在 Host 进程里:
Host ──────── 同一个地址空间 ──────── Plugin
│ │
└── panic/UB/abort/死循环 ────────────┘ 一起受影响Rust 的安全保证有前提,第三方 .so 不会因为被 Rust 加载就自动变安全。我的判断很直接:动态库只留给可信插件,别把来源不明的代码直接塞进 API Server。
3. 动态库常见的工程坑
• 目标三元组必须匹配:Linux 的 .so、macOS 的.dylib和 Windows 的.dll不能互换;CPU 架构、libc 和编译器运行库也可能不兼容。• 符号不等于版本管理:符号还在,不代表结构体布局和语义没变;必须显式版本检查。 • 卸载很危险:插件线程、回调、trait object 或全局析构仍在运行时调用 Library::close,会跳到已经卸载的代码段。第一版系统应只加载不卸载,或者先完成线程和回调排空。• 权限没有减少:插件继承宿主的文件、网络、环境变量和密钥权限。需要权限边界时,应把插件移出进程。
三、进程隔离:多一层协议,换来清楚的故障边界
插件来自不同团队,或者产品明确要求“插件崩溃不能拖垮主程序”,我会先考虑子进程。Demo 使用很薄的一层 stdin/stdout 协议,一行 JSON 请求对应一行 JSON 响应。
1. 子进程插件只有一个职责
fn main() -> Result<(), Box<dyn std::error::Error>> {
let stdin = std::io::stdin();
let mut stdout = std::io::BufWriter::new(std::io::stdout().lock());
for line in stdin.lock().lines() {
let request: PluginRequest = serde_json::from_str(&line?)?;
validate_request(&request)?;
let response = run_builtin(&request);
serde_json::to_writer(&mut stdout, &response)?;
stdout.write_all(b"\n")?;
stdout.flush()?;
}
Ok(())
}实际项目可以把 JSON 换成 length-delimited 二进制帧,但三条规矩要保留:消息边界必须明确,stdout 只放协议内容,日志全部走 stderr。少守一条,一句调试日志就可能把 Host 的响应解析搞坏。
2. Host 得接住四种失败
每次调用都在管理一个随时可能失败的外部资源:
启动 -> 写请求 -> 等响应 -> 校验响应 -> 回收
│ │ │ │
│ │ │ ├─ JSON 损坏
│ │ ├──────────── 超时
│ ├──────────────────── broken pipe
└────────────────────────────── 非零退出 / signalDemo 的 Host 对单次调用设置 2 秒超时,超时后杀死并回收子进程:
let (sender, receiver) = std::sync::mpsc::sync_channel(1);
std::thread::spawn(move || {
let mut line = String::new();
let result = BufReader::new(stdout).read_line(&mut line).map(|_| line);
let _ = sender.send(result);
});
let timeout = Duration::from_secs(2);
let line = match receiver.recv_timeout(timeout) {
Ok(result) => result?,
Err(RecvTimeoutError::Timeout) => {
let _ = child.kill();
let _ = child.wait();
return Err(HostError::Timeout(timeout));
}
Err(RecvTimeoutError::Disconnected) => {
return Err(HostError::Plugin("stdout reader stopped".into()));
}
};这里最容易漏的是 wait()。只 kill() 不 wait() 会留下僵尸进程。只等 read_line() 又不设超时,一个迟迟不输出换行的插件就能一直占住 Worker。
3. 单独起进程,还算不上完整沙箱
进程隔离能防止普通的地址空间破坏和崩溃传播,但插件仍可能:
• 读取宿主能读取的文件; • 访问宿主能访问的网络; • 吃满 CPU、内存或文件描述符; • fork 炸弹或创建大量子进程; • 从环境变量、命令行和挂载目录获取秘密。
到了生产环境,还得在启动前加 OS 级限制,包括 Linux cgroup、setrlimit、seccomp 和 mount namespace。跑在容器里也不能省事,capabilities、只读根文件系统和网络策略都要继续收紧。单独起进程只划开了地址空间,离“插件安全”还差一截。
四、WASM:插件只能使用 Host 明确给出的能力
WASM 把插件代码放进独立的线性内存,Host 通过导入逐项开放能力。没有对应导入时,模块读不了宿主文件,不能直接打开 socket,也不能随意发起系统调用。
1. 一个最小的 WAT 插件接口
下面的模块只演示 ABI 的形状,代码可以控制在容易读懂的范围内。Host 先把请求写进线性内存,再调用 plugin_run(ptr, len),插件返回响应的指针和长度。项目落地时通常会换成 wit-bindgen、组件模型或约定好的序列化协议。
(module
(memory (export "memory") 2)
(global $response_ptr (mut i32) (i32.const 65536))
(data (i32.const 65536) "{\"ok\":true,\"output\":\"OK\",\"error\":null}")
(func (export "plugin_run") (param $ptr i32) (param $len i32) (result i64)
;; 真实实现会读取 $ptr/$len,写入 response_ptr
;; 高 32 位是长度,低 32 位是指针
(i64.or
(i64.shl (i64.extend_i32_u (i32.const 38)) (i64.const 32))
(i64.extend_i32_u (global.get $response_ptr))))
)这里用 i64 打包指针和长度,只是为了缩短示例。长期使用的协议还要固定端序、检查整数溢出,并由 Host 验证指针区间确实落在当前模块的线性内存里。
2. Wasmtime Host 的核心调用
Demo 没有绑定某个 WASM runtime。以 Wasmtime 为例,Host 侧的调用大致如下:
let mut config = Config::new();
config.consume_fuel(true);
let engine = Engine::new(&config)?;
let module = Module::from_file(&engine, path)?;
let mut linker = Linker::new(&engine);
let limits = StoreLimitsBuilder::new()
.memory_size(8 * 1024 * 1024)
.build();
let mut store = Store::new(&engine, limits);
store.limiter(|limits| limits);
store.set_fuel(10_000_000)?;
let instance = linker.instantiate(&mut store, &module)?;
let memory = instance.get_memory(&mut store, "memory")
.ok_or(Error::MissingExport("memory"))?;
let run = instance.get_typed_func::<(i32, i32), i64>(&mut store, "plugin_run")?;
let request = serde_json::to_vec(&request)?;
let ptr = 0usize;
memory.write(&mut store, ptr, &request)?;
let packed = run.call(&mut store, (ptr as i32, request.len() as i32))?;
let response_ptr = packed as u32 as usize;
let response_len = (packed >> 32) as u32 as usize;
let mut response = vec![0u8; response_len];
memory.read(&store, response_ptr, &mut response)?;
let response: PluginResponse = serde_json::from_slice(&response)?;Demo 中的 plugin-wasm 可以直接编译成模块:
cargo build --manifest-path plugin-wasm/Cargo.toml \
--target wasm32-unknown-unknown \
--release \
--offline这是一个 no_std guest,同样导出 plugin_run(ptr, len) -> i64。为了把 ABI 示例压在几十行内,它固定返回一条合法的 PluginResponse。接入业务时,再把请求解析和响应写入搬进模块。
Store 要配置 fuel 或 epoch deadline,用来打断死循环。最大内存和表大小也要限制,防止插件靠分配耗尽 Host。WASI 能力按需注册,例如只开放某个只读目录,而不是把完整系统访问权限交出去。
3. WASM 的账也不能漏算
• 首次编译、实例化和 JIT/AOT 缓存有成本;高频短调用应复用实例或使用组件模型批量传输。 • 线性内存与 JSON 序列化会产生复制;追求吞吐时可以使用 MessagePack、FlatBuffers 或共享的二进制布局。 • WASM 沙箱隔离的是模块能力,不自动替你做租户认证、密钥管理和审计。 • 需要访问文件、网络或时钟时,必须设计 host function;每增加一个导入,就增加一个可审计的能力面。
我会把 WASM 用在“代码不完全可信,但所需能力列得出来”的插件上。插件如果非得运行任意系统命令,就需要添加大量宿主导入,WASM 的隔离优势也会慢慢被掏空。这类需求直接上进程 + OS 沙箱更省心。
五、Host 只认一套接口
业务代码没必要知道插件来自 .so、WASM 还是子进程。Host 只依赖一个异步接口:
#[async_trait::async_trait]
pub trait PluginExecutor: Send + Sync {
async fn execute(&self, request: PluginRequest) -> Result<PluginResponse, HostError>;
}实现层分别负责边界转换:
业务层: PluginRequest -> PluginResponse
│
┌───────┼────────┐
▼ ▼ ▼
Dynamic Wasm Process
C ABI memory framed I/O统一接口之后,测试和后端实现也能分开:
1. 业务测试可以用一个纯 Rust fake,不需要加载真实插件; 2. 每个后端都能独立验证超时、版本不匹配、损坏响应和资源回收。
后端选择集中放进插件注册表,不要散落在业务 handler 的各个 match 中。注册记录保存名称、版本、能力、超时、最大并发和执行后端,加载时一次校验完。
一个最小注册记录可以是:
pub struct PluginDescriptor {
pub name: String,
pub version: String,
pub abi: u32,
pub backend: Backend,
pub capabilities: Vec<Capability>,
pub timeout: Duration,
pub max_concurrency: usize,
}capabilities 不是给管理页面看的装饰字段。Host 启动 WASM 时根据它注册导入,启动进程时根据它配置挂载目录、网络和环境变量。对于动态库,Host 至少可以拒绝加载权限需求超出自身范围的插件。
六、失败语义要单独设计
1. 错误要区分“插件返回”和“边界失败”
插件调用失败时,至少要分清下面几类情况。全部压成一个 PluginError("failed"),后面既没法重试,也没法定位。
• 请求错误包括 ABI 不支持、消息超限。这类问题重试无效,要改配置。 • 插件业务错误会返回 ok=false,例如操作不支持,通常也不该重试。• 进程启动失败、WASM fuel 用尽属于边界暂时失败,可以按具体策略重试。 • 非零退出或 signal 说明插件崩溃,可以重试,但要配合熔断。 • 动态库 ABI 或符号不匹配是 Host 侧错误,应该直接阻止上线。 • 超过 deadline 仍未返回,要终止本次调用并记一次超时。
所有错误一律重试,坏插件很快就会把 Worker 和下游一起拖垮。这里需要配上指数退避、连续失败熔断和每插件并发上限。
2. 超时之后,代码得真的停下来
WASM 可以用 fuel/epoch 终止,进程可以 kill。动态库里的同步函数一旦进入死循环,Host 没有可靠办法在同一进程里安全取消它。三种后端在这里差得很远:
动态库:超时 = 记录超时(无法保证停止代码)
WASM: 超时 = 触发执行预算/epoch 中断
进程: 超时 = kill + wait,故障边界明确如果合规要求写着“超时后必须停止执行”,动态库就不在候选名单里。
3. 指标要能落到具体插件版本
每次调用至少记录这些字段:
plugin.name / plugin.version / backend
request_id / operation
duration_ms / outcome
memory_limit / fuel_used 或 child_pid插件可能处理用户输入,日志里不要直接塞它的完整输出。指标按插件版本聚合,才能看出“升级后只有 v2.3 的 reverse 变慢”这种问题。
七、安全边界与供应链
动态库检查清单
• 只从签名清单加载,路径不能由用户请求直接拼接; • 校验文件 hash、签名、目标平台和 ABI 版本; • 默认不支持热卸载; • 插件与 Host 使用锁定的 plugin-api版本;• 在独立 helper 进程中加载第三方动态库,避免污染主进程。
WASM 检查清单
• 默认没有 WASI 能力,只按清单注册导入; • 设置最大内存、表、栈、fuel 和实例数量; • 模块来源、hash 和组件接口都要签名; • 对导入函数做参数校验和速率限制; • 组件升级时保留旧 world,避免一次升级击穿全部插件。
进程检查清单
• Command使用绝对路径和固定环境变量,不拼接 shell 字符串;• stdin/stdout 只承载协议,日志走 stderr; • 设置启动超时、调用超时、输出大小和并发上限; • kill后必须wait,并清理临时目录;• 使用 cgroup、seccomp、只读文件系统和最小权限用户; • 插件重启要有退避,否则崩溃循环会变成 fork 风暴。
供应链签名要覆盖插件包、接口描述、构建工具链和依赖锁文件。只签 .wasm 或 .so,却没有把契约版本绑定进去,仍可能出现代码文件没变、Host 却用另一套方式解释它的问题。
八、测试不能停在“能返回 HELLO”
Demo 自带的契约测试覆盖:
#[test]
fn rejects_unknown_abi() {
let request = PluginRequest {
abi: ABI_VERSION + 1,
operation: "uppercase".into(),
input: "hi".into(),
};
assert!(validate_request(&request).is_err());
}生产系统还要覆盖故意捣乱的插件:
• 插件返回超大输出; • 返回非法 UTF-8 或损坏 JSON; • 进程在响应前退出; • 进程不写换行、持续写 stderr 或关闭 stdin; • WASM 无限循环、内存增长和 panic; • 动态库缺少符号、ABI 不匹配和加载后线程未退出; • Host 在每个阶段收到取消信号时是否回收资源。
边界协议很适合做属性测试。随机生成长度、空指针、截断消息和未知字段,看 Host 会不会越界读写,或者把无效响应误判成成功。
九、实际运行
在仓库根目录执行:
cd workspaces/plugin-system-demo
cargo build --workspace --offline
cargo build --manifest-path plugin-wasm/Cargo.toml --target wasm32-unknown-unknown --release --offline
# 子进程后端
cargo run -p plugin-host -- process 'hello rust' uppercase target/debug/plugin-process
# 动态库后端(macOS)
cargo run -p plugin-host -- dylib 'hello rust' reverse target/debug/libplugin_dylib.dylib
cargo test --workspace --offline
cargo clippy --workspace --all-targets --offline -- -D warnings输出分别类似:
{
"ok":true,
"output": "HELLO RUST",
"error":null
}{
"ok":true,
"output": "tsur olleh",
"error":null
}Demo 没有给 WASM 后端绑定具体 runtime,避免这套契约跟着某个引擎版本走。使用 Wasmtime 时,按前文的 memory、plugin_run、fuel 和导入清单接入即可。
十、我会怎样选
我的排除顺序是这样的:
1. 插件是否可信。插件来自同一团队、走同一发布链路,又确实在意极低的调用成本,我才会考虑动态库。 2. 所需能力能否列清楚。文件、网络、时钟等能力可以逐项列出时,WASM 很合适。 3. 超时后是否必须强制停止。有硬终止要求就选进程或 WASM,动态库做不到这一点。 4. 性能瓶颈到底在哪里。先测序列化、启动、实例化和业务 I/O。很多插件系统慢在网络和磁盘,函数调用本身占不了多少时间。
从零开始做,我会按这个顺序落地:
第一版:统一 JSON/二进制契约 + 子进程后端
↓ 先把版本、超时、重启和审计做对
第二版:对可信内置插件增加动态库后端
↓ 只给同一发布链路的代码使用
第三版:对第三方扩展增加 WASM 后端
↓ 用能力清单和资源预算限制执行范围dlopen、WASM 和 Command::spawn 只是三种执行手段。选型前先确定插件由谁提供、能拿到哪些权限、失控后能不能停下来。
契约、内存所有权、能力清单、超时和失败语义写清楚后,替换执行后端并不难。反过来,边界没定好就急着接入插件,三种方案都会把问题带到生产环境。















































































