Nginx 重启后整站 502 但配置没错:PHP-FPM 上游连通性的四层排查实战

为什么你的 Typecho 站点一重启就 502,而 Nginx 配置看着完全没问题

个人站长最常见的午夜事故:服务器计划外重启,或者你手滑 systemctl restart php-fpm,然后整站 502,Nginx 错误日志里只有一行 connect() failed (111: Connection refused) while connecting to upstream。你去检查 nginx -t,通过;去看 Nginx 配置,fastcgi_pass 127.0.0.1:9000; 一个字没改。于是你陷入"配置明明没错,为什么连不上"的困惑。

这篇文章不讲泛泛的"检查 PHP-FPM 是否启动",而是聚焦一个被绝大多数教程忽略的关键字:上游连通性定义(upstream connectivity semantics)。我会用真实排障顺序,把这类 502 拆成四层来定位,并给出防止重启即挂的固化配置。

第一层:先确认 FPM 真的在听,而且听在你以为的地方

很多人用 ps aux | grep php-fpm 看到进程在,就断定"服务正常"。进程在,不代表它在监听。PHP-FPM 有两种监听方式,排障时的检查命令完全不同。

如果配置是 TCP 套接字(listen = 127.0.0.1:9000),检查监听端口:

ss -lntp | grep 9000

期望输出类似 LISTEN 0 511 127.0.0.1:9000 0.0.0.0:* users:(("php-fpm",pid=1234,fd=7))。注意这里有两件事要同时看:监听地址backlog(那条数字 511)。如果监听地址显示的是 ::1:9000(IPv6 的 localhost)而 Nginx 写的是 127.0.0.1:9000,或者反过来,那就是典型的"进程在跑但连不通"。

如果配置是 Unix 套接字(listen = /run/php/php7.4-fpm.sock),检查方式和坑完全不同:

ss -lx | grep php

Unix 套接字有两个高频陷阱。第一是路径漂移:Debian/Ubuntu 上不同 PHP 版本的 sock 名字不一样(php7.4-fpm.sockphp8.1-fpm.sock),你升级 PHP 后 Nginx 还指向旧路径。第二是权限:sock 文件的属主是 www-data:www-data,权限 0660,而 Nginx 的 worker 进程如果以 nginx 用户运行(某些从源码编译或第三方源安装的情况),就没有权限连上这个 sock,结果同样是 111 Connection refused —— 注意,权限不足在这个场景下报的错和端口没监听一模一样,不会告诉你"Permission denied"。

验证权限是否匹配,直接对 sock 做一次真实连接测试,比看配置快得多:

sudo -u nginx test -w /run/php/php7.4-fpm.sock && echo OK || echo NO-ACCESS

把上面的 nginx 换成你 Nginx worker 实际运行的用户(用 ps -o user= -C nginx | head -1 查看,注意 master 进程通常是 root,要看 worker)。这条命令返回 NO-ACCESS,就是你 502 的真凶,去改 listen.owner / listen.group / listen.mode 三个参数即可。

第二层:FPM 的 backlog 与 max_children 会在重启瞬间造成雪崩

假设监听正常,进程也在。但重启后整站 502,几分钟后自己好了 —— 这种情况八成不是配置错误,而是容量问题在重启时刻被放大

服务器重启时,所有请求同时涌入。PHP-FPM 的 pm.max_children 决定了同时能处理多少个请求,其余的全部进入 listen.backlog 队列等待。如果队列满了,内核直接拒绝新连接,Nginx 收到的就是 111 或 110(Connection timed out)。排障要看 FPM 自己的状态页,而不是猜。

打开 FPM 状态页(这是排查 PHP-FPM 最重要的手段,没有之一)。在 pool 配置里加:

[www]
pm.status_path = /fpm-status
ping.path = /fpm-ping

然后在 Nginx 里只允许本机访问这个路径:

location = /fpm-status {
    allow 127.0.0.1;
    deny all;
    fastcgi_pass 127.0.0.1:9000;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}

重启后用 curl "http://127.0.0.1/fpm-status?full" 拿到全量状态,重点看四个数字:active processesmax active processeslisten queuemax listen queue。如果 max listen queue 是个三位数甚至四位数,说明重启那一刻确实溢出过,你需要调大 pm.max_childrenlisten.backlog,而不是继续在配置语法里找问题。

这里有个反直觉的点:pm.max_children 不是越大越好。每个 PHP 进程要占用实际内存(用 ps -o rss= -C php-fpm 加总估算,Typecho 类站点单进程通常 30–60 MB)。如果你把 max_children 设成 100 而机器只有 2 GB 内存,重启后的请求洪峰会直接把内存吃满触发 OOM Killer 杀掉 FPM,表现成"重启后 502 一会儿,然后连进程都没了"。正确做法是反推:可用内存 ÷ 单进程实测内存 = 安全上限,再留 20% 余量给系统和其他服务。

第三层:重启顺序 —— 最容易被忽视的"时间窗口"故障

即使容量充足,仍然有一种 502 是纯粹的启动顺序造成的:Nginx 比 PHP-FPM 先起来。

看依赖声明。Debian 系上,如果 PHP-FPM 不是通过 systemd 管理(比如你用 /etc/init.d/php-fpm start 或者自定义脚本),Nginx 的 unit 文件里就不会有 After=php-fpm.service。服务器冷启动时两者并发拉起,Nginx 先就绪的那几秒,所有请求全部 502。你可能只在重启时看到几秒钟的 502,平时完全正常,所以很容易被忽略。

正确的 Nginx unit 依赖应该包含:

[Unit]
After=network.target php7.4-fpm.service
Wants=network.target
Requires=php7.4-fpm.service

Requires 表达强依赖,After 表达顺序。只写 After 不写 Requires,当 FPM 启动失败时 Nginx 照样会启动,然后整站 502 —— 这是很多人"我明明加了 After 还是挂"的原因。

改完用 systemd-analyze verify nginx.service 检查语法,并用 systemctl list-dependencies nginx.service 确认依赖树真的挂上了。

第四层:让 Nginx 主动等待,而不是立刻报错

最后一层是 upstream 参数。默认情况下 Nginx 连接上游失败会立刻返回 502,不给后端任何恢复时间。对于"重启后短暂不可用"的场景,可以用 proxy_next_upstream 的思路处理 FastCGI —— 但 FastCGI 对应的参数是 fastcgi_next_upstream,而它只在配置了多个 upstream server 时才真正起作用。单机单进程的典型做法是这样的:

upstream php_backend {
    server 127.0.0.1:9000 max_fails=3 fail_timeout=10s;
}

location ~ \.php$ {
    fastcgi_pass php_backend;
    fastcgi_connect_timeout 5s;
    fastcgi_read_timeout 60s;
    fastcgi_send_timeout 60s;
    include fastcgi_params;
}

fastcgi_connect_timeout 5s 的意义在于:如果上游只是启动慢(而不是没起来),5 秒的窗口让 Nginx 有机会等到 FPM 就绪,而不是毫秒级就报 502。但注意,connect_timeout 只对 TCP 有效,Unix 套接字下连接几乎瞬时完成或瞬时失败,调它没用 —— 这也是"有的人调了有用,有的人调了没用"的原因。

顺带说一个诊断技巧:如果你怀疑 502 是上游超时而非连接失败,看 Nginx 错误日志里括号里的数字。upstream timed out (110: Connection timed out) 是等太久,(111: Connection refused) 是压根连不上,(13: Permission denied) 才轮到权限问题。这三个数字分别对应完全不同的修复方向,不要混着改。

把四层检查固化成一个重启后自检脚本

与其每次出事再手动查四层,不如写一个脚本,在 systemctl restart nginx 之后自动跑一遍:

#!/bin/bash
# fpm-selftest.sh
echo "1) listening:"
ss -lntp | grep -E ':9000' || ss -lx | grep -E 'php.*sock'
echo "2) ping FPM:"
curl -s -o /dev/null -w '%{http_code}\n' "http://127.0.0.1/fpm-ping"
echo "3) status queue:"
curl -s "http://127.0.0.1/fpm-status" | grep -E 'listen queue|active processes'
echo "4) nginx upstream timeouts:"
tail -n 50 /var/log/nginx/error.log | grep -c 'Connection refused'

把它挂到 Nginx 的 ExecStartPost= 里,或者作为一个独立的 systemd timer 每分钟跑一次。当 Connection refused 计数非零时告警,你就能在用户投诉之前知道 FPM 有问题。

总结一下这四层的定位顺序:监听地址与套接字权限 → backlog 与 max_children 容量 → systemd 依赖顺序 → Nginx upstream 超时参数。绝大多数"配置没错但 502"都能在这四层里找到落点,而且每一层都有可执行、可验证的检查命令,不需要凭感觉猜。

附带问题:502 修好了,但页面变慢 —— fastcgi_buffers 的连带影响

排障时还有一个经常被顺手改坏的参数:fastcgi_buffersfastcgi_buffer_size。有人为了"压掉内存占用",把这两个值调得很小,比如 fastcgi_buffers 4 4k;。结果 502 是没了,但页面变得极慢,因为上游返回的响应体超过了缓冲区大小时,Nginx 会把多余部分写进磁盘临时文件(由 fastcgi_temp_path 指定),每一次请求都要写盘再读盘。

默认值通常是 fastcgi_buffers 8 4k;fastcgi_buffer_size 4k;,对普通 HTML 页面是够的。但如果你的页面响应体很大(比如渲染后的 HTML 有 200 KB 以上,或者上游直接吐 JSON),就应该调大而不是调小:

fastcgi_buffers 16 16k;
fastcgi_buffer_size 32k;
fastcgi_busy_buffers_size 32k;
fastcgi_temp_path /var/cache/nginx/fastcgi_temp 1 2;

验证是否踩了这个坑,最直接的办法是看 fastcgi_temp_path 目录里是不是有文件生成:

ls -la /var/cache/nginx/fastcgi_temp/ | head
find /var/cache/nginx/fastcgi_temp -type f | wc -l

如果这个目录持续有文件产生(哪怕很快被删除),说明响应体频繁溢出缓冲区,调大 buffers 能明显改善首字节之后的传输耗时。注意 fastcgi_temp_path 所在的磁盘分区也要有空间,否则溢出时写盘失败会衍生出 500 或者其他奇怪错误。

预防:把 FPM 的关键指标做成常态监控

排障的终局是"不再需要排障"。前面提到的状态页,可以配一个轻量脚本,按分钟采样,把指标落到文件里,出问题时你就有历史曲线可看,而不是只能看到当下的一个瞬时值:

#!/bin/bash
# fpm-metrics.sh —— 每分钟采样一次
OUT=/var/log/fpm-metrics.log
ST=$(curl -s "http://127.0.0.1/fpm-status")
ACTIVE=$(echo "$ST" | awk '/active processes/ {print $3}')
QUEUE=$(echo "$ST" | awk '/listen queue/ {print $3}')
MAXCHILD=$(echo "$ST" | awk '/max children reached/ {print $4}')
echo "$(date '+%F %T') active=$ACTIVE queue=$QUEUE maxreached=$MAXCHILD" >> "$OUT"

配到 crontab 里每分钟执行:* * * * * /usr/local/bin/fpm-metrics.sh。日志按天轮转即可,一周下来文件也就几百 KB。

三个指标的判读规则很清晰。active 长期贴着 pm.max_children 上限,说明进程池容量不足,需要扩容或优化慢脚本。queue 频繁出现非零值,说明请求已经排过队,用户体验已经开始受损,即便没有报错。maxreached 大于零,是明确的"进程池被撑爆过"的证据,必须处置。把这三条规则固化下来,任何一次"重启后 502"都能在它发生前被预警到。

还有一点容易被忽略:FPM 的 pm 模式选择会影响所有上述指标的表现。pm = static 预分配全部子进程,启动稍慢但响应稳定,适合内存充足、流量平稳的站点;pm = dynamic 按需伸缩,省内存但扩缩有延迟,重启后的洪峰更容易触发队列溢出;pm = ondemand 最省内存,但每个新请求都要 fork,高并发下开销显著。个人站点的常见最优解是 dynamic 配合合理的 start_servers / min_spare_servers / max_spare_servers 三段配置,让常驻进程覆盖基线流量,突增时才扩容。

一个真实的排查时间线(供对照)

最后给一个我实际处理过的案例顺序,你在遇到同类问题时可以照着走。某 Typecho 站每天凌晨备份后执行一次 FPM reload,reload 后 10–30 秒内必出 502,之后自愈。第一步我用 ss -lx | grep php 确认监听正常、sock 权限正确;第二步打开状态页发现 max listen queue 高达 200+,但 max active processes 只到 8,说明不是进程池打满,而是 reload 期间新旧 worker 交接的空窗;第三步调出 FPM 日志,看到 WARNING: [pool www] seems busy;第四步才是修复:把备份脚本里的 systemctl reload php7.4-fpm 换成 kill -USR2 平滑重载,并把 pm.max_children 从 10 提到 24。

关键在于:如果第一步你就去改 Nginx 的 fastcgi_read_timeout,问题永远不会好,因为方向完全错了。先分层,再动手,这是排障效率的全部秘密。

Last modification:September 24th, 2026 at 09:25 pm

Leave a Comment