为什么个人站长要用 Lua 给 Nginx 补能力
标准 Nginx 能做反向代理、缓存、限流、重写,但它有一个根本性的短板:配置语言是「声明式」的,只能做静态的分支判断,一旦需求里出现「根据变量动态计算」「查一次外部数据再决定放不放行」「把结果写回缓存供后续请求复用」这类逻辑,config 就无能为力了。很多站长遇到这种情况的第一反应是「上一个后端接口吧」,于是为了一个简单的鉴权或限速,硬是搭了一套 Node/PHP 服务,多了一个 502 的可能,也多了几十兆内存。
OpenResty 解决的正是这个问题。它把 Nginx 与 LuaJIT 打包在一起,让你能在请求处理的各个阶段(access、content、log 等)直接插入 Lua 代码。它不是一个新服务器,而是「Nginx + 脚本」:编译产物就是 nginx,配置语法完全兼容,你现有的站点配置可以原封不动搬过去,然后按需在几个 location 里加 access_by_lua_block 就行。对于个人站长,它的价值在于把本来需要另起一个服务的逻辑(简单鉴权、动态限流、令牌桶、接口聚合、灰度分流)压回网关层,用几十行 Lua 换掉一个后端进程。
本文不讲 Lua 语法入门,而是讲个人站在真实场景下最常用、也最容易踩坑的一套实践:如何用 Lua 实现一个基于共享内存的令牌桶限流,如何做接口聚合(把三个上游请求在网关层合并成一次),如何安全地引入 resty.redis 与 resty.http 这些 OpenResty 生态库,以及在生产上必须知道的坑——Lua 阻塞的代价、共享内存大小怎么估算、热更新时怎么不丢流量。全程基于 apt install openresty 的 Debian/Ubuntu 环境,命令可直接复制执行。
安装与从 Nginx 平滑切换
不要急着把系统里的 Nginx 卸掉,先并存安装 OpenResty,确认行为一致后再切换端口和 systemd 服务。OpenResty 官方提供了 apt 源,比源码编译省事得多,也便于日后升级。
# 1. 添加官方源(Debian/Ubuntu)
wget -qO - https://openresty.org/package/pubkey.gpg | \
gpg --dearmor -o /usr/share/keyrings/openresty.gpg
echo "deb [signed-by=/usr/share/keyrings/openresty.gpg] \
http://openresty.org/package/debian $(lsb_release -sc) openresty" \
> /etc/apt/sources.list.d/openresty.list
apt update
apt install -y openresty
# 2. 确认版本与 LuaJIT
/usr/local/openresty/bin/openresty -v
# nginx version: openresty/1.25.3.1
/usr/local/openresty/luajit/bin/luajit -v
# LuaJIT 2.1.0-beta3安装后 OpenResty 的目录结构与系统 Nginx 不同:主程序在 /usr/local/openresty/nginx/sbin/nginx,配置在 /usr/local/openresty/nginx/conf/nginx.conf,默认监听 8080(避免与系统 Nginx 抢占 80)。把原有 server 块整段复制过来,先只改 listen 8081 做并行验证:
# 把现有站点配置拷过来先跑在 8081
cp /etc/nginx/sites-enabled/mysite.conf \
/usr/local/openresty/nginx/conf/conf.d/mysite.conf
sed -i 's/listen 80;/listen 8081;/' \
/usr/local/openresty/nginx/conf/conf.d/mysite.conf
# 校验并启动
/usr/local/openresty/bin/openresty -t
systemctl enable --now openresty
curl -sI http://127.0.0.1:8081/ | head -3确认 8081 上的页面和缓存行为与 80 完全一致后,再停系统 Nginx、把 listen 改回 80。这个「并行验证」步骤能避免一次切换把线上打死——尤其是当你的 config 里用了某些旧版本才支持的指令时,OpenResty 的 -t 会直接报出来,而不是等到请求进来才 500。
共享内存:OpenResty 的「进程间全局变量」
Lua 在 Nginx 里的每个 worker 进程是独立沙箱,普通的 package.loaded 全局变量在 worker 之间并不共享。要让多个 worker 看到同一份状态(限流计数、拦截名单、缓存),必须用 lua_shared_dict。这是 OpenResty 最核心的构件,也是新手最容易配错的地方。
# 在 http {} 块内声明共享内存区
http {
# 语法: lua_shared_dict <名称> <大小>;
lua_shared_dict limit_counter 10m; # 限流计数
lua_shared_dict blocklist 20m; # 封禁名单
lua_shared_dict resp_cache 50m; # 自定义响应缓存
...
}大小怎么估?共享内存里存的是 key-value,每个 key 有固定开销(约几十字节,取决于 key 长度与 LRU 元数据)。一个「计数器」场景,假设是 ip:路径 做 key,平均 40 字节,加上 value 和开销按 120 字节算,10MB 大约能放 8 万多个 key。对于限流,如果只记录活跃窗口(如 1 分钟)内的 IP,几千到几万 QPS 也够用。当共享内存写满时,OpenResty 会按 LRU 淘汰——对计数器来说这不是问题,但对「封禁名单」就要注意:如果内存太小,会把还在封禁期的 IP 挤掉,导致漏放。经验值是给名单类字典留足 20MB 以上。
共享内存是内存态,重启即清空。所有「需要持久化」的数据(真实封禁名单、黑白名单库)都应该在启动时从文件或 Redis 加载进 init_by_lua_block,共享内存只当高速缓存用。
实战一:基于共享内存的滑动窗口限流
Nginx 自带的 limit_req 是固定窗口计数器,边界处会有突发(1 秒的第 999ms 和第 1.001s 各打满一次,等于瞬时 2 倍速率)。用 Lua 加共享内存,可以实现更平滑的滑动窗口,而且能动态根据用户等级给不同的限额——这在纯 config 里做不到。
# rate_limit.lua —— 放在 /usr/local/openresty/nginx/lua/ 下
local dict = ngx.shared.limit_counter
local limit = 60 -- 每窗口允许次数
local window = 60 -- 窗口秒数
local ip = ngx.var.remote_addr
-- 取「当前窗口起点」作为 key 后缀,实现计数滚动
local now = ngx.time()
local bucket = math.floor(now / window)
local key = ip .. ":" .. bucket
local count, err = dict:incr(key, 1, 0, window * 2)
if not count then
ngx.log(ngx.ERR, "shared dict incr failed: ", err)
return
end
if count > limit then
ngx.header["Retry-After"] = window - (now % window)
ngx.status = 429
ngx.say("too many requests")
return ngx.exit(429)
end挂到想要保护的路径上。注意 access_by_lua_block 是「准入阶段」,跑在任何 content 处理之前,所以它拦截的请求不会走到 PHP/后端,省下真正的资源。
location /api/ {
access_by_lua_block {
dofile("/usr/local/openresty/nginx/lua/rate_limit.lua")
}
proxy_pass http://127.0.0.1:9000;
}
# 更常见:用 content_by_lua_file 在独立 location 里定义逻辑
location = /limited {
content_by_lua_block {
local dict, limit = ngx.shared.limit_counter, 30
local key = ngx.var.remote_addr
local c = dict:incr(key, 1, 0, 120)
if c and c > limit then
return ngx.exit(429)
end
ngx.say("ok ", c)
}
}dict:incr(key, value, init, init_ttl) 的第三个参数 init=0 表示 key 不存在时按 0 起算,第四个参数是给新 key 设置的过期时间(秒),这样窗口外的旧 key 会自动被回收,不用手写清理逻辑。这是共享内存限流比「自己维护过期时间戳」更省事的地方。压测验证限流是否生效:
# 用 ab 或 wrk 快速打 100 个请求,看有多少 429
apt install -y apache2-utils
ab -n 100 -c 10 http://127.0.0.1/limited/ 2>/dev/null | grep -E "Non-2xx|429"
# 期望:约 70 个请求返回 429(100 - 30 限额)实战二:在网关层做接口聚合,减少一次往返
个人站常见场景:一个页面需要「最新文章 + 站点公告 + 友链」三个接口,前端分别请求等于三次 TCP 握手三次往返。用 OpenResty 的 resty.http 可以在网关层并发拉取再合并,前端只发一次请求。需要先装这个生态库:
# 用 opm 安装 resty.http
/usr/local/openresty/bin/opm get ledgetech/lua-resty-http
# 或手动放置
# wget ... -O /usr/local/openresty/lualib/resty/http.lualocation = /api/dashboard {
content_by_lua_block {
local http = require "resty.http"
local cjson = require "cjson.safe"
local function fetch(url)
local c = http.new()
c:set_timeout(800) -- 毫秒,必须显式设
local res, err = c:request_uri(url)
if not res then return nil, err end
return cjson.decode(res.body)
end
-- 串行示例(简单直观);要并发用 ngx.thread.spawn
local news, e1 = fetch("http://127.0.0.1:9000/api/news")
local notice,e2 = fetch("http://127.0.0.1:9000/api/notice")
if not news then ngx.log(ngx.ERR, "news fail: ", e1) end
ngx.header["Content-Type"] = "application/json"
ngx.say(cjson.encode {
news = news or {},
notice = notice or {},
})
}
}要点:set_timeout 必须显式设置,默认值可能长到让你在压测时才发现在等上游;用 cjson.safe 而不是 cjson,因为前者解码失败返回 nil + err,而后者会直接 ngx.error 抛异常,把 500 甩给用户。上游失败时用 or {} 兜底,宁可返回空数组也不要整个接口 500——聚合接口的可用性应该等于各分片可用性的「或」,而不是「与」。
并发聚合:ngx.thread.spawn 与必须知道的阻塞代价
上面是串行写法,两个上游各 200ms 就是 400ms。OpenResty 提供了轻量级协程 ngx.thread.spawn,可以在一个请求里并发发起多个上游调用,总耗时取决于最慢的那个而不是求和:
local http = require "resty.http"
local cjson = require "cjson.safe"
local function fetch(url)
return function()
local c = http.new()
c:set_timeout(800)
local res, err = c:request_uri(url)
if not res then return nil, err end
return cjson.decode(res.body)
end
end
-- 并发发起,threads 数量 = 上游数量
local t1 = ngx.thread.spawn(fetch("http://127.0.0.1:9000/api/news"))
local t2 = ngx.thread.spawn(fetch("http://127.0.0.1:9000/api/notice"))
local ok1, news = ngx.thread.wait(t1)
local ok2, notice = ngx.thread.wait(t2)
ngx.say(cjson.encode { news = news or {}, notice = notice or {} })这里有一个极其重要但常被忽略的坑:Lua 里「阻塞式」的操作会独占当前 worker 的一个连接处理名额,但不阻塞其他请求。真正会拖垮服务器的是在 Lua 里做同步 CPU 密集或阻塞的系统调用,例如用 os.execute 跑命令、用标准库 io.open 读写大文件、死循环计算。这些会让 worker 长时间卡住,期间新请求排队。OpenResty 提供的所有 *_by_lua 之外的库(如 resty.http、resty.redis)都是非阻塞的,放心用;但一旦你要读文件,应该用 lua-resty-io 或把逻辑挪到后端,而不是 io.open。
另一个常见错误是 ngx.sleep 之外用 os.time 循环等结果。ngx.sleep 是会让出 worker 的非阻塞睡眠,其他协程可继续跑;while true do end 这种忙等则是纯粹的灾难。写 Lua 前先问自己:「这个函数会不会把 worker 占住?」凡是涉及磁盘、外部命令、同步网络的,都要换成 OpenResty 的异步等价物。
部署与热更新:改完 Lua 怎么不丢流量
Lua 代码加载后缓存在 worker 内存里,改文件不会自动生效。生产环境有两种更新方式:
# 方式一:reload(优雅,不丢连接)
# 新 worker 起来、旧 worker 处理完现有请求后退出
systemctl reload openresty
# 等价于
/usr/local/openresty/bin/openresty -s reload
# 方式二:完全重启(会瞬断,谨慎)
systemctl restart openrestyreload 是 OpenResty 的强项:它先启动新 worker(加载你改过的 Lua),再给旧 worker 发退出信号让它们优雅收尾。对于配置和 Lua 文件的更新,一律用 reload,不要 restart。但要注意 lua_shared_dict 里的数据在 reload 时不会丢失(共享内存由 master 持有),而 init_by_lua_block 会重新执行——所以别在 init 里做「累加」类操作,否则每次 reload 都会叠加一次。
建议把 Lua 逻辑做成独立文件(*.lua)用 content_by_lua_file 引用,而不是写死在 nginx.conf 里。这样升级逻辑只加一行文件替换,nginx.conf 保持稳定,也便于放进 Git 做版本管理。
常见坑位速查表
| 现象 | 根因 | 修复 |
|---|---|---|
shared dict not found | 字典没在 http 块声明,或名字打错 | 在 http {} 内加 lua_shared_dict name 10m; |
| 限流计数「各自为政」 | 用了 Lua 全局变量而非共享内存 | 改用 ngx.shared.*,worker 间才共享 |
| 接口偶发 500 且上游正常 | cjson.decode 遇到非 JSON 响应抛异常 | 换 cjson.safe,失败返回 nil 判空 |
| 压测时 QPS 上不去 | Lua 里用了 io.open/os.execute 阻塞 worker | 改异步库或挪走后端 |
| reload 后计数翻倍 | 共享内存没清但 init 又累加了一次 | init 里只做「赋值/加载」,不做累加 |
| 改了 Lua 无效果 | worker 缓存未刷新 | openresty -s reload |
最后提醒一句:OpenResty 不是「为了用而用」。如果你的需求已经被标准 Nginx 的 limit_req、map、return 覆盖了,就别引入 Lua——每多一层动态逻辑就多一份出故障的面。只有当配置语言真的表达不了(动态限流策略、网关聚合、基于外部数据的准入判断)时,OpenResty 才是那个「用几十行 Lua 换掉一个后端服务」的正确工具。先想清楚必要性,再动手写 block。