夜雨聆风学习资料网

ARTICLE · 1208635

用 Rust 实现一个插件系统:动态库、WASM 与进程隔离怎么选

用 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. 1. Host 把 JSON 序列化到自己的 Vec<u8>;
  2. 2. Host 分配固定大小的输出缓冲区;
  3. 3. Plugin 只能读取输入、写入输出,不能释放 Host 的内存;
  4. 4. 返回值只表示状态码,实际长度写回 OutputBuffer.len;
  5. 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
  └────────────────────────────── 非零退出 / signal

Demo 的 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. 1. 业务测试可以用一个纯 Rust fake,不需要加载真实插件;
  2. 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. 1. 插件是否可信。插件来自同一团队、走同一发布链路,又确实在意极低的调用成本,我才会考虑动态库。
  2. 2. 所需能力能否列清楚。文件、网络、时钟等能力可以逐项列出时,WASM 很合适。
  3. 3. 超时后是否必须强制停止。有硬终止要求就选进程或 WASM,动态库做不到这一点。
  4. 4. 性能瓶颈到底在哪里。先测序列化、启动、实例化和业务 I/O。很多插件系统慢在网络和磁盘,函数调用本身占不了多少时间。

从零开始做,我会按这个顺序落地:

第一版:统一 JSON/二进制契约 + 子进程后端
       ↓ 先把版本、超时、重启和审计做对
第二版:对可信内置插件增加动态库后端
       ↓ 只给同一发布链路的代码使用
第三版:对第三方扩展增加 WASM 后端
       ↓ 用能力清单和资源预算限制执行范围

dlopen、WASM 和 Command::spawn 只是三种执行手段。选型前先确定插件由谁提供、能拿到哪些权限、失控后能不能停下来。

契约、内存所有权、能力清单、超时和失败语义写清楚后,替换执行后端并不难。反过来,边界没定好就急着接入插件,三种方案都会把问题带到生产环境。

相关学习资料