凌晨两点,监控告警说 PHP-FPM 的子进程全红,网站 502。登录服务器一看,进程还活着,但 CPU 是 0,日志里没有任何 PHP 报错,只有 nginx 报「upstream prematurely closed connection」。重启 FPM 之后恢复正常,第二天同一时间又挂。这种情况十有八九不是 PHP 代码写错了,而是某个 worker 在 native 层崩了——段错误、abort、被 OOM killer 之外的信号打死,PHP 自己来不及写日志,进程就直接没了。
本文记录一次真实的 nginx worker 段错误溯源过程:从「进程无声消失」到用 systemd-coredump 抓到 core 文件,再在 gdb 里定位到具体是哪个模块、哪一行、什么指针出了问题。所有命令都是 Debian 12 + nginx 1.22 + PHP 8.2 上实测过的。
第一步:先确认进程是「崩了」还是「被杀」
排查 unix 进程消失,第一步永远是分清两种死法:自己崩溃(收到 SIGSEGV/SIGABRT/SIGBUS)和被人杀死(SIGKILL,多半是 OOM killer 或者监控脚本)。分不清这一点,后面所有方向都会跑偏。
# 1. 内核有没有 OOM 记录(有就是被杀,不是崩溃)
dmesg -T | grep -i -E 'killed process|out of memory'
# 2. systemd 的单元日志里有没有退出码/signal
journalctl -u nginx --since '2 days ago' | grep -E 'signal|core-dump|failed|exit'
# 3. 有没有留下 core 文件
systemctl status systemd-coredump | head -5
coredumpctl list --since '2 days ago'区分方法很直接:如果是被 OOM killer 杀的,dmesg 里一定有 Out of memory: Killed process 12345 (php-fpm) 这样的行,并且 journalctl 看到的退出码是 signal=KILL。如果是自己崩溃,退出信息里会是 signal=SEGV、signal=ABRT、signal=BUS,而 dmesg 里没有 OOM 记录。
这次的现象属于后一种:journalctl -u php8.2-fpm 里反复出现 exited, code=dumped, status=11/SEGV。status 11 就是 SIGSEGV,说明是段错误。到这一步,方向就明确了:需要 core 文件,而不是继续翻 PHP 的 error_log。
第二步:让系统真的把 core 存下来
Debian 12 默认装了 systemd-coredump,理论上崩溃会自动收集。但实际运维中经常发现「coredumpctl list 是空的」,原因无非三种:core_pattern 被改过、进程的 core 上限是 0、systemd 在 cgroup 里对进程设了 LimitCORE=0。
# 当前 core 落盘策略
cat /proc/sys/kernel/core_pattern
# 正常应该看到:|/lib/systemd/systemd-coredump %P %u %g %s %t %c %h
# 进程自己的 core 上限(0 就是不生成 core)
ulimit -c
systemctl show php8.2-fpm | grep -i limitcore
# 存储位置与容量配置
cat /etc/systemd/coredump.conf
# Storage=external -> /var/lib/systemd/coredump/
# Storage=journal -> 塞进 journal(不推荐,大 core 会把日志撑爆)
# 只保留最近 3 天的 core
cat /etc/systemd/coredump.conf.d/*.conf 2>/dev/null如果 core_pattern 是 core 这种默认值(没有管道符),说明 systemd-coredump 没接管,core 会落到进程的工作目录里——而 nginx、php-fpm 的工作目录往往没有写权限,于是 core 就悄悄没生成。
要恢复到 systemd 接管的状态,不需要手写 core_pattern,只要重启一次 systemd-coredump.socket 与 systemd-sysctl 即可,systemd 会按 /usr/lib/sysctl.d/50-coredump.conf 里的定义把管道写回内核。另外给需要排查的服务单独放开上限:
mkdir -p /etc/systemd/system/php8.2-fpm.service.d
cat > /etc/systemd/system/php8.2-fpm.service.d/core.conf <<'CONF'
[Service]
LimitCORE=infinity
CONF
systemctl daemon-reload
systemctl restart php8.2-fpm注意 LimitCORE 要写在 service 的 drop-in 里,不能只在 shell 里 ulimit -c unlimited——systemd 启动的进程继承的是 unit 的限制,不是你的 shell 限制。这是新手最常卡住的一步。
第三步:在 gdb 里读 core
有了 core 之后,coredumpctl 就是一切分析的入口。它的 info 子命令会直接打印崩溃线程的完整 backtrace,很多时候一步就能看到问题所在。
# 列出所有 core,按时间倒序,关注 SEGV 的那几条
coredumpctl list --since '3 days ago' --no-pager
# 只看某个程序的
coredumpctl list nginx --no-pager
# 打印崩溃栈(不需要手动准备 gdb 命令)
coredumpctl info 88231 --no-pager | tail -40
# 直接进交互式 gdb
coredumpctl debug 88231这次拿到的 backtrace 关键几行是:
#0 0x00007f2b3c1a2e0d in ngx_http_geoip2_module_ctx_vars (r=0x0)
at /build/nginx-geoip2/ngx_http_geoip2_module.c:412
#1 0x0000561c0a2f1b33 in ngx_http_run_posted_requests (r=0x561c0b1c2f80)
#2 0x0000561c0a2e8d44 in ngx_http_core_run_phases看到 r=0x0 就基本定性了:模块在某个回调里拿到的是空指针,还去解引用它的字段,于是 SIGSEGV。框架本身把请求对象置空是合法行为(请求刚结束、连接被重置、子请求收尾),问题在于模块没有判空。这类 bug 不是配置写错,而是模块的健壮性缺陷。
第四步:从 backtrace 反推触发条件
只知道崩在哪个函数还不够,还得回答「什么请求会让它崩」。gdb 里做两件事:看请求对象和看调用现场的参数。
coredumpctl debug 88231
(gdb) bt full # 看到所有局部变量
(gdb) frame 1
(gdb) p r->uri->data # 崩溃前正在处理的 URI
(gdb) p r->method_name->data
(gdb) p r->headers_in.host->value.data
(gdb) p (int)r->headers_in.content_length_n实测发现每次崩溃的 URI 都带 ?s= 搜索参数,且都是没有 User-Agent、发完请求立刻断开的情况。这就把范围从「随机崩溃」缩小到了「客户端提前断开 + 特定 location 走某段回调」,与前台用户正常访问无关,是扫描器造成的。有了这个结论,应急措施就很具体:那台机器上先对 /?s= 加一条 limit_req 并把该 location 的模块调用去掉,等模块升级。至于限速的具体做法,站内已经写过一篇专门讲恶意爬虫压搜索页的,这里不再重复。
第五步:coredumpctl 的三个实用技巧
真正在故障现场用的时候,下面三件事比「读懂 backtrace」更高频:
一是限制单条 core 的体积。一个 PHP 进程的 core 可能有几百 MB,几台机器合起来每天能吃掉几十 G。用 ProcessSizeMax 和 ExternalSizeMax 封顶,超过上限的只保留元数据:
cat > /etc/systemd/coredump.conf.d/size.conf <<'CONF'
[Coredump]
Storage=external
Compress=yes
ProcessSizeMax=2G
ExternalSizeMax=2G
JournalSizeMax=64M
MaxUse=5G
KeepFree=10G
CONF
systemctl restart systemd-coredump.socket二是先压缩再分析。core 默认以 zstd 压缩存在 /var/lib/systemd/coredump/,文件名里带可执行文件路径的转义。想看原始大小时不要直接 ls -l 下结论,那只是压缩后大小。真需要解压出来复用(例如要把 core 拷到另一台同版本环境的机器上分析),用 coredumpctl dump 88231 -o /tmp/core.nginx,它会把压缩包解开成 ELF core 写出。拷到别的机器时要保证那边的可执行文件和调试符号版本完全一致,否则 backtrace 里的函数名和行号全是错的——这一点比任何 gdb 命令都重要,很多人对着错版本的符号表调了半天,结论完全跑偏。
三是定期清理。MaxUse 只对 systemd 自己管理的目录生效,如果 Storage=external 并且自己改过路径,需要配一条 tmpfiles.d 规则或者直接交给 systemd-tmpfiles --clean。忘了这一步的站点经常在几个月后因为 /var 写满而全站 502,那时候原因早就不是原来的崩溃了。
第六步:能不能根本不让它崩
core 分析只是取证,最终还是要减少崩溃概率。对 nginx/PHP 这类进程,可做的有这几件事:
其一,模块和扩展尽量用发行版认证源里的版本,第三方编译的模块最容易出现空指针解引用,因为编译器版本和主程序的 ABI 未必对齐。其二,跨大版本的 nginx 不要用「二进制直接替换」的方式升级,主进程换而模块不换,崩溃会以极低频率间歇出现,极难查。其三,PHP 的 opcache 与扩展在升级之后要确认 php -m 里的版本号与 FPM 实际加载的一致,php -v 显示的是 CLI 的,不是 FPM 的——用 php-fpm -i | grep -i opcache 或直接看 /status?full 页更靠谱。
其四,崩溃虽然少见但确实不可避免,所以要有兜底。FPM 的 pm.max_requests 设一个中等值(500 到 1000)能让 worker 定期自我回收,避免内存缓慢增长累积成崩溃;request_terminate_timeout 要让卡死的请求被强制干掉而不是拖垮整个池。这两条不会消除 bug,但能把「偶发崩溃」控制成「偶发重启」,对可用性的影响小一个数量级。
还有一条容易被忽视:崩溃之后要确认「有没有连带损伤」。core 出现的那一刻,进程正在处理的请求肯定失败了,但如果崩的是 master 或者正在做磁盘 IO 的 worker,还可能留下半截临时文件、未释放的文件锁、写了一半的缓存文件。所以每次确认崩溃并重启服务之后,顺手检查三处:临时目录里有没有按时间排列的孤儿文件(find /tmp -newermt '-1 hour' -size +0)、FPM 的 /status 里 active process 是否回落到正常值、以及 nginx 的 5xx 计数是否停止增长。这三步能避免「服务重启了但问题还在」的二次故障。
把流程固化成一句话
回头看这次排查,真正省时间的不是 gdb 技巧,而是最开始那三步的判断顺序:先分「崩了还是被杀」,再确认 core 有没有落盘,最后才去读栈。很多人的问题恰恰卡在第二步——coredumpctl list 空着,就以为系统不支持 core,转而去加日志、猜代码,白白耗掉一晚上。
建议每个自建服务器的站长都花五分钟做一次预检:确认 core_pattern 有管道符、确认 coredumpctl list 能列出东西、确认 /var 至少有 5G 余量。这三件事在系统正常时做,成本几乎为零;等到崩溃发生时才做,就要在业务已经挂掉的深夜里现学。