其他分享
首页 > 其他分享> > Istio服务网格Wasm过滤器

Istio服务网格Wasm过滤器

作者:互联网

背景信息

2019年前,Envoy是以静态编译的二进制文件方式来运行的,这意味着其所有扩展等均需要在构建阶段完成编译。因此其他工程(例如Istio)只能发布他们自己维护的自定义Envoy版本,一旦有更新或者Bug修复就不得不构建一个新的二进制版本、发布、重新部署到生产环境中。

上述问题虽然没有一个完美的解决方案,但是部分场景下可以通过C++的动态可加载性来实现,即在一种标准的二进制应用接口(ABI, Application Binary Interface)下编写、交付WebAssembly(WASM)模块,

WASM 本身是源自前端的技术,是为了解决日益复杂的前端 Web 应用以及有限的 JS 脚本解释性能而诞生的技术,通过该技术可以使用非 JavaScript 编程语言编写代码并且能在浏览器上运行。

随着WASM的发展,现在WASM不仅仅可以用于浏览器, 它已经被定义为一个可移植、体积小、加载快并且兼容 Web 的全新格式为一种可移植的二进制格式。

本文讨论的WASM用于以接近本机的速度在一个内存安全(memory-safe)的沙箱中执行多种语言编写的代码,在沙箱内有明确的资源限制和API来与内嵌的主机环境(例如Envoy)通信。

优点

缺点

Proxy-wasm

Proxy-Wasm是WASM扩展模块与L4/L7代理之间的二进制应用接口(ABI)规范与标准,其明确定义了主机环境与Wasm虚拟机之间、函数调用、内存管理等通信接口。

当前Proxy-wasm提供了AssemblyScript SDKC++ SDKGo (TinyGo) SDKRust SDKZig SDK SDK,支持EnvoyIstio Proxy (Istio基于Envoy的扩展)、MOSN等代理主机环境中运行。

整体架构

在每个Envoy工作线程上(事件驱动),内置的WASM运行时将创建一个Wasm虚拟机,通过Proxy-Wasm规范来校验、实例化WASM模块(本地磁盘文件或控制面板XDS推送的方式)。

WASM模块通过扩展接口进行调用时,Proxy-Wasm通过一个垫片进行转码、翻译在Wasm虚拟机上运行。

: Envoy 使用单进程 - 多线程的架构模型。一个 master 线程管理各种琐碎的任务,而一些 worker 线程则负责执行监听、过滤和转发。当监听器接收到一个连接请求时,该连接将其生命周期绑定到一个单独的 worker 线程。

运行时

Envoy内嵌了基于LLVM的WAVMV8两个C/C++ Wasm 运行时,在WASM模块配置时可进行选择。

Proxy-wasm-go-sdk

Go (TinyGo) SDK是一种基于Tinygo语言的Proxy-Wasm实现。

本文基于该项目标签v0.14.0进行阐述。

TinyGo

TinyGo是一个Go编译器,旨在用于微控制器,WebAssembly(WASM)和命令行工具等小型场景。它重用了Go语言工具和LLVM一起使用的库,以提供编译用Go编程语言编写的程序的另一种方法。

官方的Go编译器无法产生Proxy-Wasm所兼容的二进制文件,并且TinyGo相比较于Go另一个最主要的的差异是二进制尺寸。

根据TinyGo官方描述,最简单的"Hello world"程序,在strip命令加持下(移除所有符号标志与调试信息),Go编译器产生837kb大小的二进制,而TinyGo则为10kb,接近于1%的尺寸缩减效率。

使用TinyGo也存在一些限制和约束:

虽然不支持创建协程,但是Proxy-Wasm定义了OnTick函数,类似于定时器触发函数,可用于处理一些异步调用任务。

术语

Envoy配置

Envoy中Wasm过滤器配置如下:

 vm_config:
   vm_id: "foo"
   runtime: "envoy.wasm.runtime.v8"
   configuration:
     "@type": type.googleapis.com/google.protobuf.StringValue
     value: '{"my-vm-env": "dev"}'
   code:
     local:
       filename: "example.wasm"
 configuration:
   "@type": type.googleapis.com/google.protobuf.StringValue
   value: '{"my-plugin-config": "bar"}'
字段描述
vm_config配置Wasm虚拟机
vm_config.vm_id虚拟机的id,可用于配置跨虚拟机通信
vm_config.runtimeWasm 运行时类型,如: envoy.wasm.runtime.v8.
vm_config.configuration虚拟机配置,可在运行时动态读取以便配置不同的虚拟机上下文
vm_config.codeWasm二进制文件位置
configuration插件配置,可在运行时动态读取以便配置不同的插件上下文

字段vm_config所有属性为相同值时,则多个插件共享一个Wasm虚拟机,这在资源使用及启动时延上会有一定影响。

Http Filter

处理HTTP事件,即http协议流量,插件引用为envoy.filter.http.wasm,示例如下:

 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

Network Filter

处理TCP事件,即所有tcp流量(包括http流量),插件引用为envoy.filter.network.wasm,示例如下:

 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

: Http FilterNetwork Filter之间的差别仅仅是通过不同的配置,分别作用于TCP流或HTTP流。

Wasm Service

工作于主线程,配置在bootstrap_extensions中,插件引用为envoy.bootstrap.wasm,示例如下:

 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

其中singleton属性通常配置为true代表重用主线程的虚拟机,此时主线程不会阻塞工作线程中插件的运行。

Go SDK API

环境上下文(Contexts)

Go SDK 中接口的集合,一共有四种类型的上下文: VMContext, PluginContext, TcpContext and 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)    │
 └────────────────────────────────────────────────────────────────┘

虚拟机上下文VMContext源码定义如下:

 // VMContext 相当于Wasm虚拟机的配置,是扩展网络代理的入口点。其生命周期与Wasm虚拟机相同
 type VMContext interface {
     // 当Wasm虚拟机创建时, OnVMStart 被调用,期间API GetVMConfiguration 可用来检索配置中的 vm_config.configuration 属性
     // 这个函数主要用于Wasm虚拟机级别的初始化
     OnVMStart(vmConfigurationSize int) OnVMStartStatus
 ​
     // 根据插件配置来创建 PluginContext
     NewPluginContext(contextID uint32) PluginContext
 }

插件上下文PluginContext源码定义如下:

 // PluginContext 相当于每个不同的插件配置(config.configuration)
 // 每个配置通常在一个监听器的 http/tcp 过滤器中创建,因此 PluginContext 相当于创建网络过滤器实例
 type PluginContext interface {
     // 在 OnVmStart调用发生之后, OnPluginStart 将被调用,期间API GetPluginConfiguration 可用来检索配置中的 config.configuration 属性
     OnPluginStart(pluginConfigurationSize int) OnPluginStartStatus
 ​
     // 插件在主机中结束运行时, onPluginDone 被调用
     // 返回 false 代表着其出于 pending 状态,还有一些遗留工作需要完成
     // 这种情况下,必须调用方法 PluginDone() 来告诉主机工作已完成可以清除上下文
     OnPluginDone() bool
 ​
     // 当插件调用API RegisterQueue 后,其他插件将数据入队列, 本插件的 OnQueueReady 被调用
     OnQueueReady(queueID uint32)
 ​
     // 当通过API SetTickPeriodMilliSeconds 设置了定时周期并且时间已到时, 本插件的 OnTick 被调用
     // 本方法可以用于流处理期间并行的处理其他任务
     OnTick()
 ​
     // 开发者必须实现下面两者中的出于实际流数据的扩展入口点
     //
     // NewTcpContext 用来创建 TcpContext, 返回 nil 代表本插件不适用于 TcpContext
     NewTcpContext(contextID uint32) TcpContext
     // NewHttpContext 用来创建  HttpContext, 返回 nil 代表本插件不适用于 HttpContext.
     NewHttpContext(contextID uint32) HttpContext
 }

HttpContextTcpContext不再具体展开,可通过context.go查看详情。

主机调用API

主机调用API是Proxy-Wasm提供一系列方法用于与网络插件交互,例如在HttpContext中可以调用GetHttpRequestHeaders API来获取Http请求头数据, LogInfo API可以用来在日志中添加打印信息。

所有可用的API可通过hostcall.go查看详情。

入口点

当Envoy创建Wasm虚拟机时,在他创建VMContext 前它将调用程序中main函数,因此必须在main函数中实现自定义的VMContext

Proxywasm 包中的SetVMContext正是创建VMContext的入口点,借助于Proxywasm 包提供的DefaultVMContextmain函数一般如下:

 func main() {
     proxywasm.SetVMContext(&vmContext{})
 }
 ​
 type vmContext struct {
     // 嵌入默认提供的虚拟机上下文,这样就不必实现VMContext接口中的所有方法
     types.DefaultVMContext
 }
 ​
 // 覆盖DefaultVMContext中的NewPluginContext方法
 func (*vmContext) NewPluginContext(contextID uint32) types.PluginContext {
     return &pluginContext{}
 }
 ​
 type pluginContext struct {
     // 嵌入默认提供的插件上下文
     types.DefaultPluginContext
 }
 // 覆盖DefaultPluginContext中的NewTcpContext方法
 func (ctx *pluginContext) NewTcpContext(contextID uint32) types.TcpContext {
     return &networkContext{}
 }
 ​
 type networkContext struct {
     // 嵌入默认提供的Tcp上下文
     types.DefaultTcpContext
 }
 ​
 // 覆盖DefaultTcpContext的方法OnNewConnection
 func (ctx *networkContext) OnNewConnection() types.Action {
  ... ...   

跨虚拟机通信

上文中提到,在每个Envoy工作线程上内置的WASM运行时将创建一个Wasm虚拟机,某些特定场景下我们可能需要在当前虚拟机中与其他虚拟机通信,例如集成状态信息、缓存数据等。

当前提供了两种方案来实现跨虚拟机通信。

共享数据

共享数据(Shared Data)是一种基于键值对存储以跨虚拟机或跨线程共享数据的方案。

共享数据适用于场景如:

一份共享存储区通过vm_config.vm_id配置来创建,这意味着同一份wasm二进制文件(vm_config.code指定)不是必要条件。

 

如上图所示,两个虚拟机虽然使用hello.wasmbye.wasm两个二进制文件,由于其使用同一个vm_id (foo),但是他们却共享同一份数据存储区。

共享数据可用的API如下:

 // GetSharedData 用于检索指定的键
 // 返回的 "cas" 是方法 SetSharedData 中设置的用于保证线程安全更新的值
 func GetSharedData(key string) (value []byte, cas uint32, err error) 
 ​
 // SetSharedData 用于在共享存储区设置键值对
 // 若 CAS 值未匹配上当前值,则返回 ErrorStatusCasMismatch, 这意味着有其他Wasm虚拟机已经在这个键上设置了一个值,因此当前 CAS值递增更新,因此在变成逻辑中添加重试逻辑是非常有必要的
 // 设置 cas 为0时代表不进行CAS值比对,永远返回成功
 func SetSharedData(key string, data []byte, cas uint32) error

API相对简单,其使用了Compare-And-Swap方案来确保线程安全。

共享队列

共享队列(Shared Queue)是一种先进先出(FIFO, First-In-First-Out)的队列。

共享队列适用于场景如:

一个共享队列通过配置中的vm_config.vm_id和一个队列名称(vm_id, name)来创建,通过这两个产生可产生一个队列ID(queue_id)用于出/入队列。

共享队列可用的API如下:

 // ResolveSharedQueue 通过 vm_id 与 queue name 来产生ququeID, 用于 Enqueue/DequeueSharedQueue方法
 func ResolveSharedQueue(vmID, queueName string) (ququeID uint32, err error)
 ​
 // 通过 queueID 入队列
 func EnqueueSharedQueue(queueID uint32, data []byte) error 
 ​
 // 通过 queueID 出队列
 func DequeueSharedQueue(queueID uint32) ([]byte, error) 
 ​
 // RegisterSharedQueue 用于在插件上下文注册一个共享队列
 // 注册意味着当有数据入队列时,当前插件上下文的 OnQueueReady 方法被调用
 func RegisterSharedQueue(name string) (ququeID uint32, err error)

通常情况下, RegisterSharedQueueDequeueSharedQueue 被"消费者"调用,ResolveSharedQueueEnqueueSharedQueue为"生产者"使用:

因此两个方法都返回了队列ID。

环境上下文一节中提到,PluginContext中包含一个API OnQueueReady,这正是当有数据入队列时用于通知"消费者"的机制,当其他插件将数据入队列, 本插件的 OnQueueReady 被调用。

建议在单例的Wasm Service中(如Envoy的主线程)创建共享队列,否则当 OnQueueReady发生调用时,将阻塞当前工作线程Tcp/Http流处理。

 

如上图所示,主线程Wasm虚拟机(vm_id="foo", my-singleton.wasm)通过 RegisterQueue创建、注册了两个共享队列(分别命名为"http"与"tcp")。两个共享队列的"生产者"分别在 各自工作线程的Wasm虚拟机中实例化了HttpContextTcpContext用于处理Http与Tcp数据流。当他们往各自队列入队数据时,主线程中的PluginContext自动调用OnQueueReady方法用于获取队列数据。

样例测试

由于Wasm过滤器支持的功能较为丰富,本节仅进行简单的数据流打印测试,验证其在Tcp数据流中的作用。

部署样例服务

部署以下几个服务并且注入边车,验证服务正常运行:

 [root@linux ~]# kubectl -nwasm get po
 NAME                            READY   STATUS    RESTARTS   AGE
 goserver-7c5cc7cf6-lslcz        2/2     Running   2          4d2h
 sleep-558cdddbdb-g4wwd          2/2     Running   2          3d6h
 [root@linux ~]# kubectl -nwasm get svc
 NAME           TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)                       
 goserver       NodePort    10.107.169.228   <none>        9091:16814/TCP
 sleep          ClusterIP   10.96.185.32     <none>        80/TCP   
 [root@linux ~]# kubectl -nwasm exec -it goserver-7c5cc7cf6-lslcz -c goserver --  bash
 bash-4.4# curl 127.0.0.1:8081/healthz
 {"status":"healthy","hostName":"goserver-7c5cc7cf6-lslcz"}
 ​
 [root@linux ~]# kubectl -ndubbo get po -owide
 NAME                                        READY   STATUS          IP               
 dubbo-sample-consumer-6958b44b75-lf7sr      2/2     Running        10.244.104.60
 dubbo-sample-provider-v1-cfdcf7768-ptbld    2/2     Running        10.244.122.140   
 [root@linux ~]# kubectl -ndubbo get se dubbo-samples-demoservice -o yaml
 apiVersion: networking.istio.io/v1beta1
 kind: ServiceEntry
 ...
 spec:
   addresses:
   - 240.240.0.5
   endpoints:
   - address: 10.244.122.140
     ports:
       tcp-dubbo: 20880 
 [root@linux ~]# kubectl -ndubbo logs -f deploy/dubbo-sample-consumer
 ...
 Hello Aeraki, response from dubbo-sample-provider-v1-cfdcf7768-ptbld/10.244.122.140

构建Wasm过滤器

根据web-assembly-hub教程安装wasm客户端工具,并且初始化工程

 [root@linux ~]# wasm init --language tinygo --platform istio --platform-version 1.9.x tcp-stream-data
 INFO[0000] extracting 1416 bytes to /path/tcp-stream-data
 [root@linux ~]# tree .
 .
 ├── go.mod
 ├── go.sum
 ├── main.go
 └── runtime-config.json
 ​
 0 directories, 4 files

修改go.mod,使用最新版SDK:

 go 1.16
 ​
 require github.com/tetratelabs/proxy-wasm-go-sdk v0.14.0

修改其默认生成的main.go,使得其符合新版SDK的用法(默认应用v0.1.0版本),添加自定义打印函数:

 ...
 // 打印tcp连接属性
 func (ctx *networkContext) PrintConnectionAttrs() error {
     addr, err := proxywasm.GetProperty([]string{"source", "address"})  
     ......
     proxywasm.LogInfof("source address: %s", string(addr))
 ​
     dest, err := proxywasm.GetProperty([]string{"destination", "address"})
     ......
     proxywasm.LogInfof("destination address: %s", string(dest))
 ​
     return nil
 }
 ​
 // 打印envoy上游属性
 func (ctx *networkContext) PrintUpstreamAttrs() error {
     addr, err := proxywasm.GetProperty([]string{"upstream", "address"})  
     ......
     proxywasm.LogInfof("upstream address: %s", string(addr))
     
     return nil
 }

分别在OnDownstreamData(客户端请求数据)、OnUpstreamData(服务端响应数据)中引用上述自定义函数进行打印:

 func (ctx *networkContext) OnDownstreamData(dataSize int, endOfStream bool)types.Action{
     ......
     _ = ctx.PrintConnectionAttrs()
     _ = ctx.PrintUpstreamAttrs()
 ​
     data, err := proxywasm.GetDownstreamData(0, dataSize)
     ......
     
     proxywasm.LogInfof(">>>>>> downstream data received >>>>>>\n%s", string(data))
     return types.ActionContinue
 }
 ​
 func (ctx *networkContext) OnUpstreamData(dataSize int, endOfStream bool) types.Action {
     ......
     _ = ctx.PrintConnectionAttrs()
     _ = ctx.PrintUpstreamAttrs()
 ​
     data, err := proxywasm.GetUpstreamData(0, dataSize)
     ......
 ​
     proxywasm.LogInfof("<<<<<< upstream data received <<<<<<\n%s", string(data))
     return types.ActionContinue
 }

其中,Envoy支持引用的环境上下文属性参见其官网

将代码在linux环境下编译生成二进制文件:

 # 根据需要配置网络代理
 [root@linux ~]# export http_proxy=proxyIP:proxyPort
 [root@linux ~]# export GOPROXY=https://goproxy.cn,https://goproxy.io,direct
 # 编译 (在容器镜像quay.io/solo-io/ee-builder:0.0.33内完成编译,映射编译结果到本地磁盘)
 [root@linux ~]# wasm build tinygo . -t tcp-steam-data:test  --store ./build/
 Building with tinygo...go: downloading github.com/tetratelabs/proxy-wasm-go-sdk v0.14.0
 INFO[0007] adding image to cache...                      filter file=/tmp/wasme551366072/filter.wasm tag="tcp-steam-data:test"
 INFO[0007] tagged image                                  digest="sha256:fc1563eb463aeb31119104a923509d4e885063ad0bd64fcfd2f6dd4da79c2196" image="docker.io/library/tcp-steam-data:test"
 [root@linux ~]# ls -l build/79ada3a6417713a07a6c89d400f62306/
 -rw-r--r--. 1 root root  225 Aug  9 16:57 descriptor.json
 -rw-r--r--. 1 root root 255K Aug  9 16:57 filter.wasm
 -rw-r--r--. 1 root root   37 Aug  9 16:57 image_ref
 -rw-r--r--. 1 root root  126 Aug  9 16:57 runtime-config.json

应用Wasm过滤器

为简单起见,此处以hostPath方式挂载存储,使得sleep服务边车容器能访问得到本地生成的Wasm二进制文件:

 apiVersion: apps/v1
 kind: Deployment
 metadata:
   name: sleep
 ......
   template:
     metadata:
       annotations:
         sidecar.istio.io/userVolume: '[{"name":"host","host": {"path":"/host/path"}}]'
         sidecar.istio.io/userVolumeMount: '[{"mountPath":"/mount/path","name":"host"}]'

修改客户端边车容器日志级别为Info,默认为Warn,而上文代码中使用了proxywasm.LogInfof输入Info日志:

 [root@linux ~]# kubectl -nwasm exec deploy/sleep -- curl -X POST http://localhost:15000/logging?level=info
 active loggers:
   admin: info
   ......
   wasm: info
 [root@linux ~]# kubectl -ndubbo exec deploy/dubbo-sample-consumer -- curl -X POST http://localhost:15000/logging?level=info
 active loggers:
   admin: info
   ......
   wasm: info

由于本文将Wasm模块应用到SIDECAR_OUTBOUND环境,因此需要将客户端边车日志级别做调整。

编写EnvoyFilter资源,使得Wasm过滤器插入到最后一条过滤器envoy.filters.network.tcp_proxy之前:

 apiVersion: networking.istio.io/v1alpha3
 kind: EnvoyFilter
 metadata:
   name: goserver-wasm
   namespace: wasm
 spec:
   configPatches:
     - applyTo: NETWORK_FILTER
       match:
         context: SIDECAR_OUTBOUND
         listener:
           name: 10.107.169.228_9091     # (goserver) svcIP_svcPort
           filterChain:
             filter:
               name: envoy.filters.network.tcp_proxy  # tcp流量
       patch:
         operation: INSERT_BEFORE
         value:
           name: envoy.filters.network.wasm
           typed_config:
             '@type': type.googleapis.com/envoy.extensions.filters.network.wasm.v3.Wasm
             config:
               name: tcp-stream-data
               configuration:
                 '@type': type.googleapis.com/google.protobuf.StringValue
                 value: "empty string"
               vm_config:
                 vm_id: "same_vm_id"
                 runtime: "envoy.wasm.runtime.v8"
                 code:
                   local:
                     filename: "/mount/path/filter.wasm"

Dubbo协议的EnvoyFilter配置除了listener.name需要更换为ServiceEntry.spec.addresses,其他几乎一致,此处不在展开。

http流量

进入sleep服务,发送一个http请求:

 [root@linux ~]# kubectl -nwasm exec -it deploy/sleep -c sleep --  sh
 / # curl goserver:9091/healthz
 {"status":"healthy","hostName":"goserver-7c5cc7cf6-lslcz"}

注意,此时需要使用svcName:svcPort的方式进行调用,因为根据Envoy规则将根据请求的Host消息头进行主机域名匹配。

此时观察sleep服务边车容器日志:

 

根据上述结果可知,在OnDownstreamData(客户端请求数据)、OnUpstreamData(服务端响应数据)中打印的各方地址及端口数据是一致的,由于Http流量也属于Tcp流量,因此Http请求中的消息头与正文均被完整的打印了出来。

dubbo(tcp)流量

由于部署的客户端是定时请求服务端数据,因此不需要手动触发请求发送。

 

在dubbo协议流量中请求与响应数据也被正常打印,由于dubbo自定义的协议头等为非纯文本字符格式,因此数据中出现部分乱码。

但是正文部分仍然能看到请求中的接口、方法、消息类型等数据。

小结

上文对Wasm过滤器在Istio服务网格中的应用,从其架构原理、标准使用规范到样例测试,完整的展现了其在Envoy代理中的可插拔、可扩展的特性。

不过在实践过程中仍然会发现其在Istio运用中的问题:

相信之后官方会支持愈来愈多的语言编写Envoy的Wasm扩展。我们能够轻松选择本身熟悉的语言实现诸如度量,可观察性,转换,数据丢失预防,合规性验证或其余功能。

标签:插件,虚拟机,Istio,网格,Wasm,vm,wasm,config
来源: https://blog.csdn.net/a605692769/article/details/122262262