
先说一个我压测期间的例子。当时我在负责一个集团内部的 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.varNginx 变量表比如ctx.var.uri、ctx.var.request_method、ctx.var.upstream_addrngx.req.get_headers()拿到完整请求头ngx.req.set_header()修改请求头注意要改成表形式传 valuengx.header操作响应头ngx.log输出日志到 error.logcore库APISIX 自己封装的一些工具函数比如core.request、core.response。这里分享一个我在实际改造中常用的小套路。我需要按路由维度做环境标记如果不希望每次配置里写死 URIS可以直接读ctxreturn 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\nendstring.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-functionserverless-post-function默认阶段rewriteheader_filter关注对象请求、上游之前的逻辑上游返回后的响应典型作用改写 Header、鉴权、标记响应头注入、时间记录、审计执行优先级相对靠前相对靠后配置字段functions、phasefunctions、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. 踩坑记录从函数没执行到把网关打回 5004.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 函数的入口。它们没有炫酷的界面也没有复杂的依赖但用好了确实能在日常联调、故障排查和临时控制中省下大量时间。从原理到样例再到踩坑我把自己实际遇到的情况都记录在这里了。下次你要在网关上“探”点什么的时候希望这篇能帮你少走一些弯路直接把探针插到该插的位置上。