一篇来自真实事故的复盘。不是理论,是踩完坑之后能直接抄的配置。
一、事故经过
某天早上 8 点,网站日志里出现了一条异常曲线:
| 指标 | 数据 |
|---|---|
| 攻击 IP | 单个 IP(114.66.x.x) |
| 时间窗口 | 14 分 19 秒 |
| 请求总数 | 1042 次 |
| 抓取内容 | 1040 个不重复的详情页 URL(每个只抓一次,系统性遍历) |
| 请求频率 | 约 1.2 次/秒,匀速 |
| UA | 伪装成 Chrome/131(只有 2 次暴露了真实 UA:OneNavImport/1.0) |
| 静态资源请求 | 0 个(纯 HTML 抓取,确认是脚本不是浏览器) |
| 结果 | 1041 次 200、1 次 304,WAF 零拦截 |
换句话说:整站的导航数据(标题、描述、链接、分类)在 14 分钟内被完整搬走了。
如果你还没配置过基础的 Fail2ban 封禁,可以先看这篇:《1Panel 服务器用 fail2ban 自动封禁恶意扫描 IP 实战教程》。本篇聚焦它没覆盖的场景——匀速采集。
二、为什么现有防护全部失效
1. WAF 的 UA 规则被绕过
WAF 里配了「拦截 OneNavImport 等采集器 UA」的规则。上一次它来的时候,141 次请求里被拦了 112 次。
但这一次,它把 UA 换成了普通的 Chrome/131——UA 规则只认字符串,换一个就失效了。
2. 并发限制拦不住匀速抓取
服务器上已经配了:
limit_conn perip 25; # 单 IP 最多 25 个并发连接
看起来够用?不。采集器是一次只发一个请求,等回来再发下一个——并发数始终是 1,永远不会触发 25 的上限。
limit_conn管的是「同时有多少个连接」,不管「一分钟发了多少次」。匀速爬虫正是它的盲区。
3. 没有速率限制
全站配置里有 limit_conn,但没有 limit_req(请求速率限制)。这就是 1040 页被顺利抓完的根本原因。
三、方案一:Nginx limit_req 实时限速
第一步:在 http 块定义限速区和爬虫白名单
limit_req_zone 和 map 只能写在 http 上下文(写在 server 里会报语法错误),所以改的是全局 nginx.conf:
# 爬虫白名单:搜索引擎 UA 用空 key => 不限速
map $http_user_agent $sites_rate_key {
default $binary_remote_addr;
"~*bingbot" "";
"~*googlebot" "";
"~*baiduspider" "";
"~*yisouspider" "";
"~*bytespider" "";
"~*oai-searchbot" "";
"~*claudebot" "";
"~*applebot" "";
}
# 单 IP 30 次/分钟
limit_req_zone $sites_rate_key zone=sites_rate:10m rate=30r/m;
为什么用 map 做白名单:Nginx 的 limit_req 不支持条件开关,但当 key 是空字符串时会自动跳过限速。把搜索引擎 UA 映射成空 key,就实现了「爬虫不限速,普通访客限速」。
注意:这段只是”定义”,不会影响其他站点。只有引用了 sites_rate 的站点才会生效。
第二步:在站点配置的 server 块启用
# /sites/ 详情页限速:单 IP 30 次/分钟,突发 10
location ~ ^/sites/ {
limit_req zone=sites_rate burst=10 nodelay;
limit_req_status 429;
try_files $uri $uri/ /index.php?$args;
}
参数解释:
| 参数 | 含义 | 建议值 |
|---|---|---|
rate=30r/m |
每分钟 30 次(即每 2 秒 1 次) | 按站点真实访问量调 |
burst=10 |
允许瞬时突发 10 个请求 | 给真人快速翻页留余量 |
nodelay |
超出 burst 的请求立即返回 429,不排队 | 建议开启,避免拖慢响应 |
limit_req_status 429 |
被限时返回 429 Too Many Requests | 比 444 友好,搜索引擎能理解 |
burst 是给真人留的缓冲:正常用户点开一个详情页是 1 次 HTML 请求,快速连点也就 3-5 次;10 的突发足够。而采集器 1.2 次/秒连续 14 分钟,第 41 个请求之后就会被限。
第三步:验证
# 连续请求 45 次,观察状态码变化
for i in $(seq 1 45); do
curl -s -o /dev/null -w "%{http_code} "
-A "Mozilla/5.0 Chrome/120"
https://你的域名/sites/1.html
done
# 预期:前 30 个左右 200/304,之后出现 429
实测结果(45 次连续请求):
200 200 200 ... 200(共 29 次)→ 429 429 ...
第 30 个请求开始返回 429,和 rate=30r/m 完全吻合。
紧接着再测 10 次,只拿到 2×200 + 8×429——因为令牌桶按每分钟 30 个(即每 2 秒 1 个)匀速回填,不会瞬间恢复。
别忘了验证白名单(这是最容易翻车的地方):
# Bingbot UA 连发 20 次,应该全部 200
for i in $(seq 1 20); do
curl -s -o /dev/null -w "%{http_code} "
-A "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)"
https://你的域名/sites/1.html
done
实测:Bingbot 20 次全 200、Googlebot 10 次全 200,白名单生效。
四、方案二:Fail2ban 兜底封禁
limit_req 是「限速」,采集器还能慢慢磨;Fail2ban 是「直接封 IP」,更彻底。两者叠加,一个限、一个封。
1. 思路
监控站点 access.log,统计单个 IP 对 /sites/ 的成功请求:
- 60 秒内超过 30 次 → 封禁 1 小时
- 排除搜索引擎 UA(避免误伤)
2. jail 配置
在 /etc/fail2ban/jail.local 末尾追加:
[nginx-sites]
enabled = true
filter = nginx-limit-req
logpath = /path/to/your/site/log/access.log
port = http,https
maxretry = 30
findtime = 60
bantime = 3600
banaction = iptables-multiport
action = iptables-multiport[name=nginx-sites, port="http,https"]
failregex = ^<HOST> - - [[^]]+] "GET /sites/[^"]+" (200|206|304|429)
ignoreregex = (?i)(bingbot|googlebot|baiduspider|yisouspider|bytespider|oai-searchbot|claudebot|applebot|yandex|exabot)
同时在 [DEFAULT] 段确认自己的 IP 不会被误封:
ignoreip = 127.0.0.1/8 ::1 你的家用宽带网段/16 服务器公网IP
几个关键点:
failregex直接内联在 jail 段里:fail2ban 支持 jail 级覆盖 filter 的正则,这样不用额外创建 filter 文件(对面板类环境很友好)- 必须把
429也算进去:这是实测踩到的坑。Nginx 在第 30 个请求就开始返回 429,如果 fail2ban 只统计200/206/304,采集器永远拿不到第 30 个成功响应,maxretry=30永远触发不了——两个方案会互相抵消。加入429后,”被限速了还继续请求”这种更可疑的行为也会被计数 ignoreregex排除搜索引擎:不排除的话,Bingbot 一天抓 90 次也会被封action必须显式指定:很多系统的 DEFAULT action 里带sendmail,没装 sendmail 会导致 action 启动失败,封禁不生效
3. 验证正则是否有效
不装 fail2ban-regex 也能测——用 Python 跑一遍真实日志:
import re
log = open('access.log', errors='replace').read().splitlines()
host = r'(?P<host>d{1,3}(?:.d{1,3}){3})'
pat = re.compile(r'^' + host + r' - - [[^]]+] "GET /sites/[^"]+" (200|206|304) ')
ign = re.compile(r'(?i)(bingbot|googlebot|baiduspider|yisouspider|bytespider|oai-searchbot|claudebot|applebot)')
matched = [l for l in log if pat.match(l)]
human = [l for l in matched if not ign.search(l)]
print(f'匹配 {len(matched)} 行,排除搜索引擎后 {len(human)} 行')
我方实测结果:匹配 1079 行,其中 1040 行来自那个采集器——正则精准命中,没有漏。
4. 重载
fail2ban-client restart
fail2ban-client status nginx-sites
看到 Jail 'nginx-sites' started 即成功。
5. 验证封禁链路(别只看 jail 启动就以为完成了)
jail 启动 ≠ 封禁能用。用保留测试地址验证一次完整链路:
fail2ban-client set nginx-sites banip 192.0.2.1 # 192.0.2.1 是 TEST-NET 保留地址,不会影响任何人
fail2ban-client status nginx-sites # 确认出现在 Banned IP list
fail2ban-client set nginx-sites unbanip 192.0.2.1 # 立即解封
实测:封禁 → 列表出现 192.0.2.1 → 解封成功,链路正常。
这一步顺带能发现 action 配置错误——如果 banip 后列表里没有,或者 iptables 链没生成,说明 action 有问题(比如前面提到的 sendmail)。
6. 一个容易忽略的细节:日志路径
Nginx 配置里写的是容器内路径(如 /www/sites/xxx/log/access.log),而 fail2ban 运行在宿主机,logpath 要写宿主机上的真实路径(如 /opt/1panel/www/sites/xxx/log/access.log)。
验证方法:直接读那个文件,确认能看到最新日志:
tail -3 /opt/1panel/www/sites/xxx/log/access.log
如果路径写错,jail 状态正常但永远统计不到任何请求,属于静默失效。
五、踩过的坑(都是真实教训)
-
面板的网站配置 API 会重建配置
通过 1Panel 的websites/config/update接口写入自定义location,接口返回 200,但读回发现内容被模板重建,新加的 location 被丢弃。最后只能在面板「网站 → 配置文件」里手动粘贴。改之前一定要备份。 -
fail2ban的 DEFAULT action 带 sendmail
服务器没装 sendmail,日志里一直报ERROR Failed to start jail 'nginx-sites' action 'sendmail-whois-lines',而 action 启动失败可能导致封禁也不执行。解决办法:jail 里显式写action = iptables-multiport[...],去掉邮件通知。 -
重复的 jail 段会让 reload 失败
改配置时如果同一个[nginx-sites]出现两次,fail2ban 会报错。改完记得检查段名是否唯一。 -
CDN 环境下
set_real_ip_from 0.0.0.0/0有风险
为了拿到真实用户 IP,很多配置会写set_real_ip_from 0.0.0.0/0+real_ip_header X-Forwarded-For。这意味着任何直连源站的人都能伪造 XFF 来绕过按 IP 的限速。更安全的做法是把set_real_ip_from收窄为 CDN 官方回源网段。 -
limit_conn不等于limit_req
并发限制挡不住匀速抓取,这是本次事故最核心的认知。 -
两个方案会互相抵消
Nginx 在第 30 个请求就返回 429,而 Fail2ban 最初只统计成功响应(200/206/304)——采集器最多拿到 29 个 200,maxretry=30永远触发不了。必须把429也加入 failregex,让”被限速后仍继续请求”的行为被计数。 -
ignoreip漏了自家宽带,差点把自己封了
部署完做压力测试时发现:请求在日志里记录的来源是家用宽带 IP(不是服务器 IP)。如果没把这个网段加进ignoreip,一旦测试触发封禁,第一个上不了站的就是自己。上线前务必把「自家宽带网段 + 服务器公网 IP + 127.0.0.1」都写进ignoreip。
六、实测效果汇总
部署完成后逐项验证的结果:
| 验证项 | 实测结果 |
|---|---|
| 普通 UA 连续请求 45 次 | 前 29 次 200,第 30 次起返回 429 |
| 紧接着复测 10 次 | 2×200 + 8×429(令牌桶未回填,符合预期) |
| Bingbot UA 连发 20 次 | 全 200(白名单生效) |
| Googlebot UA 连发 10 次 | 全 200 |
| Fail2ban 封禁链路(ban/unban 192.0.2.1) | 封禁 → 列表出现 → 解封,通过 |
| Fail2ban 正则对历史日志的匹配 | 1079 行,其中 1040 行来自那个采集器(精准命中) |
采集器下次再来的完整链路:
- 第 30 个请求 → 收到 429
- 如果它继续请求 → 60 秒内累计 30 次(含 429)→ Fail2ban 封 IP 1 小时
- 换 IP 再来 → 同样处理
- 搜索引擎爬虫走白名单,全程不受影响
最后提醒一句:限速规则不是一劳永逸的。攻击者会换 UA、换 IP、降速。真正长期有效的,是「速率限制 + 行为封禁 + 日志监控」三层配合,以及定期回看日志里那些”看起来很正常的异常流量”。
附:本次事故的关键数字备忘
- 攻击窗口:14 分 19 秒
- 请求:1042 次(1040 个不重复详情页)
- 频率:约 1.2 次/秒(匀速)
- 原防护:
limit_conn perip 25+ WAF UA 规则 → 全部失效 - 最终方案:Nginx
limit_req30r/m + Fail2ban 30 次/60 秒封 1 小时

