8.2 OpenResty:Nginx 里的 Lua 本节摘要:Nginx 是事件驱动的网关之王,天生拒绝任何"阻塞式长逻辑";OpenResty 把 LuaJIT 塞进 Nginx 的 worker 进程,让每个请求的处理阶段——rewrite、access、content、log——都能挂一段 Lua。脚本的铁律只有一条:不可阻塞。cosocket 把异步网络包装成同步调用(第 7 章思路的落地),网关三板斧(改写、鉴权、限流)在阶段模型里全数兑现。 网关的十一个阶段 Nginx 处理一个请求要走一串阶段:接收连接、读请求头、rewrite、access(权限判定)、content(产出内容)、log(记录收尾)等。经典 Nginx 的每个阶段只能挂 C 模块或配置指令;
本节摘要:Nginx 是事件驱动的网关之王,天生拒绝任何"阻塞式长逻辑";OpenResty 把 LuaJIT 塞进 Nginx 的 worker 进程,让每个请求的处理阶段——rewrite、access、content、log——都能挂一段 Lua。脚本的铁律只有一条:不可阻塞。cosocket 把异步网络包装成同步调用(第 7 章思路的落地),网关三板斧(改写、鉴权、限流)在阶段模型里全数兑现。
Nginx 处理一个请求要走一串阶段:接收连接、读请求头、rewrite、access(权限判定)、content(产出内容)、log(记录收尾)等。经典 Nginx 的每个阶段只能挂 C 模块或配置指令;OpenResty 的贡献是把 Lua 虚拟机嵌进 worker 进程,并在各阶段开出挂载点。用配置的语言描述(配置语法用文字示意):
location /api/ { rewrite_by_lua_file = rewrite.lua -- 改写URI与参数 access_by_lua_file = access.lua -- 鉴权与限流 content_by_lua_file = content.lua -- 产出响应 log_by_lua_file = log.lua -- 埋点记录 }
一个请求进来,四段脚本按阶段依次执行,各自管一段:

这个阶段模型把第 6 章"宿主划定能力范围"演绎到极致:脚本能做的事在阶段里定义死了——rewrite 阶段不该产响应、log 阶段改不了状态码,越界的函数干脆不存在。想全局生效就挂 init 阶段(worker 启动时跑一次,预载模块、预热连接池——第 6 章"模块顶部是单次初始化区"的现场版)。
网关脚本的日常就是三板斧,逐个上手。
改写(rewrite)。把旧版路径映射到新服务:
-- rewrite.lua:uri 改写与参数归一 local uri = ngx.var.uri local new, n = uri:gsub("^/v1/", "/v2/") if n > 0 then ngx.req.set_uri(new) -- 改写内部URI end -- 手机端参数归一:client 与 platform 同义 local args = ngx.req.get_uri_args() if args.platform and not args.client then args.client = args.platform ngx.req.set_uri_args(args) end
模式匹配(第 4 章)与 URI 表 API 组合,几十行做完过去要写 C 模块的事。
鉴权(access)。验 JWT、查黑名单、按路径分组授权:
-- access.lua:简易鉴权(示意) local auth = ngx.req.get_headers()["Authorization"] if not auth then ngx.status = 401 ngx.say("missing token") return ngx.exit(401) -- 终止请求 end local token = auth:match("Bearer%s+([%w%-_%.]+)") local ok, payload = pcall(verify_token, token) -- 验签(出错兜壳) if not ok or not payload then ngx.status = 403 return ngx.exit(403) end -- 路径级权限:admin 段要求角色 if ngx.var.uri:match("^/admin/") and payload.role ~= "admin" then ngx.status = 403 return ngx.exit(403) end ngx.ctx.user = payload -- 挂到请求上下文供后续阶段用
pcall 兜验签错误(第 6 章的壳)、模式匹配取 token(第 4 章)、早退即拦截——三板斧的通用节奏:判、拒、放。
限流(access 的另一半)。按客户端维度做令牌桶,用共享内存计数(跨 worker 共享的 k-v 结构,脚本侧以表语义访问):
-- access.lua:固定窗口限流(示意) local limit_req = require("resty.limit.req") -- 生态里的成熟库 local lim, err = limit_req.new("api_limit", 50, 2) -- 每秒50 · 突发2 if not lim then ngx.log(ngx.ERR, "failed to create limiter: ", err) return ngx.exit(500) end local key = ngx.var.binary_remote_addr -- 按IP local delay, err2 = lim:incoming(key, true) if not delay then if err2 == "rejected" then ngx.header["Retry-After"] = 1 return ngx.exit(429) -- 超限 end return ngx.exit(500) end if delay > 0 then ngx.sleep(delay) end -- 排队等待(非阻塞的睡)
lim:incoming 返回延迟或拒绝——类风格调用(第 5 章的重派在这类库里随处可见)。注意 ngx.sleep 不是系统睡眠,是让出协程的定时等待——铁律"不可阻塞"的柔性版本:让出可以,卡死不行。
content 阶段调上游服务是网关的重头。cosocket 把"建立连接、发请求、收响应"包装成普通顺序代码:
-- content.lua:把请求转发给上游并加工响应 local httpc = require("resty.http").new() local res, err = httpc:request_uri("http://upstream:8080" .. ngx.var.uri, { method = ngx.req.get_method(), headers = { Authorization = ngx.req.get_headers()["Authorization"] }, }) if not res then ngx.status = 502 ngx.say("upstream failure: ", err) return end ngx.status = res.status ngx.print(res.body)
request_uri 内部做的事:非阻塞连接、发请求、收响应——每一步都是"发起异步操作、协程 yield、事件循环干活、数据到了 resume"。写网关脚本的人看到的是第 7.2 节那三步魔术的工程完成体。代价也一并继承:跨 cosocket 调用不能持有不可重入资源、连接用完要显式回收(set_keepalive 归还连接池)、单请求的 cosocket 并发数有限制。同步的写法、异步的心——这是 OpenResty 程序员的第二本能。
性能量级给个直觉:一个中等配置的节点跑纯 Lua 网关逻辑,每秒几万请求不费劲;瓶颈通常在上游与 IO 而非脚本。LuaJIT 的执行速度(第 1 章的分支史)在这里兑现成真金白银的机器成本。
把 OpenResty 的限制条款抄成清单,与第 6 章沙箱清单对照着看:
💡 关键直觉:OpenResty 的模型是"阶段出租、协程让路"。宿主把请求的生命周期切成阶段分租给脚本,脚本在每个租期内用协程让出换并发——两件机制(第 6 章的边界、第 7 章的让出)咬合出一台高吞吐网关。
Nginx 多 worker 进程各自内嵌一套独立虚拟机,模块与数据互不可见。要跨 worker 共享(全局计数、限流桶、配置快照),用宿主提供的共享内存区——脚本侧以表语义访问,自带原子操作:
-- 初始化阶段声明共享区(配置示意):共享内存 ten兆 名字 api_stats -- worker 里的使用: local shm = ngx.shared.api_stats shm:incr("total", 1, 0) -- 原子自增,初始为 0 local qps = shm:get("total") -- 带过期的小字典:灰度开关 shm:set("feature_x", "on", 3600) -- 一小时后自动消失 if shm:get("feature_x") == "on" then -- 走新逻辑 end
incr、set、get 都是原子操作,多 worker 并发下不需要锁——把第 3 章"表是会客厅"升级到进程间:单机看是带过期的小字典,架构看是多 worker 协调的唯一通道。配置热切换的典型用法:运维往共享区写一个键,所有 worker 下一请求立即看到——网关侧最朴素的"热开关",与 9.4 节的热更方案互为补充(粗粒度开关用共享区,细粒度逻辑用模块重载)。
上线前的检查表,逐条对应本章某个机制:
最后一条容易被忽略:共享区不是 Lua 表,没有 GC——它是宿主管理的固定内存,键不放过期就永远占着。这是"宿主的能力带着宿主的账单"的又一例。
真实网关的 access 阶段很少只有一种检查——白名单、签名、限流、灰度按序叠加,任何一环不过即拒。把三板斧组织成策略链:
-- access.lua:策略链形态(每策略一个函数,早退即拦截) local policies = {} policies[#policies + 1] = function(ctx) -- 内网白名单 if ctx.ip:match("^10%.") or ctx.ip:match("^192%.168%.") then ctx.trust = "internal" return true -- 白名单直通(后续策略仍可覆盖信任级) end return true end policies[#policies + 1] = function(ctx) -- 签名校验 if ctx.trust == "internal" then return true end local sign = ctx.headers["X-Sign"] if not sign or not verify(sign, ctx.uri) then return nil, 403 end return true end policies[#policies + 1] = function(ctx) -- 限流(复用 8.2 节的 limit_req) local lim = require("resty.limit.req").new("api_limit", 50, 2) local delay, err = lim:incoming(ctx.ip, true) if not delay and err == "rejected" then return nil, 429 end if delay and delay > 0 then ngx.sleep(delay) end return true end -- 驱动:逐策略执行,带 ctx 传递结论 local ctx = { ip = ngx.var.remote_addr, uri = ngx.var.uri, headers = ngx.req.get_headers(), } for _, policy in ipairs(policies) do local ok, code = policy(ctx) if not ok then ngx.status = code return ngx.exit(code) end end ngx.ctx.gw = ctx -- 结论交给后续阶段
策略链的好处随策略数量增长:新增检查是往表里加一个函数(第 3 章的注册分发),顺序即优先级(数组语义),ctx 带着前一环的结论(白名单信任让签名跳过)。每个策略只回答一个问题,通过返回 true、拒绝返回 nil 加状态码——判断与动作分离,测试时逐策略可独立跑。这个形态与 3.2 节的中间件洋葱是同族设计,差别在网关侧的"拒绝"直接走 ngx.exit 短路,不需要回卷。
content 之前还有个常被忽视的 init 阶段(worker 启动时执行一次),两件事最适合放这:模块预载(把后续要用的库全部 require 一遍,首个请求不用付加载成本——6.1 节"模块顶部是单次初始化区"的宿主版)与全局只读数据构建(路由表、证书指纹、正则预编译结果存进模块级 local)。请求阶段的代码只读这些结构,零重复构建——每请求成本搬到每 worker 一次,这是网关脚本性能清单上性价比最高的一条。
下一节进入最严的家:Redis 单线程里的沙箱房客。