被 web 爬虫打爆的从来不是带宽,而是 PHP-FPM 进程池
个人站长最容易误判的一次故障是「网站挂了」,第一反应是打开云厂商监控,发现带宽只跑了两三兆,CPU 也就 30%,于是得出结论:服务器资源还很富裕,肯定是程序有 bug。但真实情况往往相反——带宽和 CPU 都很闲,网站却对所有人返回 502,原因在于 php-fpm 的 worker 进程池被占满了,而占满它的通常不是人,是爬虫。
这篇文章讲的是我在一个日访问量不到八千的 Typecho 站上踩过的坑:一个不遵守 robots.txt 的采集器用 260 个并发连接把 max_children 只有 20 的进程池撑爆,导致真实用户全部拿到 502;而 Nginx 的 access_log 里看起来一切正常,因为请求都被正常接收了,只是 PHP 处理不过来。排查的关键不是看流量曲线,而是看进程池的排队队列。
第一步:确认是进程池饱和,而不是别的
最直接的证据在 PHP-FPM 的状态页。开启方法是在 pool 配置里加上 pm.status_path,然后在 Nginx 里只允许内网或本机访问:
; /etc/php/8.2/fpm/pool.d/www.conf pm.status_path = /fpm-status ping.path = /fpm-ping
location = /fpm-status {
allow 127.0.0.1;
deny all;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}然后本机取一次快照:
curl -s http://127.0.0.1/fpm-status?full | head -30
要看的字段只有四个:
pool: www process manager: dynamic accepted conn: 418293 listen queue: 128 <-- 关键 max listen queue: 511 <-- 关键 listen queue len: 128 idle processes: 0 <-- 关键 active processes: 20 <-- 关键 total processes: 20 max active processes: 20 max children reached: 47 <-- 关键,非 0 就是出过饱和 slow requests: 3
listen queue 和 max listen queue 表示有多少请求在 socket 队列里排队等 worker。max children reached 非 0 说明进程池曾经达到上限,新请求只能排队或直接被拒。idle processes 长期为 0、active processes 顶在 max_children 上,基本可以确诊。
如果嫌状态页麻烦,用 ps 粗看也行:ps -eo state,cmd | grep 'php-fpm: pool' | sort | uniq -c,数一下 running 状态的 worker 数量是不是恒等于上限。
第二步:从 access_log 里把爬虫揪出来
确诊之后要回答的问题是「谁占着 worker 不放」。用一条 awk 就能按 UA 聚合请求量:
awk -F'"' '{print $6}' /var/log/nginx/access.log \
| sort | uniq -c | sort -rn | head -20更实用的是按「UA + 请求路径数量」聚合,能立刻看出某个 UA 是不是在全站扫页:
awk -F'"' '{ua=$6; n[ua]++} END {for (k in n) print n[k], k}' \
/var/log/nginx/access.log | sort -rn | head -20我那次的结果是这样的(隐去域名):
18734 "-" <-- 空 UA,可疑 8921 "Mozilla/5.0 (compatible; SomeBot/1.2; +http://example-bot.tld)" 4102 "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" 1203 "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ... Chrome/120"
空 UA 那一万八千条就是元凶。空 UA 通常意味着请求由脚本、采集器或某个「SEO 检测工具」发出,正常浏览器和正规搜索引擎都不会留空。把它的来源 IP 段、请求路径分布再看一眼:
grep '^- - ' /var/log/nginx/access.log \
| awk '{print $1}' | sort | uniq -c | sort -rn | head如果集中在少数几十个 IP 上,那就是有组织的采集,可以用 limit_req 按 IP 限速;如果散落在大量 IP 上,那是分布式抓取,限速效果有限,得从行为特征入手(例如按 UA 直接拒掉、按请求频率加验证)。
第三步:限速要限在正确的位置
很多人第一反应是给整个站点加 limit_req,结果把自己的用户体验也限死了。正确做法是分层:静态资源不限,动态 PHP 按来源分层限。
# http 段定义两个共享内存区,注意 key 的选择
limit_req_zone $binary_remote_addr zone=human:10m rate=5r/s;
limit_req_zone $http_user_agent zone=ua_ban:1m rate=1r/m;
server {
# 1) 空 UA 直接拒,Nginx 返回 403,根本不进 PHP
if ($http_user_agent = "") { return 403; }
# 2) 白名单:正规搜索引擎给足配额
map $http_user_agent $is_bot {
default 0;
"~*googlebot" 1;
"~*bingbot" 1;
"~*baiduspider" 1;
}
location / {
# 3) 人类请求按 IP 限速,排队但允许突发
limit_req zone=human burst=20 nodelay;
try_files $uri $uri/ /index.php?$args;
}
}关键点有三个。第一,if ($http_user_agent = "") 这类请求必须在进入 PHP 之前就拒掉,让 Nginx 用极低的成本返回 403——如果让它进 PHP,worker 被占用的时间是一样的,限速就失去意义了。第二,burst 必须给,否则正常用户点开一个有很多图片的页面会被自己限速误伤;nodelay 让突发请求立刻放行而不是排队,体验更顺。第三,正规搜索引擎要留够配额,否则收录会下降,这是典型的「为了防爬虫把 SEO 一起防死了」。
第四步:把 max_children 调对,而不是调大
「进程池满了就把 max_children 从 20 调到 100」是最常见的错误反应。php-fpm 是同步阻塞模型,一个 worker 在等待 MySQL 或外部 HTTP 接口时什么也做不了,把它复制 100 份只是把内存消耗扩大 5 倍,遇到慢查询仍会全部卡住——而且因为现在有一百个进程同时抢数据库连接,MySQL too many connections 会来得更快。
正确的估算方式是先量出单个 worker 的常驻内存,再按「可用内存的 60%~70% 分给 PHP」倒推:
ps -ylC php-fpm --sort:rss | awk '{sum+=$8; n++} END {print "avg KB:", sum/n}'假设单个 worker 常驻 45 MB,服务器 2 GB 内存,其中留给系统的加上 MySQL 要占掉约 900 MB,那么可用于 PHP 的约 800 MB,max_children 大约 17。这个数字看着很小,但只要没有慢请求阻塞,20 个 worker 撑住每秒上百个 Typecho 请求毫无问题——瓶颈从来不是进程数,而是请求被卡住的时间。
另外两个必须一起调的参数:request_terminate_timeout 给一个硬上限(例如 30s),防止某个卡死的外部请求永久占用 worker;pm.max_requests 设为 500~1000,让 worker 定期重启,规避内存泄漏和某些 PHP 扩展的碎片问题。这两个参数配合起来,进程池就具备了自愈能力。
第五步:把真正的原因消灭掉——页面缓存
限速和拒爬虫都只是止血。一个 Typecho 站的首页、分类页、文章页对所有人都是相同内容,每次请求都重新查数据库、重新渲染模板,本身就是巨大的浪费。给动态页加一层 Nginx 的 FastCGI 缓存,能让 90% 以上的请求根本不进入 PHP:
fastcgi_cache_path /var/cache/nginx/typecho levels=1:2
keys_zone=TYPECHO:100m inactive=7d max_size=2g;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
server {
set $skip_cache 0;
# 已登录用户、评论提交、带 cookie 的请求不缓存
if ($http_cookie ~* "typecho_") { set $skip_cache 1; }
if ($request_method = POST) { set $skip_cache 1; }
if ($query_string != "") { set $skip_cache 1; }
location ~ \.php$ {
fastcgi_cache TYPECHO;
fastcgi_cache_valid 200 301 302 10m;
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
fastcgi_cache_use_stale error timeout updating http_500 http_503;
fastcgi_cache_lock on;
fastcgi_cache_background_update on;
add_header X-Cache $upstream_cache_status;
# ... 其余 fastcgi_pass 配置
}
}这里有两个坑要注意。typecho_ 前缀的 Cookie 判断必须写对——Typecho 的登录 Cookie 名字里带这个前缀,如果漏了判断,管理员访问时会拿到缓存页面,登录状态看起来「掉了」,实际是被缓存糊弄了。另一个是 fastcgi_cache_lock on:它保证缓存未命中时只有一个请求回源,其余等待,避免缓存雪崩瞬间把进程池再打满一次——这个参数恰恰就是防爬虫场景下最有价值的那一个。上线后通过 add_header 暴露的 X-Cache 头可以看到 HIT 比例,正常稳定在 90% 以上,进程池的 active 数会长期保持在个位数。
日志里看不出问题,是因为你看错了日志
爬虫压垮进程池这类故障,有一个让很多人绕远的特征:错误日志几乎是干净的。PHP 的 error_log 里没有 fatal,MySQL 的慢日志里没有异常查询,Nginx 的 error.log 里只有零星几条 connect() to unix:/run/php/php8.2-fpm.sock failed (11: Resource temporarily unavailable)——如果你不去特意看这一行,很容易完全忽略它。
这行错误的含义正是「PHP-FPM 的监听队列满了,Nginx 连不上后端的 socket」。它是进程池饱和最直接的现场证据,比状态页更早出现。所以排查时应该养成一个习惯:先对整个 error.log 做一次按错误类型的聚合,而不是从头往下读。
awk '{for (i=1;i<=NF;i++) if ($i ~ /failed|error|timeout|refused/) print $i}' \
/var/log/nginx/error.log | sort | uniq -c | sort -rn | head同样的道理适用于 access_log:真正有价值的不是总请求数,而是状态码的分布随时间的变化。一个健康的站点 502 占比应该接近 0,当它突然从 0.1% 涨到 15% 时,你就知道那几分钟进程池被打爆了。用一条命令按分钟统计 502 数量,就能定位到故障的确切时间窗口,再拿这个时间窗口去 access_log 里筛出那几分钟的请求,肇事者的 UA 和 IP 就直接暴露出来了。
grep ' 502 ' /var/log/nginx/access.log \
| awk '{print substr($4,2,17)}' \
| sort | uniq -c | sort -rn | head -10这套「按时间窗口反查请求特征」的方法,比事后凭印象猜测靠谱得多。故障现场的证据只在日志轮转之前存在,所以我在排查结束后第一件事就是先把当天的日志打包另存一份,避免后续分析过程中日志被 rotate 掉——这个动作只需要几秒钟,但少了它,第二天再做复盘时你就只剩下回忆了。
一套可复用的防御顺序
复盘之后我固定下来的处置顺序是:先用 fpm-status 确认是不是进程池饱和,再用 access_log 按 UA 聚合找出肇事者,然后在 Nginx 层做「空 UA 拒绝 + 按 IP 限速 + 静态资源豁免」的三段式过滤,最后用 FastCGI 缓存把动态请求降到最低。这套顺序的价值在于每一步都有可以量化的验证指标:max children reached 是否停止增长、active processes 是否回落、X-Cache 里 HIT 的占比。
最后提醒一点:robots.txt 只是君子协定,对采集者没有任何强制力,把防盗链、限速、缓存当作「必须提前配好」的基础设施,而不是出事之后才想起来补的东西,个人站才能在一个不友好的网络环境里长期稳定地活下去。