一个真实场景:一天里同一个 IP 请求了两万次
上周日志巡检发现一个来自新加坡机房的 IP,单日请求 21437 次,全部是 GET /?s= 开头的搜索页,User-Agent 写着 Mozilla/5.0 (compatible; Bytespider; ...)。它不下载图片、不读 CSS,只反复抓搜索页,每次都带上百万级不同的关键词。这类请求对服务器来说是最贵的:每个搜索页都要真跑一次数据库 LIKE 查询,缓存命中率极低。
这篇文章不是讲「怎么封 IP」——封 IP 是最没技术含量的一步,而且很容易误伤正常爬虫和真实用户。我想讲的是:在你动手封之前,怎么用日志和状态码判断这到底是恶意爬虫、配错的爬虫、还是自己的问题;封的时候怎么封得不影响 SEO;封完之后怎么监控它有没有换个身份回来。
先分类:四种「不友好的流量」
把日志里让人头疼的流量分成四类,处理方式完全不同:
- 恶意采集 / 扫描:目标明确(搜索页、后台、登录接口),速率高,UA 常伪装。处理:限速 + 封禁 + WAF 规则。
- 「正规」爬虫失控:Googlebot、Bingbot、Bytespider、YisouSpider 这类有名字的爬虫,但抓取预算失控,把资源浪费在无价值的 URL 上。处理:robots.txt + 针对 UA 的限速,不要封。
- 自家程序的问题:定时任务每 5 分钟抓一次同一个页面、前端循环调接口、监控脚本 30 秒一次。这类最容易被误判成攻击,处理:改代码或加缓存,别封。
- 镜像站 / RSS 盗抓:把你的全文抓去自己站发。处理:限速 + 内容层面防范,封 IP 只是止痛。
判定的入口永远是访问日志。一条基础统计就能把画像拉出来:
LOG=/var/log/nginx/access.log
# 单 IP 请求数 TOP 20,附 UA 与命中状态码分布
awk '{print $1}' $LOG | sort | uniq -c | sort -rn | head -20
# 对可疑 IP 拉全画像
IP=203.0.113.45
grep "^$IP " $LOG | awk '{print $7}' | cut -d'?' -f1 | sort | uniq -c | sort -rn | head -15
grep "^$IP " $LOG | awk '{print $9}' | sort | uniq -c
grep "^$IP " $LOG | awk -F'"' '{print $6}' | sort | uniq -c | sort -rn | head -5注意上面的日志格式假设是默认的 combined:$1 是 IP,$7 是请求路径,$9 是状态码,$6(按双引号切分)是 UA。如果你改过 log_format 加了 request_time,字段号会变,先用 head -1 对着数一遍再写 awk。
判读的关键不是请求数多少,而是「请求的分布形状」:
- 2000 次请求集中在 3 个 URL → 大概率是自己的程序或监控在循环
- 20000 次请求分散在 20000 个不同 URL,且 404 比例高 → 扫描器或失控爬虫
- 请求全在搜索页 / 分页 / tag 页 → 针对性采集,或在探你的站内结构
- 只请求首页,间隔精确到秒 → 监控或探活,别封
第二步:确认它是不是「真」的知名爬虫
UA 是可以随便写的,所以看到 Googlebot 不能直接放行。Google 提供了两种验证方式,缺一不可:
# 1) 反向 DNS 必须属于 googlebot.com 或 google.com
dig +short -x 66.249.66.1
# 期望类似 crawl-66-249-66-1.googlebot.com.
# 2) 正查必须回到同一个 IP
dig +short crawl-66-249-66-1.googlebot.com
# 必须包含 66.249.66.1两步都过才是真 Googlebot。Bingbot 同理,反查域名应为 search.msn.com。字节的 Bytespider 验证起来麻烦一些,一般查 IP 归属(whois 或 ASN 查询)是不是字节的 ASN。
这里有个极易犯的错误:在 Nginx 里写 if ($http_user_agent ~* "bot") { deny all; }。这一条会把 Googlebot、Bingbot、所有正版 SEO 工具、以及你那台叫 uptime-robot 的监控全封了,几天后你会莫名其妙发现收录掉光。要封就封具体的、验证过是假的 UA,或者干脆不按 UA 封。
第三步:别急着封,先做「甜点限速」
对爬虫最有效的办法不是 403,而是让它慢下来、并且明确告诉它「你超了」。Nginx 有两层工具:连接级和请求级。
请求级限速:limit_req
# 定义在 http 段
limit_req_zone $binary_remote_addr zone=req_per_ip:10m rate=5r/s;
limit_req_zone $binary_remote_addr zone=search_per_ip:10m rate=1r/s;
limit_req_status 429;
# 在 server 段,对搜索页单独收紧
location = /index.php {
if ($arg_s != "") {
limit_req zone=search_per_ip burst=3 nodelay;
}
limit_req zone=req_per_ip burst=20 nodelay;
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
}几个必须理解的参数:
rate=5r/s是漏桶的填充速率,Nginx 内部按毫秒粒度排队。burst=20是允许的突发队列长度。爬虫一次开 20 个并发不奇怪,所以 burst 不能太小,否则真人开 5 个标签页也会被拦。nodelay让 burst 范围内的请求立刻处理而不是排队延迟。不加 nodelay 时,超速率请求会被「延期」,导致响应变慢但状态码仍是 200,爬虫不会收敛。limit_req_status 429比默认的 503 更准确,而且不会让 CDN 或监控误判成服务挂了。
特地把 ?s= 的搜索页设成 1r/s,是因为它是全站最贵的 URL。这类页面本来也不该被大量抓取,慢下来对谁都有好处。
连接级限速:limit_conn
limit_conn_zone $binary_remote_addr zone=conn_per_ip:10m;
# server 段
limit_conn conn_per_ip 20;
limit_conn_status 429;limit_conn 限制的是「同时在处理的连接数」,对付那种一次性开 200 个并发长连接的采集器最有效。它和 limit_req 是互补的:前者管并发,后者管速率。
别忘了 robots.txt
对正规爬虫,robots.txt 里的 Crawl-delay 只有部分爬虫(Bing、Yandex)认,Google 完全不认。对 Google 只能用 Search Console 里的「抓取速度」设置(现在藏在「设置 → 抓取统计信息」附近,且这个功能对新版 GSC 已经弱化,实际效果有限)。真正有效的是让低价值 URL 不出现在 sitemap 里、并用 canonical 收口。
User-agent: Bytespider
Crawl-delay: 10
Disallow: /?s=
Disallow: /search/
User-agent: *
Disallow: /?s=
Disallow: /admin/
Disallow: /action/
Sitemap: https://www.example.com/sitemap.xml注意 Disallow: /?s= 这种带问号的写法,是所有主流爬虫都支持的,但如果你前面还有 CDN 把 ? 之后参数归一化,规则可能匹配不上,写完后用 site:你的域名 inurl:s= 或者直接看爬虫日志确认它有没有老老实实停手。
第四步:真的需要封的时候,用 fail2ban 而不是手写 deny
手动往 Nginx 里加 deny 的问题在于:要 reload、要写文件、IP 一多配置膨胀、而且没有自动解封。fail2ban 的优势是「读日志 → 触发动作 → 自动解封」,逻辑闭环。
针对 Nginx 429/444 的 jail,比针对 404 更准:
# /etc/fail2ban/filter.d/nginx-reqlimit.conf
[Definition]
failregex = ^<HOST> - .* "(GET|POST|HEAD) [^"]*" 429
ignoreregex =
# /etc/fail2ban/jail.d/nginx-reqlimit.local
[nginx-reqlimit]
enabled = true
port = http,https
filter = nginx-reqlimit
logpath = /var/log/nginx/access.log
maxretry = 60
findtime = 600
bantime = 3600
action = nftables-multiport[name=nginx-reqlimit, port="http,https"]这里 maxretry = 60 是关键。正常用户偶尔触发一次 429 不该被封,而恶意爬虫在 10 分钟内很容易打满 60 次 429。如果设成 5 次,你会封掉一批用手机弱网重试的真实读者。
白名单一定要先配,防止自锁:
[DEFAULT]
ignoreip = 127.0.0.1/8 ::1 198.51.100.7 # 你的办公 IP、监控探针、CDN 回源段如果你站前有 CDN,日志里的 $remote_addr 会是 CDN 节点 IP,此时 fail2ban 会把整个 CDN 封掉——这是最经典的「一封封全站」事故。两种处理:要么在 Nginx 用 real_ip 还原真实 IP 后再写日志,同时把 CDN 段加入 ignoreip;要么干脆把 fail2ban 的 action 换成在 Nginx 层 deny 还原后的 IP。千万别在没还原 real_ip 的机器上直接上 fail2ban。
第五步:给僵尸流量降级,而不是拒绝
有些流量你既不想封(怕误伤),又不想让它消耗 PHP 和数据库。这时候「降级响应」比 403 文明得多。
最直接的一招:把可疑 UA 的请求直接返回静态内容或 444。
map $http_user_agent $bad_bot {
default 0;
"~*(AhrefsBot|SemrushBot|MJ12bot|DotBot)" 1;
}
server {
if ($bad_bot) {
return 444; # 直接断连,不返回任何响应,比 403 更省资源
}
}444 是 Nginx 特有的「关闭连接、不返回响应」,收益是不产生响应体、不占带宽、日志里可以 grep 出来统计。但它对 SEO 不友好(如果误伤了真爬虫),所以只用于确认是纯采集工具的 UA。
更温和的做法是给它一个「便宜的页面」:
location / {
error_page 418 = @cheap;
if ($bad_bot) { return 418; }
try_files $uri $uri/ /index.php?$args;
}
location @cheap {
add_header X-Robots-Tag "noindex, nofollow" always;
return 200 "Too many requests from your network. Please retry later.";
}418 在这里只是当个内部跳转的开关用。返回纯文本比返回完整 HTML 省 90% 的带宽,而且对爬虫来说「拿到 200 但全是无效内容」的效果,往往比 403 更能降低它的抓取意愿(它不会因此报错重试)。
第六步:封完必须验证,而且要看「延迟指标」而不是请求数
验证分两层。第一层是规则本身生效:
# 从外部模拟,确认拿到 429 而不是 200
for i in $(seq 1 60); do
curl -s -o /dev/null -w '%{http_code} ' \
-A "Mozilla/5.0 (compatible; Bytespider)" \
"https://www.example.com/?s=test$i"
done; echo
# 期望输出:前面几个 200,之后一片 429第二层是真实指标。限速的目标不是「拦掉多少请求」,而是「服务器的 PHP 处理量降下来」。所以看的是:
# 每分钟 200 状态码数量(= 真正进了 PHP 的请求)
awk -v d="$(date +%d/%b/%Y)" '$4 ~ d {print $4, $9}' access.log \
| grep ' 200$' | awk '{print $2}' | sort | uniq -c
# PHP-FPM 的活跃进程数(限速生效后应该明显下降)
curl -s http://127.0.0.1/status?full | grep -c '^pid:'
# 平均 request_time(如果 log_format 里有 $request_time)
awk '{print $NF}' slow.log | sort -n | awk '{a[NR]=$1} END{print "p95", a[int(NR*0.95)]}'如果 429 数量上去了但 PHP 处理量没降,说明限速位置不对——请求仍然进了 PHP 才被拦。检查 limit_req 是不是写在了处理请求的那个 location 里,而不是写在 /。
误伤了怎么办:回滚与申诉
限速上线后必须盯三件事,至少盯 48 小时:
- GSC 的抓取统计:Googlebot 被 429 的比例如果超过 1%,说明限速太紧,要放宽 burst。
- 真实用户投诉:统一 429 页面上写清楚「请求过于频繁,请稍后再试」,不要给白屏。
- 订单/表单提交量:如果站内有表单,POST 请求要单独放行,别和 GET 共享同一个 zone(真人反复点提交很容易触发)。
回滚很简单:注释掉 limit_req 行后 nginx -s reload。如果你用的是 fail2ban,先 fail2ban-client unban --all 再停服务,避免留下一堆封禁规则。
一个更省力的方向:让贵页面变得便宜
限速是治标。搜索页之所以贵,是因为每次都要跑 LIKE '%关键词%'。改法有三层,从便宜到彻底:
- 加短缓存:Nginx
fastcgi_cache或者 Redis 缓存搜索结果的摘要,TTL 60 秒。爬虫每次关键词不同,缓存命中率低,但至少同词重复抓取时能救。 - 限制搜索入口:只允许按标签、分类浏览,把自由关键词搜索收进一个需要 JS 的接口,爬虫拿不到结果。
- 加 noindex 到搜索页:这是最该早做的事。搜索页本来就该
noindex, follow,否则既浪费抓取预算又可能产生大量低质页面。一条<meta name="robots" content="noindex, follow">,配合 robots.txt 的Disallow: /?s=,能挡掉绝大部分搜索页抓取。
最后一次复盘:这次到底损失了什么
那次两万次搜索页请求,最终的代价是当天 PHP-FPM 的 CPU 多跑了约 9,数据库慢查询日志里多了两百多条三秒以上的 LIKE 查询。加了 limit_req(搜索页 1r/s)+ 搜索页 noindex 之后,第二天该 IP 的请求量降到 300 次以内,一周后彻底消失。
更重要的收获是那条 search_per_ip zone 一直留着,后来每次遇到类似情况都自动生效。反爬这件事的最高境界不是建规则库,而是把「贵」这件事从根上解决掉——限速只是给失控的流量装了个水龙头,把搜索页做便宜才是把水泵换了。