Apache APISIX serverless 插件完全指南:在路由生命周期中注入自定义 Lua 函数

原创2026-09-14 09:29:091,995 阅读
文章标签:后端微服务云原生

Apache APISIX serverless 插件完全指南:在路由生命周期中注入自定义 Lua 函数

导读

serverless-pre-function 与 serverless-post-function 是 Apache APISIX 提供的两个轻量级插件,允许开发者直接在路由配置中以字符串形式内联自定义 Lua 函数,并在请求处理的生命周期(rewrite、access、header_filter、body_filter、log、before_proxy)中按需执行。它们非常适合实现个性化日志、动态请求改写、A/B 分流前的标记、限流前的自定义判断等"胶水逻辑",无需编写并编译独立的 Nginx/OpenResty 模块即可快速生效。读完本文,你将掌握这两个插件的完整属性、阶段语义、函数写法约束、启用与验证方法,以及底层实现原理与测试佐证。

插件概述:一对"阶段钩子"

APISIX 提供了两个 serverless 系列插件,二者使用完全相同的属性(schema),区别仅在于挂载时机与优先级:

两个入口文件都只是简单地把工作委托给共用的工厂函数:

-- apisix/plugins/serverless-pre-function.lua
return require("apisix.plugins.serverless.init")("serverless-pre-function", 10000)
-- apisix/plugins/serverless-post-function.lua
return require("apisix.plugins.serverless.init")("serverless-post-function", -2000)

从源码结构看,共用逻辑集中在 apisix/plugins/serverless/init.lua:工厂函数接收 plugin_name 与 priority 两个参数,动态生成插件表 _M,并为 phases 列表中的每个阶段动态注册同名回调方法。也就是说,一个插件实例实际上把六个阶段的方法全部注册好了,运行时再根据 conf.phase 决定实际执行哪一个,这正是"同一个 schema、不同挂载点"的设计精髓。

属性详解

两个插件共用同一份属性定义,表格如下:

名称 类型 必选项 默认值 有效值 描述
phase string 否 ["access"] ["rewrite", "access", "header_filter", "body_filter", "log", "before_proxy"] 执行 serverless 函数的阶段
functions array[string] 是 — — 指定运行的函数列表,可包含一个或多个函数,按顺序依次执行

对照 apisix/plugins/serverless/init.lua 中的 schema 定义可以看到:

local phases = {
    "rewrite", "access", "header_filter", "body_filter",
    "log", "before_proxy"
}

local schema = {
    type = "object",
    properties = {
        phase = {
            type = "string",
            default = "access",
            enum = phases,
        },
        functions = {
            type = "array",
            items = {type = "string"},
            minItems = 1
        },
    },
    required = {"functions"}
}

几个值得注意的点:

  • phase 默认值是 access。如果不显式指定,函数会在 access 阶段执行。测试用例 t/plugin/serverless.t 的 TEST 1 通过 plugin.check_schema({functions = {...}}) 验证了省略 phase 时 schema 会被填上默认值 access。
  • functions 必须至少包含 1 个元素(minItems = 1),且为必填项。
  • phase 使用枚举校验。TEST 4 证明传入非法阶段(如 abc)时,check_schema 会返回 property "phase" validation failed: matches none of the enum values。

阶段语义与请求处理周期

这六个阶段分别对应 OpenResty 请求生命周期中的不同位置,理解它们才能选对 phase:

  • rewrite:URI 重写阶段,早于路由匹配完成后的业务逻辑,适合做早期拦截、改写上游路径、初始化上下文变量。
  • access:访问控制阶段(默认值),适合做鉴权、限流前置判断等。
  • header_filter:响应头过滤阶段,适合向响应注入自定义头。
  • body_filter:响应体过滤阶段,适合对响应体做截断、追加等处理。
  • log:日志记录阶段,适合做访问统计、异步上报,此时响应已基本确定。
  • before_proxy:访问上游之前的阶段,运行于 access 阶段之后、请求发往上游之前。文档特别说明:在 v2.12.0 之前该阶段名为 balancer,由于它与负载均衡无关,因此更名为 before_proxy。

版本注意:从 v2.6 版本开始,conf 和 ctx 会作为前两个参数传递给 serverless 函数,你可以在函数签名中直接使用它们访问插件配置与请求上下文。

函数写法约束:只接受函数,不接受其他代码

这是使用 serverless 插件最容易踩坑的地方:functions 数组中只接受"能返回一个函数"的 Lua 代码片段,不接受其他任意类型的 Lua 代码。

合法的写法包括:

匿名函数:

return function()
    ngx.log(ngx.ERR, 'one')
end

闭包(可捕获外层局部变量并维持状态):

local count = 1
return function()
    count = count + 1
    ngx.say(count)
end

非法的写法(返回的不是函数):

local count = 1
ngx.say(count)

这一约束在源码中有严格的校验逻辑。apisix/plugins/serverless/init.lua 的 check_schema 会依次对每个函数字符串执行两步检查:

  1. 先 loadstring(func_str) 编译,若语法错误直接返回 failed to loadstring: ...;
  2. 再 pcall(func) 执行该代码片段,并通过 type(ret) ~= 'function' 判断返回值类型,如果不是函数则返回 only accept Lua function, the input code type is ...。

对应的测试用例同样可以在 t/plugin/serverless.t 中看到:

  • TEST 5:传入 local a = 123; 这类不返回函数的代码,报错 only accept Lua function, the input code type is nil;
  • TEST 6:传入无法编译的代码 a,报错 failed to loadstring: [string "a"]:1: '=' expected near '<eof>'。

之所以这样设计,是因为插件在运行时通过 load_funcs 使用 loadstring 把每个字符串编译成函数并缓存(见 init.lua),然后逐个以 func(conf, ctx) 的形式调用——所以每一段配置必须"编译后求值得到函数"才能被调用。

函数执行细节:返回值的特殊语义

call_funcs 的源码揭示了 serverless 函数的一个重要行为(init.lua):

for _, func in ipairs(functions) do
    local code, body = func(conf, ctx)
    if code or body then
        return code, body
    end
end

即:一旦某个函数返回了非空的 code 或 body,插件会立即终止后续函数的执行,并把这两个返回值向上传递。这与 APISIX 其他插件的约定一致——返回的状态码会直接结束当前请求(例如返回 403, 'forbidden' 即以 403 响应直接终止)。测试用例 TEST 25/26 验证了这一点:第一个函数 return 403, 'forbidden' 生效后,第二个函数中的 ngx.log(ngx.ERR, 'unreachable') 永远不会执行,最终请求以 403 结束,响应体为 forbidden。

启用插件

以下命令演示如何在指定路由中同时启用两个插件(以下命令均基于 Admin API,默认监听 127.0.0.1:9180):

先从 conf/config.yaml 中取出 admin_key 存入环境变量(deployment.admin.admin_key 配置项在传统模式下默认位于该文件,请以你实际部署环境为准):

admin_key=$(yq '.deployment.admin.admin_key[0].key' conf/config.yaml | sed 's/"//g')

然后创建路由并配置插件:

curl http://127.0.0.1:9180/apisix/admin/routes/1  \
-H "X-API-KEY: $admin_key" -X PUT -d '
{
    "uri": "/index.html",
    "plugins": {
        "serverless-pre-function": {
            "phase": "rewrite",
            "functions" : ["return function() ngx.log(ngx.ERR, \"serverless pre function\"); end"]
        },
        "serverless-post-function": {
            "phase": "rewrite",
            "functions" : ["return function(conf, ctx) ngx.log(ngx.ERR, \"match uri \", ctx.curr_req_matched and ctx.curr_req_matched._path); end"]
        }
    },
    "upstream": {
        "type": "roundrobin",
        "nodes": {
            "127.0.0.1:1980": 1
        }
    }
}'

这段配置的关键点:

  • serverless-pre-function 在 rewrite 阶段开始处运行,仅打印一条日志;
  • serverless-post-function 同样挂在 rewrite 阶段,但通过 conf, ctx 两个参数读取了上下文中的 ctx.curr_req_matched._path,证明你可以在函数内部访问匹配到的路由信息(这里打印的是当前请求匹配的路径 /index.html)。

如果在 Admin API 端口或鉴权上有自定义配置,请对照 conf/config.yaml 中的 deployment.admin 段调整 host、端口与 key 的获取方式。

测试插件

配置完成后,向网关发起请求验证函数是否生效:

curl -i http://127.0.0.1:9080/index.html

然后检查错误日志:

grep -E "serverless pre function|match uri" logs/error.log

如果你在 ./logs/error.log 中发现 serverless pre function 和 match uri /index.html 两条 error 级别的日志,说明指定的函数已经生效。

关于这一验证路径,仓库中的自动化测试提供了更强的佐证:

  • t/plugin/serverless.t 的 TEST 7~12 分别验证了 post-function 单独生效、pre-function 单独生效、以及两者同时配置时按序打印 serverless pre function → serverless post function;
  • TEST 15/16 验证了 functions 数组内多函数按先后顺序执行(日志依次出现 one、two);
  • TEST 17/18 验证了闭包写法合法,且每次请求 count 自增(首次请求后打印 serverless pre function:2,说明闭包状态在 worker 内得以保持);
  • TEST 19/20 展示了一个真实场景:在 pre-function 中判断 http 与域名 foo.com,动态设置 Location 响应头并 ngx.exit(301) 实现 HTTP→HTTPS 强制跳转。

删除插件

当你需要移除该插件时,通过 Admin API 用不带插件配置的 JSON 覆盖该路由即可,APISIX 会自动重新加载相关配置,无需重启服务:

curl http://127.0.0.1:9180/apisix/admin/routes/1  \
-H "X-API-KEY: $admin_key" -X PUT -d '
{
    "methods": ["GET"],
    "uri": "/index.html",
    "upstream": {
        "type": "roundrobin",
        "nodes": {
            "127.0.0.1:1980": 1
        }
    }
}'

由于 Admin API 的 PUT 是整体覆盖语义,删掉 plugins 字段即等同于删除插件;随后的请求将不再执行任何 serverless 函数。

进阶场景:before_proxy 阶段的真实用途

before_proxy 阶段(v2.12.0 起由 balancer 更名而来)是这两个插件最独特的挂载点。它运行在 access 之后、请求发往上游之前,此时负载均衡器已经选定了目标节点,因此可以在该阶段读取到 ctx.balancer_ip 等上游节点信息。

仓库中的命令行级测试 t/cli/test_serverless.sh 完整演示了这一场景:配置一个带权重差异的 chash 上游(127.0.0.1:1980: 1 与 0.0.0.0:1979: 100000),并在 before_proxy 阶段用函数累计请求次数、打印当前选择的节点 IP:

-- conf/apisix.yaml 中的配置片段
serverless-pre-function:
  phase: before_proxy
  functions:
  - "return function(conf, ctx) ctx.count = (ctx.count or 0) + 1 end"
  - "return function(conf, ctx) ngx.log(ngx.WARN, \"run before_proxy phase \", ctx.count, \" with \", ctx.balancer_ip) end"

由于 before_proxy 阶段在重试场景下会多次执行(一次请求可能因上游失败而重试),测试脚本期望在 logs/error.log 中同时出现 run before_proxy phase 1 with 0.0.0.0 与 run before_proxy phase 2 with 127.0.0.1 两条日志,从而验证该阶段"每次上游尝试前都会运行"的特性;随后脚本还验证了在该阶段调用 ngx.exit(403) 可以直接以 403 结束请求。如果你需要在"最终敲定上游节点"这个时间点执行自定义逻辑,before_proxy 是唯一合适的挂载点。

使用建议与注意事项

综合文档、源码与测试,实际使用中建议遵循以下几点:

  1. 函数必须是"求值后返回函数"的代码,不要写顶层语句;每段代码独立 loadstring 编译,语法错误会在配置校验阶段就被拒绝。
  2. 合理选择 phase:默认 access;需要提前拦截选 rewrite;需要操作响应选 header_filter/body_filter;需要做日志统计选 log;需要在负载均衡选定节点后执行逻辑选 before_proxy。
  3. 多个函数按序执行、可提前短路:返回非空 code/body 会中断后续函数并直接决定响应,这与 APISIX 其他插件的返回约定一致,可用于实现"命中即拦截"。
  4. 慎用有状态的闭包:闭包状态在单个 worker 内有效,多个 worker 之间不共享,跨请求计数等场景需要考虑 worker 隔离带来的误差。
  5. 删除即覆盖:Admin API PUT 为整体覆盖语义,去掉 plugins 字段即可下线插件,无需重启 APISIX。

这两个插件把"在网关里写点小逻辑"的成本降到了最低:无需编译 Nginx 模块、无需写独立插件文件,一个函数字符串即可完成从日志埋点到请求改写的绝大多数轻量需求;而当你需要更重的自定义处理(如对接外部函数服务)时,也可以参考 apisix/plugins/serverless/generic-upstream.lua 中基于 function_uri 的通用上游调用模式进行扩展。

登录后查看全文
apisix