0. 前言
- WebAssembly (缩写为Wasm)是一种开放标准的二进制指令集,用于在Web浏览器中执行高性能的跨平台代码。它旨在成为一种通用的虚拟机,可以在各种环境中运行,不仅限于Web浏览器。WebAssembly 最初是为了提高Web应用程序的性能而设计的,但它已经扩展到其他领域,例如服务器端应用程序、嵌入式系统和桌面应用程序。本文主要介绍如何快速入门
wasm
1. 介绍
-
借助一张网图大致了解下 wasm, 可以看到大部分主流语言都是能编译成 wasm, 然后借助 wasm 的虚拟机 (运行环境) 在 x86 或 ARM 架构的系统上运行;
-
下图下介绍 wasm 能做什么以及 envoy filter 调用 wasm 的时机;
- 图上方: 可以看到过滤器的介入是在 wasm 的
OnStart
,OnConfigure
,OnHeaders
等 hook 中介入; - 图左侧: 在 wasm 编码中可以调用的 rpc 接口, 当然不同的语言对于 rpc 的代码兼容也是有待完善, 后面会讲述到这个内容;
- 图右侧: wasm 提供的内置状态API 及 打印日志的方法;
- 图下方: wasm 提供的获取请求头, 请求内容, 及设置返回内容的方法;
- 图中间: wasm 默认使用的沙箱环境 chrome v8引擎执行 wasm 的代码;
- 图上方: 可以看到过滤器的介入是在 wasm 的
2. 学会安装服务器服务器
-
wasmer
提供基于 WebAssembly 的超轻量级容器,其可以在任何地方运行:从桌面到云、以及 IoT 设备,并且也能嵌入到任何编程语言 中 (它是RUST写的,所以对Rust支持是最好的,没有之一); -
wasmer
提供自动安装方式和手动方式安装, 自动方式参考线上文档即可 自动安装 -
由于我们使用golang开发wasm 插件, 所以还需要一个
tinygo
编译器将 golang 变异成 wasm.
2.1 安装 wasmer
-
下载安装包
bashcd /usr/local/opt wget https://github.com/wasmerio/wasmer/releases/download/v3.3.0/wasmer-darwin-arm64.tar.gz
-
安装
bashtar -xvf wasmer-darwin-arm64.tar.gz mv wasmer-darwin-arm64 wasmer echo 'export PATH=/usr/local/opt/wasmer/bin:$PATH' >> ~/.bash_profile source !$
-
检查版本号
bashwasmer --version
2.2 安装 tinygo
-
下载安装包
bashcd /usr/local/opt wget https://github.com/tinygo-org/tinygo/releases
-
安装
bashmv tinygo /usr/local/opt/tinygo/ echo 'export PATH=/usr/local/opt/tinygo/bin:$PATH' >> ~/.bash_profile echo 'export TINYGOROOT=/usr/local/opt/tinygo' >> ~/.bash_profile source !$
-
验证
bashtinygo version # tinygo version 0.27.0 darwin/amd64 (using go version go1.19.4 and LLVM version 15.0.0)
2.3 安装优化工具链
Binaryen
是用 C + + 编写的 WebAssembly 编译器和工具链基础结构库。它的目标是使 WebAssembly 的编译变得简单、快速和有效:
-
下载
bashwget https://github.com/WebAssembly/binaryen/releases/
-
安装
bashcp -av binaryen /usr/local/opt/ echo 'export PATH=/usr/local/opt/binaryen/bin:$PATH' >> ~/.bash_profile
2.4 测试编译与运行
-
main.go
golangpackage main import "fmt" func main() { fmt.Println("hello wasm.") }
-
build wasm
bashtinygo build -target=wasi -o main.wasm main.go
-
executing in local envrioment
bashwasmer main.wasm
2.5 proxy-wasm-go-sdk
-
proxy-wasm-go-sdk
是一个遵循 ABI 规范的 WebAssembly 开发工具,专为 L4/L7 代理而设计。该工具依赖于 Envoy 和 TinyGo。具体而言,它是为 Golang 开发的 Envoy WASM 插件提供支持的工具。 -
安装 golang 库
bashgo get github.com/tetratelabs/proxy-wasm-go-sdk@v0.18.0
3. 入门实战
代码量不是特别多就不上传 github 了, 也不想水多几篇就一篇写完了, 懒~~~;
3.1 简单构建 wasm
当前目录为 filter1
-
整体的代码构建类似于代码框架的周期 hook, 下面是代码目录;
shell|____filter1 | |____httpcontext.go | |____pluginctx.go | |____vm.go |____main.go
-
httpcontext.go 关键函数
OnHttpRequestHeaders
和OnHttpResponseHeaders
, 代码中给出 user 参数如果不等于 shadow 就会暂停往后端传递请求;golangpackage filter1 import ( "github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm" "github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm/types" "net/url" ) const ( // 注意 proxywasm 获取 请求路径的方式 HttpPath = ":path" ) type MyHttpContext struct { types.DefaultHttpContext } func NewMyHttpContext() *MyHttpContext { return &MyHttpContext{} } func (this *MyHttpContext) OnHttpRequestHeaders(int, bool) types.Action { // 通过 header 获取request path hp, err := proxywasm.GetHttpRequestHeader(HttpPath) if err != nil { proxywasm.LogErrorf("get http path error: %s", err.Error()) } proxywasm.LogInfof("request path = %s", hp) urlParser, err := url.Parse(hp) if err != nil { proxywasm.LogError(err.Error()) } proxywasm.LogInfof("host = %s", urlParser.Host) proxywasm.LogInfof("uri = %s", urlParser.Path) proxywasm.LogInfof("params = %s", urlParser.RawQuery) // send response if user := urlParser.Query().Get("user"); user != "shadow" { _ = proxywasm.SendHttpResponse(401, [][2]string{ {"content-type", "application/json; charset=utf-8"}, }, []byte("用户没有权限或缺少参数"), -1) // 表示不可继续 return types.ActionPause } //表示正常action return types.ActionContinue } func (this *MyHttpContext) OnHttpResponseHeaders(numHeaders int, endOfStream bool) types.Action { err := proxywasm.AddHttpResponseHeader("hello", "world") if err != nil { proxywasm.LogErrorf("add header error: %s", err.Error()) } return types.ActionContinue }
-
pluginctx.go
OnPluginStart
golangpackage filter1 import ( "github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm" "github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm/types" ) var ( pluginStartCnt = 0 ) type HttpPluginContext struct { types.DefaultPluginContext } func NewHttpPluginContext() *HttpPluginContext { return &HttpPluginContext{} } func (this *HttpPluginContext) OnPluginStart(pluginConfigurationSize int) types.OnPluginStartStatus { pluginStartCnt++ proxywasm.LogInfof("pluginStartCnt: %d", pluginStartCnt) return types.OnPluginStartStatusOK } func (this *HttpPluginContext) NewHttpContext(contextID uint32) types.HttpContext { return NewMyHttpContext() }
-
vm.go
OnVMStart
golangpackage filter1 import ( "github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm" "github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm/types" ) type MyVM struct { types.DefaultVMContext } func NewMyVM() *MyVM { return &MyVM{} } func (this *MyVM) OnVMStart(vmConfigurationSize int) types.OnVMStartStatus { proxywasm.LogInfo("vm start filter 1") return types.OnVMStartStatusOK } func (this *MyVM) NewPluginContext(contextID uint32) types.PluginContext { return NewHttpPluginContext() }
-
main.go 到现在为止代码都没什么难度, 就没有解析代码, 简单来说就有点像框架的生命周期.
golangpackage main import ( "github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm" "study-wasm/2_wasm/filter1" ) func main() { proxywasm.SetVMContext(filter1.NewMyVM()) }
-
把以上代码编译成 wasm
bashcd study-wasm/2_wasm tinygo build -target=wasi -o myfilter1.wasm study-wasm/2_wasm/main.go
-
接下来需要启动 envoy, 我们将会使用 docker 启动 envoy, 在此之前需要先配置 envoy.yaml, 主要留意 http_filters wasm 的配置即可
yamladmin: address: socket_address: { address: 0.0.0.0, port_value: 9901 } static_resources: listeners: - name: listener_0 address: socket_address: { address: 0.0.0.0, port_value: 8080 } listener_filters: - name: "envoy.filters.listener.http_inspector" filter_chains: - filters: - name: envoy.filters.network.http_connection_manager typed_config: "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager stat_prefix: ingress_http codec_type: AUTO route_config: name: shadow-route virtual_hosts: - name: myhost domains: ["*"] routes: - match: {prefix: "/"} route: cluster: shadow_cluster_config http_filters: - name: envoy.filters.http.wasm typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm config: name: "study-wasm" # 这个root_id 随意就好 root_id: "test-filter" vm_config: runtime: "envoy.wasm.runtime.v8" # vm_id 可以用来共享 vm 后面会说到 vm_id: "f1" # 代码方式用本地挂载, 如果是生产环境可以配置 http url 的方式, 请自行查阅 envoy 文档 code: local: filename: "/filters/wasm/myfilter1.wasm" - name: envoy.filters.http.router clusters: # 上游配置的是 nginx 服务器 - name: shadow_cluster_config connect_timeout: 1s type: Static dns_lookup_family: V4_ONLY lb_policy: ROUND_ROBIN load_assignment: cluster_name: shadow_cluster endpoints: - lb_endpoints: - endpoint: address: socket_address: address: 172.17.0.5 port_value: 80
-
使用docker 启动 envoy, envoy 配置需要放置在
/opt/envoy/envoy.yaml
, 编译好的 wasm 文件需要放置在/opt/envoy/filters/wasm
目录下shell# docker run --name=envoy -d \ -p 9901:9901 \ -p 8080:8080 \ -v /opt/envoy/envoy.yaml:/etc/envoy/envoy.yaml \ -v /opt/envoy/filters/wasm:/filters/wasm \ envoyproxy/envoy-alpine:v1.21.0
-
启动后验证, 不带参数访问会产生 401 错误, 带正确参数访问成功获取数据. 到此我们已经简单的实现了wasm的拦截请求功能, 后续我们在上面代码的基础上进行部分修改以演示 wasm 支持的不同功能;
shell# curl http://127.0.0.1:8080 用户没有权限或缺少参数 # curl http://127.0.0.1:8080?user=shadow v1
3.2 wasm 读取配置
-
这次演示的是 wasm 如何读取配置, 方式有很多下面演示如何读取envoy中配置, 下面只放出变化部分的配置或代码;
-
envoy.yaml 主要配置了
configuration
yaml... ... http_filters: - name: envoy.filters.http.wasm typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm config: name: "study-wasm" root_id: "test-filter" configuration: "@type": type.googleapis.com/google.protobuf.StringValue value: | { "welcome_content": "欢迎登陆 xxx.com" } vm_config: runtime: "envoy.wasm.runtime.v8" vm_id: "f1" code: local: filename: "/filters/wasm/myfilter1.wasm" - name: envoy.filters.http.router ... ...
-
增加获取配置代码, 以下是在 pluginctx.go
OnPluginStart
golangfunc (this *HttpPluginContext) OnPluginStart(pluginConfigurationSize int) types.OnPluginStartStatus { // 获取 plugin 传递的 config, 对于配置插件 type.googleapis.com/google.protobuf.StringValue cfg, err := proxywasm.GetPluginConfiguration() if err != nil { proxywasm.LogErrorf("get plugin config error: %s", err.Error()) } pluginStartCnt++ proxywasm.LogInfof("pluginStartCnt: %d", pluginStartCnt) proxywasm.LogInfof("get plugin config: %s", string(cfg)) }
-
需要重新编译 wasm 插件及重启 envoy (重要)
shell// 查看envoy 日志是否打印 插件配置 # docker logs -f envoy
3.3 多个 wasm 插件共享虚拟机
-
下图(左) 可以看出 wasm vm 并不运行在主线程上, 所以它并不会阻碍主线程的运行; 下图(右) 可以看出多个 wasm 服务运行在同一个 wasm 虚拟机中, 并不一定需要每个wasm启动一个虚拟机;
-
从上图可以看出,主要标记相同的
vm_id
可以共享虚拟机。下面提供了envoy.yaml
需要修改的部分。在配置中创建了两个 wasm 插件,一个是filter1
,另一个是filter2
,而这两个插件配置的vm_id
是相同的。yaml... ... http_filters: - name: envoy.filters.http.wasm typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm config: name: "study-wasm-1" root_id: "test-filter-1" vm_config: runtime: "envoy.wasm.runtime.v8" # 需要指定 vm_id vm_id: "f1" code: local: filename: "/filters/wasm/myfilter1.wasm" - name: envoy.filters.http.wasm typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm config: name: "study-wasm-2" root_id: "test-filter-2" vm_config: runtime: "envoy.wasm.runtime.v8" # 需要指定 vm_id vm_id: "f1" code: local: filename: "/filters/wasm/myfilter2.wasm" - name: envoy.filters.http.router ... ...
3.4 多个 wasm 插件共共享存储
-
上述描述阐述了多个 Wasm 插件共享同一个虚拟机(VM)的主要目的。这种共享虚拟机的设计旨在实现资源的更有效利用,而其中最为重要的优势之一是能够共享存储。
-
延用 3.3 的envoy 配置, 我们将在插件
filter1
中存储数据, 然后在filter2
中获取数据; (filter1 和 filter2 是两套代码) -
在
filter1
启动时设置共享数据, vm.goOnVMStart
golangfunc (this *MyVM) OnVMStart(vmConfigurationSize int) types.OnVMStartStatus { proxywasm.LogInfo("vm start filter 1") // cas 是一个保证线程安全的值, 它会由 share-data 内部维护 if err := proxywasm.SetSharedData("my_name", []byte("shadow"), 1); err != nil && err != types.ErrorStatusCasMismatch { proxywasm.LogErrorf("on vm start error: %s", err.Error()) } return types.OnVMStartStatusOK }
-
在
filter2
将在返回响应时从共享存储中获取数据并返回到客户端;golangfunc (this *MyHttpContext) OnHttpResponseHeaders(numHeaders int, endOfStream bool) types.Action { // 如果需要重新设置 sharedata 则需要重新传入 cas, 让它单调递增 //v, cas, err := proxywasm.GetSharedData("my_name") // 跨 vm 获取share-data v, _, err := proxywasm.GetSharedData("my_name") if err != nil { proxywasm.LogError(err.Error()) return types.ActionContinue } if err := proxywasm.AddHttpResponseHeader("my_name", string(v)); err != nil { proxywasm.LogError(err.Error()) } return types.ActionContinue }
-
重新编译 2 个 wasm 插件及替换 envoy.yaml 并重启 envoy, 访问代理
shell// 可以看到 header 中存在 my_name: shadow # curl -v http://127.0.0.1:8080?user=shadow
3.5 对外请求
-
由于我们实际是通过 tinygo 进行代码编译, 而 tinygo 仅支持 golang 的 net 包, 而不支持 net/http 包, 如果我们使用 net 包就构建http那就会比较复杂了, 而且会有很多错误; 所以我们将使用之前提到过的内置请求函数;
-
查看 tinygo 支持的包: tinygo.org/docs/refere...
3.5.1 编写异步请求
-
在通常情况下,我们了解到整个请求过程需要极低的延迟, 而请求本身是一个网络的IO。因此,SDK为我们提供了一个异步的RPC请求方法,并且默认情况下不主动等待返回结果。
-
虽然 SDK 为我们提供了内置的 RPC 请求方式, 但是并不允许我们直接访问外部 IP, 而是只是放我们配置的上游服务, 所以我们需要在 envoy 中增加一个上游服务.
yamlclusters: .... .... - name: shadow_cluster_v2 connect_timeout: 1s type: Static dns_lookup_family: V4_ONLY lb_policy: ROUND_ROBIN load_assignment: cluster_name: shadow_cluster_v2 endpoints: - lb_endpoints: - endpoint: address: socket_address: address: 172.17.0.7 port_value: 80
-
httpcontext.go
OnHttpRequestHeaders
在请求到来时, 访问 shadow_cluster_v2, 留意下代码注释的细节golangfunc (this *MyHttpContext) OnHttpRequestHeaders(int, bool) types.Action { headers := [][2]string{ {":method", "GET"}, {":path", "/"}, // 这里由于没有域名解析所以使用地址 //{":authority", "172.17.0.7"}, //{"Host", "172.17.0.7"}, {"Host", "shadow_cluster_v2"}, {"accept", "*/*"}, {":scheme", "http"}, } // 由于 golang 不支持 net/http 包在 wasm 中使用, 所以这里使用 wasm 的http call, 所以需要在配置中设置上游地址 _, callerr := proxywasm.DispatchHttpCall("shadow_cluster_v2", headers, nil, nil, 1000, func(numHeaders, bodySize, numTrailers int) { b, err := proxywasm.GetHttpCallResponseBody(0, bodySize) if err != nil { proxywasm.LogError("http 调用出错" + err.Error()) } else { proxywasm.LogInfo("得到http请求内容: " + string(b)) } }) if callerr != nil { proxywasm.LogError(callerr.Error()) } return types.ActionContinue }
-
重新编译 wasm 插件及替换 envoy.yaml 并重启 envoy, 通过终端访问代理后查看envoy 的日志中是否打印 http 请求的内容;
3.5.2 编写同步请求
-
在某些情境下,我们可能需要进行同步等待请求返回,比如在权限验证等情况下。同步请求意味着我们需要主动暂停主流程,等待请求返回后再恢复主流程。
-
httpcontext.go
OnHttpRequestHeaders
, 原来的types.ActionContinue
需要改成types.ActionPause
以暂停主流程. 如果出发需要直接返回终端请求, 否则恢复请求.golangfunc (this *MyHttpContext) OnHttpRequestHeaders(int, bool) types.Action { // 在默认情况下 DispatchHttpCall 是异步请求,主线程不会等待我们完成就进行下一步操作; // 现在我们需要通过主线程Pause不再传递请求,直到我们完成并执行恢复函数; headers := [][2]string{ {":method", "GET"}, {":path", "/"}, // 这里由于没有域名解析所以使用地址 //{":authority", "172.17.0.7"}, //{"Host", "172.17.0.7"}, {"Host", "shadow_cluster_v2"}, {"accept", "*/*"}, {":scheme", "http"}, } _, callerr := proxywasm.DispatchHttpCall("shadow_cluster_v2", headers, nil, nil, 1000, func(numHeaders, bodySize, numTrailers int) { b, err := proxywasm.GetHttpCallResponseBody(0, bodySize) if err != nil { proxywasm.LogError("http 调用出错" + err.Error()) // 调用出错 _ = proxywasm.SendHttpResponse(500, [][2]string{ {"content-type", "application/json; charset=utf-8"}, }, []byte(fmt.Sprint("call shadow_cluster_v2 error: %s", err.Error())), -1) } else { proxywasm.LogInfo("得到http请求内容: " + string(b)) // 恢复请求 if err := proxywasm.ResumeHttpRequest(); err != nil { proxywasm.LogErrorf("恢复请求错误, err:%s", err.Error()) } } }) if callerr != nil { proxywasm.LogError(callerr.Error()) } return types.ActionPause //return types.ActionContinue }
-
重新编译 wasm 插件及替换 envoy.yaml 并重启 envoy, 通过终端访问代理后查看envoy 的日志中是否打印 http 请求的内容;
4. 写在最后
-
Wasm 的基本入门编码方式就到这结束了, 我们在生产上可以用作 istio gateway 的分流(通过判断header)、用户认证等场景.
-
后续我将可能更新的几个主题(下面的主题都有在工作中使用):
- Kubernetes ApiServer代理: 通过代理, 我们可以有效建立及管理每个用户的权限, 且由于我们代理了所有kubernetes 请求易于我们合规审计, 高危操作告警等功能的开发;
- Kubernetes 的调度: 包括调度器的入门, 以及如何编写场景调度, 跨集群调度;
- EBPF 的使用: XDP编写, 网络数据抓取及网络链路优化, 以及一些落地场景;
-
以上主题如果有兴趣的请在评论区留言, 我将优先开更;
-
👍点赞 ➕关注