什么是 Wasm 插件?
你可以使用 Wasm 插件在数据路径上添加自定义代码,轻松地扩展服务网格的功能。可以用你选择的语言编写插件。目前,有 AssemblyScript(TypeScript-ish)、C++、Rust、Zig 和 Go 语言的 Proxy-Wasm SDK。
在这篇博文中,我们描述了如何使用 Wasm 插件来验证一个请求的有效载荷。这是 Wasm 与 Istio 的一个重要用例,也是你可以使用 Wasm 扩展 Istio 的许多方法的一个例子。您可能有兴趣阅读我们关于在 Istio 中使用 Wasm 的博文,并观看我们关于在 Istio 和 Envoy 中使用 Wasm 的免费研讨会的录音。
何时使用 Wasm 插件?
当你需要添加 Envoy 或 Istio 不支持的自定义功能时,你应该使用 Wasm 插件。使用 Wasm 插件来添加自定义验证、认证、日志或管理配额。
在这个例子中,我们将构建和运行一个 Wasm 插件,验证请求 body 是 JSON,并包含两个必要的键 ——id
和 token
。
编写 Wasm 插件
这个示例使用 tinygo 来编译成 Wasm。确保你已经安装了 tinygo 编译器。
配置 Wasm 上下文
首先配置 Wasm 上下文,这样 tinygo 文件才能操作 HTTP 请求:
package main
import (
"github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm"
"github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm/types"
"github.com/tidwall/gjson"
)
func main() {
// SetVMContext 是配置整个 Wasm VM 的入口。请确保该入口在 main 函数中调用,否则 VM 将启动失败。
proxywasm.SetVMContext(&vmContext{})
}
// vmContext 实现 proxy-wasm-go SDK 的 types.VMContext 接口。
type vmContext struct {
// 在这里嵌入默认的虚拟机环境,我们不需要实现所有方法。
types.DefaultVMContext
}
// 复写 types.DefaultVMContext
func (*vmContext) NewPluginContext(contextID uint32) types.PluginContext {
return &pluginContext{}
}
// pluginContext 实现 proxy-wasm-go SDK 的 types.PluginContext 接口
type pluginContext struct {
// 在这里侵入默认的插件上下文,我们不需要实现所有方法。
types.DefaultPluginContext
}
// 复写 types.DefaultPluginContext
func (ctx *pluginContext) NewHttpContext(contextID uint32) types.HttpContext {
return &payloadValidationContext{}
}
// payloadValidationContext 实现 proxy-wasm-go SDK 的 types.HttpContext 接口
type payloadValidationContext struct {
// 在这里嵌入默认的根 http 上下文,我们不需要实现所有方法。
types.DefaultHttpContext
totalRequestBodySize int
}
验证负载
内容类型头是通过实现 OnHttpRequestHeaders
来验证的,一旦从客户端收到请求头,就会调用该头。
proxywasm.SendHttpResponse
用于响应 403 forbidden 的错误代码和信息,如果内容类型丢失的话。
func (ctx *payloadValidationContext) OnHttpRequestHeaders(numHeaders int, endOfStream bool) types.Action {
contentType, err := proxywasm.GetHttpRequestHeader("content-type")
if err != nil || contentType != "application/json" {
// 如果 header 没有期望的 content type,返回 403 响应
if err := proxywasm.SendHttpResponse(403, nil, []byte("content-type must be provided"), -1); err != nil {
proxywasm.LogErrorf("failed to send the 403 response: %v", err)
}
// 终止 ActionPause 对流量的进一步处理
return types.ActionPause
}
// ActionContinue 让主机继续处理 body
return types.ActionContinue
}
请求主体是通过实现 OnHttpRequestBody
来验证的,每次从客户端接收到请求的一个块时,都会调用该请求。这是通过等待直到 endOfStream
为真并记录所有收到的块的总大小来完成的。一旦收到整个主体,就会使用 proxywasm.GetHttpRequestBody
读取,然后可以使用 golang 进行验证。
这个例子使用 gjson
,因为 tinygo 不支持 golang 的默认 JSON 库。它检查有效载荷是否是有效的 JSON,以及键 id
和 token
是否存在。
func (ctx *payloadValidationContext) OnHttpRequestBody(bodySize int, endOfStream bool) types.Action {
ctx.totalRequestBodySize += bodySize
if !endOfStream {
// OnHttpRequestBody 等待收到到 body 的全部才开始处理。
return types.ActionPause
}
body, err := proxywasm.GetHttpRequestBody(0, ctx.totalRequestBodySize)
if err != nil {
proxywasm.LogErrorf("failed to get request body: %v", err)
return types.ActionContinue
}
if !validatePayload(body) {
// 如果验证失败,发送 403 响应。
if err := proxywasm.SendHttpResponse(403, nil, []byte("invalid payload"), -1); err != nil {
proxywasm.LogErrorf("failed to send the 403 response: %v", err)
}
// 终止流量
return types.ActionPause
}
return types.ActionContinue
}
// validatePayload 验证给定的 json 负载
// 注意该函数使用 gjson 解析 json,因为 TinyGo 不支持 encoding/json
func validatePayload(body []byte) bool {
if !gjson.ValidBytes(body) {
proxywasm.LogErrorf("body is not a valid json: %v", body)
return false
}
jsonData := gjson.ParseBytes(body)
// 验证 json。检查示例中是否存在必须的键
for _, requiredKey := range []string{"id", "token"} {
if !jsonData.Get(requiredKey).Exists() {
proxywasm.LogErrorf("required key (%v) is missing: %v", requiredKey, jsonData)
return false
}
}
return true
}
编译成 Wasm
使用 tinygo 编译器编译成 Wasm:
tinygo build -o main.wasm -scheduler=none -target=wasi main.go
部署 Wasm 插件
打包到 Docker 中部署到 Envoy
对于开发,这个插件可以在 Docker 中部署到 Envoy。下面的 Envoy 配置文件将设置 Envoy 监听 localhost:18000
,运行所提供的 Wasm 插件,并在成功后响应 HTTP 200 和文本 hello from server
。突出显示的部分是配置 Wasm 插件。
static_resources:
listeners:
- name: main
address:
socket_address:
address: 0.0.0.0
port_value: 18000
filter_chains:
- filters:
- name: envoy.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: ingress_http
codec_type: auto
route_config:
name: local_route
virtual_hosts:
- name: local_service
domains:
- "*"
routes:
- match:
prefix: "/"
route:
cluster: web_service
http_filters:
- name: envoy.filters.http.wasm
typed_config:
"@type": type.googleapis.com/udpa.type.v1.TypedStruct
type_url: type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm
value:
config:
vm_config:
runtime: "envoy.wasm.runtime.v8"
code:
local:
filename: "./main.wasm"
- name: envoy.filters.http.router
- name: staticreply
address:
socket_address:
address: 127.0.0.1
port_value: 8099
filter_chains:
- filters:
- name: envoy.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: ingress_http
codec_type: auto
route_config:
name: local_route
virtual_hosts:
- name: local_service
domains:
- "*"
routes:
- match:
prefix: "/"
direct_response:
status: 200
body:
inline_string: "hello from the server\n"
http_filters:
- name: envoy.filters.http.router
typed_config: {}
clusters:
- name: web_service
connect_timeout: 0.25s
type: STATIC
lb_policy: ROUND_ROBIN
load_assignment:
cluster_name: mock_service
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: 127.0.0.1
port_value: 8099
admin:
access_log_path: "/dev/null"
address:
socket_address:
address: 0.0.0.0
port_value: 8001
运行 Docker 容器:
docker run --rm -p 18000:18000 \
-v $PWD/envoy.yaml:/envoy.yaml \
-v $PWD/main.wasm:/main.wasm \
--entrypoint envoy containers.istio.tetratelabs.com/proxyv2:1.9.7-tetrate-v0 \
-l debug \
-c /envoy.yaml
通过 curl 测试。首先,没有设置内容类型,将返回 403:
% curl -i -X POST localhost:18000
HTTP/1.1 403 Forbidden
content-length: 29
content-type: text/plain
date: Sun, 13 Mar 2022 22:13:37 GMT
server: envoy
content-type must be provided
然后,请求 body 不是 JSON,同样返回 403。
% curl -i -X POST localhost:18000 -H 'Content-Type: application/json' --data 'not JSON'
HTTP/1.1 403 Forbidden
content-length: 15
content-type: text/plain
date: Sun, 13 Mar 2022 22:15:53 GMT
server: envoy
invalid payload
JSON 负载中没有 token
字段,还是返回 403。
% curl -i -X POST localhost:18000 -H 'Content-Type: application/json' --data '{"id": "xxx"}'
HTTP/1.1 403 Forbidden
content-length: 15
content-type: text/plain
date: Sun, 13 Mar 2022 22:17:18 GMT
server: envoy
invalid payload
当 id 和 token 字段都被提供时,将返回一个成功的响应。
% curl -i -X POST localhost:18000 -H 'Content-Type: application/json' --data '{"id": "xxx", "token": "xxx", "anotherField": "yyy"}'
HTTP/1.1 200 OK
content-length: 22
content-type: text/plain
date: Sun, 13 Mar 2022 22:18:37 GMT
server: envoy
x-envoy-upstream-service-time: 1
hello from the server
部署到 Istio
部署 Istio 和 httpbin 示例应用
我们使用 kind 来创建测试集群,对于其他方式创建的 Kubernetes 集群同样适用。
kind create cluster
集群创建完毕后,安装 Istio,我们使用的是 Istio 1.12.3,安装 Istio httpbin 示例应用。
istioctl install --set profile=demo
kubectl label namespace default istio-injection=enabled
kubectl apply -f https://raw.githubusercontent.com/istio/istio/release-1.12/samples/httpbin/httpbin.yaml
kubectl apply -f https://raw.githubusercontent.com/istio/istio/release-1.12/samples/httpbin/httpbin-gateway.yaml
在另一个终端中,将 Ingress 网关的 80 端口转发到你本地机器的 8080 端口上。
kubectl port-forward -n istio-system svc/istio-ingressgateway 8080:80
发送 curl 请求,检查服务是否正常启动,你应该应该看到成功的响应。
curl -X POST -i http://localhost:8080/post
有两种方式在 Istio 中安装 Wasm 模块:
使用 WasmPlugin 安装
WasmPlugin 资源从镜像仓库中提取 wasm 模块。因此,让我们首先为我们的 wasm 模块构建并推送一个 Docker 镜像。下面的 Docker 文件允许从你的 wasm 模块建立一个 Docker 镜像。
FROM scratch
COPY main.wasm ./
构建镜像,推送到镜像仓库。
export HUB=your_registry # e.g. docker.io/tetrate
docker build . -t $HUB/json-validation:v1
docker push $HUB/json-validation:v1
现在我们创建 WasmPlugin 资源。这将适用于所有通过 Istio Ingress 网关暴露的路由,并对其应用我们的验证。确保你把 {your_registry}
替换为你上传 wasm 镜像的镜像仓库。
apiVersion: extensions.istio.io/v1alpha1
kind: WasmPlugin
metadata:
name: json-validation
namespace: istio-system
spec:
selector:
matchLabels:
istio: ingressgateway
url: oci://{your_registry}/json-validation:v3
imagePullPolicy: IfNotPresent
phase: AUTHN
使用 EnvoyFilter 安装
为了使用 EnvoyFilter,我们创建一个包含已编译的 Wasm 插件的 ConfigMap,将 ConfigMap 挂载到网关 pod 中,然后通过 EnvoyFilter 配置 Envoy,从本地文件加载 Wasm 插件。这种方法的限制是,更大和更复杂的 Wasm 模块可能超出 ConfigMap 1MB 的大小限制。
首先,创建一个包含编译好的 Wasm 模块的 ConfigMap:
kubectl -n istio-system create configmap wasm-plugins --from-file=main.wasm
然后在 Istio Ingress 网关部署中打补丁,挂载这个 ConfigMap。
kubectl -n istio-system patch deployment istio-ingressgateway --patch='
spec:
template:
spec:
containers:
- name: istio-proxy
volumeMounts:
- name: wasm-plugins
mountPath: /var/local/lib/wasm-plugins
readOnly: true
volumes:
- name: wasm-plugins
configMap:
name: wasm-plugins'
现在 Wasm 模块就挂载到了网关 Pod 中,应用这个 EnvoyFilter。
apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
name: json-validation
namespace: istio-system
spec:
configPatches:
- applyTo: HTTP_FILTER
match:
context: GATEWAY
patch:
operation: INSERT_BEFORE
value:
name: json-validation
typed_config:
'@type': type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm
config:
vm_config:
code:
local:
filename: /var/local/lib/wasm-plugins/main.wasm
runtime: envoy.wasm.runtime.v8
vm_id: json-validation
测试 Wasm 插件
重复之前的 curl 请求。
% curl -X POST -i http://localhost:8080/post
HTTP/1.1 403 Forbidden
content-length: 29
content-type: text/plain
date: Tue, 15 Mar 2022 22:04:35 GMT
server: istio-envoy
content-type must be provided
如果提供了内容类型和 json 负载的话,请求将会成功。
curl -i http://localhost:8080/post -H 'Content-Type: application/json' --data '{"id": "xxx", "token": "xxx"}'
让必填字段可配置
与其在编译的 golang 代码中硬编码所需的 JSON 字段,不如允许通过 Envoy 配置来配置这些字段。
当在 Docker 中运行 Envoy 时,可以通过向之前创建的 Wasm http_filter
添加配置来实现。
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: |
{ "requiredKeys": ["id", "token"] }
vm_config:
runtime: "envoy.wasm.runtime.v8"
code:
local:
filename: "./main.wasm"
当使用 WasmPlugin,在 pluginConfig
字段中配置。
apiVersion: extensions.istio.io/v1alpha1
kind: WasmPlugin
metadata:
name: json-validation
namespace: istio-system
spec:
selector:
matchLabels:
istio: ingressgateway
url: oci://{your_registry}/json-validation:v3
imagePullPolicy: IfNotPresent
phase: AUTHN
pluginConfig:
requiredKeys: ["id", "token"]
最后,当使用 EnvoyFilter 时,将它添加到 filter 配置中。
value:
name: json-validation
typed_config:
'@type': type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm
config:
configuration:
"@type": type.googleapis.com/google.protobuf.StringValue
value: |
{ "requiredKeys": ["id", "token"] }
vm_config:
code:
local:
filename: /var/local/lib/wasm-plugins/main.wasm
runtime: envoy.wasm.runtime.v8
vm_id: json-validation
在代码中,实现 OnPluginStart
,使用 proxywasm.GetPluginConfiguration
加载。
// pluginContext 实现 proxy-wasm-go SDK 中的 types.PluginContext 接口
type pluginContext struct {
// 在这里嵌入默认的 plugin 上下文,这样就不用实现所有方法
types.DefaultPluginContext
configuration *pluginConfiguration
}
// pluginConfiguration 代表这个 wasm 插件中的示例配置
type pluginConfiguration struct {
// 示例配置字段,插件将验证 json 负载中是否存在这些字段。
requiredKeys []string
}
// 复写 types.DefaultPluginContext
func (ctx *pluginContext) OnPluginStart(pluginConfigurationSize int) types.OnPluginStartStatus {
data, err := proxywasm.GetPluginConfiguration()
if err != nil {
proxywasm.LogCriticalf("error reading plugin configuration: %v", err)
return types.OnPluginStartStatusFailed
}
config, err := parsePluginConfiguration(data)
if err != nil {
proxywasm.LogCriticalf("error parsing plugin configuration: %v", err)
return types.OnPluginStartStatusFailed
}
ctx.configuration = config
return types.OnPluginStartStatusOK
}
// parsePluginConfiguration 解析 json 插件配置并返回 pluginConfiguration
// 注意使用 gjson 解析 json,因为 TinyGo 不支持 encoding/json
// 你也可以使用 https://github.com/mailru/easyjson,支持解析为结构体
func parsePluginConfiguration(data []byte) (*pluginConfiguration, error) {
config := &pluginConfiguration{}
if !gjson.ValidBytes(data) {
return nil, fmt.Errorf("the plugin configuration is not a valid json: %v", data)
}
jsonData := gjson.ParseBytes(data)
requiredKeys := jsonData.Get("requiredKeys").Array()
for _, requiredKey := range requiredKeys {
config.requiredKeys = append(config.requiredKeys, requiredKey.Str)
}
return config, nil
}
现在它们被包含在 pluginConfiguration
结构中,它们可以像其他字段一样在验证过程中被使用。
// validatePayload 验证给定的 json 负载
// 注意该函数使用 gjson 解析 json,因为 TinyGo 不支持 encoding/json
func (ctx *payloadValidationContext) validatePayload(body []byte) bool {
if !gjson.ValidBytes(body) {
proxywasm.LogErrorf("body is not a valid json: %v", body)
return false
}
jsonData := gjson.ParseBytes(body)
// 验证 json。检查示例中是否包含必须的键。
// 必须的键通过插件配置。
for _, requiredKey := range ctx.requiredKeys {
if !jsonData.Get(requiredKey).Exists() {
proxywasm.LogErrorf("required key (%v) is missing: %v", requiredKey, jsonData)
return false
}
}
return true
}
然后可以使用与之前相同的命令对其进行编译和测试。
总结
总而言之,要在 Istio 1.12 和更新的版本上使用 Wasm 插件,需要三个步骤:
该教程还详细介绍了如何使用 Docker 在 Envoy 容器中运行 Wasm 插件,以加快开发速度,以及如何将其部署到旧的 Istio 版本。