⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 http://www.iocoder.cn/Fight/Nginx-shixiandongtaifengjinIPxiangxijiaochenglaile/ 「芋道源码」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注微信公众号:【芋道源码】有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言都将得到认真回复。甚至不知道如何读源码也可以请教噢。
  4. 新的源码解析文章实时收到通知。每周更新一篇左右。
  5. 认真的源码交流微信群。

凌晨三点的爬虫,运维半小时 ban 一次 IP

凌晨三点,监控告警炸了:QPS 飙到平时 10 倍,网卡入向流量打满。一查源 IP——三个段在反复抓商品列表页,明显是爬虫。

运维大哥半睡半醒爬起来,登服务器、改 iptables、-D 一段、reload——折腾半小时,刚回床上躺下,爬虫换段了又来一波。

这就是绝大多数小团队封 IP 的真实状态:手动、不及时、不分布式、不会自动解封。今天直接给一份能上生产的 60 行 Lua 脚本——Nginx 自动封 IP、时间到了自动解封、多机共享黑名单、Redis 抖动也不会拖死业务。

环境前提:

  • Linux(CentOS 7 / Ubuntu 都行)
  • Redis 5.0+
  • Nginx 走 OpenResty(自带 Lua 模块)

封 IP 在哪一层做?三种方案决策矩阵

为什么选中间这套? 因为只有它同时满足”动态”和”分布式”——多台 Nginx 共用一份 Redis 黑名单,封禁信息全集群一致;时间到了自动解封,不用手工清理。

接入 Nginx:一行配置 access_by_lua_file

在需要保护的 location 里加一行 Lua 钩子即可:

location / {
# 如果有静态资源混在同一 location,按需匹配
# if ($request_uri ~ .*\.(html|htm|jpg|js|css)) {
# access_by_lua_file /usr/local/lua/access_limit.lua;
# }

access_by_lua_file /usr/local/lua/access_limit.lua; # access 阶段执行限流
alias /usr/local/web/;
index index.html index.htm;
}

access_by_lua_file 是 OpenResty 的 access 阶段钩子——每个进入这个 location 的请求都会先跑一遍 Lua 脚本。脚本判定通过才继续走 Nginx 后续流程。

生产级 Lua 脚本:60 行能直接抄

-- /usr/local/lua/access_limit.lua

-- ===== 配置区(建议外置到 nginx.conf 用 set_by_lua)=====
local pool_max_idle_time = 10000
local pool_size = 100
local redis_connection_timeout = 100
local redis_host = "your redis host ip"
local redis_port = 6379
local redis_auth = "your redis password"

local ip_block_time = 120 -- 封禁时长(秒)
local ip_time_out = 1 -- 限频窗口(秒)
local ip_max_count = 3 -- 窗口内最大访问次数

-- 受信任的代理层数(CDN + LB + Nginx 自己 = 视部署而定,0 = 不信 X-Forwarded-For)
local trusted_proxy_hops = 0

-- ===== 工具函数 =====
local function close_redis(red)
if not red then return end
local ok, err = red:set_keepalive(pool_max_idle_time, pool_size)
if not ok then red:close() end
end

local function get_real_ip()
if trusted_proxy_hops <= 0 then
return ngx.var.remote_addr
end
local xff = ngx.req.get_headers()["X-Forwarded-For"]
if not xff then return ngx.var.remote_addr end
local ips = {}
for ip in string.gmatch(xff, "([^,]+)") do
table.insert(ips, ip:match("^%s*(.-)%s*$"))
end
local target_idx = #ips - trusted_proxy_hops
return ips[target_idx] or ngx.var.remote_addr
end

-- ===== 主流程:fail-open 保护 =====
local redis = require "resty.redis"
local client = redis:new()
client:set_timeout(redis_connection_timeout)

local ok, err = client:connect(redis_host, redis_port)
if not ok then
-- Redis 不可用直接放行,不阻断业务
ngx.log(ngx.WARN, "redis connect failed, fail-open: ", err)
return
end

if client:get_reused_times() == 0 then
local auth_ok, auth_err = client:auth(redis_auth)
if not auth_ok then
ngx.log(ngx.ERR, "redis auth failed: ", auth_err)
close_redis(client)
return
end
end

local clientIp = get_real_ip()
local incrKey = "limit:count:" .. clientIp
local blockKey = "limit:block:" .. clientIp

-- 命中黑名单 → 先归还连接再 exit
local is_block = client:get(blockKey)
if tonumber(is_block) == 1 then
close_redis(client)
return ngx.exit(ngx.HTTP_FORBIDDEN)
end

-- EVAL 让 incr + expire 原子化,杜绝孤儿 key
local count_script = [[
local current = redis.call('INCR', KEYS[1])
if current == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return current
]]
local ip_count, eval_err = client:eval(count_script, 1, incrKey, ip_time_out)
if not ip_count then
ngx.log(ngx.ERR, "eval failed: ", eval_err)
close_redis(client)
return
end

if tonumber(ip_count) > ip_max_count then
-- SET + EX 一条命令,无需两步
client:set(blockKey, 1, "EX", ip_block_time)
end

close_redis(client)

3 个变量按你业务调:ip_max_count(阈值)/ ip_time_out(窗口)/ ip_block_time(封禁时长)。

脚本里 4 个不能省的设计,每一个都对应一个生产坑

网上能搜到的版本基本都漏了下面 4 个点——少做一个就是一颗定时炸弹:

设计 1:Redis 不可用 → fail-open 放行(不是 500)

local ok, err = client:connect(redis_host, redis_port)
if not ok then
ngx.log(ngx.WARN, "redis connect failed, fail-open: ", err)
return -- ⭐ 不阻断
end

踩过的坑:很多脚本在 connect 失败时 ngx.exit(500) 阻断请求——Redis 抖一下整个网站全挂。安全机制不能反过来打死自己——爬虫多挡几秒不要紧,正常用户挂了才是大事。

设计 2:close_redis 调用必须在 ngx.exit 之前

if tonumber(is_block) == 1 then
close_redis(client) -- ⭐ 先归还连接
return ngx.exit(ngx.HTTP_FORBIDDEN)
end

踩过的坑:ngx.exit 直接结束阶段,后面代码完全不执行。如果 close_redis 写在 ngx.exit 后面,每次 403 都泄露一个 Redis 连接——连接池迟早被打满。

设计 3:incr + expire 用 EVAL 原子化

local count_script = [[
local current = redis.call('INCR', KEYS[1])
if current == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return current
]]
local ip_count = client:eval(count_script, 1, incrKey, ip_time_out)

踩过的坑:Lua 这层做 incr 然后再 expire 不是原子的——两条命令之间脚本被打断(OOM、网络抖动),下次再 INCR 时 key 没设 TTL,这个 key 永远不会过期,Redis 慢慢被孤儿 key 撑爆。

设计 4:默认用 remote_addr,X-Forwarded-For 仅在信任代理链时启用

if trusted_proxy_hops <= 0 then
return ngx.var.remote_addr -- ⭐ TCP 层 IP,伪造不了
end
-- 信任链已知(CDN → LB → Nginx)才从右往左数第 N+1 个

踩过的坑:直接 X-Forwarded-For 第一个值——爬虫伪造 header 来”换 IP”,封禁形同虚设。这个 header 只能在你完全清楚信任链时使用,否则用 remote_addr 才是底线。

进阶:白名单、动态阈值、监控告警

白名单:合作方 / 内部 IP 不走限流

简单做法——在 get_real_ip 后加一段:

local whitelist = {
["192.168.0.0/16"] = true,
["10.0.0.0/8"] = true,
["1.2.3.4"] = true, -- 合作方固定 IP
}
-- 用 ngx.re.find 或 lua-resty-iputils 做 CIDR 匹配,命中直接 return

生产环境推荐用 lua-resty-iputils 做 CIDR 匹配,性能比纯 Lua 字符串处理强一个数量级。

动态阈值:阈值放 Redis 里,运维改完即生效

把 ip_max_count / ip_block_time 从代码常量改成 Redis 读取——运维想调阈值不用 reload Nginx:

local ip_max_count = tonumber(client:get("limit:config:max_count")) or 3
local ip_block_time = tonumber(client:get("limit:config:block_time")) or 120

配合一个简单的运维后台:管理员调一下数字,整个集群秒级生效。

监控告警:被封 IP 数量打到 Prometheus

每次进入 ngx.exit(403) 时,自增一个 metric counter,Prometheus 抓回来画曲线。突然飙升 = 大概率攻击/爬虫高潮,自动告警拉群。

local metric_blocked = ...  -- 用 lua-resty-prometheus 注册的 counter
metric_blocked:inc(1, {ip = clientIp})

总结:60 行脚本能解决 80% 的爬虫问题

回头看这套方案,真正起决定作用的就三件事:

关键 解决什么
Redis 共享黑名单 多台 Nginx 集群一致——一台拉黑、全集群生效
TTL 自动过期 不用人工解封,时间到了自动释放
fail-open 设计 Redis 抖动不连累业务——安全机制不能打死自己

最后说一句:这套脚本能挡住 80% 的爬虫——剩下 20% 的高级对手(带 IP 池、行为模拟)需要更进一步的方案:UA 指纹、行为分析、滑动验证码、TLS 指纹(JA3)……但先把基础挡住,再去想高级的——别一上来就买商业 WAF,对小团队是过度设计。

文章目录
  1. 1. 凌晨三点的爬虫,运维半小时 ban 一次 IP
  2. 2. 封 IP 在哪一层做?三种方案决策矩阵
  3. 3. 接入 Nginx:一行配置 access_by_lua_file
  4. 4. 生产级 Lua 脚本:60 行能直接抄
  5. 5. 脚本里 4 个不能省的设计,每一个都对应一个生产坑
    1. 5.0.1. 设计 1:Redis 不可用 → fail-open 放行(不是 500)
    2. 5.0.2. 设计 2:close_redis 调用必须在 ngx.exit 之前
    3. 5.0.3. 设计 3:incr + expire 用 EVAL 原子化
    4. 5.0.4. 设计 4:默认用 remote_addr,X-Forwarded-For 仅在信任代理链时启用
  • 6. 进阶:白名单、动态阈值、监控告警
    1. 6.0.1. 白名单:合作方 / 内部 IP 不走限流
    2. 6.0.2. 动态阈值:阈值放 Redis 里,运维改完即生效
    3. 6.0.3. 监控告警:被封 IP 数量打到 Prometheus
  • 7. 总结:60 行脚本能解决 80% 的爬虫问题