去年夏天有一段时间,我的站每天下午三点左右准时变慢,不是挂掉,是「慢」:首页要等三到五秒,偶尔 504,但重启 Nginx 立刻恢复,过几个小时又来。查 CPU 是 15%,内存空着一半,MySQL 的慢日志干干净净,磁盘 IO 也不高。典型的「资源都不忙,服务就是接不上」——最后在 ss -ant state established 和 limit_conn 的拒绝计数里找到了答案:有几百个连接已经建立了,但一个请求都没发完,把 FPM 的进程池一个个占住,真正的用户排在后面进不来。
这种攻击/事故有个名字,叫 Slowloris 式的慢连接耗尽。它不需要带宽,不需要并发能力,一个便宜的 VPS 就能把一个单机小站拖到半死。这篇把它的原理、怎么在现场认出来、以及几层防护的取舍讲清楚。
一、慢连接为什么比高并发更致命
正常的流量模型是这样的:一个请求进来,占用一个 FPM 子进程或者一个 worker 线程,几十毫秒处理完,连接释放,下一个请求进来。所以「每秒 100 个请求」和「同时 100 个请求」是两回事,前者只要处理得快,并发度很低。
慢连接的思路完全反过来:我不发完整的请求,我只发一半。比如 HTTP 请求头规定以空行结束,那我就先发:
GET / HTTP/1.1
Host: www.example.com
X-Fill-1: aaaaaaaa...
然后停住,每 10 秒补一个字节的头部字段。服务端收到一个不完整的请求头,它不知道后面还有没有,只能等。Nginx 会给这个连接开一个读事件,等着剩余的数据;如果请求最终被转给 PHP-FPM,PHP 那边更惨——FPM 的 request_terminate_timeout 默认是 0(不限),或者被设成 30 秒、60 秒,这段时间里那个子进程就完全被一个假请求占着。
关键在于**成本极不对称**:攻击方一个 TCP 连接几乎不花资源,防御方一个连接要占一个进程或线程。如果你的 pm.max_children 是 20,攻击方开 20 个这样的连接,站点就彻底不可用了——而此时 top 里 CPU 是空的,内存是空的,只有 php-fpm 的进程数满了,nginx 的 error.log 里开始出现 connect() to unix:/run/php/php8.2-fpm.sock failed (11: Resource temporarily unavailable)。
更隐蔽的一种变体是「慢读」:攻击方正常发请求,但读响应的时候故意一个字节一个字节地读,让 Nginx 的 send_timeout 一直被拖住。这个对带宽型攻击没意义,但对单机站同样有效,因为 worker 连接被占着。
二、现场怎么认出来:四个数字
失败排查最怕的是瞎猜。慢连接耗尽有几个非常好认的特征,用几条命令就能确认。
第一个数:连接状态分布
ss -s
ss -ant | awk 'NR>1 {print $1}' | sort | uniq -c | sort -rn健康状态下,ESTAB 里有相当一部分是活跃的,TIME-WAIT 会多一些但属于正常。慢连接攻击的特征是 ESTAB 数量异常高,而且这些连接长时间不动。用 ss -anti 加上 -o 看计时器,如果大量连接停留在 timer:(persist,...) 或者长时间没有数据流动,就要警惕。
第二个数:连接建立时长与来源分布
ss -ant state established '( sport = :443 )' | head -30
ss -ant state established | awk 'NR>1 {print $5}' | cut -d: -f1 | sort | uniq -c | sort -rn | head -20正常用户的连接会很快消失。如果某一批 IP 段的连接数长期稳定在某个值(比如每个 IP 20 个、一共 15 个 IP),而且这些 IP 来自云厂商机房段,那基本可以定性了。注意这里说的「一批 IP」不一定很少,成熟的工具会用几十上百个 IP 分摊,单 IP 连接数可能只有 5 个,看起来很正常——这时要看总量和来源段的属性,不能只看单 IP。
第三个数:FPM 进程池状态
在 Nginx 里给 FPM 打开状态页,是我认为性价比最高的可观测性投入之一:
location = /fpm-status {
allow 127.0.0.1;
deny all;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
}curl -s http://127.0.0.1/fpm-status?full重点看三个数:active processes(正在干活的进程数)、max active processes(历史峰值)、listen queue。慢连接攻击的典型形态是 active processes 顶到 max_children,而 listen queue 持续不为 0。用 ?full 还能看到每个进程当前在处理哪个 URI——如果二十个进程全卡在同一个路径(比如搜索页或某个动态接口),那就不是攻击,是那个接口自己慢,方向完全不同。
第四个数:Nginx 的请求速率与连接数比值
awk '{print $1}' /var/log/nginx/access.log | tail -2000 | sort | uniq -c | tail -5如果连接数很高(比如 500 个 ESTAB)但 access.log 每秒只有三五行,说明绝大多数连接根本没形成完整请求——这就是慢连接的铁证。反之如果每秒几百行日志,那是正常的高并发,该扩容或者加缓存,不是防慢连接。
三、Nginx 层的四条防线
慢连接的第一道关卡应该在 Nginx 就拦掉,别让它进到 PHP。四条配置,按重要性排序。
1. 请求头与请求体的超时
http {
client_header_timeout 10s;
client_body_timeout 15s;
send_timeout 15s;
keepalive_timeout 30s;
client_header_buffer_size 4k;
large_client_header_buffers 4 8k;
client_max_body_size 8m;
client_body_buffer_size 128k;
}client_header_timeout 默认是 60 秒,这是慢连接能撑住的根本原因。改成 10 秒意味着「10 秒内请求头没发完,直接断开」。这个值不能太小,移动网络下信号差的用户首包可能确实要几秒,10 秒是个比较稳的折中。
client_body_timeout 管的是请求体两次读操作之间的间隔,不是总时长——它防的是「上传到一半停住」而不是「大文件上传慢」,所以设 15 秒不影响正常大文件上传。send_timeout 管的是响应写出,同理,是两次写之间的间隔。
large_client_header_buffers 不要设得过大,默认 4×8k 已经够用。有些人为了「修复 400 错误」把它调到 16×64k,结果是每个连接多占几百 KB 内存,攻击方开两千个连接就是几百兆——这条配置本身成了放大器。
2. 单 IP 并发连接数限制
http {
limit_conn_zone $binary_remote_addr zone=perip:10m;
limit_conn_status 429;
limit_conn_log_level warn;
server {
location / {
limit_conn perip 20;
limit_conn perip 10; # 错,同一 zone 在后面覆盖前面的
}
}
}注意最后那行注释——同一个 zone 在同一个 location 里写两次,后面的生效,这是很常见的笔误。另外 limit_conn 数的是连接不是请求,一条 keep-alive 连接上就算跑了一百个请求也只算 1。所以 20 这个数字对正常浏览器完全成立(一个浏览器对一个域最多开 6~8 条连接)。
但单 IP 限制对分布式慢连接没用。前面说过,攻击方用 100 个 IP 各开 5 条,limit_conn 20 一条都不触发。所以它只能算「降噪」,不能算「防御」。
3. 全局连接数上限做保险丝
http {
limit_conn_zone $server_name zone=total:10m;
server {
limit_conn total 300;
limit_conn_status 503;
}
}这一条的作用不是防攻击,而是保护 FPM 不被拖死。全站连接数超过 300 时直接 503,虽然体验差,但至少 Nginx 还活着、MySQL 还活着,攻击结束后能立刻恢复;如果没有这条,连接会一路堆到 FPM 池满、再到 somaxconn 的 accept 队列满,恢复要等所有超时走完。
4. 用 map 给可疑来源降级
如果已经确认了攻击来源段(云机房 IP,且 UA 为空或明显伪造),可以用 geo 或 map 把它们导向一个更短的超时或者直接返回状态码:
geo $slow_source {
default 0;
45.148.10.0/24 1;
185.220.101.0/24 1;
}
map $slow_source $timeout_hdr {
0 10s;
1 3s;
}
server {
client_header_timeout $timeout_hdr;
if ($slow_source) {
return 444;
}
}这里要提醒一句:if 在 server 级别配合 return 是官方文档里明确允许的安全用法,但别在同一段里塞 rewrite 或 proxy_pass,那才会踩坑。444 是 Nginx 特有的「不发响应直接关连接」,比返回 403 更省资源,也更不给出探测信息。
四、PHP-FPM 层的兜底
Nginx 拦不住的流量终究会进 FPM,所以 FPM 这一层的参数直接决定「被拖垮的速度」。
; /etc/php/8.2/fpm/pool.d/www.conf
pm = dynamic
pm.max_children = 20
pm.start_servers = 5
pm.min_spare_servers = 3
pm.max_spare_servers = 8
pm.max_requests = 500
request_terminate_timeout = 30s
request_slowlog_timeout = 2s
slowlog = /var/log/php-fpm/slow.log
几个要点:request_terminate_timeout 必须设,且必须小于 Nginx 的 fastcgi_read_timeout(默认 60 秒),否则 Nginx 先超时、FPM 还在傻等,进程白白占着。pm.max_requests = 500 让子进程处理五百个请求后自己退出,能回收慢连接留下的脏状态和内存碎片——这个参数我在好几篇里都提到过,是单机小站最省事的一道保险。
另外,pm.max_children 的算法不是拍脑袋:每个 PHP 进程常驻内存大概 40~80MB(取决于装了多少扩展),机器留给 PHP 的内存除以单进程内存就是上限。4G 的机器、留 2.5G 给 PHP、单进程 60MB,差不多就是 40 个;但如果没有慢连接防护,我宁可按 60% 算,留一点余量给尖峰。
还有一条容易被忽略的:FPM 的 listen.backlog。默认值在不同版本里是 511 或 -1(等于系统 somaxconn)。它决定了 accept 队列能排多少,排满了新连接直接被内核拒绝,用户看到的是连接被重置而不是 504。这个值和 net.core.somaxconn、Nginx 的 listen backlog 三者取小值生效。
五、CDN 和防火墙层:把战场前移
如果站点前面有 CDN,那么慢连接的绝大部分会被 CDN 边缘节点吸收——攻击方连的是 CDN 的 IP,不是你的源站。这时候要做两件事:
一是确认 CDN 的回源超时设置。很多 CDN 默认回源超时 10 秒或 30 秒,如果源站响应慢,CDN 会重试,重试会把请求量放大 2~3 倍,反而加剧源站压力。要在控制台把回源超时调到低于用户侧超时的值,并把重试次数降到 1。
二是确认源站只接受来自 CDN 的连接。如果源站 IP 可以被直接访问(很多人把源站 IP 泄露在 DNS 历史记录或者邮件头里),攻击方可以完全绕过 CDN 直接打源站,前面所有防护全部失效。做法是防火墙只放行 CDN 的回源 IP 段,或者用 CDN 提供的回源鉴权头。
如果没有 CDN,单机站的最后一道是 iptables/nftables 的 connlimit 或者 fail2ban。这里我的经验是:connlimit 的阈值不要设得太紧。一个 NAT 后面可能有几十个用户共用一个公网 IP,你设 20 会误伤整个公司或者学校。用 iptables 的话,配合 hashlimit 按「每秒新建连接数」限制比按总量限制更准:
iptables -A INPUT -p tcp --dport 443 -m conntrack --ctstate NEW \
-m hashlimit --hashlimit-name https_new --hashlimit-above 30/sec \
--hashlimit-burst 60 --hashlimit-mode srcip --hashlimit-htable-expire 60000 \
-j DROP六、事后复盘:不要只加参数
这类事故最容易犯的错是「把 client_header_timeout 从 60 改成 10,重启,收工」。参数改了确实能挡住这一次,但如果没搞清楚来源和动机,下次换个手法还会中。
我的复盘清单是四个问题:
第一,攻击是不是有明确的业务特征?比如只打搜索页、只打登录接口、只打某个带 SQL 的 URL。如果有,那说明是有针对性的探测或爬取,处理方式应该是接口层加缓存或降级,而不是全站加超时。
第二,来源 IP 是住宅宽带还是数据中心?数据中心段可以直接封,住宅段要小心误伤。
第三,这次是「打死」还是「拖慢」?打死说明容量真的不够或者有一个必死的单点(比如搜索页没有缓存),拖慢说明防护参数不合理。
第四,防护上线后用什么指标证明有效?我通常盯三个数:ESTAB 连接总数、FPM 的 active processes 峰值、以及 nginx 日志里 429/503 的比例。三个数都回到基线,才算处理完。
最后说一句反面经验:不要指望用一个 nginx 模块或者一个 WAF 规则解决所有慢连接。这类攻击的本质是「用极低的成本占用你有限的进程」,只要你的并发能力是有限的,就一定存在被耗尽的可能。真正有效的做法是分层的:超时让它不能无限等、limit_conn 让它不能无脑堆、全局上限保证核心不崩、CDN 把大部分消耗挡在外面。四层叠起来,成本才从「一个 VPS 就能打挂」变成「需要认真准备的一次攻击」。
对一个日 PV 几万的个人站来说,做到这一步已经足够了。剩下的精力应该放在「被打了能多快恢复」上——比如把 FPM 重启、Nginx reload、CDN 切维护页做成一条命令,这比多写十条防护规则更实用。