恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
APISIX Serverless探针:临时网关逻辑的轻量解决方案
首页
资讯中心
/
APISIX Serverless探针:临时网关逻辑的轻量解决方案
APISIX Serverless探针:临时网关逻辑的轻量解决方案
发布时间:2026/10/6 5:07:22
前阵子线上一个对外API网关接到一个临时需求根据调用方传来的用户ID尾号给上游后端带一个灰度分组标识规则只跑两周不想上版本。按常规思路我等于要为这一行逻辑去写一个完整的APISIX插件建项目目录、写schema、写rewrite或access阶段方法、走代码评审、发布、重新加载。说实话为了这么点临时逻辑动刀太重了。这时候APISIX内置的serverless-pre-function和serverless-post-function就派上了用场。这对插件允许我把一段Lua代码直接挂在路由配置上一个在请求的预处理阶段执行一个在access阶段收尾、转发上游之前执行就像在请求链路上插了两根轻量级“探针”探头进去干点活就跑主流程一点不用动。这篇就把这对插件的原理、配置、实战和踩坑经验完整过一遍适合正在维护APISIX网关、经常遇到“临时加逻辑”需求的同行。1. 先搞清楚这对“探针”到底探的是什么1.1 请求生命周期里的两个“插针点”APISIX的请求处理并不是一个黑盒。一次请求从客户端进来到把响应返回给客户端中间会经过若干阶段。在APISIX内部主要分这么几段路由匹配、插件的rewrite阶段、插件的access阶段、向上游发起转发、收到上游响应后的header_filter/body_filter、最后是log。其中真正影响“要不要转发、转发给谁、带什么头”的就是前三个路由匹配、rewrite、access。serverless-pre-function的默认插入点是rewrite阶段。这个阶段在路由匹配完成之后、其他插件的“业务动作”之前适合做一些早期处理注入头、改写参数、拦截非法请求。如果你觉得rewrite太早想在access阶段和鉴权、限流这类插件同一个时机跑也可以把它的phase显式配成access。这里有一个很关键的认知APISIX的插件并不是“一个插件只在一个阶段跑”而是每个插件可以在多个阶段注册回调serverless-pre-function之所以能选phase就是因为它内部支持在rewrite和access两个阶段注册同一个执行逻辑。serverless-post-function则固定在access阶段设计上放在这个阶段比较靠后的位置执行。很多刚接触的人看到“post”会以为是响应阶段跑其实不是。它跑的时候前面那些鉴权、改写的插件都已经执行完了请求还没发给上游你在这个函数里改请求头、改上游变量依然能影响最终转发结果。用一句话记住两个插件的分工pre管“进门之前/刚进门时要干什么”post管“出门之前最后再确认一遍”。理解这一点对排查问题特别重要。我有一次遇到一个很奇怪的现象在serverless-post-function里设置了一个响应头结果客户端一直看不到。查了半天才发现我把函数配在rewrite阶段执行了那个阶段设置的响应头后面根本不会被保留因为请求还没走到上游这个“响应头”只是Nginx内部一个临时变量后面的流程根本不会把它带回给客户端。所以阶段选错不是“早一点晚一点”的问题而是“根本不生效”的问题。1.2 一段代码怎么从配置变成可执行函数这两个插件接收的配置并不复杂核心就是一个functions数组。数组里每一项是一个对象可以带可选的name必须带一段funfun是字符串形式的Lua代码。很多人第一次配置会卡在fun的写法上。它不是让你直接写函数体而是要你写一段可以返回函数的Lua chunk。也就是说这段字符串经过编译执行后必须返回一个function这个function才会在每个请求阶段被调用。函数签名统一是function(ctx)ctx就是当前请求的上下文对象里面挂着大量运行时信息比如ctx.var可以访问Nginx变量ctx.consumer能拿到匹配到的消费者ctx.router里有路由相关信息。用官方文档的经典示例来说明{ serverless-pre-function: { phase: rewrite, functions: [ { name: hello, fun: return function(ctx)\n ngx.log(ngx.ERR, \serverless pre function\)\nend } ] } }这个函数被加载后APISIX会把它编译成Lua闭包缓存起来配置不变的情况下后续每个请求调用的是同一个闭包不会每次请求都重新解析一遍源码。这也是为什么它虽然叫serverless但实际运行开销并不大——它没有容器、没有进程只是“函数粒度的动态代码插桩”。如果你配置了多个函数APISIX会按数组里出现的顺序逐个调用前一个函数执行完再执行下一个。函数之间可以通过在ctx上挂自定义字段来传递数据例如前一个函数里ctx.my_temp_flag yes后一个函数里直接读ctx.my_temp_flag这种用法在pre和post配合的时候很实用。1.3 为什么不用写完整插件非要搞这对“探针”写一个完整插件的成本不光是那几行Lua。你得建项目目录、写schema校验、写对应的阶段方法、注册到APISIX的插件列表里然后还要考虑构建、加载、灰度。如果逻辑只是“临时用两周”“只覆盖一个路由”“就几行代码”这些成本就很不划算。serverless这对插件本质上就是官方给“短平快”场景留的口子。通过Admin API把代码作为路由配置的一部分写进etcdAPISIX会自动同步到所有worker不需要reload Nginx不需要重新发布。和直接在Nginx里写Lua比它又多了一层优势能直接读到APISIX上下文里的内容比如路由里的变量、前面插件的执行结果、匹配到的consumer信息这些信息在裸Nginx Lua里拿起来是很费劲的。但这不意味着所有逻辑都该塞进来。我个人的判断标准很简单如果这段逻辑要长期维护、要覆盖多个路由、要有单元测试那就该写成正式插件如果只是一次性观察、短期灰度、紧急封堵探针就是最合适的工具。后面我会专门讲什么场景用了会后悔。2. 配置一对探针从Admin API到可视化面板2.1 用Admin API把探针挂到路由上APISIX的配置入口一般是Admin API默认端口9180。下面这个例子我给 /demo 这个路由挂了一个serverless-pre-function在rewrite阶段给请求加一个自定义头。curl http://127.0.0.1:9180/apisix/admin/routes/1 -X PUT -d { uri: /demo, plugins: { serverless-pre-function: { phase: rewrite, functions: [ { name: inject_env_header, fun: return function(ctx)\n ngx.req.set_header(\X-Env\, \gray\)\nend } ] } }, upstream: { type: roundrobin, nodes: { 127.0.0.1:8080: 1 } } }如果控制台配置了Admin API认证记得在curl里加-H X-API-KEY: 你的key否则会收到401。配置成功后用GET请求这个路由的配置能看到functions已经写进etcd。接着直接请求网关端口验证curl -i http://127.0.0.1:9080/demo正常情况下上游那边能收到X-Env: gray。如果你没有后端可以看可以先在函数里加一句ngx.log(ngx.ERR, injected)再到/usr/local/apisix/logs/error.log里看日志确认执行过。这里要提醒一句Admin API的PUT操作是整体覆盖如果你在路由上已经有别的插件用上面这种方式会把其他插件覆盖掉。正确的做法是先GET当前路由配置把返回的value字段拿出来在plugins里加上你要的探针配置再PUT回去。这个坑我踩过一次还好是在测试环境直接把一个路由的限流插件冲掉了。2.2 不想写curl用静态配置或Dashboard也行如果你习惯用standalone模式本地调试很常见可以在config.yaml里以静态路由方式写同样内容APISIX启动时会直接加载。这种方式适合把探针配置放进git做代码评审也适合临时本地复现问题。如果你用的是APISIX Dashboard流程也不复杂进到“路由”页面选择一个路由点“添加插件”搜索serverless-pre-function填phase然后在functions里把name和fun粘贴进去。要注意的是Dashboard表单模式下fun里的换行和引号特别容易出错我建议先在本地写好JSON再整体粘到编辑器的JSON视图里。之前我在表单里手敲换行函数一直报语法错误切成JSON视图一次就过了。Dashboard的好处是直观但坏处也很明显编辑器对Lua代码没有语法校验拼错了它只会存进去然后在日志里报错。所以不管用哪种方式配配完都建议立刻打一个测试请求验证不要隔半天再想起来。2.3 phase参数决定了探针的“视野”pre-function的phase有两个可选项rewrite和access默认是rewrite。这个参数别随便用默认值它决定了函数能读到什么、会影响什么。rewrite阶段相对早适合做这几件事给请求注入头、拦截明显不合规的请求、改写URI参数。但要注意这时候路由虽然已经匹配了但一些插件的上下文数据可能还没生成比如consumer还没识别、某些在access阶段才产生的变量还没有。想在鉴权通过之后再干活的千万别放rewrite。access阶段是APISIX插件业务逻辑的主战场。serverless-pre-function配成access后会跟key-auth、limit-count这些插件一起按优先级顺序执行。如果你给同一个路由同时配置了key-auth和serverless-pre-functionaccess那么执行顺序取决于插件优先级pre-function优先级较高通常会在鉴权之前跑。这里有个很实用的技巧如果你想让代码“在鉴权之后运行”最省心的办法不是去调pre的phase而是直接用serverless-post-function它天然在access阶段靠后的位置执行这个时候鉴权结果已经出来了你可以放心地读ctx.consumer或者根据前面插件的返回值决定要不要终止请求。post-function没有phase可以配它唯一的位置就是“access阶段末尾、转发上游之前”。它的特点决定了它特别适合做“最后一道检查”“补日志字段”“动态调整上游变量”这类事情。我整理了一个简单的选型表方便你对照自己的需求选想做的事推荐方案路由匹配后、其他插件前注入头serverless-pre-function rewrite与鉴权插件同一阶段但想靠前执行serverless-pre-function access要在鉴权之后、转发上游之前做逻辑serverless-post-function改请求头后再转发pre或post均可看你要不要先看前面插件结果改响应体、响应头用response-rewrite等响应阶段插件别用post3. 实战我平时用探针解决的三个真实问题3.1 临时灰度按用户ID尾号给上游打标回到开头说的那个场景。用户那边要求user_id尾号为偶数的请求打上X-Gray-Group: blue奇数的打green两周后下线。我直接在目标路由上配了这样的函数{ serverless-pre-function: { phase: rewrite, functions: [ { name: gray_by_user, fun: return function(ctx)\n local uid ctx.var.arg_user_id or \\\n local last string.sub(uid, -1)\n local group \green\\n if tonumber(last) and tonumber(last) % 2 0 then\n group \blue\\n end\n ngx.req.set_header(\X-Gray-Group\, group)\nend } ] } }几点说明ctx.var.arg_user_id对应Nginx变量$arg_user_id也就是URL上的query参数user_id。取最后一位字符判断奇偶。数字尾号可能不全是数字用tonumber先判空防止报错。写完以后我给后端接口打了两条不同参数的请求后端日志里能看到请求头分组已经正确。两周后需求下线直接把这段插件配置从路由上删掉PUT一次就完事连一行代码提交都没有。这种临时灰度的需求特别适合探针因为它的生命周期很短、逻辑只针对一个路由、而且很容易在代码评审时被问“这段代码两周后还在不在”。把这种逻辑放到配置里反而方便到期清理。3.2 紧急封堵UA为空或明显为脚本的请求直接拒绝有段时间线上出现一批不带User-Agent的异常请求打到了某个比较耗资源的接口。临时接入WAF来不及写插件又得很久我选择在对应的几个路由上加了一个serverless-pre-functionaccess阶段判断UA为空直接403。{ serverless-pre-function: { phase: access, functions: [ { name: block_empty_ua, fun: return function(ctx)\n local ua ctx.var.http_user_agent or \\\n if ua \\ then\n ngx.exit(403)\n end\nend } ] } }这里有个值得一提的细节为什么选access而不是rewrite因为我想让这段逻辑在路由匹配、基础信息都齐了以后再执行而且我后面如果要接真实风控access阶段的位置更合适。直接ngx.exit(403)会终止当前请求流程返回403状态码后面的插件不会再执行请求也不会继续向上游转发。实测下来这个拦截很干净对正常请求零影响。当然这种拦截逻辑本质上还是轻量防御真正要长时间防脚本还是应该用更完整的防护方案探针适合应急不适合长跑。如果你需要返回一个JSON格式的响应体可以在函数里用APISIX的core模块local core require(apisix.core) core.response.exit(403, {reason ua is empty})这种方式会走APISIX统一响应封装返回带body的JSON更便于排查方理解拦截原因。3.3 轻量统计用共享字典统计几个路由的请求量还有一种很常见的用法临时统计某个接口的QPS、拦截量。serverless函数里可以直接用ngx.shared共享字典我在config.yaml的nginx_config里提前定义好一个lua_shared_dict api_stat 10m;然后在函数里local dict ngx.shared.api_stat dict:incr(req_hello_total, 1, 0)配合一个serverless-pre-function放在统计目标路由上函数体就是上面三行。统计值存在共享字典里多个worker之间共享不会有“每个进程各算各的”问题。想读值的时候我临时开一个只有自己知道的路由用serverless-pre-function把当前值打到响应头里看完再删。如果你对数据准确性要求很高记得这只是内存级统计APISIX重启会清零适合短周期观测不适合做持久化报表。多提一句不要试图用Lua闭包里的局部变量做统计例如在函数返回的function里local count 0; count count 1这个变量只在单个worker进程内有效而且很可能在请求间被复用统计出来是错的。共享字典才是正确姿势。如果你想统计每个路由各自的量可以用ctx.var.host或路由ID作为key的一部分拆成明细计数。4. 常见问题与排查技巧实录4.1 函数不生效先按这四个方向排查我踩过不少坑总结下来函数不生效基本就四种原因。第一函数根本没被命中。先确认请求确实走到了配置了插件的那个路由路径、Host、方法任何一项不匹配都可能走向别的路由。APISIX的路由匹配优先级和普通Nginx不一样没有“先到先得”的直觉你配在路由A上的探针请求可能被路由B匹配走了。排查办法是在目标路由的upstream后端日志里看一眼有没有收到请求或者临时在另一个肯定命中的路由上加同样的探针对比。第二phase不对。比如在rewrite阶段去读ctx.consumer读不到是正常的改成access再看。这个前面讲过了不再赘述。第三fun格式错了。最常见就是忘了return function(ctx)只写了函数体或者JSON里换行没转义导致Lua源码语法错误。这种错误一般在error.log里会有明显的load失败记录关键字是“serverless”或“load”。如果你本地装了Lua或者OpenResty可以先把fun里的代码提出来用luajit -bl或者简单的luac -p做一次语法检查能筛掉大部分低级错误。第四配置还在同步中。APISIX的配置是异步同步到各worker的刚PUT完立刻打流量极小概率会命中旧配置等一两秒再看。整理成一张速查表症状可能原因优先检查方向函数完全没执行路由没命中、插件配置没生效看路由匹配GET路由配置确认日志报Lua语法错误fun没有返回function、JSON转义问题单独验证Lua代码语法读不到consumer或插件结果阶段太早改用access阶段或post-function改了header但上游没收到被后续插件覆盖检查proxy-rewrite等插件顺序函数变更后行为没变配置没同步、key冲突确认PUT成功等同步再测4.2 想在探针里直接结束请求别忽略退出方式在serverless函数里终止请求比较直接的方式是ngx.exit(403)前面场景二就是这么干的。但有一点要注意如果函数前面已经改了一部分状态或者你只是想“拦截但不报错”更好的做法是先设置ngx.status 403再调用ngx.exit(403)确保Nginx返回的是预期状态码。如果你直接ngx.exit()不带状态码Nginx会沿用当前状态码有时候会是默认的200这个坑在应急拦截时特别致命——你以为把请求挡了客户端和后端看到的却是200正常返回监控完全发现不了。在APISIX里还有一个更贴合框架的写法引入local core require(apisix.core)然后core.response.exit(403, {reason ua is empty})它会走APISIX统一响应封装支持返回JSON body。我在应急封堵时经常用这种带body的返回方便排查方一眼看懂原因。如果你用的是Dashboard记得在fun的开头加上require不要漏掉。4.3 日志怎么打、怎么看别被海量日志淹没在函数里打日志最基础的是ngx.log(ngx.ERR, msg)但生产环境error级别日志往往很多不方便检索。我建议在关键探针里用ngx.log(ngx.WARN, [serverless-demo] injected header)这种带固定前缀的方式方便grep。也可以引入local core require(apisix.core); core.log.warn(...)。注意不要在函数里把整个ctx打出来里面包含很多敏感信息比如请求头、cookie、上游连接信息而且日志量会非常恐怖。想看当前请求的完整变量可以临时在函数里打印ctx.var.remote_addr、ctx.var.request_uri这类关键字段足够定位问题就行。调试完记得把日志级别调回来或者直接删掉这行避免长期刷日志。还有一个经验如果你用serverless-pre-function和serverless-post-function两个探针配合想确认执行顺序和每个函数是否都跑了可以在每个函数入口打一条带唯一标识的日志。比如pre函数打[serverless-pre-1]post函数打[serverless-post-1]然后看error.log里的时间顺序。这个方法在排查“pre改了变量但post读不到”的问题时特别有效。4.4 性能、安全与可维护性探针不是万能的这几个提醒都是我实际用下来的经验。第一不要在探针里做阻塞操作。ngx.sleep、同步的socket调用、耗时超过几十毫秒的循环都会卡住整个Nginx worker对同一进程上的所有请求产生连带影响。探针就干“轻活”重活交给后端。如果你确实需要调用外部接口应该用ngx.location.capture或者异步机制而不是在函数里发起同步请求。第二探针的代码越短越好。如果逻辑超过一屏或者要覆盖多个路由我强烈建议还是写正式插件。原因很简单探针代码以字符串存在etcd里没有单元测试、没有静态检查、没有版本控制除非你自己把配置同步到git一旦写错你需要靠日志去定位。它属于“快速生效也容易快速爆炸”的东西。如果一个探针函数超过20行我基本会开始考虑要不要拆成正式插件。第三不要对不可信输入做动态代码拼接。也就是不要把URL参数、请求体内容拼进字符串再loadstring这等于给攻击者开了一扇代码执行的门。探针里只用固定逻辑处理变量值绝不eval用户输入。这条安全红线一定要守住APISIX的serverless机制本身是安全的但滥用动态执行就是不安全了。第四函数内容变更后各worker会异步同步但如果你在多个路由上复制粘贴同一份函数之后想改逻辑就得逐个路由改很容易漏。这种情况应该考虑把公共逻辑抽成正式插件或者至少用APISIX的全局规则、插件模版这类手段统一管理。结尾我自己现在有个习惯接到“临时逻辑”需求会先用这两个探针顶上然后把“什么时候必须把它拔掉”写在备忘录里。原因很简单这对插件确实好用但也确实不适合长期沉淀——它没有完整的评审、测试、监控链路属于应急工具。真正让我放心的用法是临时观察、短期灰度、紧急封堵、快速打点。等需求稳定了再决定要不要转成正式插件。最后再分享一个小技巧任何探针上线前先在上游日志或响应头里留一个标记确认函数真的执行了再放开流量。别相信“我配了就该生效”相信我99%的“探针不工作”问题第一步都出在配置或格式上。