- 云原生
- API网关
- 微服务
- 服务网格
【免费下载链接】easegress
A Cloud Native traffic orchestration system. (CNCF Project)
本文以 Easegress 的 WasmHost 过滤器为核心,系统讲解如何在服务网关层用 WebAssembly 实现秒杀(Flash Sale)业务逻辑:从搭建 AssemblyScript 开发环境、创建 HTTPServer 与 Pipeline,到逐步实现"开售前拦截、随机放行、中签用户永久放行、限制放行名额"四层防护,并深入源码剖析 WasmHost 的 VM 池、共享数据与热更新机制。读完本文,你将掌握一套可直接落地的"网关 + WASM"高并发业务防护方案,并能理解其底层运行原理。
背景:为什么用"服务网关 + WebAssembly"处理秒杀
秒杀是电商在短时间内以限量、高折扣商品吸引用户的活动。优惠力度大、数量有限、持续时间短,三者叠加会带来剧烈的流量尖峰,往往导致服务变慢、拒绝服务甚至宕机。
要保护秒杀场景下的后端服务,关键在于把"控制逻辑"和"业务逻辑"分层:Easegress 作为服务网关,天然负责流量接入与控制逻辑;而秒杀这类业务逻辑高度定制、且可能频繁变化(每次活动的规则都可能不同),如果固化在网关代码里,每次调整都要重新发版。用 JavaScript、TypeScript 等高阶语言编写业务逻辑,开发效率高、技术门槛低;再借助 WebAssembly 技术把高阶语言代码编译为 WASM,就能在运行时被动态加载,同时获得良好的性能与安全性。因此"Easegress 网关 + WASM"在安全性、高性能与可定制扩展三方面提供了一个完整方案。
本文对应的完整文档位于 docs/03.Advanced-Cookbook/3.02.Flash-Sale.md,WasmHost 过滤器的通用讲解见 docs/03.Advanced-Cookbook/3.07.WasmHost.md。
1. 环境准备与项目搭建
1.1 前置依赖
动手前请确保本机已安装较新版本的 Git、Golang、Node.js 及其包管理器 npm。由于我们将使用与 TypeScript 非常相似的 AssemblyScript 编写 WASM 代码,具备 TypeScript 模块编写经验会更有帮助。
需要特别注意的是:WasmHost 过滤器默认是关闭的。从源码看,pkg/filters/wasmhost/wasmhost.go 整个文件带有//go:build wasmhost构建标签,只有开启该标签才会被编译进 Easegress。启用方法是用带wasmhost构建标签的方式重新构建服务端:
$ make build_server GOTAGS=wasmhost参考 Makefile 中第 41~47 行的逻辑:当GOTAGS包含wasmhost时,构建命令会自动追加CGO_ENABLED=1——这是因为 vm.go 底层依赖 bytecodealliance/wasmtime-go(Wasmtime 运行时),而 Wasmtime 需要 CGO 才能编译链接。
1.2 初始化 AssemblyScript 秒杀项目
按以下步骤搭建秒杀程序的 WASM 开发工程:
- 将 Easegress 的 AssemblyScript SDK 克隆到磁盘某处:
$ git clone https://github.com/megaease/easegress-assemblyscript-sdk.git- 切换到一个新目录并初始化 node 模块:
npm init- 用 npm 安装 AssemblyScript 编译器(编译器只在开发期使用,因此作为开发依赖):
npm install --save-dev assemblyscript- 使用编译器自带的脚手架工具,在当前 node 模块目录中快速生成 AssemblyScript 工程骨架:
npx asinit .- 在
package.json中给asc命令追加--use abort=,例如:
"asbuild:untouched": "asc assembly/index.ts --target debug --use abort=", "asbuild:optimized": "asc assembly/index.ts --target release --use abort=",- 用下面的代码替换
assembly/index.ts的内容,注意把{EASEGRESS_SDK_PATH}替换为第 1 步克隆 SDK 的实际路径。当前代码只是一个"骨架"程序,暂不执行任何业务逻辑,后续会逐步增强:
// this line exports everything required by Easegress, export * from '{EASEGRESS_SDK_PATH}/easegress/proxy' // import everything you need from the SDK, import { Program, registerProgramFactory } from '{EASEGRESS_SDK_PATH}/easegress' // define the program, 'FlashSale' is the name class FlashSale extends Program { // constructor is the initializer of the program, will be called once at the startup constructor(params: Map<string, string>) { super(params) } // run will be called for every request run(): i32 { return 0 } } // register a factory method of the FlashSale program registerProgramFactory((params: Map<string, string>) => { return new FlashSale(params) })这段骨架代码对应了 WasmHost 约定的程序生命周期,与 pkg/filters/wasmhost/vm.go 中的执行模型一一对应:
export * from '.../proxy'导出 Easegress 要求的wasm_run、wasm_alloc、wasm_free等符号——exportWasmFuncs会从 WASM 实例中读取这些导出(见 vm.go);constructor是程序初始化器,在启动时被调用一次——newWasmVM中通过可选的wasm_init导出传入参数数组(见 vm.go),参数正是来自过滤器配置中的parameters;run()对每个请求都会被调用,其返回值将决定该请求在 Pipeline 中的走向;registerProgramFactory注册程序工厂,运行时通过工厂创建程序实例。
- 执行构建命令,成功后会在
build目录生成untouched.wasm(调试版)与optimized.wasm(发布版):
$ npm run asbuild1.3 在 Easegress 中创建 HTTPServer 与 Pipeline
先在 Easegress 中创建一个 HTTPServer,监听 10080 端口处理 HTTP 流量:
$ echo ' kind: HTTPServer name: http-server port: 10080 keepAlive: true https: false rules: - paths: - pathPrefix: /flashsale backend: flash-sale-pipeline' | egctl create -f -再创建包含WasmHost过滤器的 Pipelineflash-sale-pipeline:
$ echo ' name: flash-sale-pipeline kind: Pipeline flow: - filter: wasm - filter: mock filters: - name: wasm kind: WasmHost maxConcurrency: 2 code: /home/megaease/example/build/optimized.wasm timeout: 100ms - name: mock kind: Mock rules: - body: "You can buy the laptop for $1 now.\n" code: 200' | egctl create -f -注意把/home/megaease/example/build/optimized.wasm替换为 1.2 节第 7 步生成文件的实际路径。
这里对 Pipeline 中的两个过滤器做一下说明:
- WasmHost:执行我们的秒杀业务逻辑。配置项与 wasmhost.go 中的
Spec结构对应:maxConcurrency是该过滤器创建的 WASM 虚拟机数量(默认值为 10,至少为 1);code是 WASM 代码来源,从 readWasmCode 的实现看,它既可以是一个本地文件路径、一个http:///https://URL,也可以是一段 base64 编码的代码内容;timeout是单次 WASM 执行超时,会由reload解析为time.Duration。Handle方法中,wasmhost.go 会启动一个 goroutine,在超时或请求上下文取消时调用vm.Interrupt()(通过 wasmtime 的 epoch 中断机制强制停止执行)。 - Mock:作为后端服务的替身,直接返回固定响应。参见 pkg/filters/mock/mock.go,其
rules中的code与body定义了响应状态码和响应体。
在实际生产环境中,这里的Mock应替换为Proxy过滤器,把放行后的请求转发到真实后端。
1.4 验证基础链路
在新终端执行下面的命令,一切正常时应得到类似结果:
$ curl http://127.0.0.1:10080/flashsale You can buy the laptop for $1 now.这说明请求依次通过了 WasmHost(当前为空操作)和 Mock(返回后端响应),基础链路已经打通。
2. 第一层防护:开售前拦截所有请求
秒杀活动都有开始时间,开始前的请求应当被拦截。假设活动开始时间是 UTC2021-08-08 00:00:00,用下面的代码即可实现:
export * from '{EASEGRESS_SDK_PATH}/easegress/proxy' import { Program, response, parseDate, getUnixTimeInMs, registerProgramFactory } from '{EASEGRESS_SDK_PATH}/easegress' class FlashSale extends Program { // startTime is the start time of the flash sale, unix timestamp in millisecond startTime: i64 constructor(params: Map<string, string>) { super(params) this.startTime = parseDate("2021-08-08T00:00:00+00:00").getTime() } run(): i32 { // if flash sale not start yet if (getUnixTimeInMs() < this.startTime) { // we just set response body to 'not start yet' here, in practice, // we will use 'response.setStatusCode(302)' to redirect user to // a static page. response.setBody(String.UTF8.encode("not start yet.\n")) return 1 } return 0 } } registerProgramFactory((params: Map<string, string>) => { return new FlashSale(params) })这里的getUnixTimeInMs()对应宿主函数host_get_unix_time_in_ms(pkg/filters/wasmhost/hostfunc.go),返回毫秒级 Unix 时间戳;response.setBody对应host_resp_set_body(hostfunc.go),用于覆写响应体。
重点理解run()的返回值语义。从 wasmhost.go 的wasmResultToFilterResult可以看到:返回0表示继续执行 Pipeline 中后面的过滤器;返回非 0 值n(1~9)会被转换为名为wasmResult{n}的过滤器结果。而 pkg/object/pipeline/pipeline.go 的执行逻辑是:当过滤器的返回结果非空、且在jumpIf中找不到对应跳转目标时,Pipeline 将直接走到END(内置终止节点)——也就是说,只要 WASM 返回非 0,本次请求就不会再往下游转发。这正是一切拦截逻辑的基础:秒杀未开始时返回 1,请求直接被截停,并回写 "not start yet." 响应。实践中通常会把这里改成response.setStatusCode(302),把用户重定向到一个静态页面。
构建并通知 Easegress 重新加载代码:
$ npm run asbuild $ egctl wasm reload-codeegctl wasm reload-code命令的实现见 cmd/client/commandv2/wasm.go:它向服务端的/wasm/code接口发送 POST 请求(对应 pkg/api/wasm.go),后者在集群中写入一个带时间戳的"代码重载事件"。WasmHost 内部通过 watchWasmCode 监听该事件,一旦收到就重新读取 WASM 代码、重建 VM 池——这就是"零停机热更新"的实现路径。
在活动开始前 curl 秒杀地址,将得到:
$ curl http://127.0.0.1:10080/flashsale not start yet.3. 第二层防护:随机拦截请求
秒杀开始后,Easegress 应当随机拦截一部分请求。这样做有两个目的:
- 保护后端:大幅减少到达后端服务的请求总数,避免流量尖峰击穿服务;
- 保证公平:地理位置差异导致不同用户的网络延迟不同,低延迟用户天然更容易抢先。随机性抹平了这部分用户的优势,让秒杀更加公平。
export * from '{EASEGRESS_SDK_PATH}/easegress/proxy' import { Program, response, parseDate, getUnixTimeInMs, rand, registerProgramFactory } from '{EASEGRESS_SDK_PATH}/easegress' class FlashSale extends Program { startTime: i64 // blockRatio is the ratio of requests being blocked to protect backend service // for example: 0.4 means we blocks 40% of the requests randomly. blockRatio: f64 constructor(params: Map<string, string>) { super(params) this.startTime = parseDate("2021-08-08T00:00:00+00:00").getTime() this.blockRatio = 0.4 } run(): i32 { if (getUnixTimeInMs() < this.startTime) { response.setBody(String.UTF8.encode("not start yet.\n")) return 1 } if (rand() > this.blockRatio) { // the lucky guy return 0 } // block this request, set response body to `sold out` response.setBody(String.UTF8.encode("sold out.\n")) return 2 } } registerProgramFactory((params: Map<string, string>) => { return new FlashSale(params) })rand()对应宿主函数host_rand(hostfunc.go),内部直接调用 Go 标准库的rand.Float64()返回 [0,1) 区间的随机浮点数。rand() > blockRatio成立即放行(概率为 1 - blockRatio),否则返回 2 拦截并回写 "sold out."。
构建并验证(假设秒杀已经开始):
$ npm run asbuild $ egctl wasm apply-data --reload-code $ curl http://127.0.0.1:10080/flashsale sold out. $ curl http://127.0.0.1:10080/flashsale You can buy the laptop for $1 now. $ curl http://127.0.0.1:10080/flashsale sold out.有 40% 的概率得到 "sold out" 提示。注意示例中blockRatio为0.4,而实践中0.999、0.9999这样的值才更有意义——秒杀商品的库存极少,99.9% 以上的拦截率可以最大限度过滤流量。
补充说明egctl wasm apply-data --reload-code这条命令:--reload-code是 egctl 提供的一个便捷开关,在写入共享数据的同时触发一次代码重载。如果不带该开关,则apply-data只写入数据,命令格式为egctl wasm apply-data <pipeline> <filter>(见 wasm.go)。
4. 第三层防护:中签一次,永远放行(Lucky Once, Lucky Always)
从业务角度看,一个用户一旦被放行("中签"),后续就不应再被拦截;但第 3 节的纯随机逻辑做不到这一点——用户再次访问时仍可能被随机拦截。
好在秒杀场景下用户都需要先登录,请求中会携带用户标识。作为示例,我们假设Authorization请求头的值就是用户标识(生产环境中该标识可以是 JWT 令牌,并可用 Easegress 的 Validator 过滤器校验,参见 docs/02.Tutorials/2.5.Traffic-Verification.md 中的 Validator 过滤器用法,但这超出了本文范围)。
4.1 为什么不能把数据存在 WASM 内
最容易想到的做法是在 WASM 程序内部用一个Set记录所有已放行的用户。但在maxConcurrency大于 1 时,这种做法行不通:
maxConcurrency是 WasmHost 过滤器创建的 WASM 虚拟机(VM)数量。从 vm.go 可以看到,NewWasmVMPool会按host.spec.MaxConcurrency预先创建同样数量的 VM 放入通道。WebAssembly 以安全为首要设计原则,两个 VM 之间无法共享数据,即使它们执行的是同一份代码。于是:VM1 放行了用户 A,但如果该用户的下一次请求恰好被 VM2 处理,就可能被拦截。当 Easegress 以集群形态部署时,跨节点的共享问题同样存在。
4.2 用集群共享数据解决问题
为此,Easegress 提供了一组访问共享数据的 API(对应 hostfunc.go 中的host_cluster_*系列宿主函数):
export * from '{EASEGRESS_SDK_PATH}/easegress/proxy' import { Program, request, parseDate, response, cluster, getUnixTimeInMs, rand, registerProgramFactory } from '{EASEGRESS_SDK_PATH}/easegress' class FlashSale extends Program { startTime: i64 blockRatio: f64 constructor(params: Map<string, string>) { super(params) this.startTime = parseDate("2021-08-08T00:00:00+00:00").getTime() this.blockRatio = 0.4 } run(): i32 { if (getUnixTimeInMs() < this.startTime) { response.setBody(String.UTF8.encode("not start yet.\n")) return 1 } // check if the user was already permitted let id = request.getHeader("Authorization") if (cluster.getString("id/" + id) == "true") { return 0 } if (rand() > this.blockRatio) { // add the lucky guy to permitted users cluster.putString("id/" + id, "true") return 0 } response.setBody(String.UTF8.encode("sold out.\n")) return 2 } } registerProgramFactory((params: Map<string, string>) => { return new FlashSale(params) })关键点解读:
request.getHeader("Authorization")对应host_req_get_header(hostfunc.go),读取请求头;cluster.getString/cluster.putString分别对应host_cluster_get_string/host_cluster_put_string(hostfunc.go)。注意 readClusterKeyFromWasm 会把传入的 key 拼上dataPrefix(即/wasm/data/{pipeline}/{filter}/,见 pkg/cluster/layout.go),所以代码里写的"id/" + id最终落在集群键.../id/user1上;- 共享数据存储在 etcd 集群中,WasmHost 通过 watchWasmData 使用集群同步器持续监听该前缀的数据变化并缓存到本地,因此集群内所有节点、所有 VM 都能看到同一份数据——这是与 VM 内
Set方案的本质区别。
构建并验证:
$ npm run asbuild $ egctl wasm apply-data --reload-code $ curl http://127.0.0.1:10080/flashsale -HAuthorization:user1 sold out. $ curl http://127.0.0.1:10080/flashsale -HAuthorization:user1 You can buy the laptop for $1 now. $ curl http://127.0.0.1:10080/flashsale -HAuthorization:user1 You can buy the laptop for $1 now.重复执行 curl 会发现:user1 一旦首次被放行,此后就再也不会被拦截。
5. 第四层防护:限制放行用户总数
秒杀商品数量有限,因此可以在放行达到一定数量后拦截所有后续用户。例如商品数量为 10 时,放行 100 个用户通常就足够了(示例中为了便于验证,maxPermission设为 3):
export * from '{EASEGRESS_SDK_PATH}/easegress/proxy' import { Program, request, parseDate, response, cluster, getUnixTimeInMs, rand, registerProgramFactory } from '{EASEGRESS_SDK_PATH}/easegress' class FlashSale extends Program { startTime: i64 blockRatio: f64 // maxPermission is the upper limits of permitted users maxPermission: i32 constructor(params: Map<string, string>) { super(params) this.startTime = parseDate("2021-08-08T00:00:00+00:00").getTime() this.blockRatio = 0.4 this.maxPermission = 3 } run(): i32 { if (getUnixTimeInMs() < this.startTime) { response.setBody(String.UTF8.encode("not start yet.\n")) return 1 } let id = request.getHeader("Authorization") if (cluster.getString("id/" + id) == "true") { return 0 } // check the count of identifiers to see if we have reached the upper limit if (cluster.countKey("id/") < this.maxPermission) { if (rand() > this.blockRatio) { cluster.putString("id/" + id, "true") return 0 } } response.setBody(String.UTF8.encode("sold out.\n")) return 2 } } registerProgramFactory((params: Map<string, string>) => { return new FlashSale(params) })cluster.countKey("id/")对应host_cluster_count_key(hostfunc.go):它在本地缓存中遍历所有 key,统计以传入前缀开头的键数量。这里统计的是已放行用户数,一旦达到maxPermission上限,新用户即使随机数"中签"也不会被放行。
构建并验证:
$ npm run asbuild $ egctl wasm apply-data --reload-code $ curl http://127.0.0.1:10080/flashsale -HAuthorization:user1 You can buy the laptop for $1 now. $ curl http://127.0.0.1:10080/flashsale -HAuthorization:user2 sold out. $ curl http://127.0.0.1:10080/flashsale -HAuthorization:user2 You can buy the laptop for $1 now. $ curl http://127.0.0.1:10080/flashsale -HAuthorization:user3 You can buy the laptop for $1 now. $ curl http://127.0.0.1:10080/flashsale -HAuthorization:user4 sold out. $ curl http://127.0.0.1:10080/flashsale -HAuthorization:user4 sold out.可以看到:3 个用户被放行后,第 4 个用户被永久拦截(user4 重试仍返回 "sold out.")。
6. 让程序可复用:参数化与共享数据管理
6.1 把配置参数化
前面几节把startTime、blockRatio、maxPermission硬编码在了代码里。这意味着每举办一次新秒杀活动都要修改代码,这不是好的实践。
更好的做法是把这些参数放进过滤器配置。WasmHost 的Spec结构中有Parameters map[string]string字段(wasmhost.go),这些参数会在创建 VM 时经wasm_init传入 WASM 程序(见 vm.go)。修改 Pipeline 配置:
filters: - name: wasm kind: WasmHost parameters: # + startTime: "2021-08-08T00:00:00+00:00" # + blockRatio: "0.4" # + maxPermission: "3" # +然后改写程序的constructor,从参数表中读取这些值(带默认值兜底):
constructor(params: Map<string, string>) { super(params) let key = "startTime" if (params.has(key)) { let val = params.get(key) this.startTime = parseDate(val).getTime() } key = "blockRatio" if (params.has(key)) { let val = params.get(key) this.blockRatio = parseFloat(val) } key = "maxPermission" if (params.has(key)) { let val = params.get(key) this.maxPermission = i32(parseInt(val)) } }这样,同一份 WASM 代码配合不同的parameters配置即可复用于任意一场秒杀活动。注意parameters是map[string]string,所有值都以字符串形式传入,因此代码中需要parseFloat、parseInt等解析。
6.2 用 egctl 管理共享数据
如第 4 节所见,共享数据很有用;但复用同一份代码和配置举办新活动时,上一场活动遗留的数据可能造成问题。Easegress 提供了专门的命令来管理这些数据。
查看当前数据(flash-sale-pipeline是 Pipeline 名,wasm是过滤器名):
$ egctl wasm list-data flash-sale-pipeline wasm id/user1: "true" id/user2: "true" id/user3: "true"更新数据:
$ echo ' id/user4: "true" id/user5: "true"' | egctl wasm apply-data flash-sale-pipeline wasm $ egctl wasm list-data flash-sale-pipeline wasm id/user1: "true" id/user2: "true" id/user3: "true" id/user4: "true" id/user5: "true"删除全部数据:
$ egctl wasm delete-data flash-sale-pipeline wasm $ egctl wasm list-data flash-sale-pipeline wasm {}这些命令分别对应 cmd/client/commandv2/wasm.go 中的list-data(GET)、apply-data(PUT)、delete-data(DELETE),底层访问服务端的/wasm/data/{pipeline}/{filter}接口(见 pkg/api/wasm.go)。其中apply-data的实现值得注意:它使用 etcd 的事务 STM 批量写入(pkg/api/wasm.go),保证并发写入的一致性;delete-data则直接调用DeletePrefix清理该过滤器对应的整个数据前缀(pkg/api/wasm.go)。
这里也顺带解释apply-data与reload-code的关系:egctl wasm apply-data --reload-code(不带<pipeline> <filter>参数)是全局性的——先向/wasm/code发布重载事件,集群中所有 WasmHost 过滤器都会重新加载代码;而带<pipeline> <filter>参数的apply-data只针对指定过滤器的共享数据。
6.3 生产环境的补充考量
以上示例仅用于演示核心思路。一套完整的生产级秒杀方案还需要考虑:爬虫与滥用防护、容量规划,以及与业务强相关的专属控制规则。如需进一步探讨 Easegress 在该场景下的能力边界,可以在 easegress 仓库提交 issue 讨论(参见 CONTRIBUTING.md 了解反馈渠道)。
7. 总结
本文演示了如何用 Easegress 的 WasmHost 过滤器在网关层完整实现一场秒杀的核心防护逻辑,防护策略可以总结为四层递进:
| 层级 | 策略 | 核心手段 | WASM 返回码 |
|---|---|---|---|
| 第一层 | 开售前全拦 | 时间戳比较 | 1 |
| 第二层 | 随机拦截 | 随机数 + 拦截比例 | 2 |
| 第三层 | 中签用户永久放行 | 集群共享数据(getString/putString) | 0 / 2 |
| 第四层 | 限制放行总数 | 共享数据前缀计数(countKey) | 0 / 2 |
这些能力建立在 WasmHost 过滤器的一系列底层机制之上,本文对应的源码证据包括:过滤器规范与 VM 池管理在 pkg/filters/wasmhost/wasmhost.go 与 pkg/filters/wasmhost/vm.go,宿主函数实现(请求/响应读写、集群数据、随机数与时间)在 pkg/filters/wasmhost/hostfunc.go,管理接口在 pkg/api/wasm.go。
利用 WebAssembly 的安全性、高性能与实时动态加载能力,我们不仅能在网关层承载高并发业务,还能实现更复杂的业务逻辑扩展——WebAssembly 可以复用 JavaScript、C/C++、Rust、Python、C# 等多种高阶语言(需要语言对应的 SDK)。在分布式架构下,Easegress 在"高性能流量编排 + 集群级共享状态"上还有更多能力可以挖掘,两者结合,为高效运维解决这类高并发问题带来了广阔的想象空间。
- 云原生
- API网关
- 微服务
- 服务网格
【免费下载链接】easegress
A Cloud Native traffic orchestration system. (CNCF Project)
相关推荐
Easegress WasmHost 过滤器实战:用 WebAssembly 扩展流量编排能力
Easegress WasmHost 过滤器实战:用 WebAssembly 扩展流量编排能力 WasmHost 是 Easegress 中一个特殊的过滤器(f
云原生API网关微服务服务网格如何安全解锁Netgear路由器的隐藏Telnet功能:技术原理与实践指南
如何安全解锁Netgear路由器的隐藏Telnet功能:技术原理与实践指南 对于网络管理员和技术爱好者来说,Netgear路由器隐藏的Telnet控制台一直是一
网络安全嵌入式Google Authenticator PAM模块源码解析:从PAM接口到认证算法的完整流程
Google Authenticator PAM模块源码解析:从PAM接口到认证算法的完整流程 Google Authenticator PAM模块是一款强大的
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考