为你的 Envoy 构建首个 WebAssembly 插件

原文链接:https://juejin.cn/post/7303798788719493157

WebAssembly (缩写为 Wasm) 是一种开放标准的二进制指令集,用于在 Web 浏览器中执行高性能的跨平台代码。它旨在成为一种通用的虚拟机,可以在各种环境中运行,不仅限于 Web 浏览器。WebAssembly 最初是为了提高 Web 应用程序的性能而设计的,但它已经扩展到其他领域,例如服务器端应用程序、嵌入式系统和桌面应用程序。本文主要介绍如何快速入门 Wasm。

介绍

借助一张网图大致了解下 Wasm,可以看到大部分主流语言都是能编译成 Wasm,然后借助 Wasm 的虚拟机 (运行环境) 在 x86 或 ARM 架构的系统上运行。

2cd517be9efff4d736dab71f3e994bcc.jpeg

下图下介绍 Wasm 能做什么以及 envoy filter 调用 Wasm 的时机:

  • 图上方:可以看到过滤器的介入是在 Wasm 的 OnStartOnConfigureOnHeaders 等 hook 中介入;

  • 图左侧:在 Wasm 编码中可以调用的 rpc 接口,当然不同的语言对于 rpc 的代码兼容也是有待完善,后面会讲述到这个内容;

  • 图右侧:wasm 提供的内置状态 API 及打印日志的方法;

  • 图下方:wasm 提供的获取请求头,请求内容,及设置返回内容的方法;

  • 图中间:wasm 默认使用的沙箱环境 chrome v8 引擎执行 Wasm 的代码。

f940b3c0f45b383fa4ef9a9b4f8038be.png

学会安装服务器服务器

wasmer 提供基于 WebAssembly 的超轻量级容器,其可以在任何地方运行:从桌面到云、以及 IoT 设备,并且也能嵌入到任何编程语言中 (它是 RUST 写的,所以对 Rust 支持是最好的,没有之一)。

wasmer 提供自动安装方式和手动方式安装,自动方式参考线上文档即可自动安装[1]

由于我们使用 golang 开发 Wasm 插件,所以还需要一个 tinygo 编译器将 golang 变异成 Wasm。

安装 Wasmer

下载安装包:

$ cd /usr/local/opt
$ wget https://github.com/wasmerio/wasmer/releases/download/v3.3.0/wasmer-darwin-arm64.tar.gz

安装:

$ tar -xvf wasmer-darwin-arm64.tar.gz
$ mv wasmer-darwin-arm64 wasmer
$ echo 'export PATH=/usr/local/opt/wasmer/bin:$PATH' >> ~/.bash_profile
$ source !$

检查版本号:

$ wasmer --version

安装 tinygo

下载安装包:

$ cd /usr/local/opt
$ wget https://github.com/tinygo-org/tinygo/releases

安装:

$ mv 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 !$

验证:

$ tinygo version
tinygo version 0.27.0 darwin/amd64 (using go version go1.19.4 and LLVM version 15.0.0)

安装优化工具链

Binaryen 是用 C + + 编写的 WebAssembly 编译器和工具链基础结构库。它的目标是使 WebAssembly 的编译变得简单、快速和有效:

下载:

$ wget https://github.com/WebAssembly/binaryen/releases/

安装:

$ cp -av binaryen /usr/local/opt/
$ echo 'export PATH=/usr/local/opt/binaryen/bin:$PATH' >> ~/.bash_profile

测试编译与运行

main.go

package main

import "fmt"

func main()  {
        fmt.Println("hello wasm.")

}

build Wasm

$ tinygo build  -target=wasi  -o main.wasm main.go

executing in local envrioment

$ wasmer main.wasm

proxy-wasm-go-sdk

proxy-wasm-go-sdk ` 是一个遵循 ABI 规范的 WebAssembly 开发工具,专为 L4/L7 代理而设计。该工具依赖于 Envoy 和 TinyGo。具体而言,它是为 Golang 开发的 Envoy WASM 插件提供支持的工具。

安装 golang 库:

$ go get github.com/tetratelabs/proxy-wasm-go-sdk@v0.18.0

入门实战

代码量不是特别多就不上传 github 了,也不想水多几篇就一篇写完了,懒~~~;

简单构建 Wasm

当前目录为 filter1

整体的代码构建类似于代码框架的周期 hook,下面是代码目录:

|____filter1
| |____httpcontext.go
| |____pluginctx.go
| |____vm.go
|____main.go

httpcontext.go 关键函数 OnHttpRequestHeadersOnHttpResponseHeaders,代码中给出 user 参数如果不等于 shadow 就会暂停往后端传递请求。

package 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

package 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

package 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 到现在为止代码都没什么难度,就没有解析代码,简单来说就有点像框架的生命周期。

package main

import (
        "github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm"
        "study-wasm/2_wasm/filter1"
)

func main() {
        proxywasm.SetVMContext(filter1.NewMyVM())
}

把以上代码编译成 Wasm:

$ cd 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 的配置即可。

admin:
  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 目录下。

$ 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 支持的不同功能。

$ curl http://127.0.0.1:8080
用户没有权限或缺少参数
$ curl http://127.0.0.1:8080?user=shadow
v1

Wasm 读取配置

这次演示的是 Wasm 如何读取配置,方式有很多下面演示如何读取 envoy 中配置,下面只放出变化部分的配置或代码。

envoy.yaml 主要配置了 configuration

...
...
                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

func (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 (重要)。

# 查看envoy 日志是否打印 插件配置
$ docker logs -f envoy

多个 Wasm 插件共享虚拟机

下图 (左) 可以看出 Wasm vm 并不运行在主线程上,所以它并不会阻碍主线程的运行;下图 (右) 可以看出多个 Wasm 服务运行在同一个 Wasm 虚拟机中,并不一定需要每个 Wasm 启动一个虚拟机。

b8009b99ced053b62291e70aeae23612.jpeg

从上图可以看出,主要标记相同的 vm_id 可以共享虚拟机。下面提供了 envoy.yaml 需要修改的部分。在配置中创建了两个 Wasm 插件,一个是 filter1,另一个是 filter2,而这两个插件配置的 vm_id 是相同的。

...
...
                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

...
...

多个 Wasm 插件共共享存储

上述描述阐述了多个 Wasm 插件共享同一个虚拟机 (VM) 的主要目的。这种共享虚拟机的设计旨在实现资源的更有效利用,而其中最为重要的优势之一是能够共享存储。

313afbd773a534c2aa4219be06557439.jpeg

延用 3.3 的 envoy 配置,我们将在插件 filter1 中存储数据,然后在 filter2 中获取数据;(filter1 和 filter2 是两套代码)

filter1 启动时设置共享数据,vm.go OnVMStart

func (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 将在返回响应时从共享存储中获取数据并返回到客户端。

func (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,访问代理。

# 可以看到 header 中存在 my_name: shadow
$ curl -v http://127.0.0.1:8080?user=shadow

对外请求

由于我们实际是通过 tinygo 进行代码编译,而 tinygo 仅支持 golang 的 net 包,而不支持 net/http 包,如果我们使用 net 包就构建 http 那就会比较复杂了,而且会有很多错误;所以我们将使用之前提到过的内置请求函数。

查看 tinygo 支持的包:

https://tinygo.org/docs/reference/lang-support/stdlib/

b7d533fcd619ec719e93bda9b61a70de.jpeg
编写异步请求

在通常情况下,我们了解到整个请求过程需要极低的延迟,而请求本身是一个网络的 IO。因此,SDK 为我们提供了一个异步的 RPC 请求方法,并且默认情况下不主动等待返回结果。

虽然 SDK 为我们提供了内置的 RPC 请求方式,但是并不允许我们直接访问外部 IP,而是只是放我们配置的上游服务,所以我们需要在 envoy 中增加一个上游服务。

clusters:
    ....
    ....
    - 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,留意下代码注释的细节。

func (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 请求的内容。

编写同步请求

在某些情境下,我们可能需要进行同步等待请求返回,比如在权限验证等情况下。同步请求意味着我们需要主动暂停主流程,等待请求返回后再恢复主流程。

httpcontext.go OnHttpRequestHeaders,原来的 types.ActionContinue 需要改成 types.ActionPause 以暂停主流程。如果出发需要直接返回终端请求,否则恢复请求。

func (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 请求的内容。

写在最后

Wasm 的基本入门编码方式就到这结束了,我们在生产上可以用作 istio gateway 的分流 (通过判断 header)、用户认证等场景。

引用链接

[1]

自动安装: https://github.com/wasmerio/wasmer-install

c3eb36155192033566a45c937eeb97c1.png

加入 Sealos 开源社区

体验像个人电脑一样简单的云操作系统

🏠官网链接

https://sealos.run

🐙GitHub 地址

https://github.com/labring/sealos

📑访问 Sealos 文档

https://sealos.run/docs/Intro

🏘️逛逛论坛

https://forum.laf.run/

往期推荐

eBPF 官方纪录片:打开内核交互新大门的魔法钥匙

2023-11-12

d0c6968afa24495b81941cc1c0362f5d.jpeg

脸贴脸教大家使用 Sealos 一键部署 Kubernetes 集群,老奶奶都会

2023-11-02

981b10d4c84fa6b1f3a8835242f02ff1.jpeg

这三大爆款开源项目竟出自同一个20人的小公司?

2023-10-16

9abc2b16077f83113743b40adc333790.jpeg

关于 Sealos

Sealos 是一款以 Kubernetes 为内核的云操作系统发行版。它以云原生的方式,抛弃了传统的云计算架构,转向以 Kubernetes 为云内核的新架构,使企业能够像使用个人电脑一样简单地使用云。

关注 Sealos 公众号与我们一同成长👇👇👇

ba8d77556dd5783abcee0b48bddb0a02.jpeg

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值