8.2 OpenResty:Nginx 里的 Lua


文档摘要

8.2 OpenResty:Nginx 里的 Lua 本节摘要:Nginx 是事件驱动的网关之王,天生拒绝任何"阻塞式长逻辑";OpenResty 把 LuaJIT 塞进 Nginx 的 worker 进程,让每个请求的处理阶段——rewrite、access、content、log——都能挂一段 Lua。脚本的铁律只有一条:不可阻塞。cosocket 把异步网络包装成同步调用(第 7 章思路的落地),网关三板斧(改写、鉴权、限流)在阶段模型里全数兑现。 网关的十一个阶段 Nginx 处理一个请求要走一串阶段:接收连接、读请求头、rewrite、access(权限判定)、content(产出内容)、log(记录收尾)等。经典 Nginx 的每个阶段只能挂 C 模块或配置指令;

8.2 OpenResty:Nginx 里的 Lua

本节摘要: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 -- 埋点记录 }

一个请求进来,四段脚本按阶段依次执行,各自管一段:

08-02-fig01

这个阶段模型把第 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 不是系统睡眠,是让出协程的定时等待——铁律"不可阻塞"的柔性版本:让出可以,卡死不行。

cosocket:同步外观,异步内核

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 章沙箱清单对照着看:

  • 不可阻塞:标准 Lua 的 io、os.sleep 一概不可用(宿主直接没提供),文件 IO 走非阻塞封装或搬到 log 阶段;
  • 阶段越界即不存在:log 阶段没有 ngx.say,rewrite 阶段出不了响应体——能力按阶段发放;
  • 请求间不共享可写状态:每个请求一套新协程与上下文表;要共享就上共享内存(带锁语义的 k-v 表);
  • 错误即终止该请求:脚本抛错由宿主壳接住,返回 500,worker 与其它请求无恙(6.2 节壳的 Nginx 版);
  • 模块常驻:require 缓存跨请求生效(第 6 章),热加载要走专用工具(第 9 章会提到 clean 脚本缓存的实践)。

💡 关键直觉:OpenResty 的模型是"阶段出租、协程让路"。宿主把请求的生命周期切成阶段分租给脚本,脚本在每个租期内用协程让出换并发——两件机制(第 6 章的边界、第 7 章的让出)咬合出一台高吞吐网关。

共享内存:worker 之间的会客厅

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 节的热更方案互为补充(粗粒度开关用共享区,细粒度逻辑用模块重载)。

网关脚本的验收清单

上线前的检查表,逐条对应本章某个机制:

  • 每个阶段函数都有 ngx.exit 或正常返回路径——不留悬挂分支;
  • 调上游全部走 cosocket 与连接池(request_uri 之外的连接记得 set_keepalive 归还);
  • 不出现 io、os.sleep、系统调用——出现即是事故预告(多数宿主直接不让加载,但依赖库里的旧代码要扫);
  • 错误分支有日志(带 ngx.ERR 级别与 request id),pcall 壳包住所有第三方库调用;
  • 限流与熔断在 access 阶段尽早判定,把便宜的事放在前面(先本地内存、再共享区、最后上游);
  • 共享区的键有 TTL 或清理策略——共享内存不会被 GC,泄漏就是永久占用。

最后一条容易被忽略:共享区不是 Lua 表,没有 GC——它是宿主管理的固定内存,键不放过期就永远占着。这是"宿主的能力带着宿主的账单"的又一例。

多策略叠加:access 阶段的完整形态

真实网关的 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 一次,这是网关脚本性能清单上性价比最高的一条。

本节要点回顾

  • 阶段即挂钩:rewrite 改写、access 拦截、content 产响应、log 埋点,脚本按阶段领取能力,越界即不存在;
  • 铁律不可阻塞:卡住事件循环等于卡住整个 worker;ngx.sleep 与 cosocket 是"让出式"的柔性等待;
  • 三板斧节奏:判、拒、放——pcall 兜错、模式匹配取字段、早退即拦截;
  • 共享内存是 worker 间的会客厅:incr/set 原子操作、TTL 小字典、粗粒度热开关——但它没有 GC,键要管生命周期;
  • cosocket 是第 7 章三步魔术的工程完成体:同步写法、异步内核,代价是不可重入资源的纪律与连接显式回收;
  • 请求间隔离:每请求新协程与上下文,共享走共享内存;模块常驻带来零请求内 IO 的加载成本;
  • 验收清单六条:出口齐全、连接归还、无阻塞调用、错误有壳有日志、便宜的事先做、共享区有 TTL——上线前逐条过;
  • 性能直觉:脚本很少是瓶颈,上游与 IO 才是——LuaJIT 把语言开销压到可忽略。

下一节进入最严的家:Redis 单线程里的沙箱房客。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U