Wasm in Envoy
spec/docs/WebAssembly-in-Envoy.md at master · proxy-wasm/spec
wasm in envoy:为什么在envoy中使用Wasm扩展?
优点:
- Agility:敏捷,不用更新或者重启envoy就可以完成wasm二进制更新。
- 可靠性和隔离性:plugin运行在沙箱内,即使插件崩溃了,envoy本身不会受影响。
- 安全:proxy沙箱提供的API很明确,插件的行为完全可控。
- 多样性:支持的语言多
- 通用性:后续可能其他proxy会支持这个标准。
缺点:
- 如果使用多个VM的话,内存消耗会比较多。
- 对payload转码的性能会比较低,因为涉及到大量数据要和沙箱交互。
- CPU密集型的plugin性能比较低,和原生代码相比慢不到2倍。
对于VM和proxy-wasm扩展之间的映射模型,需要用户考虑启动延迟、资源利用率、隔离性以及安全多种因素之后合理选择。有以下这些模型可以考虑:
- 每个工作线程的每个wasm模块都有一个VM(在使用这个wasm extension的多个配置之间共享)。一个wasm模块可以有多个extension(比如lister filter和trasport socket都在同一个package里)。对于每个wasm模块,一旦创建了一个固定的VM,这个VM就可以被所有在配置中引用了它的wasm扩展共享。
- 每个工作线程的每个wasm扩展有一个VM。
- 每个wasm配置对应一个VM。多租首选,就是资源有点浪费。
- 每个请求一个VM,太浪费了。
- 线程外虚拟机:不做介绍。
看一下envoy中wasm filter的定义
{ "name": ..., # "root_id": ..., "vm_config": {...}, "configuration": {...}, "fail_open": ..., "capability_restriction_config": {...} }
name:当有多个filter都用了同一个vm_id和root_id时,这个字段可以用来唯一确定当前filter
root_id:主要是同一个VM中的一系列filter的集合,这个root id用来区分这个集合。如果使用了这个字段的话,这些filter将会共享RootContext以及其他Context。如果是空,所有VM_id相同的且没有填root_id的filter共享一套上下文。
vm_config:用来查找或者启动VM
configuration:Wasm插件配置。
fail_open:如果VM出现了致命错误,所有和这个VM关联的plugin都会关闭,此时会根据这个配置,返回503,或或者继续这个请求。注意:在on_start
或者on_configure
在xDS配置更新期间返回false,xDS config就会被拒绝;在on_start
或者on_configure
envoy启动期间返回false,envoy就起不来了。
capability_restriction_config:限制WASM程序可以做的系统调用。
{ "vm_id": ..., "runtime": ..., "code": {...}, "configuration": {...}, "allow_precompiled": ..., "nack_on_code_cache_miss": ..., "environment_variables": {...} }
vm_id:一个id,和代码的hash值一起用来决定这个plugin要在哪个VM上运行。所有vm_id相同的plugin都将使用相同的VM。可以是空。多个plugin共享VM,可以更方便的共享数据,但是也可能造成安全隐患。
runtime:选择的wasm runtime,不多解释。
code:wasm二进制程序。
configuration:在启动一个新的VM时(proxy_on_start
)需要用到的配置。如果是结构体,就会被序列化成hson,如果是bytes或者string,就会原封不动的传进去。
allow_precompiled:不多解释。
nack_on_code_cache_miss:不多解释。
environment_variables:指定环境变量,wasmplugin可以通过一些系统变量获取到。
Istio中和Wasm有关的内容
apiVersion extensions.istio.io/v1alpha1 kind WasmPlugin metadata name auth-wasm-plugin namespace default spec imagePullPolicy Always match mode SERVER selector matchLabels app xxxxx url xxxxxxxxx
{ "@type": "type.googleapis.com/envoy.admin.v3.EcdsConfigDump", "ecds_filters": [ { "version_info": "2024-02-29T08:09:15Z/52", "ecds_filter": { "@type": "type.googleapis.com/envoy.config.core.v3.TypedExtensionConfig", "name": "default.auth-wasm-plugin", "typed_config": { "@type": "type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm", "config": { "name": "default.auth-wasm-plugin", "vm_config": { "runtime": "envoy.wasm.runtime.v8", "code": { "local": { "filename": "xxxxxxx.wasm" } } }, "configuration": {} } } }, "last_updated": "2024-02-29T08:46:51.496Z" } ] }
Proxy-Wasm Go SDK介绍
术语简单解释:
- VM:用来运行wasm程序,wasm程序要被拷贝到VM中,然后运行。
- plugin是你扩展proxy的基本配置单元。允许一个VM中有多个plugin。使用本SDK,你可以在envoy中配置三种plugin:HTTPFilter、NetworkFilter以及wasm service。有了这些之后,你就可以编写一个程序,同时作为NetworkFilter和HTTPFilter去运行了。
- HTTPFilter用来处理HTTP层级的数据
- NetworkFilter用来处理TCP层面的数据
- wasm service通常是单独运行在虚拟机中的,它主要用来多一些和networkfilter以及httpfilter无关的事情,比如aggregating metrics。
envoy的配置
Http Filter
http_filters: - name: envoy.filters.http.wasm typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm config: vm_config: { ... } # ... plugin config follows - name: envoy.filters.http.router
在这种情况下,envoy的每个工作线程都会创建VM,每个VM就会处理对应工作线程上的listener上的http流。
Network Filter
filter_chains: - filters: - name: envoy.filters.network.wasm typed_config: "@type": type.googleapis.com/envoy.extensions.filters.network.wasm.v3.Wasm config: vm_config: { ... } # ... plugin config follows - name: envoy.tcp_proxy
在这种情况下,envoy的每个工作线程都会创建VM,每个VM就会处理对应工作线程上的listener上的tcp流。
wasm的http filter和networkfilter配置字段完全相同,唯一的不同就是两者操作的数据。
Wasm Service
你也可以单独跑一个wasm程序,不用融入envoy的那套请求处理流程
bootstrap_extensions: - name: envoy.bootstrap.wasm typed_config: "@type": type.googleapis.com/envoy.extensions.wasm.v3.WasmService singleton: true config: vm_config: { ... } # ... plugin config follows
在每个线程上的多个plugin之间共享VM
static_resources: listeners: - name: http-header-operation address: socket_address: address: 0.0.0.0 port_value: 18000 filter_chains: - filters: - name: envoy.http_connection_manager typed_config: # .... http_filters: - name: envoy.filters.http.wasm typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm config: configuration: "@type": type.googleapis.com/google.protobuf.StringValue value: "http-header-operation" vm_config: vm_id: "my-vm-id" runtime: "envoy.wasm.runtime.v8" configuration: "@type": type.googleapis.com/google.protobuf.StringValue value: "my-vm-configuration" code: local: filename: "all-in-one.wasm" - name: envoy.filters.http.router - name: http-body-operation address: socket_address: address: 0.0.0.0 port_value: 18001 filter_chains: - filters: - name: envoy.http_connection_manager typed_config: # .... http_filters: - name: envoy.filters.http.wasm typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm config: configuration: "@type": type.googleapis.com/google.protobuf.StringValue value: "http-body-operation" vm_config: vm_id: "my-vm-id" runtime: "envoy.wasm.runtime.v8" configuration: "@type": type.googleapis.com/google.protobuf.StringValue value: "my-vm-configuration" code: local: filename: "all-in-one.wasm" - name: envoy.filters.http.router - name: tcp-total-data-size-counter address: socket_address: address: 0.0.0.0 port_value: 18002 filter_chains: - filters: - name: envoy.filters.network.wasm typed_config: "@type": type.googleapis.com/envoy.extensions.filters.network.wasm.v3.Wasm config: configuration: "@type": type.googleapis.com/google.protobuf.StringValue value: "tcp-total-data-size-counter" vm_config: vm_id: "my-vm-id" runtime: "envoy.wasm.runtime.v8" configuration: "@type": type.googleapis.com/google.protobuf.StringValue value: "my-vm-configuration" code: local: filename: "all-in-one.wasm" - name: envoy.tcp_proxy typed_config: # ...
你可以看见,三个listener上的vm_config都一样,每个工作线程上的多个plugin会共享同一个wasm VM。这里又个小要求:如果要使用同一个VM,所有的这些vm_config.vm_id
vm_config.runtime
vm_config.configuration
vm_config.code
必须要相同。
应用上述配置之后,每个Wasm VM将会创建三个plugincontext,每个plugincontext对应上述的每个filter。
Proxy-Wasm Go SDK API
Contexts
Contexts是一系列接口的集合。用户需要实现这些接口,来实现特定的扩展。
有4类Context:VMContext
PluginContext
TcpContext
HttpContext
,他们的映射关系如下所示:
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) │ └────────────────────────────────────────────────────────────────┘
- 每个
.vm_config.code
对应一个VMContext
,VMContext
存在于每个虚拟机上。 VMContext
负责创建PluginContext
,可以创建任意数量。PluginContext
对应一个plugin instance。这意味着一个PluginContext
对应一个HttpFilter或者一个NetworkFilter或者一个WasmService,它通过pluginconfig中的.configuration
字段来配置。PluginContext
负责创建TcpContext
以及HttpContext
,他可以创建任意数量的这些context,只要用户在http filter或者networkfilter中配置了TcpContext
负责处理每一个TCP stream。HttpContext
负责处理每个Http stream。
所以你要做的就实现VMContext
和PluginContext
,如果你想插入到HttpFilter或者NetworkFilter中,只需要继续实现HttpContext
和TcpContext
。
type VMContext interface { // OnVMStart is called after the VM is created and main function is called. // During this call, GetVMConfiguration hostcall is available and can be used to // retrieve the configuration set at vm_config.configuration. // This is mainly used for doing Wasm VM-wise initialization. OnVMStart(vmConfigurationSize int) OnVMStartStatus // NewPluginContext is used for creating PluginContext for each plugin configurations. NewPluginContext(contextID uint32) PluginContext }
VM启动时,你可以通过GetVMConfiguration
来获取.vm_config.configuration
。通过这种方式,您可以进行VM级别的初始化控制。
然后就是PluginContext
,这里简化了一些方法:
type PluginContext interface { // OnPluginStart is called on all plugin contexts (after OnVmStart if this is the VM context). // During this call, GetPluginConfiguration is available and can be used to // retrieve the configuration set at config.configuration in envoy.yaml OnPluginStart(pluginConfigurationSize int) OnPluginStartStatus // The following functions are used for creating contexts on streams, // and developers *must* implement either of them corresponding to // extension points. For example, if you configure this plugin context is running // at Http filters, then NewHttpContext must be implemented. Same goes for // Tcp filters. // // NewTcpContext is used for creating TcpContext for each Tcp streams. NewTcpContext(contextID uint32) TcpContext // NewHttpContext is used for creating HttpContext for each Http streams. NewHttpContext(contextID uint32) HttpContext }
和VMContext
类似,PluginContext
也有OnPluginStart
,当pulgin初始化之后会被调用。调用这个方法时,.configuration
的值可以通过GetPluginConfiguration
方法获取。通过这种方式,开发人员可以告知一个PluginContext
改做什么动作,比如指定一个PluginContext
应该扮演一个HttpFilter,并且将指定的header插入http header中。
其他请自行参考具体的接口定义。
Hostcall API
一些提供的系统调用。主要用来和Proxy交互。在proxywasm包的hostcall.go中定义。比如GetHttpRequestHeaders
API可以通过HttpContext
访问一个http请求的header。hostcall.go中有完整的API列表。
Entrypoint
当envoy创建了VM之后,它会在创建VMContext
之前,在启动阶段先调用程序里的main函数。因此你比如在main函数中传递自己的VMContext
实现。
proxywasm包的SetVMContext
就是用来实现这个目的的,所以通常,main函数应该如下所示:
func main() { proxywasm.SetVMContext(&myVMContext{}) } type myVMContext struct { .... } var _ types.VMContext = &myVMContext{} // Implementations follow...
Cross-VM communications
VM是线程级别的,又是我们可能想和另一个VM通信,比如聚合数据、缓存数据等。
这里有两个概念:Shared Date和Shared Queue,可以看这个this talk
Shared Data
如果你想搞一个跨多个工作线程的所有VM统一的全局请求技术,该怎么办呢?或者你想缓存一些数据,所有的VM都会用到,又该怎么做呢?此时就应该考虑SharedData,或者称为Shared KVS(key-value store)。
Shared DAta相当于一个跨所有VM的kv存储。一个共享数据的kvs由vm_config
中指定的vm_id
创建。这意味着要共享kvs的话,必须要有相同的wasm二进制代码vm_config.code
。只要有相同的vm_id
就行。
上图就可以看出来,及时wasm二进制不通,也可以共享数据。
下面的api可以用来操作shared data,在hostcall.go中:
// GetSharedData is used for retrieving the value for given "key". // Returned "cas" is be used for SetSharedData on that key for // thread-safe updates. func GetSharedData(key string) (value []byte, cas uint32, err error) // SetSharedData is used for setting key-value pairs in the shared data storage // which is defined per "vm_config.vm_id" in the hosts. // // ErrorStatusCasMismatch will be returned when a given CAS value is mismatched // with the current value. That indicates that other Wasm VMs has already succeeded // to set a value on the same key and the current CAS for the key is incremented. // Having retry logic in the face of this error is recommended. // // Setting cas = 0 will never return ErrorStatusCasMismatch and always succeed, but // it is not thread-safe, i.e. maybe another VM has already set the value // and the value you see is already different from the one stored by the time // when you call this function. func SetSharedData(key string, value []byte, cas uint32) error
这个API很简单,但是他却通过cas机制实现了进程安全性以及跨VM安全性。
Shared Queue
如果你想要在并发的请求处理中跨VM做一些aggregate metrics改怎么搞呢?或者你想要将一些跨VM的集合信息发送给一个远程server呢?此时可以用Shared Queue。
Shared Queue是根据vm_id
和queue的名称来创建的一个FIFO队列。使用queue id和名称可以唯一的确定一个queue,可以用来做入队和出队操作。
这个队列里入队和出队操作都是线程安全并且是跨VM安全的。可以简单看下hostcall.go的说明:
// DequeueSharedQueue dequeues an data from the shared queue of the given queueID. // In order to get queue id for a target queue, use "ResolveSharedQueue" first. func DequeueSharedQueue(queueID uint32) ([]byte, error) // RegisterSharedQueue registers the shared queue on this plugin context. // "Register" means that OnQueueReady is called for this plugin context whenever a new item is enqueued on that queueID. // Only available for types.PluginContext. The returned queueID can be used for Enqueue/DequeueSharedQueue. // Note that "name" must be unique across all Wasm VMs which share a same "vm_id". // That means you can use "vm_id" can be used for separating shared queue namespace. // // Only after RegisterSharedQueue is called, ResolveSharedQueue("this vm_id", "name") succeeds // to retrive queueID by other VMs. func RegisterSharedQueue(name string) (queueID uint32, err error) // EnqueueSharedQueue enqueues an data to the shared queue of the given queueID. // In order to get queue id for a target queue, use "ResolveSharedQueue" first. func EnqueueSharedQueue(queueID uint32, data []byte) error // ResolveSharedQueue acquires the queueID for the given vm_id and queue name. // The returned queueID can be used for Enqueue/DequeueSharedQueue. func ResolveSharedQueue(vmID, queueName string) (queueID uint32, err error)
消费者负责调用:RegisterSharedQueue和DequeueSharedQueue。创建一个queue,从queue里取数据。
生产者使用ResolveSharedQueue和EnqueueSharedQueue来获取一个queue,并且给queue中加数据。
- RegisterSharedQueue用来根据name和vm_id来创建一个queue。如果你想要使用一个queue,就必须要先有一个vm创建它。比如可以在一个PluginContext中创建,此时就会将它视为消费者。
- ResolveSharedQueue用来通过name和vm_id获取一个queue。然后就可以添加数据。
从消费者的视角来看,该怎么知道什么时候queue里面有数据了呢?PluginContext中有一个接口叫OnQueueReady(queueID uint32)
。当前PluginContext注册的queue中如果有数据,这个方法就会被调用。
强烈推荐shared queue应该由一个单例的Wasm Service来创建(在envoy的主线程上)。否则,工作线程上调用OnQueueReady的话,可能会导致这些工作线程阻塞,进而影响http或者tcp流的处理。(目前istio暴露的接口,应该还不支持使用WasmService。)
限制和考量
现在不支持goroutine,不支持recover。
强烈建议使用OnTick
来完成异步编程,而不是goroutine。
注意
如何修改route
envoy的wasmfilter有一个tricky的地方,可能会让大家迷惑。
正常来说,要在httpfilter中更改路由决策,envoy的标准方法是:clearRouteCache()
。
proxywasm并没有提供这个接口。所以我们无法主动在wasm中清除路由缓存。
但是golang的proxywasm给了一个示例,改了header之后,路由目标变了。。。
原因是:envoy的wasmfilter实现的时候,在addHeaderMapValue、setHeaderMapPairs、removeHeaderMapValue、replaceHeaderMapValue这四个函数中默认调用了clearRouteCache()
,所以修改header就可以实现在wasm中修改路由。
此时,如果想搞metadatamatch,在wasm中加一个metadata,然后实现用metadata修改路由目标是不可行的。除非再同时改一个不关键的header,这个时候就能顺便清除路由缓存了。
在多说一句,envoy中其实也提供了一个测试的filter,专门用来clear-route-cache。这个在test包下,不确定默认会不会编译进去。