Nginx 防爬虫配置中如何设置动态黑名单过期时间?
Nginx 动态黑名单过期时间由存储层决定:原生 map/geo 无法自动过期,只能定时重写并 reload;OpenResty 通过 lua_shared_dict 的 exptime 设置过期,需注意惰性删除和重启丢失,阶梯封禁可分别存
Nginx 本身没有「黑名单过期时间」这个配置项,过期时间由承载黑名单的存储层决定:用 OpenResty 就走 ngx.shared.DICT:set(ip, 1, 600) 的第三个参数 exptime(单位秒),用 fail2ban 就调 bantime 与 bantime.increment,而纯原生 Nginx 的 map/geo 方案无法自动过期,只能靠定时任务重写配置再 nginx -s reload。结论:选哪种过期机制,先看你有没有 lua-nginx-module。
Nginx 原生为什么做不到黑名单自动过期?
结论:deny、map、geo 都是配置加载期生成的静态结构,只在 nginx -s reload 时重建,运行期没有任何「写入—失效」接口,所以原生方案不存在过期语义。
有人用 map $remote_addr $blocked { ... } 加一个定时脚本,把封禁 IP 追加到 blocklist.conf 再 reload。这条路可行但有明确代价:每次 reload 都要新旧 worker 平滑切换,IP 列表到 1 万条以上时 reload 耗时与内存翻倍的抖动会很明显,而且频繁 reload 会打断长连接。封禁量小(几百条)可以用,规模上来必须换存储。
lua_shared_dict 的过期时间怎么设置?
结论:在 http 块声明 lua_shared_dict blacklist 10m;,写入时用 dict:set(key, value, exptime) 指定秒数,到点后 dict:get() 自动返回 nil,无需任何清理任务。
http {
lua_shared_dict blacklist 10m;
lua_shared_dict ban_count 10m;
server {
access_by_lua_block {
local ip = ngx.var.remote_addr
if ngx.shared.blacklist:get(ip) then
return ngx.exit(403)
end
}
}
}
三个必须知道的细节:
exptime传0或省略表示永不过期,别把「0 = 立即过期」搞反。- 过期是惰性删除:键到期后仍占着内存,直到被再次访问或被 LRU 淘汰。用
local n = dict:flush_expired(100)主动清理,每次最多清 100 个,建议放在ngx.timer.every(60, flush)里。 - shared dict 是所有 worker 共享的同一块内存,
nginx -s reload期间数据保留,但restart或进程崩溃后全部丢失。要跨重启保留,得把黑名单落到 Redis。
容量按「每个键值约 100 字节元数据 + 键值本身」估算,10MB 大致能存几万条 IPv4 记录;写满后默认按 LRU 淘汰,会把仍在封禁期的 IP 挤掉,所以宁可开 20m 也别卡着用。
怎么做阶梯封禁(10 分钟 → 1 小时 → 24 小时)?
结论:用两个 dict,一个存违规次数(长 TTL,如 604800 秒),一个存当前封禁状态(短 TTL),按次数映射不同的 exptime 即可。
local bl = ngx.shared.blacklist
local cnt = ngx.shared.ban_count
local ip = ngx.var.remote_addr
if bl:get(ip) then return ngx.exit(403) end
-- 命中爬虫特征后执行:计数窗口 7 天
local n = cnt:incr(ip, 1, 0, 604800)
local ttl = ({600, 3600, 86400})[math.min(n, 3)]
bl:set(ip, n, ttl)
dict:incr(key, value, init, init_ttl) 的后两个参数需要 OpenResty 1.11.2 以上版本支持,老版本要手动 get 后 set。
fail2ban + ipset 方案的过期怎么配?
结论:改 bantime 控制单次封禁时长,加 bantime.increment = true 实现递增封禁,这三项配置在 fail2ban 0.11.0 及以上版本生效。
[nginx-badbot]
enabled = true
filter = nginx-badbot
action = iptables-ipset-proto6[name=badbot]
logpath = /var/log/nginx/access.log
findtime = 600
maxretry = 30
bantime = 3600
bantime.increment = true
bantime.factor = 2
bantime.maxtime = 86400
含义是首次封 1 小时,第二次 2 小时、第三次 4 小时…… 最长不超过 24 小时。ipset 层对应的是 timeout 参数,可以直接验证:ipset list badbot 能看到每条记录的剩余超时。
配过期时间最容易踩哪些坑?
结论:最大的坑是拿不到真实 IP 和过期时间单位弄错,前者让封禁误伤整段 CDN 节点,后者让封禁从 1 小时变成 1 秒。
- 站前有 CDN 时,
ngx.var.remote_addr拿到的是回源节点地址。必须在http或server块加set_real_ip_from 回源段;与real_ip_header X-Forwarded-For;,否则一条黑名单会把整个 CDN 段封掉。 lua_shared_dict的exptime单位是秒,不是毫秒;bantime支持1h、1d这类带单位写法,两种风格别混。- 拦截要放在
access_by_lua_block,放在log_by_lua_block只能记录不能拦截;判定逻辑要早于content阶段,否则静态文件已经被try_files处理掉了。 - 别只封 IP:UA 特征、请求频率、
robots.txt之外的路径扫描,建议和 IP 黑名单叠加使用,避免同一出口 IP 下正常用户被误伤。
总的来说,动态黑名单的「动态」和「过期」是两件事:前者靠运行期可写的存储(shared dict 或 Redis),后者靠存储自带的 TTL。OpenResty 环境优先用 lua_shared_dict + exptime + 定时 flush_expired;已经在用 fail2ban 的,直接调 bantime 和 bantime.increment,成本最低。配完之后务必用 curl 从一台测试 IP 触发一次封禁,再等 TTL 到期确认自动解封,别只看日志。
转载请注明出处,版权归原作者所有。