8.4 实战:构建一个 Serverless Rust 函数平台

引言:函数即服务 (FaaS) 的魅力

在现代云原生架构中,Serverless(无服务器)和 FaaS(函数即服务)已经成为一种流行的范式。它允许开发者只关注编写核心的业务逻辑(一个函数),而将服务器的部署、运维、扩展等所有繁重的工作交给平台来处理。像 AWS Lambda, Google Cloud Functions, Vercel Functions, Deno Deploy, 和 Cloudflare Workers 都是这种思想的体现。

这些平台的核心是什么?它们本质上是一个安全沙箱一个高速的运行时。当一个请求到来时,平台会:

  1. 快速启动一个包含用户代码的、隔离的执行环境(沙箱)。
  2. 将请求数据传递给用户函数。
  3. 执行函数。
  4. 获取函数的返回值,并将其转换成 HTTP 响应。
  5. 销毁或回收沙箱。

Rust 凭借其性能、安全性和极小的二进制体积,是构建这种高性能 FaaS 平台的理想语言。而我们在本周学习的嵌入式脚本语言Wasm (WebAssembly) 运行时,正是构建这种安全沙箱的关键技术。

本章,我们将挑战一个激动人心的综合项目:使用 axum 作为 Web 框架,并嵌入一个运行时,来构建一个我们自己的、极简的 FaaS 平台。我们将允许用户“部署”(在我们的例子中是上传或指定)一个函数,然后通过 HTTP 请求来执行它。

我们将主要探索基于 Wasm 的实现,因为它提供了比嵌入 JS 或 Python 更高级别的安全隔离。

架构选择:为什么是 Wasm?

在上一章,我们了解了 rquickjs (JavaScript) 和 pyo3 (Python)。我们当然可以用它们来执行用户代码。但与它们相比,WebAssembly (Wasm) 作为运行用户代码的沙箱,有几个无与伦比的优势:

  1. 安全性: Wasm 运行在一个完全线性的内存沙箱中。默认情况下,Wasm 代码不能访问文件系统、网络、环境变量或任何外部资源。它只能执行纯计算,并与宿主(我们的 Rust 程序)通过明确的函数调用进行交互。这种“能力为本”(capability-based)的安全模型是最高级别的。
  2. 可移植性: Wasm 是一个为 Web 设计的、平台无关的二进制指令集标准。任何可以编译到 Wasm 的语言(C, C++, Rust, Go, Swift, C#等)编写的函数,都可以在我们的平台上运行。
  3. 高性能: Wasm 被设计为可以被即时(JIT)或提前(AOT)编译为接近原生的机器码,性能非常高。
  4. 轻量级与快速启动: Wasm 模块的启动(实例化)速度极快,通常在微秒级别,远快于启动一个新的进程或一个完整的 JS/Python 虚拟机。

在 Rust 生态中,wasmtime 是一个由字节码联盟(Bytecode Alliance)支持的、领先的、生产级的 Wasm 运行时。

项目架构设计

我们的 FaaS 平台需要以下组件:

  1. 一个 axum Web 服务器:作为总入口,负责接收 HTTP 请求。
  2. 函数加载器: 负责从磁盘(或未来从数据库/对象存储)加载用户的 Wasm 函数模块。
  3. Wasm 运行时 (wasmtime): 负责实例化和执行 Wasm 模块。
  4. 宿主与沙箱的接口 (ABI): 我们需要定义一个清晰的“契约”,规定用户的 Wasm 函数应该是什么样的(例如,它应该导出一个名为 handler 的函数),以及它如何与我们的 Rust 主机交换数据(例如,如何获取请求体,如何返回响应)。

请求流程:

graph TD
    A[HTTP POST /run/my_func] --> B(axum Server);
    B --> C{Function Loader};
    C -- "加载 my_func.wasm" --> D[Wasmtime Engine];
    D -- "编译和实例化" --> E(Wasm 实例 - 沙箱);
    
    subgraph E
        F[Wasm 内存]
        G[导出的 `handler` 函数]
    end
    
    B -- "请求体数据" --> H(写入 Wasm 内存);
    H --> G;
    G -- "调用" --> I(Wasm 代码执行);
    I -- "结果" --> J(写入 Wasm 内存);
    
    J -- "读取响应数据" --> K(Rust Host);
    K --> L(构建 HTTP 响应);
    L --> M[返回给客户端];

实战:构建 Wasm FaaS 平台

1. 环境准备

[dependencies]
axum = "0.6"
tokio = { version = "1", features = ["full"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
anyhow = "1.0"

# Wasmtime
wasmtime = "12.0"
wasmtime-wasi = "12.0" # 用于为 Wasm 提供类似 POSIX 的系统接口

# 用于在 Wasm 模块和宿主之间传递数据
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

2. 编写用户 Wasm 函数

我们需要先创建一个“用户函数”项目。这是一个独立的 Rust 项目,但它将被编译成 Wasm。

创建 guest 项目:

cargo new --lib guest-function
cd guest-function

修改 Cargo.toml:

[package]
name = "guest-function"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"] # 编译为动态库

[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

cdylib 是生成 Wasm 模块所必需的 crate 类型。

编写 guest 代码 (src/lib.rs):

use serde::{Deserialize, Serialize};

// 我们定义请求和响应的结构
#[derive(Deserialize)]
struct Request {
    name: String,
}

#[derive(Serialize)]
struct Response {
    message: String,
}

// 这是我们将要分配的内存区域的指针和长度
// 用于与宿主交换数据
static mut BUFFER: [u8; 1024] = [0; 1024];

/// 返回一个指向我们静态缓冲区的指针
#[no_mangle]
pub unsafe fn get_buffer_ptr() -> *const u8 {
    BUFFER.as_ptr()
}

/// 这是我们的核心处理函数,它会被 Rust 主机调用
/// 它接收输入数据的长度,并返回输出数据的长度
#[no_mangle]
pub unsafe fn handler(input_len: usize) -> usize {
    // 1. 从缓冲区读取输入数据
    let input_bytes = &BUFFER[..input_len];
    let input_str = std::str::from_utf8(input_bytes).unwrap_or("");
    
    // 2. 解析请求
    let request: Request = serde_json::from_str(input_str).unwrap_or(Request { name: "World".to_string() });

    // 3. 执行核心业务逻辑
    let response = Response {
        message: format!("Hello, {}!", request.name),
    };
    
    // 4. 将响应序列化为 JSON 字符串
    let response_str = serde_json::to_string(&response).unwrap();
    let response_bytes = response_str.as_bytes();
    let response_len = response_bytes.len();

    // 5. 将响应数据写入缓冲区
    BUFFER[..response_len].copy_from_slice(response_bytes);

    // 6. 返回响应数据的长度
    response_len
}

编译为 Wasm:

# 添加 wasm32-wasi 目标
rustup target add wasm32-wasi
# 编译
cargo build --target wasm32-wasi --release

编译成功后,你会在 target/wasm32-wasi/release/guest_function.wasm 找到 Wasm 文件。

3. 构建 axum 宿主服务器

现在我们回到主项目 crm-service (或者新建一个 faas-host)。

main.rs 实现:

use axum::{
    body::Bytes,
    extract::{Path, State},
    http::StatusCode,
    response::{IntoResponse, Response},
    routing::post,
    Router,
};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use wasmtime::*;
use wasmtime_wasi::WasiCtx;

// 应用状态,用于缓存编译好的 Wasm 模块
#[derive(Clone, Default)]
struct AppState {
    modules: Arc<Mutex<HashMap<String, Module>>>,
    engine: Engine,
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // 初始化 Wasmtime 引擎
    let engine = Engine::default();
    let state = AppState {
        modules: Arc::new(Mutex::new(HashMap::new())),
        engine,
    };

    let app = Router::new()
        .route("/run/:function_name", post(run_function))
        .with_state(state);

    let addr = "127.0.0.1:3000".parse()?;
    println!("FaaS Host listening on {}", addr);
    axum::Server::bind(&addr)
        .serve(app.into_make_service())
        .await?;

    Ok(())
}

async fn run_function(
    State(state): State<AppState>,
    Path(function_name): Path<String>,
    body: Bytes, // axum 提取器,用于获取原始请求体
) -> Result<Response, StatusCode> {
    // 1. 加载和编译 Wasm 模块(带缓存)
    let module = {
        let mut modules = state.modules.lock().unwrap();
        if let Some(module) = modules.get(&function_name) {
            module.clone()
        } else {
            // 从文件加载 .wasm 模块
            let wasm_path = format!("./{}.wasm", function_name);
            let module = Module::from_file(&state.engine, wasm_path)
                .map_err(|_| StatusCode::NOT_FOUND)?;
            modules.insert(function_name.clone(), module.clone());
            module
        }
    };

    // 2. 创建 Wasmtime Store 和 Linker
    let mut linker = Linker::new(&state.engine);
    wasmtime_wasi::add_to_linker(&mut linker, |s| s).unwrap();
    let wasi = wasmtime_wasi::WasiCtxBuilder::new()
        .inherit_stdout()
        .inherit_stderr()
        .build();
    let mut store = Store::new(&state.engine, wasi);

    // 3. 实例化模块
    let instance = linker.instantiate(&mut store, &module).await.unwrap();

    // 4. 获取 Wasm 导出的内存和函数
    let memory = instance.get_memory(&mut store, "memory").ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
    let get_buffer_ptr = instance.get_typed_func::<(), i32>(&mut store, "get_buffer_ptr").unwrap();
    let handler = instance.get_typed_func::<i32, i32>(&mut store, "handler").unwrap();

    // 5. 将请求体写入 Wasm 内存
    let input_len = body.len();
    let ptr = get_buffer_ptr.call_async(&mut store, ()).await.unwrap() as usize;
    memory.write(&mut store, ptr, &body).unwrap();
    
    // 6. 调用 Wasm handler 函数
    let output_len = handler.call_async(&mut store, input_len as i32).await.unwrap() as usize;

    // 7. 从 Wasm 内存中读取响应数据
    let mut response_buffer = vec![0u8; output_len];
    memory.read(&store, ptr, &mut response_buffer).unwrap();

    // 8. 构建 HTTP 响应
    Ok((
        StatusCode::OK,
        [(hyper::header::CONTENT_TYPE, "application/json")],
        response_buffer,
    ).into_response())
}

代码分析:

  1. AppState: 我们用一个 HashMap 来缓存已编译的 wasmtime::Module。编译是一个耗时操作,对于每个函数我们只想做一次。
  2. run_function handler:
    • 加载或从缓存中获取 Module
    • 创建 StoreLinkerStore 代表了一个 Wasm 实例的所有状态。Linker 用于将宿主函数(如此处的 WASI 函数)链接到 Wasm 实例。
    • wasmtime_wasi 为 Wasm 模块提供了一套标准的系统接口(如 stdout),这让 Wasm 内部的 println! 等可以工作。
    • instance.get_typed_func:从实例中安全地获取我们定义的 handlerget_buffer_ptr 函数的句柄。
    • 数据交换: 这是最关键的部分。我们调用 get_buffer_ptr 获取 Wasm 模块内部的共享内存地址,然后使用 memory.write 将 HTTP 请求体复制进去。调用 handler 后,再使用 memory.read 将结果从同一块内存中读出来。
    • 最后,将读取到的响应字节作为 HTTP 响应返回。

运行和测试

  1. 将之前编译好的 guest_function.wasm 文件重命名为 my_func.wasm 并放在 FaaS host 项目的根目录。
  2. 运行 axum 服务器:cargo run
  3. 在另一个终端,使用 curl 来调用函数:
    # 发送一个带 JSON body 的 POST 请求
    curl -X POST \
      http://127.0.0.1:3000/run/my_func \
      -H "Content-Type: application/json" \
      -d '{"name": "Rustacean"}'
    
  4. 服务器响应:
    {"message":"Hello, Rustacean!"}
    

成功了!我们的 axum 服务器成功加载并执行了一个沙箱化的 Wasm 函数,处理了 HTTP 请求,并返回了结果。

总结与展望

本章,我们完成了一个极具挑战但也极具价值的项目,构建了一个微型的 FaaS(函数即服务)平台。这个项目综合了我们课程中学习到的多项关键技术。

  1. 嵌入式运行时: 我们学习了在 Rust 应用中嵌入一个完全不同的运行时(Wasmtime)来执行不可信的用户代码。这是构建可扩展平台的核心能力。
  2. WebAssembly (Wasm): 我们看到了 Wasm 作为安全沙箱的巨大优势——它提供了强大的隔离性、可移植性和高性能。
  3. 主机-沙箱通信: 我们通过共享内存和函数调用的方式,实现了一种低级但高效的主机-沙箱数据交换 ABI (应用二进制接口)。在真实世界中,WASI-HTTP 等标准正在定义更高级、更标准化的接口。
  4. axum 的灵活性: axum 作为一个 Web 框架,其灵活性足以支撑这种非传统的、需要与底层运行时深度交互的应用场景。Bytes 提取器让我们能方便地处理原始请求体。
  5. 异步集成: wasmtime 提供了 .call_async() 方法,使其能与 tokioaxum 的异步世界无缝集成。

我们的实现虽然简单,但它包含了所有商业 FaaS 平台的核心组件。以此为基础,你可以进行各种激动人心的扩展:

  • 多语言支持: 只要能编译到 WASI,任何语言写的函数都可以运行。
  • 更丰富的宿主 API: 向 Wasm 沙箱中注入更多的宿主函数,例如,提供一个键值存储 API (host_kv_set(key, value)),让 Wasm 函数可以持久化数据。
  • 函数部署: 实现一个 /deploy 接口,允许用户通过 HTTP 上传他们的 .wasm 文件。
  • 冷启动优化: 使用 Wasmtime 的快照(snapshotting)功能或 AOT(提前编译)来进一步减少函数的冷启动时间。

通过这个项目,你不仅掌握了如何嵌入脚本/Wasm 运行时,更重要的是,你体验了作为“平台构建者”的思维方式——如何设计一个安全、高效、可扩展的系统,让其他开发者能在你的平台上构建他们自己的应用。

思考题

  1. 相比于我们实现的“共享内存”数据交换方式,你认为一个更理想的、更高级的主机-Wasm 通信接口应该是什么样的?它会如何处理复杂的类型和错误?
  2. WASI (WebAssembly System Interface) 的目标是什么?为什么在我们的 wasmtime 设置中需要它?
  3. 我们的 FaaS 平台为每个请求都创建了一个新的 StoreInstance。这被称为“冷启动”。它的优缺点是什么?你会如何设计一个能复用 Instance(“热启动”)的系统来处理连续的请求?
  4. 如果一个用户上传的 Wasm 函数中包含一个无限循环,我们的服务器会发生什么?你会如何防范这种“拒绝服务”攻击?(提示:Store::limiter)。
  5. 除了 wasmtime,Rust 生态中还有其他流行的 Wasm 运行时,如 wasmer。请简要了解一下 wasmer,并比较它与 wasmtime 的异同。

实践练习

  1. 实现一个 rquickjs 后端: 仿照本章的 wasmtime 实现,创建另一个路由 /run/js/:function_name,它会加载一个 .js 文件,使用 rquickjs 来执行其中的 handler 函数,并处理请求和响应。
  2. 提供键值存储宿主 API:
    • AppState 中添加一个 Arc<DashMap<String, String>> 作为全局的键值存储。
    • 在创建 Linker 时,定义并链接一个名为 host_kv_set(key_ptr, key_len, val_ptr, val_len) 的宿主函数,它的实现会将数据写入 DashMap
    • 在你的 guest-function Wasm 代码中,声明并调用这个外部函数。
  3. 实现函数部署: 创建一个 POST /deploy/:function_nameaxum 路由,它接收一个 application/wasm 的请求体,并将上传的 Wasm 二进制数据保存到磁盘,以便 /run 接口可以加载它。
  4. 资源限制: 阅读 wasmtime 的文档,学习如何使用 Config::epoch_interruptionStore::limiter 来限制 Wasm 函数的执行时间或内存使用,以防止恶意代码消耗过多资源。

更多推荐