diff --git a/src/components/starlight/ContentPanel.astro b/src/components/starlight/ContentPanel.astro index 042f07bcca..3e0fcc60fe 100644 --- a/src/components/starlight/ContentPanel.astro +++ b/src/components/starlight/ContentPanel.astro @@ -2,7 +2,7 @@ import type { Props } from "../props"; --- -
+
@@ -28,4 +28,9 @@ import type { Props } from "../props"; } } } + @media (min-width: 100rem) { + .large-screen-width .max-unfold{ + max-width: 100%!important; + } + } diff --git a/src/components/starlight/TwoColumnContent.astro b/src/components/starlight/TwoColumnContent.astro index 42e065e03b..9965c580da 100644 --- a/src/components/starlight/TwoColumnContent.astro +++ b/src/components/starlight/TwoColumnContent.astro @@ -90,6 +90,14 @@ import { Icon } from "@astrojs/starlight/components"; ); } } + @media (min-width: 100rem) { + .collapsed { + max-width: 20rem!important; + } + .max-unfold .sl-markdown-content .expressive-code{ + max-width: calc(100vw - var(--sl-sidebar-width) - 20rem); + } + } } @@ -151,7 +159,21 @@ import { Icon } from "@astrojs/starlight/components"; } } - document.addEventListener("astro:page-load", toggleSidebar); + // 大屏幕下文档三栏宽度自适应 100rem + function largeScreen() { + const toggleBtn = document.getElementById("toggle-btn"); + const remwidth = parseFloat(getComputedStyle(document.documentElement).fontSize) * 100; + const windowWidth = window.innerWidth; + if (toggleBtn && windowWidth > remwidth) { + toggleBtn.click(); + } + }; + + document.addEventListener("astro:page-load", ()=> { + toggleSidebar() + largeScreen() + }); + document.addEventListener("DOMContentLoaded", toggleSidebar); document.addEventListener("astro:page-load", addBackBtnClick); document.addEventListener("DOMContentLoaded", addBackBtnClick); diff --git a/src/constant.ts b/src/constant.ts index 51611c498b..153437481c 100644 --- a/src/constant.ts +++ b/src/constant.ts @@ -489,6 +489,14 @@ export const COMMUNITY_MENU_LIST = [ en: "Blog", }, }, + { + label: "电子书", + target: "_self", + link: "/docs/ebook/wasm14/", + translations: { + en: "E-book", + }, + }, ], }, ]; diff --git a/src/content/docs/ebook/_sidebar.json b/src/content/docs/ebook/_sidebar.json new file mode 100644 index 0000000000..a93f8fbc4f --- /dev/null +++ b/src/content/docs/ebook/_sidebar.json @@ -0,0 +1,52 @@ +[ + { + "label": "Wasm 插件和开发篇", + "translations": { + "en": "Development Wasm Plugin" + }, + "items": [ + { + "label": "Wasm 插件介绍和开发自定义插件", + "translations": { + "en": "Plugins and Development custom" + }, + "link": "docs/ebook/wasm14/" + }, + { + "label": "Wasm 插件原理", + "translations": { + "en": "Wasm Plugin principle" + }, + "link": "docs/ebook/wasm15/" + }, + { + "label": "Higress 插件GoSDK与处理流程", + "translations": { + "en": "Higress GoSDK Processing " + }, + "link": "docs/ebook/wasm16/" + }, + { + "label": "HTTP 调用", + "translations": { + "en": "HTTP Call" + }, + "link": "docs/ebook/wasm17/" + }, + { + "label": "Redis 调用", + "translations": { + "en": "Redis Call" + }, + "link": "docs/ebook/wasm18/" + }, + { + "label": "Wasm 生效原理", + "translations": { + "en": "Wasm Effective Principle " + }, + "link": "docs/ebook/wasm19/" + } + ] + } +] \ No newline at end of file diff --git a/src/content/docs/ebook/en/wasm14.md b/src/content/docs/ebook/en/wasm14.md new file mode 100644 index 0000000000..878af95f5c --- /dev/null +++ b/src/content/docs/ebook/en/wasm14.md @@ -0,0 +1,1072 @@ +--- +title: Wasm 插件介绍和开发自定义插件 +keywords: [Higress] +--- + +# Wasm 插件介绍和开发自定义插件 + +本章开始进入 Wasm 插件开发篇,主要介绍 Wasm 插件配置、Higress WasmPlugin CRD 以及如何开发自定义插件。 + +## 1 测试环境准备 + +> Higress 本地测试环境网关地址是 127.0.0.1,端口是 80 和 443。 + +准备 echo-server 和 Ingress, 其 YAML 配置如下: + +```yaml +apiVersion: v1 +kind: Namespace +metadata: + name: higress-course +--- +apiVersion: v1 +kind: Service +metadata: + name: echo-server + namespace: higress-course +spec: + selector: + app: echo-server + ports: + - protocol: TCP + port: 8080 + targetPort: 3000 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: echo-server + namespace: higress-course + labels: + app: echo-server +spec: + replicas: 1 + selector: + matchLabels: + app: echo-server + template: + metadata: + labels: + app: echo-server + spec: + containers: + - name: echo-server + image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/echo-server:1.3.0 + env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + resources: + requests: + cpu: 10m +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-foo + namespace: higress-course +spec: + ingressClassName: higress + rules: + - host: "foo.com" + http: + paths: + - pathType: Prefix + path: "/" + backend: + service: + name: echo-server + port: + number: 8080 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-bar + namespace: higress-course +spec: + ingressClassName: higress + rules: + - host: "bar.com" + http: + paths: + - pathType: Prefix + path: "/" + backend: + service: + name: echo-server + port: + number: 8080 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-baz + namespace: higress-course +spec: + ingressClassName: higress + rules: + - host: "baz.com" + http: + paths: + - pathType: Prefix + path: "/" + backend: + service: + name: echo-server + port: + number: 8080 +``` + +## 2 Wasm 插件配置 + +Higress WasmPlugin 在 Istio WasmPlugin 的基础上进行了扩展,支持全局、路由、域名、服务级别的配置。这 4 个配置优先级是:路由级 > 域名级 > 服务级 > 全局,对于没有匹配到具体路由、域名、服务级别的请求才会应用全局配置。 + +下面以 Higress 官方提供的 [custom-response](https://higress.io/zh-cn/docs/plugins/transformation/custom-response) 插件为例进行介绍。custom-response 插件支持配置自定义响应,包括 HTTP 响应状态码、HTTP 响应头,以及 HTTP 响应体。custom-response 插件不仅可以用于模拟响应,还可以根据特定状态码返回自定义响应。例如,在触发网关限流策略时,返回自定义响应。 + +应用 custom-response 插件,YAML 配置如下: + +```yaml +apiVersion: extensions.higress.io/v1alpha1 +kind: WasmPlugin +metadata: + name: custom-response + namespace: higress-system +spec: + priority: 200 + # 配置会全局生效,但如果被下面规则匹配到,则会改为执行命中规则的配置 + defaultConfig: + headers: + - key1=value1 + "body": "{\"hello\":\"foo\"}" + matchRules: + # 域名级生效配置 + - domain: + - bar.com + config: + headers: + - key2=value2 + "body": "{\"hello\":\"bar\"}" + # 路由级生效配置 + - ingress: + - higress-course/ingress-baz + # higress-course 命名空间下名为 ingress-baz 的 ingress 会应用下面这个配置 + config: + headers: + - key3=value3 + "body": "{\"hello\":\"baz\"}" + url: oci://higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins/custom-response:1.0.0 +``` + +访问 foo.com,由于请求没有匹配任何域名级或路由级配置,因此最终应用了全局配置。 + +```shell +curl -v -H "Host: foo.com" http://127.0.0.1/ + +* Trying 127.0.0.1:80... +* Connected to 127.0.0.1 (127.0.0.1) port 80 (#0) +> GET / HTTP/1.1 +> Host: foo.com +> User-Agent: curl/8.1.2 +> Accept: */* +> +< HTTP/1.1 200 OK +< key1: value1 +< content-type: application/json; charset=utf-8 +< content-length: 15 +< date: Sun, 14 Jul 2024 02:45:51 GMT +< server: istio-envoy +< +* Connection #0 to host 127.0.0.1 left intact +{"hello":"foo"} +``` + +访问 bar.com,请求匹配域名级配置。 + +```shell +curl -v -H "Host: bar.com" http://127.0.0.1/ + +* Trying 127.0.0.1:80... +* Connected to 127.0.0.1 (127.0.0.1) port 80 (#0) +> GET / HTTP/1.1 +> Host: bar.com +> User-Agent: curl/8.1.2 +> Accept: */* +> +< HTTP/1.1 200 OK +< key2: value2 +< content-type: application/json; charset=utf-8 +< content-length: 15 +< date: Sun, 14 Jul 2024 02:47:51 GMT +< server: istio-envoy +< +* Connection #0 to host 127.0.0.1 left intact +{"hello":"bar"} +``` + +访问 baz.com,请求匹配路由级配置。 + +```shell +curl -v -H "Host: baz.com" http://127.0.0.1/ + +* Trying 127.0.0.1:80... +* Connected to 127.0.0.1 (127.0.0.1) port 80 (#0) +> GET / HTTP/1.1 +> Host: baz.com +> User-Agent: curl/8.1.2 +> Accept: */* +> +< HTTP/1.1 200 OK +< key3: value3 +< content-type: application/json; charset=utf-8 +< content-length: 15 +< date: Sun, 14 Jul 2024 08:44:03 GMT +< server: istio-envoy +< +* Connection #0 to host 127.0.0.1 left intact +{"hello":"baz"} +``` + +测试完成后删除 `custom-response` WasmPlugin,避免对后续测试产生影响。 + +```shell +kubectl delete wasmplugin custom-response -n higress-system +``` + +## 3 Higress WasmPlugin CRD + +Higress WasmPlugin CRD 在 [Istio WasmPlugin CRD](https://istio.io/latest/docs/reference/config/proxy_extensions/wasm-plugin/) 的基础上进行了扩展,新增 `defaultConfig` 和 `matchRules` 字段,用于配置插件的默认配置和路由级、域名级配置。 + +主要配置如下: + +| 字段名称 | 数据类型 | 填写要求 | 描述 | +|------------------|-----------------|----|-------------------------------------------------------------------------------------------------------------| +| `pluginName` | string | 选填 | 插件名称 | +| `phase` | string | 选填 | 插件插入插件链中的位置,默认是 UNSPECIFIED_PHASE | +| `priority` | int | 选填 | 插件执行优先级,默认为 0,在相同 `phase` 下,值越大越先处理请求,但越后处理响应 | +| `imagePullPolicy` | string | 选填 | 插件镜像拉取策略,可选值有:`UNSPECIFIED_POLICY`(默认值)、`IfNotPresent`、`Always` | +| `imagePullSecret` | string | 选填 | 用于拉取 OCI 镜像的凭据。与 WasmPlugin 在同一命名空间中的Kubernetes Secret 的名称 | +| `url` | string | 必填 | Wasm 文件或 OCI 容器的 URL,默认为 `oci://`,引用 OCI 镜像。同时支持 `file://`,用于容器本地的 Wasm 文件,以及 `http[s]://`,用于引用远程托管的 Wasm 文件 | +| `Sha256` | string | 选填 | 用于验证 Wasm 文件或 OCI 容器的 SHA256 校验和 | +| `defaultConfig` | object | 选填 | 插件默认配置,全局生效于没有匹配具体域名和路由配置的请求 | +| `defaultConfigDisable`| bool | 选填 | 插件默认配置是否失效,默认值是 false | +| `matchRules` | array of object | 选填 | 匹配域名或路由生效的配置 | + +`phase` 配置说明: + +| 字段名称 | 描述 | +|--------------------|------------------------------------------------------------------------| +| `UNSPECIFIED_PHASE` | 在插件过滤器链的末端,在路由器之前插入插件,如果没有指定插件的 `phase`,则默认设置为 `UNSPECIFIED_PHASE` | +| `AUTHN` | 在 Istio 认证过滤器之前插入插件 | +| `AUTHZ` | 在 Istio 授权过滤器之前且在 Istio 认证过滤器之后插入插件 | +| `STATS` | 在 Istio 统计过滤器之前且在 Istio 授权过滤器之后插入插件 | + +`matchRules` 中每一项的配置字段说明: + +| 字段名称 | 数据类型 | 填写要求 | 配置示例 | 描述 | +|-----------------|-------|--------------------------------------|--------------------------------|-----------------------------------------| +| `ingress` | 字符串数组 | `ingress`、`domain` 和 `service` 中必填一项 | ["default/foo", "default/bar"] | 匹配 ingress 资源对象,匹配格式为: `命名空间/ingress名称` | +| `domain` | 字符串数组 | `ingress`、`domain` 和 `service` 中必填一项| ["example.com", "*.test.com"] | 匹配域名,支持泛域名 | +| `service` | 字符串数组 | `ingress`、`domain` 和 `service` 中必填一项 | ["echo-server.higress-course.svc.cluster.local"] | 匹配服务名称 | +| `config` | 对象 | 选填 | - | 匹配后生效的插件配置 | +| `configDisable` | bool | 选填 | false | 配置是否生效,默认设置为 false | + + +## 4 自定义插件开发 + +开发一个简单日志插件 `easy-logger`, 这个插件根据配置记录请求和响应到网关日志中。整个过程涉及到插件开发环境准备、开发和测试、部署和验证。 + +### 4.1 环境准备 + +环境准备如下: + +- Docker 官方安装连接:https://docs.docker.com/engine/install/ +- Go 版本: >= 1.19 (需要支持范型特性),官方安装链接:https://go.dev/doc/install + +如果选择用 TinyGo 在本地构建 Wasm 文件,再拷贝到 Docker 镜像中,需要安装 TinyGo,其环境要求如下: + +- TinyGo 版本: >= 0.28.1 (建议版本 0.28.1) 官方安装链接:https://tinygo.org/getting-started/install/ + +### 4.2 开发和测试 + +#### 4.2.1 初始化工程目录 + +1. 新建一个工程目录文件 easy-logger。 + +```shell +mkdir easy-logger +``` +2. 在所建目录下执行以下命令,初始化 Go 工程。 + +```shell +go mod init easy-logger +``` + +go.mod 文件中 go 版本需要设置为 1.19,由于在 4.3.3 节中我们将使用 1.19 版本的 wasm-go-builder 镜像来构建插件,因此需要保持两者的 go 版本一致。 + +```shell +module easy-logger + +go 1.19 +``` + +3. 国内环境可能需要设置下载依赖包的代理 + +```shell +go env -w GOPROXY=https://proxy.golang.com.cn,direct +``` + +4. 下载构建插件的依赖 + +```shell +go get github.com/higress-group/proxy-wasm-go-sdk +go get github.com/alibaba/higress/plugins/wasm-go@main +go get github.com/tidwall/gjson +``` + +#### 4.2.2 编写 main.go 文件 +首先,我们编写 easy-logger 插件的基本框架,暂时只读取我们设置的配置参数,不在请求和响应阶段进行任何处理。 + +```golang +package main + +import ( + "github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper" + "github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types" + "github.com/tidwall/gjson" +) + +func main() { + wrapper.SetCtx( + // 插件名称 + "easy-logger", + // 设置自定义函数解析插件配置 + wrapper.ParseConfigBy(parseConfig), + // 设置自定义函数处理请求头 + wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders), + // 设置自定义函数处理请求体 + wrapper.ProcessRequestBodyBy(onHttpRequestBody), + // 设置自定义函数处理响应头 + wrapper.ProcessResponseHeadersBy(onHttpResponseHeaders), + // 设置自定义函数处理响应体 + wrapper.ProcessResponseBodyBy(onHttpResponseBody), + // 设置自定义函数处理流式请求体 + //wrapper.ProcessStreamingRequestBodyBy(onHttpStreamingRequestBody), + // 设置自定义函数处理流式响应体 + //wrapper.ProcessStreamingResponseBodyBy(onHttpStreamingResponseBody), + ) +} + +// 自定义插件配置 +type LoggerConfig struct { + // 是否打印请求 + request bool + // 是否打印响应 + response bool + // 打印响应状态码,* 表示打印所有状态响应,500,502,503 表示打印 HTTP 500、502、503 状态响应,默认是 * + responseStatusCodes string +} + +func parseConfig(json gjson.Result, config *LoggerConfig, log wrapper.Log) error { + log.Debugf("parseConfig()") + config.request = json.Get("request").Bool() + config.response = json.Get("response").Bool() + config.responseStatusCodes = json.Get("responseStatusCodes").String() + if config.responseStatusCodes == "" { + config.responseStatusCodes = "*" + } + log.Debugf("parse config:%v", config) + return nil +} + +func onHttpRequestHeaders(ctx wrapper.HttpContext, config LoggerConfig, log wrapper.Log) types.Action { + log.Debugf("onHttpRequestHeaders()") + return types.ActionContinue +} + +func onHttpRequestBody(ctx wrapper.HttpContext, config LoggerConfig, body []byte, log wrapper.Log) types.Action { + log.Debugf("onHttpRequestBody()") + return types.ActionContinue +} + +func onHttpResponseBody(ctx wrapper.HttpContext, config LoggerConfig, body []byte, log wrapper.Log) types.Action { + log.Debugf("onHttpResponseBody()") + return types.ActionContinue +} + +func onHttpResponseHeaders(ctx wrapper.HttpContext, config LoggerConfig, log wrapper.Log) types.Action { + log.Debugf("onHttpResponseHeaders()") + return types.ActionContinue +} +``` + +Higress 插件 SDK 开发涉及到以下内容: + +- wrapper.HttpContext:请求上下文。 +- LoggerConfig:自定义插件配置。 +- wrapper.Log:插件日志工具。 +- wrapper.ProcessXXXX:插件回调钩子函数。 +- proxywasm:提供插件工具函数包。 + +wrapper 插件回调钩子函数包含以下函数,可以根据实际业务需求选择设置以下钩子函数: + +- wrapper.ParseConfigBy(parseConfig):解析插件配置。 +- wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders):设置自定义函数处理请求头。 +- wrapper.ProcessRequestBodyBy(onHttpRequestBody):设置自定义函数处理请求体。 +- wrapper.ProcessResponseHeadersBy(onHttpResponseHeaders):设置自定义函数处理响应头。 +- wrapper.ProcessResponseBodyBy(onHttpResponseBody):设置自定义函数处理响应体。 +- wrapper.ProcessStreamingRequestBodyBy(onHttpStreamingRequestBody):设置自定义函数处理流式请求体。 +- wrapper.ProcessStreamingResponseBodyBy(onHttpStreamingResponseBody):设置自定义函数处理流式响应体。 + +关于 Higress 插件 SDK 内容会在后续章节中详细展开。 + +#### 4.3.3 本地测试 + +1. 第一步:在插件目录下创建文件 envoy.yaml,内容如下。网关在 10000 端口监听 HTTP 请求,将请求转发到 echo-server 服务。 + +```yaml +admin: + address: + socket_address: + protocol: TCP + address: 0.0.0.0 + port_value: 9901 +static_resources: + listeners: + - name: listener_0 + address: + socket_address: + protocol: TCP + address: 0.0.0.0 + port_value: 10000 + 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 + scheme_header_transformation: + scheme_to_overwrite: https + stat_prefix: ingress_http + route_config: + name: local_route + virtual_hosts: + - name: local_service + domains: ["*"] + routes: + - match: + prefix: "/" + route: + cluster: echo-server + http_filters: + - name: wasmdemo + typed_config: + "@type": type.googleapis.com/udpa.type.v1.TypedStruct + type_url: type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm + value: + config: + name: wasmdemo + vm_config: + runtime: envoy.wasm.runtime.v8 + code: + local: + filename: /etc/envoy/plugin.wasm + configuration: + "@type": "type.googleapis.com/google.protobuf.StringValue" + value: |- + { + "request": true, + "response": true, + "responseStatusCodes": "200,500,502,503" + } + - name: envoy.filters.http.router + clusters: + - name: echo-server + connect_timeout: 30s + type: LOGICAL_DNS + dns_lookup_family: V4_ONLY + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: echo-server + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: echo-server + port_value: 3000 +``` + +插件通过本地文件的方式加载到 Envoy 中,插件配置的如下: + +```yaml + configuration: + "@type": "type.googleapis.com/google.protobuf.StringValue" + value: |- + { + "request": true, + "response": true, + "responseStatusCodes": "200,500,502,503" + } +``` + +2. 第二步:在插件目录下创建文件 docker-compose.yaml,内容如下: + +```yaml +version: '3.9' +services: + envoy: + image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/gateway:v1.4.1 + entrypoint: /usr/local/bin/envoy + # 注意这里对 Wasm 开启了 debug 级别日志,在生产环境部署时请使用默认的 info 级别 + # 如果需要将 Envoy 的日志级别调整为 debug,将 --log-level 参数设置为 debug + command: -c /etc/envoy/envoy.yaml --log-level info --log-path /etc/envoy/envoy.log --component-log-level wasm:debug + depends_on: + - echo-server + networks: + - wasmtest + ports: + - "10000:10000" + - "9901:9901" + volumes: + - ./envoy.yaml:/etc/envoy/envoy.yaml + - ./build/plugin.wasm:/etc/envoy/plugin.wasm + - ./envoy.log:/etc/envoy/envoy.log + echo-server: + image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/echo-server:1.3.0 + networks: + - wasmtest + ports: + - "3000:3000" +networks: + wasmtest: {} +``` +3. 第三步:在插件目录下创建文件 Dockerfile,内容如下: + +```yaml +ARG BUILDER=higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins/wasm-go-builder:go1.19-tinygo0.28.1-oras1.0.0 +FROM $BUILDER as builder + +ARG GOPROXY +ENV GOPROXY=${GOPROXY} + +ARG EXTRA_TAGS="" +ENV EXTRA_TAGS=${EXTRA_TAGS} + +WORKDIR /workspace +COPY . . +RUN go mod tidy +RUN tinygo build -o /main.wasm -scheduler=none -gc=custom -tags="custommalloc nottinygc_finalizer $EXTRA_TAGS" -target=wasi ./main.go + +FROM scratch as output +COPY --from=builder /main.wasm plugin.wasm +``` + +4. 第四步:在插件目录下创建文件 Makefile,内容如下: + +```shell +PLUGIN_NAME ?= hello-world +BUILDER_REGISTRY ?= higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins/ +REGISTRY ?= higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins/ +GO_VERSION ?= 1.19 +TINYGO_VERSION ?= 0.28.1 +ORAS_VERSION ?= 1.0.0 +HIGRESS_VERSION ?= 1.4.1 +USE_HIGRESS_TINYGO ?= true +BUILDER ?= ${BUILDER_REGISTRY}wasm-go-builder:go${GO_VERSION}-tinygo${TINYGO_VERSION}-oras${ORAS_VERSION} +BUILD_TIME := $(shell date "+%Y%m%d-%H%M%S") +COMMIT_ID := $(shell git rev-parse --short HEAD 2>/dev/null) +IMAGE_TAG = $(if $(strip $(PLUGIN_VERSION)),${PLUGIN_VERSION},${BUILD_TIME}-${COMMIT_ID}) +IMG ?= ${REGISTRY}${PLUGIN_NAME}:${IMAGE_TAG} +GOPROXY := $(shell go env GOPROXY) +EXTRA_TAGS ?= proxy_wasm_version_0_2_100 + +.DEFAULT: +local-docker-build: + DOCKER_BUILDKIT=1 docker build --build-arg BUILDER=${BUILDER} \ + --build-arg GOPROXY=$(GOPROXY) \ + --build-arg EXTRA_TAGS=$(EXTRA_TAGS) \ + -t ${IMG} \ + --output build \ + . + @echo "" + @echo "output wasm file: ./build/plugin.wasm" + +build-image: + DOCKER_BUILDKIT=1 docker build --build-arg BUILDER=${BUILDER} \ + --build-arg GOPROXY=$(GOPROXY) \ + --build-arg EXTRA_TAGS=$(EXTRA_TAGS) \ + -t ${IMG} \ + . + @echo "" + @echo "image: ${IMG}" + +build-push: build-image + docker push ${IMG} + +local-build: + tinygo build -scheduler=none -target=wasi -gc=custom -tags='custommalloc nottinygc_finalizer $(EXTRA_TAGS)' \ + -o ./build/plugin.wasm main.go + @echo "" + @echo "wasm: ./build/plugin.wasm" + +local-run: + echo > ./envoy.log + docker compose down + docker compose up -d + +local-all: local-build local-run +local-docker-all: local-docker-build local-run +``` + +请将 Makefile 文件中镜像仓库地址 `REGISTRY ?= higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins/` 换成自己的镜像仓库地址。 + +其命令说明如下: +- `make local-docker-build`: 本地 Docker 环境构建插件,生成插件文件 ./build/plugin.wasm。 +- `make local-build`: 本地 TinyGo 构建插件,生成插件文件 ./build/plugin.wasm。 +- `make local-run`: 本地启动测试环境。 +- `PLUGIN_NAME=easy-logger PLUGIN_VERSION=1.0.0 make build-image` 构建 easy-logger 插件镜像,插件版本为 1.0.0。 +- `PLUGIN_NAME=easy-logger PLUGIN_VERSION=1.0.0 make build-push` 构建 easy-logger 插件镜像,插件版本为 1.0.0,同时推送到镜像仓库。 +- `make local-docker-all`: 本地 Docker 环境构建插件,生成插件文件 build/plugin.wasm,同时启动本地测试环境。 +- `make local-all`: 本地 TinyGo 构建插件,生成插件文件 ./build/plugin.wasm,同时启动本地测试环境。 + +注意用 TinyGo 本地构建命令如下: +```shell +tinygo build -scheduler=none -target=wasi -gc=custom -tags='custommalloc nottinygc_finalizer proxy_wasm_version_0_2_100' -o ./build/plugin.wasm main.go +``` + +5. 第五步:本地 Docker 环境构建和启动测试环境 + +本地 Docker 环境构建和启动测试环境,命令如下: + +```shell +make local-docker-all +``` + +本地启动测试环境后,插件目录整体文件结构如下: + +```shell +tree +. +├── Dockerfile +├── Makefile +├── build +│   └── plugin.wasm # 构建生成的 Wasm 文件 +├── docker-compose.yaml +├── envoy.log # Envoy 日志文件 +├── envoy.yaml +├── go.mod +├── go.sum +└── main.go +``` + +执行以下命令通过网关访问 echo-server。 + +```shell +curl -X POST -v http://127.0.0.1:10000/hello \ +-H "Content-type: application/json" -H 'host:foo.com' \ +-d '{"username":["unamexxxx"], "password":["pswdxxxx"]}' + +* Trying 127.0.0.1:10000... +* Connected to 127.0.0.1 (127.0.0.1) port 10000 (#0) +> POST /hello HTTP/1.1 +> Host:foo.com +> User-Agent: curl/8.1.2 +> Accept: */* +> Content-type: application/json +> Content-Length: 50 +> +< HTTP/1.1 200 OK +< content-type: application/json +< x-content-type-options: nosniff +< date: Sat, 20 Jul 2024 04:39:46 GMT +< content-length: 642 +< req-cost-time: 48 +< req-arrive-time: 1721450386098 +< resp-start-time: 1721450386146 +< x-envoy-upstream-service-time: 30 +< server: envoy +< +{ + "path": "/hello", + "host": "foo.com", + "method": "POST", + "proto": "HTTP/1.1", + "headers": { + "Accept": [ + "*/*" + ], + "Content-Length": [ + "50" + ], + "Content-Type": [ + "application/json" + ], + "Original-Host": [ + "foo.com" + ], + "Req-Start-Time": [ + "1721450386098" + ], + "User-Agent": [ + "curl/8.1.2" + ], + "X-Envoy-Expected-Rq-Timeout-Ms": [ + "15000" + ], + "X-Forwarded-Proto": [ + "https" + ], + "X-Request-Id": [ + "2f9ff093-7891-4c55-992b-874f7ba00d0e" + ] + }, + "namespace": "", + "ingress": "", + "service": "", + "pod": "", + "body": { + "password": [ + "pswdxxxx" + ], + "username": [ + "unamexxxx" + ] + } +* Connection #0 to host 127.0.0.1 left intact +} +``` + +查看插件目录下 envoy.log 文件,可以看到 easy-logger 插件的日志输出。 + +```shell +[2024-07-20 04:08:19.990][22][debug][wasm] [external/envoy/source/extensions/common/wasm/wasm.cc:146] Thread-Local Wasm created 10 now active +[2024-07-20 04:08:19.993][22][debug][wasm] [external/envoy/source/extensions/common/wasm/context.cc:1308] wasm log: [easy-logger] parseConfig() +[2024-07-20 04:08:19.993][22][debug][wasm] [external/envoy/source/extensions/common/wasm/context.cc:1308] wasm log: [easy-logger] parse config:&{request:true response:true responseStatusCodes:200,500,502,503} +[2024-07-20 04:08:19.993][1][warning][main] [external/envoy/source/server/server.cc:715] there is no configured limit to the number of allowed active connections. Set a limit via the runtime key overload.global_downstream_max_connections +[2024-07-20 04:39:46.114][29][debug][wasm] [external/envoy/source/extensions/common/wasm/context.cc:1308] wasm log wasmdemo: [easy-logger] onHttpRequestHeaders() +[2024-07-20 04:39:46.116][29][debug][wasm] [external/envoy/source/extensions/common/wasm/context.cc:1308] wasm log wasmdemo: [easy-logger] onHttpRequestBody() +[2024-07-20 04:39:46.147][29][debug][wasm] [external/envoy/source/extensions/common/wasm/context.cc:1308] wasm log wasmdemo: [easy-logger] onHttpResponseHeaders() +[2024-07-20 04:39:46.147][29][debug][wasm] [external/envoy/source/extensions/common/wasm/context.cc:1308] wasm log wasmdemo: [easy-logger] onHttpResponseBody() +``` + +到这里表示整体开发和测试环境已经完成,下面就是完善插件功能,然后重新测试。 + +### 4.3 完善插件功能 + +接下来,我们将通过自定义函数来处理请求和响应信息。通过设置插件参数,我们可以控制是否打印请求和响应信息,并根据指定的响应状态码决定是否记录响应内容。 + +```golang +package main + +import ( + "fmt" + "strings" + + "github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper" + "github.com/google/uuid" + "github.com/higress-group/proxy-wasm-go-sdk/proxywasm" + "github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types" + "github.com/tidwall/gjson" +) + +func main() { + wrapper.SetCtx( + // 插件名称 + "easy-logger", + // 设置自定义函数解析插件配置 + wrapper.ParseConfigBy(parseConfig), + // 设置自定义函数处理请求头 + wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders), + // 设置自定义函数处理请求体 + wrapper.ProcessRequestBodyBy(onHttpRequestBody), + // 设置自定义函数处理响应头 + wrapper.ProcessResponseHeadersBy(onHttpResponseHeaders), + // 设置自定义函数处理响应体 + wrapper.ProcessResponseBodyBy(onHttpResponseBody), + // 设置自定义函数处理流式请求体 + //wrapper.ProcessStreamingRequestBodyBy(onHttpStreamingRequestBody), + // 设置自定义函数处理流式响应体 + //wrapper.ProcessStreamingResponseBodyBy(onHttpStreamingResponseBody), + ) +} + +// 自定义插件配置 +type LoggerConfig struct { + // 是否打印请求 + request bool + // 是否打印响应 + response bool + // 打印响应状态码,* 表示打印所有状态响应,500,502,503 表示打印 HTTP 500、502、503 状态响应,默认是 * + responseStatusCodes string +} + +func parseConfig(json gjson.Result, config *LoggerConfig, log wrapper.Log) error { + log.Debugf("parseConfig()") + config.request = json.Get("request").Bool() + config.response = json.Get("response").Bool() + config.responseStatusCodes = json.Get("responseStatusCodes").String() + if config.responseStatusCodes == "" { + config.responseStatusCodes = "*" + } + log.Debugf("parse config:%+v", config) + return nil +} + +func onHttpRequestHeaders(ctx wrapper.HttpContext, config LoggerConfig, log wrapper.Log) types.Action { + log.Debugf("onHttpRequestHeaders()") + requestId := uuid.New().String() + ctx.SetContext("requestId", requestId) + if !config.request { + return types.ActionContinue + } + // 获取并打印请求头 + headers, _ := proxywasm.GetHttpRequestHeaders() + var build strings.Builder + build.WriteString("\n===========request headers===============\n") + build.WriteString(fmt.Sprintf("requestId:%s\n", requestId)) + for _, values := range headers { + build.WriteString(fmt.Sprintf("%s:%s\n", values[0], values[1])) + } + log.Infof(build.String()) + // 继续处理请求 + return types.ActionContinue +} + +func onHttpRequestBody(ctx wrapper.HttpContext, config LoggerConfig, body []byte, log wrapper.Log) types.Action { + log.Debugf("onHttpRequestBody()") + // 打印请求体 + if config.request { + var build strings.Builder + build.WriteString("\n===========request body===============\n") + requestId := ctx.GetContext("requestId").(string) + build.WriteString(fmt.Sprintf("requestId:%s\n", requestId)) + build.WriteString(fmt.Sprintf("body:%s\n", string(body))) + log.Infof(build.String()) + } + return types.ActionContinue +} + +func onHttpResponseHeaders(ctx wrapper.HttpContext, config LoggerConfig, log wrapper.Log) types.Action { + log.Debugf("onHttpResponseHeaders()") + // 添加自定义响应头 + proxywasm.AddHttpResponseHeader("x-easy-logger", "1.0.0") + if !config.response { + return types.ActionContinue + } + // 获取响应状态码 + statusCode, _ := proxywasm.GetHttpResponseHeader(":status") + logResponseBody := false + // 根据响应状态码决定是否打印响应体 + if config.responseStatusCodes == "*" || strings.Contains(config.responseStatusCodes, statusCode) { + logResponseBody = true + } + // 将是否记录响应体的信息存储在上下文中,在 onHttpResponseBody 阶段获取上下文判断是否打印响应体 + ctx.SetContext("logResponseBody", logResponseBody) + // 获取响应头 + headers, _ := proxywasm.GetHttpResponseHeaders() + // 打印响应头 + var build strings.Builder + build.WriteString("\n===========response headers===============\n") + requestId := ctx.GetContext("requestId").(string) + build.WriteString(fmt.Sprintf("requestId:%s\n", requestId)) + for _, values := range headers { + build.WriteString(fmt.Sprintf("%s:%s\n", values[0], values[1])) + } + log.Infof(build.String()) + return types.ActionContinue +} + +func onHttpResponseBody(ctx wrapper.HttpContext, config LoggerConfig, body []byte, log wrapper.Log) types.Action { + log.Debugf("onHttpResponseBody()") + // 获取在 onHttpRequestHeaders 阶段设置的上下文 + logResponseBody, ok := ctx.GetContext("logResponseBody").(bool) + if !ok { + return types.ActionContinue + } + // 打印响应体 + if logResponseBody { + var build strings.Builder + build.WriteString("\n===========response body===============\n") + requestId := ctx.GetContext("requestId").(string) + build.WriteString(fmt.Sprintf("requestId:%s\n", requestId)) + build.WriteString(fmt.Sprintf("body:%s\n", string(body))) + log.Infof(build.String()) + } + return types.ActionContinue +} +``` + + +### 4.4 部署插件和验证 + +1. 构建插件镜像 + +```shell +PLUGIN_NAME=easy-logger PLUGIN_VERSION=1.0.0 make build-push +``` + +2. 部署插件 + +easy-logger 插件部署 YAML 如下: +```yaml +apiVersion: extensions.higress.io/v1alpha1 +kind: WasmPlugin +metadata: + name: easy-logger + namespace: higress-system +spec: + priority: 300 + matchRules: + # 域名级生效配置 + - domain: + - foo.com + config: + request: true + response: true + responseStatusCodes: "200,500,502,503" + url: oci://higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins/easy-logger:1.0.0 +``` + +3. 验证插件 + +- 设置网关插件的日志级别为 debug。 + +```shell +kubectl exec -n higress-system -- \ +curl -X POST http://127.0.0.1:15000/logging?wasm=debug +``` + +- 请求访问 + +```shell +curl -X POST -v http://127.0.0.1/hello \ +-H "Content-type: application/json" -H 'host:foo.com' \ +-d '{"username":["unamexxxx"],"password":["pswdxxxx"]}' +``` + +- 查看网关的日志,可以看到输出了请求和响应的详细信息 + +```shell +kubectl logs -f -n higress-system + +[Envoy (Epoch 0)] [2024-07-20 04:56:55.251][39][debug][wasm] wasm log higress-system.easy-logger: [easy-logger] onHttpRequestHeaders() +[Envoy (Epoch 0)] [2024-07-20 04:56:55.252][39][info][wasm] wasm log higress-system.easy-logger: [easy-logger] +===========request headers=============== +requestId:a791e8e6-8126-4a1d-92f0-a0333b706c1d +:authority:foo.com +:path:/hello +:method:POST +:scheme:http +user-agent:curl/8.1.2 +accept:*/* +content-type:application/json +content-length:50 +x-forwarded-for:192.168.65.1 +x-forwarded-proto:http +x-envoy-internal:true +x-request-id:2ad88049-6ba3-4f3d-bc81-dc29fa48ffce +x-envoy-decorator-operation:echo-server.higress-course.svc.cluster.local:8080/* + +[Envoy (Epoch 0)] [2024-07-20 04:56:55.254][39][debug][wasm] wasm log higress-system.easy-logger: [easy-logger] onHttpRequestBody() +[Envoy (Epoch 0)] [2024-07-20 04:56:55.254][39][info][wasm] wasm log higress-system.easy-logger: [easy-logger] +===========request body=============== +requestId:a791e8e6-8126-4a1d-92f0-a0333b706c1d +body:{"username":["unamexxxx"],"password":["pswdxxxx"]} + +[Envoy (Epoch 0)] [2024-07-20 04:56:55.256][39][debug][wasm] wasm log higress-system.easy-logger: [easy-logger] onHttpResponseHeaders() +[Envoy (Epoch 0)] [2024-07-20 04:56:55.256][39][info][wasm] wasm log higress-system.easy-logger: [easy-logger] +===========response headers=============== +requestId:a791e8e6-8126-4a1d-92f0-a0333b706c1d +:status:200 +content-type:application/json +x-content-type-options:nosniff +date:Sat, 20 Jul 2024 04:56:55 GMT +content-length:993 +req-cost-time:8 +req-arrive-time:1721451415248 +resp-start-time:1721451415256 +x-envoy-upstream-service-time:2 +x-easy-logger:1.0.0 + +[Envoy (Epoch 0)] [2024-07-20 04:56:55.257][39][debug][wasm] wasm log higress-system.easy-logger: [easy-logger] onHttpResponseBody() +[Envoy (Epoch 0)] [2024-07-20 04:56:55.257][39][info][wasm] wasm log higress-system.easy-logger: [easy-logger] +===========response body=============== +requestId:a791e8e6-8126-4a1d-92f0-a0333b706c1d +body:{ + "path": "/hello", + "host": "foo.com", + "method": "POST", + "proto": "HTTP/1.1", + "headers": { + "Accept": [ + "*/*" + ], + "Content-Length": [ + "50" + ], + "Content-Type": [ + "application/json" + ], + "Original-Host": [ + "foo.com" + ], + "Req-Start-Time": [ + "1721451415248" + ], + "User-Agent": [ + "curl/8.1.2" + ], + "X-B3-Sampled": [ + "0" + ], + "X-B3-Spanid": [ + "f642c00a89551b07" + ], + "X-B3-Traceid": [ + "dfab58b011681d29f642c00a89551b07" + ], + "X-Envoy-Attempt-Count": [ + "1" + ], + "X-Envoy-Decorator-Operation": [ + "echo-server.higress-course.svc.cluster.local:8080/*" + ], + "X-Envoy-Internal": [ + "true" + ], + "X-Forwarded-For": [ + "192.168.65.1" + ], + "X-Forwarded-Proto": [ + "http" + ], + "X-Request-Id": [ + "2ad88049-6ba3-4f3d-bc81-dc29fa48ffce" + ] + }, + "namespace": "higress-course", + "ingress": "", + "service": "", + "pod": "echo-server-6f4df5fcff-nksqz", + "body": { + "password": [ + "pswdxxxx" + ], + "username": [ + "unamexxxx" + ] + } +} +``` + + + + + + + diff --git a/src/content/docs/ebook/en/wasm15.md b/src/content/docs/ebook/en/wasm15.md new file mode 100644 index 0000000000..ad6d670ac1 --- /dev/null +++ b/src/content/docs/ebook/en/wasm15.md @@ -0,0 +1,804 @@ +--- +title: Wasm 插件原理 +keywords: [Higress] +--- + +# Wasm 插件原理 + +本章主要介绍 Proxy-Wasm Go SDK 和 Wasm 插件基本原理。 + +## 1 Wasm、TinyGo、Proxy-Wasm Go SDK + +### 1.1 Wasm + +#### 1.1.1 什么是 Wasm ? + + [WebAssembly(简称 Wasm)](https://webassembly.org/) 是操作堆栈虚拟机的二进制指令集,Wasm 可以在 Web 浏览器中运行或者其他环境比如服务器端应用程序运行。Wasm有以下特点: + +- 高效性能:提供了接近机器码的性能。 +- 跨平台:Wasm 是一种与平台无关的格式,可以在任何支持它的平台上运行,包括浏览器和服务器。 +- 安全性:Wasm 在一个内存安全的沙箱环境中运行,这意味着它可以安全地执行不受信任的代码,而不会访问或修改主机系统的其他部分。 +- 可移植性:Wasm 模块可以被编译成 WebAssembly 二进制文件,这些文件可以被传输和加载到支持 Wasm 的任何环境中。 +- 多语言支持:Wasm 支持多种编程语言,开发者可以使用 C、C++、Rust、Go 等多种语言编写代码,然后编译成 Wasm 格式。 + +#### 1.1.2 Wasm 模块 + +Wasm 模块主要有以下两种格式: +- 二进制格式:Wasm 的主要编码格式,以 .wasm 后缀结尾。 +- 文本格式:主要是为了方便开发者理解 Wasm 模块,以 .wat 后缀结尾,相当于汇编语言程序。 + +Wasm 模块二进制格式是 Wasm 二进制文件,Wasm 模块二进制格式也是以魔数和版本号开头,之后就是模块的主体内容,这些内容根据不同用途被分别放在不同的段(Section) 中。一共定义了 12 种段,每种段分配了 ID(从 0 到 11),依次有如下 12 个段:自定义段、类型段、导入段、函数段、表段、内存段、全局段、导出段、起始段、元素段、代码段、数据段。 +Wasm 模块二进制格式的组成如下图(图片来源 [WebAssembly 解释器实现篇](https://github.com/mcuking/blog/issues/96/))所示: +![img](https://img.alicdn.com/imgextra/i1/O1CN01rLuxGp1zIX413ZQ0g_!!6000000006691-0-tps-1784-1266.jpg) + +每一个不同的段都描述了这个 Wasm 模块的一部分信息。而模块内的所有段放在一起,便描述了这个 Wasm 模块的全部信息: +- 内存段和数据段:内存段是线性内存(linear memory)用于存储程序的运行时动态数据。数据段用于存储初始化内存的静态数据。内存可以从外部宿主导入,同时内存对象也可以导出到外部宿主环境。 +- 表段和元素段:表段用于存储对象引用,目前对象只能是函数,因此可以通过表段实现函数指针的功能。元素段用于存储初始化表段的数据。表对象可以从外部宿主导入,同时表对象也可以导出到外部宿主环境。 +- 起始段:起始段用于存储起始函数的索引,即指定了一个在加载时自动运行的函数。起始函数主要作用:1. 在模块加载后进行初始化工作; 2. 将模块变成可执行文件。 +- 全局段:全局段用于存储全局变量的信息(全局变量的值类型、可变性、初始化表达式等)。 +- 函数段、代码段和类型段:这三个段均是用于存储表达函数的数据。其中 + - 类型段:类型段用于存储模块内所有的函数签名(函数签名记录了函数的参数和返回值的类型和数量),注意若存在多个函数的函数签名相同,则存储一份即可。 + - 函数段:函数段用于存储函数对应的函数签名索引,注意是函数签名的索引,而不是函数索引。 + - 代码段:代码段用于存储函数的字节码和局部变量,也就是函数体内的局部变量和代码所对应的字节码。 +- 导入段和导出段:导出段用于存储导出项信息(导出项的成员名、类型,以及在对应段中的索引等)。导入段用于存储导入项信息(导入项的成员名、类型,以及从哪个模块导入等)。导出/导入项类型有 4 种:函数、表、内存、全局变量。 +- 自定义段:自定义段主要用于保存调试符号等和运行无关的信息。 + +关于 Wasm 模块二进制格式详细内容可以参考 [Wasm 模块 Binary Format](https://webassembly.github.io/spec/core/binary/modules.html)。 + +Wasm 模块 wat 文本格式 使用了 `S- 表达式` 的形式来表达 Wasm 模块及其相关定义。关于 wat 格式的更多介绍可以参考 [理解 WebAssembly 文本格式](https://developer.mozilla.org/zh-CN/docs/WebAssembly/Understanding_the_text_format)。 +下图(图片来源 [WebAssembly 解释器实现篇](https://github.com/mcuking/blog/issues/96/))就是使用 C 语言编写的阶乘函数,以及对应的 Wasm 文本格式和二进制格式。 +![img](https://img.alicdn.com/imgextra/i4/O1CN01VcoLBQ1ZcWL5XHYIR_!!6000000003215-0-tps-1892-878.jpg) + +可以通过 [WebAssembly Code Explorer](https://wasdk.github.io/wasmcodeexplorer/) 更直观地查看 Wasm 二进制格式和文本格式之间的关联。也可以通过 [wabt](https://github.com/WebAssembly/wabt) 提供工具 ,可以方便的进行 Wasm 二进制格式和文本格式的转换。 + +#### 1.1.3 Wasm 指令集 + +Wasm 指令集包含如下内容: +- Wasm 指令主要分为控制指令、参数指令、变量指令、内存指令和数值指令,每条指令包含操作码和操作数。感兴趣的可以点击查看下 [Wasm 所有的操作码](https://pengowray.github.io/wasm-ops/), 可视化表格直观地展示了 Wasm 所有的操作码。 +- 只有四种数据类型: i32、i64、f32、f64 +- 指令基于栈,并且支持递归调用。例如 i32.add 从栈弹出两个 i32 类型的值,并将它们相加,然后将结果压入栈。 +- 从内存读取数据 + - i32.load 从内存中读取一个 i32 类型的值。 + - i32.const value 将一个 i32 类型的值压入栈。 + - 从线性内存读取数据 + +关于更多 Wasm 解释器实现原理的可以参考 [WebAssembly 解释器实现篇](https://github.com/mcuking/blog/issues/96)。 + +### 1.2 TinyGo + +[TinyGo](https://tinygo.org/) 是一个 Go 语言编译器,它专注于生成小型、高效的 Go 程序,特别是为嵌入式系统和 WebAssembly 环境设计。 TinyGo 与 Go 语言的标准编译器不同,它有以下优势: + +- 生成小型二进制文件:TinyGo 优化了生成的二进制文件的大小,使其非常适合资源受限的环境。 +- 简化的 Go 标准库:TinyGo 提供了一个简化版本的 Go 标准库,减少了依赖和复杂性。TinyGo 支持标准库详情:https://tinygo.org/docs/reference/lang-support/stdlib/ 。 +- 跨平台编译:TinyGo 支持跨平台编译,允许开发者为不同的目标平台生成代码。 +- 支持 WebAssembly:通过使用 TinyGo,开发者可以为 WebAssembly 环境编写高效的 Go 应用程序,同时利用 Go 语言的简洁性和易用性。 + +“为什么不使用官方 Go 编译器?”的问题,如果对细节感兴趣,请参考 Go 仓库中的相关 issue: + +- https://github.com/golang/go/issues/25612 +- https://github.com/golang/go/issues/31105 +- https://github.com/golang/go/issues/38248 + +这些 issue 讨论了官方 Go 编译器在生成 Wasm 支持方面的限制和进展。 TinyGo 作为一个替代方案,能够生成适合 [Proxy-Wasm ABI](https://github.com/proxy-wasm/spec) 规范的 Wasm 二进制文件,这使得它成为开发 Proxy-Wasm 应用程序的理想选择。 + +### 1.3 Proxy-Wasm Go SDK + +[Proxy-Wasm Go SDK](https://github.com/higress-group/proxy-wasm-go-sdk) 依赖于 TinyGo, 同时 Proxy-Wasm Go SDK 是基于 [Proxy-Wasm ABI](https://github.com/proxy-wasm/spec) 规范使用 Go 编程语言扩展网络代理(例如 Envoyproxy)的 SDK, 而 Proxy-Wasm ABI 定义了网络代理和在网络代理内部运行的 Wasm 虚拟机之间的接口。 +通过这个 SDK,可以轻松地生成符合 Proxy-Wasm 规范的 Wasm 二进制文件,而无需了解 Proxy-Wasm ABI 规范,同时开发人员可以依赖这个 SDK 的 Go API 来开发插件扩展 Enovy 功能。 + + +## 2 Wasm VM、插件和 Envoy 配置 + +Wasm 虚拟机(Wasm VM) 或简称 VM 指的是加载 Wasm 程序的实例。 在 Envoy 中,VM 通常在每个线程中创建并相互隔离。因此 Wasm 程序将复制到 Envoy 所创建的线程里,并在这些虚拟机上加载并执行。 +插件提供了一种灵活的方式来扩展和自定义 Envoy 的行为。Proxy-Wasm 规范允许在每个 VM 中配置多个插件。因此一个 VM 可以被多个插件共同使用。Envoy 中有三种类型插件: `Http Filter`、`Network Filter` 和 `Wasm Service`。 + +- `Http Filter` 是一种处理 Http 协议的插件,例如操作 Http 请求头、正文等。 +- `Network Filter` 是一种处理 Tcp 协议的插件,例如操作 Tcp 数据帧、连接建立等。 +- `Wasm Service` 是在单例 VM 中运行的插件类型(即在 Envoy 主线程中只有一个实例)。它主要用于执行与 `Network Filter` 或 `Http Filter` 并行的一些额外工作,如聚合指标、日志等。这样的单例 VM 本身也被称为 `Wasm Service`。 + +其架构如下图(图片来源 [Proxy-Wasm Go SDK](https://github.com/higress-group/proxy-wasm-go-sdk/blob/main/doc/OVERVIEW.md)): +![img](https://img.alicdn.com/imgextra/i4/O1CN018UJzEX1YlqnAmBV4u_!!6000000003100-0-tps-2321-1190.jpg) + + +### 2.1 Envoy 配置 + +所有类型插件的配置都包含 `vm_config` 用于配置 Wasm VM, 和 `configuration` 用于配置插件实例。 + +```yaml +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 | +| `vm_config.vm_id` | 用于跨 VM 通信的语义隔离。详情请参考 跨 VM 通信 部分。 | +| `vm_config.runtime` | 指定 Wasm 运行时类型。默认为 envoy.wasm.runtime.v8。 | +| `vm_config.configuration` | 用于设置 VM 的配置数据 | +| `vm_config.code` | Wasm 二进制文件的位置 | +| `configuration` | 对应于每个插件实例配置(在下面介绍的 PluginContext)。 | + +> 完全相同的 vm_config 配置的多个插件它们之间共享一个 Wasm VM,单个 Wasm VM 用于多个 Http Filter 或 Network Filter,可以提升内存/CPU 资源效率、降低启动延迟。 +> 完整的 Envoy API 配置可以 [参考 Envoy 文档](https://www.envoyproxy.io/docs/envoy/latest/api-v3/extensions/wasm/v3/wasm.proto#envoy-v3-api-msg-extensions-wasm-v3-pluginconfig)。 + +Envoy Wasm 运行时目前有以下几种选择: +- envoy.wasm.runtime.null:这表示一个空的沙盒(null sandbox)环境,Wasm 模块必须被编译并链接到 Envoy 的二进制文件中。这种方式适用于那些需要将 Wasm 模块与 Envoy 二进制文件一起分发的部署场景。 +- envoy.wasm.runtime.v8: 基于 V8 JavaScript 引擎的运行时。 +- envoy.wasm.runtime.wamr: WAMR (WebAssembly Micro Runtime) 运行时。 +- envoy.wasm.runtime.wasmtime: Wasmtime 运行时。 + +不同的运行时有各自的优缺点,比如 [V8](https://v8.dev/) 性能较好但容器体积较大,[WAMR](https://github.com/bytecodealliance/wasm-micro-runtime) 和 [wasmtime](https://wasmtime.dev/) 则相对轻量。 + +> [待补充?] envoy v8 runtime 如何加载 wasm 和 如何和 envoy 交互原理。 + +### 2.2 Http Filter 配置 + +Http Filter 插件配置设置为 `envoy.filter.http.wasm`,Http Filter 插件可以处理 HTTP 请求和响应。 其主要配置如下: +```yaml +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: + configuration: + "@type": type.googleapis.com/google.protobuf.StringValue + value: | + { + "header": "x-wasm-header", + "value": "demo-wasm" + } + vm_config: + runtime: "envoy.wasm.runtime.v8" + code: + local: + filename: "./examples/http_headers/main.wasm" + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router +``` + +这时 Envoy 会在每个工作线程中实例化一个 Wasm 虚拟机,该虚拟机将专门用于处理该线程上的 HTTP 请求和响应。每个虚拟机都会加载和执行 WebAssembly 代码,允许对 HTTP 流量进行自定义处理,如修改头信息、处理请求和响应体等。 +完整的配置可以参考 [envoy.yaml](https://github.com/higress-group/proxy-wasm-go-sdk/blob/main/examples/http_headers/envoy.yaml) 。 + + +### 2.3 Network Filter 配置 + +`Network Filter` 插件配置设置为 `envoy.filters.network.wasm`,`Network Filter` 插件可以处理 TCP 请求和响应。 其主要配置如下: + +```yaml +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 会在每个工作线程中实例化一个 Wasm 虚拟机,该虚拟机将专门用于处理该线程上的 TCP 请求和响应。每个虚拟机都会加载和执行 WebAssembly 代码,允许对 TCP 流量进行自定义处理等。 +完整的配置可以参考 [envoy.yaml](https://github.com/higress-group/proxy-wasm-go-sdk/blob/main/examples/network/envoy.yaml) 。 + + +### 2.4 Wasm Service 配置 + +`Wasm Service` 插件配置设置为 `envoy.bootstrap.wasm`。插件在 Envoy 启动时加载的,其主要配置如下: + +```yaml +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 时,生成虚拟机(VM)是单例,并且运行在 Envoy 的主线程上,因此它不会阻塞任何工作线程。 + +完整的配置可以参考 [envoy.yaml](https://github.com/higress-group/proxy-wasm-go-sdk/blob/main/examples/shared_queue/envoy.yaml) 。 + +### 2.5 每个线程中多个插件共享一个 VM + +每个线程中多个插件共享一个 VM,其主要配置如下: + +```yaml +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: # ... +``` + + + +在 `18000` 和 `18001` 监听器上的 Http 过滤器链以及 `18002` 上的网络过滤器链中,vm_config 字段都是相同的。在这种情况下,Envoy 中的多个插件将使用同一个 Wasm 虚拟机。 为了重用相同的 VM,所有的 vm_config.vm_id、vm_config.runtime、vm_config.configuration 和 vm_config.code 必须相同。 + +通过这种配置方式允许为不同的过滤器重用同一个 Wasm 虚拟机,通过为每个 `PluginContext` 提供了一个隔离的环境,使得插件能够独立运行,同时共享同一个虚拟机的执行环境,虚拟机只需要加载和初始化一次即可为多个插件服务,这不仅可以减少内存占用,还可以降低启动时的延迟。 + +完整的配置可以参考 [envoy.yaml](https://github.com/higress-group/proxy-wasm-go-sdk/blob/main/examples/shared_queue/envoy.yaml) 。 + +## 3 Proxy-Wasm Go SDK API + +上面介绍插件概念和插件配置,下面开始深入探讨 Proxy-Wasm Go SDK 的 API。 + +### 3.1 Contexts + +上下文(Contexts) 是 Proxy-Wasm Go SDK 中的接口集合,它们在 [types](https://github.com/higress-group/proxy-wasm-go-sdk/tree/main/proxywasm/types) 包中定义。 +有四种类型的上下文: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) │ +└────────────────────────────────────────────────────────────────┘ +``` + +1) VMContext 对应于每个 .vm_config.code,每个 VM 中只存在一个 VMContext。 +2) VMContext 是 PluginContexts 的父上下文,负责创建 PluginContext。 +3) PluginContext 对应于一个 Plugin 实例。一个 PluginContext 对应于 Http Filter、Network Filter、Wasm Service 的 configuration 字段配置。 +4) PluginContext 是 TcpContext 和 HttpContext 的父上下文,并且负责为 处理 Http 流的Http Filter 或 处理 Tcp 流的 Network Filter 创建上下文。 +5) TcpContext 负责处理每个 Tcp 流。 +6) HttpContext 负责处理每个 Http 流。 + +因此,自定义插件要实现 `VMContext` 和 `PluginContext`。 同时 `Http Filter` 或 `Network Filter`,要分别实现 `HttpContext` 或 `TcpContext`。 + +首先 VMContext 定义如下: + +```go +type VMContext interface { + // OnVMStart 在 VM 创建和调用 main 函数后被调用。 + // 在此调用期间,可以通过 GetVMConfiguration 获取在 vm_config.configuration 设置的配置。 + // 这主要用于执行 Wasm VM 范围内的初始化。 + OnVMStart(vmConfigurationSize int) OnVMStartStatus + + // NewPluginContext 用于为每个插件配置创建 PluginContext。 + NewPluginContext(contextID uint32) PluginContext +} +``` + +VMContext 负责通过 NewPluginContext 方法创建 PluginContext。同时在 VM 启动阶段调用 OnVMStart,并且可以通过 `GetVMConfiguration` hostcall API 获取 vm_config.configuration 的值。这样就可以进行 VM 范围内的插件初始化并控制 VMContext 的行为。 + +PluginContext,定义如下(省略了一些方法): +```go +type PluginContext interface { + // OnPluginStart 在所有插件上下文上调用(如果在这是 VM 上下文,则在 OnVmStart 之后)。 + // 在此调用期间,可以通过 GetPluginConfiguration 获取 envoy.yaml 中 config.configuration 设置的配置。 + OnPluginStart(pluginConfigurationSize int) OnPluginStartStatus + + // 以下函数用于在流上创建上下文, + // *必须* 实现它们中的任一个,对应于扩展点。例如,如果您配置此插件上下文在 Http 过滤器上运行,那么必须实现 NewHttpContext。 + // 对 Tcp 过滤器也是如此。 + // + // NewTcpContext 用于为每个 Tcp 流创建 TcpContext。 + NewTcpContext(contextID uint32) TcpContext + // NewHttpContext 用于为每个 Http 流创建 HttpContext。 + NewHttpContext(contextID uint32) HttpContext +} +``` + +`PluginContext` 有 `OnPluginStart` 方法,创建插件时调用,可以通过 GetPluginConfiguration hostcall API 获取 plugin config 中 configuration 字段的值。 +另外 `PluginContext` 有 `NewTcpContext` 和 `NewHttpContext` 方法,为每个 Http 或 Tcp 流创建上下文时调用。 关于 HttpContext 和 TcpContext 的详细定义请参考 [context.go](https://github.com/higress-group/proxy-wasm-go-sdk/blob/main/proxywasm/types/context.go) 。 + +### 3.2 Hostcall API + +Hostcall API 是指在 Wasm 模块内调用 Envoy 提供的功能。这些功能通常用于获取外部数据或与 Envoy 交互。在开发 Wasm 插件时,需要访问网络请求的元数据、修改请求或响应头、记录日志等,这些都可以通过 Hostcall API 来实现。 +Hostcall API 在 proxywasm 包的 [hostcall.go](https://github.com/higress-group/proxy-wasm-go-sdk/blob/main/proxywasm/hostcall.go) 中定义。 +Hostcall API 包括配置和初始化、定时器设置、上下文管理、插件完成、共享队列管理、Redis 操作、Http 调用、TCP 流操作、HTTP 请求/响应头和体操作、共享数据操作、日志操作、属性和元数据操作、指标操作。具体函数名称和描述如下: + +#### 1.配置和初始化 +| 函数名 | 描述 | +|--------------------------------|--------------------------------------------------------------| +| `GetVMConfiguration` | 获取在 `vm_config.configuration` 字段中指定的配置。此功能仅在 `types.PluginContext.OnVMStart` 调用期间可用。 | +| `GetPluginConfiguration` | 获取在 `config.configuration` 字段中指定的配置。此功能仅在 `types.PluginContext.OnPluginStart` 调用期间可用。 | + +#### 2.定时器设置 +| 函数名 | 描述 | +|--------------------------------|--------------------------------------------------------------| +| `SetTickPeriodMilliSeconds` | 设置 `types.PluginContext.OnTick` 调用的tick间隔。此功能仅对 `types.PluginContext` 有效。 | + +#### 3.上下文管理 +| 函数名 | 描述 | +|--------------------------------|--------------------------------------------------------------| +| `SetEffectiveContext` | 设置有效上下文为 `context_id`。通常用于在接收到 `types.PluginContext.OnQueueReady` 或 `types.PluginContext.OnTick` 后更改上下文。 | + +#### 4.插件完成 +| 函数名 | 描述 | +|--------------------------------|-----------------------------------------------------------------------------------| +| `PluginDone` | 当 `OnPluginDone` 返回 false,表示插件处于待定状态,在删除之前必须调用此函数。此功能仅对 `types.PluginContext` 有效。 | + +#### 5.共享队列管理 +| 函数名 | 描述 | +|--------------------------------|-------------------------------------------------------------| +| `RegisterSharedQueue` | 在此插件上下文中注册共享队列。 | +| `ResolveSharedQueue` | 获取给定 `vmID` 和 `queueName` 的队列ID。 | +| `EnqueueSharedQueue` | 将数据排队到给定队列ID的共享队列。 | +| `DequeueSharedQueue` | 从给定队列ID的共享队列中出队数据。 | + +#### 6.Redis 操作 +| 函数名 | 描述 | +|--------------------------------|--------------------------------------------------------------| +| `RedisInit` | 初始化Redis连接。 | +| `DispatchRedisCall` | 发送Redis调用。 | +| `GetRedisCallResponse` | 获取Redis调用响应。 | + +#### 7.HTTP 调用 +| 函数名 | 描述 | +|--------------------------------|--------------------------------------------------------------| +| `DispatchHttpCall` | 向远程集群分派HTTP调用。此功能可被所有上下文使用。 | +| `GetHttpCallResponseHeaders` | 用于检索由远程集群返回的HTTP响应头,此功能仅在传递给 `DispatchHttpCall` 的 "callback" 函数中可用。 | +| `GetHttpCallResponseBody` | 用于检索由远程集群返回的HTTP响应体,此功能仅在传递给 `DispatchHttpCall` 的 "callback" 函数中可用。 | +| `GetHttpCallResponseTrailers` | 用于检索由远程集群返回的HTTP响应尾随头,此功能仅在传递给 `DispatchHttpCall` 的 "callback" 函数中可用。 | + +#### 8.TCP 流操作 +| 函数名 | 描述 | +|--------------------------------|--------------------------------------------------------------| +| `GetDownstreamData` | 用于检索在宿主中缓冲的TCP下游数据。此功能仅在 `types.TcpContext.OnDownstreamData` 期间可用。 | +| `AppendDownstreamData` | 将给定字节追加到宿主中缓冲的TCP下游数据。此功能仅在 `types.TcpContext.OnDownstreamData` 期间可用。 | +| `PrependDownstreamData` | 将给定字节前缀到宿主中缓冲的TCP下游数据。此功能仅在 `types.TcpContext.OnDownstreamData` 期间可用。 | +| `ReplaceDownstreamData` | 用给定字节替换宿主中缓冲的TCP下游数据。此功能仅在 `types.TcpContext.OnDownstreamData` 期间可用。 | +| `GetUpstreamData` | 用于检索在宿主中缓冲的TCP上游数据。此功能仅在 `types.TcpContext.OnUpstreamData` 期间可用。 | +| `AppendUpstreamData` | 将给定字节追加到宿主中缓冲的TCP上游数据。此功能仅在 `types.TcpContext.OnUpstreamData` 期间可用。 | +| `PrependUpstreamData` | 将给定字节前缀到宿主中缓冲的TCP上游数据。此功能仅在 `types.TcpContext.OnUpstreamData` 期间可用。 | +| `ReplaceUpstreamData` | 用给定字节替换宿主中缓冲的TCP上游数据。此功能仅在 `types.TcpContext.OnUpstreamData` 期间可用。 | +| `ContinueTcpStream` | 在返回 `types.Action.Pause` 后,继续TCP连接的处理。此功能仅对 `types.TcpContext` 有效。 | +| `CloseDownstream` | 关闭Tcp上下文中的下游TCP连接。此功能仅对 `types.TcpContext` 有效。 | +| `CloseUpstream` | 关闭Tcp上下文中的上游TCP连接。此功能仅对 `types.TcpContext` 有效。 | + +#### 9.HTTP 请求/响应头和体操作 +| 函数名 | 描述 | +|-------------------------------|--------------------------------------------------------------| +| `GetHttpRequestHeaders` | 获取HTTP请求头。此功能仅在 `types.HttpContext.OnHttpRequestHeaders` 和 `types.HttpContext.OnHttpStreamDone` 期间可用。 | +| `ReplaceHttpRequestHeaders` | 用给定的头替换HTTP请求头。此功能仅在 `types.HttpContext.OnHttpRequestHeaders` 期间可用。 | +| `GetHttpRequestHeader` | 获取给定 "key" 的HTTP请求头的值。此功能仅在 `types.HttpContext.OnHttpRequestHeaders` 和 `types.HttpContext.OnHttpStreamDone` 期间可用。 | +| `RemoveHttpRequestHeader` | 移除请求头中给定 "key" 的所有值。此功能仅在 `types.HttpContext.OnHttpRequestHeaders` 期间可用。 | +| `ReplaceHttpRequestHeader` | 替换请求头中给定 "key" 的值。此功能仅在 `types.HttpContext.OnHttpRequestHeaders` 期间可用。 | +| `AddHttpRequestHeader` | 向请求头添加给定 "key" 的值。此功能仅在 `types.HttpContext.OnHttpRequestHeaders` 期间可用。 | +| `GetHttpRequestBody` | 获取整个HTTP请求体。此功能仅在 `types.HttpContext.OnHttpRequestBody` 期间可用。 | +| `AppendHttpRequestBody` | 向HTTP请求体缓冲区追加给定字节。此功能仅在 `types.HttpContext.OnHttpRequestBody` 期间可用。 | +| `PrependHttpRequestBody` | 向HTTP请求体缓冲区前缀给定字节。此功能仅在 `types.HttpContext.OnHttpRequestBody` 期间可用。 | +| `ReplaceHttpRequestBody` | 用给定字节替换HTTP请求体缓冲区。此功能仅在 `types.HttpContext.OnHttpRequestBody` 期间可用。 | +| `GetHttpRequestTrailers` | 获取HTTP请求尾随头。此功能仅在 types.HttpContext.OnHttpRequestTrailers 和 types.HttpContext.OnHttpStreamDone 期间可用。 | +| `ReplaceHttpRequestTrailers` | 用给定的尾随头替换HTTP请求尾随头。此功能仅在 types.HttpContext.OnHttpRequestTrailers 期间可用。 | +| `GetHttpRequestTrailer` | 获取给定 "key" 的HTTP请求尾随头的值。此功能仅在 types.HttpContext.OnHttpRequestTrailers 和 types.HttpContext.OnHttpStreamDone 期间可用。 | +| `RemoveHttpRequestTrailer` | 移除请求尾随头中给定 "key" 的所有值。此功能仅在 types.HttpContext.OnHttpRequestTrailers 期间可用。 | +| `ReplaceHttpRequestTrailer` | 替换请求尾随头中给定 "key" 的值。此功能仅在 types.HttpContext.OnHttpRequestTrailers 期间可用。 | +| `AddHttpRequestTrailer` | 向请求尾随头添加给定 "key" 的值。此功能仅在 types.HttpContext.OnHttpRequestTrailers 期间可用。 | +| `ResumeHttpRequest` | 继续停止的HTTP请求处理。此功能仅在 types.HttpContext 期间可用。 | +| `GetHttpResponseHeaders` | 获取HTTP响应头。此功能仅在 types.HttpContext.OnHttpResponseHeaders 和 types.HttpContext.OnHttpStreamDone 期间可用。 | +| `ReplaceHttpResponseHeaders` | 用给定的头替换HTTP响应头。此功能仅在 types.HttpContext.OnHttpResponseHeaders 期间可用。 | +| `GetHttpResponseHeader ` | 获取给定 "key" 的HTTP响应头的值。此功能仅在 types.HttpContext.OnHttpResponseHeaders 和 types.HttpContext.OnHttpStreamDone 期间可用。 | +| `RemoveHttpResponseHeader` | 移除响应头中给定 "key" 的所有值。此功能仅在 types.HttpContext.OnHttpResponseHeaders 期间可用。 | +| `ReplaceHttpResponseHeader` | 替换响应头中给定 "key" 的值。此功能仅在 types.HttpContext.OnHttpResponseHeaders 期间可用。 | +| `AddHttpResponseHeader` | 向响应头添加给定 "key" 的值。此功能仅在 types.HttpContext.OnHttpResponseHeaders 期间可用。 | +| `GetHttpResponseBody` | 获取整个HTTP响应体。此功能仅在 types.HttpContext.OnHttpResponseBody 期间可用。 | +| `AppendHttpResponseBody` | 向HTTP响应体缓冲区追加给定字节。此功能仅在 types.HttpContext.OnHttpResponseBody 期间可用。 | +| `PrependHttpResponseBody` | 向HTTP响应体缓冲区前缀给定字节。此功能仅在 types.HttpContext.OnHttpResponseBody 期间可用。 | +| `ReplaceHttpResponseBody` | 用给定字节替换HTTP响应体缓冲区。此功能仅在 types.HttpContext.OnHttpResponseBody 期间可用。 | +| `GetHttpResponseTrailers` | 获取HTTP响应尾随头。此功能仅在 types.HttpContext.OnHttpResponseTrailers 和 types.HttpContext.OnHttpStreamDone 期间可用。 | +| `ReplaceHttpResponseTrailers` | 用给定的尾随头替换HTTP响应尾随头。此功能仅在 types.HttpContext.OnHttpResponseTrailers 期间可用。 | +| `GetHttpResponseTrailer` | 获取给定 "key" 的HTTP响应尾随头的值。此功能仅在 types.HttpContext.OnHttpResponseTrailers 和 types.HttpContext.OnHttpStreamDone 期间可用。 | +| `RemoveHttpResponseTrailer` | 移除响应尾随头中给定 "key" 的所有值。此功能仅在 types.HttpContext.OnHttpResponseTrailers 期间可用。 | +| `ReplaceHttpResponseTrailer` | 替换响应尾随头中给定 "key" 的值。此功能仅在 types.HttpContext.OnHttpResponseHeaders 期间可用。 | +| `AddHttpResponseTrailer` | 向响应尾随头添加给定 "key" 的值。此功能仅在 types.HttpContext.OnHttpResponseHeaders 期间可用。 | +| `ResumeHttpResponse` | 继续停止的HTTP响应处理。此功能仅在 types.HttpContext 期间可用。 | +| `SendHttpResponse` | 向下游发送HTTP响应。调用此函数后,您必须返回 types.Action.Pause 以停止初始HTTP请求/响应的进一步处理。 | + + +#### 10.共享数据操作 +| 函数名 | 描述 | +|--------------------------------|---------| +| `GetSharedData` | 获取共享数据。 | +| `SetSharedData` | 设置共享数据。 | + +#### 11.日志操作 +| 函数名 | 描述 | +|--------------------------------|--------------------------------------------------------------| +| `LogTrace` | 以 Trace 日志级别发出消息。 | +| `LogTracef` | 根据格式说明符格式化并发出 Trace 日志级别的日志。 | +| `LogDebug` | 以 Debug 日志级别发出消息。 | +| `LogDebugf` | 根据格式说明符格式化并发出 Debug 日志级别的日志。 | +| `LogInfo` | 以 Info 日志级别发出消息。 | +| `LogInfof` | 根据格式说明符格式化并发出 Info 日志级别的日志。 | +| `LogWarn` | 以 Warn 日志级别发出消息。 | +| `LogWarnf` | 根据格式说明符格式化并发出 Warn 日志级别的日志。 | +| `LogError` | 以 Error 日志级别发出消息。 | +| `LogErrorf` | 根据格式说明符格式化并发出 Error 日志级别的日志。 | +| `LogCritical` | 以 Critical 日志级别发出消息。 | +| `LogCriticalf` | 根据格式说明符格式化并发出 Critical 日志级别的日志。 | + +#### 12.指标操作 +| 函数名 | 描述 | +|--------------------------------|--------------------------------------------------------------| +| `DefineCounterMetric` | 为名称定义一个计数器指标。返回一个 `MetricCounter` 用于后续操作。 | +| `DefineGaugeMetric` | 为名称定义一个计量器指标。返回一个 `MetricGauge` 用于后续操作。 | +| `DefineHistogramMetric` | 为名称定义一个直方图指标。返回一个 `MetricHistogram` 用于后续操作。 | +| `MetricCounter.Value` | 获取 `MetricCounter` 的当前值。 | +| `MetricCounter.Increment` | 将 `MetricCounter` 的当前值增加指定的偏移量。 | +| `MetricGauge.Value` | 获取 `MetricGauge` 的当前值。 | +| `MetricGauge.Add` | 将 `MetricGauge` 的当前值增加指定的偏移量。 | +| `MetricHistogram.Value` | 获取 `MetricHistogram` 的当前值。 | +| `MetricHistogram.Record` | 为 `MetricHistogram` 记录一个值。 | + + + +### 3.3 插件调用入口 Entrypoint + +当 Envoy 创建 VM 时,在虚拟机内部创建 `VMContext` 之前,它会在启动阶段调用插件程序的 `main` 函数。所以必须在 `main` 函数中传递插件自定义的 `VMContext` 实现。 +[proxywasm](https://github.com/higress-group/proxy-wasm-go-sdk/blob/main/proxywasm/) 包的 `SetVMContext` 函数是入口点。`main` 函数如下: + +```go +func main() { + proxywasm.SetVMContext(&myVMContext{}) +} + +type myVMContext struct { .... } + +var _ types.VMContext = &myVMContext{} + +// Implementations follow... +``` + +## 4 跨虚拟机通信 + +Envoy 中的跨虚拟机通信(Cross-VM communications)允许在不同线程中运行 的Wasm 虚拟机(VMs)之间进行数据交换和通信。这在需要在多个VMs之间聚合数据、统计信息或缓存数据等场景中非常有用。 +跨虚拟机通信主要有两种方式: + +- 共享数据(Shared Data): + - 共享数据是一种在所有 VMs 之间共享的键值存储,可以用于存储和检索简单的数据项。 + - 它适用于存储小的、不经常变化的数据,例如配置参数或统计信息。 +- 共享队列(Shared Queue): + - 共享队列允许VMs之间进行更复杂的数据交换,支持发送和接收更丰富的数据结构。 + - 队列可以用于实现任务调度、异步消息传递等模式。 + +### 4.1 共享数据(Shared Data) + +如果想要在所有 Wasm 虚拟机(VMs)运行的多个工作线程间拥有全局请求计数器,或者想要缓存一些应被所有 Wasm VMs 使用的数据,那么共享数据(Shared Data)或等效的共享键值存储(Shared KVS)就会发挥作用。 +共享数据本质上是一个跨所有VMs共享的键值存储(即跨 VM 或跨线程)。 + +共享数据 KVS 是根据 vm_config 中指定的创建的。可以在所有 Wasm VMs 之间共享一个键值存储,而它们不必具有相同的二进制文件 `vm_config.code`,唯一的要求是具有相同的 vm_id。 + +![img](https://img.alicdn.com/imgextra/i2/O1CN01fLn4Lr1GXxhKORL9t_!!6000000000633-0-tps-1784-1266.jpg) + +在上图(图片来源 [Proxy-Wasm Go SDK](https://github.com/higress-group/proxy-wasm-go-sdk/blob/main/doc/OVERVIEW.md))中,可以看到即使它们具有不同的二进制文件( hello.wasm 和 bye.wasm ),"vm_id=foo"的 VMs 也共享相同的共享数据存储。 +hostcall.go 中定义共享数据相关的 API如下: +```go +// GetSharedData 用于检索给定 "key" 的值。 +// 返回的 "cas" 应用于 SetSharedData 以实现该键的线程安全更新。 +func GetSharedData(key string) (value []byte, cas uint32, err error) + +// SetSharedData 用于在共享数据存储中设置键值对。 +// 共享数据存储按主机中的 "vm_config.vm_id" 定义。 +// +// 当给定的 CAS 值与当前值不匹配时,将返回 ErrorStatusCasMismatch。 +// 这表明其他 Wasm VM 已经成功设置相同键的值,并且该键的当前 CAS 已递增。 +// 建议在遇到此错误时实现重试逻辑。 +// +// 将 cas 设置为 0 将永远不会返回 ErrorStatusCasMismatch 并且总是成功的, +// 但这并不是线程安全的,即可能在您调用此函数时另一个 VM 已经设置了该值, +// 看到的值与存储时的值已经不同。 +func SetSharedData(key string, value []byte, cas uint32) error +``` + +共享数据 API 是其线程安全性和跨 VM 安全性,这通过 "cas" ([Compare-And-Swap](https://en.wikipedia.org/wiki/Compare-and-swap))值来实现。如何使用 GetSharedData 和 SetSharedData 函数可以参考 [示例](https://github.com/higress-group/proxy-wasm-go-sdk/blob/main/examples/shared_data/main.go)。 + + +### 4.2 共享队列 Shared Queue + +如果要在请求/响应处理的同时跨所有 Wasm VMs 聚合指标,或者将一些跨 VM 聚合的信息推送到远程服务器,可以通过 *Shared Queue* 来实现。 + +*Shared Queue* 是为 `vm_id` 和队列名称的组合创建的 FIFO(先进先出)队列。并为该组合(`vm_id`,名称)分配了一个唯一的 *queue id*,该 ID 用于入队/出队操作。 + +“入队”和“出队”等操作具有线程安全性和跨 VM 安全性。在 hostcall.go 中与 *Shared Queue* 相关 API 如下: + +```go +// DequeueSharedQueue 从给定 queueID 的共享队列中出队数据。 +// 要获取目标队列的 queue id,请先使用 "ResolveSharedQueue"。 +func DequeueSharedQueue(queueID uint32) ([]byte, error) + +// RegisterSharedQueue 在此插件上下文中注册共享队列。 +// "注册" 意味着每当该 queueID 上有新数据入队时,将对此插件上下文调用 OnQueueReady。 +// 仅适用于 types.PluginContext。返回的 queueID 可用于 Enqueue/DequeueSharedQueue。 +// 请注意 "name" 必须在所有共享相同 "vm_id" 的 Wasm VMs 中是唯一的。使用 "vm_id" 来分隔共享队列的命名空间。 +// +// 只有在调用 RegisterSharedQueue 之后,ResolveSharedQueue("此 vm_id", "名称") 才能成功 +// 通过其他 VMs 检索 queueID。 +func RegisterSharedQueue(name string) (queueID uint32, err error) + +// EnqueueSharedQueue 将数据入队到给定 queueID 的共享队列。 +// 要获取目标队列的 queue id,请先使用 "ResolveSharedQueue"。 +func EnqueueSharedQueue(queueID uint32, data []byte) error + +// ResolveSharedQueue 获取给定 vmID 和队列名称的 queueID。 +// 返回的 queueID 可用于 Enqueue/DequeueSharedQueue。 +func ResolveSharedQueue(vmID, queueName string) (queueID uint32, err error) +``` + +`RegisterSharedQueue` 和 `DequeueSharedQueue` 由队列的“消费者”使用,而 `ResolveSharedQueue` 和 `EnqueueSharedQueue` 是为队列“生产者”准备的。请注意: + +- RegisterSharedQueue 用于为调用者的 name 和 vm_id 创建共享队列。使用一个队列,那么必须先由一个 VM 调用这个函数。这可以由 PluginContext 调用,因此可以认为“消费者” = PluginContexts。 +- ResolveSharedQueue 用于获取 name 和 vm_id 的 queue id。这是为“生产者”准备的。 + +这两个调用都返回一个队列 ID,该 ID 用于 DequeueSharedQueue 和 EnqueueSharedQueue。同时当队列中入队新数据时 消费者 PluginContext 中有 OnQueueReady(queueID uint32) 接口会收到通知。 +还强烈建议由 Envoy 的主线程上的单例 Wasm Service 创建共享队列。否则 OnQueueReady 将在工作线程上调用,这会阻塞它们处理 Http 或 Tcp 流。 + +![img](https://img.alicdn.com/imgextra/i1/O1CN01s1cT1s28xb7OKkEg0_!!6000000007999-0-tps-2378-1316.jpg) +在上图(图片来源 [Proxy-Wasm Go SDK](https://github.com/higress-group/proxy-wasm-go-sdk/blob/main/doc/OVERVIEW.md))中展示共享队列工作原理,更详细如何使用共享队列可以参考 [示例](https://github.com/higress-group/proxy-wasm-go-sdk/blob/main/examples/shared_queue/main.go)。 + + +## 5 限制和注意事项 + +以下是在使用 Proxy-Wasm Go SDK 和 Proxy-Wasm 编写插件时需要注意事项。 + +### 5.1 一些标准库不可用 + +一些现有的标准库不可用(可导入但运行时 panic / 无法导入)。这有几个原因: +1. TinyGo 的 WASI 目标不支持某些系统调用。 +2. TinyGo 没有实现 reflect 包的全部功能。 +3. [Proxy-Wasm C++ 主机](https://github.com/proxy-wasm/proxy-wasm-cpp-host) 尚未支持某些 WASI API。 +4. TinyGo 或 Proxy-Wasm 中不支持一些语言特性:包括 `recover` 和 `goroutine`。 + +随着 TinyGo 和 Proxy-Wasm 的发展,这些问题将得到缓解。 + +### 5.2 由于垃圾回收导致的性能开销 + +由于 GC,使用 Go/TinyGo 会带来性能开销,尽管可以认为与代理中的其他操作相比,GC 的开销足够小。 +TinyGo 允许禁用 GC,但由于内部需要使用映射(隐式引起分配)来保存虚拟机的状态,可以通过 `alloc(uintptr)` [接口](https://github.com/tinygo-org/tinygo/blob/v0.14.1/src/runtime/gc_none.go#L13) 使用 `-gc=custom` 选项设置 proxy-wasm 定制的 GC 算法。 + +### 5.3 `recover` 未实现 + +在 TinyGo 中,`recover` 未实现(https://github.com/tinygo-org/tinygo/issues/891)。这也意味着依赖 `recover` 的代码将无法按预期工作。 + +### 5.4 Goroutine 不支持 + +在 TinyGo 中,Goroutine 通过 LLVM 的协程实现(见[这篇博客文章](https://aykevl.nl/2019/02/tinygo-goroutines))。 在 Envoy 中,Wasm 模块以事件驱动的方式运行,因此一旦主函数退出,“调度器”就不再执行。因此不能像普通主机环境中那样使用 Goroutine 。 +在以事件驱动方式执行的 Wasm VM 线程中如何处理 Goroutine 的问题尚未有解决方案。建议使用实现 `OnTick` 函数来处理任何异步任务。 + +## 6 插件开发样例 + +用 Proxy-Wasm Go SDK 实现一个简单的插件,具体样例如下: + +```golang +package main + +import ( + "github.com/higress-group/proxy-wasm-go-sdk/proxywasm" + "github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types" +) + +// 插件入口 +func main() { + proxywasm.SetVMContext(&vmContext{}) +} + +// VM 上下文 +type vmContext struct { + // Embed the default VM context here, + types.DefaultVMContext + // 这里添加 VM 配置 +} + +// VM 启动回调 +func (*vmContext) OnVMStart(vmConfigurationSize int) types.OnVMStartStatus { + proxywasm.LogInfof("OnVMStart()") + // 获取 VM 配置 + _, err := proxywasm.GetVMConfiguration() + if err != nil { + proxywasm.LogCriticalf("error reading vm configuration: %v", err) + } + // 这里解析 VM 配置 + return types.OnVMStartStatusOK +} + +// 生成插件上下文 +func (*vmContext) NewPluginContext(contextID uint32) types.PluginContext { + proxywasm.LogInfof("NewPluginContex()") + return &pluginContext{} +} + +// 插件上下文 +type pluginContext struct { + // Embed the default plugin context here, + types.DefaultPluginContext + // 这里添加插件配置 +} + +// Http 上下文 +type httpContext struct { + // Embed the default root http context here, + // so that we don't need to reimplement all the methods. + types.DefaultHttpContext + // 这里添加http 上下文属性 + requestBodySize int + responseBodySize int +} + +// 生成 Http 上下文 +func (ctx *pluginContext) NewHttpContext(contextID uint32) types.HttpContext { + proxywasm.LogInfof("NewHttpContext()") + return &httpContext{} +} + +// 插件启动回调, +func (ctx *pluginContext) OnPluginStart(pluginConfigurationSize int) types.OnPluginStartStatus { + proxywasm.LogInfof("OnPluginStart()") + // 获取插件配置 + _, err := proxywasm.GetPluginConfiguration() + if err != nil { + proxywasm.LogCriticalf("error reading plugin configuration: %v", err) + } + // 这里解析插件配置 + + return types.OnPluginStartStatusOK +} + +// http 请求头回调 +func (ctx *httpContext) OnHttpRequestHeaders(numHeaders int, endOfStream bool) types.Action { + proxywasm.LogInfof("OnHttpRequestHeaders()") + // 这里处理请求头回调 + return types.ActionContinue +} + +// http 请求体回调,注意这里流式处理 +func (ctx *httpContext) OnHttpRequestBody(bodySize int, endOfStream bool) types.Action { + proxywasm.LogInfof("OnHttpRequestBody()") + ctx.requestBodySize += bodySize + if !endOfStream { + // Wait until we see the entire body to replace. + return types.ActionPause + } + _, err := proxywasm.GetHttpRequestBody(0, ctx.requestBodySize) + if err != nil { + proxywasm.LogErrorf("failed to get request body: %v", err) + return types.ActionContinue + } + + return types.ActionContinue +} + +// http 响应头回调 +func (ctx *httpContext) OnHttpResponseHeaders(numHeaders int, endOfStream bool) types.Action { + proxywasm.LogInfof("OnHttpResponseHeaders()") + // 这里响应头回调 + return types.ActionContinue +} + +// http 响应体回调, 注意这里流式处理 +func (ctx *httpContext) OnHttpResponseBody(bodySize int, endOfStream bool) types.Action { + proxywasm.LogInfof("OnHttpResponseBody()") + ctx.responseBodySize += bodySize + // 判断是否响应体结束 + if !endOfStream { + // Wait until we see the entire body to replace. + return types.ActionPause + } + _, err := proxywasm.GetHttpResponseBody(0, ctx.responseBodySize) + if err != nil { + proxywasm.LogErrorf("failed to get response body: %v", err) + return types.ActionContinue + } + return types.ActionContinue +} +``` +核心步骤如下: +- 入口注册 vmContext +- VM 启动回调时候解析 VM 配置 +- 由 vmContext 生成 pluginContext +- 插件启动回调时候解析插件配置 +- 对于每个 http 流,pluginContext 生成 httpContext +- 生成的 httpContext 处理请求头、请求体、响应头、响应体,这里要注意的是处理 OnHttpRequestBody 和 OnHttpResponseBody 回调是流式处理 + +可以通过 [开发样例](https://github.com/higress-group/proxy-wasm-go-sdk/tree/main/examples) 查看更多 Proxy-Wasm Go SDK 插件开发样例。 + +## 参考 +- [1] [proxy-wasm-go-sdk doc](https://github.com/higress-group/proxy-wasm-go-sdk/blob/main/doc/OVERVIEW.md) +- [2] [proxy-wasm-go-sdk example](https://github.com/higress-group/proxy-wasm-go-sdk/tree/main/examples) +- [3] [WebAssembly 解释器实现篇](https://github.com/mcuking/blog/issues/96/) +- [4] [理解 WebAssembly 文本格式](https://developer.mozilla.org/zh-CN/docs/WebAssembly/Understanding_the_text_format) +- [5] [Wasm Module Binary Format](https://webassembly.github.io/spec/core/binary/modules.html) +- [6] [WebAssembly 究竟是什么?](https://www.bilibili.com/video/BV1WK42117dW) +- [7] [WebAssembly 在 MOSN 中的实践 - 基础框架篇](https://mosn.io/blog/posts/mosn-wasm-framework/) \ No newline at end of file diff --git a/src/content/docs/ebook/en/wasm16.md b/src/content/docs/ebook/en/wasm16.md new file mode 100644 index 0000000000..0e154e326f --- /dev/null +++ b/src/content/docs/ebook/en/wasm16.md @@ -0,0 +1,651 @@ +--- +title: Higress 插件 Go SDK 与处理流程 +keywords: [Higress] +--- + +# Higress 插件 Go SDK 与处理流程 + +本章开始介绍详细 Higress 插件开发 SDK 、插件开发流程和插件开发注意事项。 + +## 1 Higress 插件 Go SDK + +Higress 插件 Go SDK 在 proxy-wasm-go-sdk 上封装了一层,简化插件开发和增强功能。其代码位置:https://github.com/alibaba/higress/tree/main/plugins/wasm-go/pkg ,代码文件结构如下: + +```shell +tree +. +├── matcher +│   ├── rule_matcher.go +│   ├── rule_matcher_test.go +│   └── utils.go +└── wrapper + ├── cluster_wrapper.go + ├── cluster_wrapper_test.go + ├── http_wrapper.go + ├── log_wrapper.go + ├── plugin_wrapper.go + ├── redis_wrapper.go + └── request_wrapper.go + └── response_wrapper.go +``` +Higress 插件 Go SDK 主要增强功能如下: +- matcher 包提供全局、路由、域名级别配置的解析功能。 +- wrapper 包下 log_wrapper.go 封装和简化插件日志的输出功能。 +- wrapper 包下 cluster_wrapper.go、redis_wrapper.go、http_wrapper.go 封装 Http 和 Redis Host Function Call。 +- wrapper 包下 plugin_wrapper.go 封装 proxy-wasm-go-sdk 的 VMContext、PluginContext、HttpContext、插件配置解析功能。 +- wrapper 包下 request_wrapper.go、response_wrapper.go 提供关于请求和响应公共方法。 + +本章主要集中介绍 plugin_wrapper.go 提供 VMContext、PluginContext、HttpContext、插件配置解析功能。 + +## 2 Higress 插件 Go SDK 开发 + +相对应于 proxy-wasm-go-sdk 中的 VMContext、PluginContext、HttpContext 3 个上下文, 在 Higress 插件 Go SDK 中是 CommonVmCtx、CommonPluginCtx、CommonHttpCtx 3 个支持泛型的 struct。 3 个 struct 的核心内容如下: + +```golang +type CommonVmCtx[PluginConfig any] struct { + // proxy-wasm-go-sdk DefaultVMContext 默认实现 + types.DefaultVMContext + // 插件名称 + pluginName string + // 插件日志工具 + log Log + hasCustomConfig bool + // 插件配置解析函数 + parseConfig ParseConfigFunc[PluginConfig] + // 插件路由、域名、服务级别配置解析函数 + parseRuleConfig ParseRuleConfigFunc[PluginConfig] + // 以下是自定义插件回调钩子函数 + onHttpRequestHeaders onHttpHeadersFunc[PluginConfig] + onHttpRequestBody onHttpBodyFunc[PluginConfig] + onHttpStreamingRequestBody onHttpStreamingBodyFunc[PluginConfig] + onHttpResponseHeaders onHttpHeadersFunc[PluginConfig] + onHttpResponseBody onHttpBodyFunc[PluginConfig] + onHttpStreamingResponseBody onHttpStreamingBodyFunc[PluginConfig] + onHttpStreamDone onHttpStreamDoneFunc[PluginConfig] +} + +type CommonPluginCtx[PluginConfig any] struct { + // proxy-wasm-go-sdk DefaultPluginContext 默认实现 + types.DefaultPluginContext + // 解析后保存路由、域名、服务级别配置和全局插件配置 + matcher.RuleMatcher[PluginConfig] + // 引用 CommonVmCtx + vm *CommonVmCtx[PluginConfig] + // tickFunc 数组 + onTickFuncs []TickFuncEntry +} + +type CommonHttpCtx[PluginConfig any] struct { + // proxy-wasm-go-sdk DefaultHttpContext 默认实现 + types.DefaultHttpContext + // 引用 CommonPluginCtx + plugin *CommonPluginCtx[PluginConfig] + // 当前 Http 上下文下匹配插件配置,可能是路由、域名、服务级别配置或者全局配置 + config *PluginConfig + // 是否处理请求体 + needRequestBody bool + // 是否处理响应体 + needResponseBody bool + // 是否处理流式请求体 + streamingRequestBody bool + // 是否处理流式响应体 + streamingResponseBody bool + // 非流式处理缓存请求体大小 + requestBodySize int + // 非流式处理缓存响应体大小 + responseBodySize int + // Http 上下文 ID + contextID uint32 + // 自定义插件设置自定义插件上下文 + userContext map[string]interface{} +} +``` + +它们的关系如下图: +![img](https://img.alicdn.com/imgextra/i3/O1CN01nkPR171qsrwfUK0WW_!!6000000005552-2-tps-1640-600.png) + +### 2.1 启动入口和 VM 上下文(CommonVmCtx) + +```golang +func main() { + wrapper.SetCtx( + // 插件名称 + "hello-world", + // 设置自定义函数解析插件配置,这个方法适合插件全局配置和路由、域名、服务级别配置内容规则是一样 + wrapper.ParseConfigBy(parseConfig), + // 设置自定义函数解析插件全局配置和路由、域名、服务级别配置,这个方法适合插件全局配置和路由、域名、服务级别配置内容规则不一样 + wrapper.ParseOverrideConfigBy(parseConfig, parseRuleConfig) + // 设置自定义函数处理请求头 + wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders), + // 设置自定义函数处理请求体 + wrapper.ProcessRequestBodyBy(onHttpRequestBody), + // 设置自定义函数处理响应头 + wrapper.ProcessResponseHeadersBy(onHttpResponseHeaders), + // 设置自定义函数处理响应体 + wrapper.ProcessResponseBodyBy(onHttpResponseBody), + // 设置自定义函数处理流式请求体 + wrapper.ProcessStreamingRequestBodyBy(onHttpStreamingRequestBody), + // 设置自定义函数处理流式响应体 + wrapper.ProcessStreamingResponseBodyBy(onHttpStreamingResponseBody), + // 设置自定义函数处理流式请求完成 + wrappper.ProcessStreamDoneBy(onHttpStreamDone) + ) +} +``` +> 可以根据实际业务需要来选择设置回调钩子函数。 + +跟踪一下 wrapper.SetCtx 的实现: +- 创建 CommonVmCtx 对象同时设置自定义插件回调钩子函数。 +- 然后再调用 proxywasm.SetVMContext 设置 VMContext。 + +```golang +func SetCtx[PluginConfig any](pluginName string, setFuncs ...SetPluginFunc[PluginConfig]) { + // 调用 proxywasm.SetVMContext 设置 VMContext + proxywasm.SetVMContext(NewCommonVmCtx(pluginName, setFuncs...)) +} + +func NewCommonVmCtx[PluginConfig any](pluginName string, setFuncs ...SetPluginFunc[PluginConfig]) *CommonVmCtx[PluginConfig] { + ctx := &CommonVmCtx[PluginConfig]{ + pluginName: pluginName, + log: Log{pluginName}, + hasCustomConfig: true, + } + // CommonVmCtx 里设置自定义插件回调钩子函数 + for _, set := range setFuncs { + set(ctx) + } + ... + return ctx +``` + +### 2.2 插件上下文(CommonPluginCtx) + +#### 2.2.1 创建 CommonPluginCtx 对象 +通过 CommonVmCtx 的 NewPluginContext 方法创建 CommonPluginCtx 对象, 设置 CommonPluginCtx 的 vm 引用。 +```golang +func (ctx *CommonVmCtx[PluginConfig]) NewPluginContext(uint32) types.PluginContext { + return &CommonPluginCtx[PluginConfig]{ + vm: ctx, + } +} +``` + +#### 2.2.2 插件启动和插件配置解析 + +CommonPluginCtx 的 OnPluginStart 部分核心代码如下: +```golang +func (ctx *CommonPluginCtx[PluginConfig]) OnPluginStart(int) types.OnPluginStartStatus { + // 调用 proxywasm.GetPluginConfiguration 获取插件配置 + data, err := proxywasm.GetPluginConfiguration() + globalOnTickFuncs = nil + ... + var jsonData gjson.Result + // 插件配置转成 json + jsonData = gjson.ParseBytes(data) + + // 设置 parseOverrideConfig + var parseOverrideConfig func(gjson.Result, PluginConfig, *PluginConfig) error + if ctx.vm.parseRuleConfig != nil { + parseOverrideConfig = func(js gjson.Result, global PluginConfig, cfg *PluginConfig) error { + // 解析插件路由、域名、服务级别插件配置 + return ctx.vm.parseRuleConfig(js, global, cfg, ctx.vm.log) + } + } + ... + // 解析插件配置 + err = ctx.ParseRuleConfig(jsonData, + func(js gjson.Result, cfg *PluginConfig) error { + // 解析插件全局或者当 parseRuleConfig 没有设置时候同时解析路由、域名、服务级别插件配置 + return ctx.vm.parseConfig(js, cfg, ctx.vm.log) + }, + parseOverrideConfig, + ) + ... + if globalOnTickFuncs != nil { + ctx.onTickFuncs = globalOnTickFuncs + ... + } + return types.OnPluginStartStatusOK +} +``` + +可以发现在解析插件配置过程中有两个回调钩子函数,parseConfig 和 parseRuleConfig。 +- parseConfig :解析插件全局配置,如果 parseRuleConfig 没有设置,那么 parseConfig 会同时解析全局配置和路由、域名、服务级别配置。也就是说插件全局配置和路由、域名、服务级别配置规则是一样。 +- parseRuleConfig: 解析路由、域名、服务级别插件配置。如果设置 parseRuleConfig,也就是说插件全局配置和路由、域名、服务级别配置规则是不同的。 + + +> 这里我们不进一步分析插件解析过程,后续在插件生效原理章节从控制面和数据面详细分析插件全局、路由、域名、服务级别生效原理。 + +大部分情况下插件全局配置和路由、域名、服务级别配置规则是一样的,因此在定义插件时只需要调用 wrapper.ParseConfigBy(parseConfig) 来设置插件配置解析回调钩子函数。 +而有些插件(如 [basic-auth](https://higress.io/docs/latest/plugins/authentication/basic-auth/))的全局配置和路由、域名、服务级别配置规则是不一样的。baisc-auth 插件配置 YAML 样例如下: +```yaml +apiVersion: extensions.higress.io/v1alpha1 +kind: WasmPlugin +metadata: + name: cpp-basic-auth + namespace: higress-system +spec: + defaultConfig: + consumers: + - credential: admin:123456 + name: consumer1 + - credential: guest:abc + name: consumer2 + global_auth: false + defaultConfigDisable: false + matchRules: + - config: + allow: + - consumer1 + configDisable: false + ingress: + - higress-conformance-infra/wasmplugin-cpp-basic-auth + url: file:///opt/plugins/wasm-cpp/extensions/basic-auth/plugin.wasm +``` + +可以看出 matchRule 下 config 配置内容和 defaultConfig 配置内容不一样。所以在开发插件的时候,需要同时设置 parseConfig 和 parseRuleConfig 两个回调钩子函数。 +baisc-auth 部分核心代码如下: +```golang +func main() { + wrapper.SetCtx( + "basic-auth", + // 要同时设置 parseConfig 和 parseRuleConfig 回调钩子函数 + // ParseOverrideConfigBy 函数的第一个参数接收 parseConfig 回调钩子函数,第二个参数接收 parseRuleConfig 回调钩子函数 + wrapper.ParseOverrideConfigBy(parseGlobalConfig, parseOverrideRuleConfig), + wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders), + ) +} + +// 自定义插件配置 +type BasicAuthConfig struct { + // 插件全局配置内容 + globalAuth *bool `yaml:"global_auth"` + consumers []Consumer `yaml:"consumers"` + ... + // 插件路由、域名、服务级别配置内容 + allow []string `yaml:"allow"` +} + +type Consumer struct { + name string `yaml:"name"` + credential string `yaml:"credential"` +} + +// 解析插件全局配置回调钩子函数 +func parseGlobalConfig(json gjson.Result, global *BasicAuthConfig, log wrapper.Log) error { + log.Debug("global config") + // 解析插件全局配置 + ... + consumers := json.Get("consumers") + ... + globalAuth := json.Get("global_auth") + ... + return nil +} + +// 解析插件路由、域名、服务级别配置回调钩子函数 +func parseOverrideRuleConfig(json gjson.Result, global BasicAuthConfig, config *BasicAuthConfig, log wrapper.Log) error { + log.Debug("domain/route config") + // 这里要注意要用全局配置内容复制到路由、域名、服务级别配置中,这样后续在 HttpContext 中可以获取当前 Http 请求下插件配置包括全局配置和路由、域名、服务级别配置 + *config = global + // 解析插件路由、域名、服务级别配置 + allow := json.Get("allow") + ... + for _, item := range allow.Array() { + config.allow = append(config.allow, item.String()) + } + ... + return nil +} +``` +开发这种类型插件需要注意: +- 自定义插件配置 struct 要包含全局配置内容和路由、域名、服务级别配置内容。 +- wrapper.ParseOverrideConfigBy 要同时设置 parseConfig 和 parseRuleConfig 回调钩子函数。 +- 在 parseRuleConfig 回调钩子函数处理中,全局配置内容要复制到路由、域名、服务级别配置中,这样后续在 HttpContext 中可以获取当前 Http 请求下插件配置包括全局配置和路由、域名、服务级别配置内容。 + +### 2.3 HTTP 上下文(CommonHttpCtx) + +#### 2.3.1 创建 CommonHttpCtx + +CommonPluginCtx 的 NewHttpContext 部分核心代码如下: + +```golang +func (ctx *CommonPluginCtx[PluginConfig]) NewHttpContext(contextID uint32) types.HttpContext { + httpCtx := &CommonHttpCtx[PluginConfig]{ + plugin: ctx, + contextID: contextID, + userContext: map[string]interface{}{}, + } + // 根据插件配置判断设置是否需要处理请求和响应的 body + if ctx.vm.onHttpRequestBody != nil || ctx.vm.onHttpStreamingRequestBody != nil { + httpCtx.needRequestBody = true + } + if ctx.vm.onHttpResponseBody != nil || ctx.vm.onHttpStreamingResponseBody != nil { + httpCtx.needResponseBody = true + } + if ctx.vm.onHttpStreamingRequestBody != nil { + httpCtx.streamingRequestBody = true + } + if ctx.vm.onHttpStreamingResponseBody != nil { + httpCtx.streamingResponseBody = true + } + + return httpCtx +} +``` +#### 2.3.2 OnHttpRequestHeaders + +OnHttpRequestHeaders 核心代码如下: +```golang +func (ctx *CommonHttpCtx[PluginConfig]) OnHttpRequestHeaders(numHeaders int, endOfStream bool) types.Action { + // 获取当前 HTTP 请求生效插件配置 + config, err := ctx.plugin.GetMatchConfig() + ... + // 设置插件配置到 HttpContext + ctx.config = config + // 如果请求 content-type 是 octet-stream/grpc 或者定义 content-encoding,则不处理请求 body + if IsBinaryRequestBody() { + ctx.needRequestBody = false + } + ... + // 调用自定义插件 onHttpRequestHeaders 回调钩子函数 + return ctx.plugin.vm.onHttpRequestHeaders(ctx, *config, ctx.plugin.vm.log) +} +``` +主要处理逻辑如下: +- 获取匹配当前 HTTP 请求插件配置,可能是路由、域名、服务级别配置或者全局配置。 +- 设置插件配置到 HttpContext。 +- 如果请求 content-type 是 octet-stream/grpc 或者定义 content-encoding,则不处理请求 body。 +- 调用自定义插件 onHttpRequestHeaders 回调钩子函数。 + +关于插件配置可以看出, Higress 插件 Go SDK 封装如下: +- 在插件启动时候,解析插件路由、域名、服务级别插件配置和全局配置保存到 CommonPluginCtx 中。 +- 在 onHttpRequestHeaders 阶段,根据当前 HTTP 上下文中路由、域名、服务等信息匹配插件配置,返回路由、域名、服务级别配置或者全局配置。然后把匹配到插件配置设置到 HttpContext 对象的 config 属性中,这样自定义插件的所有回调钩子函数就可以获取到这个配置。 + +#### 2.3.3 OnHttpRequestBody + +OnHttpRequestBody 核心代码如下: +```golang +func (ctx *CommonHttpCtx[PluginConfig]) OnHttpRequestBody(bodySize int, endOfStream bool) types.Action { + ... + // 如果不需要处理请求 body,则直接返回,继续后续处理 + if !ctx.needRequestBody { + return types.ActionContinue + } + // 先判断是否要需要进行流式处理,如果需要则调用自定义插件 onHttpStreamingRequestBody 回调钩子函数 + if ctx.plugin.vm.onHttpStreamingRequestBody != nil && ctx.streamingRequestBody { + chunk, _ := proxywasm.GetHttpRequestBody(0, bodySize) + // 调用自定义插件 onHttpStreamingRequestBody 回调钩子函数 + modifiedChunk := ctx.plugin.vm.onHttpStreamingRequestBody(ctx, *ctx.config, chunk, endOfStream, ctx.plugin.vm.log) + err := proxywasm.ReplaceHttpRequestBody(modifiedChunk) + ... + return types.ActionContinue + } + // 再判断是否要需要进行非流式处理,需要缓存请求 body,等读取整个请求 body 后调用自定义插件 onHttpRequestBody 回调钩子函数 + if ctx.plugin.vm.onHttpRequestBody != nil { + ctx.requestBodySize += bodySize + if !endOfStream { + return types.ActionPause + } + body, err := proxywasm.GetHttpRequestBody(0, ctx.requestBodySize) + ... + // 调用自定义插件 onHttpRequestBody 回调钩子函数 + return ctx.plugin.vm.onHttpRequestBody(ctx, *ctx.config, body, ctx.plugin.vm.log) + } + return types.ActionContinue +} +``` + +核心逻辑如下: +- 如果 ctx.needRequestBody 为 false 时,则直接返回,继续后续处理。 +- 当 ctx.streamingRequestBody 为 true 时,同时自定义插件有 onHttpStreamingRequestBody 回调钩子函数,则调用自定义插件 onHttpStreamingRequestBody 回调钩子函数。 +- 当自定义插件有 onHttpRequestBody 回调钩子函数,需要缓存请求 body,等读取整个请求 body 后调用自定义插件 onHttpRequestBody 回调钩子函数。 + +#### 2.3.4 OnHttpResponseHeaders + +OnHttpResponseHeaders 核心代码如下: +```golang +func (ctx *CommonHttpCtx[PluginConfig]) OnHttpResponseHeaders(numHeaders int, endOfStream bool) types.Action { + ... + // To avoid unexpected operations, plugins do not read the binary content body + if IsBinaryResponseBody() { + ctx.needResponseBody = false + } + ... + return ctx.plugin.vm.onHttpResponseHeaders(ctx, *ctx.config, ctx.plugin.vm.log) +} + +``` +主要处理逻辑如下: +- 如果响应 content-type 是 octet-stream/grpc 或者定义 content-encoding,则不处理响应 body。 +- 调用自定义插件 onHttpResponseHeaders 回调钩子函数。 + +#### 2.3.5 OnHttpResponseBody + +OnHttpResponseBody 核心代码如下: +```golang +func (ctx *CommonHttpCtx[PluginConfig]) OnHttpResponseBody(bodySize int, endOfStream bool) types.Action { + ... + // 如果不需要处理响应 body,则直接返回,继续后续处理 + if !ctx.needResponseBody { + return types.ActionContinue + } + // 先判断是否要需要进行流式处理,如果需要则调用自定义插件 onHttpStreamingResponseBod 回调钩子函数 + if ctx.plugin.vm.onHttpStreamingResponseBody != nil && ctx.streamingResponseBody { + chunk, _ := proxywasm.GetHttpResponseBody(0, bodySize) + // 调用自定义插件 onHttpStreamingResponseBody 回调钩子函数 + modifiedChunk := ctx.plugin.vm.onHttpStreamingResponseBody(ctx, *ctx.config, chunk, endOfStream, ctx.plugin.vm.log) + ... + return types.ActionContinue + } + // 再判断是否要需要进行非流式处理,需要缓存响应 body,等读取整个响应 body 后调用自定义插件 onHttpResponseBody 回调钩子函数 + if ctx.plugin.vm.onHttpResponseBody != nil { + ctx.responseBodySize += bodySize + if !endOfStream { + return types.ActionPause + } + body, err := proxywasm.GetHttpResponseBody(0, ctx.responseBodySize) + ... + // 调用自定义插件 onHttpResponseBody 回调钩子函数 + return ctx.plugin.vm.onHttpResponseBody(ctx, *ctx.config, body, ctx.plugin.vm.log) + } + return types.ActionContinue +} +``` + +核心逻辑如下: +- 当 ctx.needResponseBody 为 false 时,则直接返回,继续后续处理。 +- 当 ctx.streamingResponseBody 为 true 时,同时自定义插件有 onHttpStreamingResponseBody 回调钩子函数,则调用自定义插件 onHttpStreamingResponseBody 回调钩子函数。 +- 当自定义插件有 onHttpResponseBody 回调钩子函数,需要缓存响应 body,等读取整个响应 body 后调用自定义插件 onHttpResponseBody 回调钩子函数。 + +#### 2.3.6 OnHttpStreamDone + +OnHttpStreamDone 核心代码如下: +```golang +func (ctx *CommonHttpCtx[PluginConfig]) OnHttpStreamDone() { + ... + ctx.plugin.vm.onHttpStreamDone(ctx, *ctx.config, ctx.plugin.vm.log) +} +``` + +OnHttpStreamDone 比较简单,自定义插件有 onHttpStreamDone 回调钩子函数,则调用自定义插件 onHttpStreamDone 回调钩子函数。 + +#### 2.3.7 CommonHttpCtx 方法 + +CommonHttpCtx 提供以下方法,自定义插件可以调用,其代码和注释如下: +```golang +// 设置自定义上下文,这个上下文可以在自定义插件所有回调钩子函数中可以获取 +func (ctx *CommonHttpCtx[PluginConfig]) SetContext(key string, value interface{}) { + ctx.userContext[key] = value +} +// 获取自定义上下文,这个上下文可以在自定义插件所有回调钩子函数中可以获取 +func (ctx *CommonHttpCtx[PluginConfig]) GetContext(key string) interface{} { + return ctx.userContext[key] +} +// 获取 bool 类型自定义上下文 +func (ctx *CommonHttpCtx[PluginConfig]) GetBoolContext(key string, defaultValue bool) bool { + if b, ok := ctx.userContext[key].(bool); ok { + return b + } + return defaultValue +} +// 获取 string 类型自定义上下文 +func (ctx *CommonHttpCtx[PluginConfig]) GetStringContext(key, defaultValue string) string { + if s, ok := ctx.userContext[key].(string); ok { + return s + } + return defaultValue +} +// 获取请求 scheme +func (ctx *CommonHttpCtx[PluginConfig]) Scheme() string { + proxywasm.SetEffectiveContext(ctx.contextID) + return GetRequestScheme() +} +// 获取请求 host +func (ctx *CommonHttpCtx[PluginConfig]) Host() string { + proxywasm.SetEffectiveContext(ctx.contextID) + return GetRequestHost() +} +// 获取请求 path +func (ctx *CommonHttpCtx[PluginConfig]) Path() string { + proxywasm.SetEffectiveContext(ctx.contextID) + return GetRequestPath() +} +// 获取请求 method +func (ctx *CommonHttpCtx[PluginConfig]) Method() string { + proxywasm.SetEffectiveContext(ctx.contextID) + return GetRequestMethod() +} + +// 调用这个方法可以禁止读取请求 body 和处理。 +// 比如在 onHttpRequestHeaders 回调钩子函数中根据某些条件时调用,可以跳过后续的请求 body 读取和处理 +func (ctx *CommonHttpCtx[PluginConfig]) DontReadRequestBody() { + ctx.needRequestBody = false +} + +// 调用这个方法禁止读取响应 body, +// 比如在 onHttpResponseHeaders 回调钩子函数中根据某些条件时调用,可以跳过后续的响应 body 读取和处理 +func (ctx *CommonHttpCtx[PluginConfig]) DontReadResponseBody() { + ctx.needResponseBody = false +} +// 调用这个方法禁止请求流式处理 +// 比如在 onHttpRequestHeaders 回调钩子函数中根据某些条件时调用,跳过后续的 onHttpStreamingRequestBody 流式处理, 转成 onHttpRequestBody 处理 +func (ctx *CommonHttpCtx[PluginConfig]) BufferRequestBody() { + ctx.streamingRequestBody = false +} + +// 调用这个方法禁止响应流式处理 +// 比如在 onHttpResponseHeaders 回调钩子函数中根据某些条件时调用,跳过后续的 onHttpStreamingResponseBody 流式处理, 转成 onHttpResponseBody 处理 +func (ctx *CommonHttpCtx[PluginConfig]) BufferResponseBody() { + ctx.streamingResponseBody = false +} + +// 调用这个方法可以禁止重新计算路由,Envoy 默认在 Http 头发生变更时会重新计算路由,调用这个方法,可以禁止重新计算路由。 +// 比如在 onHttpRequestHeaders 回调钩子函数中根据某些条件时调用这个方法,可以禁止重新计算路由。 +// 关于 DisableReroute 使用场景可以参考 Higress 官方提供 [ai-proxy 插件](https://github.com/alibaba/higress/blob/main/plugins/wasm-go/extensions/ai-proxy/main.go#L76) +func (ctx *CommonHttpCtx[PluginConfig]) DisableReroute() { + _ = proxywasm.SetProperty([]string{"clear_route_cache"}, []byte("off")) +} +// 设置请求 body buffer limit +// 关于 DisableReroute 使用场景可以参考 Higress 官方提供 [ai-proxy 插件](https://github.com/alibaba/higress/blob/main/plugins/wasm-go/extensions/ai-proxy/main.go#L81) +func (ctx *CommonHttpCtx[PluginConfig]) SetRequestBodyBufferLimit(size uint32) { + ctx.plugin.vm.log.Infof("SetRequestBodyBufferLimit: %d", size) + _ = proxywasm.SetProperty([]string{"set_decoder_buffer_limit"}, []byte(strconv.Itoa(int(size)))) +} +// 设置响应 body buffer limit +func (ctx *CommonHttpCtx[PluginConfig]) SetResponseBodyBufferLimit(size uint32) { + ctx.plugin.vm.log.Infof("SetResponseBodyBufferLimit: %d", size) + _ = proxywasm.SetProperty([]string{"set_encoder_buffer_limit"}, []byte(strconv.Itoa(int(size)))) +} +``` + +核心内容如下: +- 在 onHttpRequestHeaders 阶段: + - 调用 DontReadRequestBody 方法,可以跳过读取请求 body 和处理。 + - 调用 BufferRequestBody 方法,可以跳过请求 onHttpStreamingRequestBody 流式处理,转成 onHttpRequestBody 处理。 + - 调用 DisableReroute 方法,可以禁止重新计算路由。 +- 在 onHttpResponseHeaders 阶段: + - 调用 DontReadResponseBody 方法,可以跳过读取响应 body 和处理。 + - 调用 BufferResponseBody 方法,可以跳过响应 onHttpStreamingResponseBody 流式处理,转成 onHttpResponseBody 处理。 +- SetContext 和 GetContext 方法,可以设置和获取自定义上下文,在自定义插件所有回调钩子函数中可以使用。 +- SetRequestBodyBufferLimit 和 SetResponseBodyBufferLimit 方法,可以设置请求 body buffer limit 和响应 body buffer limit。 + +### 2.4 Types.Action + +在自定义插件中 onHttpRequestHeaders、onHttpRequestBody、onHttpResponseHeaders、onHttpResponseBody 返回值类型为 types.Action。通过 types.Action 枚举值来控制插件的运行流程,常见的返回值如下: + +1. types.ActionContinue:继续后续处理,比如继续读取请求 body,或者继续读取响应 body。 + +2. types.ActionPause: 暂停后续处理,比如在 onHttpRequestHeaders 通过 Http 或者 Redis 调用外部服务获取认证信息,在调用外部服务回调钩子函数中调用 proxywasm.ResumeHttpRequest() 来恢复后续处理 或者调用 proxywasm.SendHttpResponseWithDetail() 来返回响应。 + + +#### 2.4.1 编译插件注意事项 + +1. Higress 需要编译时启用 `EXTRA_TAGS=proxy_wasm_version_0_2_100` 标签来修改 Proxy Wasm ABI。 TinyGo 本地构建命令如下: +```shell +tinygo build -scheduler=none -target=wasi -gc=custom -tags='custommalloc nottinygc_finalizer proxy_wasm_version_0_2_100' -o ./build/plugin.wasm main.go +``` +2. Makefile 文件默认启用 `proxy_wasm_version_0_2_100` 标签,所以不需要修改 Makefile 文件。 + +#### 2.4.2 Header 状态码 + +1. HeaderContinue: + +表示当前 filter 已经处理完毕,可以继续交给下一个 filter 处理。 types.ActionContinue 对应就是这个状态。 + +2. HeaderStopIteration: + +表示 header 还不能继续交给下一个 filter 来处理。 但是并不停止从连接读数据,继续触发 body data 的处理。 这样可以在 body data 处理阶段可以更新 Http 请求头内容。 如果 body data 要交给下一个 filter 处理, 这时 header 是也会被一起交给下一个 filter 处理。 + +3. HeaderContinueAndEndStream: + +表示 header 可以继续交给下一个 filter 处理,但是下一个 filter 收到的 end_stream = false,也就是标记请求还未结束。以便当前 filter 再增加 body。 + +4. HeaderStopAllIterationAndBuffer: + +停止所有迭代,表示 header 不能继续交给下一个 filter,并且当前 filter 也不能收到 body data。 并对当前过滤器及后续过滤器的头部、数据和尾部进行缓冲。如果缓存大小超过了 buffer limit,在请求阶段就直接返回 413,响应阶段就直接返回 500。 +同时需要调用 proxywasm.ResumeHttpRequest()、 proxywasm.ResumeHttpResponse() 或者 proxywasm.SendHttpResponseWithDetail() 函数来恢复后续处理。 + +5. HeaderStopAllIterationAndWatermark: + +同上,区别是,当缓存超过了 buffer limit 会触发流控,也就是暂停从连接上读数据。 types.ActionPause 实际上对应就是这个状态。 + +> 关于 types.HeaderStopIteration 和 HeaderStopAllIterationAndWatermark 的使用场景可以参考 Higress 官方提供 [ai-transformer 插件](https://github.com/alibaba/higress/blob/main/plugins/wasm-go/extensions/ai-transformer/main.go#L93-L99) 和 [ai-quota 插件](https://github.com/alibaba/higress/blob/main/plugins/wasm-go/extensions/ai-quota/main.go#L179) 。 + +#### 2.4.3 Data 状态码 + +1. DataContinue: + +和 header 类似,表示当前 filter 已经处理完毕,可以继续交给下一个 filter 处理。 如果 header 之前返回的是 HeaderStopIteration,且尚未交给下一个 filter 处理,那么此时 header 和 data 也会被交给下一个 filter 处理。types.ActionContinue 对应就是这个状态。 + +2. DataStopIterationAndBuffer: + +表示当前 data 不能继续交给下一个 filter 处理,并且将当前 data 缓存起来。 与 header 类似,如果达到 buffer limit,在请求阶段就直接返回 413,响应阶段就直接返回 500。 +同时需要调用 proxywasm.ResumeHttpRequest()、 proxywasm.ResumeHttpResponse() 或者 proxywasm.SendHttpResponseWithDetail() 函数来恢复后续处理。 + +3. DataStopIterationAndWatermark: + +同上,只是达到 buffer limit 会触发流控。types.ActionPause 实际上对应就是这个状态。 + +4. DataStopIterationNoBuffer: + +表示当前 data 不能继续交给下一个 filter,但是不缓存当前 data 。 + + +### 2.5 Envoy 请求缓存区限制 + +当自定义插件使用 `onHttpRequestBody` 非流式传输,当请求超过 `downstream` 缓存区限制(默认是 32k)。Envoy 会给用户返回 413, 同时报 `request_payload_too_large` 错误。 +比如在 AI 长上下文中场景中可能会碰到这个问题,这个问题可以通过参考 [全局配置说明](https://higress.io/docs/latest/user/configmap/) 调整 Downstream 配置项 `connectionBufferLimits` 解决, 或者 使用 `SetRequestBodyBufferLimit` 方法设置请求 body buffer limit 解决。 关于如何使用 `SetRequestBodyBufferLimit` 可以参考 Higress 官方提供 [ai-proxy 插件](https://github.com/alibaba/higress/blob/main/plugins/wasm-go/extensions/ai-proxy/main.go#L81) 的实现。 + + +## 3 Envoy 属性(Attributes) + +属性是 Envoy 的一个特性,允许用户在插件中设置和获取这些属性,可以通过 `proxywasm.GetProperty` 和 `proxywasm.SetProperty` 方法获取和设置。 +Envoy 预定义属性包括请求属性、响应属性、连接属性、Upstream 属性、Wasm 属性、和 Metadata 等属性, 具体可以参考 [Envoy 属性](https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/advanced/attributes)。 +同时用户也可以设置自定义属性,这些属性可以在插件链中不同插件共享。 + + +## 参考 +- [1] [Envoy 开发入门:搞懂 http filter 状态码](https://uncledou.site/2022/envoy-filter-status/) + + diff --git a/src/content/docs/ebook/en/wasm17.md b/src/content/docs/ebook/en/wasm17.md new file mode 100644 index 0000000000..b8ff87ed12 --- /dev/null +++ b/src/content/docs/ebook/en/wasm17.md @@ -0,0 +1,633 @@ +--- +title: HTTP 调用 +keywords: [Higress] +--- + +# HTTP 调用 + +这章主要介绍如何使用 Higress 插件 Go SDK 实现 HTTP 调用。 + +## 1 Envoy 集群(Cluster)名称和服务发现来源 + +Higress 插件的 Go SDK 在进行 HTTP 和 Redis 调用时,是通过指定的集群名称来识别并连接到相应的 Envoy 集群。 此外,Higress 利用 [McpBridge](https://higress.io/docs/latest/user/mcp-bridge/) 支持多种服务发现机制,包括静态配置(static)、DNS、Kubernetes 服务、Eureka、Consul、Nacos、以及 Zookeeper 等。 +每种服务发现机制对应的集群名称生成规则都有所不同,这些规则在 cluster_wrapper.go 代码文件中有所体现。 +为了包装不同的服务发现机制,Higress 插件 Go SDK 定义了 Cluster 接口,该接口包含两个方法:ClusterName 和 HostName。 +```golang +type Cluster interface { + // 返回 Envoy 集群名称 + ClusterName() string + // 返回 Hostname, 在 HTTP 调用服务时候,用于设置 Http host 请求头 + HostName() string +} +``` + +### 1.1 静态配置(static) +```golang +type StaticIpCluster struct { + ServiceName string + Port int64 + Host string +} +``` +- 集群名称规则为:`outbound|||.static`。 +- HostName 规则为:默认为 。 + +### 1.2 DNS 配置(dns) +```golang +type DnsCluster struct { + ServiceName string + Domain string + Port int64 +} + +``` +- 集群名称规则为:`outbound|||.dns`。 +- HostName 规则为:如果设置 Host,返回 Host,否则返回。 + +### 1.3 Kubernetes 服务(kubernetes) +```golang + +type K8sCluster struct { + ServiceName string + Namespace string + Port int64 + Version string + Host string +} +``` +- 集群名称规则为:`outbound|||..svc.cluster.local`。 +- HostName 规则为:如果设置 Host,返回 Host,否则返回 ..svc.cluster.local。 + +### 1.4 Nacos +```golang + +type NacosCluster struct { + ServiceName string + // use DEFAULT-GROUP by default + Group string + NamespaceID string + Port int64 + // set true if use edas/sae registry + IsExtRegistry bool + Version string + Host string +} +``` +- 集群名称规则为:`outbound|||...nacos`。 +- HostName 规则为:如果设置 Host,返回 Host,否则返回 。 + +### 1.5 Consul +```golang +type ConsulCluster struct { + ServiceName string + Datacenter string + Port int64 + Host string +} +``` +- 集群名称规则为:`outbound|||..consul`。 +- HostName 规则为:如果设置 Host,返回 Host,否则返回 。 + +### 1.6 FQDN + +```golang + +type FQDNCluster struct { + FQDN string + Host string + Port int64 +} +``` +- 集群名称规则为:`outbound|||`。 +- HostName 规则为:如果设置 Host,返回 Host,否则返回 ``。 + +## 2 HTTP 调用 +http_wrapper.go 部分核心代码如下: +```golang +// 回调函数 +type ResponseCallback func(statusCode int, responseHeaders http.Header, responseBody []byte) + +// HTTP 调用接口 +type HttpClient interface { + Get(path string, headers [][2]string, cb ResponseCallback, timeoutMillisecond ...uint32) error + Head(path string, headers [][2]string, cb ResponseCallback, timeoutMillisecond ...uint32) error + Options(path string, headers [][2]string, cb ResponseCallback, timeoutMillisecond ...uint32) error + Post(path string, headers [][2]string, body []byte, cb ResponseCallback, timeoutMillisecond ...uint32) error + Put(path string, headers [][2]string, body []byte, cb ResponseCallback, timeoutMillisecond ...uint32) error + Patch(path string, headers [][2]string, body []byte, cb ResponseCallback, timeoutMillisecond ...uint32) error + Delete(path string, headers [][2]string, body []byte, cb ResponseCallback, timeoutMillisecond ...uint32) error + Connect(path string, headers [][2]string, body []byte, cb ResponseCallback, timeoutMillisecond ...uint32) error + Trace(path string, headers [][2]string, body []byte, cb ResponseCallback, timeoutMillisecond ...uint32) error + Call(method, path string, headers [][2]string, body []byte, cb ResponseCallback, timeoutMillisecond ...uint32) error +} + +// 实现 httpClient 接口 +type ClusterClient[C Cluster] struct { + cluster C +} +``` +ClusterClient Get、Head、Options、Post、PUT、Patch、Delete、Connect、Trace、Call 方法最后调用 HttpCall 方法,其核心代码如下: + +```golang +func HttpCall(cluster Cluster, method, path string, headers [][2]string, body []byte, + callback ResponseCallback, timeoutMillisecond ...uint32) error { + + // 删除 :method, :path, :authority + for i := len(headers) - 1; i >= 0; i-- { + key := headers[i][0] + if key == ":method" || key == ":path" || key == ":authority" { + headers = append(headers[:i], headers[i+1:]...) + } + } + // 设置 timeout + var timeout uint32 = 500 + if len(timeoutMillisecond) > 0 { + timeout = timeoutMillisecond[0] + } + // 重新设置 :method, :path, :authority + headers = append(headers, [2]string{":method", method}, [2]string{":path", path}, [2]string{":authority", cluster.HostName()}) + requestID := uuid.New().String() + // 调用 HTTP 请求 + _, err := proxywasm.DispatchHttpCall(cluster.ClusterName(), headers, body, nil, timeout, func(numHeaders, bodySize, numTrailers int) { + // 获取 HTTP 响应 body 和 headers + respBody, err := proxywasm.GetHttpCallResponseBody(0, bodySize) + ... + respHeaders, err := proxywasm.GetHttpCallResponseHeaders() + ... + code := http.StatusBadGateway + var normalResponse bool + headers := make(http.Header) + for _, h := range respHeaders { + if h[0] == ":status" { + code, err = strconv.Atoi(h[1]) + .. + } + headers.Add(h[0], h[1]) + } + ... + // 调用自定义插件回调函数 + callback(code, headers, respBody) + }) + ... + return err +} +``` + +## 3 easy-jwt 插件开发 + +在实际业务场景中,可能需要独立认证授权服务,来完成每个请求的认证和授权,现在开发一个简单的 easy-jwt 插件来演示如何在 Wasm 插件进行 HTTP 调用。 +其插件核心流程如下图: +![img](https://img.alicdn.com/imgextra/i1/O1CN01DPi2w6244OvEejP1q_!!6000000007337-0-tps-1488-682.jpg) + +Token Server 提供 2 个接口: +- /api/token/auth: 认证令牌接口 +- /api/token/create: 生成令牌接口 + +### 3.1 插件部分核心代码 + +```golang +package main +... + +const ( + AuthUIDHeader = "x-auth-user" +) + +func main() { + wrapper.SetCtx( + // 插件名称 + "easy-jwt", + // 设置自定义函数解析插件配置 + wrapper.ParseConfigBy(parseConfig), + // 设置自定义函数处理请求头 + wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders), + ) +} + +// 自定义插件配置 +type JwtConfig struct { + // HTTP Client + client wrapper.HttpClient + // 令牌服务器的完全限定域名 + tokenServerFQDN string + // 令牌服务器的端口 + tokenServerPort int + // HTTP请求头中包含令牌的字段名称 + tokenFromHeaderName string + // 令牌前缀,如Bearer + tokenFromHeaderPrefix string + // 插件将忽略令牌验证 UR L列表 + ignoreUrls []string + // 匿名令牌,用于未认证的请求 + anonymousToken string + // 匿名用户ID + anonymousUID int + // 当令牌验证失败时返回的 HTTP 状态码 + responseErrorStatusCode uint32 + // 返回的错误信息格式 + responseErrorBody string +} + +func parseConfig(json gjson.Result, config *JwtConfig, log wrapper.Log) error { + log.Debugf("parseConfig()") + // 解析插件配置 + config.tokenServerFQDN = json.Get("tokenServerFQDN").String() + config.tokenServerPort = int(json.Get("tokenServerPort").Int()) + config.tokenFromHeaderName = json.Get("tokenFromHeaderName").String() + config.tokenFromHeaderPrefix = json.Get("tokenFromHeaderPrefix").String() + config.anonymousUID = int(json.Get("anonymousUID").Int()) + config.anonymousToken = json.Get("anonymousToken").String() + config.responseErrorBody = json.Get("responseErrorBoy").String() + config.responseErrorStatusCode = uint32(json.Get("responseErrorStatusCode").Int()) + config.responseErrorBody = json.Get("responseErrorBody").String() + config.ignoreUrls = make([]string, 0) + for _, item := range json.Get("ignoreUrls").Array() { + config.ignoreUrls = append(config.ignoreUrls, item.String()) + } + // 设置 HTTP Client + config.client = wrapper.NewClusterClient(wrapper.FQDNCluster{ + FQDN: json.Get("tokenServerFQDN").String(), + Port: json.Get("tokenServerPort").Int(), + }) + log.Debugf("parseConfig result:%+v", config) + return nil +} + +func onHttpRequestHeaders(ctx wrapper.HttpContext, config JwtConfig, log wrapper.Log) types.Action { + // 首先检查请求的路径是否在 ignoreUrls 列表中,如果是,则添加匿名用户ID到请求头并继续处理请求 + rawPath := ctx.Path() + path, _ := url.Parse(rawPath) + for _, url := range config.ignoreUrls { + if isPathMatch(path.Path, url) { + proxywasm.AddHttpRequestHeader(AuthUIDHeader, fmt.Sprintf("%d", config.anonymousUID)) + return types.ActionContinue + } + } + // 如果请求头中包含令牌,插件将尝试从请求头中提取令牌 + token, err := extractTokenFromHeader(ctx, config) + if err != nil { + log.Debugf("extractTokenFromHeader() error: %v", err) + body := fmt.Sprintf(config.responseErrorBody, err.Error()) + proxywasm.SendHttpResponse(config.responseErrorStatusCode, [][2]string{{"content-type", "application/json"}}, []byte(body), -1) + return types.ActionContinue + } + // 如果是匿名令牌,则添加匿名用户ID到请求头并继续处理请求 + if len(config.anonymousToken) > 0 && config.anonymousToken == token { + proxywasm.AddHttpRequestHeader(AuthUIDHeader, fmt.Sprintf("%d", config.anonymousUID)) + return types.ActionContinue + } + + authRequest, _ := json.Marshal(map[string]string{"token": token}) + log.Debugf("call token-server with auth request:%s", string(authRequest)) + // 插件将使用配置的HTTP客户端向令牌服务器发送POST请求,以验证令牌的有效性 + err2 := config.client.Post( + "/api/token/auth", + [][2]string{{"content-type", "application/json"}}, + authRequest, + func(statusCode int, responseHeaders http.Header, responseBody []byte) { + defer func() { + // 保证恢复请求 + _ = proxywasm.ResumeHttpRequest() + }() + + log.Debugf("auth response status:%d, response:%s", statusCode, string(responseBody)) + var jsonData gjson.Result + jsonData = gjson.ParseBytes(responseBody) + if statusCode != 200 { + // 如果响应状态码不是200,表示验证失败,插件将直接发送错误响应给客户端。 + message := jsonData.Get("message").String() + body := fmt.Sprintf(config.responseErrorBody, message) + proxywasm.SendHttpResponse(config.responseErrorStatusCode, [][2]string{{"content-type", "application/json"}}, []byte(body), -1) + } else { + // 如果验证成功,插件将从响应中提取用户ID,并将其添加到后续请求头中 + uid := jsonData.Get("uid").Int() + proxywasm.AddHttpRequestHeader(AuthUIDHeader, fmt.Sprintf("%d", uid)) + } + }, + 2000, + ) + + if err2 != nil { + // 如果连接失败,则直接发送错误响应给客户端。 + log.Debugf("call token server error:%v", err2) + body := fmt.Sprintf(config.responseErrorBody, err2.Error()) + proxywasm.SendHttpResponse(config.responseErrorStatusCode, [][2]string{{"content-type", "application/json"}}, []byte(body), -1) + return types.ActionContinue + } + // 暂停请求处理,直到调用 proxywasm.ResumeHttpRequest() 恢复请求 + return types.ActionPause +} + +func extractTokenFromHeader(ctx wrapper.HttpContext, config JwtConfig) (string, error) { + ... +} + +func isPathMatch(path string, url string) bool { + ... +} +``` + +核心流程如下: +- 初始化插件 +- 解析配置 +- onHttpRequestHeaders 处理 + - 检查请求路径是否在 ignoreUrls 列表中 + - 是:添加匿名 UID 到请求头,继续处理请求 + - 否:继续 + - 从请求头中提取令牌,检查令牌是否存在 + - 存在:继续 + - 不存在:返回错误,发送响应 + - 验证令牌 + - 如果令牌是匿名令牌,添加匿名 UID 到请求头,继续处理请求 + - 如果令牌不是匿名令牌,调用认证服务 /api/token/auth 接口验证令牌 + - 如果验证成功,从响应中提取 UID,添加到请求头中,继续处理请求 + - 如果验证失败,返回错误,发送响应 + +### 3.2 部署和验证 + +1. 部署 YAML 如下: +```yaml +apiVersion: v1 +kind: Namespace +metadata: + name: higress-course +--- +apiVersion: v1 +kind: Service +metadata: + name: echo-server + namespace: higress-course +spec: + selector: + app: echo-server + ports: + - protocol: TCP + port: 8080 + targetPort: 3000 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: echo-server + namespace: higress-course + labels: + app: echo-server +spec: + replicas: 1 + selector: + matchLabels: + app: echo-server + template: + metadata: + labels: + app: echo-server + spec: + containers: + - name: echo-server + image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/echo-server:1.3.0 + env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + resources: + requests: + cpu: 10m +--- +apiVersion: v1 +kind: Service +metadata: + name: echo-server + namespace: higress-course +spec: + selector: + app: echo-server + ports: + - protocol: TCP + port: 8080 + targetPort: 3000 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: token-server + namespace: higress-course + labels: + app: token-server +spec: + replicas: 1 + selector: + matchLabels: + app: token-server + template: + metadata: + labels: + app: token-server + spec: + containers: + - name: token-server + image: registry.cn-hangzhou.aliyuncs.com/2456868764/token-server:1.0.0 + env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + resources: + requests: + cpu: 10m +--- +apiVersion: v1 +kind: Service +metadata: + name: token-server + namespace: higress-course +spec: + selector: + app: token-server + ports: + - protocol: TCP + port: 9090 + targetPort: 9090 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-foo + namespace: higress-course +spec: + ingressClassName: higress + rules: + - host: "foo.com" + http: + paths: + - pathType: Prefix + path: "/" + backend: + service: + name: echo-server + port: + number: 8080 +--- +apiVersion: networking.higress.io/v1 +kind: McpBridge +metadata: + name: default + namespace: higress-system +spec: + registries: + - name: token-server + domain: token-server.higress-course.svc.cluster.local + port: 9090 + type: dns +--- +apiVersion: extensions.higress.io/v1alpha1 +kind: WasmPlugin +metadata: + name: easy-jwt + namespace: higress-system +spec: + priority: 200 + matchRules: + - ingress: + - higress-course/ingress-foo + config: + tokenServerFQDN: "token-server.dns" + tokenServerPort: 9090 + tokenFromHeaderName: "Authorization" + tokenFromHeaderPrefix: "Bearer " + anonymousToken: "AnonymousToken" + anonymousUID: 0 + responseErrorStatusCode: 401 + responseErrorBody: "{\"message\":\"%s\"}" + url: oci://registry.cn-hangzhou.aliyuncs.com/2456868764/easy-jwt:1.0.0 +``` +2. 获取令牌 + +获取 uid 为 100 的用户的访问令牌,其命令如下,其中 `` 是 token-server pod 名称。 +```shell +kubectl exec -n higress-course -- curl -X POST http://127.0.0.1:9090/api/token/create -d '{"uid":100}' -H "content-type:application/json" + +{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVSUQiOjEwMCwiZXhwIjoxNzU0Mzg2MzQ4fQ.jncbLJqBern5DYCFvED3moiCvg6sUn5jdlllhneuHrY"}% +``` + +3. 请求验证 + +```shell +curl http://127.0.0.1/hello -X POST -d "{}" -H "host:foo.com" -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVSUQiOjEwMCwiZXhwIjoxNzU0Mzg2MzQ4fQ.jncbLJqBern5DYCFvED3moiCvg6sUn5jdlllhneuHrY" -H "content-type:application/json" + +{ + "path": "/hello", + "host": "foo.com", + "method": "POST", + "proto": "HTTP/1.1", + "headers": { + "Accept": [ + "*/*" + ], + "Authorization": [ + "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVSUQiOjEwMCwiZXhwIjoxNzU0Mzg2MzQ4fQ.jncbLJqBern5DYCFvED3moiCvg6sUn5jdlllhneuHrY" + ], + "Content-Length": [ + "2" + ], + "Content-Type": [ + "application/json" + ], + "Original-Host": [ + "foo.com" + ], + "Req-Start-Time": [ + "1722850461721" + ], + "User-Agent": [ + "curl/8.1.2" + ], + "X-Auth-User": [ + "100" + ], + "X-B3-Sampled": [ + "0" + ], + "X-B3-Spanid": [ + "642eab8e332d6500" + ], + "X-B3-Traceid": [ + "d9b9e94203603997642eab8e332d6500" + ], + "X-Envoy-Attempt-Count": [ + "1" + ], + "X-Envoy-Decorator-Operation": [ + "echo-server.higress-course.svc.cluster.local:8080/*" + ], + "X-Envoy-Internal": [ + "true" + ], + "X-Forwarded-For": [ + "192.168.65.1" + ], + "X-Forwarded-Proto": [ + "http" + ], + "X-Request-Id": [ + "47ff21bc-c3d5-4932-8bfb-361d268d319d" + ] + }, + "namespace": "higress-course", + "ingress": "", + "service": "", + "pod": "echo-server-6f4df5fcff-nksqz", + "body": {} +} +``` +可以看到请求头中包含了 `X-Auth-User` 同时值为 100 。 + +## 4 ext-auth 插件 + +Higress 官方提供 [ext-auth](https://github.com/alibaba/higress/tree/main/plugins/wasm-go/extensions/ext-auth) 插件,其功能更加丰富。 ext-auth 插件实现了向外部授权服务发送鉴权请求,以检查客户端请求是否得到授权。该插件实现时参考了 Envoy 原生的 [ext_authz filter](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/ext_authz_filter),实现了原生 filter 中对接 HTTP 服务的部分能力。 + +## 5 Envoy Cluster 不存在问题 + +在默认情况下,Higress 控制面只下发和路由关联的服务到 Envoy Cluster 中,因此有可能在实际开发过程中,发现对应调用 HTTP 服务在 Envoy Cluster 中不存在。 +有 3 种方案去解决: +- helm 参数 global.onlyPushRouteCluster, 默认值为 true, 只推送路由关联的 Cluster 到 Envoy Cluster 中。修改为 false 即可。 +- 创建一个新路由关联到对应的调用的 HTTP 服务。 +- 通过 McpBridge 配置,添加调用的 HTTP 服务。 + +上面 easy-jwt 插件中调用 token-server 服务,是通过 McpBridge 配置,添加 dns 类型服务,其配置如下: + +```yaml +apiVersion: networking.higress.io/v1 +kind: McpBridge +metadata: + name: default + namespace: higress-system +spec: + registries: + - name: token-server + domain: token-server.higress-course.svc.cluster.local + port: 9090 + type: dns +``` + +## 6 HTTP 回调链问题 + +在实际开发过程中,可能会遇到 HTTP 回调链的情况,比如在 onHttpRequestHeader 处理阶段,需要调用两个 HTTP 服务,这个时候在 onHttpRequestHeader 阶段中,要先调用第一个 HTTP 服务,在第一个 HTTP 服务的响应回调函数中,再发起第二个 HTTP 服务的调用。 +以此类推。这种情况 Redis 调用也是一样处理。 关于回调链可以参考 Higress 官方提供 [ai-agent](https://github.com/alibaba/higress/blob/main/plugins/wasm-go/extensions/ai-agent/main.go#L169) 插件功能。 + +## 参考 +- [1][Mcp Bridge 配置说明](https://higress.io/docs/latest/user/mcp-bridge/) + + + + + + diff --git a/src/content/docs/ebook/en/wasm18.md b/src/content/docs/ebook/en/wasm18.md new file mode 100644 index 0000000000..4b690a58ba --- /dev/null +++ b/src/content/docs/ebook/en/wasm18.md @@ -0,0 +1,720 @@ +--- +title: Redis 调用 +keywords: [Higress] +--- + +# Redis 调用 + +本章介绍如何在插件中调用 Redis、本地开发环境搭建、以及开发基于令牌桶限流插件。 + +## 1 Redis 调用 + +Higress 插件的 Go SDK 中 redis_wrapper.go 封装 Redis 调用, 部分核心代码如下: + +```shell +// Redis 回调函数 +type RedisResponseCallback func(response resp.Value) + +// Redis 调用接口 +type RedisClient interface { + // 初始化接口 + Init(username, password string, timeout int64) error + // with this function, you can call redis as if you are using redis-cli + } + // 命令接口 + Command(cmds []interface{}, callback RedisResponseCallback) error + // Lua脚本接口 + Eval(script string, numkeys int, keys, args []interface{}, callback RedisResponseCallback) error + + // 以下是 Redis 各种命令接口 + // Key + Del(key string, callback RedisResponseCallback) error + Exists(key string, callback RedisResponseCallback) error + Expire(key string, ttl int, callback RedisResponseCallback) error + Persist(key string, callback RedisResponseCallback) error + ... +} + +// RedisClusterClient, Redis 调用接口具体实现 +type RedisClusterClient[C Cluster] struct { + cluster C +} + +func RedisInit(cluster Cluster, username, password string, timeout uint32) error { + return proxywasm.RedisInit(cluster.ClusterName(), username, password, timeout) +} +// 真正调用 Redis 的函数 +func RedisCall(cluster Cluster, respQuery []byte, callback RedisResponseCallback) error { + requestID := uuid.New().String() + _, err := proxywasm.DispatchRedisCall( + cluster.ClusterName(), + respQuery, + func(status int, responseSize int) { + response, err := proxywasm.GetRedisCallResponse(0, responseSize) + var responseValue resp.Value + // 获取 Redis 回调结果 responseValue + ... + if callback != nil { + // 调用回调函数 + callback(responseValue) + } + }) + ... + return err +} +``` + +所有调用 Redis 的接口,最终通过 RedisCall 调用 Redis, 同时回调 RedisResponseCallback 回调函数。 + +## 2 令牌桶限流 + +常见的限流算法有固定窗口限流算法、滑动窗口限流算法、漏桶限流算法、令牌桶限流算法等。这里主要介绍令牌桶限流算法。 +令牌桶算法原理: +- 令牌以固定的频率被添加到令牌桶中。 +- 如果令牌数量满了,超过令牌桶容量的限制,那就丢弃。 +- 系统在接受到一个用户请求时,都会先去令牌桶要一个令牌。如果拿到令牌,那么就处理这个请求的业务逻辑。 +- 如果拿不到令牌,就直接拒绝这个请求。 + +令牌桶算法允许一定量的突发请求,因为桶可以存储一定数量的令牌,从而在短期内处理更多的请求。具体原理见下图: +![img](https://img.alicdn.com/imgextra/i2/O1CN018T2vsi1bbGU9PeVx6_!!6000000003483-0-tps-902-922.jpg) + +关于 QPS 限流算法和令牌桶算法两种限流算法优缺点,可以参考:[限流算法选择](https://help.aliyun.com/document_detail/149952.html)。 + +## 3 本地开发环境搭建 + +### 3.1 初始化工程目录 + +1. 新建一个工程目录文件 cluster-bucket-limit。 + +```shell +mkdir cluster-bucket-limit +``` +2. 在所建目录下执行以下命令,初始化 Go 工程。 + +```shell +go mod init cluster-bucket-limit +``` +更详细信息参考第十四章 Wasm 插件介绍和开发自定义插件。 + +### 3.2 Makefile、Dockerfile、docker-compose.yaml、envoy.yaml 文件 + +1. Makefile、Dockerfile + +Makefile、Dockerfile 文件参考第十四章 Wasm 插件介绍和开发自定义插件。 + +2. docker-compose.yaml + +```yaml +version: '3.9' +services: + envoy: + image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/gateway:v1.4.1 + entrypoint: /usr/local/bin/envoy + # 注意这里对wasm开启了debug级别日志,正式部署时则默认info级别 + command: -c /etc/envoy/envoy.yaml --log-level info --log-path /etc/envoy/envoy.log --component-log-level wasm:debug + depends_on: + - echo-server + networks: + - wasmtest + ports: + - "10000:10000" + - "9901:9901" + volumes: + - ./envoy.yaml:/etc/envoy/envoy.yaml + - ./build/plugin.wasm:/etc/envoy/plugin.wasm + - ./envoy.log:/etc/envoy/envoy.log + echo-server: + image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/echo-server:1.3.0 + networks: + - wasmtest + ports: + - "3000:3000" + redis: + image: registry.cn-hangzhou.aliyuncs.com/2456868764/redis:latest + environment: + - ALLOW_EMPTY_PASSWORD=yes + networks: + wasmtest: + ipv4_address: 172.20.0.100 + ports: + - "6379:6379" +networks: + wasmtest: + ipam: + config: + - subnet: 172.20.0.0/24 + +``` + +3. envoy.yaml 文件 + +envoy.yaml 配置文件如下: +```yaml +admin: + address: + socket_address: + protocol: TCP + address: 0.0.0.0 + port_value: 9901 +static_resources: + listeners: + - name: listener_0 + address: + socket_address: + protocol: TCP + address: 0.0.0.0 + port_value: 10000 + 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 + scheme_header_transformation: + scheme_to_overwrite: https + stat_prefix: ingress_http + route_config: + name: local_route + virtual_hosts: + - name: local_service + domains: ["*"] + routes: + - match: + prefix: "/" + route: + cluster: echo-server + http_filters: + - name: wasmdemo + typed_config: + "@type": type.googleapis.com/udpa.type.v1.TypedStruct + type_url: type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm + value: + config: + name: wasmdemo + vm_config: + runtime: envoy.wasm.runtime.v8 + code: + local: + filename: /etc/envoy/plugin.wasm + configuration: + "@type": "type.googleapis.com/google.protobuf.StringValue" + value: |- + { + "keys": [ + "authorization" + ], + "in_header": true, + "limits": [ + { + "name": "credential1", + "consumer": "Bearer credential1", + "rate": 2, + "capacity": 4 + }, + { + "name": "all", + "consumer": "*", + "rate": 1, + "capacity": 2 + } + ], + "rejected_code": 429, + "rejected_msg": "Too Many Requests", + "show_limit_quota_header": true, + "redis":{ + "service_name": "redis.static", + "service_port": 6379, + "timeout": 2000 + } + } + - name: envoy.filters.http.router + clusters: + - name: echo-server + connect_timeout: 30s + type: LOGICAL_DNS + # Comment out the following line to test on v6 networks + dns_lookup_family: V4_ONLY + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: echo-server + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: echo-server + port_value: 3000 + - name: outbound|6379||redis.static + connect_timeout: 30s + type: STATIC + load_assignment: + cluster_name: outbound|6379||redis.static + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: 172.20.0.100 + port_value: 6379 +``` +envoy.yaml 配置文件增加了 `outbound|6379||redis.static` 集群,用于连接 Redis 服务。Redis 连接目前不支持 DNS 域名配置,只支持 IP 地址配置。 +因此这里 Redis 的 IP 地址是 `172.20.0.100`。 + +## 3 令牌桶限流插件开发 + +### 3.1 插件配置和配置解析 + +插件配置和配置解析部分核心代码如下: +```golang +// LimitConfig 定义了限流插件的配置结构。 +type LimitConfig struct { + Keys []string `yaml:"keys"` // 定义了用于提取限流信息的HTTP请求头字段名称。 + InQuery bool `yaml:"in_query,omitempty"` // 标识是否从查询参数中获取限流信息。 + InHeader bool `yaml:"in_header,omitempty"` // 标识是否从请求头中获取限流信息。 + Limits []LimitItem `yaml:"limits"` // 包含具体的限流规则项。 + RejectedCode uint32 `yaml:"rejected_code"` // 请求超过阈值被拒绝时返回的 HTTP 状态码。 + RejectedMsg string `yaml:"rejected_msg"` // 请求超过阈值被拒绝时返回的响应体。 + ShowLimitQuotaHeader bool `yaml:"show_limit_quota_header"` // 标识是否在响应头中显示限流配额信息。 + RedisInfo RedisInfo `yaml:"redis"` // 定义了与 Redis 交互所需的信息。 + RedisClient wrapper.RedisClient // Redis客户端,用于执行Redis命令。 +} + +// LimitItem 定义了具体的限流项,包括限流名称、消费者标识、请求速率和容量。 +type LimitItem struct { + Name string `yaml:"name"` // 限流项的名称。 + Consumer string `yaml:"consumer"` // 限流项关联的消费者标识,用于匹配特定的请求。 + Rate int `yaml:"rate"` // 每秒放入桶内的令牌数量。 + Capacity int `yaml:"capacity"` // 限流桶的最大容量。 +} + +// RedisInfo 定义了连接Redis所需的详细信息。 +type RedisInfo struct { + ServiceName string `required:"true" yaml:"service_name" json:"service_name"` // Redis 服务名或地址。 + ServicePort int `yaml:"service_port" json:"service_port"` // Redis 服务端口。 + Username string `yaml:"username" json:"username"` // 连接 Redis 的用户名,如果需要。 + Password string `yaml:"password" json:"password"` // 连接 Redis 的密码,如果需要。 + Timeout int `yaml:"timeout" json:"timeout"` // 连接 Redis 的超时时间(毫秒)。 +} + +// LimitContext 定义了限流上下文,存储了限流相关的信息。 +type LimitContext struct { + Allowed int // 表示当前请求是否被允许。 + Count int // 限流桶当前的计数。 + Remaining int // 限流桶剩余的容量。 + Reset int // 限流桶重置时间(秒)。 +} + +// 解析配置,这里忽略插件配置解析的细节。主要显示 Redis 部分解析配置。 +func parseConfig(json gjson.Result, config *LimitConfig, log wrapper.Log) error { + // keys + names := json.Get("keys") + ... + // in_query and in_header + in_query := json.Get("in_query") + in_header := json.Get("in_header") + ... + // parse limit + limits := json.Get("limits") + ... + config.ShowLimitQuotaHeader = json.Get("show_limit_quota_header").Bool() + + // parse redis + redisConfig := json.Get("redis") + if !redisConfig.Exists() { + return errors.New("missing redis in config") + } + serviceName := redisConfig.Get("service_name").String() + if serviceName == "" { + return errors.New("redis service name must not be empty") + } + servicePort := int(redisConfig.Get("service_port").Int()) + if servicePort == 0 { + if strings.HasSuffix(serviceName, ".static") { + // use default logic port which is 80 for static service + servicePort = 80 + } else { + servicePort = 6379 + } + } + username := redisConfig.Get("username").String() + password := redisConfig.Get("password").String() + timeout := int(redisConfig.Get("timeout").Int()) + if timeout == 0 { + timeout = 1000 + } + config.RedisInfo.ServiceName = serviceName + config.RedisInfo.ServicePort = servicePort + config.RedisInfo.Username = username + config.RedisInfo.Password = password + config.RedisInfo.Timeout = timeout + config.RedisClient = wrapper.NewRedisClusterClient(wrapper.FQDNCluster{ + FQDN: serviceName, + Port: int64(servicePort), + }) + log.Debugf("parseConfig()+%+v", config) + return config.RedisClient.Init(username, password, int64(timeout)) +} +``` + +这里忽略插件配置解析的细节,主要显示 Redis 解析配置。可以看出这里需要调用 `wrapper.NewRedisClusterClient` 方法初始化 RedisClient 和 RedisClient `Init` 方法初始化 Redis 连接。 + +### 3.2 插件限流 Lua 脚本 +令牌桶限流的 Lua 脚本如下: +```lua +local tokens_key = KEYS[1] +local timestamp_key = KEYS[2] +--redis.log(redis.LOG_WARNING, "tokens_key " .. tokens_key) + +local rate = tonumber(ARGV[1]) +local capacity = tonumber(ARGV[2]) +local now = tonumber(ARGV[3]) +local requested = tonumber(ARGV[4]) +local unit = tonumber(ARGV[5]) + +local fill_time = capacity/rate +local ttl = math.floor(fill_time*2*unit) + +--redis.log(redis.LOG_WARNING, "rate " .. ARGV[1]) +--redis.log(redis.LOG_WARNING, "capacity " .. ARGV[2]) +--redis.log(redis.LOG_WARNING, "now " .. ARGV[3]) +--redis.log(redis.LOG_WARNING, "requested " .. ARGV[4]) +--redis.log(redis.LOG_WARNING, "filltime " .. fill_time) +--redis.log(redis.LOG_WARNING, "ttl " .. ttl) + +local last_tokens = tonumber(redis.call("get", tokens_key)) +if last_tokens == nil then + last_tokens = capacity +end +--redis.log(redis.LOG_WARNING, "last_tokens " .. last_tokens) + +local last_refreshed = tonumber(redis.call("get", timestamp_key)) +if last_refreshed == nil then + last_refreshed = 0 +end +--redis.log(redis.LOG_WARNING, "last_refreshed " .. last_refreshed) + +local delta = math.max(0, (now-last_refreshed)/unit) +local filled_tokens = math.min(capacity, last_tokens + math.floor(delta*rate)) +local allowed = filled_tokens >= requested +local new_tokens = filled_tokens +local allowed_num = 0 +if allowed then + new_tokens = filled_tokens - requested + allowed_num = 1 +end + +--redis.log(redis.LOG_WARNING, "delta " .. delta) +--redis.log(redis.LOG_WARNING, "filled_tokens " .. filled_tokens) +--redis.log(redis.LOG_WARNING, "allowed_num " .. allowed_num) +--redis.log(redis.LOG_WARNING, "new_tokens " .. new_tokens) + +if ttl > 0 then + redis.call("setex", tokens_key, ttl, new_tokens) + redis.call("setex", timestamp_key, ttl, now) +end + +return { allowed_num, new_tokens, capacity, ttl} +``` +Lua 脚本是在 Redis 中执行的,用于实现令牌桶限流算法。下面是对脚本参数和原理的分析: + +1. 参数解释: +- KEYS[1] 和 KEYS[2]:这两个参数是通过 Redis 调用传递的键(keys),通常用于存储令牌桶的当前令牌数(tokens_key)和最后刷新时间(timestamp_key)。 +- ARGV[1] 到 ARGV[5]:这些参数是通过 Redis 调用传递的参数,用于配置限流策略。 + - rate:单位时间内生成的令牌数量。 + - capacity:令牌桶的容量,即最多可以容纳的令牌数。 + - now:当前时间,通常以时间戳表示,以秒单位。 + - requested:当前请求需要的令牌数。 + - unit:令牌生成的时间单位,1 表示秒,60 表示分钟,3600 表示小时。 +2. 脚本原理: +- 初始化变量:根据传入的参数初始化令牌桶的填充时间和 TTL(生存时间)。 +- 获取当前状态:从 Redis 中获取当前的令牌数(last_tokens)和最后刷新时间(last_refreshed)。如果不存在,则初始化为令牌桶的容量。 +- 计算令牌填充: + - delta:自上次刷新以来经过的单位时间。 + - filled_tokens:根据 delta 和 rate 计算应该填充的令牌数,但不能超过桶的容量。 +- 判断是否允许请求: + - allowed:如果当前令牌数加上填充的令牌数大于等于请求的令牌数,则允许请求。 +- 更新令牌数: + - 如果请求被允许,从当前令牌数中减去请求的令牌数,更新 new_tokens。 +- 设置新的状态: + - 如果 TTL 大于 0,则更新 Redis 中的令牌数和时间戳,设置新的 TTL。 +- 返回结果: + - 返回一个包含限流结果的数组,包括是否允许请求、新的令牌数、桶容量和 TTL。 + +### 3.3 插件限流具体实现 + +限流主要实现在插件 `onHttpRequestHeaders` 方法中,部分核心代码如下: + +```golang +// onHttpRequestHeaders 函数在处理 HTTP 请求头时被调用,用于执行限流逻辑。 +func onHttpRequestHeaders(ctx wrapper.HttpContext, config LimitConfig, log wrapper.Log) types.Action { + log.Debugf("onHttpRequestHeaders()") + // 初始化 tokens 切片,用于存储从请求头或查询参数中提取的 tokens 信息 + var tokens []string + + // 如果配置指定从请求头中获取 tokens 信息 + if config.InHeader { + // 遍历配置中定义的所有键(key),尝试从请求头中获取每个键的值 + for _, key := range config.Keys { + // 使用 proxywasm 库的 GetHttpRequestHeader 函数获取请求头中的值 + value, err := proxywasm.GetHttpRequestHeader(key) + // 如果没有错误且值不为空,则将值添加到 tokens 切片中 + if err == nil && value != "" { + tokens = append(tokens, value) + } + } + } else if config.InQuery { + // 如果配置指定从查询参数中获取 tokens 信息 + // 获取 ":path" 请求头以获取请求 URL + requestUrl, _ := proxywasm.GetHttpRequestHeader(":path") + // 解析 URL 并获取查询参数 + url, _ := url.Parse(requestUrl) + queryValues := url.Query() + + // 遍历配置中定义的所有键 + for _, key := range config.Keys { + // 从查询参数中获取每个键的值 + values, ok := queryValues[key] + // 如果查询参数存在且有值,则将值添加到 tokens 切片中 + if ok && len(values) > 0 { + tokens = append(tokens, values...) + } + } + } + + // 如果从请求中提取了多于一个的 tokens,返回错误处理 + if len(tokens) > 1 { + return deniedMultiKeyAuthData() + } else if len(tokens) <= 0 { + // 如果没有提取到 tokens,返回错误处理 + return deniedNoKeyAuthData() + } + + // 提取第一个 token 作为主要的令牌信息 + limitKey := strings.Split(tokens[0], ",")[0] + log.Debugf("limitKey:%s", limitKey) + + // 根据提取的 limitKey 查找对应的限流项 + limitItem := findLimitItem(config, limitKey) + log.Debugf("limitItem:%+v", limitItem) + + // 如果没有找到对应的限流项,继续处理请求 + if limitItem.Consumer == "" { + return types.ActionContinue + } + + // 构建 Redis 脚本需要的键,用于操作令牌桶 + tokenKey := fmt.Sprintf(ClusterRateLimitFormat, limitKey, "token") + expireKey := fmt.Sprintf(ClusterRateLimitFormat, limitKey, "expire") + + // 获取当前时间,用于计算令牌桶的填充状态 + now := time.Now() + // 将当前时间转换为 Unix 时间戳(秒) + unixTimestamp := now.UnixNano() + milliseconds := unixTimestamp / 1e6 + seconds := milliseconds / 1000 + + // 构建调用 Redis 脚本所需的参数 + keys := []interface{}{tokenKey, expireKey} + args := []interface{}{limitItem.Rate, limitItem.Capacity, seconds, 1, 1} + + // 调用 Redis 脚本执行限流逻辑 + err := config.RedisClient.Eval(BucketTokenScript, 2, keys, args, func(response resp.Value) { + log.Debugf("RedisClient.Eval(),keys:%+v,args:%+v", keys, args) + // 检查脚本返回的结果是否包含 4 个元素 + resultArray := response.Array() + if len(resultArray) != 4 { + log.Errorf("redis response parse error, response: %v", response) + proxywasm.ResumeHttpRequest() + return + } + // 根据脚本返回的结果创建 LimitContext 对象 + context := LimitContext{ + Allowed: resultArray[0].Integer(), + Remaining: resultArray[1].Integer(), + Count: resultArray[2].Integer(), + Reset: resultArray[3].Integer(), + } + log.Debugf("context:%+v", context) + // 如果请求未被允许(Allowed <= 0),触发限流逻辑 + if context.Allowed <= 0 { + log.Debugf("request rejected") + rejected(config, context) + return + } else { + // 将限流上下文存储在 HttpContext 中,供后续处理使用 + ctx.SetContext(LimitContextKey, context) + } + // 恢复 HTTP 请求处理 + proxywasm.ResumeHttpRequest() + }) + // 如果调用 Redis 脚本时出现错误,记录错误并继续处理请求 + if err != nil { + log.Errorf("redis call failed: %v", err) + return types.ActionContinue + } + // 暂停处理当前请求头,等待 Redis 脚本调用完成 + return types.HeaderStopAllIterationAndWatermark +} + +// findLimitItem 函数用于在给定的配置中查找与特定消费者匹配的限流项。如果没有找到具体的匹配项,它将返回一个默认的 LimitItem 结构。 +func findLimitItem(config LimitConfig, key string) LimitItem { + // 遍历配置中的所有限流项 + for _, limitItem := range config.Limits { + // 检查当前限流项的消费者字段是否与提供的 key 匹配,且消费者不是通配符"*" + if limitItem.Consumer == key && limitItem.Consumer != "*" { + // 如果找到匹配的限流项,返回这个限流项 + return limitItem + } + } + // 再次遍历配置中的所有限流项,这次是为了查找通配符"*"的消费者 + // 通配符"*"表示这个限流项适用于所有消费者 + for _, limitItem := range config.Limits { + if limitItem.Consumer == "*" { + // 如果找到通配符限流项,返回这个限流项 + return limitItem + } + } + // 则返回一个空的 LimitItem 结构,表示没有找到任何适用的限流规则 + return LimitItem{} +} +``` +onHttpRequestHeaders 函数的核心逻辑可以概括为以下几个步骤: +- 获取 Tokens:根据配置(config.InHeader 或 config.InQuery),从请求头或查询参数中提取用于限流的 tokens 信息。 +- 验证 Tokens:检查提取的 tokens 是否存在且数量合理(不能多于一个),如果不符合要求,返回相应的错误处理。 +- 查找限流项:使用提取的 tokens 查找配置中的限流规则(LimitItem),如果没有找到适用的限流规则,则允许请求继续。 +- 执行限流逻辑:如果找到限流规则,构建 Redis 脚本需要的键和参数,然后调用 Redis 脚本执行限流算法。 +- 处理 Redis 脚本结果:根据 Redis 脚本返回的结果,创建 LimitContext 对象并根据算法结果决定是否允许请求继续: + - 如果请求被拒绝(context.Allowed \<= 0),执行限流逻辑并通知客户端。 + - 如果请求被允许,将 LimitContext 对象存储在 HttpContext 中,供后续处理使用。 + + +## 4 测试和验证 + +1. 正常流量 +```shell +curl -X POST -v http://127.0.0.1:10000/hello \ + -H 'Authorization: Bearer credential1' \ + -H 'Content-type: application/json' \ + -H 'host:foo.com' \ + -d '{"username":["unamexxxx"],"password":["pswdxxxx"]}' + +* Trying 127.0.0.1:10000... +* Connected to 127.0.0.1 (127.0.0.1) port 10000 (#0) +> POST /hello HTTP/1.1 +> Host:foo.com +> User-Agent: curl/8.1.2 +> Accept: */* +> Authorization: Bearer credential1 +> Content-type: application/json +> Content-Length: 50 +> +< HTTP/1.1 200 OK +< content-type: application/json +< x-content-type-options: nosniff +< date: Tue, 20 Aug 2024 08:55:13 GMT +< content-length: 692 +< req-cost-time: 42 +< req-arrive-time: 1724144113842 +< resp-start-time: 1724144113885 +< x-envoy-upstream-service-time: 8 +< x-ratelimit-limit: 4 +< x-ratelimit-remaining: 3 +< server: envoy +< +{ + "path": "/hello", + "host": "foo.com", + "method": "POST", + "proto": "HTTP/1.1", + "headers": { + "Accept": [ + "*/*" + ], + "Authorization": [ + "Bearer credential1" + ], + "Content-Length": [ + "50" + ], + "Content-Type": [ + "application/json" + ], + "Original-Host": [ + "foo.com" + ], + "Req-Start-Time": [ + "1724144113842" + ], + "User-Agent": [ + "curl/8.1.2" + ], + "X-Envoy-Expected-Rq-Timeout-Ms": [ + "15000" + ], + "X-Forwarded-Proto": [ + "https" + ], + "X-Request-Id": [ + "b40e9ebb-f36c-4e22-b8fc-2559c9495f43" + ] + }, + "namespace": "", + "ingress": "", + "service": "", + "pod": "", + "body": { + "password": [ + "pswdxxxx" + ], + "username": [ + "unamexxxx" + ] + } +``` +可以看到请求被允许,并且返回了相应的响应。 +```shell +< x-ratelimit-limit: 4 +< x-ratelimit-remaining: 3 +``` + +2. 触发流控 + +```shell +for i in $(seq 1 10); do + curl -X POST -v http://127.0.0.1:10000/hello \ + -H 'Authorization: Bearer credential1' \ + -H 'Content-type: application/json' \ + -H 'host:foo.com' \ + -d '{"username":["unamexxxx"],"password":["pswdxxxx"]}' +done + +> POST /hello HTTP/1.1 +> Host:foo.com +> User-Agent: curl/8.1.2 +> Accept: */* +> Authorization: Bearer credential1 +> Content-type: application/json +> Content-Length: 50 +> +< HTTP/1.1 429 Too Many Requests +< x-ratelimit-limit: 4 +< x-ratelimit-remaining: 0 +< x-ratelimit-reset: 4 +< content-length: 17 +< content-type: text/plain +< date: Tue, 20 Aug 2024 08:56:57 GMT +< server: envoy +< +* Connection #0 to host 127.0.0.1 left intact +Too Many Requests% +``` + +## 参考 +- [1] [限流算法选择](https://help.aliyun.com/document_detail/149952.html) + + + + + + + + diff --git a/src/content/docs/ebook/en/wasm19.md b/src/content/docs/ebook/en/wasm19.md new file mode 100644 index 0000000000..78abbbdf28 --- /dev/null +++ b/src/content/docs/ebook/en/wasm19.md @@ -0,0 +1,260 @@ +--- +title: Wasm 生效原理 +keywords: [Higress] +--- + + +# Wasm 生效原理 + +这一章主要介绍 Wasm 的生效原理包括全局/路由/域名/服务级别生效原理、Wasm插件的 phase & priority、以及 Wasm 插件分发的原理。 + +## 1 测试插件链结构 + +这里以 custom-response、transformer、key-auth、easy-logger 四个插件组成插件链为例,介绍 Wasm插件的生效原理。其插件链如下图: + +![img](https://img.alicdn.com/imgextra/i2/O1CN01sSmytv1DfnczmUj0j_!!6000000000244-2-tps-1830-460.png) + + +## 2 全局/路由/域名/服务级生效原理 + +以插件 custom-response 为例,其插件配置如下: +```yaml +apiVersion: extensions.higress.io/v1alpha1 +kind: WasmPlugin +metadata: + name: custom-response + namespace: higress-system +spec: + priority: 200 + phase: AUTHN + # 配置会全局生效,但如果被下面规则匹配到,则会改为执行命中规则的配置 + defaultConfig: + headers: + - key1=value1 + "body": "{\"hello\":\"foo\"}" + matchRules: + # 域名级生效配置 + - domain: + - foo.com + config: + headers: + - key2=value2 + "body": "{\"hello\":\"foo\"}" + - ingress: + - higress-course/ingress-bar + # higress-course 命名空间下名为 ingress-bar 的 ingress 会应用下面这个配置 + config: + headers: + - key3=value3 + "body": "{\"hello\":\"bar\"}" + - service: + - echo-server.higress-course.svc.cluster.local + config: + headers: + - key4=value4 + "body": "{\"hello\":\"echo server\"}" + url: oci://higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins/custom-response:1.0.0 + imagePullPolicy: Always +``` + +Higress Controller 控制面会把 Higress WasmPlugin 配置转换成 Istio WasmPlugin 配置,同时通过 MCP over Xds 同步到 Istio Discovery, 然后下发到 Envoy 。 +这里可以通过 Higress Controller Debug 接口查看转换后的 Istio WasmPlugin 配置: + +```shell +kubectl exec -c higress-core -n higress-system -- curl http://127.0.0.1:8888/debug/configz +``` +以下是 custom-response 插件转换成 Istio WasmPlugin YAML 配置如下: +```yaml +kind: WasmPlugin +apiVersion: extensions.istio.io/v1alpha1 +metadata: + name: custom-response + namespace: higress-system +spec: + imagePullPolicy: Always + phase: AUTHN + pluginConfig: + _rules_: + - _match_domain_: + - foo.com + body: '{"hello":"foo"}' + headers: + - key2=value2 + - _match_route_: + - higress-course/ingress-bar + body: '{"hello":"bar"}' + headers: + - key3=value3 + - _match_service_: + - echo-server.higress-course.svc.cluster.local + body: '{"hello":"echo server"}' + headers: + - key4=value4 + body: '{"hello":"foo"}' + headers: + - key1=value1 + priority: 200 + selector: + matchLabels: + higress: higress-system-higress-gateway + url: oci://higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins/custom-response:1.0.0 +``` +发现在 pluginConfig 中增加了 `_rules_` 规则列表,规则中可以指定匹配方式,并填写对应生效的配置: +- `_match_domain_`:匹配域名生效,填写域名即可,支持通配符。 +- `_match_route_`:匹配 Ingress 生效,匹配格式为:Ingress 所在命名空间 + "/" + Ingress 名称。 +- `_match_service_`:匹配服务生效,填写服务即可,支持通配符。 + +Higress Controller 控制面转换代码逻辑在 `pkg/ingress/config/ingress_config.go` 文件的 `convertIstioWasmPlugin` func 中实现,其实现代码逻辑比较简单。 +```golang +func (m *IngressConfig) convertIstioWasmPlugin(obj *higressext.WasmPlugin) (*extensions.WasmPlugin, error) { + ... +} +``` + +同时在第`十六章 Higress 插件 Go SDK 与处理流程`中介绍 `CommonPluginCtx` 插件上下文在插件启动时候解析全局/路由/域名/服务级配置,其代码逻辑在 `plugins/wasm-go/pkg/matcher/rule_matcher.go` 文件的 `ParseRuleConfig` func 中实现。 + +```golang +func (m *RuleMatcher[PluginConfig]) ParseRuleConfig(config gjson.Result, + parsePluginConfig func(gjson.Result, *PluginConfig) error, + parseOverrideConfig func(gjson.Result, PluginConfig, *PluginConfig) error) error { + ... +} +``` +另外在插件 `OnHttpRequestHeaders` 阶段根据当前请求的 `:authority`、`route_name`、`cluster_name` 获取对应的域名、路由、服务级和全局插件配置。其代码逻辑在 `plugins/wasm-go/pkg/matcher/rule_matcher.go` 文件的 `GetMatchConfig` func 中实现。 +```golang +func (m RuleMatcher[PluginConfig]) GetMatchConfig() (*PluginConfig, error) { + host, err := proxywasm.GetHttpRequestHeader(":authority") + ... + routeName, err := proxywasm.GetProperty([]string{"route_name"}) + ... + serviceName, err := proxywasm.GetProperty([]string{"cluster_name"}) + ... +} +``` +这里代码逻辑相对比较简单,这里就不再赘述了,有兴趣同学可以直接看源代码。 + +## 3 Wasm插件的 phase 和 priority + +Wasm 插件 phase 有 `UNSPECIFIED_PHASE`、`AUTHN`、`AUTHZ`、`STATS` 四个值,分别对应插件过滤器链的末端、Istio 认证过滤器之前、Istio 授权过滤器之前且在 Istio 认证过滤器之后、Istio 统计过滤器之前且在 Istio 授权过滤器之后。 +同时在相同 phase 情况下,priority 值越大,插件在插件链位置越靠前。 关于认证和授权相关内容可以参考 [Istio 安全](https://istio.io/latest/zh/docs/concepts/security/)官方文档。其插件链结构如下图: + +![img](https://img.alicdn.com/imgextra/i4/O1CN017aWyas29NFISP7P4o_!!6000000008055-2-tps-1274-1114.png) + +可以通过导出 Envoy 配置查看插件链结构: + +```shell +kubectl exec -n higress-system -- curl http://127.0.0.1:15000/config_dump +``` +其 Enovy 插件链结构 YAML 格式如下: +```yaml +name: envoy.filters.network.http_connection_manager +typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: outbound_0.0.0.0_80 + http_filters: + - name: envoy.filters.http.cors + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.cors.v3.Cors + - name: higress-system.custom-response + config_discovery: + config_source: + ads: {} + initial_fetch_timeout: 0s + resource_api_version: V3 + default_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.composite.v3.Composite + apply_default_config_without_warming: true + type_urls: + - type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm + - type.googleapis.com/envoy.extensions.filters.http.composite.v3.Composite + - name: higress-system.wasm-transformer + config_discovery: + config_source: + ads: {} + initial_fetch_timeout: 0s + resource_api_version: V3 + default_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.composite.v3.Composite + apply_default_config_without_warming: true + type_urls: + - type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm + - type.googleapis.com/envoy.extensions.filters.http.composite.v3.Composite + - name: envoy.filters.http.rbac + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBAC + - name: envoy.filters.http.local_ratelimit + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.local_ratelimit.v3.LocalRateLimit + stat_prefix: http_local_rate_limiter + - name: higress-system.wasm-keyauth + config_discovery: + config_source: + ads: {} + initial_fetch_timeout: 0s + resource_api_version: V3 + default_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.composite.v3.Composite + apply_default_config_without_warming: true + type_urls: + - type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm + - type.googleapis.com/envoy.extensions.filters.http.composite.v3.Composite + - name: higress-system.easy-logger + config_discovery: + config_source: + ads: {} + initial_fetch_timeout: 0s + resource_api_version: V3 + default_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.composite.v3.Composite + apply_default_config_without_warming: true + type_urls: + - type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm + - type.googleapis.com/envoy.extensions.filters.http.composite.v3.Composite + - name: envoy.filters.http.fault + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.fault.v3.HTTPFault + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router +``` + +## 4 Wasm 插件分发的原理 + +Wasm 插件是通过 OCI 实现了 wasm 文件更新,直接热加载,不会导致任何连接中断,业务流量完全无损。其插件分发流程如下图: + +![img](https://img.alicdn.com/imgextra/i4/O1CN01rx9nle1TI0uWQqI3Q_!!6000000002358-0-tps-1498-1058.jpg) + +OCI 分发流程如下: + +1. 当 Higress WasmPlugin 资源更新时,Higress Core 监听到这个变化,同时把 Higress WasmPlugin 转成 Istio WasmPlugin。 +2. Higress Core 将转成 Istio WasmPlugin 通过 MCP Over Xds 推送给 Discovery。 +3. Discovery 通过 Pilot Agent 的 ADS 连接,通过 LDS 协议下发给 Plot Agent。 +4. Pilot Agent 将 LDS 响应直接代理转发给 Envoy。 +5. Envoy 根据 Listener 里插件配置,通过 ECDS (Extension Config Discovery Service) 订阅插件配置。 +6. Pilot Agent 代理 ECDS 协议请求到 Discovery, 同时拦截 ECDS 协议响应。 +7. Pilot Agent 根据 ECDS 响应里插件 OCI 配置,从 Registry Hub 下载镜像。 +8. Pilot Agent 把镜像里插件 Wasm 文件解压到本地,同时修改 ECDS 响应里插件地址到本地 Wasm 文件路径,然后把 ECDS 协议响应返回给 Envoy。 +9. Envoy 根据 ECDS 协议响应,加载本地 Wasm 文件。 + +注意第 5 步没有直接下发插件配置。而是下发 config_discovery 配置。下面是 Envoy 导出 `custom-response` 插件配置。 + +```yaml +- name: higress-system.custom-response + config_discovery: + config_source: + ads: {} + initial_fetch_timeout: 0s + resource_api_version: V3 + default_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.composite.v3.Composite + apply_default_config_without_warming: true + type_urls: + - type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm + - type.googleapis.com/envoy.extensions.filters.http.composite.v3.Composite +``` +关于 ECDS 配置,可以参考 [Envoy ECDS](https://www.envoyproxy.io/docs/envoy/latest/api-v3/service/extension/v3/config_discovery.proto)。 + + +## 参考 +- [1][Higress 实战:30 行代码写一个 Wasm Go插件](https://mp.weixin.qq.com/s/daYa4MSo3XelpjnIFuxhKw) + diff --git a/src/content/docs/ebook/zh-cn/wasm14.md b/src/content/docs/ebook/zh-cn/wasm14.md new file mode 100644 index 0000000000..878af95f5c --- /dev/null +++ b/src/content/docs/ebook/zh-cn/wasm14.md @@ -0,0 +1,1072 @@ +--- +title: Wasm 插件介绍和开发自定义插件 +keywords: [Higress] +--- + +# Wasm 插件介绍和开发自定义插件 + +本章开始进入 Wasm 插件开发篇,主要介绍 Wasm 插件配置、Higress WasmPlugin CRD 以及如何开发自定义插件。 + +## 1 测试环境准备 + +> Higress 本地测试环境网关地址是 127.0.0.1,端口是 80 和 443。 + +准备 echo-server 和 Ingress, 其 YAML 配置如下: + +```yaml +apiVersion: v1 +kind: Namespace +metadata: + name: higress-course +--- +apiVersion: v1 +kind: Service +metadata: + name: echo-server + namespace: higress-course +spec: + selector: + app: echo-server + ports: + - protocol: TCP + port: 8080 + targetPort: 3000 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: echo-server + namespace: higress-course + labels: + app: echo-server +spec: + replicas: 1 + selector: + matchLabels: + app: echo-server + template: + metadata: + labels: + app: echo-server + spec: + containers: + - name: echo-server + image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/echo-server:1.3.0 + env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + resources: + requests: + cpu: 10m +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-foo + namespace: higress-course +spec: + ingressClassName: higress + rules: + - host: "foo.com" + http: + paths: + - pathType: Prefix + path: "/" + backend: + service: + name: echo-server + port: + number: 8080 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-bar + namespace: higress-course +spec: + ingressClassName: higress + rules: + - host: "bar.com" + http: + paths: + - pathType: Prefix + path: "/" + backend: + service: + name: echo-server + port: + number: 8080 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-baz + namespace: higress-course +spec: + ingressClassName: higress + rules: + - host: "baz.com" + http: + paths: + - pathType: Prefix + path: "/" + backend: + service: + name: echo-server + port: + number: 8080 +``` + +## 2 Wasm 插件配置 + +Higress WasmPlugin 在 Istio WasmPlugin 的基础上进行了扩展,支持全局、路由、域名、服务级别的配置。这 4 个配置优先级是:路由级 > 域名级 > 服务级 > 全局,对于没有匹配到具体路由、域名、服务级别的请求才会应用全局配置。 + +下面以 Higress 官方提供的 [custom-response](https://higress.io/zh-cn/docs/plugins/transformation/custom-response) 插件为例进行介绍。custom-response 插件支持配置自定义响应,包括 HTTP 响应状态码、HTTP 响应头,以及 HTTP 响应体。custom-response 插件不仅可以用于模拟响应,还可以根据特定状态码返回自定义响应。例如,在触发网关限流策略时,返回自定义响应。 + +应用 custom-response 插件,YAML 配置如下: + +```yaml +apiVersion: extensions.higress.io/v1alpha1 +kind: WasmPlugin +metadata: + name: custom-response + namespace: higress-system +spec: + priority: 200 + # 配置会全局生效,但如果被下面规则匹配到,则会改为执行命中规则的配置 + defaultConfig: + headers: + - key1=value1 + "body": "{\"hello\":\"foo\"}" + matchRules: + # 域名级生效配置 + - domain: + - bar.com + config: + headers: + - key2=value2 + "body": "{\"hello\":\"bar\"}" + # 路由级生效配置 + - ingress: + - higress-course/ingress-baz + # higress-course 命名空间下名为 ingress-baz 的 ingress 会应用下面这个配置 + config: + headers: + - key3=value3 + "body": "{\"hello\":\"baz\"}" + url: oci://higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins/custom-response:1.0.0 +``` + +访问 foo.com,由于请求没有匹配任何域名级或路由级配置,因此最终应用了全局配置。 + +```shell +curl -v -H "Host: foo.com" http://127.0.0.1/ + +* Trying 127.0.0.1:80... +* Connected to 127.0.0.1 (127.0.0.1) port 80 (#0) +> GET / HTTP/1.1 +> Host: foo.com +> User-Agent: curl/8.1.2 +> Accept: */* +> +< HTTP/1.1 200 OK +< key1: value1 +< content-type: application/json; charset=utf-8 +< content-length: 15 +< date: Sun, 14 Jul 2024 02:45:51 GMT +< server: istio-envoy +< +* Connection #0 to host 127.0.0.1 left intact +{"hello":"foo"} +``` + +访问 bar.com,请求匹配域名级配置。 + +```shell +curl -v -H "Host: bar.com" http://127.0.0.1/ + +* Trying 127.0.0.1:80... +* Connected to 127.0.0.1 (127.0.0.1) port 80 (#0) +> GET / HTTP/1.1 +> Host: bar.com +> User-Agent: curl/8.1.2 +> Accept: */* +> +< HTTP/1.1 200 OK +< key2: value2 +< content-type: application/json; charset=utf-8 +< content-length: 15 +< date: Sun, 14 Jul 2024 02:47:51 GMT +< server: istio-envoy +< +* Connection #0 to host 127.0.0.1 left intact +{"hello":"bar"} +``` + +访问 baz.com,请求匹配路由级配置。 + +```shell +curl -v -H "Host: baz.com" http://127.0.0.1/ + +* Trying 127.0.0.1:80... +* Connected to 127.0.0.1 (127.0.0.1) port 80 (#0) +> GET / HTTP/1.1 +> Host: baz.com +> User-Agent: curl/8.1.2 +> Accept: */* +> +< HTTP/1.1 200 OK +< key3: value3 +< content-type: application/json; charset=utf-8 +< content-length: 15 +< date: Sun, 14 Jul 2024 08:44:03 GMT +< server: istio-envoy +< +* Connection #0 to host 127.0.0.1 left intact +{"hello":"baz"} +``` + +测试完成后删除 `custom-response` WasmPlugin,避免对后续测试产生影响。 + +```shell +kubectl delete wasmplugin custom-response -n higress-system +``` + +## 3 Higress WasmPlugin CRD + +Higress WasmPlugin CRD 在 [Istio WasmPlugin CRD](https://istio.io/latest/docs/reference/config/proxy_extensions/wasm-plugin/) 的基础上进行了扩展,新增 `defaultConfig` 和 `matchRules` 字段,用于配置插件的默认配置和路由级、域名级配置。 + +主要配置如下: + +| 字段名称 | 数据类型 | 填写要求 | 描述 | +|------------------|-----------------|----|-------------------------------------------------------------------------------------------------------------| +| `pluginName` | string | 选填 | 插件名称 | +| `phase` | string | 选填 | 插件插入插件链中的位置,默认是 UNSPECIFIED_PHASE | +| `priority` | int | 选填 | 插件执行优先级,默认为 0,在相同 `phase` 下,值越大越先处理请求,但越后处理响应 | +| `imagePullPolicy` | string | 选填 | 插件镜像拉取策略,可选值有:`UNSPECIFIED_POLICY`(默认值)、`IfNotPresent`、`Always` | +| `imagePullSecret` | string | 选填 | 用于拉取 OCI 镜像的凭据。与 WasmPlugin 在同一命名空间中的Kubernetes Secret 的名称 | +| `url` | string | 必填 | Wasm 文件或 OCI 容器的 URL,默认为 `oci://`,引用 OCI 镜像。同时支持 `file://`,用于容器本地的 Wasm 文件,以及 `http[s]://`,用于引用远程托管的 Wasm 文件 | +| `Sha256` | string | 选填 | 用于验证 Wasm 文件或 OCI 容器的 SHA256 校验和 | +| `defaultConfig` | object | 选填 | 插件默认配置,全局生效于没有匹配具体域名和路由配置的请求 | +| `defaultConfigDisable`| bool | 选填 | 插件默认配置是否失效,默认值是 false | +| `matchRules` | array of object | 选填 | 匹配域名或路由生效的配置 | + +`phase` 配置说明: + +| 字段名称 | 描述 | +|--------------------|------------------------------------------------------------------------| +| `UNSPECIFIED_PHASE` | 在插件过滤器链的末端,在路由器之前插入插件,如果没有指定插件的 `phase`,则默认设置为 `UNSPECIFIED_PHASE` | +| `AUTHN` | 在 Istio 认证过滤器之前插入插件 | +| `AUTHZ` | 在 Istio 授权过滤器之前且在 Istio 认证过滤器之后插入插件 | +| `STATS` | 在 Istio 统计过滤器之前且在 Istio 授权过滤器之后插入插件 | + +`matchRules` 中每一项的配置字段说明: + +| 字段名称 | 数据类型 | 填写要求 | 配置示例 | 描述 | +|-----------------|-------|--------------------------------------|--------------------------------|-----------------------------------------| +| `ingress` | 字符串数组 | `ingress`、`domain` 和 `service` 中必填一项 | ["default/foo", "default/bar"] | 匹配 ingress 资源对象,匹配格式为: `命名空间/ingress名称` | +| `domain` | 字符串数组 | `ingress`、`domain` 和 `service` 中必填一项| ["example.com", "*.test.com"] | 匹配域名,支持泛域名 | +| `service` | 字符串数组 | `ingress`、`domain` 和 `service` 中必填一项 | ["echo-server.higress-course.svc.cluster.local"] | 匹配服务名称 | +| `config` | 对象 | 选填 | - | 匹配后生效的插件配置 | +| `configDisable` | bool | 选填 | false | 配置是否生效,默认设置为 false | + + +## 4 自定义插件开发 + +开发一个简单日志插件 `easy-logger`, 这个插件根据配置记录请求和响应到网关日志中。整个过程涉及到插件开发环境准备、开发和测试、部署和验证。 + +### 4.1 环境准备 + +环境准备如下: + +- Docker 官方安装连接:https://docs.docker.com/engine/install/ +- Go 版本: >= 1.19 (需要支持范型特性),官方安装链接:https://go.dev/doc/install + +如果选择用 TinyGo 在本地构建 Wasm 文件,再拷贝到 Docker 镜像中,需要安装 TinyGo,其环境要求如下: + +- TinyGo 版本: >= 0.28.1 (建议版本 0.28.1) 官方安装链接:https://tinygo.org/getting-started/install/ + +### 4.2 开发和测试 + +#### 4.2.1 初始化工程目录 + +1. 新建一个工程目录文件 easy-logger。 + +```shell +mkdir easy-logger +``` +2. 在所建目录下执行以下命令,初始化 Go 工程。 + +```shell +go mod init easy-logger +``` + +go.mod 文件中 go 版本需要设置为 1.19,由于在 4.3.3 节中我们将使用 1.19 版本的 wasm-go-builder 镜像来构建插件,因此需要保持两者的 go 版本一致。 + +```shell +module easy-logger + +go 1.19 +``` + +3. 国内环境可能需要设置下载依赖包的代理 + +```shell +go env -w GOPROXY=https://proxy.golang.com.cn,direct +``` + +4. 下载构建插件的依赖 + +```shell +go get github.com/higress-group/proxy-wasm-go-sdk +go get github.com/alibaba/higress/plugins/wasm-go@main +go get github.com/tidwall/gjson +``` + +#### 4.2.2 编写 main.go 文件 +首先,我们编写 easy-logger 插件的基本框架,暂时只读取我们设置的配置参数,不在请求和响应阶段进行任何处理。 + +```golang +package main + +import ( + "github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper" + "github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types" + "github.com/tidwall/gjson" +) + +func main() { + wrapper.SetCtx( + // 插件名称 + "easy-logger", + // 设置自定义函数解析插件配置 + wrapper.ParseConfigBy(parseConfig), + // 设置自定义函数处理请求头 + wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders), + // 设置自定义函数处理请求体 + wrapper.ProcessRequestBodyBy(onHttpRequestBody), + // 设置自定义函数处理响应头 + wrapper.ProcessResponseHeadersBy(onHttpResponseHeaders), + // 设置自定义函数处理响应体 + wrapper.ProcessResponseBodyBy(onHttpResponseBody), + // 设置自定义函数处理流式请求体 + //wrapper.ProcessStreamingRequestBodyBy(onHttpStreamingRequestBody), + // 设置自定义函数处理流式响应体 + //wrapper.ProcessStreamingResponseBodyBy(onHttpStreamingResponseBody), + ) +} + +// 自定义插件配置 +type LoggerConfig struct { + // 是否打印请求 + request bool + // 是否打印响应 + response bool + // 打印响应状态码,* 表示打印所有状态响应,500,502,503 表示打印 HTTP 500、502、503 状态响应,默认是 * + responseStatusCodes string +} + +func parseConfig(json gjson.Result, config *LoggerConfig, log wrapper.Log) error { + log.Debugf("parseConfig()") + config.request = json.Get("request").Bool() + config.response = json.Get("response").Bool() + config.responseStatusCodes = json.Get("responseStatusCodes").String() + if config.responseStatusCodes == "" { + config.responseStatusCodes = "*" + } + log.Debugf("parse config:%v", config) + return nil +} + +func onHttpRequestHeaders(ctx wrapper.HttpContext, config LoggerConfig, log wrapper.Log) types.Action { + log.Debugf("onHttpRequestHeaders()") + return types.ActionContinue +} + +func onHttpRequestBody(ctx wrapper.HttpContext, config LoggerConfig, body []byte, log wrapper.Log) types.Action { + log.Debugf("onHttpRequestBody()") + return types.ActionContinue +} + +func onHttpResponseBody(ctx wrapper.HttpContext, config LoggerConfig, body []byte, log wrapper.Log) types.Action { + log.Debugf("onHttpResponseBody()") + return types.ActionContinue +} + +func onHttpResponseHeaders(ctx wrapper.HttpContext, config LoggerConfig, log wrapper.Log) types.Action { + log.Debugf("onHttpResponseHeaders()") + return types.ActionContinue +} +``` + +Higress 插件 SDK 开发涉及到以下内容: + +- wrapper.HttpContext:请求上下文。 +- LoggerConfig:自定义插件配置。 +- wrapper.Log:插件日志工具。 +- wrapper.ProcessXXXX:插件回调钩子函数。 +- proxywasm:提供插件工具函数包。 + +wrapper 插件回调钩子函数包含以下函数,可以根据实际业务需求选择设置以下钩子函数: + +- wrapper.ParseConfigBy(parseConfig):解析插件配置。 +- wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders):设置自定义函数处理请求头。 +- wrapper.ProcessRequestBodyBy(onHttpRequestBody):设置自定义函数处理请求体。 +- wrapper.ProcessResponseHeadersBy(onHttpResponseHeaders):设置自定义函数处理响应头。 +- wrapper.ProcessResponseBodyBy(onHttpResponseBody):设置自定义函数处理响应体。 +- wrapper.ProcessStreamingRequestBodyBy(onHttpStreamingRequestBody):设置自定义函数处理流式请求体。 +- wrapper.ProcessStreamingResponseBodyBy(onHttpStreamingResponseBody):设置自定义函数处理流式响应体。 + +关于 Higress 插件 SDK 内容会在后续章节中详细展开。 + +#### 4.3.3 本地测试 + +1. 第一步:在插件目录下创建文件 envoy.yaml,内容如下。网关在 10000 端口监听 HTTP 请求,将请求转发到 echo-server 服务。 + +```yaml +admin: + address: + socket_address: + protocol: TCP + address: 0.0.0.0 + port_value: 9901 +static_resources: + listeners: + - name: listener_0 + address: + socket_address: + protocol: TCP + address: 0.0.0.0 + port_value: 10000 + 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 + scheme_header_transformation: + scheme_to_overwrite: https + stat_prefix: ingress_http + route_config: + name: local_route + virtual_hosts: + - name: local_service + domains: ["*"] + routes: + - match: + prefix: "/" + route: + cluster: echo-server + http_filters: + - name: wasmdemo + typed_config: + "@type": type.googleapis.com/udpa.type.v1.TypedStruct + type_url: type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm + value: + config: + name: wasmdemo + vm_config: + runtime: envoy.wasm.runtime.v8 + code: + local: + filename: /etc/envoy/plugin.wasm + configuration: + "@type": "type.googleapis.com/google.protobuf.StringValue" + value: |- + { + "request": true, + "response": true, + "responseStatusCodes": "200,500,502,503" + } + - name: envoy.filters.http.router + clusters: + - name: echo-server + connect_timeout: 30s + type: LOGICAL_DNS + dns_lookup_family: V4_ONLY + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: echo-server + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: echo-server + port_value: 3000 +``` + +插件通过本地文件的方式加载到 Envoy 中,插件配置的如下: + +```yaml + configuration: + "@type": "type.googleapis.com/google.protobuf.StringValue" + value: |- + { + "request": true, + "response": true, + "responseStatusCodes": "200,500,502,503" + } +``` + +2. 第二步:在插件目录下创建文件 docker-compose.yaml,内容如下: + +```yaml +version: '3.9' +services: + envoy: + image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/gateway:v1.4.1 + entrypoint: /usr/local/bin/envoy + # 注意这里对 Wasm 开启了 debug 级别日志,在生产环境部署时请使用默认的 info 级别 + # 如果需要将 Envoy 的日志级别调整为 debug,将 --log-level 参数设置为 debug + command: -c /etc/envoy/envoy.yaml --log-level info --log-path /etc/envoy/envoy.log --component-log-level wasm:debug + depends_on: + - echo-server + networks: + - wasmtest + ports: + - "10000:10000" + - "9901:9901" + volumes: + - ./envoy.yaml:/etc/envoy/envoy.yaml + - ./build/plugin.wasm:/etc/envoy/plugin.wasm + - ./envoy.log:/etc/envoy/envoy.log + echo-server: + image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/echo-server:1.3.0 + networks: + - wasmtest + ports: + - "3000:3000" +networks: + wasmtest: {} +``` +3. 第三步:在插件目录下创建文件 Dockerfile,内容如下: + +```yaml +ARG BUILDER=higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins/wasm-go-builder:go1.19-tinygo0.28.1-oras1.0.0 +FROM $BUILDER as builder + +ARG GOPROXY +ENV GOPROXY=${GOPROXY} + +ARG EXTRA_TAGS="" +ENV EXTRA_TAGS=${EXTRA_TAGS} + +WORKDIR /workspace +COPY . . +RUN go mod tidy +RUN tinygo build -o /main.wasm -scheduler=none -gc=custom -tags="custommalloc nottinygc_finalizer $EXTRA_TAGS" -target=wasi ./main.go + +FROM scratch as output +COPY --from=builder /main.wasm plugin.wasm +``` + +4. 第四步:在插件目录下创建文件 Makefile,内容如下: + +```shell +PLUGIN_NAME ?= hello-world +BUILDER_REGISTRY ?= higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins/ +REGISTRY ?= higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins/ +GO_VERSION ?= 1.19 +TINYGO_VERSION ?= 0.28.1 +ORAS_VERSION ?= 1.0.0 +HIGRESS_VERSION ?= 1.4.1 +USE_HIGRESS_TINYGO ?= true +BUILDER ?= ${BUILDER_REGISTRY}wasm-go-builder:go${GO_VERSION}-tinygo${TINYGO_VERSION}-oras${ORAS_VERSION} +BUILD_TIME := $(shell date "+%Y%m%d-%H%M%S") +COMMIT_ID := $(shell git rev-parse --short HEAD 2>/dev/null) +IMAGE_TAG = $(if $(strip $(PLUGIN_VERSION)),${PLUGIN_VERSION},${BUILD_TIME}-${COMMIT_ID}) +IMG ?= ${REGISTRY}${PLUGIN_NAME}:${IMAGE_TAG} +GOPROXY := $(shell go env GOPROXY) +EXTRA_TAGS ?= proxy_wasm_version_0_2_100 + +.DEFAULT: +local-docker-build: + DOCKER_BUILDKIT=1 docker build --build-arg BUILDER=${BUILDER} \ + --build-arg GOPROXY=$(GOPROXY) \ + --build-arg EXTRA_TAGS=$(EXTRA_TAGS) \ + -t ${IMG} \ + --output build \ + . + @echo "" + @echo "output wasm file: ./build/plugin.wasm" + +build-image: + DOCKER_BUILDKIT=1 docker build --build-arg BUILDER=${BUILDER} \ + --build-arg GOPROXY=$(GOPROXY) \ + --build-arg EXTRA_TAGS=$(EXTRA_TAGS) \ + -t ${IMG} \ + . + @echo "" + @echo "image: ${IMG}" + +build-push: build-image + docker push ${IMG} + +local-build: + tinygo build -scheduler=none -target=wasi -gc=custom -tags='custommalloc nottinygc_finalizer $(EXTRA_TAGS)' \ + -o ./build/plugin.wasm main.go + @echo "" + @echo "wasm: ./build/plugin.wasm" + +local-run: + echo > ./envoy.log + docker compose down + docker compose up -d + +local-all: local-build local-run +local-docker-all: local-docker-build local-run +``` + +请将 Makefile 文件中镜像仓库地址 `REGISTRY ?= higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins/` 换成自己的镜像仓库地址。 + +其命令说明如下: +- `make local-docker-build`: 本地 Docker 环境构建插件,生成插件文件 ./build/plugin.wasm。 +- `make local-build`: 本地 TinyGo 构建插件,生成插件文件 ./build/plugin.wasm。 +- `make local-run`: 本地启动测试环境。 +- `PLUGIN_NAME=easy-logger PLUGIN_VERSION=1.0.0 make build-image` 构建 easy-logger 插件镜像,插件版本为 1.0.0。 +- `PLUGIN_NAME=easy-logger PLUGIN_VERSION=1.0.0 make build-push` 构建 easy-logger 插件镜像,插件版本为 1.0.0,同时推送到镜像仓库。 +- `make local-docker-all`: 本地 Docker 环境构建插件,生成插件文件 build/plugin.wasm,同时启动本地测试环境。 +- `make local-all`: 本地 TinyGo 构建插件,生成插件文件 ./build/plugin.wasm,同时启动本地测试环境。 + +注意用 TinyGo 本地构建命令如下: +```shell +tinygo build -scheduler=none -target=wasi -gc=custom -tags='custommalloc nottinygc_finalizer proxy_wasm_version_0_2_100' -o ./build/plugin.wasm main.go +``` + +5. 第五步:本地 Docker 环境构建和启动测试环境 + +本地 Docker 环境构建和启动测试环境,命令如下: + +```shell +make local-docker-all +``` + +本地启动测试环境后,插件目录整体文件结构如下: + +```shell +tree +. +├── Dockerfile +├── Makefile +├── build +│   └── plugin.wasm # 构建生成的 Wasm 文件 +├── docker-compose.yaml +├── envoy.log # Envoy 日志文件 +├── envoy.yaml +├── go.mod +├── go.sum +└── main.go +``` + +执行以下命令通过网关访问 echo-server。 + +```shell +curl -X POST -v http://127.0.0.1:10000/hello \ +-H "Content-type: application/json" -H 'host:foo.com' \ +-d '{"username":["unamexxxx"], "password":["pswdxxxx"]}' + +* Trying 127.0.0.1:10000... +* Connected to 127.0.0.1 (127.0.0.1) port 10000 (#0) +> POST /hello HTTP/1.1 +> Host:foo.com +> User-Agent: curl/8.1.2 +> Accept: */* +> Content-type: application/json +> Content-Length: 50 +> +< HTTP/1.1 200 OK +< content-type: application/json +< x-content-type-options: nosniff +< date: Sat, 20 Jul 2024 04:39:46 GMT +< content-length: 642 +< req-cost-time: 48 +< req-arrive-time: 1721450386098 +< resp-start-time: 1721450386146 +< x-envoy-upstream-service-time: 30 +< server: envoy +< +{ + "path": "/hello", + "host": "foo.com", + "method": "POST", + "proto": "HTTP/1.1", + "headers": { + "Accept": [ + "*/*" + ], + "Content-Length": [ + "50" + ], + "Content-Type": [ + "application/json" + ], + "Original-Host": [ + "foo.com" + ], + "Req-Start-Time": [ + "1721450386098" + ], + "User-Agent": [ + "curl/8.1.2" + ], + "X-Envoy-Expected-Rq-Timeout-Ms": [ + "15000" + ], + "X-Forwarded-Proto": [ + "https" + ], + "X-Request-Id": [ + "2f9ff093-7891-4c55-992b-874f7ba00d0e" + ] + }, + "namespace": "", + "ingress": "", + "service": "", + "pod": "", + "body": { + "password": [ + "pswdxxxx" + ], + "username": [ + "unamexxxx" + ] + } +* Connection #0 to host 127.0.0.1 left intact +} +``` + +查看插件目录下 envoy.log 文件,可以看到 easy-logger 插件的日志输出。 + +```shell +[2024-07-20 04:08:19.990][22][debug][wasm] [external/envoy/source/extensions/common/wasm/wasm.cc:146] Thread-Local Wasm created 10 now active +[2024-07-20 04:08:19.993][22][debug][wasm] [external/envoy/source/extensions/common/wasm/context.cc:1308] wasm log: [easy-logger] parseConfig() +[2024-07-20 04:08:19.993][22][debug][wasm] [external/envoy/source/extensions/common/wasm/context.cc:1308] wasm log: [easy-logger] parse config:&{request:true response:true responseStatusCodes:200,500,502,503} +[2024-07-20 04:08:19.993][1][warning][main] [external/envoy/source/server/server.cc:715] there is no configured limit to the number of allowed active connections. Set a limit via the runtime key overload.global_downstream_max_connections +[2024-07-20 04:39:46.114][29][debug][wasm] [external/envoy/source/extensions/common/wasm/context.cc:1308] wasm log wasmdemo: [easy-logger] onHttpRequestHeaders() +[2024-07-20 04:39:46.116][29][debug][wasm] [external/envoy/source/extensions/common/wasm/context.cc:1308] wasm log wasmdemo: [easy-logger] onHttpRequestBody() +[2024-07-20 04:39:46.147][29][debug][wasm] [external/envoy/source/extensions/common/wasm/context.cc:1308] wasm log wasmdemo: [easy-logger] onHttpResponseHeaders() +[2024-07-20 04:39:46.147][29][debug][wasm] [external/envoy/source/extensions/common/wasm/context.cc:1308] wasm log wasmdemo: [easy-logger] onHttpResponseBody() +``` + +到这里表示整体开发和测试环境已经完成,下面就是完善插件功能,然后重新测试。 + +### 4.3 完善插件功能 + +接下来,我们将通过自定义函数来处理请求和响应信息。通过设置插件参数,我们可以控制是否打印请求和响应信息,并根据指定的响应状态码决定是否记录响应内容。 + +```golang +package main + +import ( + "fmt" + "strings" + + "github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper" + "github.com/google/uuid" + "github.com/higress-group/proxy-wasm-go-sdk/proxywasm" + "github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types" + "github.com/tidwall/gjson" +) + +func main() { + wrapper.SetCtx( + // 插件名称 + "easy-logger", + // 设置自定义函数解析插件配置 + wrapper.ParseConfigBy(parseConfig), + // 设置自定义函数处理请求头 + wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders), + // 设置自定义函数处理请求体 + wrapper.ProcessRequestBodyBy(onHttpRequestBody), + // 设置自定义函数处理响应头 + wrapper.ProcessResponseHeadersBy(onHttpResponseHeaders), + // 设置自定义函数处理响应体 + wrapper.ProcessResponseBodyBy(onHttpResponseBody), + // 设置自定义函数处理流式请求体 + //wrapper.ProcessStreamingRequestBodyBy(onHttpStreamingRequestBody), + // 设置自定义函数处理流式响应体 + //wrapper.ProcessStreamingResponseBodyBy(onHttpStreamingResponseBody), + ) +} + +// 自定义插件配置 +type LoggerConfig struct { + // 是否打印请求 + request bool + // 是否打印响应 + response bool + // 打印响应状态码,* 表示打印所有状态响应,500,502,503 表示打印 HTTP 500、502、503 状态响应,默认是 * + responseStatusCodes string +} + +func parseConfig(json gjson.Result, config *LoggerConfig, log wrapper.Log) error { + log.Debugf("parseConfig()") + config.request = json.Get("request").Bool() + config.response = json.Get("response").Bool() + config.responseStatusCodes = json.Get("responseStatusCodes").String() + if config.responseStatusCodes == "" { + config.responseStatusCodes = "*" + } + log.Debugf("parse config:%+v", config) + return nil +} + +func onHttpRequestHeaders(ctx wrapper.HttpContext, config LoggerConfig, log wrapper.Log) types.Action { + log.Debugf("onHttpRequestHeaders()") + requestId := uuid.New().String() + ctx.SetContext("requestId", requestId) + if !config.request { + return types.ActionContinue + } + // 获取并打印请求头 + headers, _ := proxywasm.GetHttpRequestHeaders() + var build strings.Builder + build.WriteString("\n===========request headers===============\n") + build.WriteString(fmt.Sprintf("requestId:%s\n", requestId)) + for _, values := range headers { + build.WriteString(fmt.Sprintf("%s:%s\n", values[0], values[1])) + } + log.Infof(build.String()) + // 继续处理请求 + return types.ActionContinue +} + +func onHttpRequestBody(ctx wrapper.HttpContext, config LoggerConfig, body []byte, log wrapper.Log) types.Action { + log.Debugf("onHttpRequestBody()") + // 打印请求体 + if config.request { + var build strings.Builder + build.WriteString("\n===========request body===============\n") + requestId := ctx.GetContext("requestId").(string) + build.WriteString(fmt.Sprintf("requestId:%s\n", requestId)) + build.WriteString(fmt.Sprintf("body:%s\n", string(body))) + log.Infof(build.String()) + } + return types.ActionContinue +} + +func onHttpResponseHeaders(ctx wrapper.HttpContext, config LoggerConfig, log wrapper.Log) types.Action { + log.Debugf("onHttpResponseHeaders()") + // 添加自定义响应头 + proxywasm.AddHttpResponseHeader("x-easy-logger", "1.0.0") + if !config.response { + return types.ActionContinue + } + // 获取响应状态码 + statusCode, _ := proxywasm.GetHttpResponseHeader(":status") + logResponseBody := false + // 根据响应状态码决定是否打印响应体 + if config.responseStatusCodes == "*" || strings.Contains(config.responseStatusCodes, statusCode) { + logResponseBody = true + } + // 将是否记录响应体的信息存储在上下文中,在 onHttpResponseBody 阶段获取上下文判断是否打印响应体 + ctx.SetContext("logResponseBody", logResponseBody) + // 获取响应头 + headers, _ := proxywasm.GetHttpResponseHeaders() + // 打印响应头 + var build strings.Builder + build.WriteString("\n===========response headers===============\n") + requestId := ctx.GetContext("requestId").(string) + build.WriteString(fmt.Sprintf("requestId:%s\n", requestId)) + for _, values := range headers { + build.WriteString(fmt.Sprintf("%s:%s\n", values[0], values[1])) + } + log.Infof(build.String()) + return types.ActionContinue +} + +func onHttpResponseBody(ctx wrapper.HttpContext, config LoggerConfig, body []byte, log wrapper.Log) types.Action { + log.Debugf("onHttpResponseBody()") + // 获取在 onHttpRequestHeaders 阶段设置的上下文 + logResponseBody, ok := ctx.GetContext("logResponseBody").(bool) + if !ok { + return types.ActionContinue + } + // 打印响应体 + if logResponseBody { + var build strings.Builder + build.WriteString("\n===========response body===============\n") + requestId := ctx.GetContext("requestId").(string) + build.WriteString(fmt.Sprintf("requestId:%s\n", requestId)) + build.WriteString(fmt.Sprintf("body:%s\n", string(body))) + log.Infof(build.String()) + } + return types.ActionContinue +} +``` + + +### 4.4 部署插件和验证 + +1. 构建插件镜像 + +```shell +PLUGIN_NAME=easy-logger PLUGIN_VERSION=1.0.0 make build-push +``` + +2. 部署插件 + +easy-logger 插件部署 YAML 如下: +```yaml +apiVersion: extensions.higress.io/v1alpha1 +kind: WasmPlugin +metadata: + name: easy-logger + namespace: higress-system +spec: + priority: 300 + matchRules: + # 域名级生效配置 + - domain: + - foo.com + config: + request: true + response: true + responseStatusCodes: "200,500,502,503" + url: oci://higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins/easy-logger:1.0.0 +``` + +3. 验证插件 + +- 设置网关插件的日志级别为 debug。 + +```shell +kubectl exec -n higress-system -- \ +curl -X POST http://127.0.0.1:15000/logging?wasm=debug +``` + +- 请求访问 + +```shell +curl -X POST -v http://127.0.0.1/hello \ +-H "Content-type: application/json" -H 'host:foo.com' \ +-d '{"username":["unamexxxx"],"password":["pswdxxxx"]}' +``` + +- 查看网关的日志,可以看到输出了请求和响应的详细信息 + +```shell +kubectl logs -f -n higress-system + +[Envoy (Epoch 0)] [2024-07-20 04:56:55.251][39][debug][wasm] wasm log higress-system.easy-logger: [easy-logger] onHttpRequestHeaders() +[Envoy (Epoch 0)] [2024-07-20 04:56:55.252][39][info][wasm] wasm log higress-system.easy-logger: [easy-logger] +===========request headers=============== +requestId:a791e8e6-8126-4a1d-92f0-a0333b706c1d +:authority:foo.com +:path:/hello +:method:POST +:scheme:http +user-agent:curl/8.1.2 +accept:*/* +content-type:application/json +content-length:50 +x-forwarded-for:192.168.65.1 +x-forwarded-proto:http +x-envoy-internal:true +x-request-id:2ad88049-6ba3-4f3d-bc81-dc29fa48ffce +x-envoy-decorator-operation:echo-server.higress-course.svc.cluster.local:8080/* + +[Envoy (Epoch 0)] [2024-07-20 04:56:55.254][39][debug][wasm] wasm log higress-system.easy-logger: [easy-logger] onHttpRequestBody() +[Envoy (Epoch 0)] [2024-07-20 04:56:55.254][39][info][wasm] wasm log higress-system.easy-logger: [easy-logger] +===========request body=============== +requestId:a791e8e6-8126-4a1d-92f0-a0333b706c1d +body:{"username":["unamexxxx"],"password":["pswdxxxx"]} + +[Envoy (Epoch 0)] [2024-07-20 04:56:55.256][39][debug][wasm] wasm log higress-system.easy-logger: [easy-logger] onHttpResponseHeaders() +[Envoy (Epoch 0)] [2024-07-20 04:56:55.256][39][info][wasm] wasm log higress-system.easy-logger: [easy-logger] +===========response headers=============== +requestId:a791e8e6-8126-4a1d-92f0-a0333b706c1d +:status:200 +content-type:application/json +x-content-type-options:nosniff +date:Sat, 20 Jul 2024 04:56:55 GMT +content-length:993 +req-cost-time:8 +req-arrive-time:1721451415248 +resp-start-time:1721451415256 +x-envoy-upstream-service-time:2 +x-easy-logger:1.0.0 + +[Envoy (Epoch 0)] [2024-07-20 04:56:55.257][39][debug][wasm] wasm log higress-system.easy-logger: [easy-logger] onHttpResponseBody() +[Envoy (Epoch 0)] [2024-07-20 04:56:55.257][39][info][wasm] wasm log higress-system.easy-logger: [easy-logger] +===========response body=============== +requestId:a791e8e6-8126-4a1d-92f0-a0333b706c1d +body:{ + "path": "/hello", + "host": "foo.com", + "method": "POST", + "proto": "HTTP/1.1", + "headers": { + "Accept": [ + "*/*" + ], + "Content-Length": [ + "50" + ], + "Content-Type": [ + "application/json" + ], + "Original-Host": [ + "foo.com" + ], + "Req-Start-Time": [ + "1721451415248" + ], + "User-Agent": [ + "curl/8.1.2" + ], + "X-B3-Sampled": [ + "0" + ], + "X-B3-Spanid": [ + "f642c00a89551b07" + ], + "X-B3-Traceid": [ + "dfab58b011681d29f642c00a89551b07" + ], + "X-Envoy-Attempt-Count": [ + "1" + ], + "X-Envoy-Decorator-Operation": [ + "echo-server.higress-course.svc.cluster.local:8080/*" + ], + "X-Envoy-Internal": [ + "true" + ], + "X-Forwarded-For": [ + "192.168.65.1" + ], + "X-Forwarded-Proto": [ + "http" + ], + "X-Request-Id": [ + "2ad88049-6ba3-4f3d-bc81-dc29fa48ffce" + ] + }, + "namespace": "higress-course", + "ingress": "", + "service": "", + "pod": "echo-server-6f4df5fcff-nksqz", + "body": { + "password": [ + "pswdxxxx" + ], + "username": [ + "unamexxxx" + ] + } +} +``` + + + + + + + diff --git a/src/content/docs/ebook/zh-cn/wasm15.md b/src/content/docs/ebook/zh-cn/wasm15.md new file mode 100644 index 0000000000..ad6d670ac1 --- /dev/null +++ b/src/content/docs/ebook/zh-cn/wasm15.md @@ -0,0 +1,804 @@ +--- +title: Wasm 插件原理 +keywords: [Higress] +--- + +# Wasm 插件原理 + +本章主要介绍 Proxy-Wasm Go SDK 和 Wasm 插件基本原理。 + +## 1 Wasm、TinyGo、Proxy-Wasm Go SDK + +### 1.1 Wasm + +#### 1.1.1 什么是 Wasm ? + + [WebAssembly(简称 Wasm)](https://webassembly.org/) 是操作堆栈虚拟机的二进制指令集,Wasm 可以在 Web 浏览器中运行或者其他环境比如服务器端应用程序运行。Wasm有以下特点: + +- 高效性能:提供了接近机器码的性能。 +- 跨平台:Wasm 是一种与平台无关的格式,可以在任何支持它的平台上运行,包括浏览器和服务器。 +- 安全性:Wasm 在一个内存安全的沙箱环境中运行,这意味着它可以安全地执行不受信任的代码,而不会访问或修改主机系统的其他部分。 +- 可移植性:Wasm 模块可以被编译成 WebAssembly 二进制文件,这些文件可以被传输和加载到支持 Wasm 的任何环境中。 +- 多语言支持:Wasm 支持多种编程语言,开发者可以使用 C、C++、Rust、Go 等多种语言编写代码,然后编译成 Wasm 格式。 + +#### 1.1.2 Wasm 模块 + +Wasm 模块主要有以下两种格式: +- 二进制格式:Wasm 的主要编码格式,以 .wasm 后缀结尾。 +- 文本格式:主要是为了方便开发者理解 Wasm 模块,以 .wat 后缀结尾,相当于汇编语言程序。 + +Wasm 模块二进制格式是 Wasm 二进制文件,Wasm 模块二进制格式也是以魔数和版本号开头,之后就是模块的主体内容,这些内容根据不同用途被分别放在不同的段(Section) 中。一共定义了 12 种段,每种段分配了 ID(从 0 到 11),依次有如下 12 个段:自定义段、类型段、导入段、函数段、表段、内存段、全局段、导出段、起始段、元素段、代码段、数据段。 +Wasm 模块二进制格式的组成如下图(图片来源 [WebAssembly 解释器实现篇](https://github.com/mcuking/blog/issues/96/))所示: +![img](https://img.alicdn.com/imgextra/i1/O1CN01rLuxGp1zIX413ZQ0g_!!6000000006691-0-tps-1784-1266.jpg) + +每一个不同的段都描述了这个 Wasm 模块的一部分信息。而模块内的所有段放在一起,便描述了这个 Wasm 模块的全部信息: +- 内存段和数据段:内存段是线性内存(linear memory)用于存储程序的运行时动态数据。数据段用于存储初始化内存的静态数据。内存可以从外部宿主导入,同时内存对象也可以导出到外部宿主环境。 +- 表段和元素段:表段用于存储对象引用,目前对象只能是函数,因此可以通过表段实现函数指针的功能。元素段用于存储初始化表段的数据。表对象可以从外部宿主导入,同时表对象也可以导出到外部宿主环境。 +- 起始段:起始段用于存储起始函数的索引,即指定了一个在加载时自动运行的函数。起始函数主要作用:1. 在模块加载后进行初始化工作; 2. 将模块变成可执行文件。 +- 全局段:全局段用于存储全局变量的信息(全局变量的值类型、可变性、初始化表达式等)。 +- 函数段、代码段和类型段:这三个段均是用于存储表达函数的数据。其中 + - 类型段:类型段用于存储模块内所有的函数签名(函数签名记录了函数的参数和返回值的类型和数量),注意若存在多个函数的函数签名相同,则存储一份即可。 + - 函数段:函数段用于存储函数对应的函数签名索引,注意是函数签名的索引,而不是函数索引。 + - 代码段:代码段用于存储函数的字节码和局部变量,也就是函数体内的局部变量和代码所对应的字节码。 +- 导入段和导出段:导出段用于存储导出项信息(导出项的成员名、类型,以及在对应段中的索引等)。导入段用于存储导入项信息(导入项的成员名、类型,以及从哪个模块导入等)。导出/导入项类型有 4 种:函数、表、内存、全局变量。 +- 自定义段:自定义段主要用于保存调试符号等和运行无关的信息。 + +关于 Wasm 模块二进制格式详细内容可以参考 [Wasm 模块 Binary Format](https://webassembly.github.io/spec/core/binary/modules.html)。 + +Wasm 模块 wat 文本格式 使用了 `S- 表达式` 的形式来表达 Wasm 模块及其相关定义。关于 wat 格式的更多介绍可以参考 [理解 WebAssembly 文本格式](https://developer.mozilla.org/zh-CN/docs/WebAssembly/Understanding_the_text_format)。 +下图(图片来源 [WebAssembly 解释器实现篇](https://github.com/mcuking/blog/issues/96/))就是使用 C 语言编写的阶乘函数,以及对应的 Wasm 文本格式和二进制格式。 +![img](https://img.alicdn.com/imgextra/i4/O1CN01VcoLBQ1ZcWL5XHYIR_!!6000000003215-0-tps-1892-878.jpg) + +可以通过 [WebAssembly Code Explorer](https://wasdk.github.io/wasmcodeexplorer/) 更直观地查看 Wasm 二进制格式和文本格式之间的关联。也可以通过 [wabt](https://github.com/WebAssembly/wabt) 提供工具 ,可以方便的进行 Wasm 二进制格式和文本格式的转换。 + +#### 1.1.3 Wasm 指令集 + +Wasm 指令集包含如下内容: +- Wasm 指令主要分为控制指令、参数指令、变量指令、内存指令和数值指令,每条指令包含操作码和操作数。感兴趣的可以点击查看下 [Wasm 所有的操作码](https://pengowray.github.io/wasm-ops/), 可视化表格直观地展示了 Wasm 所有的操作码。 +- 只有四种数据类型: i32、i64、f32、f64 +- 指令基于栈,并且支持递归调用。例如 i32.add 从栈弹出两个 i32 类型的值,并将它们相加,然后将结果压入栈。 +- 从内存读取数据 + - i32.load 从内存中读取一个 i32 类型的值。 + - i32.const value 将一个 i32 类型的值压入栈。 + - 从线性内存读取数据 + +关于更多 Wasm 解释器实现原理的可以参考 [WebAssembly 解释器实现篇](https://github.com/mcuking/blog/issues/96)。 + +### 1.2 TinyGo + +[TinyGo](https://tinygo.org/) 是一个 Go 语言编译器,它专注于生成小型、高效的 Go 程序,特别是为嵌入式系统和 WebAssembly 环境设计。 TinyGo 与 Go 语言的标准编译器不同,它有以下优势: + +- 生成小型二进制文件:TinyGo 优化了生成的二进制文件的大小,使其非常适合资源受限的环境。 +- 简化的 Go 标准库:TinyGo 提供了一个简化版本的 Go 标准库,减少了依赖和复杂性。TinyGo 支持标准库详情:https://tinygo.org/docs/reference/lang-support/stdlib/ 。 +- 跨平台编译:TinyGo 支持跨平台编译,允许开发者为不同的目标平台生成代码。 +- 支持 WebAssembly:通过使用 TinyGo,开发者可以为 WebAssembly 环境编写高效的 Go 应用程序,同时利用 Go 语言的简洁性和易用性。 + +“为什么不使用官方 Go 编译器?”的问题,如果对细节感兴趣,请参考 Go 仓库中的相关 issue: + +- https://github.com/golang/go/issues/25612 +- https://github.com/golang/go/issues/31105 +- https://github.com/golang/go/issues/38248 + +这些 issue 讨论了官方 Go 编译器在生成 Wasm 支持方面的限制和进展。 TinyGo 作为一个替代方案,能够生成适合 [Proxy-Wasm ABI](https://github.com/proxy-wasm/spec) 规范的 Wasm 二进制文件,这使得它成为开发 Proxy-Wasm 应用程序的理想选择。 + +### 1.3 Proxy-Wasm Go SDK + +[Proxy-Wasm Go SDK](https://github.com/higress-group/proxy-wasm-go-sdk) 依赖于 TinyGo, 同时 Proxy-Wasm Go SDK 是基于 [Proxy-Wasm ABI](https://github.com/proxy-wasm/spec) 规范使用 Go 编程语言扩展网络代理(例如 Envoyproxy)的 SDK, 而 Proxy-Wasm ABI 定义了网络代理和在网络代理内部运行的 Wasm 虚拟机之间的接口。 +通过这个 SDK,可以轻松地生成符合 Proxy-Wasm 规范的 Wasm 二进制文件,而无需了解 Proxy-Wasm ABI 规范,同时开发人员可以依赖这个 SDK 的 Go API 来开发插件扩展 Enovy 功能。 + + +## 2 Wasm VM、插件和 Envoy 配置 + +Wasm 虚拟机(Wasm VM) 或简称 VM 指的是加载 Wasm 程序的实例。 在 Envoy 中,VM 通常在每个线程中创建并相互隔离。因此 Wasm 程序将复制到 Envoy 所创建的线程里,并在这些虚拟机上加载并执行。 +插件提供了一种灵活的方式来扩展和自定义 Envoy 的行为。Proxy-Wasm 规范允许在每个 VM 中配置多个插件。因此一个 VM 可以被多个插件共同使用。Envoy 中有三种类型插件: `Http Filter`、`Network Filter` 和 `Wasm Service`。 + +- `Http Filter` 是一种处理 Http 协议的插件,例如操作 Http 请求头、正文等。 +- `Network Filter` 是一种处理 Tcp 协议的插件,例如操作 Tcp 数据帧、连接建立等。 +- `Wasm Service` 是在单例 VM 中运行的插件类型(即在 Envoy 主线程中只有一个实例)。它主要用于执行与 `Network Filter` 或 `Http Filter` 并行的一些额外工作,如聚合指标、日志等。这样的单例 VM 本身也被称为 `Wasm Service`。 + +其架构如下图(图片来源 [Proxy-Wasm Go SDK](https://github.com/higress-group/proxy-wasm-go-sdk/blob/main/doc/OVERVIEW.md)): +![img](https://img.alicdn.com/imgextra/i4/O1CN018UJzEX1YlqnAmBV4u_!!6000000003100-0-tps-2321-1190.jpg) + + +### 2.1 Envoy 配置 + +所有类型插件的配置都包含 `vm_config` 用于配置 Wasm VM, 和 `configuration` 用于配置插件实例。 + +```yaml +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 | +| `vm_config.vm_id` | 用于跨 VM 通信的语义隔离。详情请参考 跨 VM 通信 部分。 | +| `vm_config.runtime` | 指定 Wasm 运行时类型。默认为 envoy.wasm.runtime.v8。 | +| `vm_config.configuration` | 用于设置 VM 的配置数据 | +| `vm_config.code` | Wasm 二进制文件的位置 | +| `configuration` | 对应于每个插件实例配置(在下面介绍的 PluginContext)。 | + +> 完全相同的 vm_config 配置的多个插件它们之间共享一个 Wasm VM,单个 Wasm VM 用于多个 Http Filter 或 Network Filter,可以提升内存/CPU 资源效率、降低启动延迟。 +> 完整的 Envoy API 配置可以 [参考 Envoy 文档](https://www.envoyproxy.io/docs/envoy/latest/api-v3/extensions/wasm/v3/wasm.proto#envoy-v3-api-msg-extensions-wasm-v3-pluginconfig)。 + +Envoy Wasm 运行时目前有以下几种选择: +- envoy.wasm.runtime.null:这表示一个空的沙盒(null sandbox)环境,Wasm 模块必须被编译并链接到 Envoy 的二进制文件中。这种方式适用于那些需要将 Wasm 模块与 Envoy 二进制文件一起分发的部署场景。 +- envoy.wasm.runtime.v8: 基于 V8 JavaScript 引擎的运行时。 +- envoy.wasm.runtime.wamr: WAMR (WebAssembly Micro Runtime) 运行时。 +- envoy.wasm.runtime.wasmtime: Wasmtime 运行时。 + +不同的运行时有各自的优缺点,比如 [V8](https://v8.dev/) 性能较好但容器体积较大,[WAMR](https://github.com/bytecodealliance/wasm-micro-runtime) 和 [wasmtime](https://wasmtime.dev/) 则相对轻量。 + +> [待补充?] envoy v8 runtime 如何加载 wasm 和 如何和 envoy 交互原理。 + +### 2.2 Http Filter 配置 + +Http Filter 插件配置设置为 `envoy.filter.http.wasm`,Http Filter 插件可以处理 HTTP 请求和响应。 其主要配置如下: +```yaml +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: + configuration: + "@type": type.googleapis.com/google.protobuf.StringValue + value: | + { + "header": "x-wasm-header", + "value": "demo-wasm" + } + vm_config: + runtime: "envoy.wasm.runtime.v8" + code: + local: + filename: "./examples/http_headers/main.wasm" + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router +``` + +这时 Envoy 会在每个工作线程中实例化一个 Wasm 虚拟机,该虚拟机将专门用于处理该线程上的 HTTP 请求和响应。每个虚拟机都会加载和执行 WebAssembly 代码,允许对 HTTP 流量进行自定义处理,如修改头信息、处理请求和响应体等。 +完整的配置可以参考 [envoy.yaml](https://github.com/higress-group/proxy-wasm-go-sdk/blob/main/examples/http_headers/envoy.yaml) 。 + + +### 2.3 Network Filter 配置 + +`Network Filter` 插件配置设置为 `envoy.filters.network.wasm`,`Network Filter` 插件可以处理 TCP 请求和响应。 其主要配置如下: + +```yaml +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 会在每个工作线程中实例化一个 Wasm 虚拟机,该虚拟机将专门用于处理该线程上的 TCP 请求和响应。每个虚拟机都会加载和执行 WebAssembly 代码,允许对 TCP 流量进行自定义处理等。 +完整的配置可以参考 [envoy.yaml](https://github.com/higress-group/proxy-wasm-go-sdk/blob/main/examples/network/envoy.yaml) 。 + + +### 2.4 Wasm Service 配置 + +`Wasm Service` 插件配置设置为 `envoy.bootstrap.wasm`。插件在 Envoy 启动时加载的,其主要配置如下: + +```yaml +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 时,生成虚拟机(VM)是单例,并且运行在 Envoy 的主线程上,因此它不会阻塞任何工作线程。 + +完整的配置可以参考 [envoy.yaml](https://github.com/higress-group/proxy-wasm-go-sdk/blob/main/examples/shared_queue/envoy.yaml) 。 + +### 2.5 每个线程中多个插件共享一个 VM + +每个线程中多个插件共享一个 VM,其主要配置如下: + +```yaml +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: # ... +``` + + + +在 `18000` 和 `18001` 监听器上的 Http 过滤器链以及 `18002` 上的网络过滤器链中,vm_config 字段都是相同的。在这种情况下,Envoy 中的多个插件将使用同一个 Wasm 虚拟机。 为了重用相同的 VM,所有的 vm_config.vm_id、vm_config.runtime、vm_config.configuration 和 vm_config.code 必须相同。 + +通过这种配置方式允许为不同的过滤器重用同一个 Wasm 虚拟机,通过为每个 `PluginContext` 提供了一个隔离的环境,使得插件能够独立运行,同时共享同一个虚拟机的执行环境,虚拟机只需要加载和初始化一次即可为多个插件服务,这不仅可以减少内存占用,还可以降低启动时的延迟。 + +完整的配置可以参考 [envoy.yaml](https://github.com/higress-group/proxy-wasm-go-sdk/blob/main/examples/shared_queue/envoy.yaml) 。 + +## 3 Proxy-Wasm Go SDK API + +上面介绍插件概念和插件配置,下面开始深入探讨 Proxy-Wasm Go SDK 的 API。 + +### 3.1 Contexts + +上下文(Contexts) 是 Proxy-Wasm Go SDK 中的接口集合,它们在 [types](https://github.com/higress-group/proxy-wasm-go-sdk/tree/main/proxywasm/types) 包中定义。 +有四种类型的上下文: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) │ +└────────────────────────────────────────────────────────────────┘ +``` + +1) VMContext 对应于每个 .vm_config.code,每个 VM 中只存在一个 VMContext。 +2) VMContext 是 PluginContexts 的父上下文,负责创建 PluginContext。 +3) PluginContext 对应于一个 Plugin 实例。一个 PluginContext 对应于 Http Filter、Network Filter、Wasm Service 的 configuration 字段配置。 +4) PluginContext 是 TcpContext 和 HttpContext 的父上下文,并且负责为 处理 Http 流的Http Filter 或 处理 Tcp 流的 Network Filter 创建上下文。 +5) TcpContext 负责处理每个 Tcp 流。 +6) HttpContext 负责处理每个 Http 流。 + +因此,自定义插件要实现 `VMContext` 和 `PluginContext`。 同时 `Http Filter` 或 `Network Filter`,要分别实现 `HttpContext` 或 `TcpContext`。 + +首先 VMContext 定义如下: + +```go +type VMContext interface { + // OnVMStart 在 VM 创建和调用 main 函数后被调用。 + // 在此调用期间,可以通过 GetVMConfiguration 获取在 vm_config.configuration 设置的配置。 + // 这主要用于执行 Wasm VM 范围内的初始化。 + OnVMStart(vmConfigurationSize int) OnVMStartStatus + + // NewPluginContext 用于为每个插件配置创建 PluginContext。 + NewPluginContext(contextID uint32) PluginContext +} +``` + +VMContext 负责通过 NewPluginContext 方法创建 PluginContext。同时在 VM 启动阶段调用 OnVMStart,并且可以通过 `GetVMConfiguration` hostcall API 获取 vm_config.configuration 的值。这样就可以进行 VM 范围内的插件初始化并控制 VMContext 的行为。 + +PluginContext,定义如下(省略了一些方法): +```go +type PluginContext interface { + // OnPluginStart 在所有插件上下文上调用(如果在这是 VM 上下文,则在 OnVmStart 之后)。 + // 在此调用期间,可以通过 GetPluginConfiguration 获取 envoy.yaml 中 config.configuration 设置的配置。 + OnPluginStart(pluginConfigurationSize int) OnPluginStartStatus + + // 以下函数用于在流上创建上下文, + // *必须* 实现它们中的任一个,对应于扩展点。例如,如果您配置此插件上下文在 Http 过滤器上运行,那么必须实现 NewHttpContext。 + // 对 Tcp 过滤器也是如此。 + // + // NewTcpContext 用于为每个 Tcp 流创建 TcpContext。 + NewTcpContext(contextID uint32) TcpContext + // NewHttpContext 用于为每个 Http 流创建 HttpContext。 + NewHttpContext(contextID uint32) HttpContext +} +``` + +`PluginContext` 有 `OnPluginStart` 方法,创建插件时调用,可以通过 GetPluginConfiguration hostcall API 获取 plugin config 中 configuration 字段的值。 +另外 `PluginContext` 有 `NewTcpContext` 和 `NewHttpContext` 方法,为每个 Http 或 Tcp 流创建上下文时调用。 关于 HttpContext 和 TcpContext 的详细定义请参考 [context.go](https://github.com/higress-group/proxy-wasm-go-sdk/blob/main/proxywasm/types/context.go) 。 + +### 3.2 Hostcall API + +Hostcall API 是指在 Wasm 模块内调用 Envoy 提供的功能。这些功能通常用于获取外部数据或与 Envoy 交互。在开发 Wasm 插件时,需要访问网络请求的元数据、修改请求或响应头、记录日志等,这些都可以通过 Hostcall API 来实现。 +Hostcall API 在 proxywasm 包的 [hostcall.go](https://github.com/higress-group/proxy-wasm-go-sdk/blob/main/proxywasm/hostcall.go) 中定义。 +Hostcall API 包括配置和初始化、定时器设置、上下文管理、插件完成、共享队列管理、Redis 操作、Http 调用、TCP 流操作、HTTP 请求/响应头和体操作、共享数据操作、日志操作、属性和元数据操作、指标操作。具体函数名称和描述如下: + +#### 1.配置和初始化 +| 函数名 | 描述 | +|--------------------------------|--------------------------------------------------------------| +| `GetVMConfiguration` | 获取在 `vm_config.configuration` 字段中指定的配置。此功能仅在 `types.PluginContext.OnVMStart` 调用期间可用。 | +| `GetPluginConfiguration` | 获取在 `config.configuration` 字段中指定的配置。此功能仅在 `types.PluginContext.OnPluginStart` 调用期间可用。 | + +#### 2.定时器设置 +| 函数名 | 描述 | +|--------------------------------|--------------------------------------------------------------| +| `SetTickPeriodMilliSeconds` | 设置 `types.PluginContext.OnTick` 调用的tick间隔。此功能仅对 `types.PluginContext` 有效。 | + +#### 3.上下文管理 +| 函数名 | 描述 | +|--------------------------------|--------------------------------------------------------------| +| `SetEffectiveContext` | 设置有效上下文为 `context_id`。通常用于在接收到 `types.PluginContext.OnQueueReady` 或 `types.PluginContext.OnTick` 后更改上下文。 | + +#### 4.插件完成 +| 函数名 | 描述 | +|--------------------------------|-----------------------------------------------------------------------------------| +| `PluginDone` | 当 `OnPluginDone` 返回 false,表示插件处于待定状态,在删除之前必须调用此函数。此功能仅对 `types.PluginContext` 有效。 | + +#### 5.共享队列管理 +| 函数名 | 描述 | +|--------------------------------|-------------------------------------------------------------| +| `RegisterSharedQueue` | 在此插件上下文中注册共享队列。 | +| `ResolveSharedQueue` | 获取给定 `vmID` 和 `queueName` 的队列ID。 | +| `EnqueueSharedQueue` | 将数据排队到给定队列ID的共享队列。 | +| `DequeueSharedQueue` | 从给定队列ID的共享队列中出队数据。 | + +#### 6.Redis 操作 +| 函数名 | 描述 | +|--------------------------------|--------------------------------------------------------------| +| `RedisInit` | 初始化Redis连接。 | +| `DispatchRedisCall` | 发送Redis调用。 | +| `GetRedisCallResponse` | 获取Redis调用响应。 | + +#### 7.HTTP 调用 +| 函数名 | 描述 | +|--------------------------------|--------------------------------------------------------------| +| `DispatchHttpCall` | 向远程集群分派HTTP调用。此功能可被所有上下文使用。 | +| `GetHttpCallResponseHeaders` | 用于检索由远程集群返回的HTTP响应头,此功能仅在传递给 `DispatchHttpCall` 的 "callback" 函数中可用。 | +| `GetHttpCallResponseBody` | 用于检索由远程集群返回的HTTP响应体,此功能仅在传递给 `DispatchHttpCall` 的 "callback" 函数中可用。 | +| `GetHttpCallResponseTrailers` | 用于检索由远程集群返回的HTTP响应尾随头,此功能仅在传递给 `DispatchHttpCall` 的 "callback" 函数中可用。 | + +#### 8.TCP 流操作 +| 函数名 | 描述 | +|--------------------------------|--------------------------------------------------------------| +| `GetDownstreamData` | 用于检索在宿主中缓冲的TCP下游数据。此功能仅在 `types.TcpContext.OnDownstreamData` 期间可用。 | +| `AppendDownstreamData` | 将给定字节追加到宿主中缓冲的TCP下游数据。此功能仅在 `types.TcpContext.OnDownstreamData` 期间可用。 | +| `PrependDownstreamData` | 将给定字节前缀到宿主中缓冲的TCP下游数据。此功能仅在 `types.TcpContext.OnDownstreamData` 期间可用。 | +| `ReplaceDownstreamData` | 用给定字节替换宿主中缓冲的TCP下游数据。此功能仅在 `types.TcpContext.OnDownstreamData` 期间可用。 | +| `GetUpstreamData` | 用于检索在宿主中缓冲的TCP上游数据。此功能仅在 `types.TcpContext.OnUpstreamData` 期间可用。 | +| `AppendUpstreamData` | 将给定字节追加到宿主中缓冲的TCP上游数据。此功能仅在 `types.TcpContext.OnUpstreamData` 期间可用。 | +| `PrependUpstreamData` | 将给定字节前缀到宿主中缓冲的TCP上游数据。此功能仅在 `types.TcpContext.OnUpstreamData` 期间可用。 | +| `ReplaceUpstreamData` | 用给定字节替换宿主中缓冲的TCP上游数据。此功能仅在 `types.TcpContext.OnUpstreamData` 期间可用。 | +| `ContinueTcpStream` | 在返回 `types.Action.Pause` 后,继续TCP连接的处理。此功能仅对 `types.TcpContext` 有效。 | +| `CloseDownstream` | 关闭Tcp上下文中的下游TCP连接。此功能仅对 `types.TcpContext` 有效。 | +| `CloseUpstream` | 关闭Tcp上下文中的上游TCP连接。此功能仅对 `types.TcpContext` 有效。 | + +#### 9.HTTP 请求/响应头和体操作 +| 函数名 | 描述 | +|-------------------------------|--------------------------------------------------------------| +| `GetHttpRequestHeaders` | 获取HTTP请求头。此功能仅在 `types.HttpContext.OnHttpRequestHeaders` 和 `types.HttpContext.OnHttpStreamDone` 期间可用。 | +| `ReplaceHttpRequestHeaders` | 用给定的头替换HTTP请求头。此功能仅在 `types.HttpContext.OnHttpRequestHeaders` 期间可用。 | +| `GetHttpRequestHeader` | 获取给定 "key" 的HTTP请求头的值。此功能仅在 `types.HttpContext.OnHttpRequestHeaders` 和 `types.HttpContext.OnHttpStreamDone` 期间可用。 | +| `RemoveHttpRequestHeader` | 移除请求头中给定 "key" 的所有值。此功能仅在 `types.HttpContext.OnHttpRequestHeaders` 期间可用。 | +| `ReplaceHttpRequestHeader` | 替换请求头中给定 "key" 的值。此功能仅在 `types.HttpContext.OnHttpRequestHeaders` 期间可用。 | +| `AddHttpRequestHeader` | 向请求头添加给定 "key" 的值。此功能仅在 `types.HttpContext.OnHttpRequestHeaders` 期间可用。 | +| `GetHttpRequestBody` | 获取整个HTTP请求体。此功能仅在 `types.HttpContext.OnHttpRequestBody` 期间可用。 | +| `AppendHttpRequestBody` | 向HTTP请求体缓冲区追加给定字节。此功能仅在 `types.HttpContext.OnHttpRequestBody` 期间可用。 | +| `PrependHttpRequestBody` | 向HTTP请求体缓冲区前缀给定字节。此功能仅在 `types.HttpContext.OnHttpRequestBody` 期间可用。 | +| `ReplaceHttpRequestBody` | 用给定字节替换HTTP请求体缓冲区。此功能仅在 `types.HttpContext.OnHttpRequestBody` 期间可用。 | +| `GetHttpRequestTrailers` | 获取HTTP请求尾随头。此功能仅在 types.HttpContext.OnHttpRequestTrailers 和 types.HttpContext.OnHttpStreamDone 期间可用。 | +| `ReplaceHttpRequestTrailers` | 用给定的尾随头替换HTTP请求尾随头。此功能仅在 types.HttpContext.OnHttpRequestTrailers 期间可用。 | +| `GetHttpRequestTrailer` | 获取给定 "key" 的HTTP请求尾随头的值。此功能仅在 types.HttpContext.OnHttpRequestTrailers 和 types.HttpContext.OnHttpStreamDone 期间可用。 | +| `RemoveHttpRequestTrailer` | 移除请求尾随头中给定 "key" 的所有值。此功能仅在 types.HttpContext.OnHttpRequestTrailers 期间可用。 | +| `ReplaceHttpRequestTrailer` | 替换请求尾随头中给定 "key" 的值。此功能仅在 types.HttpContext.OnHttpRequestTrailers 期间可用。 | +| `AddHttpRequestTrailer` | 向请求尾随头添加给定 "key" 的值。此功能仅在 types.HttpContext.OnHttpRequestTrailers 期间可用。 | +| `ResumeHttpRequest` | 继续停止的HTTP请求处理。此功能仅在 types.HttpContext 期间可用。 | +| `GetHttpResponseHeaders` | 获取HTTP响应头。此功能仅在 types.HttpContext.OnHttpResponseHeaders 和 types.HttpContext.OnHttpStreamDone 期间可用。 | +| `ReplaceHttpResponseHeaders` | 用给定的头替换HTTP响应头。此功能仅在 types.HttpContext.OnHttpResponseHeaders 期间可用。 | +| `GetHttpResponseHeader ` | 获取给定 "key" 的HTTP响应头的值。此功能仅在 types.HttpContext.OnHttpResponseHeaders 和 types.HttpContext.OnHttpStreamDone 期间可用。 | +| `RemoveHttpResponseHeader` | 移除响应头中给定 "key" 的所有值。此功能仅在 types.HttpContext.OnHttpResponseHeaders 期间可用。 | +| `ReplaceHttpResponseHeader` | 替换响应头中给定 "key" 的值。此功能仅在 types.HttpContext.OnHttpResponseHeaders 期间可用。 | +| `AddHttpResponseHeader` | 向响应头添加给定 "key" 的值。此功能仅在 types.HttpContext.OnHttpResponseHeaders 期间可用。 | +| `GetHttpResponseBody` | 获取整个HTTP响应体。此功能仅在 types.HttpContext.OnHttpResponseBody 期间可用。 | +| `AppendHttpResponseBody` | 向HTTP响应体缓冲区追加给定字节。此功能仅在 types.HttpContext.OnHttpResponseBody 期间可用。 | +| `PrependHttpResponseBody` | 向HTTP响应体缓冲区前缀给定字节。此功能仅在 types.HttpContext.OnHttpResponseBody 期间可用。 | +| `ReplaceHttpResponseBody` | 用给定字节替换HTTP响应体缓冲区。此功能仅在 types.HttpContext.OnHttpResponseBody 期间可用。 | +| `GetHttpResponseTrailers` | 获取HTTP响应尾随头。此功能仅在 types.HttpContext.OnHttpResponseTrailers 和 types.HttpContext.OnHttpStreamDone 期间可用。 | +| `ReplaceHttpResponseTrailers` | 用给定的尾随头替换HTTP响应尾随头。此功能仅在 types.HttpContext.OnHttpResponseTrailers 期间可用。 | +| `GetHttpResponseTrailer` | 获取给定 "key" 的HTTP响应尾随头的值。此功能仅在 types.HttpContext.OnHttpResponseTrailers 和 types.HttpContext.OnHttpStreamDone 期间可用。 | +| `RemoveHttpResponseTrailer` | 移除响应尾随头中给定 "key" 的所有值。此功能仅在 types.HttpContext.OnHttpResponseTrailers 期间可用。 | +| `ReplaceHttpResponseTrailer` | 替换响应尾随头中给定 "key" 的值。此功能仅在 types.HttpContext.OnHttpResponseHeaders 期间可用。 | +| `AddHttpResponseTrailer` | 向响应尾随头添加给定 "key" 的值。此功能仅在 types.HttpContext.OnHttpResponseHeaders 期间可用。 | +| `ResumeHttpResponse` | 继续停止的HTTP响应处理。此功能仅在 types.HttpContext 期间可用。 | +| `SendHttpResponse` | 向下游发送HTTP响应。调用此函数后,您必须返回 types.Action.Pause 以停止初始HTTP请求/响应的进一步处理。 | + + +#### 10.共享数据操作 +| 函数名 | 描述 | +|--------------------------------|---------| +| `GetSharedData` | 获取共享数据。 | +| `SetSharedData` | 设置共享数据。 | + +#### 11.日志操作 +| 函数名 | 描述 | +|--------------------------------|--------------------------------------------------------------| +| `LogTrace` | 以 Trace 日志级别发出消息。 | +| `LogTracef` | 根据格式说明符格式化并发出 Trace 日志级别的日志。 | +| `LogDebug` | 以 Debug 日志级别发出消息。 | +| `LogDebugf` | 根据格式说明符格式化并发出 Debug 日志级别的日志。 | +| `LogInfo` | 以 Info 日志级别发出消息。 | +| `LogInfof` | 根据格式说明符格式化并发出 Info 日志级别的日志。 | +| `LogWarn` | 以 Warn 日志级别发出消息。 | +| `LogWarnf` | 根据格式说明符格式化并发出 Warn 日志级别的日志。 | +| `LogError` | 以 Error 日志级别发出消息。 | +| `LogErrorf` | 根据格式说明符格式化并发出 Error 日志级别的日志。 | +| `LogCritical` | 以 Critical 日志级别发出消息。 | +| `LogCriticalf` | 根据格式说明符格式化并发出 Critical 日志级别的日志。 | + +#### 12.指标操作 +| 函数名 | 描述 | +|--------------------------------|--------------------------------------------------------------| +| `DefineCounterMetric` | 为名称定义一个计数器指标。返回一个 `MetricCounter` 用于后续操作。 | +| `DefineGaugeMetric` | 为名称定义一个计量器指标。返回一个 `MetricGauge` 用于后续操作。 | +| `DefineHistogramMetric` | 为名称定义一个直方图指标。返回一个 `MetricHistogram` 用于后续操作。 | +| `MetricCounter.Value` | 获取 `MetricCounter` 的当前值。 | +| `MetricCounter.Increment` | 将 `MetricCounter` 的当前值增加指定的偏移量。 | +| `MetricGauge.Value` | 获取 `MetricGauge` 的当前值。 | +| `MetricGauge.Add` | 将 `MetricGauge` 的当前值增加指定的偏移量。 | +| `MetricHistogram.Value` | 获取 `MetricHistogram` 的当前值。 | +| `MetricHistogram.Record` | 为 `MetricHistogram` 记录一个值。 | + + + +### 3.3 插件调用入口 Entrypoint + +当 Envoy 创建 VM 时,在虚拟机内部创建 `VMContext` 之前,它会在启动阶段调用插件程序的 `main` 函数。所以必须在 `main` 函数中传递插件自定义的 `VMContext` 实现。 +[proxywasm](https://github.com/higress-group/proxy-wasm-go-sdk/blob/main/proxywasm/) 包的 `SetVMContext` 函数是入口点。`main` 函数如下: + +```go +func main() { + proxywasm.SetVMContext(&myVMContext{}) +} + +type myVMContext struct { .... } + +var _ types.VMContext = &myVMContext{} + +// Implementations follow... +``` + +## 4 跨虚拟机通信 + +Envoy 中的跨虚拟机通信(Cross-VM communications)允许在不同线程中运行 的Wasm 虚拟机(VMs)之间进行数据交换和通信。这在需要在多个VMs之间聚合数据、统计信息或缓存数据等场景中非常有用。 +跨虚拟机通信主要有两种方式: + +- 共享数据(Shared Data): + - 共享数据是一种在所有 VMs 之间共享的键值存储,可以用于存储和检索简单的数据项。 + - 它适用于存储小的、不经常变化的数据,例如配置参数或统计信息。 +- 共享队列(Shared Queue): + - 共享队列允许VMs之间进行更复杂的数据交换,支持发送和接收更丰富的数据结构。 + - 队列可以用于实现任务调度、异步消息传递等模式。 + +### 4.1 共享数据(Shared Data) + +如果想要在所有 Wasm 虚拟机(VMs)运行的多个工作线程间拥有全局请求计数器,或者想要缓存一些应被所有 Wasm VMs 使用的数据,那么共享数据(Shared Data)或等效的共享键值存储(Shared KVS)就会发挥作用。 +共享数据本质上是一个跨所有VMs共享的键值存储(即跨 VM 或跨线程)。 + +共享数据 KVS 是根据 vm_config 中指定的创建的。可以在所有 Wasm VMs 之间共享一个键值存储,而它们不必具有相同的二进制文件 `vm_config.code`,唯一的要求是具有相同的 vm_id。 + +![img](https://img.alicdn.com/imgextra/i2/O1CN01fLn4Lr1GXxhKORL9t_!!6000000000633-0-tps-1784-1266.jpg) + +在上图(图片来源 [Proxy-Wasm Go SDK](https://github.com/higress-group/proxy-wasm-go-sdk/blob/main/doc/OVERVIEW.md))中,可以看到即使它们具有不同的二进制文件( hello.wasm 和 bye.wasm ),"vm_id=foo"的 VMs 也共享相同的共享数据存储。 +hostcall.go 中定义共享数据相关的 API如下: +```go +// GetSharedData 用于检索给定 "key" 的值。 +// 返回的 "cas" 应用于 SetSharedData 以实现该键的线程安全更新。 +func GetSharedData(key string) (value []byte, cas uint32, err error) + +// SetSharedData 用于在共享数据存储中设置键值对。 +// 共享数据存储按主机中的 "vm_config.vm_id" 定义。 +// +// 当给定的 CAS 值与当前值不匹配时,将返回 ErrorStatusCasMismatch。 +// 这表明其他 Wasm VM 已经成功设置相同键的值,并且该键的当前 CAS 已递增。 +// 建议在遇到此错误时实现重试逻辑。 +// +// 将 cas 设置为 0 将永远不会返回 ErrorStatusCasMismatch 并且总是成功的, +// 但这并不是线程安全的,即可能在您调用此函数时另一个 VM 已经设置了该值, +// 看到的值与存储时的值已经不同。 +func SetSharedData(key string, value []byte, cas uint32) error +``` + +共享数据 API 是其线程安全性和跨 VM 安全性,这通过 "cas" ([Compare-And-Swap](https://en.wikipedia.org/wiki/Compare-and-swap))值来实现。如何使用 GetSharedData 和 SetSharedData 函数可以参考 [示例](https://github.com/higress-group/proxy-wasm-go-sdk/blob/main/examples/shared_data/main.go)。 + + +### 4.2 共享队列 Shared Queue + +如果要在请求/响应处理的同时跨所有 Wasm VMs 聚合指标,或者将一些跨 VM 聚合的信息推送到远程服务器,可以通过 *Shared Queue* 来实现。 + +*Shared Queue* 是为 `vm_id` 和队列名称的组合创建的 FIFO(先进先出)队列。并为该组合(`vm_id`,名称)分配了一个唯一的 *queue id*,该 ID 用于入队/出队操作。 + +“入队”和“出队”等操作具有线程安全性和跨 VM 安全性。在 hostcall.go 中与 *Shared Queue* 相关 API 如下: + +```go +// DequeueSharedQueue 从给定 queueID 的共享队列中出队数据。 +// 要获取目标队列的 queue id,请先使用 "ResolveSharedQueue"。 +func DequeueSharedQueue(queueID uint32) ([]byte, error) + +// RegisterSharedQueue 在此插件上下文中注册共享队列。 +// "注册" 意味着每当该 queueID 上有新数据入队时,将对此插件上下文调用 OnQueueReady。 +// 仅适用于 types.PluginContext。返回的 queueID 可用于 Enqueue/DequeueSharedQueue。 +// 请注意 "name" 必须在所有共享相同 "vm_id" 的 Wasm VMs 中是唯一的。使用 "vm_id" 来分隔共享队列的命名空间。 +// +// 只有在调用 RegisterSharedQueue 之后,ResolveSharedQueue("此 vm_id", "名称") 才能成功 +// 通过其他 VMs 检索 queueID。 +func RegisterSharedQueue(name string) (queueID uint32, err error) + +// EnqueueSharedQueue 将数据入队到给定 queueID 的共享队列。 +// 要获取目标队列的 queue id,请先使用 "ResolveSharedQueue"。 +func EnqueueSharedQueue(queueID uint32, data []byte) error + +// ResolveSharedQueue 获取给定 vmID 和队列名称的 queueID。 +// 返回的 queueID 可用于 Enqueue/DequeueSharedQueue。 +func ResolveSharedQueue(vmID, queueName string) (queueID uint32, err error) +``` + +`RegisterSharedQueue` 和 `DequeueSharedQueue` 由队列的“消费者”使用,而 `ResolveSharedQueue` 和 `EnqueueSharedQueue` 是为队列“生产者”准备的。请注意: + +- RegisterSharedQueue 用于为调用者的 name 和 vm_id 创建共享队列。使用一个队列,那么必须先由一个 VM 调用这个函数。这可以由 PluginContext 调用,因此可以认为“消费者” = PluginContexts。 +- ResolveSharedQueue 用于获取 name 和 vm_id 的 queue id。这是为“生产者”准备的。 + +这两个调用都返回一个队列 ID,该 ID 用于 DequeueSharedQueue 和 EnqueueSharedQueue。同时当队列中入队新数据时 消费者 PluginContext 中有 OnQueueReady(queueID uint32) 接口会收到通知。 +还强烈建议由 Envoy 的主线程上的单例 Wasm Service 创建共享队列。否则 OnQueueReady 将在工作线程上调用,这会阻塞它们处理 Http 或 Tcp 流。 + +![img](https://img.alicdn.com/imgextra/i1/O1CN01s1cT1s28xb7OKkEg0_!!6000000007999-0-tps-2378-1316.jpg) +在上图(图片来源 [Proxy-Wasm Go SDK](https://github.com/higress-group/proxy-wasm-go-sdk/blob/main/doc/OVERVIEW.md))中展示共享队列工作原理,更详细如何使用共享队列可以参考 [示例](https://github.com/higress-group/proxy-wasm-go-sdk/blob/main/examples/shared_queue/main.go)。 + + +## 5 限制和注意事项 + +以下是在使用 Proxy-Wasm Go SDK 和 Proxy-Wasm 编写插件时需要注意事项。 + +### 5.1 一些标准库不可用 + +一些现有的标准库不可用(可导入但运行时 panic / 无法导入)。这有几个原因: +1. TinyGo 的 WASI 目标不支持某些系统调用。 +2. TinyGo 没有实现 reflect 包的全部功能。 +3. [Proxy-Wasm C++ 主机](https://github.com/proxy-wasm/proxy-wasm-cpp-host) 尚未支持某些 WASI API。 +4. TinyGo 或 Proxy-Wasm 中不支持一些语言特性:包括 `recover` 和 `goroutine`。 + +随着 TinyGo 和 Proxy-Wasm 的发展,这些问题将得到缓解。 + +### 5.2 由于垃圾回收导致的性能开销 + +由于 GC,使用 Go/TinyGo 会带来性能开销,尽管可以认为与代理中的其他操作相比,GC 的开销足够小。 +TinyGo 允许禁用 GC,但由于内部需要使用映射(隐式引起分配)来保存虚拟机的状态,可以通过 `alloc(uintptr)` [接口](https://github.com/tinygo-org/tinygo/blob/v0.14.1/src/runtime/gc_none.go#L13) 使用 `-gc=custom` 选项设置 proxy-wasm 定制的 GC 算法。 + +### 5.3 `recover` 未实现 + +在 TinyGo 中,`recover` 未实现(https://github.com/tinygo-org/tinygo/issues/891)。这也意味着依赖 `recover` 的代码将无法按预期工作。 + +### 5.4 Goroutine 不支持 + +在 TinyGo 中,Goroutine 通过 LLVM 的协程实现(见[这篇博客文章](https://aykevl.nl/2019/02/tinygo-goroutines))。 在 Envoy 中,Wasm 模块以事件驱动的方式运行,因此一旦主函数退出,“调度器”就不再执行。因此不能像普通主机环境中那样使用 Goroutine 。 +在以事件驱动方式执行的 Wasm VM 线程中如何处理 Goroutine 的问题尚未有解决方案。建议使用实现 `OnTick` 函数来处理任何异步任务。 + +## 6 插件开发样例 + +用 Proxy-Wasm Go SDK 实现一个简单的插件,具体样例如下: + +```golang +package main + +import ( + "github.com/higress-group/proxy-wasm-go-sdk/proxywasm" + "github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types" +) + +// 插件入口 +func main() { + proxywasm.SetVMContext(&vmContext{}) +} + +// VM 上下文 +type vmContext struct { + // Embed the default VM context here, + types.DefaultVMContext + // 这里添加 VM 配置 +} + +// VM 启动回调 +func (*vmContext) OnVMStart(vmConfigurationSize int) types.OnVMStartStatus { + proxywasm.LogInfof("OnVMStart()") + // 获取 VM 配置 + _, err := proxywasm.GetVMConfiguration() + if err != nil { + proxywasm.LogCriticalf("error reading vm configuration: %v", err) + } + // 这里解析 VM 配置 + return types.OnVMStartStatusOK +} + +// 生成插件上下文 +func (*vmContext) NewPluginContext(contextID uint32) types.PluginContext { + proxywasm.LogInfof("NewPluginContex()") + return &pluginContext{} +} + +// 插件上下文 +type pluginContext struct { + // Embed the default plugin context here, + types.DefaultPluginContext + // 这里添加插件配置 +} + +// Http 上下文 +type httpContext struct { + // Embed the default root http context here, + // so that we don't need to reimplement all the methods. + types.DefaultHttpContext + // 这里添加http 上下文属性 + requestBodySize int + responseBodySize int +} + +// 生成 Http 上下文 +func (ctx *pluginContext) NewHttpContext(contextID uint32) types.HttpContext { + proxywasm.LogInfof("NewHttpContext()") + return &httpContext{} +} + +// 插件启动回调, +func (ctx *pluginContext) OnPluginStart(pluginConfigurationSize int) types.OnPluginStartStatus { + proxywasm.LogInfof("OnPluginStart()") + // 获取插件配置 + _, err := proxywasm.GetPluginConfiguration() + if err != nil { + proxywasm.LogCriticalf("error reading plugin configuration: %v", err) + } + // 这里解析插件配置 + + return types.OnPluginStartStatusOK +} + +// http 请求头回调 +func (ctx *httpContext) OnHttpRequestHeaders(numHeaders int, endOfStream bool) types.Action { + proxywasm.LogInfof("OnHttpRequestHeaders()") + // 这里处理请求头回调 + return types.ActionContinue +} + +// http 请求体回调,注意这里流式处理 +func (ctx *httpContext) OnHttpRequestBody(bodySize int, endOfStream bool) types.Action { + proxywasm.LogInfof("OnHttpRequestBody()") + ctx.requestBodySize += bodySize + if !endOfStream { + // Wait until we see the entire body to replace. + return types.ActionPause + } + _, err := proxywasm.GetHttpRequestBody(0, ctx.requestBodySize) + if err != nil { + proxywasm.LogErrorf("failed to get request body: %v", err) + return types.ActionContinue + } + + return types.ActionContinue +} + +// http 响应头回调 +func (ctx *httpContext) OnHttpResponseHeaders(numHeaders int, endOfStream bool) types.Action { + proxywasm.LogInfof("OnHttpResponseHeaders()") + // 这里响应头回调 + return types.ActionContinue +} + +// http 响应体回调, 注意这里流式处理 +func (ctx *httpContext) OnHttpResponseBody(bodySize int, endOfStream bool) types.Action { + proxywasm.LogInfof("OnHttpResponseBody()") + ctx.responseBodySize += bodySize + // 判断是否响应体结束 + if !endOfStream { + // Wait until we see the entire body to replace. + return types.ActionPause + } + _, err := proxywasm.GetHttpResponseBody(0, ctx.responseBodySize) + if err != nil { + proxywasm.LogErrorf("failed to get response body: %v", err) + return types.ActionContinue + } + return types.ActionContinue +} +``` +核心步骤如下: +- 入口注册 vmContext +- VM 启动回调时候解析 VM 配置 +- 由 vmContext 生成 pluginContext +- 插件启动回调时候解析插件配置 +- 对于每个 http 流,pluginContext 生成 httpContext +- 生成的 httpContext 处理请求头、请求体、响应头、响应体,这里要注意的是处理 OnHttpRequestBody 和 OnHttpResponseBody 回调是流式处理 + +可以通过 [开发样例](https://github.com/higress-group/proxy-wasm-go-sdk/tree/main/examples) 查看更多 Proxy-Wasm Go SDK 插件开发样例。 + +## 参考 +- [1] [proxy-wasm-go-sdk doc](https://github.com/higress-group/proxy-wasm-go-sdk/blob/main/doc/OVERVIEW.md) +- [2] [proxy-wasm-go-sdk example](https://github.com/higress-group/proxy-wasm-go-sdk/tree/main/examples) +- [3] [WebAssembly 解释器实现篇](https://github.com/mcuking/blog/issues/96/) +- [4] [理解 WebAssembly 文本格式](https://developer.mozilla.org/zh-CN/docs/WebAssembly/Understanding_the_text_format) +- [5] [Wasm Module Binary Format](https://webassembly.github.io/spec/core/binary/modules.html) +- [6] [WebAssembly 究竟是什么?](https://www.bilibili.com/video/BV1WK42117dW) +- [7] [WebAssembly 在 MOSN 中的实践 - 基础框架篇](https://mosn.io/blog/posts/mosn-wasm-framework/) \ No newline at end of file diff --git a/src/content/docs/ebook/zh-cn/wasm16.md b/src/content/docs/ebook/zh-cn/wasm16.md new file mode 100644 index 0000000000..0e154e326f --- /dev/null +++ b/src/content/docs/ebook/zh-cn/wasm16.md @@ -0,0 +1,651 @@ +--- +title: Higress 插件 Go SDK 与处理流程 +keywords: [Higress] +--- + +# Higress 插件 Go SDK 与处理流程 + +本章开始介绍详细 Higress 插件开发 SDK 、插件开发流程和插件开发注意事项。 + +## 1 Higress 插件 Go SDK + +Higress 插件 Go SDK 在 proxy-wasm-go-sdk 上封装了一层,简化插件开发和增强功能。其代码位置:https://github.com/alibaba/higress/tree/main/plugins/wasm-go/pkg ,代码文件结构如下: + +```shell +tree +. +├── matcher +│   ├── rule_matcher.go +│   ├── rule_matcher_test.go +│   └── utils.go +└── wrapper + ├── cluster_wrapper.go + ├── cluster_wrapper_test.go + ├── http_wrapper.go + ├── log_wrapper.go + ├── plugin_wrapper.go + ├── redis_wrapper.go + └── request_wrapper.go + └── response_wrapper.go +``` +Higress 插件 Go SDK 主要增强功能如下: +- matcher 包提供全局、路由、域名级别配置的解析功能。 +- wrapper 包下 log_wrapper.go 封装和简化插件日志的输出功能。 +- wrapper 包下 cluster_wrapper.go、redis_wrapper.go、http_wrapper.go 封装 Http 和 Redis Host Function Call。 +- wrapper 包下 plugin_wrapper.go 封装 proxy-wasm-go-sdk 的 VMContext、PluginContext、HttpContext、插件配置解析功能。 +- wrapper 包下 request_wrapper.go、response_wrapper.go 提供关于请求和响应公共方法。 + +本章主要集中介绍 plugin_wrapper.go 提供 VMContext、PluginContext、HttpContext、插件配置解析功能。 + +## 2 Higress 插件 Go SDK 开发 + +相对应于 proxy-wasm-go-sdk 中的 VMContext、PluginContext、HttpContext 3 个上下文, 在 Higress 插件 Go SDK 中是 CommonVmCtx、CommonPluginCtx、CommonHttpCtx 3 个支持泛型的 struct。 3 个 struct 的核心内容如下: + +```golang +type CommonVmCtx[PluginConfig any] struct { + // proxy-wasm-go-sdk DefaultVMContext 默认实现 + types.DefaultVMContext + // 插件名称 + pluginName string + // 插件日志工具 + log Log + hasCustomConfig bool + // 插件配置解析函数 + parseConfig ParseConfigFunc[PluginConfig] + // 插件路由、域名、服务级别配置解析函数 + parseRuleConfig ParseRuleConfigFunc[PluginConfig] + // 以下是自定义插件回调钩子函数 + onHttpRequestHeaders onHttpHeadersFunc[PluginConfig] + onHttpRequestBody onHttpBodyFunc[PluginConfig] + onHttpStreamingRequestBody onHttpStreamingBodyFunc[PluginConfig] + onHttpResponseHeaders onHttpHeadersFunc[PluginConfig] + onHttpResponseBody onHttpBodyFunc[PluginConfig] + onHttpStreamingResponseBody onHttpStreamingBodyFunc[PluginConfig] + onHttpStreamDone onHttpStreamDoneFunc[PluginConfig] +} + +type CommonPluginCtx[PluginConfig any] struct { + // proxy-wasm-go-sdk DefaultPluginContext 默认实现 + types.DefaultPluginContext + // 解析后保存路由、域名、服务级别配置和全局插件配置 + matcher.RuleMatcher[PluginConfig] + // 引用 CommonVmCtx + vm *CommonVmCtx[PluginConfig] + // tickFunc 数组 + onTickFuncs []TickFuncEntry +} + +type CommonHttpCtx[PluginConfig any] struct { + // proxy-wasm-go-sdk DefaultHttpContext 默认实现 + types.DefaultHttpContext + // 引用 CommonPluginCtx + plugin *CommonPluginCtx[PluginConfig] + // 当前 Http 上下文下匹配插件配置,可能是路由、域名、服务级别配置或者全局配置 + config *PluginConfig + // 是否处理请求体 + needRequestBody bool + // 是否处理响应体 + needResponseBody bool + // 是否处理流式请求体 + streamingRequestBody bool + // 是否处理流式响应体 + streamingResponseBody bool + // 非流式处理缓存请求体大小 + requestBodySize int + // 非流式处理缓存响应体大小 + responseBodySize int + // Http 上下文 ID + contextID uint32 + // 自定义插件设置自定义插件上下文 + userContext map[string]interface{} +} +``` + +它们的关系如下图: +![img](https://img.alicdn.com/imgextra/i3/O1CN01nkPR171qsrwfUK0WW_!!6000000005552-2-tps-1640-600.png) + +### 2.1 启动入口和 VM 上下文(CommonVmCtx) + +```golang +func main() { + wrapper.SetCtx( + // 插件名称 + "hello-world", + // 设置自定义函数解析插件配置,这个方法适合插件全局配置和路由、域名、服务级别配置内容规则是一样 + wrapper.ParseConfigBy(parseConfig), + // 设置自定义函数解析插件全局配置和路由、域名、服务级别配置,这个方法适合插件全局配置和路由、域名、服务级别配置内容规则不一样 + wrapper.ParseOverrideConfigBy(parseConfig, parseRuleConfig) + // 设置自定义函数处理请求头 + wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders), + // 设置自定义函数处理请求体 + wrapper.ProcessRequestBodyBy(onHttpRequestBody), + // 设置自定义函数处理响应头 + wrapper.ProcessResponseHeadersBy(onHttpResponseHeaders), + // 设置自定义函数处理响应体 + wrapper.ProcessResponseBodyBy(onHttpResponseBody), + // 设置自定义函数处理流式请求体 + wrapper.ProcessStreamingRequestBodyBy(onHttpStreamingRequestBody), + // 设置自定义函数处理流式响应体 + wrapper.ProcessStreamingResponseBodyBy(onHttpStreamingResponseBody), + // 设置自定义函数处理流式请求完成 + wrappper.ProcessStreamDoneBy(onHttpStreamDone) + ) +} +``` +> 可以根据实际业务需要来选择设置回调钩子函数。 + +跟踪一下 wrapper.SetCtx 的实现: +- 创建 CommonVmCtx 对象同时设置自定义插件回调钩子函数。 +- 然后再调用 proxywasm.SetVMContext 设置 VMContext。 + +```golang +func SetCtx[PluginConfig any](pluginName string, setFuncs ...SetPluginFunc[PluginConfig]) { + // 调用 proxywasm.SetVMContext 设置 VMContext + proxywasm.SetVMContext(NewCommonVmCtx(pluginName, setFuncs...)) +} + +func NewCommonVmCtx[PluginConfig any](pluginName string, setFuncs ...SetPluginFunc[PluginConfig]) *CommonVmCtx[PluginConfig] { + ctx := &CommonVmCtx[PluginConfig]{ + pluginName: pluginName, + log: Log{pluginName}, + hasCustomConfig: true, + } + // CommonVmCtx 里设置自定义插件回调钩子函数 + for _, set := range setFuncs { + set(ctx) + } + ... + return ctx +``` + +### 2.2 插件上下文(CommonPluginCtx) + +#### 2.2.1 创建 CommonPluginCtx 对象 +通过 CommonVmCtx 的 NewPluginContext 方法创建 CommonPluginCtx 对象, 设置 CommonPluginCtx 的 vm 引用。 +```golang +func (ctx *CommonVmCtx[PluginConfig]) NewPluginContext(uint32) types.PluginContext { + return &CommonPluginCtx[PluginConfig]{ + vm: ctx, + } +} +``` + +#### 2.2.2 插件启动和插件配置解析 + +CommonPluginCtx 的 OnPluginStart 部分核心代码如下: +```golang +func (ctx *CommonPluginCtx[PluginConfig]) OnPluginStart(int) types.OnPluginStartStatus { + // 调用 proxywasm.GetPluginConfiguration 获取插件配置 + data, err := proxywasm.GetPluginConfiguration() + globalOnTickFuncs = nil + ... + var jsonData gjson.Result + // 插件配置转成 json + jsonData = gjson.ParseBytes(data) + + // 设置 parseOverrideConfig + var parseOverrideConfig func(gjson.Result, PluginConfig, *PluginConfig) error + if ctx.vm.parseRuleConfig != nil { + parseOverrideConfig = func(js gjson.Result, global PluginConfig, cfg *PluginConfig) error { + // 解析插件路由、域名、服务级别插件配置 + return ctx.vm.parseRuleConfig(js, global, cfg, ctx.vm.log) + } + } + ... + // 解析插件配置 + err = ctx.ParseRuleConfig(jsonData, + func(js gjson.Result, cfg *PluginConfig) error { + // 解析插件全局或者当 parseRuleConfig 没有设置时候同时解析路由、域名、服务级别插件配置 + return ctx.vm.parseConfig(js, cfg, ctx.vm.log) + }, + parseOverrideConfig, + ) + ... + if globalOnTickFuncs != nil { + ctx.onTickFuncs = globalOnTickFuncs + ... + } + return types.OnPluginStartStatusOK +} +``` + +可以发现在解析插件配置过程中有两个回调钩子函数,parseConfig 和 parseRuleConfig。 +- parseConfig :解析插件全局配置,如果 parseRuleConfig 没有设置,那么 parseConfig 会同时解析全局配置和路由、域名、服务级别配置。也就是说插件全局配置和路由、域名、服务级别配置规则是一样。 +- parseRuleConfig: 解析路由、域名、服务级别插件配置。如果设置 parseRuleConfig,也就是说插件全局配置和路由、域名、服务级别配置规则是不同的。 + + +> 这里我们不进一步分析插件解析过程,后续在插件生效原理章节从控制面和数据面详细分析插件全局、路由、域名、服务级别生效原理。 + +大部分情况下插件全局配置和路由、域名、服务级别配置规则是一样的,因此在定义插件时只需要调用 wrapper.ParseConfigBy(parseConfig) 来设置插件配置解析回调钩子函数。 +而有些插件(如 [basic-auth](https://higress.io/docs/latest/plugins/authentication/basic-auth/))的全局配置和路由、域名、服务级别配置规则是不一样的。baisc-auth 插件配置 YAML 样例如下: +```yaml +apiVersion: extensions.higress.io/v1alpha1 +kind: WasmPlugin +metadata: + name: cpp-basic-auth + namespace: higress-system +spec: + defaultConfig: + consumers: + - credential: admin:123456 + name: consumer1 + - credential: guest:abc + name: consumer2 + global_auth: false + defaultConfigDisable: false + matchRules: + - config: + allow: + - consumer1 + configDisable: false + ingress: + - higress-conformance-infra/wasmplugin-cpp-basic-auth + url: file:///opt/plugins/wasm-cpp/extensions/basic-auth/plugin.wasm +``` + +可以看出 matchRule 下 config 配置内容和 defaultConfig 配置内容不一样。所以在开发插件的时候,需要同时设置 parseConfig 和 parseRuleConfig 两个回调钩子函数。 +baisc-auth 部分核心代码如下: +```golang +func main() { + wrapper.SetCtx( + "basic-auth", + // 要同时设置 parseConfig 和 parseRuleConfig 回调钩子函数 + // ParseOverrideConfigBy 函数的第一个参数接收 parseConfig 回调钩子函数,第二个参数接收 parseRuleConfig 回调钩子函数 + wrapper.ParseOverrideConfigBy(parseGlobalConfig, parseOverrideRuleConfig), + wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders), + ) +} + +// 自定义插件配置 +type BasicAuthConfig struct { + // 插件全局配置内容 + globalAuth *bool `yaml:"global_auth"` + consumers []Consumer `yaml:"consumers"` + ... + // 插件路由、域名、服务级别配置内容 + allow []string `yaml:"allow"` +} + +type Consumer struct { + name string `yaml:"name"` + credential string `yaml:"credential"` +} + +// 解析插件全局配置回调钩子函数 +func parseGlobalConfig(json gjson.Result, global *BasicAuthConfig, log wrapper.Log) error { + log.Debug("global config") + // 解析插件全局配置 + ... + consumers := json.Get("consumers") + ... + globalAuth := json.Get("global_auth") + ... + return nil +} + +// 解析插件路由、域名、服务级别配置回调钩子函数 +func parseOverrideRuleConfig(json gjson.Result, global BasicAuthConfig, config *BasicAuthConfig, log wrapper.Log) error { + log.Debug("domain/route config") + // 这里要注意要用全局配置内容复制到路由、域名、服务级别配置中,这样后续在 HttpContext 中可以获取当前 Http 请求下插件配置包括全局配置和路由、域名、服务级别配置 + *config = global + // 解析插件路由、域名、服务级别配置 + allow := json.Get("allow") + ... + for _, item := range allow.Array() { + config.allow = append(config.allow, item.String()) + } + ... + return nil +} +``` +开发这种类型插件需要注意: +- 自定义插件配置 struct 要包含全局配置内容和路由、域名、服务级别配置内容。 +- wrapper.ParseOverrideConfigBy 要同时设置 parseConfig 和 parseRuleConfig 回调钩子函数。 +- 在 parseRuleConfig 回调钩子函数处理中,全局配置内容要复制到路由、域名、服务级别配置中,这样后续在 HttpContext 中可以获取当前 Http 请求下插件配置包括全局配置和路由、域名、服务级别配置内容。 + +### 2.3 HTTP 上下文(CommonHttpCtx) + +#### 2.3.1 创建 CommonHttpCtx + +CommonPluginCtx 的 NewHttpContext 部分核心代码如下: + +```golang +func (ctx *CommonPluginCtx[PluginConfig]) NewHttpContext(contextID uint32) types.HttpContext { + httpCtx := &CommonHttpCtx[PluginConfig]{ + plugin: ctx, + contextID: contextID, + userContext: map[string]interface{}{}, + } + // 根据插件配置判断设置是否需要处理请求和响应的 body + if ctx.vm.onHttpRequestBody != nil || ctx.vm.onHttpStreamingRequestBody != nil { + httpCtx.needRequestBody = true + } + if ctx.vm.onHttpResponseBody != nil || ctx.vm.onHttpStreamingResponseBody != nil { + httpCtx.needResponseBody = true + } + if ctx.vm.onHttpStreamingRequestBody != nil { + httpCtx.streamingRequestBody = true + } + if ctx.vm.onHttpStreamingResponseBody != nil { + httpCtx.streamingResponseBody = true + } + + return httpCtx +} +``` +#### 2.3.2 OnHttpRequestHeaders + +OnHttpRequestHeaders 核心代码如下: +```golang +func (ctx *CommonHttpCtx[PluginConfig]) OnHttpRequestHeaders(numHeaders int, endOfStream bool) types.Action { + // 获取当前 HTTP 请求生效插件配置 + config, err := ctx.plugin.GetMatchConfig() + ... + // 设置插件配置到 HttpContext + ctx.config = config + // 如果请求 content-type 是 octet-stream/grpc 或者定义 content-encoding,则不处理请求 body + if IsBinaryRequestBody() { + ctx.needRequestBody = false + } + ... + // 调用自定义插件 onHttpRequestHeaders 回调钩子函数 + return ctx.plugin.vm.onHttpRequestHeaders(ctx, *config, ctx.plugin.vm.log) +} +``` +主要处理逻辑如下: +- 获取匹配当前 HTTP 请求插件配置,可能是路由、域名、服务级别配置或者全局配置。 +- 设置插件配置到 HttpContext。 +- 如果请求 content-type 是 octet-stream/grpc 或者定义 content-encoding,则不处理请求 body。 +- 调用自定义插件 onHttpRequestHeaders 回调钩子函数。 + +关于插件配置可以看出, Higress 插件 Go SDK 封装如下: +- 在插件启动时候,解析插件路由、域名、服务级别插件配置和全局配置保存到 CommonPluginCtx 中。 +- 在 onHttpRequestHeaders 阶段,根据当前 HTTP 上下文中路由、域名、服务等信息匹配插件配置,返回路由、域名、服务级别配置或者全局配置。然后把匹配到插件配置设置到 HttpContext 对象的 config 属性中,这样自定义插件的所有回调钩子函数就可以获取到这个配置。 + +#### 2.3.3 OnHttpRequestBody + +OnHttpRequestBody 核心代码如下: +```golang +func (ctx *CommonHttpCtx[PluginConfig]) OnHttpRequestBody(bodySize int, endOfStream bool) types.Action { + ... + // 如果不需要处理请求 body,则直接返回,继续后续处理 + if !ctx.needRequestBody { + return types.ActionContinue + } + // 先判断是否要需要进行流式处理,如果需要则调用自定义插件 onHttpStreamingRequestBody 回调钩子函数 + if ctx.plugin.vm.onHttpStreamingRequestBody != nil && ctx.streamingRequestBody { + chunk, _ := proxywasm.GetHttpRequestBody(0, bodySize) + // 调用自定义插件 onHttpStreamingRequestBody 回调钩子函数 + modifiedChunk := ctx.plugin.vm.onHttpStreamingRequestBody(ctx, *ctx.config, chunk, endOfStream, ctx.plugin.vm.log) + err := proxywasm.ReplaceHttpRequestBody(modifiedChunk) + ... + return types.ActionContinue + } + // 再判断是否要需要进行非流式处理,需要缓存请求 body,等读取整个请求 body 后调用自定义插件 onHttpRequestBody 回调钩子函数 + if ctx.plugin.vm.onHttpRequestBody != nil { + ctx.requestBodySize += bodySize + if !endOfStream { + return types.ActionPause + } + body, err := proxywasm.GetHttpRequestBody(0, ctx.requestBodySize) + ... + // 调用自定义插件 onHttpRequestBody 回调钩子函数 + return ctx.plugin.vm.onHttpRequestBody(ctx, *ctx.config, body, ctx.plugin.vm.log) + } + return types.ActionContinue +} +``` + +核心逻辑如下: +- 如果 ctx.needRequestBody 为 false 时,则直接返回,继续后续处理。 +- 当 ctx.streamingRequestBody 为 true 时,同时自定义插件有 onHttpStreamingRequestBody 回调钩子函数,则调用自定义插件 onHttpStreamingRequestBody 回调钩子函数。 +- 当自定义插件有 onHttpRequestBody 回调钩子函数,需要缓存请求 body,等读取整个请求 body 后调用自定义插件 onHttpRequestBody 回调钩子函数。 + +#### 2.3.4 OnHttpResponseHeaders + +OnHttpResponseHeaders 核心代码如下: +```golang +func (ctx *CommonHttpCtx[PluginConfig]) OnHttpResponseHeaders(numHeaders int, endOfStream bool) types.Action { + ... + // To avoid unexpected operations, plugins do not read the binary content body + if IsBinaryResponseBody() { + ctx.needResponseBody = false + } + ... + return ctx.plugin.vm.onHttpResponseHeaders(ctx, *ctx.config, ctx.plugin.vm.log) +} + +``` +主要处理逻辑如下: +- 如果响应 content-type 是 octet-stream/grpc 或者定义 content-encoding,则不处理响应 body。 +- 调用自定义插件 onHttpResponseHeaders 回调钩子函数。 + +#### 2.3.5 OnHttpResponseBody + +OnHttpResponseBody 核心代码如下: +```golang +func (ctx *CommonHttpCtx[PluginConfig]) OnHttpResponseBody(bodySize int, endOfStream bool) types.Action { + ... + // 如果不需要处理响应 body,则直接返回,继续后续处理 + if !ctx.needResponseBody { + return types.ActionContinue + } + // 先判断是否要需要进行流式处理,如果需要则调用自定义插件 onHttpStreamingResponseBod 回调钩子函数 + if ctx.plugin.vm.onHttpStreamingResponseBody != nil && ctx.streamingResponseBody { + chunk, _ := proxywasm.GetHttpResponseBody(0, bodySize) + // 调用自定义插件 onHttpStreamingResponseBody 回调钩子函数 + modifiedChunk := ctx.plugin.vm.onHttpStreamingResponseBody(ctx, *ctx.config, chunk, endOfStream, ctx.plugin.vm.log) + ... + return types.ActionContinue + } + // 再判断是否要需要进行非流式处理,需要缓存响应 body,等读取整个响应 body 后调用自定义插件 onHttpResponseBody 回调钩子函数 + if ctx.plugin.vm.onHttpResponseBody != nil { + ctx.responseBodySize += bodySize + if !endOfStream { + return types.ActionPause + } + body, err := proxywasm.GetHttpResponseBody(0, ctx.responseBodySize) + ... + // 调用自定义插件 onHttpResponseBody 回调钩子函数 + return ctx.plugin.vm.onHttpResponseBody(ctx, *ctx.config, body, ctx.plugin.vm.log) + } + return types.ActionContinue +} +``` + +核心逻辑如下: +- 当 ctx.needResponseBody 为 false 时,则直接返回,继续后续处理。 +- 当 ctx.streamingResponseBody 为 true 时,同时自定义插件有 onHttpStreamingResponseBody 回调钩子函数,则调用自定义插件 onHttpStreamingResponseBody 回调钩子函数。 +- 当自定义插件有 onHttpResponseBody 回调钩子函数,需要缓存响应 body,等读取整个响应 body 后调用自定义插件 onHttpResponseBody 回调钩子函数。 + +#### 2.3.6 OnHttpStreamDone + +OnHttpStreamDone 核心代码如下: +```golang +func (ctx *CommonHttpCtx[PluginConfig]) OnHttpStreamDone() { + ... + ctx.plugin.vm.onHttpStreamDone(ctx, *ctx.config, ctx.plugin.vm.log) +} +``` + +OnHttpStreamDone 比较简单,自定义插件有 onHttpStreamDone 回调钩子函数,则调用自定义插件 onHttpStreamDone 回调钩子函数。 + +#### 2.3.7 CommonHttpCtx 方法 + +CommonHttpCtx 提供以下方法,自定义插件可以调用,其代码和注释如下: +```golang +// 设置自定义上下文,这个上下文可以在自定义插件所有回调钩子函数中可以获取 +func (ctx *CommonHttpCtx[PluginConfig]) SetContext(key string, value interface{}) { + ctx.userContext[key] = value +} +// 获取自定义上下文,这个上下文可以在自定义插件所有回调钩子函数中可以获取 +func (ctx *CommonHttpCtx[PluginConfig]) GetContext(key string) interface{} { + return ctx.userContext[key] +} +// 获取 bool 类型自定义上下文 +func (ctx *CommonHttpCtx[PluginConfig]) GetBoolContext(key string, defaultValue bool) bool { + if b, ok := ctx.userContext[key].(bool); ok { + return b + } + return defaultValue +} +// 获取 string 类型自定义上下文 +func (ctx *CommonHttpCtx[PluginConfig]) GetStringContext(key, defaultValue string) string { + if s, ok := ctx.userContext[key].(string); ok { + return s + } + return defaultValue +} +// 获取请求 scheme +func (ctx *CommonHttpCtx[PluginConfig]) Scheme() string { + proxywasm.SetEffectiveContext(ctx.contextID) + return GetRequestScheme() +} +// 获取请求 host +func (ctx *CommonHttpCtx[PluginConfig]) Host() string { + proxywasm.SetEffectiveContext(ctx.contextID) + return GetRequestHost() +} +// 获取请求 path +func (ctx *CommonHttpCtx[PluginConfig]) Path() string { + proxywasm.SetEffectiveContext(ctx.contextID) + return GetRequestPath() +} +// 获取请求 method +func (ctx *CommonHttpCtx[PluginConfig]) Method() string { + proxywasm.SetEffectiveContext(ctx.contextID) + return GetRequestMethod() +} + +// 调用这个方法可以禁止读取请求 body 和处理。 +// 比如在 onHttpRequestHeaders 回调钩子函数中根据某些条件时调用,可以跳过后续的请求 body 读取和处理 +func (ctx *CommonHttpCtx[PluginConfig]) DontReadRequestBody() { + ctx.needRequestBody = false +} + +// 调用这个方法禁止读取响应 body, +// 比如在 onHttpResponseHeaders 回调钩子函数中根据某些条件时调用,可以跳过后续的响应 body 读取和处理 +func (ctx *CommonHttpCtx[PluginConfig]) DontReadResponseBody() { + ctx.needResponseBody = false +} +// 调用这个方法禁止请求流式处理 +// 比如在 onHttpRequestHeaders 回调钩子函数中根据某些条件时调用,跳过后续的 onHttpStreamingRequestBody 流式处理, 转成 onHttpRequestBody 处理 +func (ctx *CommonHttpCtx[PluginConfig]) BufferRequestBody() { + ctx.streamingRequestBody = false +} + +// 调用这个方法禁止响应流式处理 +// 比如在 onHttpResponseHeaders 回调钩子函数中根据某些条件时调用,跳过后续的 onHttpStreamingResponseBody 流式处理, 转成 onHttpResponseBody 处理 +func (ctx *CommonHttpCtx[PluginConfig]) BufferResponseBody() { + ctx.streamingResponseBody = false +} + +// 调用这个方法可以禁止重新计算路由,Envoy 默认在 Http 头发生变更时会重新计算路由,调用这个方法,可以禁止重新计算路由。 +// 比如在 onHttpRequestHeaders 回调钩子函数中根据某些条件时调用这个方法,可以禁止重新计算路由。 +// 关于 DisableReroute 使用场景可以参考 Higress 官方提供 [ai-proxy 插件](https://github.com/alibaba/higress/blob/main/plugins/wasm-go/extensions/ai-proxy/main.go#L76) +func (ctx *CommonHttpCtx[PluginConfig]) DisableReroute() { + _ = proxywasm.SetProperty([]string{"clear_route_cache"}, []byte("off")) +} +// 设置请求 body buffer limit +// 关于 DisableReroute 使用场景可以参考 Higress 官方提供 [ai-proxy 插件](https://github.com/alibaba/higress/blob/main/plugins/wasm-go/extensions/ai-proxy/main.go#L81) +func (ctx *CommonHttpCtx[PluginConfig]) SetRequestBodyBufferLimit(size uint32) { + ctx.plugin.vm.log.Infof("SetRequestBodyBufferLimit: %d", size) + _ = proxywasm.SetProperty([]string{"set_decoder_buffer_limit"}, []byte(strconv.Itoa(int(size)))) +} +// 设置响应 body buffer limit +func (ctx *CommonHttpCtx[PluginConfig]) SetResponseBodyBufferLimit(size uint32) { + ctx.plugin.vm.log.Infof("SetResponseBodyBufferLimit: %d", size) + _ = proxywasm.SetProperty([]string{"set_encoder_buffer_limit"}, []byte(strconv.Itoa(int(size)))) +} +``` + +核心内容如下: +- 在 onHttpRequestHeaders 阶段: + - 调用 DontReadRequestBody 方法,可以跳过读取请求 body 和处理。 + - 调用 BufferRequestBody 方法,可以跳过请求 onHttpStreamingRequestBody 流式处理,转成 onHttpRequestBody 处理。 + - 调用 DisableReroute 方法,可以禁止重新计算路由。 +- 在 onHttpResponseHeaders 阶段: + - 调用 DontReadResponseBody 方法,可以跳过读取响应 body 和处理。 + - 调用 BufferResponseBody 方法,可以跳过响应 onHttpStreamingResponseBody 流式处理,转成 onHttpResponseBody 处理。 +- SetContext 和 GetContext 方法,可以设置和获取自定义上下文,在自定义插件所有回调钩子函数中可以使用。 +- SetRequestBodyBufferLimit 和 SetResponseBodyBufferLimit 方法,可以设置请求 body buffer limit 和响应 body buffer limit。 + +### 2.4 Types.Action + +在自定义插件中 onHttpRequestHeaders、onHttpRequestBody、onHttpResponseHeaders、onHttpResponseBody 返回值类型为 types.Action。通过 types.Action 枚举值来控制插件的运行流程,常见的返回值如下: + +1. types.ActionContinue:继续后续处理,比如继续读取请求 body,或者继续读取响应 body。 + +2. types.ActionPause: 暂停后续处理,比如在 onHttpRequestHeaders 通过 Http 或者 Redis 调用外部服务获取认证信息,在调用外部服务回调钩子函数中调用 proxywasm.ResumeHttpRequest() 来恢复后续处理 或者调用 proxywasm.SendHttpResponseWithDetail() 来返回响应。 + + +#### 2.4.1 编译插件注意事项 + +1. Higress 需要编译时启用 `EXTRA_TAGS=proxy_wasm_version_0_2_100` 标签来修改 Proxy Wasm ABI。 TinyGo 本地构建命令如下: +```shell +tinygo build -scheduler=none -target=wasi -gc=custom -tags='custommalloc nottinygc_finalizer proxy_wasm_version_0_2_100' -o ./build/plugin.wasm main.go +``` +2. Makefile 文件默认启用 `proxy_wasm_version_0_2_100` 标签,所以不需要修改 Makefile 文件。 + +#### 2.4.2 Header 状态码 + +1. HeaderContinue: + +表示当前 filter 已经处理完毕,可以继续交给下一个 filter 处理。 types.ActionContinue 对应就是这个状态。 + +2. HeaderStopIteration: + +表示 header 还不能继续交给下一个 filter 来处理。 但是并不停止从连接读数据,继续触发 body data 的处理。 这样可以在 body data 处理阶段可以更新 Http 请求头内容。 如果 body data 要交给下一个 filter 处理, 这时 header 是也会被一起交给下一个 filter 处理。 + +3. HeaderContinueAndEndStream: + +表示 header 可以继续交给下一个 filter 处理,但是下一个 filter 收到的 end_stream = false,也就是标记请求还未结束。以便当前 filter 再增加 body。 + +4. HeaderStopAllIterationAndBuffer: + +停止所有迭代,表示 header 不能继续交给下一个 filter,并且当前 filter 也不能收到 body data。 并对当前过滤器及后续过滤器的头部、数据和尾部进行缓冲。如果缓存大小超过了 buffer limit,在请求阶段就直接返回 413,响应阶段就直接返回 500。 +同时需要调用 proxywasm.ResumeHttpRequest()、 proxywasm.ResumeHttpResponse() 或者 proxywasm.SendHttpResponseWithDetail() 函数来恢复后续处理。 + +5. HeaderStopAllIterationAndWatermark: + +同上,区别是,当缓存超过了 buffer limit 会触发流控,也就是暂停从连接上读数据。 types.ActionPause 实际上对应就是这个状态。 + +> 关于 types.HeaderStopIteration 和 HeaderStopAllIterationAndWatermark 的使用场景可以参考 Higress 官方提供 [ai-transformer 插件](https://github.com/alibaba/higress/blob/main/plugins/wasm-go/extensions/ai-transformer/main.go#L93-L99) 和 [ai-quota 插件](https://github.com/alibaba/higress/blob/main/plugins/wasm-go/extensions/ai-quota/main.go#L179) 。 + +#### 2.4.3 Data 状态码 + +1. DataContinue: + +和 header 类似,表示当前 filter 已经处理完毕,可以继续交给下一个 filter 处理。 如果 header 之前返回的是 HeaderStopIteration,且尚未交给下一个 filter 处理,那么此时 header 和 data 也会被交给下一个 filter 处理。types.ActionContinue 对应就是这个状态。 + +2. DataStopIterationAndBuffer: + +表示当前 data 不能继续交给下一个 filter 处理,并且将当前 data 缓存起来。 与 header 类似,如果达到 buffer limit,在请求阶段就直接返回 413,响应阶段就直接返回 500。 +同时需要调用 proxywasm.ResumeHttpRequest()、 proxywasm.ResumeHttpResponse() 或者 proxywasm.SendHttpResponseWithDetail() 函数来恢复后续处理。 + +3. DataStopIterationAndWatermark: + +同上,只是达到 buffer limit 会触发流控。types.ActionPause 实际上对应就是这个状态。 + +4. DataStopIterationNoBuffer: + +表示当前 data 不能继续交给下一个 filter,但是不缓存当前 data 。 + + +### 2.5 Envoy 请求缓存区限制 + +当自定义插件使用 `onHttpRequestBody` 非流式传输,当请求超过 `downstream` 缓存区限制(默认是 32k)。Envoy 会给用户返回 413, 同时报 `request_payload_too_large` 错误。 +比如在 AI 长上下文中场景中可能会碰到这个问题,这个问题可以通过参考 [全局配置说明](https://higress.io/docs/latest/user/configmap/) 调整 Downstream 配置项 `connectionBufferLimits` 解决, 或者 使用 `SetRequestBodyBufferLimit` 方法设置请求 body buffer limit 解决。 关于如何使用 `SetRequestBodyBufferLimit` 可以参考 Higress 官方提供 [ai-proxy 插件](https://github.com/alibaba/higress/blob/main/plugins/wasm-go/extensions/ai-proxy/main.go#L81) 的实现。 + + +## 3 Envoy 属性(Attributes) + +属性是 Envoy 的一个特性,允许用户在插件中设置和获取这些属性,可以通过 `proxywasm.GetProperty` 和 `proxywasm.SetProperty` 方法获取和设置。 +Envoy 预定义属性包括请求属性、响应属性、连接属性、Upstream 属性、Wasm 属性、和 Metadata 等属性, 具体可以参考 [Envoy 属性](https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/advanced/attributes)。 +同时用户也可以设置自定义属性,这些属性可以在插件链中不同插件共享。 + + +## 参考 +- [1] [Envoy 开发入门:搞懂 http filter 状态码](https://uncledou.site/2022/envoy-filter-status/) + + diff --git a/src/content/docs/ebook/zh-cn/wasm17.md b/src/content/docs/ebook/zh-cn/wasm17.md new file mode 100644 index 0000000000..b8ff87ed12 --- /dev/null +++ b/src/content/docs/ebook/zh-cn/wasm17.md @@ -0,0 +1,633 @@ +--- +title: HTTP 调用 +keywords: [Higress] +--- + +# HTTP 调用 + +这章主要介绍如何使用 Higress 插件 Go SDK 实现 HTTP 调用。 + +## 1 Envoy 集群(Cluster)名称和服务发现来源 + +Higress 插件的 Go SDK 在进行 HTTP 和 Redis 调用时,是通过指定的集群名称来识别并连接到相应的 Envoy 集群。 此外,Higress 利用 [McpBridge](https://higress.io/docs/latest/user/mcp-bridge/) 支持多种服务发现机制,包括静态配置(static)、DNS、Kubernetes 服务、Eureka、Consul、Nacos、以及 Zookeeper 等。 +每种服务发现机制对应的集群名称生成规则都有所不同,这些规则在 cluster_wrapper.go 代码文件中有所体现。 +为了包装不同的服务发现机制,Higress 插件 Go SDK 定义了 Cluster 接口,该接口包含两个方法:ClusterName 和 HostName。 +```golang +type Cluster interface { + // 返回 Envoy 集群名称 + ClusterName() string + // 返回 Hostname, 在 HTTP 调用服务时候,用于设置 Http host 请求头 + HostName() string +} +``` + +### 1.1 静态配置(static) +```golang +type StaticIpCluster struct { + ServiceName string + Port int64 + Host string +} +``` +- 集群名称规则为:`outbound|||.static`。 +- HostName 规则为:默认为 。 + +### 1.2 DNS 配置(dns) +```golang +type DnsCluster struct { + ServiceName string + Domain string + Port int64 +} + +``` +- 集群名称规则为:`outbound|||.dns`。 +- HostName 规则为:如果设置 Host,返回 Host,否则返回。 + +### 1.3 Kubernetes 服务(kubernetes) +```golang + +type K8sCluster struct { + ServiceName string + Namespace string + Port int64 + Version string + Host string +} +``` +- 集群名称规则为:`outbound|||..svc.cluster.local`。 +- HostName 规则为:如果设置 Host,返回 Host,否则返回 ..svc.cluster.local。 + +### 1.4 Nacos +```golang + +type NacosCluster struct { + ServiceName string + // use DEFAULT-GROUP by default + Group string + NamespaceID string + Port int64 + // set true if use edas/sae registry + IsExtRegistry bool + Version string + Host string +} +``` +- 集群名称规则为:`outbound|||...nacos`。 +- HostName 规则为:如果设置 Host,返回 Host,否则返回 。 + +### 1.5 Consul +```golang +type ConsulCluster struct { + ServiceName string + Datacenter string + Port int64 + Host string +} +``` +- 集群名称规则为:`outbound|||..consul`。 +- HostName 规则为:如果设置 Host,返回 Host,否则返回 。 + +### 1.6 FQDN + +```golang + +type FQDNCluster struct { + FQDN string + Host string + Port int64 +} +``` +- 集群名称规则为:`outbound|||`。 +- HostName 规则为:如果设置 Host,返回 Host,否则返回 ``。 + +## 2 HTTP 调用 +http_wrapper.go 部分核心代码如下: +```golang +// 回调函数 +type ResponseCallback func(statusCode int, responseHeaders http.Header, responseBody []byte) + +// HTTP 调用接口 +type HttpClient interface { + Get(path string, headers [][2]string, cb ResponseCallback, timeoutMillisecond ...uint32) error + Head(path string, headers [][2]string, cb ResponseCallback, timeoutMillisecond ...uint32) error + Options(path string, headers [][2]string, cb ResponseCallback, timeoutMillisecond ...uint32) error + Post(path string, headers [][2]string, body []byte, cb ResponseCallback, timeoutMillisecond ...uint32) error + Put(path string, headers [][2]string, body []byte, cb ResponseCallback, timeoutMillisecond ...uint32) error + Patch(path string, headers [][2]string, body []byte, cb ResponseCallback, timeoutMillisecond ...uint32) error + Delete(path string, headers [][2]string, body []byte, cb ResponseCallback, timeoutMillisecond ...uint32) error + Connect(path string, headers [][2]string, body []byte, cb ResponseCallback, timeoutMillisecond ...uint32) error + Trace(path string, headers [][2]string, body []byte, cb ResponseCallback, timeoutMillisecond ...uint32) error + Call(method, path string, headers [][2]string, body []byte, cb ResponseCallback, timeoutMillisecond ...uint32) error +} + +// 实现 httpClient 接口 +type ClusterClient[C Cluster] struct { + cluster C +} +``` +ClusterClient Get、Head、Options、Post、PUT、Patch、Delete、Connect、Trace、Call 方法最后调用 HttpCall 方法,其核心代码如下: + +```golang +func HttpCall(cluster Cluster, method, path string, headers [][2]string, body []byte, + callback ResponseCallback, timeoutMillisecond ...uint32) error { + + // 删除 :method, :path, :authority + for i := len(headers) - 1; i >= 0; i-- { + key := headers[i][0] + if key == ":method" || key == ":path" || key == ":authority" { + headers = append(headers[:i], headers[i+1:]...) + } + } + // 设置 timeout + var timeout uint32 = 500 + if len(timeoutMillisecond) > 0 { + timeout = timeoutMillisecond[0] + } + // 重新设置 :method, :path, :authority + headers = append(headers, [2]string{":method", method}, [2]string{":path", path}, [2]string{":authority", cluster.HostName()}) + requestID := uuid.New().String() + // 调用 HTTP 请求 + _, err := proxywasm.DispatchHttpCall(cluster.ClusterName(), headers, body, nil, timeout, func(numHeaders, bodySize, numTrailers int) { + // 获取 HTTP 响应 body 和 headers + respBody, err := proxywasm.GetHttpCallResponseBody(0, bodySize) + ... + respHeaders, err := proxywasm.GetHttpCallResponseHeaders() + ... + code := http.StatusBadGateway + var normalResponse bool + headers := make(http.Header) + for _, h := range respHeaders { + if h[0] == ":status" { + code, err = strconv.Atoi(h[1]) + .. + } + headers.Add(h[0], h[1]) + } + ... + // 调用自定义插件回调函数 + callback(code, headers, respBody) + }) + ... + return err +} +``` + +## 3 easy-jwt 插件开发 + +在实际业务场景中,可能需要独立认证授权服务,来完成每个请求的认证和授权,现在开发一个简单的 easy-jwt 插件来演示如何在 Wasm 插件进行 HTTP 调用。 +其插件核心流程如下图: +![img](https://img.alicdn.com/imgextra/i1/O1CN01DPi2w6244OvEejP1q_!!6000000007337-0-tps-1488-682.jpg) + +Token Server 提供 2 个接口: +- /api/token/auth: 认证令牌接口 +- /api/token/create: 生成令牌接口 + +### 3.1 插件部分核心代码 + +```golang +package main +... + +const ( + AuthUIDHeader = "x-auth-user" +) + +func main() { + wrapper.SetCtx( + // 插件名称 + "easy-jwt", + // 设置自定义函数解析插件配置 + wrapper.ParseConfigBy(parseConfig), + // 设置自定义函数处理请求头 + wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders), + ) +} + +// 自定义插件配置 +type JwtConfig struct { + // HTTP Client + client wrapper.HttpClient + // 令牌服务器的完全限定域名 + tokenServerFQDN string + // 令牌服务器的端口 + tokenServerPort int + // HTTP请求头中包含令牌的字段名称 + tokenFromHeaderName string + // 令牌前缀,如Bearer + tokenFromHeaderPrefix string + // 插件将忽略令牌验证 UR L列表 + ignoreUrls []string + // 匿名令牌,用于未认证的请求 + anonymousToken string + // 匿名用户ID + anonymousUID int + // 当令牌验证失败时返回的 HTTP 状态码 + responseErrorStatusCode uint32 + // 返回的错误信息格式 + responseErrorBody string +} + +func parseConfig(json gjson.Result, config *JwtConfig, log wrapper.Log) error { + log.Debugf("parseConfig()") + // 解析插件配置 + config.tokenServerFQDN = json.Get("tokenServerFQDN").String() + config.tokenServerPort = int(json.Get("tokenServerPort").Int()) + config.tokenFromHeaderName = json.Get("tokenFromHeaderName").String() + config.tokenFromHeaderPrefix = json.Get("tokenFromHeaderPrefix").String() + config.anonymousUID = int(json.Get("anonymousUID").Int()) + config.anonymousToken = json.Get("anonymousToken").String() + config.responseErrorBody = json.Get("responseErrorBoy").String() + config.responseErrorStatusCode = uint32(json.Get("responseErrorStatusCode").Int()) + config.responseErrorBody = json.Get("responseErrorBody").String() + config.ignoreUrls = make([]string, 0) + for _, item := range json.Get("ignoreUrls").Array() { + config.ignoreUrls = append(config.ignoreUrls, item.String()) + } + // 设置 HTTP Client + config.client = wrapper.NewClusterClient(wrapper.FQDNCluster{ + FQDN: json.Get("tokenServerFQDN").String(), + Port: json.Get("tokenServerPort").Int(), + }) + log.Debugf("parseConfig result:%+v", config) + return nil +} + +func onHttpRequestHeaders(ctx wrapper.HttpContext, config JwtConfig, log wrapper.Log) types.Action { + // 首先检查请求的路径是否在 ignoreUrls 列表中,如果是,则添加匿名用户ID到请求头并继续处理请求 + rawPath := ctx.Path() + path, _ := url.Parse(rawPath) + for _, url := range config.ignoreUrls { + if isPathMatch(path.Path, url) { + proxywasm.AddHttpRequestHeader(AuthUIDHeader, fmt.Sprintf("%d", config.anonymousUID)) + return types.ActionContinue + } + } + // 如果请求头中包含令牌,插件将尝试从请求头中提取令牌 + token, err := extractTokenFromHeader(ctx, config) + if err != nil { + log.Debugf("extractTokenFromHeader() error: %v", err) + body := fmt.Sprintf(config.responseErrorBody, err.Error()) + proxywasm.SendHttpResponse(config.responseErrorStatusCode, [][2]string{{"content-type", "application/json"}}, []byte(body), -1) + return types.ActionContinue + } + // 如果是匿名令牌,则添加匿名用户ID到请求头并继续处理请求 + if len(config.anonymousToken) > 0 && config.anonymousToken == token { + proxywasm.AddHttpRequestHeader(AuthUIDHeader, fmt.Sprintf("%d", config.anonymousUID)) + return types.ActionContinue + } + + authRequest, _ := json.Marshal(map[string]string{"token": token}) + log.Debugf("call token-server with auth request:%s", string(authRequest)) + // 插件将使用配置的HTTP客户端向令牌服务器发送POST请求,以验证令牌的有效性 + err2 := config.client.Post( + "/api/token/auth", + [][2]string{{"content-type", "application/json"}}, + authRequest, + func(statusCode int, responseHeaders http.Header, responseBody []byte) { + defer func() { + // 保证恢复请求 + _ = proxywasm.ResumeHttpRequest() + }() + + log.Debugf("auth response status:%d, response:%s", statusCode, string(responseBody)) + var jsonData gjson.Result + jsonData = gjson.ParseBytes(responseBody) + if statusCode != 200 { + // 如果响应状态码不是200,表示验证失败,插件将直接发送错误响应给客户端。 + message := jsonData.Get("message").String() + body := fmt.Sprintf(config.responseErrorBody, message) + proxywasm.SendHttpResponse(config.responseErrorStatusCode, [][2]string{{"content-type", "application/json"}}, []byte(body), -1) + } else { + // 如果验证成功,插件将从响应中提取用户ID,并将其添加到后续请求头中 + uid := jsonData.Get("uid").Int() + proxywasm.AddHttpRequestHeader(AuthUIDHeader, fmt.Sprintf("%d", uid)) + } + }, + 2000, + ) + + if err2 != nil { + // 如果连接失败,则直接发送错误响应给客户端。 + log.Debugf("call token server error:%v", err2) + body := fmt.Sprintf(config.responseErrorBody, err2.Error()) + proxywasm.SendHttpResponse(config.responseErrorStatusCode, [][2]string{{"content-type", "application/json"}}, []byte(body), -1) + return types.ActionContinue + } + // 暂停请求处理,直到调用 proxywasm.ResumeHttpRequest() 恢复请求 + return types.ActionPause +} + +func extractTokenFromHeader(ctx wrapper.HttpContext, config JwtConfig) (string, error) { + ... +} + +func isPathMatch(path string, url string) bool { + ... +} +``` + +核心流程如下: +- 初始化插件 +- 解析配置 +- onHttpRequestHeaders 处理 + - 检查请求路径是否在 ignoreUrls 列表中 + - 是:添加匿名 UID 到请求头,继续处理请求 + - 否:继续 + - 从请求头中提取令牌,检查令牌是否存在 + - 存在:继续 + - 不存在:返回错误,发送响应 + - 验证令牌 + - 如果令牌是匿名令牌,添加匿名 UID 到请求头,继续处理请求 + - 如果令牌不是匿名令牌,调用认证服务 /api/token/auth 接口验证令牌 + - 如果验证成功,从响应中提取 UID,添加到请求头中,继续处理请求 + - 如果验证失败,返回错误,发送响应 + +### 3.2 部署和验证 + +1. 部署 YAML 如下: +```yaml +apiVersion: v1 +kind: Namespace +metadata: + name: higress-course +--- +apiVersion: v1 +kind: Service +metadata: + name: echo-server + namespace: higress-course +spec: + selector: + app: echo-server + ports: + - protocol: TCP + port: 8080 + targetPort: 3000 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: echo-server + namespace: higress-course + labels: + app: echo-server +spec: + replicas: 1 + selector: + matchLabels: + app: echo-server + template: + metadata: + labels: + app: echo-server + spec: + containers: + - name: echo-server + image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/echo-server:1.3.0 + env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + resources: + requests: + cpu: 10m +--- +apiVersion: v1 +kind: Service +metadata: + name: echo-server + namespace: higress-course +spec: + selector: + app: echo-server + ports: + - protocol: TCP + port: 8080 + targetPort: 3000 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: token-server + namespace: higress-course + labels: + app: token-server +spec: + replicas: 1 + selector: + matchLabels: + app: token-server + template: + metadata: + labels: + app: token-server + spec: + containers: + - name: token-server + image: registry.cn-hangzhou.aliyuncs.com/2456868764/token-server:1.0.0 + env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + resources: + requests: + cpu: 10m +--- +apiVersion: v1 +kind: Service +metadata: + name: token-server + namespace: higress-course +spec: + selector: + app: token-server + ports: + - protocol: TCP + port: 9090 + targetPort: 9090 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-foo + namespace: higress-course +spec: + ingressClassName: higress + rules: + - host: "foo.com" + http: + paths: + - pathType: Prefix + path: "/" + backend: + service: + name: echo-server + port: + number: 8080 +--- +apiVersion: networking.higress.io/v1 +kind: McpBridge +metadata: + name: default + namespace: higress-system +spec: + registries: + - name: token-server + domain: token-server.higress-course.svc.cluster.local + port: 9090 + type: dns +--- +apiVersion: extensions.higress.io/v1alpha1 +kind: WasmPlugin +metadata: + name: easy-jwt + namespace: higress-system +spec: + priority: 200 + matchRules: + - ingress: + - higress-course/ingress-foo + config: + tokenServerFQDN: "token-server.dns" + tokenServerPort: 9090 + tokenFromHeaderName: "Authorization" + tokenFromHeaderPrefix: "Bearer " + anonymousToken: "AnonymousToken" + anonymousUID: 0 + responseErrorStatusCode: 401 + responseErrorBody: "{\"message\":\"%s\"}" + url: oci://registry.cn-hangzhou.aliyuncs.com/2456868764/easy-jwt:1.0.0 +``` +2. 获取令牌 + +获取 uid 为 100 的用户的访问令牌,其命令如下,其中 `` 是 token-server pod 名称。 +```shell +kubectl exec -n higress-course -- curl -X POST http://127.0.0.1:9090/api/token/create -d '{"uid":100}' -H "content-type:application/json" + +{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVSUQiOjEwMCwiZXhwIjoxNzU0Mzg2MzQ4fQ.jncbLJqBern5DYCFvED3moiCvg6sUn5jdlllhneuHrY"}% +``` + +3. 请求验证 + +```shell +curl http://127.0.0.1/hello -X POST -d "{}" -H "host:foo.com" -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVSUQiOjEwMCwiZXhwIjoxNzU0Mzg2MzQ4fQ.jncbLJqBern5DYCFvED3moiCvg6sUn5jdlllhneuHrY" -H "content-type:application/json" + +{ + "path": "/hello", + "host": "foo.com", + "method": "POST", + "proto": "HTTP/1.1", + "headers": { + "Accept": [ + "*/*" + ], + "Authorization": [ + "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVSUQiOjEwMCwiZXhwIjoxNzU0Mzg2MzQ4fQ.jncbLJqBern5DYCFvED3moiCvg6sUn5jdlllhneuHrY" + ], + "Content-Length": [ + "2" + ], + "Content-Type": [ + "application/json" + ], + "Original-Host": [ + "foo.com" + ], + "Req-Start-Time": [ + "1722850461721" + ], + "User-Agent": [ + "curl/8.1.2" + ], + "X-Auth-User": [ + "100" + ], + "X-B3-Sampled": [ + "0" + ], + "X-B3-Spanid": [ + "642eab8e332d6500" + ], + "X-B3-Traceid": [ + "d9b9e94203603997642eab8e332d6500" + ], + "X-Envoy-Attempt-Count": [ + "1" + ], + "X-Envoy-Decorator-Operation": [ + "echo-server.higress-course.svc.cluster.local:8080/*" + ], + "X-Envoy-Internal": [ + "true" + ], + "X-Forwarded-For": [ + "192.168.65.1" + ], + "X-Forwarded-Proto": [ + "http" + ], + "X-Request-Id": [ + "47ff21bc-c3d5-4932-8bfb-361d268d319d" + ] + }, + "namespace": "higress-course", + "ingress": "", + "service": "", + "pod": "echo-server-6f4df5fcff-nksqz", + "body": {} +} +``` +可以看到请求头中包含了 `X-Auth-User` 同时值为 100 。 + +## 4 ext-auth 插件 + +Higress 官方提供 [ext-auth](https://github.com/alibaba/higress/tree/main/plugins/wasm-go/extensions/ext-auth) 插件,其功能更加丰富。 ext-auth 插件实现了向外部授权服务发送鉴权请求,以检查客户端请求是否得到授权。该插件实现时参考了 Envoy 原生的 [ext_authz filter](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/ext_authz_filter),实现了原生 filter 中对接 HTTP 服务的部分能力。 + +## 5 Envoy Cluster 不存在问题 + +在默认情况下,Higress 控制面只下发和路由关联的服务到 Envoy Cluster 中,因此有可能在实际开发过程中,发现对应调用 HTTP 服务在 Envoy Cluster 中不存在。 +有 3 种方案去解决: +- helm 参数 global.onlyPushRouteCluster, 默认值为 true, 只推送路由关联的 Cluster 到 Envoy Cluster 中。修改为 false 即可。 +- 创建一个新路由关联到对应的调用的 HTTP 服务。 +- 通过 McpBridge 配置,添加调用的 HTTP 服务。 + +上面 easy-jwt 插件中调用 token-server 服务,是通过 McpBridge 配置,添加 dns 类型服务,其配置如下: + +```yaml +apiVersion: networking.higress.io/v1 +kind: McpBridge +metadata: + name: default + namespace: higress-system +spec: + registries: + - name: token-server + domain: token-server.higress-course.svc.cluster.local + port: 9090 + type: dns +``` + +## 6 HTTP 回调链问题 + +在实际开发过程中,可能会遇到 HTTP 回调链的情况,比如在 onHttpRequestHeader 处理阶段,需要调用两个 HTTP 服务,这个时候在 onHttpRequestHeader 阶段中,要先调用第一个 HTTP 服务,在第一个 HTTP 服务的响应回调函数中,再发起第二个 HTTP 服务的调用。 +以此类推。这种情况 Redis 调用也是一样处理。 关于回调链可以参考 Higress 官方提供 [ai-agent](https://github.com/alibaba/higress/blob/main/plugins/wasm-go/extensions/ai-agent/main.go#L169) 插件功能。 + +## 参考 +- [1][Mcp Bridge 配置说明](https://higress.io/docs/latest/user/mcp-bridge/) + + + + + + diff --git a/src/content/docs/ebook/zh-cn/wasm18.md b/src/content/docs/ebook/zh-cn/wasm18.md new file mode 100644 index 0000000000..4b690a58ba --- /dev/null +++ b/src/content/docs/ebook/zh-cn/wasm18.md @@ -0,0 +1,720 @@ +--- +title: Redis 调用 +keywords: [Higress] +--- + +# Redis 调用 + +本章介绍如何在插件中调用 Redis、本地开发环境搭建、以及开发基于令牌桶限流插件。 + +## 1 Redis 调用 + +Higress 插件的 Go SDK 中 redis_wrapper.go 封装 Redis 调用, 部分核心代码如下: + +```shell +// Redis 回调函数 +type RedisResponseCallback func(response resp.Value) + +// Redis 调用接口 +type RedisClient interface { + // 初始化接口 + Init(username, password string, timeout int64) error + // with this function, you can call redis as if you are using redis-cli + } + // 命令接口 + Command(cmds []interface{}, callback RedisResponseCallback) error + // Lua脚本接口 + Eval(script string, numkeys int, keys, args []interface{}, callback RedisResponseCallback) error + + // 以下是 Redis 各种命令接口 + // Key + Del(key string, callback RedisResponseCallback) error + Exists(key string, callback RedisResponseCallback) error + Expire(key string, ttl int, callback RedisResponseCallback) error + Persist(key string, callback RedisResponseCallback) error + ... +} + +// RedisClusterClient, Redis 调用接口具体实现 +type RedisClusterClient[C Cluster] struct { + cluster C +} + +func RedisInit(cluster Cluster, username, password string, timeout uint32) error { + return proxywasm.RedisInit(cluster.ClusterName(), username, password, timeout) +} +// 真正调用 Redis 的函数 +func RedisCall(cluster Cluster, respQuery []byte, callback RedisResponseCallback) error { + requestID := uuid.New().String() + _, err := proxywasm.DispatchRedisCall( + cluster.ClusterName(), + respQuery, + func(status int, responseSize int) { + response, err := proxywasm.GetRedisCallResponse(0, responseSize) + var responseValue resp.Value + // 获取 Redis 回调结果 responseValue + ... + if callback != nil { + // 调用回调函数 + callback(responseValue) + } + }) + ... + return err +} +``` + +所有调用 Redis 的接口,最终通过 RedisCall 调用 Redis, 同时回调 RedisResponseCallback 回调函数。 + +## 2 令牌桶限流 + +常见的限流算法有固定窗口限流算法、滑动窗口限流算法、漏桶限流算法、令牌桶限流算法等。这里主要介绍令牌桶限流算法。 +令牌桶算法原理: +- 令牌以固定的频率被添加到令牌桶中。 +- 如果令牌数量满了,超过令牌桶容量的限制,那就丢弃。 +- 系统在接受到一个用户请求时,都会先去令牌桶要一个令牌。如果拿到令牌,那么就处理这个请求的业务逻辑。 +- 如果拿不到令牌,就直接拒绝这个请求。 + +令牌桶算法允许一定量的突发请求,因为桶可以存储一定数量的令牌,从而在短期内处理更多的请求。具体原理见下图: +![img](https://img.alicdn.com/imgextra/i2/O1CN018T2vsi1bbGU9PeVx6_!!6000000003483-0-tps-902-922.jpg) + +关于 QPS 限流算法和令牌桶算法两种限流算法优缺点,可以参考:[限流算法选择](https://help.aliyun.com/document_detail/149952.html)。 + +## 3 本地开发环境搭建 + +### 3.1 初始化工程目录 + +1. 新建一个工程目录文件 cluster-bucket-limit。 + +```shell +mkdir cluster-bucket-limit +``` +2. 在所建目录下执行以下命令,初始化 Go 工程。 + +```shell +go mod init cluster-bucket-limit +``` +更详细信息参考第十四章 Wasm 插件介绍和开发自定义插件。 + +### 3.2 Makefile、Dockerfile、docker-compose.yaml、envoy.yaml 文件 + +1. Makefile、Dockerfile + +Makefile、Dockerfile 文件参考第十四章 Wasm 插件介绍和开发自定义插件。 + +2. docker-compose.yaml + +```yaml +version: '3.9' +services: + envoy: + image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/gateway:v1.4.1 + entrypoint: /usr/local/bin/envoy + # 注意这里对wasm开启了debug级别日志,正式部署时则默认info级别 + command: -c /etc/envoy/envoy.yaml --log-level info --log-path /etc/envoy/envoy.log --component-log-level wasm:debug + depends_on: + - echo-server + networks: + - wasmtest + ports: + - "10000:10000" + - "9901:9901" + volumes: + - ./envoy.yaml:/etc/envoy/envoy.yaml + - ./build/plugin.wasm:/etc/envoy/plugin.wasm + - ./envoy.log:/etc/envoy/envoy.log + echo-server: + image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/echo-server:1.3.0 + networks: + - wasmtest + ports: + - "3000:3000" + redis: + image: registry.cn-hangzhou.aliyuncs.com/2456868764/redis:latest + environment: + - ALLOW_EMPTY_PASSWORD=yes + networks: + wasmtest: + ipv4_address: 172.20.0.100 + ports: + - "6379:6379" +networks: + wasmtest: + ipam: + config: + - subnet: 172.20.0.0/24 + +``` + +3. envoy.yaml 文件 + +envoy.yaml 配置文件如下: +```yaml +admin: + address: + socket_address: + protocol: TCP + address: 0.0.0.0 + port_value: 9901 +static_resources: + listeners: + - name: listener_0 + address: + socket_address: + protocol: TCP + address: 0.0.0.0 + port_value: 10000 + 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 + scheme_header_transformation: + scheme_to_overwrite: https + stat_prefix: ingress_http + route_config: + name: local_route + virtual_hosts: + - name: local_service + domains: ["*"] + routes: + - match: + prefix: "/" + route: + cluster: echo-server + http_filters: + - name: wasmdemo + typed_config: + "@type": type.googleapis.com/udpa.type.v1.TypedStruct + type_url: type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm + value: + config: + name: wasmdemo + vm_config: + runtime: envoy.wasm.runtime.v8 + code: + local: + filename: /etc/envoy/plugin.wasm + configuration: + "@type": "type.googleapis.com/google.protobuf.StringValue" + value: |- + { + "keys": [ + "authorization" + ], + "in_header": true, + "limits": [ + { + "name": "credential1", + "consumer": "Bearer credential1", + "rate": 2, + "capacity": 4 + }, + { + "name": "all", + "consumer": "*", + "rate": 1, + "capacity": 2 + } + ], + "rejected_code": 429, + "rejected_msg": "Too Many Requests", + "show_limit_quota_header": true, + "redis":{ + "service_name": "redis.static", + "service_port": 6379, + "timeout": 2000 + } + } + - name: envoy.filters.http.router + clusters: + - name: echo-server + connect_timeout: 30s + type: LOGICAL_DNS + # Comment out the following line to test on v6 networks + dns_lookup_family: V4_ONLY + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: echo-server + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: echo-server + port_value: 3000 + - name: outbound|6379||redis.static + connect_timeout: 30s + type: STATIC + load_assignment: + cluster_name: outbound|6379||redis.static + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: 172.20.0.100 + port_value: 6379 +``` +envoy.yaml 配置文件增加了 `outbound|6379||redis.static` 集群,用于连接 Redis 服务。Redis 连接目前不支持 DNS 域名配置,只支持 IP 地址配置。 +因此这里 Redis 的 IP 地址是 `172.20.0.100`。 + +## 3 令牌桶限流插件开发 + +### 3.1 插件配置和配置解析 + +插件配置和配置解析部分核心代码如下: +```golang +// LimitConfig 定义了限流插件的配置结构。 +type LimitConfig struct { + Keys []string `yaml:"keys"` // 定义了用于提取限流信息的HTTP请求头字段名称。 + InQuery bool `yaml:"in_query,omitempty"` // 标识是否从查询参数中获取限流信息。 + InHeader bool `yaml:"in_header,omitempty"` // 标识是否从请求头中获取限流信息。 + Limits []LimitItem `yaml:"limits"` // 包含具体的限流规则项。 + RejectedCode uint32 `yaml:"rejected_code"` // 请求超过阈值被拒绝时返回的 HTTP 状态码。 + RejectedMsg string `yaml:"rejected_msg"` // 请求超过阈值被拒绝时返回的响应体。 + ShowLimitQuotaHeader bool `yaml:"show_limit_quota_header"` // 标识是否在响应头中显示限流配额信息。 + RedisInfo RedisInfo `yaml:"redis"` // 定义了与 Redis 交互所需的信息。 + RedisClient wrapper.RedisClient // Redis客户端,用于执行Redis命令。 +} + +// LimitItem 定义了具体的限流项,包括限流名称、消费者标识、请求速率和容量。 +type LimitItem struct { + Name string `yaml:"name"` // 限流项的名称。 + Consumer string `yaml:"consumer"` // 限流项关联的消费者标识,用于匹配特定的请求。 + Rate int `yaml:"rate"` // 每秒放入桶内的令牌数量。 + Capacity int `yaml:"capacity"` // 限流桶的最大容量。 +} + +// RedisInfo 定义了连接Redis所需的详细信息。 +type RedisInfo struct { + ServiceName string `required:"true" yaml:"service_name" json:"service_name"` // Redis 服务名或地址。 + ServicePort int `yaml:"service_port" json:"service_port"` // Redis 服务端口。 + Username string `yaml:"username" json:"username"` // 连接 Redis 的用户名,如果需要。 + Password string `yaml:"password" json:"password"` // 连接 Redis 的密码,如果需要。 + Timeout int `yaml:"timeout" json:"timeout"` // 连接 Redis 的超时时间(毫秒)。 +} + +// LimitContext 定义了限流上下文,存储了限流相关的信息。 +type LimitContext struct { + Allowed int // 表示当前请求是否被允许。 + Count int // 限流桶当前的计数。 + Remaining int // 限流桶剩余的容量。 + Reset int // 限流桶重置时间(秒)。 +} + +// 解析配置,这里忽略插件配置解析的细节。主要显示 Redis 部分解析配置。 +func parseConfig(json gjson.Result, config *LimitConfig, log wrapper.Log) error { + // keys + names := json.Get("keys") + ... + // in_query and in_header + in_query := json.Get("in_query") + in_header := json.Get("in_header") + ... + // parse limit + limits := json.Get("limits") + ... + config.ShowLimitQuotaHeader = json.Get("show_limit_quota_header").Bool() + + // parse redis + redisConfig := json.Get("redis") + if !redisConfig.Exists() { + return errors.New("missing redis in config") + } + serviceName := redisConfig.Get("service_name").String() + if serviceName == "" { + return errors.New("redis service name must not be empty") + } + servicePort := int(redisConfig.Get("service_port").Int()) + if servicePort == 0 { + if strings.HasSuffix(serviceName, ".static") { + // use default logic port which is 80 for static service + servicePort = 80 + } else { + servicePort = 6379 + } + } + username := redisConfig.Get("username").String() + password := redisConfig.Get("password").String() + timeout := int(redisConfig.Get("timeout").Int()) + if timeout == 0 { + timeout = 1000 + } + config.RedisInfo.ServiceName = serviceName + config.RedisInfo.ServicePort = servicePort + config.RedisInfo.Username = username + config.RedisInfo.Password = password + config.RedisInfo.Timeout = timeout + config.RedisClient = wrapper.NewRedisClusterClient(wrapper.FQDNCluster{ + FQDN: serviceName, + Port: int64(servicePort), + }) + log.Debugf("parseConfig()+%+v", config) + return config.RedisClient.Init(username, password, int64(timeout)) +} +``` + +这里忽略插件配置解析的细节,主要显示 Redis 解析配置。可以看出这里需要调用 `wrapper.NewRedisClusterClient` 方法初始化 RedisClient 和 RedisClient `Init` 方法初始化 Redis 连接。 + +### 3.2 插件限流 Lua 脚本 +令牌桶限流的 Lua 脚本如下: +```lua +local tokens_key = KEYS[1] +local timestamp_key = KEYS[2] +--redis.log(redis.LOG_WARNING, "tokens_key " .. tokens_key) + +local rate = tonumber(ARGV[1]) +local capacity = tonumber(ARGV[2]) +local now = tonumber(ARGV[3]) +local requested = tonumber(ARGV[4]) +local unit = tonumber(ARGV[5]) + +local fill_time = capacity/rate +local ttl = math.floor(fill_time*2*unit) + +--redis.log(redis.LOG_WARNING, "rate " .. ARGV[1]) +--redis.log(redis.LOG_WARNING, "capacity " .. ARGV[2]) +--redis.log(redis.LOG_WARNING, "now " .. ARGV[3]) +--redis.log(redis.LOG_WARNING, "requested " .. ARGV[4]) +--redis.log(redis.LOG_WARNING, "filltime " .. fill_time) +--redis.log(redis.LOG_WARNING, "ttl " .. ttl) + +local last_tokens = tonumber(redis.call("get", tokens_key)) +if last_tokens == nil then + last_tokens = capacity +end +--redis.log(redis.LOG_WARNING, "last_tokens " .. last_tokens) + +local last_refreshed = tonumber(redis.call("get", timestamp_key)) +if last_refreshed == nil then + last_refreshed = 0 +end +--redis.log(redis.LOG_WARNING, "last_refreshed " .. last_refreshed) + +local delta = math.max(0, (now-last_refreshed)/unit) +local filled_tokens = math.min(capacity, last_tokens + math.floor(delta*rate)) +local allowed = filled_tokens >= requested +local new_tokens = filled_tokens +local allowed_num = 0 +if allowed then + new_tokens = filled_tokens - requested + allowed_num = 1 +end + +--redis.log(redis.LOG_WARNING, "delta " .. delta) +--redis.log(redis.LOG_WARNING, "filled_tokens " .. filled_tokens) +--redis.log(redis.LOG_WARNING, "allowed_num " .. allowed_num) +--redis.log(redis.LOG_WARNING, "new_tokens " .. new_tokens) + +if ttl > 0 then + redis.call("setex", tokens_key, ttl, new_tokens) + redis.call("setex", timestamp_key, ttl, now) +end + +return { allowed_num, new_tokens, capacity, ttl} +``` +Lua 脚本是在 Redis 中执行的,用于实现令牌桶限流算法。下面是对脚本参数和原理的分析: + +1. 参数解释: +- KEYS[1] 和 KEYS[2]:这两个参数是通过 Redis 调用传递的键(keys),通常用于存储令牌桶的当前令牌数(tokens_key)和最后刷新时间(timestamp_key)。 +- ARGV[1] 到 ARGV[5]:这些参数是通过 Redis 调用传递的参数,用于配置限流策略。 + - rate:单位时间内生成的令牌数量。 + - capacity:令牌桶的容量,即最多可以容纳的令牌数。 + - now:当前时间,通常以时间戳表示,以秒单位。 + - requested:当前请求需要的令牌数。 + - unit:令牌生成的时间单位,1 表示秒,60 表示分钟,3600 表示小时。 +2. 脚本原理: +- 初始化变量:根据传入的参数初始化令牌桶的填充时间和 TTL(生存时间)。 +- 获取当前状态:从 Redis 中获取当前的令牌数(last_tokens)和最后刷新时间(last_refreshed)。如果不存在,则初始化为令牌桶的容量。 +- 计算令牌填充: + - delta:自上次刷新以来经过的单位时间。 + - filled_tokens:根据 delta 和 rate 计算应该填充的令牌数,但不能超过桶的容量。 +- 判断是否允许请求: + - allowed:如果当前令牌数加上填充的令牌数大于等于请求的令牌数,则允许请求。 +- 更新令牌数: + - 如果请求被允许,从当前令牌数中减去请求的令牌数,更新 new_tokens。 +- 设置新的状态: + - 如果 TTL 大于 0,则更新 Redis 中的令牌数和时间戳,设置新的 TTL。 +- 返回结果: + - 返回一个包含限流结果的数组,包括是否允许请求、新的令牌数、桶容量和 TTL。 + +### 3.3 插件限流具体实现 + +限流主要实现在插件 `onHttpRequestHeaders` 方法中,部分核心代码如下: + +```golang +// onHttpRequestHeaders 函数在处理 HTTP 请求头时被调用,用于执行限流逻辑。 +func onHttpRequestHeaders(ctx wrapper.HttpContext, config LimitConfig, log wrapper.Log) types.Action { + log.Debugf("onHttpRequestHeaders()") + // 初始化 tokens 切片,用于存储从请求头或查询参数中提取的 tokens 信息 + var tokens []string + + // 如果配置指定从请求头中获取 tokens 信息 + if config.InHeader { + // 遍历配置中定义的所有键(key),尝试从请求头中获取每个键的值 + for _, key := range config.Keys { + // 使用 proxywasm 库的 GetHttpRequestHeader 函数获取请求头中的值 + value, err := proxywasm.GetHttpRequestHeader(key) + // 如果没有错误且值不为空,则将值添加到 tokens 切片中 + if err == nil && value != "" { + tokens = append(tokens, value) + } + } + } else if config.InQuery { + // 如果配置指定从查询参数中获取 tokens 信息 + // 获取 ":path" 请求头以获取请求 URL + requestUrl, _ := proxywasm.GetHttpRequestHeader(":path") + // 解析 URL 并获取查询参数 + url, _ := url.Parse(requestUrl) + queryValues := url.Query() + + // 遍历配置中定义的所有键 + for _, key := range config.Keys { + // 从查询参数中获取每个键的值 + values, ok := queryValues[key] + // 如果查询参数存在且有值,则将值添加到 tokens 切片中 + if ok && len(values) > 0 { + tokens = append(tokens, values...) + } + } + } + + // 如果从请求中提取了多于一个的 tokens,返回错误处理 + if len(tokens) > 1 { + return deniedMultiKeyAuthData() + } else if len(tokens) <= 0 { + // 如果没有提取到 tokens,返回错误处理 + return deniedNoKeyAuthData() + } + + // 提取第一个 token 作为主要的令牌信息 + limitKey := strings.Split(tokens[0], ",")[0] + log.Debugf("limitKey:%s", limitKey) + + // 根据提取的 limitKey 查找对应的限流项 + limitItem := findLimitItem(config, limitKey) + log.Debugf("limitItem:%+v", limitItem) + + // 如果没有找到对应的限流项,继续处理请求 + if limitItem.Consumer == "" { + return types.ActionContinue + } + + // 构建 Redis 脚本需要的键,用于操作令牌桶 + tokenKey := fmt.Sprintf(ClusterRateLimitFormat, limitKey, "token") + expireKey := fmt.Sprintf(ClusterRateLimitFormat, limitKey, "expire") + + // 获取当前时间,用于计算令牌桶的填充状态 + now := time.Now() + // 将当前时间转换为 Unix 时间戳(秒) + unixTimestamp := now.UnixNano() + milliseconds := unixTimestamp / 1e6 + seconds := milliseconds / 1000 + + // 构建调用 Redis 脚本所需的参数 + keys := []interface{}{tokenKey, expireKey} + args := []interface{}{limitItem.Rate, limitItem.Capacity, seconds, 1, 1} + + // 调用 Redis 脚本执行限流逻辑 + err := config.RedisClient.Eval(BucketTokenScript, 2, keys, args, func(response resp.Value) { + log.Debugf("RedisClient.Eval(),keys:%+v,args:%+v", keys, args) + // 检查脚本返回的结果是否包含 4 个元素 + resultArray := response.Array() + if len(resultArray) != 4 { + log.Errorf("redis response parse error, response: %v", response) + proxywasm.ResumeHttpRequest() + return + } + // 根据脚本返回的结果创建 LimitContext 对象 + context := LimitContext{ + Allowed: resultArray[0].Integer(), + Remaining: resultArray[1].Integer(), + Count: resultArray[2].Integer(), + Reset: resultArray[3].Integer(), + } + log.Debugf("context:%+v", context) + // 如果请求未被允许(Allowed <= 0),触发限流逻辑 + if context.Allowed <= 0 { + log.Debugf("request rejected") + rejected(config, context) + return + } else { + // 将限流上下文存储在 HttpContext 中,供后续处理使用 + ctx.SetContext(LimitContextKey, context) + } + // 恢复 HTTP 请求处理 + proxywasm.ResumeHttpRequest() + }) + // 如果调用 Redis 脚本时出现错误,记录错误并继续处理请求 + if err != nil { + log.Errorf("redis call failed: %v", err) + return types.ActionContinue + } + // 暂停处理当前请求头,等待 Redis 脚本调用完成 + return types.HeaderStopAllIterationAndWatermark +} + +// findLimitItem 函数用于在给定的配置中查找与特定消费者匹配的限流项。如果没有找到具体的匹配项,它将返回一个默认的 LimitItem 结构。 +func findLimitItem(config LimitConfig, key string) LimitItem { + // 遍历配置中的所有限流项 + for _, limitItem := range config.Limits { + // 检查当前限流项的消费者字段是否与提供的 key 匹配,且消费者不是通配符"*" + if limitItem.Consumer == key && limitItem.Consumer != "*" { + // 如果找到匹配的限流项,返回这个限流项 + return limitItem + } + } + // 再次遍历配置中的所有限流项,这次是为了查找通配符"*"的消费者 + // 通配符"*"表示这个限流项适用于所有消费者 + for _, limitItem := range config.Limits { + if limitItem.Consumer == "*" { + // 如果找到通配符限流项,返回这个限流项 + return limitItem + } + } + // 则返回一个空的 LimitItem 结构,表示没有找到任何适用的限流规则 + return LimitItem{} +} +``` +onHttpRequestHeaders 函数的核心逻辑可以概括为以下几个步骤: +- 获取 Tokens:根据配置(config.InHeader 或 config.InQuery),从请求头或查询参数中提取用于限流的 tokens 信息。 +- 验证 Tokens:检查提取的 tokens 是否存在且数量合理(不能多于一个),如果不符合要求,返回相应的错误处理。 +- 查找限流项:使用提取的 tokens 查找配置中的限流规则(LimitItem),如果没有找到适用的限流规则,则允许请求继续。 +- 执行限流逻辑:如果找到限流规则,构建 Redis 脚本需要的键和参数,然后调用 Redis 脚本执行限流算法。 +- 处理 Redis 脚本结果:根据 Redis 脚本返回的结果,创建 LimitContext 对象并根据算法结果决定是否允许请求继续: + - 如果请求被拒绝(context.Allowed \<= 0),执行限流逻辑并通知客户端。 + - 如果请求被允许,将 LimitContext 对象存储在 HttpContext 中,供后续处理使用。 + + +## 4 测试和验证 + +1. 正常流量 +```shell +curl -X POST -v http://127.0.0.1:10000/hello \ + -H 'Authorization: Bearer credential1' \ + -H 'Content-type: application/json' \ + -H 'host:foo.com' \ + -d '{"username":["unamexxxx"],"password":["pswdxxxx"]}' + +* Trying 127.0.0.1:10000... +* Connected to 127.0.0.1 (127.0.0.1) port 10000 (#0) +> POST /hello HTTP/1.1 +> Host:foo.com +> User-Agent: curl/8.1.2 +> Accept: */* +> Authorization: Bearer credential1 +> Content-type: application/json +> Content-Length: 50 +> +< HTTP/1.1 200 OK +< content-type: application/json +< x-content-type-options: nosniff +< date: Tue, 20 Aug 2024 08:55:13 GMT +< content-length: 692 +< req-cost-time: 42 +< req-arrive-time: 1724144113842 +< resp-start-time: 1724144113885 +< x-envoy-upstream-service-time: 8 +< x-ratelimit-limit: 4 +< x-ratelimit-remaining: 3 +< server: envoy +< +{ + "path": "/hello", + "host": "foo.com", + "method": "POST", + "proto": "HTTP/1.1", + "headers": { + "Accept": [ + "*/*" + ], + "Authorization": [ + "Bearer credential1" + ], + "Content-Length": [ + "50" + ], + "Content-Type": [ + "application/json" + ], + "Original-Host": [ + "foo.com" + ], + "Req-Start-Time": [ + "1724144113842" + ], + "User-Agent": [ + "curl/8.1.2" + ], + "X-Envoy-Expected-Rq-Timeout-Ms": [ + "15000" + ], + "X-Forwarded-Proto": [ + "https" + ], + "X-Request-Id": [ + "b40e9ebb-f36c-4e22-b8fc-2559c9495f43" + ] + }, + "namespace": "", + "ingress": "", + "service": "", + "pod": "", + "body": { + "password": [ + "pswdxxxx" + ], + "username": [ + "unamexxxx" + ] + } +``` +可以看到请求被允许,并且返回了相应的响应。 +```shell +< x-ratelimit-limit: 4 +< x-ratelimit-remaining: 3 +``` + +2. 触发流控 + +```shell +for i in $(seq 1 10); do + curl -X POST -v http://127.0.0.1:10000/hello \ + -H 'Authorization: Bearer credential1' \ + -H 'Content-type: application/json' \ + -H 'host:foo.com' \ + -d '{"username":["unamexxxx"],"password":["pswdxxxx"]}' +done + +> POST /hello HTTP/1.1 +> Host:foo.com +> User-Agent: curl/8.1.2 +> Accept: */* +> Authorization: Bearer credential1 +> Content-type: application/json +> Content-Length: 50 +> +< HTTP/1.1 429 Too Many Requests +< x-ratelimit-limit: 4 +< x-ratelimit-remaining: 0 +< x-ratelimit-reset: 4 +< content-length: 17 +< content-type: text/plain +< date: Tue, 20 Aug 2024 08:56:57 GMT +< server: envoy +< +* Connection #0 to host 127.0.0.1 left intact +Too Many Requests% +``` + +## 参考 +- [1] [限流算法选择](https://help.aliyun.com/document_detail/149952.html) + + + + + + + + diff --git a/src/content/docs/ebook/zh-cn/wasm19.md b/src/content/docs/ebook/zh-cn/wasm19.md new file mode 100644 index 0000000000..78abbbdf28 --- /dev/null +++ b/src/content/docs/ebook/zh-cn/wasm19.md @@ -0,0 +1,260 @@ +--- +title: Wasm 生效原理 +keywords: [Higress] +--- + + +# Wasm 生效原理 + +这一章主要介绍 Wasm 的生效原理包括全局/路由/域名/服务级别生效原理、Wasm插件的 phase & priority、以及 Wasm 插件分发的原理。 + +## 1 测试插件链结构 + +这里以 custom-response、transformer、key-auth、easy-logger 四个插件组成插件链为例,介绍 Wasm插件的生效原理。其插件链如下图: + +![img](https://img.alicdn.com/imgextra/i2/O1CN01sSmytv1DfnczmUj0j_!!6000000000244-2-tps-1830-460.png) + + +## 2 全局/路由/域名/服务级生效原理 + +以插件 custom-response 为例,其插件配置如下: +```yaml +apiVersion: extensions.higress.io/v1alpha1 +kind: WasmPlugin +metadata: + name: custom-response + namespace: higress-system +spec: + priority: 200 + phase: AUTHN + # 配置会全局生效,但如果被下面规则匹配到,则会改为执行命中规则的配置 + defaultConfig: + headers: + - key1=value1 + "body": "{\"hello\":\"foo\"}" + matchRules: + # 域名级生效配置 + - domain: + - foo.com + config: + headers: + - key2=value2 + "body": "{\"hello\":\"foo\"}" + - ingress: + - higress-course/ingress-bar + # higress-course 命名空间下名为 ingress-bar 的 ingress 会应用下面这个配置 + config: + headers: + - key3=value3 + "body": "{\"hello\":\"bar\"}" + - service: + - echo-server.higress-course.svc.cluster.local + config: + headers: + - key4=value4 + "body": "{\"hello\":\"echo server\"}" + url: oci://higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins/custom-response:1.0.0 + imagePullPolicy: Always +``` + +Higress Controller 控制面会把 Higress WasmPlugin 配置转换成 Istio WasmPlugin 配置,同时通过 MCP over Xds 同步到 Istio Discovery, 然后下发到 Envoy 。 +这里可以通过 Higress Controller Debug 接口查看转换后的 Istio WasmPlugin 配置: + +```shell +kubectl exec -c higress-core -n higress-system -- curl http://127.0.0.1:8888/debug/configz +``` +以下是 custom-response 插件转换成 Istio WasmPlugin YAML 配置如下: +```yaml +kind: WasmPlugin +apiVersion: extensions.istio.io/v1alpha1 +metadata: + name: custom-response + namespace: higress-system +spec: + imagePullPolicy: Always + phase: AUTHN + pluginConfig: + _rules_: + - _match_domain_: + - foo.com + body: '{"hello":"foo"}' + headers: + - key2=value2 + - _match_route_: + - higress-course/ingress-bar + body: '{"hello":"bar"}' + headers: + - key3=value3 + - _match_service_: + - echo-server.higress-course.svc.cluster.local + body: '{"hello":"echo server"}' + headers: + - key4=value4 + body: '{"hello":"foo"}' + headers: + - key1=value1 + priority: 200 + selector: + matchLabels: + higress: higress-system-higress-gateway + url: oci://higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins/custom-response:1.0.0 +``` +发现在 pluginConfig 中增加了 `_rules_` 规则列表,规则中可以指定匹配方式,并填写对应生效的配置: +- `_match_domain_`:匹配域名生效,填写域名即可,支持通配符。 +- `_match_route_`:匹配 Ingress 生效,匹配格式为:Ingress 所在命名空间 + "/" + Ingress 名称。 +- `_match_service_`:匹配服务生效,填写服务即可,支持通配符。 + +Higress Controller 控制面转换代码逻辑在 `pkg/ingress/config/ingress_config.go` 文件的 `convertIstioWasmPlugin` func 中实现,其实现代码逻辑比较简单。 +```golang +func (m *IngressConfig) convertIstioWasmPlugin(obj *higressext.WasmPlugin) (*extensions.WasmPlugin, error) { + ... +} +``` + +同时在第`十六章 Higress 插件 Go SDK 与处理流程`中介绍 `CommonPluginCtx` 插件上下文在插件启动时候解析全局/路由/域名/服务级配置,其代码逻辑在 `plugins/wasm-go/pkg/matcher/rule_matcher.go` 文件的 `ParseRuleConfig` func 中实现。 + +```golang +func (m *RuleMatcher[PluginConfig]) ParseRuleConfig(config gjson.Result, + parsePluginConfig func(gjson.Result, *PluginConfig) error, + parseOverrideConfig func(gjson.Result, PluginConfig, *PluginConfig) error) error { + ... +} +``` +另外在插件 `OnHttpRequestHeaders` 阶段根据当前请求的 `:authority`、`route_name`、`cluster_name` 获取对应的域名、路由、服务级和全局插件配置。其代码逻辑在 `plugins/wasm-go/pkg/matcher/rule_matcher.go` 文件的 `GetMatchConfig` func 中实现。 +```golang +func (m RuleMatcher[PluginConfig]) GetMatchConfig() (*PluginConfig, error) { + host, err := proxywasm.GetHttpRequestHeader(":authority") + ... + routeName, err := proxywasm.GetProperty([]string{"route_name"}) + ... + serviceName, err := proxywasm.GetProperty([]string{"cluster_name"}) + ... +} +``` +这里代码逻辑相对比较简单,这里就不再赘述了,有兴趣同学可以直接看源代码。 + +## 3 Wasm插件的 phase 和 priority + +Wasm 插件 phase 有 `UNSPECIFIED_PHASE`、`AUTHN`、`AUTHZ`、`STATS` 四个值,分别对应插件过滤器链的末端、Istio 认证过滤器之前、Istio 授权过滤器之前且在 Istio 认证过滤器之后、Istio 统计过滤器之前且在 Istio 授权过滤器之后。 +同时在相同 phase 情况下,priority 值越大,插件在插件链位置越靠前。 关于认证和授权相关内容可以参考 [Istio 安全](https://istio.io/latest/zh/docs/concepts/security/)官方文档。其插件链结构如下图: + +![img](https://img.alicdn.com/imgextra/i4/O1CN017aWyas29NFISP7P4o_!!6000000008055-2-tps-1274-1114.png) + +可以通过导出 Envoy 配置查看插件链结构: + +```shell +kubectl exec -n higress-system -- curl http://127.0.0.1:15000/config_dump +``` +其 Enovy 插件链结构 YAML 格式如下: +```yaml +name: envoy.filters.network.http_connection_manager +typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: outbound_0.0.0.0_80 + http_filters: + - name: envoy.filters.http.cors + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.cors.v3.Cors + - name: higress-system.custom-response + config_discovery: + config_source: + ads: {} + initial_fetch_timeout: 0s + resource_api_version: V3 + default_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.composite.v3.Composite + apply_default_config_without_warming: true + type_urls: + - type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm + - type.googleapis.com/envoy.extensions.filters.http.composite.v3.Composite + - name: higress-system.wasm-transformer + config_discovery: + config_source: + ads: {} + initial_fetch_timeout: 0s + resource_api_version: V3 + default_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.composite.v3.Composite + apply_default_config_without_warming: true + type_urls: + - type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm + - type.googleapis.com/envoy.extensions.filters.http.composite.v3.Composite + - name: envoy.filters.http.rbac + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBAC + - name: envoy.filters.http.local_ratelimit + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.local_ratelimit.v3.LocalRateLimit + stat_prefix: http_local_rate_limiter + - name: higress-system.wasm-keyauth + config_discovery: + config_source: + ads: {} + initial_fetch_timeout: 0s + resource_api_version: V3 + default_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.composite.v3.Composite + apply_default_config_without_warming: true + type_urls: + - type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm + - type.googleapis.com/envoy.extensions.filters.http.composite.v3.Composite + - name: higress-system.easy-logger + config_discovery: + config_source: + ads: {} + initial_fetch_timeout: 0s + resource_api_version: V3 + default_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.composite.v3.Composite + apply_default_config_without_warming: true + type_urls: + - type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm + - type.googleapis.com/envoy.extensions.filters.http.composite.v3.Composite + - name: envoy.filters.http.fault + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.fault.v3.HTTPFault + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router +``` + +## 4 Wasm 插件分发的原理 + +Wasm 插件是通过 OCI 实现了 wasm 文件更新,直接热加载,不会导致任何连接中断,业务流量完全无损。其插件分发流程如下图: + +![img](https://img.alicdn.com/imgextra/i4/O1CN01rx9nle1TI0uWQqI3Q_!!6000000002358-0-tps-1498-1058.jpg) + +OCI 分发流程如下: + +1. 当 Higress WasmPlugin 资源更新时,Higress Core 监听到这个变化,同时把 Higress WasmPlugin 转成 Istio WasmPlugin。 +2. Higress Core 将转成 Istio WasmPlugin 通过 MCP Over Xds 推送给 Discovery。 +3. Discovery 通过 Pilot Agent 的 ADS 连接,通过 LDS 协议下发给 Plot Agent。 +4. Pilot Agent 将 LDS 响应直接代理转发给 Envoy。 +5. Envoy 根据 Listener 里插件配置,通过 ECDS (Extension Config Discovery Service) 订阅插件配置。 +6. Pilot Agent 代理 ECDS 协议请求到 Discovery, 同时拦截 ECDS 协议响应。 +7. Pilot Agent 根据 ECDS 响应里插件 OCI 配置,从 Registry Hub 下载镜像。 +8. Pilot Agent 把镜像里插件 Wasm 文件解压到本地,同时修改 ECDS 响应里插件地址到本地 Wasm 文件路径,然后把 ECDS 协议响应返回给 Envoy。 +9. Envoy 根据 ECDS 协议响应,加载本地 Wasm 文件。 + +注意第 5 步没有直接下发插件配置。而是下发 config_discovery 配置。下面是 Envoy 导出 `custom-response` 插件配置。 + +```yaml +- name: higress-system.custom-response + config_discovery: + config_source: + ads: {} + initial_fetch_timeout: 0s + resource_api_version: V3 + default_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.composite.v3.Composite + apply_default_config_without_warming: true + type_urls: + - type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm + - type.googleapis.com/envoy.extensions.filters.http.composite.v3.Composite +``` +关于 ECDS 配置,可以参考 [Envoy ECDS](https://www.envoyproxy.io/docs/envoy/latest/api-v3/service/extension/v3/config_discovery.proto)。 + + +## 参考 +- [1][Higress 实战:30 行代码写一个 Wasm Go插件](https://mp.weixin.qq.com/s/daYa4MSo3XelpjnIFuxhKw) +