事故:一个 IP 把连接数占满,全站开始 502
那天下午收到一条告警:站点返回 502 的比例突然升到 30%。登上去看,Nginx 的 error.log 里刷着同一句话:
2026/10/18 14:22:31 [error] 8123#0: *88211 connect() failed (111: Connection refused) while connecting to upstream, client: 127.0.0.1, server: zz1984.com, request: "GET /tag/Mysql/ HTTP/1.1", upstream: "fastcgi://unix:/run/php/php8.3-fpm.sock:"注意 client 是 127.0.0.1,说明请求来自 CDN 回源或本机代理,不是直接暴露的公网用户。再看 FPM 的状态页,listen queue 已经排到 128,max_children 全部占满。但 top 里 CPU 不高、内存也够、MySQL 的 Threads_connected 只有十几。也就是说:进程没死,是被请求数量堵死了。
翻 access.log 统计单 IP 请求数,一个 IP 在一分钟内请求了 4200 次,全部是同一个搜索页 URL 带不同的 ?s= 参数。这是一个典型的参数枚举型扫描器:它把搜索框当成了一个可以无限穷举的入口。
这类事故我之前处理过一次(写过用 limit_req 限速的方案),但这次情况不同:限速管的是「每秒多少个请求」,管不住「同时开着多少个连接」。扫描器很聪明,它把并发开到 300,每个请求都慢(搜索页要查数据库),于是 QPS 并不高、限速规则完全没触发,但 300 个并发连接把 FPM 进程池吃干,正常用户排队排到超时。
这就是 limit_conn 要解决的问题:按 IP 限制同时进行中的连接数,而不是按时间窗口限制请求速率。
limit_req 与 limit_conn:到底管的是两件什么事
很多人把这两个指令混着用,结果限速配了一堆、攻击照打。它们的语义完全不同:
| 指令 | 计量单位 | 算法 | 拦得住的场景 | 拦不住的场景 |
|---|---|---|---|---|
limit_req | 每秒请求数 | 漏桶,rate 是漏速,burst 是桶容量 | 高频轮询、秒杀式刷接口 | 低 QPS 高并发(每个请求很慢) |
limit_conn | 同时进行的连接数 | 计数器,连接建立 +1,关闭 -1 | 慢请求堆积、长轮询、下载拖连接 | 快速小请求的高频轰炸(连接瞬间就关) |
判断用哪个,看一个指标:攻击流量的瓶颈是「每单位时间来了多少」,还是「同时挂着多少」。搜索页、复杂查询页、生成报表的接口、大文件下载,都属于「每个请求都占着后端资源很久」,应该用 limit_conn。而首页这种 20ms 就返回的页面,用 limit_conn 根本没用——对方的连接还没数起来就已经关闭了。
更实用的结论是:两个都要配,但配在不同位置。全局层面用 limit_conn 防「并发堆积打挂进程池」,这是最后的容量保险;搜索页这类昂贵路径上再加 limit_req 防高频枚举。反过来(全局 limit_req + 搜索页 limit_conn)效果差得多。
几个必须先搞清楚的概念:连接不是请求
limit_conn 数的是连接不是请求。在 HTTP/1.1 带 keepalive 的情况下,一个 TCP 连接上可以串行发送几十个请求,Nginx 只算 1 个连接。这带来两个后果:
第一,限制值要比你想象的小。很多人写 limit_conn addr 50,心里想的是「一个 IP 最多 50 个请求」,实际上浏览器对一个域名只会开 6 个连接(HTTP/1.1)或 1 个连接(HTTP/2),所以 50 对一个真实用户来说几乎是无限。而对扫描器来说,50 个并发连接已经足以把 8 核机器上的 FPM 池打满。所以「50」这个数字对正经用户毫无影响,对攻击者却照样致命——这正是 limit_conn 好用之处:它天然区分「正常浏览」和「机器并发」。
第二,HTTP/2 会让限制值失效。一个浏览器用 HTTP/2 连过来只占用 1 个 TCP 连接,但在这个连接上可以并发上百个 stream。如果你的站点开了 HTTP/2,limit_conn 只能限制到「一个客户端最多几个 TCP 连接」,对单个连接内的 stream 并发无能为力——那需要 http2_max_concurrent_streams。好在扫描器大多不用 HTTP/2(用 curl、python requests 更省事),所以实际防护中 limit_conn 仍然有效。
第三,keepalive 的超时决定计数持续时间。请求结束后连接不会立刻关闭,要等 keepalive_timeout(默认 75s)空闲超时才回收。这意味着限制值要按「同时活跃的客户端数」来定,而不是按「同时活跃的请求数」。一个扫描器开 100 个连接打完之后,这 100 个连接的计数要 75 秒后才释放。如果你把 keepalive_timeout 调到 15s,同样的限制值能容纳的有效并发就高 5 倍。
落地配置:三个 zone、两处引用
limit_conn 需要先在 http 段声明共享内存 zone,再在 server 或 location 里引用。zone 的大小决定能记录多少个不同的 key,算错会导致计数溢出后静默失效。
# /etc/nginx/conf.d/connlimit.conf —— 必须放在 http 段
# 格式:limit_conn_zone $key zone=名字:大小;
# 每个 key 固定占用约 64 字节(32 位系统减半),1m ≈ 16000 个 key
# 按客户端 IP 限制总并发
limit_conn_zone $binary_remote_addr zone=perip:10m;
# 按「IP + 主机名」限制,多站点共用一台机器时防止一站被打连带
limit_conn_zone $binary_remote_addr$host zone=periphost:10m;
# 全局总连接上限:key 用固定字符串,整个 zone 只有 1 个计数器
limit_conn_zone $server_name zone=perhost:1m;这里有三处细节值得说明。
为什么用 $binary_remote_addr 而不是 $remote_addr:后者是字符串形式("203.0.113.45",最多 15 字节 + 结构开销),前者是打包后的二进制(IPv4 固定 4 字节)。zone 的容量按 key 的固定槽位算,用 binary 版本能省一半以上内存。10m 的 zone 配 binary 大约能记 16 万个不同 IP,对单个博客绰绰有余。
为什么另开一个 periphost:如果你在同一台机器上跑三个站,只按 IP 限制的话,一个扫描器打 A 站耗尽了配额,同时访问 B 站的正常用户也会被 503。加上 $host 之后,配额按「IP + 站点」分开计,互不干扰。不过要注意 $host 来自请求头,可以被伪造——伪造的结果只是「给自己换一个配额桶」,并不能突破全局限制,所以风险可接受。
perhost 这个全局计数器要慎用:$server_name 在单个虚拟主机里是常量,所以整个 server 只有一个计数槽,相当于「本站总并发上限」。这是防止「几千个不同 IP 的低慢攻击」(每个 IP 只开 1 个连接,perip 完全拦不住)的最后一道保险。但它的值必须大于正常峰值,否则自己把自己限死。个人博客设 3000~5000 是安全的,前提是前面还有 CDN 挡着。
在 server 与 location 里引用
server {
listen 443 ssl http2;
server_name zz1984.com www.zz1984.com;
# 全局:每 IP 最多 30 个并发连接;本站总并发 3000
limit_conn perip 30;
limit_conn perhost 3000;
# 超限后的状态码:429 比 503 语义更准,且不触发 CDN 的错误页缓存
limit_conn_status 429;
# 超限时记一条日志,并按状态码分级写日志(便于后续统计)
limit_conn_log_level warn;
# 搜索页是最贵的路径,单独收紧
location = /index.php {
if ($arg_s != "") {
limit_conn perip 4;
limit_conn_status 429;
}
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
# 文件下载与大附件:放宽到 8,避免用户多标签下载被误伤
location ~* \.(zip|tar\.gz|7z|pdf|mp4)$ {
limit_conn perip 8;
limit_conn_status 503; # 下载场景 503 更符合客户端重试预期
root /www/static;
}
}几个容易被忽略的点:
limit_conn 在 location 里是「继承 + 覆盖」语义。如果 server 段写了 limit_conn perip 30,然后某个 location 里写了 limit_conn perip 4,那么这个 location 里的实际生效值是 4(不是 4+30,也不是取小的两个 zone 之外的叠加)。但如果 location 里引用的是另一个 zone(比如 periphost),那么 server 段的 perip 限制依然生效——两个 zone 同时计数,都不超才放行。这一点常被误读成「location 会清空父级」,实际是「同一个 zone 覆盖,不同 zone 叠加」。
不要在 location 里只写 limit_conn 却忘了父级还有更松的限制。如果想在某个 location 里完全放开(比如 /health 探针),不能靠写一个大数字绕过,得用 limit_conn perip 0——但注意 0 的含义不是「不限」,而是「不允许任何连接」,会直接把该 location 打死。真正要放开只能换一个 zone 名,或者干脆不让它继承:把 limit_conn 从 server 段移到需要限制的 location 里,而不是反过来。
limit_conn_status 选 429 还是 503:429(Too Many Requests)是语义正确的答案,客户端和爬虫都能理解「等一等再来」。503 会被 CDN 当作回源故障,可能触发「源站不可用」的整个节点摘除,反而放大事故。日志统计里两者也分开得更清楚。唯一的例外是下载类路径,部分下载器对 429 不做退避,对 503 会重试。
真实 IP 是前提:CDN 后面直接配就是白配
这一点如果搞错,前面所有配置都无效——而且会变成反向事故。
站点套了 CDN 之后,$remote_addr 拿到的是 CDN 回源节点的 IP(比如上面日志里的 127.0.0.1,或者某个边缘节点的地址)。这时候按 IP 限制会变成对 CDN 节点限速:所有用户的流量都从少数几个节点回源,几十个正常用户就能把 perip 30 打满,全站开始 429。
正确做法是先用 real_ip 模块还原客户端真实地址,并且 set_real_ip_from 必须精确列到 CDN 官方给的回源段:
# 只信任 CDN 的回源 IP 段,绝不能写 0.0.0.0/0
set_real_ip_from 198.51.100.0/24;
set_real_ip_from 203.0.113.0/24;
set_real_ip_from 2001:db8:abcd::/48;
real_ip_header X-Forwarded-For;
real_ip_recursive on;set_real_ip_from 0.0.0.0/0 是一个极其危险的写法:它意味着「任何来源的 X-Forwarded-For 都被信任」。攻击者只要在自己请求里加一行 X-Forwarded-For: 1.2.3.4,每次换个假 IP,就能让 perip 完全失效——因为每次都是「新 IP」。这个坑我在限速那篇里提过一次,但在 limit_conn 场景下后果更严重:限速失效只是慢,连接数失效是直接被打挂。
验证 real_ip 是否生效,别靠猜。在站点根目录放一个探针页(返回 $remote_addr),用手机流量和本机分别访问,看返回的是不是自己的公网 IP:
# 临时加一个只对本机开放的调试 location
location = /_debug_ip {
allow 127.0.0.1;
deny all;
default_type text/plain;
return 200 "remote=$remote_addr xff=$http_x_forwarded_for realip=$realip_remote_addr";
}如果 $remote_addr 和 $realip_remote_addr 不一样,说明 real_ip 生效了;如果 $remote_addr 一直是 127.0.0.1 或某个固定的节点 IP,说明 CDN 没传 XFF 或者你的 real_ip_header 名字写错(有些 CDN 用 X-Real-IP,Cloudflare 用 CF-Connecting-IP)。
限谁:白名单、爬虫与「误伤自己」
limit_conn 是全局限的,它不认识「这是 Googlebot」「这是我的监控脚本」。如果不做例外处理,会出现三种误伤:
第一种,SEO 爬虫被限。Google 抓取一个站点时会开少量并发(通常 2~6),perip 30 不会挡住它。但如果你的站点是被同一个出口 IP 后面的大量抓取任务扫描(比如某个 SEO 工具聚合平台),就可能触发。真正的解法不是给爬虫放行,而是在 robots.txt 与 Nginx 层面区分已知好爬虫:验证 PTR、给已知爬虫单独一个更宽松的 zone。我的做法是给 Bingbot/Googlebot 的已验证段一个 perip 60 的宽松值,其余保持 30。注意这必须建立在 PTR 正反查验证之上,只看 UA 等于给伪装者开门。
第二种,自己的备份与监控脚本被限。我从本机 cron 每小时跑 curl 探针,如果探针并发 5 个(首页、搜索页、文章页、sitemap、feed),加上 Nginx 的 keepalive,很容易接近限值。做法是把 127.0.0.1 加入白名单:
geo $limit_key {
default $binary_remote_addr;
127.0.0.1 1; # 本机,key 固定为 1,几乎不会超
192.168.0.0/16 1; # 内网
}
limit_conn_zone $limit_key zone=perip:10m;
limit_conn perip 30;用 geo 把白名单 IP 映射到常量 key 1,它们的计数全挤在同一个槽里,只要不自己开 30 个并发就不会被限。这个写法比 allow/deny 更合适——allow 是访问控制,不是限速豁免。
第三种,分享型出口 IP 被连带。公司、学校、运营商 NAT 后面几百个用户共享一个公网 IP,perip 30 对他们是灾难。这类流量占博客读者比例不高,但一旦命中就是「整栋楼打不开」。缓解办法是把 perip 提到 30 以上(我最终用的是 40),并且只在搜索页、下载页做严格限制,首页与文章页保持宽松。
上线前必须算的三个数
限值不是拍脑袋定的。至少要有三个数字作为依据,否则不是「限太松没效果」就是「限太紧自己挂」。
数字一:后端真实能承受的并发。FPM 的 pm.max_children 是硬上限,超过就排队。我的 2 核 4G 机器设的是 24(每个 PHP 进程约 70MB,留出 MySQL 与系统余量)。所以「本站总并发」绝不能超过 24×(上游数),一旦超过就是必然的 502。而 perhost 3000 看起来远大于 24,是因为 Nginx 本身能持有成千上万个连接,只是它们大多在排队等 FPM——所以【连接数上限】和【后端并发能力】是两个层次的数,别混。真正要保的是:让超额流量在 Nginx 层被 429 掉,而不是全部堆进 FPM 队列。
数字二:正常峰值的并发。从 access.log 里算,别猜:
# 统计每秒的活跃请求数,取 95 分位
awk '{print $4}' /var/log/nginx/access.log \
| cut -d: -f2-3 | sort | uniq -c | sort -rn | head -5
# 更准的方式:统计同一秒内不同 IP 的数量
awk '{print $4 " " $1}' /var/log/nginx/access.log \
| sort -u | awk '{print $1}' | uniq -c | sort -rn | head -5第一条给出「最忙的那一秒有多少请求」,第二条给出「最忙的那一秒有多少个不同 IP」。后者才是 limit_conn 该参考的数字——如果峰值时只有 80 个不同 IP,那么 perip 30 显然够用;如果峰值有 1200 个 IP,说明流量很分散,perip 根本不是瓶颈,该防的是总量。
数字三:zone 的内存占用。前面说了 1m ≈ 16000 个 key。如果你的站点每天有 20 万个独立 IP 访问,10m 只够记 5 分钟内的活跃 IP,超出后 Nginx 会按 LRU 淘汰——被淘汰的 key 计数归零,等于限制失效。这种情况 zone 要开到 32m 以上。查当前用了多少:
# 需要先在编译时加 --with-http_stub_status_module
curl -s http://127.0.0.1/nginx_status
# 或者看共享内存实际占用
ls -l /dev/shm/ | grep limit_conn压测验证:用 ab 或 wrk 打自己,别等被打
配置写完必须验证它能触发、触发得对、恢复得也快。我用的最小验证是 ab 开 50 并发打同一个页面,看返回码分布:
# 50 并发、总共 500 个请求,全部打到搜索页
ab -c 50 -n 500 "https://www.zz1984.com/?s=test&page=1"
# 期望结果:Complete requests 500,Non-2xx responses 中出现大量 429
# 如果全部 200,说明 limit_conn 没生效(zone 没在 http 段、或 real_ip 把 IP 改了)
# 如果出现 502/504,说明限制值太高,流量已经打到 FPM 了注意 ab 会复用连接,而 limit_conn 数的是连接数,所以 -c 50 正好对应「50 个并发连接」,是理想的测试工具。wrk 默认也用少量连接打高 QPS,反而测不出 limit_conn 的效果——那种场景要用 wrk -c 100 才准。
验证完还要看恢复:压测结束后立刻正常访问,应该在 1 秒内恢复(因为连接关闭后计数立即递减)。如果压测停了仍然一直 429,说明计数泄漏了——最常见的原因是 upstream 侧 keepalive 与 keepalive_timeout 配置不当,连接没真正关闭。查 ss -tan state established | wc -l 是不是远大于正常值。
踩过的四个坑
坑一:把 limit_conn_zone 写在 server 段。Nginx 启动直接报 "limit_conn_zone" directive is not allowed here。这个指令只能在 http 段,这是新手最常见的失败,而且报错信息不明显(很多人以为是配置文件路径错)。放的位置建议单独一个 conf.d/connlimit.conf,靠文件名排序保证它在 site.conf 之前被 include。
坑二:改了配置忘了 nginx -t 直接 reload。reload 失败时旧配置继续生效,所以你以为改了、实际上没改,排查时会被自己骗。养成习惯:nginx -t && nginx -s reload,用 && 而不是 ;。
坑三:CDN 的缓存把 429 缓存住了。第一次触发限流后,CDN 把 429 页面缓存了 5 分钟,导致攻击结束后正常用户还在看 429。解决是在返回 429 的响应里加 Cache-Control: no-store,并在 CDN 后台把 429 加入「不缓存状态码」列表。Nginx 侧:
map $status $cache_control {
429 "no-store";
default "";
}
add_header Cache-Control $cache_control always;注意 map 里 default "" 会让 add_header 遇到空值时不发送该头,这是 Nginx 的既定行为,正好符合「只在 429 时加」的需求。但别忘了 add_header 的子块覆盖父块语义——如果你在其他 location 里已经有 add_header,这里会冲突,需要把安全头统一提到 server 段并加上 always。
坑四:以为限流生效了,其实是 FPM 先死。有一次我配了 perip 10,压测时看到大量 502 而不是 429,误以为限制没生效。实际原因是 pm.max_children 只有 24 而 perip 10 允许 10 个 IP 各开 10 个,总共 100 个并发全涌进 FPM,队列瞬间溢出。正确顺序是先保证 FPM 容量、再配连接限制,而且 perip × 未知的 IP 数不能超过 max_children 的合理倍数。这就是为什么我最终把 perip 调到 40 而不是 10——不是因为 10 太严,而是因为 10 太严但组合起来仍然会打穿后端;真正管总量的是 perhost 那个计数器。
长期观察:三个指标定周报
限流配好不是终点,得知道它有没有在正常工作、有没有误伤。我每周看三件事:
429 的比例与来源。如果 429 占全部请求不到 0.1%,说明限制太松(或者根本没触发过);如果超过 3%,说明要么有攻击、要么限制太紧误伤正常用户。按 limit_conn 的 zone 名分别统计(`limit_conn_log_level warn` + 日志格式里带 $limit_conn_status)就能定位是哪个 zone 在触发。
FPM 的 max listen queue。用 curl http://127.0.0.1/php-status 读 listen queue 与 max listen queue。如果 max queue 长期大于 0,说明限流还不够紧,或者 max_children 需要调整。
ESTABLISHED 连接的来源分布。ss -tan state established | awk '{print $5}' | cut -d: -f1 | sort | uniq -c | sort -rn | head,看是不是有单 IP 长期占据大量连接。这个数字配合 limit_conn 的限值,就是「限得准不准」的直接证据。
最后一句经验:限流的目标不是「挡住攻击」,而是「让攻击变得不经济」。一个开 300 并发的扫描器,被 429 掉 90% 后仍然会继续扫——但你的 FPM 不再被它吃掉,正常用户几乎无感。对个人站长来说,这个程度就够了,不必追求完美防御。真正需要长期对抗的流量,应该前移到 CDN 层解决。