一、先说结论:恶意爬虫和正常爬虫在日志里长得一样
做站三年以上的人都会遇到这个场景:某天凌晨开始,服务器负载从 0.3 涨到 8,Nginx 的连接数从 200 飙到 3000,但带宽没涨多少,数据库也没慢查询。翻访问日志才发现,某个 UA 在十分钟内请求了 4 万次,抓的全是同一个列表页的翻页参数。它不是搜索引擎,也不遵守 robots.txt,伪装成一个看起来很像 Chrome 的 UA,每个请求都换 IP。
这篇文章不讲"装个 fail2ban 就完了",因为 fail2ban 是按单 IP 计数封禁的,而现代恶意爬虫基本都是住宅代理池,单个 IP 只请求三五十次就换下一个。真正的解法是分层:先在一层做低成本的识别,再用 Nginx 原生限速压住,最后才是封禁。顺序反了的话,你会把大量 CPU 花在写 iptables 规则上,而流量根本没降下来。
站点已有文章讲过"恶意爬虫把搜索页抓爆"的限速思路,本文换一个角度:怎么把爬虫和其他流量区分开,以及在没有面板、没有付费 WAF 的前提下,用 Nginx + 一点点 Lua 或 map 做到全站生效。
二、第一步不是封,是分类:把流量分成五类
在动手之前先把流量分类,这一步决定了后面每条规则的写法。我用的是五类划分法,每一类的处置策略完全不同:
- 真搜索引擎:Googlebot、Bingbot、Baiduspider。要放行,但需要验证身份,因为任何人都会伪造 UA。
- 善意爬虫:RSS 阅读器、监控探针(UptimeRobot)、社交平台预览抓取。低频,放行即可,但要有上限。
- 灰色爬虫:SEO 工具、竞品分析、AI 训练爬虫(GPTBot、CCBot)。它们不攻击你,但消耗带宽和数据库。可以限速,或者返回精简版页面。
- 恶意爬虫:高频翻页、撞库、扫目录、抓全量内容。必须限速 + 累计降级。
- 攻击流量:漏洞扫描、SQL 注入尝试、CC 攻击。直接拒绝。
分类的落地方式是在 Nginx 里用 map 把 UA 和请求特征映射成一个变量 $client_class,后续所有 limit、日志、回源都读这个变量。不要在每个 location 里重复写 if,map 是唯一不会踩 if 嵌套陷阱的写法。
三、用 map 做一层零成本的流量分类
下面这段放在 http 块里。关键在于 UA 匹配要"取前缀"而不是全等,因为真实 UA 后面还跟着一长串版本号和渲染引擎信息。
map $http_user_agent $client_class {
default "other";
# 真搜索引擎:后面还要做正向 DNS 验证
~*(Googlebot|Google-InspectionTool) "search_google";
~*(bingbot|BingPreview) "search_bing";
~*(Baiduspider|YisouSpider) "search_baidu";
~*(Sogou|360Spider|Bytespider) "search_cn";
# AI 训练爬虫:单独归类,便于统计和限额
~*(GPTBot|CCBot|ClaudeBot|anthropic) "ai_crawler";
# 善意但需要上限的
~*(UptimeRobot|Pingdom|StatusCake) "monitor";
~*(facebookexternalhit|Twitterbot) "social_preview";
# 已知的高频采集工具
~*(python-requests|Scrapy|Go-http-client|curl|wget|libwww-perl|Java/1) "script";
"" "empty_ua";
}
# 空 UA 单独标记:正常浏览器绝不会有空 UA
map $client_class $is_suspect {
"script" 1;
"empty_ua" 1;
default 0;
}几个实战要点:
- curl 和 wget 也归到 script。你自己用 curl 测站会被限速,这是好事,说明规则生效了。测试时在 UA 里加个自己的标记,单独 map 成
self_test并跳过限制。 - 不要把空 UA 直接 403。有些老旧设备、企业代理、健康检查会发空 UA。给它限速比直接拒绝安全,否则可能把监控打挂。
- Bytespider(字节跳动)放在 search_cn 里但要单独统计,它的抓取频率通常远高于百度,是中文站带宽的主要消耗者之一。
分类完成后,先不要加任何限制,只把 $client_class 写进日志,跑一天看分布。这一步很多人跳过,结果规则上线后把正常流量也打限速了,还找不到原因。
log_format main_class '$remote_addr - [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" class=$client_class rt=$request_time';
access_log /var/log/nginx/access.log main_class;一天之后用 awk 统计:
awk '{for(i=1;i<=NF;i++) if($i ~ /^class=/) print $i}' /var/log/nginx/access.log \
| sort | uniq -c | sort -rn四、验证搜索引擎身份:UA 可以伪造,反向 DNS 不能
最容易被忽略的一点:伪造 Googlebot 是攻击者最常用的低成本手段,因为站长一般看到 Googlebot 就放行。验证方法是正向-反向 DNS 双查:先对来源 IP 做 PTR 查询拿到主机名,再对该主机名做 A 查询,看解析结果是否包含原 IP。
# 手动验证一个 IP
IP=66.249.66.1
HOST=$(dig +short -x $IP | head -1)
echo "PTR: $HOST"
dig +short $HOST | grep -qx "$IP" && echo "VERIFIED" || echo "FORGED"把这个逻辑做成 cron 脚本,每天把"声称是 Googlebot 但验证失败"的 IP 落盘,第二天早上你就能看到一批住宅代理 IP——这些就是伪装爬虫的真实来源。验证结果是动态的,所以不要用它做实时拦截(DNS 查询在请求路径上会增加延迟,而且递归服务器抖动时会误杀),而是用它生成一份"确认恶意"的 IP 名单,交给 ipset 做黑名单。
# 生成 ipset 名单(以 Googlebot 伪造为例)
ipset create forged_crawler hash:ip timeout 86400 -exist
while read ip; do
HOST=$(dig +short -x "$ip" @8.8.8.8 | head -1)
if [ -n "$HOST" ] && ! dig +short "$HOST" @8.8.8.8 | grep -qx "$ip"; then
ipset add forged_crawler "$ip" -exist
fi
done < /var/log/nginx/forged_candidates.txt然后在 Nginx 里用 geo 读 ipset 是不支持的,标准做法是在 iptables 的 INPUT 链里对 ipset 做 DROP。但要注意:如果你前面有 CDN,拿到的 $remote_addr 是 CDN 节点 IP,这套验证会失效。此时必须先正确配置 real_ip,并且只在 CDN 提供的"真实客户端 IP 头"上做判断。
五、Nginx 原生限速:limit_req 的四个参数怎么定
限速是压住爬虫最有效的一层,因为它在 Nginx 的 epoll 层就完成了,不消耗 PHP 进程。核心是 limit_req_zone 和 limit_req,四个参数都有实际含义:
# 按 IP 限速:每秒 5 个请求,允许突发 10 个
limit_req_zone $binary_remote_addr zone=perip:10m rate=5r/s;
# 按 UA 分类限速:脚本类单独一个桶
limit_req_zone "$client_class:$binary_remote_addr" zone=byclass:10m rate=2r/s;
server {
location / {
limit_req zone=perip burst=10 nodelay;
limit_req_status 429;
...
}
}- zone 大小:10m 大约能存 16 万个 IP 的计数状态。1m 约 1.6 万。别写 1m 然后奇怪为什么限速不生效——状态表满了之后 Nginx 会开始淘汰,限制就漏了。
- rate:
5r/s对普通浏览足够(人不会一秒点 5 个页面),对爬虫则很痛。如果你想按分钟算,可以写30r/m,但低于 1r/s 的表达要注意精度。 - burst:允许的突发数量。浏览器打开一个页面会并发请求 CSS/JS/图片,所以首页的 burst 要放宽到 30~50,否则正常用户会看到图片加载失败。
- nodelay:不加的话,超过 rate 的请求会被排队(每个延迟 200ms),而不是直接拒绝。对爬虫应该加 nodelay,让它立刻收到 429;对普通页面可以不加,用排队保护后端。
一个真实坑:$binary_remote_addr 在启用 real_ip 后是 IPv4 的紧凑二进制,省内存;但如果你的站有 IPv6 访客,同一个用户可能用 v6 地址绕过 v4 的计数。IPv6 地址空间巨大,按单个地址限速几乎等于不限。对策是对 v6 地址按 /64 前缀聚合:
# IPv6 按 /64 归并计数(需要 Nginx 1.11+ 的变量拼接)
map $remote_addr $limit_key_ipv6 {
"~^(?<prefix>[0-9a-f:]{1,4}:[0-9a-f:]*):[0-9a-f:]+$" "$prefix::/64";
default $binary_remote_addr;
}这条正则并不完美,实际更稳的做法是用 geo 模块配合 ipv6= 参数,或者在 Lua 里对 v6 地址做位运算取前 64 位。如果你不想折腾,最低限度也要启用 v6 的限速桶,别让 v6 成为免费通道。
六、累计阈值:为什么限速之后还是要做降级
限速只能压住瞬时速率,压不住总量。一个爬虫用 50 个 IP、每个 IP 每秒 4 个请求,仍然能打出 200 QPS,全部在你设定的 rate 之下。这时需要的是累计阈值 + 降级响应,也就是给高频访问者返回更少的内容,而不是拒绝它。
降级比封禁更聪明的理由:封禁会激发对方换 IP 重试,而降级让它在无感知的情况下只拿到打折的页面,攻击收益下降,同时你的日志里留下完整证据。
实现方式有两种。轻量版用 Nginx 的 limit_req 配合 error_page 429 @fallback,把超限请求转到静态快照或精简页:
location @fallback {
# 返回一个极轻量的提示页,不查库不渲染模板
add_header Retry-After 60;
add_header Cache-Control "no-store";
return 429;
}进阶版用 OpenResty 的 Lua,把每个 IP 的计数写进共享字典,按时间窗分档:正常 → 限速 → 只返回摘要 → 返回空列表。
lua_shared_dict crawler_cnt 20m;
location /api/list {
access_by_lua_block {
local dict = ngx.shared.crawler_cnt
local key = ngx.var.binary_remote_addr
local n, err = dict:incr(key, 1, 0, 300) -- 300 秒窗口
if n and n > 300 then
ngx.header["X-Degraded"] = "1"
ngx.exit(429)
elseif n and n > 120 then
ngx.var.args = "--summary=1" -- 后续走精简接口
end
}
...
}注意 incr 的第四个参数是初始值,第三个是过期时间,写反了会导致计数永不过期、字典很快被写满然后全部失效。这是 Lua 限速最常见的错误,部署后一定要用 lua_shared_dict 的统计接口看 free_page_size 是否持续下降。
七、日志留证:为什么这一步不能省
爬虫治理最难的不是拦截,是"证明它存在"。当你需要向 CDN 服务商投诉、需要给老板解释带宽为什么涨、需要复盘某次故障时,唯一能拿出来的就是日志。所以从第一天起就要做到三件事:
- 开启独立日志:把被限速的请求单独写一个文件,不要混在主日志里,否则一天几 GB 翻不动。用
access_log /var/log/nginx/limited.log main_class;挂在if或map分支上。 - 保留 UA 完整原文:不要截断 UA。很多分析脚本靠 UA 里的版本号段位识别采集工具,截断后信息就丢了。
- 定期归档而不是直接删:logrotate 配置按天切分、保留 14 天、压缩存储。一天几十万行文本压缩后只有几十 MB。
/var/log/nginx/limited.log {
daily
rotate 14
compress
delaycompress
missingok
notifempty
sharedscripts
postrotate
[ -f /run/nginx.pid ] && kill -USR1 $(cat /run/nginx.pid)
endscript
}kill -USR1 是 Nginx 重新打开日志文件的标准信号,用 reload 会重建 worker,连接的请求会被中断。这个细节很多教程写错,导致每天凌晨有一秒钟的请求失败。
八、上线顺序与回滚
这套东西牵一发动全身,直接全站上线风险很高。我的建议是四步走,每一步都留出观察时间:
第一,只加分类不限制,跑 24 小时,用 awk 统计各分类占比。如果 script 类占比超过 30%,说明你的站点正在被大量采集,后面所有调优都有据可依。
第二,只对 script 和 empty_ua 限速,rate 设 2r/s、burst 5。观察 429 的数量和分布。如果 429 里出现了正常用户,说明 UA 分类有误,调整 map 而不是调整 rate。
第三,全局限速但只对动态路径。静态资源(~* \.(jpg|css|js|woff2)$)不加 limit_req,交给 CDN 和浏览器缓存。这一步能减少 80% 的误伤。
第四,加累计阈值和降级。此时你已经有一周的分类数据,阈值有依据了,不再是拍脑袋写 100。
回滚要提前准备好:所有 Nginx 改动都写进独立文件 conf.d/crawler_guard.conf,回滚就是 mv 掉这个文件再 reload。不要在 nginx.conf 里东改一行西改一行——出了问题时你没有 30 秒恢复的能力。
nginx -t && nginx -s reload
# 回滚
mv /etc/nginx/conf.d/crawler_guard.conf /etc/nginx/crawler_guard.conf.bak_$(date +%F)
nginx -t && nginx -s reloadnginx -t 必须写在 reload 前面并且用 && 串联,这是保命的一条。配置语法错误时 reload 不会生效,但如果你用 ; 分隔,后面的 reload 仍会执行并报错,日志里一片混乱,排查时间翻倍。
九、小结
恶意爬虫治理的本质是"先分类、再限制、后封禁",顺序不能反。用 map 做零成本的分类,用 limit_req 在 Nginx 层压住瞬时速率,用累计阈值做降级而不是封禁,用日志留证支撑后续决策,最后用四步上线法把风险分摊到两周里。这套方案不依赖任何付费服务,一台 2 核机器就能扛住每天千万级的请求,唯一需要你投入的是对日志的持续观察——而这件事本来就该是站长日常工作的一部分。