👉这是一个或许对你有用的社群
🐱 一对一交流/面试小册/简历优化/求职解惑,欢迎加入「芋道快速开发平台」知识星球。下面是星球提供的部分资料:
《项目实战(视频)》:从书中学,往事中“练”
《互联网高频面试题》:面朝简历学习,春暖花开
《架构 x 系统设计》:摧枯拉朽,掌控面试高频场景题
《精进 Java 学习指南》:系统学习,互联网主流技术栈
《必读 Java 源码专栏》:知其然,知其所以然
👉这是一个或许对你有用的开源项目
国产Star破10w的开源项目,前端包括管理后台、微信小程序,后端支持单体、微服务架构
RBAC权限、数据权限、SaaS多租户、商城、支付、工作流、大屏报表、ERP、CRM、AI大模型、IoT物联网等功能:
多模块:https://gitee.com/zhijiantianya/ruoyi-vue-pro
微服务:https://gitee.com/zhijiantianya/yudao-cloud
视频教程:https://doc.iocoder.cn
【国内首批】支持 JDK17/21+SpringBoot3、JDK8/11+Spring Boot2双版本
凌晨三点的爬虫,运维半小时 ban 一次 IP
封 IP 在哪一层做?三种方案决策矩阵
接入 Nginx:一行配置 access_by_lua_file
生产级 Lua 脚本:60 行能直接抄
脚本里 4 个不能省的设计,每一个都对应一个生产坑
进阶:白名单、动态阈值、监控告警
总结:60 行脚本能解决 80% 的爬虫问题
凌晨三点的爬虫,运维半小时 ban 一次 IP
凌晨三点,监控告警炸了:QPS 飙到平时 10 倍,网卡入向流量打满。一查源 IP——三个段在反复抓商品列表页,明显是爬虫。
运维大哥半睡半醒爬起来,登服务器、改iptables、-D一段、reload——折腾半小时,刚回床上躺下,爬虫换段了又来一波。
这就是绝大多数小团队封 IP 的真实状态:手动、不及时、不分布式、不会自动解封。今天直接给一份能上生产的 60 行 Lua 脚本——Nginx 自动封 IP、时间到了自动解封、多机共享黑名单、Redis 抖动也不会拖死业务。
环境前提:
Linux(CentOS 7 / Ubuntu 都行)
Redis 5.0+
Nginx 走 OpenResty(自带 Lua 模块)
基于 Spring Boot + MyBatis Plus + Vue & Element 实现的后台管理系统 + 用户小程序,支持 RBAC 动态权限、多租户、数据权限、工作流、三方登录、支付、短信、商城等功能
项目地址:https://github.com/YunaiV/ruoyi-vue-pro
视频教程:https://doc.iocoder.cn/video/
封 IP 在哪一层做?三种方案决策矩阵
为什么选中间这套?因为只有它同时满足"动态"和"分布式"——多台 Nginx 共用一份 Redis 黑名单,封禁信息全集群一致;时间到了自动解封,不用手工清理。
基于 Spring Cloud Alibaba + Gateway + Nacos + RocketMQ + Vue & Element 实现的后台管理系统 + 用户小程序,支持 RBAC 动态权限、多租户、数据权限、工作流、三方登录、支付、短信、商城等功能
项目地址:https://github.com/YunaiV/yudao-cloud
视频教程:https://doc.iocoder.cn/video/
接入 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 -- ===== 工具函数 ===== localfunction close_redis(red) ifnot red thenreturnend local ok, err = red:set_keepalive(pool_max_idle_time, pool_size) ifnot ok then red:close() end end localfunction get_real_ip() if trusted_proxy_hops <= 0then return ngx.var.remote_addr end local xff = ngx.req.get_headers()["X-Forwarded-For"] ifnot xff thenreturn ngx.var.remote_addr end local ips = {} for ip instring.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) ifnot ok then -- Redis 不可用直接放行,不阻断业务 ngx.log(ngx.WARN, "redis connect failed, fail-open: ", err) return end if client:get_reused_times() == 0then local auth_ok, auth_err = client:auth(redis_auth) ifnot 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) iftonumber(is_block) == 1then 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) ifnot ip_count then ngx.log(ngx.ERR, "eval failed: ", eval_err) close_redis(client) return end iftonumber(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,对小团队是过度设计。
欢迎加入我的知识星球,全面提升技术能力。
👉 加入方式,“长按”或“扫描”下方二维码噢:
星球的内容包括:项目实战、面试招聘、源码解析、学习路线。
文章有帮助的话,在看,转发吧。 谢谢支持哟 (*^__^*)