先说一个我压测期间的例子。
当时我在负责一个集团内部的 API 接入网关,每天要转发的业务请求量不大不小,但联调环境特别多。经常有同事跑过来问:能不能帮我在某个路由上临时加一个请求头?能不能让某个测试接口快速返回一下,不用等后端更新?这类需求通常都很“碎”,改业务代码吧,要发版、要等流水线;不改吧,联调进度卡在那。后来我把思路转到网关层,APISIX 里正好有一对轻量级的插件:serverless-pre-function 和 serverless-post-function。名字有点绕,本质却很简单——你可以在不经业务服务发版、不写完整 APISIX 插件的情况下,往请求处理链路的前后塞一段 Lua 函数,像探针一样点一下、看一眼、改一笔。
这篇文章我想把这对插件的完整用法、配置细节、实际案例和我在排障时踩过的坑都写出来。适合正在用 APISIX 做网关、但又不想一上来就折腾自定义插件的朋友参考。基础功能其实很薄,但就是薄,所以灵活;真正要小心的是函数怎么写、阶段怎么选、出了错怎么查。
1. 为什么我会在网关上埋一个"探针"函数
1.1 三个马上就能落地的场景
先说场景,再讲原理,这样大家更容易理解这对插件到底解决什么问题。
第一个场景是临时调试。某个接口在线上突然返回到奇怪的结果,你想确认这个请求到底有没有经过网关、经过的是哪个上游节点、响应头里带了什么。如果每次都要去后端服务打日志,沟通成本太高。我在 serverless-pre-function 里塞一段函数,把请求的 URI、Host、关键 Header 打出来,确认完再摘掉,整个过程不用动业务代码。
第二个场景是环境识别和 Header 补充。很多公司内部的服务有环境隔离,生产、预发、联调环境通过 Header 识别。可联调环境里的测试客户端不一定方便自定义 Header,或者某些历史客户端根本不支持。这时可以在 pre 函数里做一次判断:如果目标 URI 是某个测试路由,就给上游补一个固定的环境 Header。服务端不用做任何适配,请求一进来就已经带着它需要的标识。
第三个场景是简单的内部访问控制。有些内部接口不想暴露给所有人,但又不想为它们单独做一次完整的鉴权逻辑。pre 函数里判断来源 IP 或内部调用方 Header,不满足条件的请求在网关层直接拦掉。这比在业务服务里写拦截器要轻得多,并且你可以随时改、随时撤。
这三个场景有一个共同点:需求边界清晰,逻辑量很小,又不愿意为它引入一次发版流程。APISIX 的 serverless 类插件正是为这种场景设计的,官方文档里也管它叫“Serverless”插件——不是 FaaS 平台那种 Serverless,而是指“你只需要提供函数,平台负责执行时机”。
1.2 "探针"轻在哪里
我一直觉得这两个插件最难得的地方在于“轻量级”三个字,它轻在四个层面。
第一,部署轻。配置通过 Admin API 或者 etcd 下发给 APISIX,不需要重启网关进程,也不需要在网关节点上放额外 Lua 文件。你改一段函数,推送配置,下一个请求就生效。
第二,侵入轻。不起新服务、不改上游业务逻辑、不接入第三方追踪系统。纯网关层逻辑注入,业务方基本无感知。
第三,心智负担轻。用 APISIX 默认的插件机制即可,不需要自己从零开始编写一个完整插件。函数就是一串 Lua 代码,写起来非常直接。
第四,回滚轻。探针逻辑出问题或者不需要了,直接删掉插件配置,路由恢复原状。不会像代码改动一样留下一个需要 revert 的提交。
这些“轻”加起来,让这对插件很适合在排查问题、灰度切换、活动保障时临时上场。用过的同事常说它是一种网关层的“胶带”,哪里需要临时粘哪里,粘得牢,扯下来也没痕迹。虽然有点戏谑,但我认为是很形象的号。
1.3 和 APISIX 其它插件的关系:执行顺序优先级
APISIX 的插件体系有一个 priority 概念,每个插件都有执行优先级,数字大的先跑。serverless-pre-function 的优先级设得非常高,意味着它几乎会在一轮请求进来的早期就执行;而 serverless-post-function 的优先级设得很低,所以在其它插件处理完之后才轮到它。
这个设计非常关键。你可以在 pre 阶段先改请求、加 Header、做拦截,后面的鉴权插件、限流插件、转发逻辑再基于改动后的结果继续走;也可以在 post 阶段看到最终响应,做响应头的补充和标记。它保障了“探针”的执行时机可控:想在最前面动手就在最前面动手,想在最后面补刀就在最后面补刀。
理解了这一点,后面所有的配置和踩坑就都有了解释。
2. serverless-pre-function 的运行时机与编写规范
2.1 配置字段 functions 和 phase 到底怎么工作
serverless-pre-function 的配置本身非常简单,核心字段就两个:functions和phase。
functions是一个字符串数组,数组里的每个字符串都会被拼接起来,最终组成一段完整的 Lua chunk。注意是“拼接”,所以你要保证拼接后的整体是合法 Lua 代码。比较常见的写法是:
"return function(conf, ctx)\n ngx.log(ngx.INFO, \"probe fired\")\nend"这段字符串就是一个完整的 chunk:定义一个匿名函数,然后把它作为返回值返回。conf是插件配置,ctxAPISIX 的请求上下文对象。绝大多数场景下,你的探针逻辑都写在这个函数体里。
phase决定这段函数挂在请求处理的哪个阶段。serverless-pre-function 支持rewrite、access、balancer、header_filter、body_filter、log,如果不写,默认是rewrite。rewrite 阶段在 Nginx 里属于“改写请求”的位置,APISIX 的很多 URL 改写、Header 改写逻辑都在这个阶段做,所以 pre 函数默认能访问到请求信息,也能安全地修改请求。
举个例子,下面这段配置为/hello路由挂了一个最简探针:
curl http://127.0.0.1:9180/apisix/admin/routes/1 -X PUT -d ' { "uri": "/hello", "plugins": { "serverless-pre-function": { "phase": "rewrite", "functions": [ "return function(conf, ctx)\n ngx.log(ngx.INFO, \"url:\", ctx.var.uri, \" host:\", ctx.var.host)\nend" ] } }, "upstream": { "type": "roundrobin", "nodes": {"127.0.0.1:8080": 1} } }'请求一到,网关日志就会打印出访问的 URI 和 Host。如果你不打算让探针影响请求,只是看一眼,这就够了。
2.2 函数的返回值和 ctx 对象的正确用法
这个插件的函数不是随便写一段代码就行的,它的返回值必须是一个 function。如果数组内容拼起来没有返回 function,或者返回的东西不可调用,插件在执行时会报错导致请求异常终止。
函数内部能拿到ctx,它是一个很丰富的对象,几乎囊括了当前请求在 APISIX 里的所有上下文信息。我常用的几个访问点:
ctx.var:Nginx 变量表,比如ctx.var.uri、ctx.var.request_method、ctx.var.upstream_addr;ngx.req.get_headers():拿到完整请求头;ngx.req.set_header():修改请求头,注意要改成表形式传 value;ngx.header:操作响应头;ngx.log:输出日志到 error.log;core库:APISIX 自己封装的一些工具函数,比如core.request、core.response。
这里分享一个我在实际改造中常用的小套路。我需要按路由维度做环境标记,如果不希望每次配置里写死 URIS,可以直接读ctx:
"return function(conf, ctx)\n local uri = ctx.var.uri\n if uri ~= nil and string.find(uri, '/api/test', 1, true) then\n ngx.req.set_header('X-Env-Tag', 'dev')\n end\nend"string.find的第四个参数传true表示纯文本匹配,避免 URI 里的特殊字符被当成模式串。这种细节第一次写的时候不容易想到,但实际使用里特别容易踩。
2.3 前置访问控制:一个更完整的最小样例
再给一个访问控制的例子。假设有一个内部管理接口/internal/metrics,只允许内网 IP 网段访问。可以把 pre 函数配成:
"return function(conf, ctx)\n local client_ip = ctx.var.remote_addr or ''\n if string.sub(client_ip, 1, 10) ~= '10.10.1.' then\n return core.response.exit(403, {code = 403, message = 'forbidden'})\n end\nend"这里用了core.response.exit,它比直接用ngx.exit更符合 APISIX 的插件规范,可以统一处理响应格式。注意我把ngx.req.set_header和core.response.exit放在同一个函数体里,先判断、后拦截,职责清晰。
有人会问:APISIX 明明有ip-restriction插件,为什么还要用 serverless 手写?因为 ip-restriction 只能做纯 IP 名单,而我这个场景除了 IP,还要叠加“只对特定路由生效”“根据 Header 二次放行”这类组合规则。如果开一堆插件反而笨重,不如在探针里直接写完,维护起来也就是一小段字符串的问题。
3. serverless-post-function 的响应侧探测
3.1 post 版本和 pre 版本的差异
没看文档之前,我本来以为 serverless-post-function 就是把 post 开关打开,和 pre 没什么区别。实际上两者是对称的,但执行阶段不同。serverless-pre-function 默认挂在rewrite阶段,关注的是“请求还没到上游之前”;serverless-post-function 默认挂在header_filter阶段,此时上游已经返回了响应,APISIX 正在回包给客户端,你的函数就有机会在响应发出前修改响应头。
我用一个表格来总结两兄弟的差异:
| 对比项 | serverless-pre-function | serverless-post-function |
|---|---|---|
| 默认阶段 | rewrite | header_filter |
| 关注对象 | 请求、上游之前的逻辑 | 上游返回后的响应 |
| 典型作用 | 改写 Header、鉴权、标记 | 响应头注入、时间记录、审计 |
| 执行优先级 | 相对靠前 | 相对靠后 |
| 配置字段 | functions、phase | functions、phase |
这背后的逻辑很符合“探针”的定位:要么在请求头上路前检查一遍,要么在响应发出前检查一遍。两者合起来,就是请求全生命周期的一对小钩子。
3.2 真实案例:给调试响应添加自定义头
我在生产中最常用的 post 场景是加调试响应头。联调阶段前后端经常扯皮:前端说接口通了,后端说我没收到;后端说响应带了数据,前端说我没看到。这类问题到最后都是“口说无凭”。
后来我在网关侧挂了一个 serverless-post-function,把所有关键信息直接打进响应头:
curl http://127.0.0.1:9180/apisix/admin/routes/1 -X PATCH -d ' { "plugins": { "serverless-post-function": { "functions": [ "return function(conf, ctx)\n ngx.header[\"X-Resp-Node\"] = ctx.var.upstream_addr or \"\"\n ngx.header[\"X-Resp-Duration\"] = ctx.var.upstream_response_time or \"\"\n ngx.header[\"X-Resp-Route-Id\"] = ctx.var.route_id or \"\"\nend" ] } } }'这段函数做的事情很简单:把实际处理请求的上游地址、上游响应耗时、命中的路由 ID 都写到响应头里。联调的时候看一眼响应头,节点对不对、耗时多长、路由是否命中,一目了然。出了问题时,把响应头截图丢给后端,基本不用来回拉锯。
这个操作在 header_filter 阶段做非常安全,因为只是加响应头,不改 body,不会影响链路内容。
3.3 响应侧不能随意做的事
post 函数看着方便,但有些事不要随手做。
首先,不要妄想在没有配置 body_filter 的情况下改响应体。默认阶段是 header_filter,此时响应 body 还没开始发送,你改了 body 也不一定生效,或者会导致响应和 Content-Length 不匹配。真要改 body,要把 phase 配成body_filter。但改 body 本身是一件高风险的事,特别是上游已经计算好 Content-Length、或链路中有缓存和压缩时,很容易把响应搞坏。
其次,不要在响应阶段里随意发起新的子请求。不是说不能做,而是因为响应已经在返回路径上了,任何同步阻塞都会直接拖累网关回包速度。你有更好的选择:如果要打审计日志、做异步通知,可以放到log阶段,那里对响应时延影响最小。
我在一次活动中就把统计逻辑放在 post 的 header_filter 里做,本来以为只是算一个数字,结果每次请求都多做一次外部 KV 查询,压测时 TPS 明显掉了一截。教训很深刻:探针要轻,就要把脏活累活放到最合适的阶段。
4. 踩坑记录:从函数没执行到把网关打回 500
4.1 函数总是不生效,先检查 chunk 是否完整
这个坑几乎每个刚上手的人都会遇到。functions 是字符串数组,多个字符串会按顺序拼接成一段 Lua chunk。如果你把函数拆成三段写:
"local flag = true", "if flag then", "return function() end"没问题,拼接后是合法 Lua。但如果你某段漏了换行符,或者写成了:
"return function(conf, ctx)", " ngx.log(ngx.ERR, 'x')" "end"第二行和第三行之间没有加换行的话,拼出来就会变成一行错误语法。APISIX 在配置推送时并不总能立刻诊断出运行期错误,很多错误要到真正执行时才暴露。
我的习惯是:函数字符串里显式写\n,不要把换行交给数组元素之间的自然拼接。这样至少逻辑上看得很清楚,函数定义在哪结束、下一行是什么,不会出现“配置推送成功但请求 500”的情况。
4.2 作用域与 require:探针不会自己带环境
另一个容易出问题的点是作用域。serverless 函数是在 APISIX 的 worker 进程里执行的,但它不会给你提供一个独立的隔离环境。你在一个路由的 pre 函数里定义了一个全局变量,另一个路由的 pre 函数理论上也能看到它(取决于执行顺序和 worker)。这种“串味”非常危险,可能导致请求之间互相干扰。
所以我在探针里写的每一段代码,都尽量是自包含的:
"return function(conf, ctx)\n local now = ngx.now()\n -- do something\nend"变量用local声明,逻辑不依赖外部状态。如果需要复用一段较复杂的逻辑,也尽量把它抽取成 APISIX 可访问的 Lua 模块,然后在函数里require,而不是把一大段逻辑直接塞进函数字符串。
这里说一下 require 的路径问题。APISIX 本身以 OpenResty 运行,它的 Lua 模块搜索路径包含 APISIX 自定义的目录,但未必包含你本地随意放置的目录。如果你 require 一个并不在搜索路径里的模块,执行到那一步才会报错。稳妥的做法是把模块放到lua_package_path覆盖的目录下,再在探针里调用。千万别在函数字符串里写一堆绝对路径依赖。
4.3 调试三板斧
探针这东西,配置简单,但调试起来信息很少。我总结了三板斧。
第一板斧:开启日志。在函数开头顺手加一行ngx.log(ngx.INFO, ...),把关键变量打出来。APISIX 的默认日志级别是warn,如果看不到 INFO,记得在config.yaml里把nginx_config.error_log_level调到info,或者在部署环境里直接临时调低。
第二板斧:看ctx而不是猜。很多排查最后都归结为“某个变量是不是 nil”。你不需要在函数里写.xxx再判断,直接打ctx.var下的字段名。打个最简单的日志:
ngx.log(ngx.ERR, "headers: ", require("apisix.utils.json").encode(ngx.req.get_headers()))多看两轮,就能确认函数到底在哪一步没按预期走。
第三板斧:配一条测试路由。不要在核心生产路由上直接调函数。在 APISIX 里复制一条测试 URI,挂上探针,用 curl 反复打。等确认逻辑稳定,再把它贴到真正要生效的路由上。探针的好处本来就是可以随时摘掉,所以我建议把“先在测试路由上跑通”当成默认流程,而不是等到线上出了 500 再去回滚。
4.4 可维护性与性能:别把探针写成炸弹
探针函数如果写得不好,会给网关带来灾难性影响。最典型的问题是在函数里做同步网络 IO。
有一点需要理解:网关进程是事件驱动的,它要同时处理数千个连接。你在函数里做了一个 Redis 查询、发了一个 HTTP 子请求,看起来只多等了几十毫秒。但在高并发下,这几十毫秒会把事件循环占住,整体吞吐量瞬间下滑。APISIX 不是不能承受耗时操作,而是这类操作应该放到log阶段或异步机制里去,而不是放在每个请求必经的 rewrite/header_filter 路径上。
另外,异常处理非常关键。探针函数里的代码如果发生运行时错误,默认行为会直接导致请求失败。APISIX 会把错误记录下来并返回 500。一个看起来无害的 nil 判断,在某个边界条件下就会把整个路由打挂。
我在实际维护里给自己定了一条纪律:函数体里的大逻辑一律用 pcall 包裹,一旦出错就打日志并放行或按安全策略处理,而不是让异常直接支配请求结果。比如:
"return function(conf, ctx)\n local ok, err = pcall(function()\n local h = ngx.req.get_headers()\n if h['X-Env'] == 'dev' then\n ngx.req.set_header('X-Env-Tag', 'dev')\n end\n end)\n if not ok then\n ngx.log(ngx.ERR, 'probe error: ', err)\n end\nend"这样即使探针本身写得不完美,最坏的结果是一行错误日志,而不是破坏整个请求链路。安全边界优先于功能完备,这句话在探针场景里再适用不过。
5. 什么情况不要用这两个插件
5.1 复杂业务逻辑:转向正式插件
serverless 插件最适合的是逻辑少、变化快、生命周期短的场景。一旦你的函数开始超过十行、二十行,或者需要被多个路由复用,就应该考虑把它改成正式的 APISIX 插件。
原因很简单:正式插件可以写在文件里,有完整的测试覆盖,逻辑可以复用,也可以在管理界面里统一管理。而 serverless 函数的代码是嵌在配置里的字符串,散落在各个路由上,时间一长就变成“只有搬代码的人才知道在哪”的隐性资产。我见过一个路由里塞了上百行 functions,读起来非常痛苦,改起来更痛苦。
我在团队里的实践是:探针做探测,插件做沉淀。先在 serverless 函数里验证方案,等确认有效、逻辑稳定了,再把它抽成正式插件,低风险地上生产。这个流程既保留了探索的速度,又保证了长期的可维护性。
5.2 高频热路径下的性能取舍
如果某个路由的 QPS 非常高,我一般不建议在它上面挂复杂函数。不是说不能挂,而是要想清楚代价。探针的每一次执行都是 CPU 开销,虽然 LuaJIT 执行一小段逻辑非常快,但如果你在函数里加了字符串拼接、正则匹配、甚至循环遍历一个大表,这个开销会被高并发放大很多倍。
我习惯用最简单的 benchmark 思维来判断:这段函数在最坏情况下多消耗多少微秒,再乘以 QPS,看总开销是否可接受。如果只是判断 header 是否存在,那是纳秒级的事,随便挂;如果要遍历一本书大小的配置数据,那就要想办法预处理或缓存。
5.3 在任何时候,给探针留一条“后路”
最后说一个很容易被忽略的点:探针再轻,也是线上逻辑。出事时快速拔掉探针,比研究探针为什么出错更重要。
我在部署探针时有一个习惯,把带探针的路由配置在 etcd 里做好备份,同时在文档里写明这个路由最近加过什么探针、为什么加、由谁加。出问题时,最坏情况下我可以一条 Admin API 调用把插件整个删掉,让路由恢复原样,再慢慢回头查日志。这条“后路”看起来朴素,但它在多次故障处理里都帮我稳住了局面。
APISIX 的 serverless-pre-function 和 serverless-post-function,说穿了就是两个在网关链路里按需执行 Lua 函数的入口。它们没有炫酷的界面,也没有复杂的依赖,但用好了确实能在日常联调、故障排查和临时控制中省下大量时间。从原理到样例再到踩坑,我把自己实际遇到的情况都记录在这里了。下次你要在网关上“探”点什么的时候,希望这篇能帮你少走一些弯路,直接把探针插到该插的位置上。