proxy-wasm 与 ABI
昨天用了 proxywasm.GetHttpRequestHeader 这类调用。今天往下探一层:这些调用底层怎么和 Envoy 通信?Wasm 虚拟机怎么隔离?理解这层,你才明白插件"能做什么、不能做什么"的边界。
proxywasm.GetHttpRequestHeader 这类调用了;今天钻到地板下,看它们怎么和机场中控(Envoy)通信。ABI 就是芯片和中控之间那台"对讲机"约定好的话术——只有说约定好的暗号(proxy_get_header_map_value 之类)中控才听得懂。沙箱 = 芯片被关在一间只有对讲机窗口的小屋里:想读旅客证件、想发拦截通知,都不能自己伸手,只能对着对讲机喊、让中控替你做。理解这层,你就懂了插件"能做什么、不能做什么"的边界。ABI 是什么
proxy_get_header_map_value、发起 HTTP 调用叫 proxy_http_call"。proxy-wasm 是一套开放标准的 ABI,Envoy、其他代理都实现了它。插件只要按这套 ABI 写,就能跑在任何支持 proxy-wasm 的代理上。好比 USB 标准——插头和插座约定好形状,任何 USB 设备都能插任何 USB 口。proxy-wasm-go-sdk 就是把这些 ABI 函数包成 Go 函数。两个方向的调用
proxy-wasm 的调用是双向的:
- Host → VM(下行钩子):Envoy 调插件。如
OnPluginStart、OnHttpRequestHeaders——"请求头来了,插件你处理下"。 - VM → Host(宿主调用/hostcall):插件调 Envoy。如
GetHttpRequestHeader、SendHttpResponse、HttpCall——"帮我读个头/发个响应/调个后端"。
Process* 钩子对应下行调用,所有 proxywasm.Xxx 对应宿主调用。OnHttpRequestHeaders(①下行)→ 插件想看路径,对讲机喊 GetHttpRequestHeader(":path")(②宿主调用)拿到 /swagger.html → 命中黑名单,再喊 SendHttpResponse(403,…)(②宿主调用)让中控发拦截页。插件全程没碰过一次真实内存,全靠喊话。VM 按线程克隆
回想 Envoy 课的线程模型:1 主 + N worker。每个 worker 线程有自己独立的一份 Wasm VM(VM 之间不共享内存)。plugin_wrapper.go 里每个 VM 有唯一 vmID(NewCommonVmCtxWithOptions 里 uuid.New().String())。
vmID 就是用来区分是哪份 VM 的(Leader 选举时用)。👶 小白:我在插件里写个 var counter int 统计请求总数,为什么数字对不上?
👨🏫 老师:因为 Envoy 有 N 个 worker 线程、每个线程一份独立 VM,你的 counter 在每份 VM 里各有一个,谁都只数到自己那份。就像机场开了 8 条安检线、每条线的计数器各记各的,加起来才是总数。想要"全局唯一的计数",得用跨 VM 的 SharedData(Day 17),普通全局变量做不到。
三级上下文对象
proxy-wasm 定义了三层上下文,一层套一层:
- VMContext:一个 VM 一个,管整个虚拟机生命周期。
- PluginContext:一份配置一个,管插件配置和 tick。
- HttpContext:一个请求一个,管单次请求处理。
wasm-go 对应三个类型:CommonVmCtx / CommonPluginCtx / HttpContext(plugin_wrapper.go:42-43 定义了别名)。
DefaultVMContext
plugin_wrapper.go:75-95 的 CommonVmCtx 内嵌了 types.DefaultVMContext:
type CommonVmCtx[PluginConfig any] struct {
types.DefaultVMContext // 内嵌 proxy-wasm 的默认实现
pluginName string
log log.Log
parseConfig ParseRawConfigWithContextFunc[PluginConfig]
onHttpRequestHeaders onHttpHeadersFunc[PluginConfig]
// ... 各阶段回调 ...
}
DefaultVMContext 提供了 proxy-wasm 要求的一堆默认方法,CommonVmCtx 内嵌它就不用自己全写一遍,只需覆盖关心的(比如 NewPluginContext)。这是 Go 里"组合优于继承"的典型手法。你写插件时也是内嵌 SDK 提供的东西,只填自己关心的回调。沙箱与限制
Wasm 沙箱带来安全,也带来限制:
- 插件不能直接开线程、不能直接读写文件/网络——一切 IO 走宿主调用。
- 外部调用(HTTP/Redis)是异步回调式的(Day 14),不能像普通 Go 那样同步阻塞。
- 全局变量每 VM 独立,跨 VM 共享用 SharedData。
resp := http.Get(url) 就阻塞等结果。但 Wasm 插件跑在网关的 IO 循环里,不能阻塞(阻塞会卡住整个线程的其他请求)。所以外部调用必须"发起后立刻返回 ActionPause,结果到了再用回调处理"。这就是为什么 Day 14 的 HttpCall 长得跟普通 HTTP 请求不一样。适应这个"回调式"思维是写插件的关键门槛。CallForeignFunction
有些高级能力不在标准 ABI 里,用"外部函数"扩展。例如 plugin_wrapper.go:497-505 设置 IO 并发限制:
func setGlobalMaxRequestsPerIoCycle(maxRequests uint64) error {
param := make([]byte, 8)
binary.LittleEndian.PutUint64(param, maxRequests)
_, err := proxywasm.CallForeignFunction("set_global_max_requests_per_io_cycle", param)
// ...
}
CallForeignFunction 是"调用宿主提供的自定义函数"——Higress 版 Envoy 扩展了一些标准 proxy-wasm 没有的能力(如 IO 限流、streaming 注入),用这个通道调用。这也说明 wasm-go 是为 Higress 定制的(依赖 Higress fork 的 Envoy 提供的扩展函数)。今日小结 + 动手
🧠 今天你应该能回答
- ABI 是什么?proxy-wasm 为什么像 USB 标准?
- 下行钩子 vs 宿主调用,分别对应 wasm-go 的什么?
- 为什么每 worker 线程一份 VM?带来什么限制?
- 三级上下文是哪三级?为什么外部调用必须异步?
✋ 动手
cd /Users/bitmart/work/codes/github/higress-group/wasm-go
sed -n '75,122p' pkg/wrapper/plugin_wrapper.go
sed -n '497,505p' pkg/wrapper/plugin_wrapper.go
# 看 proxy-wasm-go-sdk 提供的 proxywasm.* 函数
go doc github.com/higress-group/proxy-wasm-go-sdk/proxywasm 2>/dev/null | head -40
CommonVmCtx/CommonPluginCtx/HttpContext 各自的字段与职责,以及 iface/context.go 里 HttpContext 那一长串能力接口。