nginx 自己不会跑 PHP。它把请求转发给 PHP-FPM,等结果,再回给浏览器。中间这段用的是 FastCGI 协议。

大多数人配 enable-php-83.conf 就完事了,出问题只会看到两个词:502 Bad Gateway 和 File not found。这篇把中间那条链路拆开,搞清楚谁在等谁。

一、请求进来之后的三步

1
2
3
浏览器 ──HTTP──> nginx ──FastCGI──> php-fpm ──> 磁盘上的 .php
│ │
└── 等结果 ◄───────┘
  1. nginx 解析 URI,匹配 location,发现是 .php,决定不自己处理
  2. nginx 按 fastcgi_pass 的地址建立连接,用 FastCGI 协议发一段二进制
  3. FPM 的 worker 进程收到,解析参数,执行脚本,把输出流回来

关键在于第 2 步:nginx 交给 FPM 的不是 HTTP 请求,而是一组键值对。 里面既没有请求行,也没有原始报文,只有 REQUEST_URI、SCRIPT_FILENAME 这类 CGI 变量,加上请求体。

一次请求的完整链路:浏览器到 nginx 走 HTTP,nginx 到 PHP-FPM 换成 FastCGI 二进制协议

二、那段二进制长什么样

FastCGI 把数据切成「记录(record)」,每条记录的头固定 8 字节:

字段长度说明
version1固定 1
type11=BEGIN_REQUEST 4=PARAMS 5=STDIN 3=END_REQUEST
requestId2同一连接上多路复用,nginx 用 1
contentLength2数据长度
paddingLength1填充长度,按 8 字节对齐
reserved1保留

一条 PHP 请求大致是这么走完的:

1
2
3
4
5
6
7
8
9
10
BEGIN_REQUEST     role=RESPONDER, flags=0
PARAMS SCRIPT_FILENAME=/www/wwwroot/app/index.php
PARAMS REQUEST_METHOD=GET
PARAMS QUERY_STRING=id=1
PARAMS ...(几十个变量,可能切多条记录)
PARAMS 空记录 ← 参数结束
STDIN 请求体(GET 时是空记录)

STDOUT 响应头 + 正文
END_REQUEST appStatus=0, protocolStatus=REQUEST_COMPLETE

PARAMS 的编码很省:每项是「名字长度 + 值长度 + 名字 + 值」,长度小于 128 时各占 1 字节,否则占 4 字节(最高位置 1)。所以 SCRIPT_FILENAME=/www/wwwroot/app/index.php 这一项大约 50 字节就传完了。

FPM 收到后把这些变量塞进 $_SERVER,然后 chdir 到脚本目录、include 那个文件。从这个点开始,才是你写的 PHP 在跑。

三、SCRIPT_FILENAME 才是主角

这是最容易出错的一个参数,因为它决定了「跑哪个文件」,和 URL 没有直接关系:

1
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;

拆开看:$document_root 来自 root 指令,$fastcgi_script_name 是 URI 里那段 .php 路径。两者拼起来就是磁盘路径。

如果这一行漏了或者拼错了,你会看到两种报错,含义不同:

现象含义
nginx 日志 Primary script unknownnginx 侧就没算出路径,$document_root 是空的
浏览器看到 File not found.FPM 收到了路径,但那文件不存在或权限不对

File not found. 是 FPM 自己吐的,说明协议已经通了 —— 这能帮你把问题定位到「路径 / 权限」而不是「连接」。

顺带提一个历史遗留问题:cgi.fix_pathinfo。

PHP 默认(很多发行版打包时改了)是 1,此时请求 /upload/evil.jpg/x.php,FPM 找不到 x.php 会沿着路径往前找,最后执行 evil.jpg。这就是著名的解析漏洞,能让上传的图片变成可执行脚本。

正确做法:

1
2
; php.ini
cgi.fix_pathinfo = 0

同时在 nginx 里明确要求路径必须存在:

1
2
3
4
5
6
# 只有真实存在的 php 文件才转发
location ~ \.php$ {
try_files $uri =404;
include fastcgi.conf;
fastcgi_pass unix:/tmp/php-cgi-83.sock;
}

try_files $uri =404 这一行是很多安全扫描的必查项,加上它之后,不存在的路径根本进不到 FPM。

四、FPM 那边:一个 master,一堆 worker

1
2
3
4
php-fpm(master)
├── worker #1 ← 真正执行 PHP
├── worker #2
└── worker #3

master 进程自己不跑业务代码,职责只有四个:绑定 socket、fork worker、收信号、回收挂掉的 worker。所以你看 ps 会发现有一堆 php-fpm: pool www。

worker 数量的控制就是 pm 三选一:

模式行为适用
static启动就固定 N 个,永不增减内存充足、流量稳定的生产机
dynamic按负载在 min/max 之间伸缩大多数场景的默认选择
ondemand零请求时全部退出,来了再 fork低流量、内存紧张的机器

dynamic 的伸缩有延迟:突发流量到来时,FPM 已经按 pm.start_servers fork 了一批在等,如果不够,就要等 pm.process_idle_timeout 后逐个补 —— 这期间请求会堆在 backlog 里。

pm.max_children 怎么算

不要凭感觉填 100。正确的算法是:

1
max_children = 可用内存 ÷ 单个 worker 的常驻内存

先量一下实际 RSS:

1
ps -o rss= -C php-fpm8.3 | awk '{s+=$1; n++} END {printf "平均 %.1f MB, 共 %d 个\n", s/n/1024, n}'

假设平均 40 MB,机器给 PHP 可用 2 GB,那就是 2048 / 40 = 51,留 20% 余量取 40。

填太大会 OOM Killer 上门,填太小则请求排不上队直接 502。这个数只能靠实测,不同项目的 RSS 差几倍很正常。

还有两个配套参数容易被忽略:

1
2
pm.max_requests = 500    ; 每个 worker 处理 500 次后重启,防止内存缓慢泄漏
request_terminate_timeout = 60s ; 单请求墙钟超时,兜底防卡死

pm.max_requests 是最便宜的稳定性保险:哪怕代码有轻微内存泄漏,worker 定期重启就能把它洗干净。

五、502 和 504,到底是谁的锅

这两个错误码经常被混为一谈,实际上完全不是一回事。

502 Bad Gateway:nginx 没能从 FPM 拿到一个完整的响应。常见四种:

成因特征
worker 全忙,连接排满 backlog高峰期出现,日志有 connect() failed 或 Connection reset by peer
FPM master 挂了全站 502,systemctl status php-fpm 看得到
socket 权限 / 路径不对一直 502,ss -lx 看不到那个 sock
worker 处理时被信号杀掉偶发 502,FPM 日志有 child exited on signal 11(段错误)

504 Gateway Timeout:FPM 还在跑,但 nginx 等不下去了。这是超时问题,不是崩溃。

三个超时的时间关系:PHP 自己先放弃,FPM 再强杀 worker,nginx 最后回 504

三个超时参数各管一段,必须按 nginx < FPM 的顺序排:

1
2
3
4
5
max_execution_time        (PHP 层,只算 CPU 时间,不计 IO 等待)
<
request_terminate_timeout (FPM 层,墙钟,到点直接 SIGTERM worker)
<
fastcgi_read_timeout (nginx 层,等上游响应的上限)

比如 PHP 设 30s、FPM 设 60s、nginx 设 90s。这样最坏情况是 PHP 自己先报错退出,用户能看到一个正常的错误页,而不是白屏的 504。

反过来的配置最坑:nginx 设 30s,PHP 设 60s。用户等到 30 秒看到 504,但脚本还在后台继续跑,写了一半的数据留在库里。

还有一条容易被忽略:max_execution_time 在 Linux 上不包含数据库查询、文件读写、curl 等待的时间(因为计时器只在 CPU 密集时触发)。所以「跑 10 分钟的慢 SQL」不会被它拦住,只有 request_terminate_timeout 才管得住。

六、一份真实配置

下面是我自己站点(nginx + PHP 8.3 + 宝塔面板)在用的配置,去掉证书部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
server {
listen 443 ssl;
http2 on;
server_name ly1314.com www.ly1314.com;
root /www/wwwroot/ly1314.com;
index index.php index.html;

# 安全响应头
server_tokens off;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

# 动态 sitemap,必须放在 rewrite 包含之前
rewrite ^/sitemap\.xml$ /sitemap.php last;

# 屏蔽敏感文件
location ~ ^/(\.env|\.git|composer\.(json|lock)|package\.json) {
return 404;
}

# PHP:交给 enable-php-83.conf 里的 location 处理
include enable-php-83.conf;

# Typecho 的伪静态
include /www/server/panel/vhost/rewrite/ly1314.com.conf;
}

enable-php-83.conf 是面板生成的,展开后核心就是:

1
2
3
4
5
6
location ~ [^/]\.php(/|$) {
fastcgi_pass unix:/tmp/php-cgi-83.sock;
fastcgi_index index.php;
include fastcgi.conf;
include pathinfo.conf;
}

几个可以立刻改进的点:

用 unix socket 而不是 TCP。 unix:/tmp/php-cgi-83.sock 省掉了 loopback 的 TCP 握手和校验和计算,小请求下能省 0.1~0.3 ms。注意 sock 文件和 nginx、php-fpm 的进程用户要一致,不然会 Permission denied。

给连接加 keepalive。 FPM 支持长连接,但 nginx 默认每次新建一条:

1
2
3
4
5
6
7
8
9
10
11
upstream php_fpm {
server unix:/tmp/php-cgi-83.sock;
keepalive 32;
}

location ~ \.php$ {
fastcgi_pass php_fpm;
fastcgi_keep_conn on; # 关键
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}

要同时写 fastcgi_keep_conn on,否则 upstream 里的 keepalive 不生效 —— 这是个很常见的「配了没用」的坑。

静态资源不要进 PHP 的 location。 图片、CSS 的 location 要写在 PHP 那条前面,或者用 ^~ 修饰符,否则正则匹配顺序会让它们也被转发给 FPM。

七、小结

  • nginx 交给 FPM 的是一组 CGI 变量,不是原样的 HTTP 报文;载体是 FastCGI 的 8 字节头 record
  • SCRIPT_FILENAME 决定跑哪个文件,和 URL 无直接关系
  • cgi.fix_pathinfo = 0 + try_files $uri =404 是最低限度的上传漏洞防护
  • pm.max_children 用「可用内存 ÷ 实测 RSS」算,配 pm.max_requests 当内存保险
  • 502 是拿不到响应(worker 不够 / 进程挂了 / socket 不通),504 是等太久(超时链没排好)
  • 三个超时按 max_execution_time < request_terminate_timeout < fastcgi_read_timeout 排

下次再看到 502,先别重启服务 —— 看一眼 FPM 的 error log 和 ps aux | grep php-fpm 的数量,基本三分钟就能定位。