为什么单靠 Nginx 限流已经挡不住恶意流量
很多个人站长的服务器第一次被人打爆,往往是在深夜里毫无预兆地发生。第二天早上打开监控一看,CPU 跑满、带宽跑满、MySQL 连接数爆表,网站已经 502 了几个小时。第一反应通常是去查 Nginx 的 limit_req 和 limit_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.DICT 的 incr 在字典写满时会失败,生产环境里一定要判断返回值,写不进去时选择放行而不是报错。
如果返回 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 这一下能救命。