先看一件事:日志里一半流量是假人
我给一个日 PV 两万左右的小站做配置梳理时,做的第一件事是把当天的 access.log 按 User-Agent 归并统计。结果是:标称 PV 两万里,真人访问不到八千。剩下的部分里,有三千多条来自各种"兼容性扫描""站点健康检查"的 UA,两千多条是空 UA 或只有一个连字符,还有一批把自己伪装成 Googlebot 的请求——它们的来源 IP 段根本不是谷歌的。
这个数字改变了我对小站"流量"的理解:在前台统计工具里,你看到的访问量已经被大量机器流量稀释了;而在服务器侧,这些机器流量实实在在占着 CPU、占着数据库连接、占着带宽。所以做爬虫日志分析不是做报表好看,而是为了回答三个具体的问题:谁在抓我、值不值得让它抓、怎么让不该抓的抓不到。
这篇文章按一条完整的分析链路走:先把日志切出来(不改线上配置)、再分清阵营(真搜索引擎、伪装者、扫描器、正常用户)、然后针对每一类出对策,最后讲怎么把结论固化成一个可以每天跑的小脚本。全部基于 Nginx 默认的 combined 格式,不依赖任何第三方服务。
第一步:先把日志切干净,且不要动线上配置
很多人一上来就想在 log_format 里加 $request_time、$upstream_response_time、$http_referer 的完整值,甚至加 $ssl_protocol。这没错,但有个前提:改 log_format 必须 reload Nginx,而 reload 会让新写入的行和旧行格式不一致,之后所有按列切分的命令都会错位。所以正确的顺序是先用手上已有的日志做分析,等确认了要长期跟踪哪些字段,再一次性改配置、并在切换点上做个时间标记。
默认的 combined 格式长这样:
$remote_addr - $remote_user [$time_local] "$request" $status $body_bytes_sent "$http_referer" "$http_user_agent"
要点是引号内的字段里可能有空格(UA 一定有),所以不能简单地用 awk '{print $12}' 取 UA。可靠的做法有两种:一是用 awk -F'"' 以双引号为分隔符,此时第 2 段是 request、第 4 段是 referer、第 6 段是 UA;二是用正则。前者更快,适合百万行级别的日志。
# 统计 UA 排行(第 6 段是 UA)
awk -F'"' '{print $6}' /var/log/nginx/access.log \
| sort | uniq -c | sort -rn | head -30
如果你每天有几十万行,上面的命令还能在几秒内跑完。到千万行级别时,sort | uniq -c 会因为排序而变慢,这时可以换成哈希统计或者直接用 GoAccess。GoAccess 的用法我在另一篇里写过,这里不重复,只提一句:它是"实时终端报表"的思路,适合看趋势;而 awk 管道适合回答"某个具体问题",两者不冲突。
第二步:分阵营,别用 UA 单一维度判断
爬虫识别的核心难点是:UA 可以随便写。所以只看 UA 一定会被骗。可靠的判断至少要看两个维度:UA 与来源 IP 是否匹配,以及行为是否像爬虫。
真搜索引擎:反查 PTR 与官方 IP 段
谷歌和必应都公开了自己的爬虫 IP 段,正确的做法是做反向 DNS 解析,再对结果做正向解析比对,确认"这个 IP 确实属于 googlebot.com"。这比看 UA 可靠得多,因为攻击者无法控制你对他 IP 的反向解析记录。
# 对单个 IP 做正反查
ip=66.249.66.1
host=$(dig +short -x $ip | sed 's/\.$//')
echo "PTR: $host"
dig +short "$host" | grep -q "^$ip$" && echo "正反一致,可信" || echo "不一致,疑似伪装"
这里有个容易踩的坑:dig -x 对某些 IP 返回空,并不是"这个 IP 不是爬虫",可能只是 DNS 反解超时或该段没配 PTR。所以反查要允许超时并分类处理,别把"查不到"直接当成"假货"而拉黑,否则容易误封运营商 NAT 后面的真实用户。
伪装者:UA 说是 Googlebot,IP 却不是
这是最常见的一类。它们的特征通常很明显:UA 字符串里带 "Googlebot" 但来源 IP 属于某云厂商的普通机房段;或者 UA 拼写有细微错误,比如 Googlebot/2.1 (+http://www.google.com/bot.html 少了右括号,或 compatible; Googlebot 前面少了空格。
# 找声称是 googlebot 的请求,按 IP 排序
awk -F'"' '$6 ~ /Googlebot/ {print $1}' access.log \
| sort | uniq -c | sort -rn | head -20
拿到这份 IP 列表之后,只要对排名前几的 IP 做一次正反查,就能确认哪些是假的。假的那部分,不用犹豫,直接限速或拒绝。
扫描器与采集器:看行为,不看身份
第三类攻击者根本不伪装,或者伪装得毫无章法。它们的特征是:请求路径高度集中(疯狂枚举 /wp-login.php、/.env、/admin/)、状态码集中在 404 和 403、单 IP 请求频率很高而页面停留为零、没有 referer。
# 404 最多的 IP
awk '$9 == 404 {print $1}' access.log | sort | uniq -c | sort -rn | head -20
# 访问最集中的路径(去掉查询串)
awk -F'"' '{split($2,a," "); print a[2]}' access.log \
| sed 's/?.*//' | sort | uniq -c | sort -rn | head -20
这两条命令基本能在半分钟内告诉你站点当前被谁盯着。我自己站上跑出来最集中的路径是 /wp-login.php,而我这个站根本不是 WordPress——这类请求占到 404 总量的很大一部分,纯粹是别人的扫描脚本在乱撞。
第三步:把资源花在值得的爬虫上,而不是一视同仁
分清阵营之后,对策才有意义。粗暴地"全站限速"会误伤谷歌和必应,直接影响收录;而"完全不限"则会让垃圾爬虫把你的数据库拖垮。个人站应该做的是分级。
真搜索引擎:限速但不误伤,且给足它需要的
谷歌的抓取是有预算的,站点响应慢会直接导致抓取频次下降。所以对真爬虫要做的恰恰相反:保证它们能快速拿到内容。具体做法是让动态页面在 Nginx 层就返回缓存结果,或者在检测到搜索引擎 UA 时跳过一些昂贵的统计逻辑。
限速层面给一个宽容的阈值即可,比如每秒 5 到 10 个请求,正常爬虫根本达不到这个速度。关键是不要把 429 返回给真爬虫,返回 429 会被理解为"站点过载",抓取频次会被降。真要拒绝,用 403 或直接 503 加 Retry-After 更合适。
伪装者与扫描器:限速 + 拦截,别用 444 骗自己
最常见的写法是用 if 判断 UA 然后返回 444(直接断连接)。它确实省资源,但有个副作用:日志里什么都不留,出问题时无法回溯。更严重的是,如果你的规则写错了,把真爬虫也 444 掉,日志里同样没有痕迹,你只会看到"收录掉了"却查不出原因。
我的做法是分两步:先用 map 把可疑 UA 映射成一个变量,在 limit_req 里用这个变量选择更严格的限速区,而不是直接拒绝。观察一周,确认无误伤,再考虑升级到 444。
map $http_user_agent $bot_limit_key {
default "";
~*"Googlebot|bingbot|Baiduspider" "";
~*"scan|spider|curl|python|java" $binary_remote_addr;
}
limit_req_zone $bot_limit_key zone=botlim:10m rate=1r/s;
server {
location / {
limit_req zone=botlim burst=5 nodelay;
limit_req_status 429;
# ...
}
}
这里的关键是 map 里对正常爬虫返回空字符串。空的 key 在 limit_req 里不计入限速——这是 Nginx 的一个有用特性,可以让你用同一个 zone 同时表达"这些人不限"和"这些人严限"。比写两个 zone 更省内存。
robots.txt 能做什么、不能做什么
robots.txt 只对守规矩的爬虫有效。真搜索引擎会遵守,垃圾采集器基本无视。所以它正确的用途是"告诉正规爬虫别抓低价值页面",比如搜索结果页、标签分页、带参数的排序链接。把这些低价值 URL 挡在抓取预算之外,对收录质量有帮助。但如果你的目的是省服务器资源,robots.txt 完全无用,必须靠限速。
另外有个细节:robots.txt 里的 Disallow 并不能阻止页面被索引。如果这个 URL 已经被别人链接过,搜索引擎仍可能把它列在结果里,只是不显示摘要。要真正阻止索引,用 noindex;两者别混用,混用会造成"被 disallow 了所以爬虫看不到 noindex"的死锁。
第四步:把分析结果变成一个每天跑的小脚本
手工跑命令只能解决一次性问题。真正有价值的是每天自动出一份"昨日爬虫简报",只回答四个数字:真爬虫请求数、伪装者请求数、404 总量、404 最集中的前五个路径。这四个数字有变化就说明有事发生,没变化就不用管。
#!/bin/bash
LOG=/var/log/nginx/access.log.1
[ -f "$LOG" ] || exit 0
total=$(wc -l < "$LOG")
g404=$(awk '$9 == 404' "$LOG" | wc -l)
gbot=$(awk -F'"' '$6 ~ /Googlebot/' "$LOG" | wc -l)
empty_ua=$(awk -F'"' '$6 == "-" || $6 == ""' "$LOG" | wc -l)
top404=$(awk '$9 == 404 {print $7}' "$LOG" \
| sed 's/?.*//' | sort | uniq -c | sort -rn | head -5)
echo "total=$total 404=$g404 googlebot=$gbot empty_ua=$empty_ua"
echo "$top404"
这个脚本刻意不用 set -e,因为 grep -c 在没有匹配时返回 1,会让整个脚本静默退出。另外 wc -l 在日志被 logrotate 切割的瞬间可能读到半个文件,所以脚本读的是 .1 这个已轮转完成的文件,而不是正在写的那个。
脚本的输出通过邮件或 webhook 每天推一次,只推这一条,别推整份报表。人的注意力是有限的,一条五行的简报会被每天看一眼,一份一百行的报表三天后就没人打开了。
两个常见误判,以及一个必须守住的边界
误判一:把 CDN 节点当成攻击者。如果站点前面挂了 CDN,日志里记的 IP 全是 CDN 节点的。这时候不管你怎么统计,看到的都是"几个 IP 疯狂请求",然后你会去封 CDN 的 IP,结果是整站不可达。必须先确认 real_ip 是否正确还原(set_real_ip_from 只填 CDN 官方段,配合 real_ip_header X-Forwarded-For),再看日志里的 $remote_addr 是不是真实用户 IP。这个顺序错了,后面所有分析都是废的。
误判二:把正常用户当成爬虫。有时候一个用户开了几十个标签页,或者用了预加载类浏览器扩展,行为看起来很像爬虫。判断依据永远不要是"请求多",而是"UA 与 IP 是否自洽"和"路径是否集中在不该集中的地方"。宁可放过,也不要错封一个真实读者。
必须守住的边界:任何拦截规则上线之前,先在 log only 模式下跑一周。Nginx 里可以用 map 生成一个变量并在日志里记下"本来会被拦"的请求,看清楚名单再开关。我见过最糟的事故是:一条 UA 黑名单正则写得太宽,把某个真实用户的浏览器 UA 也匹配进去了,那位用户连续三天打不开站点,而我完全不知道。
爬虫日志分析的价值不在于"抓出多少坏人",而在于让资源配置和内容策略有依据。真爬虫多,说明你的内容被抓取频繁,要保证服务能力;伪装者和扫描器多,说明暴露面需要收口。两个方向的结论都会影响你下一步改什么,这才是一份日志分析该交付的东西。