实验17:使用 Wasm 和 Go 扩展 Envoy

在这个实验中,我们将使用 TinyGoproxy-wasm-go-sdkfunc-e CLI 来构建和测试一个 Envoy Wasm 扩展。

我们将写一个简单的 Wasm 模块,为响应头添加一个头。稍后,我们将展示如何读取配置和添加自定义指标。我们将使用 Golang 并使用 TinyGo 编译器进行编译。

安装 TinyGo

让我们下载并安装 TinyGo。

wget https://github.com/tinygo-org/tinygo/releases/download/v0.21.0/tinygo_0.21.0_amd64.deb
sudo dpkg -i tinygo_0.21.0_amd64.deb

你可以运行 tinygo version 来检查安装是否成功。

$ tinygo version
tinygo version 0.21.0 linux/amd64 (using go version go1.17.2 and LLVM version 11.0.0)

为 Wasm 模块搭建脚手架

我们将首先为我们的扩展创建一个新的文件夹,初始化 Go 模块,并下载 SDK 依赖。

$ mkdir header-filter && cd header-filter
$ go mod init header-filter
$ go mod edit -require=github.com/tetratelabs/proxy-wasm-go-sdk@main
$ go mod download github.com/tetratelabs/proxy-wasm-go-sdk

接下来,让我们创建 main.go 文件,其中有我们 WASM 扩展的代码。

package main

import (
  "github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm"
  "github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm/types"
)

func main() {
  proxywasm.SetVMContext(&vmContext{})
}

type vmContext struct {
  // Embed the default VM context here,
  // so that we don't need to reimplement all the methods.
  types.DefaultVMContext
}

// Override types.DefaultVMContext.
func (*vmContext) NewPluginContext(contextID uint32) types.PluginContext {
  return &pluginContext{}
}

type pluginContext struct {
  // Embed the default plugin context here,
  // so that we don't need to reimplement all the methods.
  types.DefaultPluginContext
}

// Override types.DefaultPluginContext.
func (*pluginContext) NewHttpContext(contextID uint32) types.HttpContext {
  return &httpHeaders{contextID: contextID}
}

type httpHeaders struct {
  // Embed the default http context here,
  // so that we don't need to reimplement all the methods.
  types.DefaultHttpContext
  contextID uint32
}

func (ctx *httpHeaders) OnHttpRequestHeaders(numHeaders int, endOfStream bool) types.Action {
  proxywasm.LogInfo("OnHttpRequestHeaders")
  return types.ActionContinue
}

func (ctx *httpHeaders) OnHttpResponseHeaders(numHeaders int, endOfStream bool) types.Action {
  proxywasm.LogInfo("OnHttpResponseHeaders")
  return types.ActionContinue
}

func (ctx *httpHeaders) OnHttpStreamDone() {
  proxywasm.LogInfof("%d finished", ctx.contextID)
}

将上述内容保存在一个名为 main.go 的文件中。

让我们建立过滤器,检查是否一切正常。

tinygo build -o main.wasm -scheduler=none -target=wasi main.go

构建命令应该成功运行并生成一个名为 main.wasm 的文件。

我们将使用 func-e 来运行一个本地 Envoy 实例来测试我们构建的扩展。

首先,我们需要一个 Envoy 配置,它将配置扩展。

static_resources:
  listeners:
    - name: main
      address:
        socket_address:
          address: 0.0.0.0
          port_value: 10000
      filter_chains:
        - filters:
            - name: envoy.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: local_route
                  virtual_hosts:
                    - name: local_service
                      domains:
                        - "*"
                      routes:
                        - match:
                            prefix: "/"
                          direct_response:
                            status: 200
                            body:
                              inline_string: "hello world\n"
                http_filters:
                  - name: envoy.filters.http.wasm
                    typed_config:
                      "@type": type.googleapis.com/udpa.type.v1.TypedStruct
                      type_url: type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm
                      value:
                        config:
                          vm_config:
                            runtime: "envoy.wasm.runtime.v8"
                            code:
                              local:
                                filename: "main.wasm"
                  - name: envoy.filters.http.router
admin:
  address:
    socket_address:
      address: 127.0.0.1
      port_value: 9901

将上述内容保存到 8-lab-2-wasm-config.yaml 文件。

Envoy 的配置在 10000 端口设置了一个监听器,返回一个直接响应(HTTP 200),正文是 hello world。在 http_filters 部分,我们配置了 envoy.filters.http.wasm 过滤器,并引用了我们之前建立的本地 WASM 文件(main.wasm)。

让我们在后台用这个配置运行 Envoy。

func-e run -c 8-Lab-2-wasm-config.yaml &

Envoy 实例的启动应该没有任何问题。一旦启动,我们就可以向 Envoy 监听的端口(10000)发送一个请求。

$ curl -v localhost:10000
...
< HTTP/1.1 200 OK
< content-length: 13
< content-type: text/plain
< my-new-header: some-value-here
< date: Mon, 22 Jun 2021 17:02:31 GMT
< server: envoy
<
hello world

输出显示了两个日志条目:一个来自 OnHttpRequestHeaders 处理器,第二个来自 OnHttpResponseHeaders 处理器。最后一行是过滤器中的直接响应配置所返回的响应示例。

你可以通过用 fg 把进程带到前台,然后按 CTRL+C 停止代理。

在 HTTP 响应上设置附加头信息

让我们打开 main.go 文件,在响应头信息中添加一个头信息。我们将更新 OnHttpResponseHeaders 函数来做到这一点。

我们将调用 AddHttpResponseHeader 函数来添加一个新的头。更新 OnHttpResponseHeaders 函数,使其看起来像这样。

func (ctx *httpHeaders) OnHttpResponseHeaders(numHeaders int, endOfStream bool) types.action {
  proxywasm.LogInfo("OnHttpResponseHeaders")
  err := proxywasm.AddHttpResponseHeader("my-new-header", "some-value-here")
  if err != nil {
    proxywasm.LogCriticalf(" failed to add response header: %v", err)
  }
  return types.ActionContinue
}

让我们重新建立扩展。

tinygo build -o main.wasm -scheduler=none -target=wasi main.go

现在我们可以用更新后的扩展来重新运行 Envoy 代理。

func-e run -c 8-Lab-2-wasm-config.yaml &

现在,如果我们再次发送一个请求(确保添加 -v 标志),我们将看到被添加到响应中的头。

$ curl -v localhost:10000
...
< HTTP/1.1 200 OK
< content-length: 13
< content-type: text/plain
< my-new-header: some-value-here
< date: Mon, 22 Jun 2021 17:02:31 GMT
< server: envoy
<
hello world

从配置中读取数值

在代码中硬编码这样的值从来不是一个好主意。让我们看看我们如何读取额外的头文件。

1.additionalHeaderscontextID 添加到 pluginContext 结构体中:

type pluginContext struct {
// 在这里嵌入默认的插件上下文。
// 这样我们就不需要重新实现所有的方法了。
    types.DefaultPluginContext
    additionalHeaders map[string]string
    contextID uint32
}

2. 更新 NewPluginContext 函数以初始化数值。

func (*vmContext) NewPluginContext(contextID uint32) types.PluginContext {
  return &pluginContext{contextID: contextID, additionalHeaders: map[string]string{}}
}

3.OnPluginStart 函数中,我们现在可以从 Envoy 配置中读入值,并将键 / 值对存储在 extrapperHeaders 映射中。

  func (ctx *pluginContext) OnPluginStart(pluginConfigurationSize int) types.OnPluginStartStatus {
    // Get the plugin configuration
    config, err := proxywasm.GetPluginConfiguration()
    if err != nil && err != types.ErrorStatusNotFound {
      proxywasm.LogCriticalf("failed to load config: %v", err)
      return types.OnPluginStartStatusFailed
    }

    // Read the config
    scanner := bufio.NewScanner(bytes.NewReader(config))
    for scanner.Scan() {
      line := scanner.Text()
      if strings.HasPrefix(line, "#") {
        continue
      }
      // Each line in the config is in the "key=value" format
      if tokens := strings.Split(scanner.Text(), "="); len(tokens) == 2 {
        ctx.additionalHeaders[tokens[0]] = tokens[1]
      }
    }
    return types.OnPluginStartStatusOK
  }

为了访问我们设置的配置值,我们需要在初始化 HTTP 上下文时将该地图添加到 HTTP 上下文中。要做到这一点,我们需要先更新 httpheaders 结构。

type httpHeaders struct {
  // 在这里嵌入默认的http上下文。
  // 这样我们就不需要重新实现所有的方法了。
  types.DefaultHttpContext
  contextID uint32
  additionalHeaders map[string]string
}

然后,在 NewHttpContext 函数中,我们可以用来自插件上下文的附加 Header map 来实例化 httpHeaders。

func (ctx *pluginContext) NewHttpContext(contextID uint32) types.HttpContext {
  return &httpHeaders{contextID: contextID, additionalHeaders: ctx.additionalHeaders}
}

最后,为了设置 Header,我们修改了 OnHttpResponseHeaders 函数,遍历 extraHeaders 映射,并为每个项目调用 AddHttpResponseHeader

func (ctx *httpHeaders) OnHttpResponseHeaders(numHeaders int, endOfStream bool) types.Action {
  proxywasm.LogInfo("OnHttpResponseHeaders")

  for key, value := range ctx.additionalHeaders {
    if err := proxywasm.AddHttpResponseHeader(key, value); err != nil {
        proxywasm.LogCriticalf("failed to add header: %v", err)
        return types.ActionPause
    }
    proxywasm.LogInfof("header set: %s=%s", key, value)
  }

  return types.ActionContinue
}

让我们再次重建这个扩展。

tinygo build -o main.wasm -scheduler=none -target=wasi main.go

另外,让我们更新配置文件,在过滤器配置(configuration 字段)中包括额外的头信息。

- name: envoy.filters.http.wasm
  typed_config:
    "@type": type.googleapis.com/udpa.type.v1.TypedStruct
    type_url: type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm
    value:
      config:
        vm_config:
          runtime: "envoy.wasm.runtime.v8"
          code:
            local:
              filename: "main.wasm"
        # ADD THESE LINES
        configuration:
          "@type": type.googleapis.com/google.protobuf.StringValue
          value: |
            header_1=somevalue
            header_2=secondvalue            

随着过滤器的更新,我们可以重新运行代理。当你发送一个请求时,你会注意到我们在过滤器配置中设置的头信息被添加为响应头信息。

$ curl -v localhost:10000
...
< HTTP/1.1 200 OK
< content-length: 13
< content-type: text/plain
< header_1: somevalue
< header_2: secondvalue
< date: Mon, 22 Jun 2021 17:54:53 GMT
< server: envoy
...

添加一个指标

让我们添加另一个功能 —— 计数器,每次有一个叫 hello 的请求头被设置时都会增加。

首先,让我们更新 pluginContext 以包括 helloHeaderCounter

type pluginContext struct {
  // 在这里嵌入默认的插件上下文。
  // 这样我们就不需要重新实现所有的方法了。
  types.DefaultPluginContext
  additionalHeaders map[string]string
  contextID uint32
  // 添加这一行
  helloHeaderCounter proxywasm.MetricCounter 
}

有了结构中的计数器指标,我们现在可以在 NewPluginContext 函数中创建它。我们将调用头信息 hello_header_counter

func (*vmContext) NewPluginContext(contextID uint32) types.PluginContext {
  return &pluginContext{contextID: contextID, additionalHeaders: map[string]string{}, helloHeaderCounter: proxywasm.DefineCounterMetric("hello_header_counter") }
}

由于我们要检查传入的请求头以决定是否增加计数器,我们需要将 helloHeaderCounter 也添加到 httpHeaders 结构中。

type httpHeaders struct {
  // 在这里嵌入默认的http上下文。
  // 这样我们就不需要重新实现所有的方法了。
  types.DefaultHttpContext
  contextID uint32
  additionalHeaders map[string]string
  // 添加这一行
  helloHeaderCounter proxywasm.MetricCounter
}

另外,我们需要从 pluginContext 中获取计数器,并在创建新的 HTTP 上下文时设置它。

// 覆盖 types.DefaultPluginContext
func (ctx *pluginContext) NewHttpContext(contextID uint32) types.HttpContext {
  return &httpHeaders{contextID: contextID, additionalHeaders: ctx.additionalHeaders, helloHeaderCounter: ctx.helloHeaderCounter}
}

现在,我们已经将 helloHeaderCounter 一直输送到 httpHeaders 中,我们可以在 OnHttpRequestHeaders 函数中使用它。

func (ctx *httpHeaders) OnHttpRequestHeaders(numHeaders int, endOfStream bool) types.action {
  proxywasm.LogInfo("OnHttpRequestHeaders")

  _, err := proxywasm.GetHttpRequestHeader("hello")
  if err != nil {
    // 如果头没有被设置,则忽略
    return types.ActionContinue
  }

  ctx.helloHeaderCounter.Increment(1)
  proxywasm.LogInfo("hello_header_counter incremented")
  return types.ActionContinue
}

在这里,我们要检查 “hello” 请求头是否被定义(注意,我们并不关心头的值),如果它被定义,我们就在计数器实例上调用 Increment 函数。否则,我们将忽略它,如果我们从 GetHttpRequestHeader 调用中得到一个错误,则返回 ActionContinue。

让我们再次重建这个扩展。

tinygo build -o main.wasm -scheduler=none -target=wasi main.go

然后重新运行 Envoy 代理。像这样发出几个请求。

curl -H "hello: something" localhost:10000

你会注意到像这样的日志 Envoy 日志条目。

wasm log: hello_header_counter incremented

你也可以使用 9901 端口的管理地址来检查指标是否被跟踪。

$ curl localhost:9901/stats/prometheus | grep hello
# TYPE envoy_hello_header_counter counter
envoy_hello_header_counter{} 1