8.4 实战:构建一个 Serverless Rust 函数平台
8.4 实战:构建一个 Serverless Rust 函数平台
引言:函数即服务 (FaaS) 的魅力
在现代云原生架构中,Serverless(无服务器)和 FaaS(函数即服务)已经成为一种流行的范式。它允许开发者只关注编写核心的业务逻辑(一个函数),而将服务器的部署、运维、扩展等所有繁重的工作交给平台来处理。像 AWS Lambda, Google Cloud Functions, Vercel Functions, Deno Deploy, 和 Cloudflare Workers 都是这种思想的体现。
这些平台的核心是什么?它们本质上是一个安全沙箱和一个高速的运行时。当一个请求到来时,平台会:
- 快速启动一个包含用户代码的、隔离的执行环境(沙箱)。
- 将请求数据传递给用户函数。
- 执行函数。
- 获取函数的返回值,并将其转换成 HTTP 响应。
- 销毁或回收沙箱。
Rust 凭借其性能、安全性和极小的二进制体积,是构建这种高性能 FaaS 平台的理想语言。而我们在本周学习的嵌入式脚本语言和 Wasm (WebAssembly) 运行时,正是构建这种安全沙箱的关键技术。
本章,我们将挑战一个激动人心的综合项目:使用 axum 作为 Web 框架,并嵌入一个运行时,来构建一个我们自己的、极简的 FaaS 平台。我们将允许用户“部署”(在我们的例子中是上传或指定)一个函数,然后通过 HTTP 请求来执行它。
我们将主要探索基于 Wasm 的实现,因为它提供了比嵌入 JS 或 Python 更高级别的安全隔离。
架构选择:为什么是 Wasm?
在上一章,我们了解了 rquickjs (JavaScript) 和 pyo3 (Python)。我们当然可以用它们来执行用户代码。但与它们相比,WebAssembly (Wasm) 作为运行用户代码的沙箱,有几个无与伦比的优势:
- 安全性: Wasm 运行在一个完全线性的内存沙箱中。默认情况下,Wasm 代码不能访问文件系统、网络、环境变量或任何外部资源。它只能执行纯计算,并与宿主(我们的 Rust 程序)通过明确的函数调用进行交互。这种“能力为本”(capability-based)的安全模型是最高级别的。
- 可移植性: Wasm 是一个为 Web 设计的、平台无关的二进制指令集标准。任何可以编译到 Wasm 的语言(C, C++, Rust, Go, Swift, C#等)编写的函数,都可以在我们的平台上运行。
- 高性能: Wasm 被设计为可以被即时(JIT)或提前(AOT)编译为接近原生的机器码,性能非常高。
- 轻量级与快速启动: Wasm 模块的启动(实例化)速度极快,通常在微秒级别,远快于启动一个新的进程或一个完整的 JS/Python 虚拟机。
在 Rust 生态中,wasmtime 是一个由字节码联盟(Bytecode Alliance)支持的、领先的、生产级的 Wasm 运行时。
项目架构设计
我们的 FaaS 平台需要以下组件:
- 一个
axumWeb 服务器:作为总入口,负责接收 HTTP 请求。 - 函数加载器: 负责从磁盘(或未来从数据库/对象存储)加载用户的 Wasm 函数模块。
- Wasm 运行时 (
wasmtime): 负责实例化和执行 Wasm 模块。 - 宿主与沙箱的接口 (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())
}
代码分析:
AppState: 我们用一个HashMap来缓存已编译的wasmtime::Module。编译是一个耗时操作,对于每个函数我们只想做一次。run_functionhandler:- 加载或从缓存中获取
Module。 - 创建
Store和Linker。Store代表了一个 Wasm 实例的所有状态。Linker用于将宿主函数(如此处的 WASI 函数)链接到 Wasm 实例。 wasmtime_wasi为 Wasm 模块提供了一套标准的系统接口(如stdout),这让 Wasm 内部的println!等可以工作。instance.get_typed_func:从实例中安全地获取我们定义的handler和get_buffer_ptr函数的句柄。- 数据交换: 这是最关键的部分。我们调用
get_buffer_ptr获取 Wasm 模块内部的共享内存地址,然后使用memory.write将 HTTP 请求体复制进去。调用handler后,再使用memory.read将结果从同一块内存中读出来。 - 最后,将读取到的响应字节作为 HTTP 响应返回。
- 加载或从缓存中获取
运行和测试
- 将之前编译好的
guest_function.wasm文件重命名为my_func.wasm并放在 FaaS host 项目的根目录。 - 运行
axum服务器:cargo run。 - 在另一个终端,使用
curl来调用函数:# 发送一个带 JSON body 的 POST 请求 curl -X POST \ http://127.0.0.1:3000/run/my_func \ -H "Content-Type: application/json" \ -d '{"name": "Rustacean"}' - 服务器响应:
{"message":"Hello, Rustacean!"}
成功了!我们的 axum 服务器成功加载并执行了一个沙箱化的 Wasm 函数,处理了 HTTP 请求,并返回了结果。
总结与展望
本章,我们完成了一个极具挑战但也极具价值的项目,构建了一个微型的 FaaS(函数即服务)平台。这个项目综合了我们课程中学习到的多项关键技术。
- 嵌入式运行时: 我们学习了在 Rust 应用中嵌入一个完全不同的运行时(Wasmtime)来执行不可信的用户代码。这是构建可扩展平台的核心能力。
- WebAssembly (Wasm): 我们看到了 Wasm 作为安全沙箱的巨大优势——它提供了强大的隔离性、可移植性和高性能。
- 主机-沙箱通信: 我们通过共享内存和函数调用的方式,实现了一种低级但高效的主机-沙箱数据交换 ABI (应用二进制接口)。在真实世界中,WASI-HTTP 等标准正在定义更高级、更标准化的接口。
axum的灵活性:axum作为一个 Web 框架,其灵活性足以支撑这种非传统的、需要与底层运行时深度交互的应用场景。Bytes提取器让我们能方便地处理原始请求体。- 异步集成:
wasmtime提供了.call_async()方法,使其能与tokio和axum的异步世界无缝集成。
我们的实现虽然简单,但它包含了所有商业 FaaS 平台的核心组件。以此为基础,你可以进行各种激动人心的扩展:
- 多语言支持: 只要能编译到 WASI,任何语言写的函数都可以运行。
- 更丰富的宿主 API: 向 Wasm 沙箱中注入更多的宿主函数,例如,提供一个键值存储 API (
host_kv_set(key, value)),让 Wasm 函数可以持久化数据。 - 函数部署: 实现一个
/deploy接口,允许用户通过 HTTP 上传他们的.wasm文件。 - 冷启动优化: 使用 Wasmtime 的快照(snapshotting)功能或 AOT(提前编译)来进一步减少函数的冷启动时间。
通过这个项目,你不仅掌握了如何嵌入脚本/Wasm 运行时,更重要的是,你体验了作为“平台构建者”的思维方式——如何设计一个安全、高效、可扩展的系统,让其他开发者能在你的平台上构建他们自己的应用。
思考题
- 相比于我们实现的“共享内存”数据交换方式,你认为一个更理想的、更高级的主机-Wasm 通信接口应该是什么样的?它会如何处理复杂的类型和错误?
- WASI (WebAssembly System Interface) 的目标是什么?为什么在我们的
wasmtime设置中需要它? - 我们的 FaaS 平台为每个请求都创建了一个新的
Store和Instance。这被称为“冷启动”。它的优缺点是什么?你会如何设计一个能复用Instance(“热启动”)的系统来处理连续的请求? - 如果一个用户上传的 Wasm 函数中包含一个无限循环,我们的服务器会发生什么?你会如何防范这种“拒绝服务”攻击?(提示:
Store::limiter)。 - 除了
wasmtime,Rust 生态中还有其他流行的 Wasm 运行时,如wasmer。请简要了解一下wasmer,并比较它与wasmtime的异同。
实践练习
- 实现一个
rquickjs后端: 仿照本章的wasmtime实现,创建另一个路由/run/js/:function_name,它会加载一个.js文件,使用rquickjs来执行其中的handler函数,并处理请求和响应。 - 提供键值存储宿主 API:
- 在
AppState中添加一个Arc<DashMap<String, String>>作为全局的键值存储。 - 在创建
Linker时,定义并链接一个名为host_kv_set(key_ptr, key_len, val_ptr, val_len)的宿主函数,它的实现会将数据写入DashMap。 - 在你的
guest-functionWasm 代码中,声明并调用这个外部函数。
- 在
- 实现函数部署: 创建一个
POST /deploy/:function_name的axum路由,它接收一个application/wasm的请求体,并将上传的 Wasm 二进制数据保存到磁盘,以便/run接口可以加载它。 - 资源限制: 阅读
wasmtime的文档,学习如何使用Config::epoch_interruption或Store::limiter来限制 Wasm 函数的执行时间或内存使用,以防止恶意代码消耗过多资源。
更多推荐
所有评论(0)