Py学习  »  NGINX

Nginx 动态封 IP,60 行,给你一份生产级脚本(带避坑)

Java基基 • 2 月前 • 151 次点击  

👉 这是一个或许对你有用的社群

🐱 一对一交流/面试小册/简历优化/求职解惑,欢迎加入芋道快速开发平台知识星球。下面是星球提供的部分资料: 

👉这是一个或许对你有用的开源项目

国产Star破10w的开源项目,前端包括管理后台、微信小程序,后端支持单体、微服务架构

RBAC权限、数据权限、SaaS多租户、商城、支付、工作流、大屏报表、 ERPCRMAI大模型、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

凌晨三点,监控告警炸了: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_addrX-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 ,对小团队是过度设计。



欢迎加入我的知识星球,全面提升技术能力。

👉 加入方式,长按”或“扫描”下方二维码噢

星球的内容包括:项目实战、面试招聘、源码解析、学习路线。

文章有帮助的话,在看,转发吧。

谢谢支持哟 (*^__^*)

Python社区是高质量的Python/Django开发社区
本文地址:http://www.python88.com/topic/195724