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。其架构如下:

image.png

Proxy-Wasm Go SDK 封装了 Proxy-Wasm ABI 规范细节,降低了开发 Wasm 插件的门槛

1.3 Contexts

上下文(Contexts) 是 Proxy-Wasm Go SDK 中的接口集合,它们在  types  包中定义。有四种类型的上下文:VMContext、PluginContext、TcpContext 和 HttpContext。它们的关系如下图:

1
2
3
4
5
6
7
8
9
10
11
12
13
                    Wasm Virtual Machine
(.vm_config.code)
┌────────────────────────────────────────────────────────────────┐
│ Your program (.vm_config.code) TcpContext │
│ │ ╱ (Tcp stream) │
│ │ 1: 1 ╱ │
│ │ 1: N ╱ 1: N │
│ VMContext ────────── PluginContext │
│ (Plugin) ╲ 1: N │
│ ╲ │
│ ╲ HttpContext │
│ (Http stream) │
└────────────────────────────────────────────────────────────────┘
  1. VMContext 对应于每个 .vm_config.code,每个 VM 中只存在一个 VMContext。
  2. VMContext 是 PluginContexts 的父上下文,负责创建 PluginContext。
  3. PluginContext 对应于一个 Plugin 实例。一个 PluginContext 对应于 Http Filter、Network Filter、Wasm Service 的 configuration 字段配置。
  4. PluginContext 是 TcpContext 和 HttpContext 的父上下文,并且负责给处理 Http 流的 Http Filter 或 处理 Tcp 流的 Network Filter 创建上下文。
  5. TcpContext 负责处理每个 Tcp 流。
  6. 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
2
3
4
5
6
7
8
9
func main() {
proxywasm.SetVMContext(&myVMContext{})
}

type myVMContext struct { .... }

var _ types.VMContext = &myVMContext{}

// Implementations follow...

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。
image.png

在上图中,可以看到即使它们具有不同的二进制文件( hello.wasm 和 bye.wasm ),“vm_id=foo”的 VMs 也共享相同的共享数据存储。hostcall.go 中定义共享数据相关的 API 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// GetSharedData 用于检索给定 "key" 的值。
// 返回的 "cas" 应用于 SetSharedData 以实现该键的线程安全更新。
func GetSharedData(key string) (value []byte, cas uint32, err error)

// SetSharedData 用于在共享数据存储中设置键值对。
// 共享数据存储按主机中的 "vm_config.vm_id" 定义。
//
// 当给定的 CAS 值与当前值不匹配时,将返回 ErrorStatusCasMismatch。
// 这表明其他 Wasm VM 已经成功设置相同键的值,并且该键的当前 CAS 已递增。
// 建议在遇到此错误时实现重试逻辑。
//
// 将 cas 设置为 0 将永远不会返回 ErrorStatusCasMismatch 并且总是成功的,
// 但这并不是线程安全的,即可能在您调用此函数时另一个 VM 已经设置了该值,
// 看到的值与存储时的值已经不同。
func SetSharedData(key string, value []byte, cas uint32) error

共享数据 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// DequeueSharedQueue 从给定 queueID 的共享队列中出队数据。
// 要获取目标队列的 queue id,请先使用 "ResolveSharedQueue"。
func DequeueSharedQueue(queueID uint32) ([]byte, error)

// RegisterSharedQueue 在此插件上下文中注册共享队列。
// "注册" 意味着每当该 queueID 上有新数据入队时,将对此插件上下文调用 OnQueueReady。
// 仅适用于 types.PluginContext。返回的 queueID 可用于 Enqueue/DequeueSharedQueue。
// 请注意 "name" 必须在所有共享相同 "vm_id" 的 Wasm VMs 中是唯一的。使用 "vm_id" 来分隔共享队列的命名空间。
//
// 只有在调用 RegisterSharedQueue 之后,ResolveSharedQueue("此 vm_id", "名称") 才能成功
// 通过其他 VMs 检索 queueID。
func RegisterSharedQueue(name string) (queueID uint32, err error)

// EnqueueSharedQueue 将数据入队到给定 queueID 的共享队列。
// 要获取目标队列的 queue id,请先使用 "ResolveSharedQueue"。
func EnqueueSharedQueue(queueID uint32, data []byte) error

// ResolveSharedQueue 获取给定 vmID 和队列名称的 queueID。
// 返回的 queueID 可用于 Enqueue/DequeueSharedQueue。
func ResolveSharedQueue(vmID, queueName string) (queueID uint32, err error)

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 流。
image.png

2 Wasm 编程基础

2.1 Higress Wasm

原生的基于 proxy-wasm-go-sdk 的 Wasm 插件开发比较繁琐,因此 Higress 在这之上封装了一层,从而简化插件开发并且可以增强原生 sdk 的功能。打开 Higress Wasm 的代码,可以看到文件结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
tree
.
├── matcher
│ ├── rule_matcher.go
│ ├── rule_matcher_test.go
│ └── utils.go
└── wrapper
├── cluster_wrapper.go
├── cluster_wrapper_test.go
├── http_wrapper.go
├── log_wrapper.go
├── plugin_wrapper.go
├── redis_wrapper.go
└── request_wrapper.go
└── response_wrapper.go

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
type CommonVmCtx[PluginConfig any] struct {
// proxy-wasm-go-sdk DefaultVMContext 默认实现
types.DefaultVMContext
// 插件名称
pluginName string
// 插件日志工具
log Log
hasCustomConfig bool
// 插件配置解析函数
parseConfig ParseConfigFunc[PluginConfig]
// 插件路由、域名、服务级别配置解析函数
parseRuleConfig ParseRuleConfigFunc[PluginConfig]
// 以下是自定义插件回调钩子函数
onHttpRequestHeaders onHttpHeadersFunc[PluginConfig]
onHttpRequestBody onHttpBodyFunc[PluginConfig]
onHttpStreamingRequestBody onHttpStreamingBodyFunc[PluginConfig]
onHttpResponseHeaders onHttpHeadersFunc[PluginConfig]
onHttpResponseBody onHttpBodyFunc[PluginConfig]
onHttpStreamingResponseBody onHttpStreamingBodyFunc[PluginConfig]
onHttpStreamDone onHttpStreamDoneFunc[PluginConfig]
}

CommonPluginCtx 则是在 DefaultPluginContext 的基础上提供了更加方便的配置管理等。其结构体如下:

1
2
3
4
5
6
7
8
9
10
type CommonPluginCtx[PluginConfig any] struct {
// proxy-wasm-go-sdk DefaultPluginContext 默认实现
types.DefaultPluginContext
// 解析后保存路由、域名、服务级别配置和全局插件配置
matcher.RuleMatcher[PluginConfig]
// 引用 CommonVmCtx
vm *CommonVmCtx[PluginConfig]
// tickFunc 数组
onTickFuncs []TickFuncEntry
}

2.1.2 启动入口和 VM 上下文(CommonVmCtx)

CommonVmCtx 的钩子函数在插件中的启动入口如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func main() {
wrapper.SetCtx(
// 插件名称
"hello-world",
// 设置自定义函数解析插件配置,这个方法适合插件全局配置和路由、域名、服务级别配置内容规则是一样
wrapper.ParseConfigBy(parseConfig),
// 设置自定义函数解析插件全局配置和路由、域名、服务级别配置,这个方法适合插件全局配置和路由、域名、服务级别配置内容规则不一样
wrapper.ParseOverrideConfigBy(parseConfig, parseRuleConfig)
// 设置自定义函数处理请求头
wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders),
// 设置自定义函数处理请求体
wrapper.ProcessRequestBodyBy(onHttpRequestBody),
// 设置自定义函数处理响应头
wrapper.ProcessResponseHeadersBy(onHttpResponseHeaders),
// 设置自定义函数处理响应体
wrapper.ProcessResponseBodyBy(onHttpResponseBody),
// 设置自定义函数处理流式请求体
wrapper.ProcessStreamingRequestBodyBy(onHttpStreamingRequestBody),
// 设置自定义函数处理流式响应体
wrapper.ProcessStreamingResponseBodyBy(onHttpStreamingResponseBody),
// 设置自定义函数处理流式请求完成
wrappper.ProcessStreamDoneBy(onHttpStreamDone)
)
}

在实际编写插件时,这些不是全必选的,需要根据自己的实际业务来确定需要选哪个钩子。例如,我想拦截 HTTP 请求的 Body,之后针对 Body 的内容做一些操作,就需要通过 wrapper.SetCtx 来设置 wrapper.ProcessRequestBodyBy(onHttpRequestBody)。

wrapper.SetCtx 的底层实际上就是调用的 Wasm 原生的接口,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func SetCtx[PluginConfig any](pluginName string, setFuncs ...SetPluginFunc[PluginConfig]) {
// 调用 proxywasm.SetVMContext 设置 VMContext
proxywasm.SetVMContext(NewCommonVmCtx(pluginName, setFuncs...))
}


func NewCommonVmCtx[PluginConfig any](pluginName string, setFuncs ...SetPluginFunc[PluginConfig]) *CommonVmCtx[PluginConfig] {
ctx := &CommonVmCtx[PluginConfig]{
pluginName: pluginName,
log: Log{pluginName},
hasCustomConfig: true,
}
// CommonVmCtx 里设置自定义插件回调钩子函数
for _, set := range setFuncs {
set(ctx)
}
...
return ctx

2.1.3 插件上下文(CommonPluginCtx)

插件上下文主要是用来解析插件配置的,其核心代码在 OnPluginStart  方法中。我们摘取部分核心代码,大概看一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
func (ctx *CommonPluginCtx[PluginConfig]) OnPluginStart(int) types.OnPluginStartStatus {
// 调用 proxywasm.GetPluginConfiguration 获取插件配置
data, err := proxywasm.GetPluginConfiguration()
globalOnTickFuncs = nil
...
var jsonData gjson.Result
// 插件配置转成 json
jsonData = gjson.ParseBytes(data)


// 设置 parseOverrideConfig
var parseOverrideConfig func(gjson.Result, PluginConfig, *PluginConfig) error
if ctx.vm.parseRuleConfig != nil {
parseOverrideConfig = func(js gjson.Result, global PluginConfig, cfg *PluginConfig) error {
// 解析插件路由、域名、服务级别插件配置
return ctx.vm.parseRuleConfig(js, global, cfg, ctx.vm.log)
}
}
...
// 解析插件配置
err = ctx.ParseRuleConfig(jsonData,
func(js gjson.Result, cfg *PluginConfig) error {
// 解析插件全局或者当 parseRuleConfig 没有设置时候同时解析路由、域名、服务级别插件配置
return ctx.vm.parseConfig(js, cfg, ctx.vm.log)
},
parseOverrideConfig,
)
...
if globalOnTickFuncs != nil {
ctx.onTickFuncs = globalOnTickFuncs
...
}
return types.OnPluginStartStatusOK
}
  • 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插件

image.png

编译 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 之间