Day 03 / 共 20 天 · 第 1 周 入门与生命周期

proxy-wasm 与 ABI

昨天用了 proxywasm.GetHttpRequestHeader 这类调用。今天往下探一层:这些调用底层怎么和 Envoy 通信?Wasm 虚拟机怎么隔离?理解这层,你才明白插件"能做什么、不能做什么"的边界。

📍 你在 wasm-go 前 10 天的位置(W1 入门与生命周期 · W2 配置与匹配;W3-4 见 D11-20)
D01 全景· D02 request-block· D03 ABI· D04 三级Context· D05 SetCtx/选项 D06 配置解析· D07 RuleMatcher· D08 运行时匹配· D09 全局vs规则· D10 生命周期 W3-4 请求处理/高级
💡 延续「机场安检」类比看今天 前两天你已经会用 proxywasm.GetHttpRequestHeader 这类调用了;今天钻到地板下,看它们怎么和机场中控(Envoy)通信。ABI 就是芯片和中控之间那台"对讲机"约定好的话术——只有说约定好的暗号(proxy_get_header_map_value 之类)中控才听得懂。沙箱 = 芯片被关在一间只有对讲机窗口的小屋里:想读旅客证件、想发拦截通知,都不能自己伸手,只能对着对讲机喊、让中控替你做。理解这层,你就懂了插件"能做什么、不能做什么"的边界。
L01

ABI 是什么

🤔 痛点:两段用不同语言、跑在不同内存里的程序,怎么对话? Envoy 是 C++、你的插件是 Go 编译的 Wasm,各自内存互相看不见。没有一套白纸黑字的约定,插件想读个请求头都无从下手——就像两个不通语言的人对讲机在手却没约定暗号,喊了也白喊。
ABI = 两个程序之间的"接口约定" ABI(Application Binary Interface)是"Envoy 和 Wasm 插件之间约定好的一套函数"——比如"读某个请求头叫 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 函数。
L02

两个方向的调用

proxy-wasm 的调用是双向的:

  • Host → VM(下行钩子):Envoy 调插件。如 OnPluginStartOnHttpRequestHeaders——"请求头来了,插件你处理下"。
  • VM → Host(宿主调用/hostcall):插件调 Envoy。如 GetHttpRequestHeaderSendHttpResponseHttpCall——"帮我读个头/发个响应/调个后端"。
为什么要分两个方向? 插件是沙箱里的"客人",不能直接碰网关的内存和网络。所有交互都得通过 ABI 这道"传话窗口":Envoy 通过下行钩子把事件递进来("有请求了"),插件通过宿主调用把请求递出去("帮我读个头")。回想 Envoy 课的 proxy-wasm——那节课从网关侧讲这两个方向,今天从插件侧再看一遍。wasm-go 的所有 Process* 钩子对应下行调用,所有 proxywasm.Xxx 对应宿主调用。
对讲机的两个方向:喊话进来 vs 喊话出去 Envoy(机场中控 / Host) 掌握内存、网络、请求数据 Wasm 插件(沙箱小屋 / VM) 只有对讲机窗口 ① 下行钩子 Host→VM "请求头来了,你处理"→ OnHttpRequestHeaders ② 宿主调用 VM→Host:"帮我读个头 / 发个响应" → proxywasm.Xxx
图注:下行钩子是"中控喊插件干活",宿主调用是"插件喊中控借数据"。所有数据都过 ABI 这道窗口,插件从不直接碰内存。
📝 举个例子:一次拦截里两个方向各出现一次 请求头到达 → 中控喊话 OnHttpRequestHeaders(①下行)→ 插件想看路径,对讲机喊 GetHttpRequestHeader(":path")(②宿主调用)拿到 /swagger.html → 命中黑名单,再喊 SendHttpResponse(403,…)(②宿主调用)让中控发拦截页。插件全程没碰过一次真实内存,全靠喊话。
L03

VM 按线程克隆

回想 Envoy 课的线程模型:1 主 + N worker。每个 worker 线程有自己独立的一份 Wasm VM(VM 之间不共享内存)。plugin_wrapper.go 里每个 VM 有唯一 vmIDNewCommonVmCtxWithOptionsuuid.New().String())。

为什么每线程一份 VM? Envoy 用多个 worker 线程并行处理请求。如果所有线程共享一个 VM,就得加锁,慢且易错。所以给每个 worker 克隆一份独立 VM——各跑各的,无锁、无竞争。代价是:插件里的全局变量在每个 VM 里是独立的(一个 worker 改了另一个看不到)。要跨 VM/跨线程共享数据,得用 SharedData(Day 17 讲),不能用普通全局变量。vmID 就是用来区分是哪份 VM 的(Leader 选举时用)。

👶 小白:我在插件里写个 var counter int 统计请求总数,为什么数字对不上?

👨‍🏫 老师:因为 Envoy 有 N 个 worker 线程、每个线程一份独立 VM,你的 counter 在每份 VM 里各有一个,谁都只数到自己那份。就像机场开了 8 条安检线、每条线的计数器各记各的,加起来才是总数。想要"全局唯一的计数",得用跨 VM 的 SharedData(Day 17),普通全局变量做不到。

⚠️ 常见误解:以为"VM 克隆"是每来一个请求克隆一次。不是——是每个 worker 线程克隆一份、常驻整个进程生命周期;一份 VM 顺序处理它那条线上的成千上万个请求。
L04

三级上下文对象

proxy-wasm 定义了三层上下文,一层套一层:

  • VMContext:一个 VM 一个,管整个虚拟机生命周期。
  • PluginContext:一份配置一个,管插件配置和 tick。
  • HttpContext:一个请求一个,管单次请求处理。

wasm-go 对应三个类型:CommonVmCtx / CommonPluginCtx / HttpContext(plugin_wrapper.go:42-43 定义了别名)。

读法:层级关系:一个 VM 里可以有多份配置(多个 PluginContext),一份配置处理很多请求(多个 HttpContext)。明天 Day 04 专门拆这三级 Context 的字段和职责。
L05

DefaultVMContext

plugin_wrapper.go:75-95CommonVmCtx 内嵌了 types.DefaultVMContext

type CommonVmCtx[PluginConfig any] struct {
    types.DefaultVMContext    // 内嵌 proxy-wasm 的默认实现
    pluginName string
    log        log.Log
    parseConfig ParseRawConfigWithContextFunc[PluginConfig]
    onHttpRequestHeaders  onHttpHeadersFunc[PluginConfig]
    // ... 各阶段回调 ...
}
"内嵌"是 Go 的组合复用 Go 没有类继承,但可以"内嵌"一个类型,白得它的所有方法。DefaultVMContext 提供了 proxy-wasm 要求的一堆默认方法,CommonVmCtx 内嵌它就不用自己全写一遍,只需覆盖关心的(比如 NewPluginContext)。这是 Go 里"组合优于继承"的典型手法。你写插件时也是内嵌 SDK 提供的东西,只填自己关心的回调。
L06

沙箱与限制

Wasm 沙箱带来安全,也带来限制:

  • 插件不能直接开线程、不能直接读写文件/网络——一切 IO 走宿主调用。
  • 外部调用(HTTP/Redis)是异步回调式的(Day 14),不能像普通 Go 那样同步阻塞。
  • 全局变量每 VM 独立,跨 VM 共享用 SharedData。
"异步回调"为什么绕? 普通 Go 里你写 resp := http.Get(url) 就阻塞等结果。但 Wasm 插件跑在网关的 IO 循环里,不能阻塞(阻塞会卡住整个线程的其他请求)。所以外部调用必须"发起后立刻返回 ActionPause,结果到了再用回调处理"。这就是为什么 Day 14 的 HttpCall 长得跟普通 HTTP 请求不一样。适应这个"回调式"思维是写插件的关键门槛。
L07

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 提供的扩展函数)。
L08

今日小结 + 动手

🧠 今天你应该能回答

  • 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
明天预告 · Day 04三级 Context——细看 CommonVmCtx/CommonPluginCtx/HttpContext 各自的字段与职责,以及 iface/context.go 里 HttpContext 那一长串能力接口。
← Day 02 Day 04 · 三级 Context →