OpenResty(Nginx+Lua)自定义高并发流量过滤与限流实战

面对突发流量与恶意请求,静态Nginx配置难以灵活应对。本文深入OpenResty(Nginx+Lua)架构,手写令牌桶与计数器限流、IP/UA过滤等策略,附完整代码示例与部署避坑指南,助你构建可编程的流量盾牌。

OpenResty(Nginx+Lua)自定义高并发流量过滤与限流实战
封面图:ZuCDN · ZuCDN 原创

为什么需要自定义流量过滤与限流

生产环境中,Nginx 作为反向代理经常要承受每秒数万甚至数十万的请求。传统 ngx_http_limit_req_module 和 ngx_http_limit_conn_module 虽然能解决简单限流,但面对动态规则(如根据响应时间调整限流阈值、按地理位置或 User-Agent 做精细过滤)时,配置方式显得僵化。OpenResty 将 Lua 虚拟机嵌入 Nginx,允许在请求处理的每个阶段执行 Lua 代码,从而用极少的代码实现高度定制化的流量控制。

OpenResty 的核心能力:阶段与共享内存

OpenResty 的流量控制依赖两个基础:请求处理阶段共享内存字典(shared dict)

  • 阶段钩子:init_by_lua(初始化)、access_by_lua(权限验证)、balancer_by_lua(负载均衡)、log_by_lua(日志)等。限流和过滤最常挂在 access_by_lua 阶段,此时请求还未传给上游,驳回成本最低。
  • 共享内存:通过 lua_shared_dict 声明,所有 worker 进程可原子操作。它天然是进程安全的计数器与缓存池,适合存储限流状态(如令牌桶剩余令牌、滑动窗口计数器)。

理解这两个概念,后续实现才能得心应手。

流量过滤:不仅仅是 IP 黑名单

过滤是限流前的第一道筛子。除了 IP 白名单/黑名单,实际场景常需要更复杂的规则:

  • User-Agent 指纹:屏蔽 curl、python-requests 等无浏览器特征的客户端。
  • 请求路径与参数:对 /api/sensitive 路径实施更严格的检查,或对带特定参数(如 ?debug=1)的请求直接拒绝。
  • Referer 来源:禁止未授权的第三方域名盗链。
  • 动态名单:从 Redis 或 MySQL 加载最新的黑名单。

Lua 实现:多条件组合过滤

-- nginx.conf 中声明共享字典
lua_shared_dict filter_rules 10m;

-- access_by_lua_block 内
local method = ngx.req.get_method()
local uri = ngx.var.uri
local ua = ngx.var.http_user_agent or ""
local ip = ngx.var.remote_addr

-- 动态黑名单(假设来自 Redis)
local blacklist = require("resty.redis").new()
-- ... 省略连接逻辑
if blacklist:sismember("blacklist:ip", ip) == 1 then
    ngx.exit(ngx.HTTP_FORBIDDEN)
end

-- 禁止非浏览器 UA 访问
if ua:match("^curl") or ua:match("^python") then
    ngx.exit(ngx.HTTP_FORBIDDEN)
end

-- 敏感路径限制
if uri:match("^/api/private") and method ~= "POST" then
    ngx.exit(ngx.HTTP_FORBIDDEN)
end

注意:过滤逻辑应尽量前置,避免不必要的计算。IP 白名单可放到 geo 模块配合 Lua 变量。

高并发限流:从计数器到令牌桶

限流算法有很多,但生产环境经得起考验的常用三种:固定窗口计数滑动窗口计数令牌桶。固定窗口实现简单但存在“边界突发”问题;滑动窗口更平滑;令牌桶允许一定程度的突发,且能平滑整形流量。

示例1:基于共享内存的滑动窗口计数器

滑动窗口将时间划分为多个子槽,每个槽记录请求数。以秒级窗口为例,分 10 个子槽(每 100ms 一个)。Lua 实现要点:

local limiter = {}
local dict = ngx.shared.my_limit

function limiter:check(key, max_requests, window_sec)
    local now = ngx.time()
    local slot = math.floor(now * 10) % 10  -- 10 个槽
    local slot_key = key .. ":" .. slot
    local current = dict:incr(slot_key, 1, 0)
    
    -- 清理过期槽(与当前时间差超过窗口的槽)
    local total = 0
    for i = 0, 9 do
        local k = key .. ":" .. slot
        local v = dict:get(k) or 0
        total = total + v
    end
    
    if total > max_requests then
        return false, "rate exceeded"
    end
    return true
end

此方法浪费内存,但无需定时器。实际可以用 lua-resty-limit-traffic 库提供的计数器。

示例2:令牌桶实现(推荐)

令牌桶算法需要两个变量:当前令牌数上次更新时间。每次请求时先计算这段时间应生成的令牌数,再决定是否消耗一个令牌。用 shared dict 的 add()incr() 保证原子性。

local token_bucket = {}

function token_bucket:init(rate, burst)
    -- rate: 每秒生成令牌数, burst: 桶容量
    self.key = "bucket:" .. self.rate .. ":" .. self.burst
    local dict = ngx.shared.token_buckets
    if not dict:get(self.key) then
        dict:set(self.key, 0)  -- 当前令牌
        dict:set(self.key .. ":ts", ngx.now())
    end
end

function token_bucket:acquire()
    local dict = ngx.shared.token_buckets
    local now = ngx.now()
    local tokens = dict:get(self.key) or 0
    local last_ts = dict:get(self.key .. ":ts") or now
    
    -- 计算新增令牌
    local elapsed = now - last_ts
    tokens = math.min(self.burst, tokens + elapsed * self.rate)
    
    -- 更新时间和令牌数
    dict:set(self.key .. ":ts", now)
    
    if tokens < 1 then
        ngx.log(ngx.WARN, "令牌不足,请求被拒")
        return false
    end
    
    dict:set(self.key, tokens - 1)
    return true
end

注意:使用 ngx.now() 获取浮点数时间。令牌桶允许短时间内突发到峰值速率(burst),适合 API 网关场景。

部署配置:在 nginx.conf 中串联过滤与限流

把上述逻辑放到 access_by_lua_block 中,顺序执行过滤、限流,任意一步拒绝则直接 exit。

location /api/ {
    access_by_lua_block {
        -- 第一步:过滤
        local ok, err = filter_by_ip(ngx.var.remote_addr)
        if not ok then
            ngx.exit(ngx.HTTP_FORBIDDEN)
        end
        
        -- 第二步:限流(令牌桶)
        local bucket = token_bucket:new({rate=100, burst=200})
        if not bucket:acquire() then
            ngx.status = ngx.HTTP_TOO_MANY_REQUESTS
            ngx.say("{"code":429,"msg":"rate limit"}")
            ngx.exit(ngx.HTTP_OK)  -- 返回 429 但依旧调用 exit
        end
    }
    proxy_pass http://backend;
}

建议将 Lua 代码存为外部 .lua 文件,通过 access_by_lua_file 引入,便于维护和热加载(配合 lua_code_cache on;修改文件后 reload Nginx 即可生效)。

验证、调优与监控

部署后务必进行压力测试。推荐 wrkhey,关注以下指标:

  • 正确性:是否在预期阈值处开始拒绝?滑动窗口是否平滑?
  • 性能损耗:Lua 脚本执行时间应 < 1ms,否则用 lua_package_cpath 加载 C 拓展。共享字典操作 O(1)。
  • 内存占用:shared dict 要足够大(例如 10m 可存储数万个 key)。避免因内存不足导致新请求失败。
  • 日志:在 log_by_lua 中记录被拒绝的请求详情,用于分析恶意模式。
log_by_lua_block {
    if ngx.status == 429 or ngx.status == 403 then
        local logger = ngx.log
        logger(ngx.WARN, "blocked: ", ngx.var.remote_addr, " ", ngx.var.uri)
    end
}

常见陷阱与规避

  • 原子操作get + set 组合不是原子的!必须使用 incraddreplacelua-resty-lock。上面的令牌桶代码用了两次 set,在高并发下会导致竞争。最佳实践是使用 dict:get_stale() 加锁或改用 lua-resty-limit-traffic 封装好的 API。
  • 时间同步:多 worker 使用 ngx.now() 时间基本一致,但若 Nginx 机器时间跳变会导致限流失效,建议使用 NTP 服务。
  • 缓存穿透:过滤时若从 Redis 加载黑名单,应本地缓存并设置过期时间,避免每次请求都打 Redis。
  • 优雅降级:当 Redis 连接失败时,应放行而非全部拒绝(fail open 还是 fail closed 需业务决定)。

总结

OpenResty 的高并发流量控制能力远超静态配置。通过灵活的 Lua 脚本,可以实现 IP 黑白名单、动态 UA 过滤、基于令牌桶或滑动窗口的精准限流,并且所有操作在 Nginx worker 内完成,无需外部服务。写好脚本后,务必进行原子性检查和压力验证。掌握这些技能,便能应对多数突发流量与 CC 攻击场景,构建出高效、可编程的流量网关。

延伸阅读