OpenResty 风控实战:用 Lua 给 Nginx 加一层地域限制与恶意流量拦截

为什么单靠 Nginx 限流已经挡不住恶意流量

很多个人站长的服务器第一次被人打爆,往往是在深夜里毫无预兆地发生。第二天早上打开监控一看,CPU 跑满、带宽跑满、MySQL 连接数爆表,网站已经 502 了几个小时。第一反应通常是去查 Nginx 的 limit_reqlimit_conn,加了几个限流规则,然后发现问题只解决了一半——攻击流量确实被压下去一些,但正常用户的访问也跟着变慢了,而真正讨厌的那批 IP 依然在源源不断地敲门。

原因其实不复杂:limit_req 是基于「请求频率」做判断的,它只能回答「这个 IP 一秒请求了多少次」,却回答不了「这个 IP 来自哪里」「它的行为像不像真人」「它请求的是不是它本来就不该访问的接口」。当一个爬虫把频率控制在每秒 3 次、用几百个 IP 轮换、并且专门挑你的搜索接口和列表页翻页时,频率型限流几乎完全失效。这时候你需要的是在 Nginx 这一层做「风控」,也就是在请求真正進入 PHP 之前,用更丰富的信息来判断这个请求该不该被放行。

本文会从零开始,带你用 OpenResty(也就是带 Lua 的 Nginx)搭建一套轻量级但足够实用的风控层,包含 GeoIP 地域识别、恶意 UA 拦截、热点接口频次画像、IP 黑名单动态加载,以及如何在拦截之后留下可追溯的日志。整套方案在 1 核 1G 的小机器上也能跑,不会给你本来就不宽裕的内存雪上加霜。

准备工作:OpenResty 与 GeoIP 数据

OpenResty 本质上就是 Nginx 加上了 LuaJIT 和一系列 Lua 库,你现有的 Nginx 配置几乎可以原样迁移。在 Debian/Ubuntu 上安装非常直接:

# 添加官方源并安装
apt-get install -y gnupg ca-certificates
wget -O - 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-get update
apt-get install -y openresty libmaxminddb0 libmaxminddb-dev

# 版本确认
/usr/local/openresty/nginx/sbin/nginx -v

安装完成后建议先停掉原来的 Nginx,避免两个进程抢 80 端口。OpenResty 的配置文件默认在 /usr/local/openresty/nginx/conf/nginx.conf,你可以先把原来 /etc/nginx/ 下的站点配置整体拷贝过去,改一下 include 路径即可。

GeoIP 需要一份 IP 到国家、城市的映射数据库。MaxMind 官方的 GeoLite2 免费,但需要注册账号拿 License Key,下载链接是带签名的,非常不适合写在脚本里长期使用。更省事的做法是用社区维护的镜像数据,或者直接用只包含国家段的精简库。如果你只想做「某个国家/地区的流量分流」,其实只需要国家维度的数据,几百 KB 就够用了:

# 目录准备
mkdir -p /usr/local/openresty/geoip

# 使用 lua-resty-maxminddb 作为解析库(OpenResty 生态的标准选择)
cd /usr/local/openresty
git clone https://github.com/anjia0532/lua-resty-maxminddb.git
cp -r lua-resty-maxminddb/lib/resty/maxminddb* \
      /usr/local/openresty/lualib/resty/

# 把下载好的 GeoLite2-Country.mmdb 放到 geoip 目录
ls -lh /usr/local/openresty/geoip/GeoLite2-Country.mmdb

这里有个容易被忽略的坑:lua-resty-maxminddb 加载 mmdb 文件是在 worker 启动时完成的,如果你在运行中替换了 mmdb 文件,必须 nginx -s reload 才能生效,光重载配置不重新打开文件句柄是不够的。另外 mmdb 文件路径一定要用绝对路径,用相对路径在 reload 后可能因为工作目录变化而找不到文件,表现为所有请求的 GeoIP 判断都返回空。

核心:一套可读性强的风控 Lua 脚本

把复杂的判断逻辑全部塞进 access_by_lua_block 会让 nginx.conf 变得难以维护,更好的做法是抽成独立的 Lua 模块。下面这套脚本把风控拆成四道闸门,任何一道命中就直接返回 403 并记录原因。

-- /usr/local/openresty/nginx/conf/waf.lua
local _M = {}

local maxminddb = require "resty.maxminddb"
local GEO_DB = "/usr/local/openresty/geoip/GeoLite2-Country.mmdb"

-- 明显恶意的 User-Agent 特征(爬虫、扫描器、压测工具)
local BAD_UA = {
    "sqlmap", "nikto", "nmap", "masscan", "acunetix",
    "zgrab", "go%-http%-client/1.1", "python%-requests/2.2",
}

-- 只允许放行的国家码,其余一律拦截
-- 如果你的站点面向中文用户,这里通常是 {"CN", "HK", "TW", "MO"}
local ALLOW_COUNTRY = { CN = true, HK = true, TW = true, MO = true }

function _M.check_country()
    local ok, err = maxminddb.init(GEO_DB)
    if not ok then
        -- 关键:GeoIP 加载失败时放行,绝不因风控组件故障误伤全部用户
        ngx.log(ngx.ERR, "geoip init failed: ", err)
        return true
    end
    local ip = ngx.var.remote_addr
    local res, err2 = maxminddb.lookup(ip)
    if not res then
        ngx.log(ngx.WARN, "geoip lookup failed for ", ip, ": ", err2)
        return true
    end
    local cc = res.country and res.country.iso_code
    if not cc then return true end
    if not ALLOW_COUNTRY[cc] then
        return false, "country_block:" .. cc
    end
    return true
end

function _M.check_ua()
    local ua = ngx.var.http_user_agent or ""
    local lower = string.lower(ua)
    if ua == "" then
        return false, "empty_ua"
    end
    for _, pat in ipairs(BAD_UA) do
        if ngx.re.find(lower, pat, "jo") then
            return false, "bad_ua:" .. pat
        end
    end
    return true
end

function _M.check_uri()
    -- 拦截常见扫描路径,省掉大量无意义的 PHP 解析开销
    local uri = ngx.var.request_uri
    local patterns = {
        "%.env", "%.git/", "wp%-login%.php", "phpmyadmin",
        "%.sql$", "eval%-stdin", "xmlrpc%.php$", "/vendor/",
    }
    for _, pat in ipairs(patterns) do
        if ngx.re.find(uri, pat, "jo") then
            return false, "scan_uri:" .. pat
        end
    end
    return true
end

return _M

然后在 nginx.conf 的 server 段里挂上这层检查:

lua_shared_dict risklog 10m;

server {
    listen 80;
    server_name www.example.com;

    set $block_reason "";

    access_by_lua_block {
        local waf = require "waf"
        local ok, reason = waf.check_ua()
        if not ok then
            ngx.var.block_reason = reason
            return ngx.exit(403)
        end
        ok, reason = waf.check_uri()
        if not ok then
            ngx.var.block_reason = reason
            return ngx.exit(403)
        end
        -- 地域风控放在最后,因为它开销最大
        ok, reason = waf.check_country()
        if not ok then
            ngx.var.block_reason = reason
            return ngx.exit(403)
        end
    }

    log_format risk '$remote_addr - [$time_local] "$request" '
                    '$status ua="$http_user_agent" block="$block_reason"';
    access_log /var/log/nginx/risk.log risk;

    location / { try_files $uri $uri/ /index.php?$args; }
}

这套判断顺序是有讲究的:先做最便宜的正则匹配,再做需要读内存映射文件的 GeoIP 查询。把开销大的判断放后面,可以让绝大多数正常请求少走一段路。同时你一定会注意到那段「GeoIP 加载失败就放行」的逻辑——这是风控系统里最重要的设计原则之一:宁可漏放,不可误杀。一个风控脚本写崩了、把所有访客挡在门外,对个人站点的伤害远大于放过几百个恶意请求。

把「频率画像」做成真正有用的东西

前面说过,单纯的频率限流对轮换 IP 的爬虫无效。但如果你把频率和「访问目标」结合起来,效果会好很多。比如搜索接口、翻页接口、详情页这三个位置,正常用户的访问节奏和数据抓取程序是完全不同的:正常人翻页会有停顿、会点进详情、会有 404;而抓取程序往往在几秒内连续请求几十个不同的 ID,却没有一次点击详情页里的外链。

-- 用共享字典记录每个 IP 近期访问过的不同 URI 数量
local dict = ngx.shared.risklog

local function uri_diversity_score(ip)
    local key = "uri:" .. ip
    local n = dict:get(key)
    if not n then
        -- 60 秒过期窗口,计数器本身也当作去重集合的近似
        dict:set(key, 1, 60)
        return 1
    end
    n = n + 1
    dict:set(key, n, 60)
    return n
end

-- 在 access_by_lua_block 中调用
local score = uri_diversity_score(ngx.var.remote_addr)
-- 60 秒内请求超过 120 个不同 URI,基本可以判定是采集
if score > 120 then
    ngx.var.block_reason = "uri_flood:" .. score
    return ngx.exit(429)
end

需要注意的是,共享字典是带过期的近似统计,不是精确的集合。用计数器代替真正的去重集合,是为了省内存——一个真实 URI 集合存 1 万个 IP 会把 lua_shared_dict 撑爆,而在风控场景下近似值已经完全够用。另外 ngx.shared.DICTincr 在字典写满时会失败,生产环境里一定要判断返回值,写不进去时选择放行而不是报错。

如果返回 429,记得同时加上 Retry-After 头,这是对正常用户和搜索引擎爬虫的礼貌:

if score > 120 then
    ngx.header["Retry-After"] = "120"
    ngx.var.block_reason = "uri_flood:" .. score
    return ngx.exit(429)
end

动态黑名单:不 reload 也能封 IP

风控做到最后,一定会遇到「某个 IP 段实在太烦了,我要立刻封掉」的场景。每次都改配置文件再 reload,遇到高强度攻击时 reload 本身就可能成为负担。用 lua_shared_dict 配合一个外部管理接口是更优雅的方案。

-- 黑名单检查
local function is_blacklisted(ip)
    local bl = ngx.shared.blacklist
    if not bl then return false end
    if bl:get(ip) then return true end
    -- 支持按 C 段封禁
    local c_seg = ip:match("^(%d+%.%d+%.%d+)%.")
    if c_seg and bl:get(c_seg .. ".0/24") then return true end
    return false
end

-- 管理接口,必须做来源限制
location = /admin/block {
    allow 127.0.0.1;
    deny all;
    content_by_lua_block {
        local ip = ngx.var.arg_ip
        local ttl = tonumber(ngx.var.arg_ttl) or 3600
        if not ip then return ngx.say("missing ip") end
        local bl = ngx.shared.blacklist
        bl:set(ip, 1, ttl)
        ngx.say("blocked ", ip, " for ", ttl, "s")
    }
}

管理接口只监听 127.0.0.1 并通过 SSH 隧道访问,不要暴露在公网。封禁时设置 TTL 是刻意的设计——永久的动态黑名单会在几个月后变成一堆没人清理的垃圾数据,把正常用户也挡在外面,而 1 小时到 24 小时的封禁已经足以让绝大多数自动化攻击者放弃。

日志与复盘:拦截之后要能说得清

风控最容易犯的错误是「只会拦,不会看」。上线一个月后你打开日志,发现有几万条 403,却不知道它们分别来自哪里、命中了哪条规则。所以从第一天起就要把 $block_reason 写进日志,然后用一条简单的命令做统计:

# 统计各类拦截原因的数量
awk '{for(i=1;i<=NF;i++) if($i ~ /^block=/) print $i}' \
    /var/log/nginx/risk.log | sort | uniq -c | sort -rn | head -20

# 找出被拦次数最多的前 20 个 IP
awk '$9 == 403 {print $1}' /var/log/nginx/risk.log \
    | sort | uniq -c | sort -rn | head -20

# 看被拦的 UA 长什么样,判断是不是误伤
awk '$9 == 403 {match($0, /ua="[^"]*"/); print substr($0, RSTART+4, RLENGTH-5)}' \
    /var/log/nginx/risk.log | sort | uniq -c | sort -rn | head -20

这三条命令分别回答三个问题:我的规则哪条最常触发、谁在打我、我有没有误伤正常浏览器。特别是第三条,如果统计结果里出现了大量正常浏览器 UA,说明某条规则写得太宽了,需要立刻收紧。建议把这三条命令写成 logrotate 的 postrotate 脚本,每天自动生成一份统计报告发到自己邮箱,这样即使你不主动查日志,也能第一时间发现规则误伤。

最后一点:风控的上限和边界

需要坦诚地说,Nginx 层的风控不是银弹。它能挡掉的是「特征明显、行为粗暴」的流量,比如扫描器、无脑采集、单一 IP 的高频访问。但如果遇到的是分布在几千个真实家宽 IP 上、模拟真人行为、带完整浏览器指纹的分布式爬虫,单机 OpenResty 是挡不住的——那种场景需要接入专业的 WAF 或者 CDN 厂商的 Bot 管理服务,成本也完全是另一个量级。

对个人站长来说更现实的定位是:用最低的成本把 90% 的低质量流量挡在 PHP 之前。这带来的好处不只是安全,还包括实实在在的性能收益——每一个被 Lua 层挡掉的请求,都省下了一次 PHP 进程的调度、一次数据库连接的建立、一次的日志写入。在 1 核小机器上,这套风控在压测中能把有效承载能力提升 30% 以上,这才是它最被低估的价值。

另外无论规则写得多完备,都建议留一个逃生通道:给所有风控规则加一个全局开关(比如读一个 /tmp/waf_off 文件是否存在,存在就跳过全部检查)。当你半夜发现误伤、却又不方便立刻改代码的时候,touch /tmp/waf_off 这一下能救命。

Last modification:September 18th, 2026 at 12:24 pm

Leave a Comment