06 - WASM 插件开发
1 认识Wasm
1.1 什么是 Wasm
Wasm 可以理解为是一种轻量级的编码格式,它可以由多种语言编写的程序编译而来。最初 Wasm 是用于 Web 浏览器中,为了解决前端 JS 性能不足而发明的,但是在后面逐渐扩展到了后端以及云原生等多个领域。Wasm 有以下特点:
- 高效性能:提供了接近机器码的性能。
- 跨平台:Wasm 是一种与平台无关的格式,可以在任何支持它的平台上运行,包括浏览器和服务器。
- 安全性:Wasm 在一个内存安全的沙箱环境中运行,这意味着它可以安全地执行不受信任的代码,而不会访问或修改主机系统的其他部分。
- 可移植性:Wasm 模块可以被编译成 WebAssembly 二进制文件,这些文件可以被传输和加载到支持 Wasm 的任何环境中。
- 多语言支持:Wasm 支持多种编程语言,开发者可以使用 C、C++、Rust、Go 等多种语言编写代码,然后编译成 Wasm 格式。
1.2 Wasm VM
在 Envoy 中,VM 通常在每个线程中创建并相互隔离。因此 Wasm 程序将复制到 Envoy 所创建的线程里,并在这些虚拟机上加载并执行。插件提供了一种灵活的方式来扩展和自定义 Envoy 的行为。Proxy-Wasm 规范允许在每个 VM 中配置多个插件。因此一个 VM 可以被多个插件共同使用。Envoy 中有三种类型插件:Http Filter、Network Filter 和 Wasm Service。
- Http Filter 是一种处理 Http 协议的插件,例如操作 Http 请求头、正文等。
- Network Filter 是一种处理 Tcp 协议的插件,例如操作 Tcp 数据帧、连接建立等。
- Wasm Service 是在单例 VM 中运行的插件类型(即在 Envoy 主线程中只有一个实例)。它主要用于执行与 Network Filter 或 Http Filter 并行的一些额外工作,如聚合指标、日志等。这样的单例 VM 本身也被称为 Wasm Service。其架构如下:
Proxy-Wasm Go SDK 封装了 Proxy-Wasm ABI 规范细节,降低了开发 Wasm 插件的门槛
1.3 Contexts
上下文(Contexts) 是 Proxy-Wasm Go SDK 中的接口集合,它们在 types 包中定义。有四种类型的上下文:VMContext、PluginContext、TcpContext 和 HttpContext。它们的关系如下图:
1 | Wasm Virtual Machine |
- VMContext 对应于每个 .vm_config.code,每个 VM 中只存在一个 VMContext。
- VMContext 是 PluginContexts 的父上下文,负责创建 PluginContext。
- PluginContext 对应于一个 Plugin 实例。一个 PluginContext 对应于 Http Filter、Network Filter、Wasm Service 的 configuration 字段配置。
- PluginContext 是 TcpContext 和 HttpContext 的父上下文,并且负责给处理 Http 流的 Http Filter 或 处理 Tcp 流的 Network Filter 创建上下文。
- TcpContext 负责处理每个 Tcp 流。
- HttpContext 负责处理每个 Http 流。
自定义插件要实现 VMContext 和 PluginContext。同时 Http Filter 或 Network Filter,要分别实现 HttpContext 或 TcpContext。
1.4 Hostcall API
Hostcall API 是指在 Wasm 模块内调用 Envoy 提供的功能。这些功能通常用于获取外部数据或与 Envoy 交互。在开发 Wasm 插件时,需要访问网络请求的元数据、修改请求或响应头、记录日志等,这些都可以通过 Hostcall API 来实现。
Hostcall API 在 proxywasm 包的 hostcall.go 中定义。Hostcall API 包括配置和初始化、定时器设置、上下文管理、插件完成、共享队列管理、Redis 操作、Http 调用、TCP 流操作、HTTP 请求 / 响应头和体操作、共享数据操作、日志操作、属性和元数据操作、指标操作。
1.5 插件调用入口 Entrypoint
当 Envoy 创建 VM 时,在虚拟机内部创建 VMContext 之前,它会在启动阶段调用插件程序的 main 函数。所以必须在 main 函数中传递插件自定义的 VMContext 实现。proxywasm 包的 SetVMContext 函数是入口点。main 函数如下:
1 | func main() { |
1.5 跨虚拟机通信
Envoy 中的跨虚拟机通信(Cross-VM communications)允许不同线程在运行 的 Wasm 虚拟机(VMs)之间进行数据交换和通信。这在需要在多个 VMs 之间聚合数据、统计信息或缓存数据等场景中非常有用。跨虚拟机通信主要有两种方式:
- 共享数据(Shared Data):
- 共享数据是一种在所有 VMs 之间共享的键值存储,可以用于存储和检索简单的数据项。
- 它适用于存储小的、不经常变化的数据,例如配置参数或统计信息。
- 共享队列(Shared Queue):
- 共享队列允许 VMs 之间进行更复杂的数据交换,支持发送和接收更丰富的数据结构。
- 队列可以用于实现任务调度、异步消息传递等模式。
1.5.1 共享数据(Shared Data)
如果想要在所有 Wasm 虚拟机(VMs)运行的多个工作线程间拥有全局请求计数器,或者想要缓存一些应被所有 Wasm VMs 使用的数据,那么共享数据(Shared Data)或等效的共享键值存储(Shared KVS)就会发挥作用。共享数据本质上是一个跨所有 VMs 共享的键值存储(即跨 VM 或跨线程)。
共享数据 KVS 是根据 vm_config 中指定的创建的。可以在所有 Wasm VMs 之间共享一个键值存储,而它们不必具有相同的二进制文件 vm_config.code,唯一的要求是具有相同的 vm_id。
在上图中,可以看到即使它们具有不同的二进制文件( hello.wasm 和 bye.wasm ),“vm_id=foo”的 VMs 也共享相同的共享数据存储。hostcall.go 中定义共享数据相关的 API 如下:
1 | // GetSharedData 用于检索给定 "key" 的值。 |
共享数据 API 是其线程安全性和跨 VM 安全性,这通过“cas”(Compare-And-Swap)值来实现。
1.5.2 共享队列(Shared Queue)
如果要在请求 / 响应处理的同时跨所有 Wasm VMs 聚合指标,或者将一些跨 VM 聚合的信息推送到远程服务器,可以通过 Shared Queue 来实现。
Shared Queue 是为 vm_id 和队列名称的组合创建的 FIFO(先进先出)队列。并为该组合(vm_id,名称)分配了一个唯一的 queue id,该 ID 用于入队 / 出队操作。
“入队”和“出队”等操作具有线程安全性和跨 VM 安全性。在 hostcall.go 中与 Shared Queue 相关 API 如下:
1 | // DequeueSharedQueue 从给定 queueID 的共享队列中出队数据。 |
RegisterSharedQueue 和 DequeueSharedQueue 由队列的“消费者”使用,而 ResolveSharedQueue 和 EnqueueSharedQueue 是为队列“生产者”准备的。请注意:
- RegisterSharedQueue 用于为调用者的 name 和 vm_id 创建共享队列。使用一个队列,那么必须先由一个 VM 调用这个函数。这可以由 PluginContext 调用,因此可以认为“消费者” = PluginContexts。
- ResolveSharedQueue 用于获取 name 和 vm_id 的 queue id。这是为“生产者”准备的。
这两个调用都返回一个队列 ID,该 ID 用于 DequeueSharedQueue 和 EnqueueSharedQueue。同时当队列中入队新数据时消费者 PluginContext 中有 OnQueueReady(queueID uint32) 接口会收到通知。还强烈建议由 Envoy 的主线程上的单例 Wasm Service 创建共享队列。否则 OnQueueReady 将在工作线程上调用,这会阻塞它们处理 Http 或 Tcp 流。
2 Wasm 编程基础
2.1 Higress Wasm
原生的基于 proxy-wasm-go-sdk 的 Wasm 插件开发比较繁琐,因此 Higress 在这之上封装了一层,从而简化插件开发并且可以增强原生 sdk 的功能。打开 Higress Wasm 的代码,可以看到文件结构如下:
1 | tree |
Higress 插件 Go SDK 主要增强功能包括:
- matcher 包提供全局、路由、域名级别配置的解析功能。
- wrapper 包下 log_wrapper.go 封装和简化插件日志的输出功能。
- wrapper 包下 cluster_wrapper.go、redis_wrapper.go、http_wrapper.go 封装 Http 和 Redis Host Function Call。
- wrapper 包下 plugin_wrapper.go 封装 proxy-wasm-go-sdk 的 VMContext、PluginContext、HttpContext、插件配置解析功能。
- wrapper 包下 request_wrapper.go、response_wrapper.go 提供关于请求和响应公共方法。
2.1.1 Higress Wasm Go SDK 上下文
在原生 Wasm 中,存在 VMContext、PluginContext、HttpContext 3 个上下文结构体,在 Higress 中对这三个结构体进行了封装,支持了泛型。封装后的结构体名字为 CommonVmCtx、CommonPluginCtx、CommonHttpCtx。
CommonVmCtx 继承了 DefaultVMContext ,并在此基础上扩充了一些通用的工具方法,例如日志工具、解析函数、HTTP 通信的各个阶段的钩子函数等,其结构体如下:
1 | type CommonVmCtx[PluginConfig any] struct { |
CommonPluginCtx 则是在 DefaultPluginContext 的基础上提供了更加方便的配置管理等。其结构体如下:
1 | type CommonPluginCtx[PluginConfig any] struct { |
2.1.2 启动入口和 VM 上下文(CommonVmCtx)
CommonVmCtx 的钩子函数在插件中的启动入口如下所示:
1 | func main() { |
在实际编写插件时,这些不是全必选的,需要根据自己的实际业务来确定需要选哪个钩子。例如,我想拦截 HTTP 请求的 Body,之后针对 Body 的内容做一些操作,就需要通过 wrapper.SetCtx 来设置 wrapper.ProcessRequestBodyBy(onHttpRequestBody)。
wrapper.SetCtx 的底层实际上就是调用的 Wasm 原生的接口,代码如下:
1 | func SetCtx[PluginConfig any](pluginName string, setFuncs ...SetPluginFunc[PluginConfig]) { |
2.1.3 插件上下文(CommonPluginCtx)
插件上下文主要是用来解析插件配置的,其核心代码在 OnPluginStart 方法中。我们摘取部分核心代码,大概看一下。
1 | func (ctx *CommonPluginCtx[PluginConfig]) OnPluginStart(int) types.OnPluginStartStatus { |
- parseConfig :解析插件全局配置,如果 parseRuleConfig 没有设置,那么 parseConfig 会同时解析全局配置和路由、域名、服务级别配置。也就是说插件全局配置和路由、域名、服务级别配置规则是一样的。
- parseRuleConfig: 解析路由、域名、服务级别插件配置。如果设置 parseRuleConfig,也就是说插件全局配置和路由、域名、服务级别配置规则是不同的。
2.2.1 HTTP上下文(CommonHttpCtx)
HTTP 上下文的核心功能如下:
- 请求和响应的处理:CommonHttpCtx 提供了对 HTTP 请求和响应的全面控制能力。开发者可以通过它读取请求头、请求体、查询参数等信息,并根据需要修改响应头、响应体等内容。例如,可以在 onHttpRequestHeaders 钩子中检查请求头中的认证信息,或者在 onHttpResponseBody 钩子中对响应体进行加密或压缩。
- 流式处理:CommonHttpCtx 支持流式处理请求体和响应体。这对于处理大文件或实时数据流非常有用。通过 onHttpStreamingRequestBody 和 onHttpStreamingResponseBody 钩子,开发者可以逐块处理请求体或响应体,而不需要一次性加载整个内容。
- 配置管理:CommonHttpCtx 可以访问插件配置,这些配置可以是全局配置,也可以是针对特定路由、域名或服务的配置。通过 config 字段,开发者可以根据不同的配置执行不同的逻辑。例如,某些路由可能需要特殊的认证逻辑,而其他路由则不需要。
- 上下文管理:CommonHttpCtx 提供了 userContext 字段,允许开发者在 HTTP 请求的生命周期内存储和共享自定义数据。这对于需要在多个钩子函数之间传递数据的场景非常有用。例如,可以在 onHttpRequestHeaders 钩子中解析用户信息,并将其存储在 userContext 中,以便在后续的钩子中使用。
常用的钩子函数:
- onHttpRequestHeaders:在接收到请求头时触发。可以用于检查请求头中的认证信息、修改请求头等。
- onHttpRequestBody:在接收到请求体时触发。可以用于解析请求体内容、修改请求体等。
- onHttpResponseHeaders:在接收到响应头时触发。可以用于修改响应头、添加自定义头等。
- onHttpResponseBody:在接收到响应体时触发。可以用于修改响应体内容、加密或压缩响应体等。
- onHttpStreamingRequestBody:在接收到流式请求体时触发。可以用于逐块处理请求体。
- onHttpStreamingResponseBody:在接收到流式响应体时触发。可以用于逐块处理响应体。
- onHttpStreamDone:在流式处理完成时触发。可以用于清理资源或执行最终的处理逻辑。
2.1.2 Types.Action
Types.Action 是一个非常重要的枚举类型,它用于控制 HTTP 请求和响应的处理流程。通过返回不同的 Types.Action 值,开发者可以决定是否继续处理请求或者暂停处理。在自定义插件中 onHttpRequestHeaders、onHttpRequestBody、onHttpResponseHeaders、onHttpResponseBody 返回值类型为 types.Action。通过 types.Action 枚举值来控制插件的运行流程,常见的返回值有 2 个。
一个是 types.ActionContinue,继续后续处理,比如继续读取请求 body,或者继续读取响应 body;另一个是 types.ActionPause,暂停后续处理,比如在 onHttpRequestHeaders 通过 Http 或者 Redis 调用外部服务获取认证信息,在调用外部服务回调钩子函数中调用 proxywasm.ResumeHttpRequest() 来恢复后续处理 或者调用 proxywasm.SendHttpResponseWithDetail() 来返回响应。
2.2 编写Wasm插件
编译 wasm 插件:
1 | tinygo build -o main.wasm -scheduler=none -target=wasi -gc=custom -tags='custommalloc nottinygc_finalizer' ./main.go |
官方固定要求 TinyGo 必须是 0.28.1 版本,Go 版本会提示要求在 1.18~1.20 之间