☰
PHP-FPM崩溃排查:TP3项目segfault的coredump与GDB定位
2026/10/13 6:49:43 网站建设 项目流程

先说明一下标题里的tp3,我这里指的是ThinkPHP 3.x,一个至今还跑在很多老项目里的PHP框架。前两天接到线上告警,一台运行TP3项目的机器,PHP-FPM的worker进程每隔几小时就毫无征兆地退出,Nginx疯狂回502,PHP错误日志却干干净净。系统日志里反复出现一行:php-fpm[12345]: segfault at ...,看到segfault我第一反应不是翻业务代码,而是先去找有没有生成coredump。折腾了两三天,最后定位到的问题跟TP3框架本身的关系不大,是一个PHP扩展和运行时环境的兼容性坑。但整套排查过程确实值得沉淀:从Linux开启coredump,到用GDB把崩溃现场翻个底朝天,再到修复和验证,每步都有不少容易踩的细节。这篇文章按我的实际操作顺序记录,给遇到同类问题的同学一个可以直接套用的排查路径。

1. 项目背景与问题现象:先从TP3项目崩溃现场说起

1.1 线上现象:502、SIGSEGV和那个没人关注的core文件

这台机器跑的是ThinkPHP 3.2.3的老后台,PHP版本是7.0,PHP-FPM进程数不多,但流量也不算小。第一次收到告警是下午高峰期,Nginx的error.log里大量出现connect() failed (111: Connection refused),紧接着就是一串502。打开PHP-FPM的日志,发现并没有常见的Fatal error或者Exception,只有一行:WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) after 214.123 seconds from start。

signal 11就是SIGSEGV,段错误。简单说就是进程访问了不该访问的内存地址,被操作系统强制杀掉。这个级别的崩溃,PHP的错误日志通常什么都记不下来,因为进程是直接“横死”的,根本没机会执行错误处理逻辑。再去看系统日志,发现类似php-fpm[12345]: segfault at 0 ip 00007f...的记录已经出现过好几次。当时我就明白,这不是普通的PHP业务异常,必须往下挖底层。

1.2 为什么常规排查查不出问题

一开始我也按老套路来:先看PHP日志,没有;再开display_errors,没有;把TP3的日志级别调到最高,还是没有。接着怀疑是Redis扩展、Memcached扩展或者OPcache的问题,逐个排除,问题是崩溃依旧。

后来我意识到一个关键点:PHP应用层能捕获的错误,比如E_WARNING、E_ERROR,都会体现在PHP错误日志里。但SIGSEGV发生在Zend引擎或者某个扩展的C代码层,进程直接挂掉,PHP根本来不及写日志。这种情况下,唯一能还原事发瞬间内存状态的东西就是coredump。coredump是操作系统在进程崩溃时把整个进程的内存映像、寄存器状态、调用栈信息一起dump到磁盘的文件,相当于案发现场的“监控录像”。没有它,定位这种问题基本靠猜。

1.3 确定排查方向:先开coredump,再谈定位

我当时的判断很简单:PHP-FPM能稳定的每隔几小时崩一次,说明触发条件是能复现的。既然能复现,就一定能抓到完整现场。接下来的思路分三步:第一,确保Linux系统在崩溃瞬间会生成core文件;第二,用压力测试把崩溃复现出来,拿到core文件;第三,用GDB打开core文件,定位崩溃发生在哪个函数、哪个扩展、哪一行代码。这一套跑完,基本能锁定问题。

2. Linux开启coredump:从配置到落盘的完整操作

2.1 先确认当前环境到底有没有开coredump

很多服务器默认是不生成core文件的。查一下就知道:在shell里执行ulimit -a,如果看到core file size是0,说明当前shell进程限制了core文件大小。再执行cat /proc/sys/kernel/core_pattern,看系统层面core文件的命名规则和存放路径。需要注意的是,这两个检查必须分开看,因为即使ulimit -c设置为unlimited,kernel.core_pattern配置不对,core文件也可能写到无法落盘的目录里,或者干脆被丢弃。

这里有个最容易被坑的点:在终端里执行ulimit -c unlimited只是修改了当前shell的进程限制,对PHP-FPM这种由systemd拉起的服务根本不生效。我一开始就是只改了终端,结果PHP-FPM崩了之后照样没有core文件。正确做法是先看PHP-FPM主进程的实际限制:cat /proc/$(pgrep -f php-fpm | head -1)/limits,找到Max core file size这一行。如果显示unlimited,说明服务进程允许生成core;如果显示0或有限值,说明限制在systemd服务配置里。

2.2 永久开启并配置core文件生成规则

要永久开启,至少需要改两个地方。第一个是/etc/security/limits.conf,在文件末尾加:

* soft core unlimited * hard core unlimited

这行配置对通过login、ssh等登录方式启动的进程有效。第二个是内核参数kernel.core_pattern,控制core文件的保存路径和文件名格式。我建议设置成带进程名、PID和时间的格式:

sysctl -w kernel.core_pattern=/var/crash/core_%e_%p_%t echo 'kernel.core_pattern=/var/crash/core_%e_%p_%t' >> /etc/sysctl.conf sysctl -p

这里%e是进程名,%p是PID,%t是时间戳。设置这个格式不是为了好看,而是防止多个崩溃进程共用同一个core文件名互相覆盖。如果机器崩溃频繁,没有PID区分,后生成的core会直接覆盖之前的,等于现场被二次破坏。另外建议同时设置kernel.core_uses_pid=1,让文件名默认附带PID。

2.3 新版systemd环境下必须调整PHP-FPM服务配置

如果你用的是CentOS 7以上、Ubuntu 16.04以上的系统,PHP-FPM基本都由systemd管理。systemd服务的LimitCORE默认可能不是unlimited,它会覆盖limits.conf的配置。最省事的办法是为php-fpm创建一个override配置:

mkdir -p /etc/systemd/system/php-fpm.service.d cat > /etc/systemd/system/php-fpm.service.d/override.conf <<EOF [Service] LimitCORE=infinity EOF systemctl daemon-reload systemctl restart php-fpm

重启后再次确认/proc/<php-fpm-pid>/limits,看到Max core file size unlimited才算真正生效。另外,较新的systemd版本默认把kernel.core_pattern接管为|/usr/lib/systemd/systemd-coredump,这种情况下core文件不会直接落到普通目录,而是被systemd-coredump服务存到/var/lib/systemd/coredump/。可以用coredumpctl list查看所有已捕获的崩溃文件,用coredumpctl dump <PID>导出。我建议先执行cat /proc/sys/kernel/core_pattern确认当前是被谁接管,再做下一步。

2.4 别让core文件撑爆磁盘

开coredump最大的副作用是文件可能很大。PHP-FPM的进程驻留内存动辄几百MB甚至上GB,崩溃时dump出来的文件只会比这更大。生产环境如果开着不管理,崩溃几次磁盘就满了,系统直接雪崩。

我常用的方案是把kernel.core_pattern指向一个独立分区,比如/var/crash,并给这个分区单独配置大小限制。再用cron定期清理几天前的core文件。如果你用systemd-coredump,可以把/etc/systemd/coredump.conf里的Compress设置为yes,它会自动用zstd压缩存储,能省下不少空间。不过要提醒一点:压缩后的core文件用GDB打开时需要先解压,systemd提供了coredumpctl dump -o /tmp/core.gz导出方式,导出后文件名虽然没有.gz后缀,但实际内容已经是解压后的裸core,可以直接喂给GDB。

3. 复现崩溃并抓取完整的core文件

3.1 让崩溃从“偶发”变成“必现”

定位这种时有时无的崩溃,最优先的任务是提升复现概率。我当时用的压测工具是wrk,比ab更轻量,并发连接控制也方便。没有现成的wrk也可以用ab:

ab -n 20000 -c 100 http://127.0.0.1/index.php

压测过程中重点观察两个东西:一是PHP-FPM是否出现新的signal 11日志,二是/var/crash目录下是否出现新的core文件。TP3老项目本身性能不算好,可能存在锁竞争、慢查询、文件缓存并发写入等问题,并发拉到100左右,原来几个小时崩一次的问题很快变成几分钟崩一次。

3.2 确认core文件真的落盘了

压测跑完,用find /var/crash -name 'core_*' -mmin -5查看刚生成的core文件。如果目录下没有,先检查两点:第一,cat /proc/sys/kernel/core_pattern是否真的指向这个目录;第二,PHP-FPM进程的当前工作目录和uid是否有权限写这个目录。很多人不知道,PHP-FPM的chdir目录可能被设置成某个项目目录,如果core_pattern配置的是相对路径core,文件会写到PHP-FPM的当前工作目录而不是你想当然的目录。用绝对路径能彻底避免这个问题。

如果系统用的systemd-coredump,直接执行:

coredumpctl list --since "10 minutes ago"

能看到类似php-fpm 12345 SIGSEGV present的记录。再用coredumpctl dump 12345 -o /var/crash/core-php-fpm-12345导出来。别小看这一步,很多新手在这里卡住,因为ulimit、core_pattern、systemd三者任何一个配置不对,都可能导致core文件“消失了”。

3.3 给分析留足证据:版本、扩展、二进制一个都不能少

拿到core文件后,别急着打开。先做现场证据收集,否则后面GDB分析会很被动。我当时的记录习惯是:用file看core文件基本信息,确认它对应的可执行文件;用php -v记录PHP版本;用php -m列出所有加载的扩展;再用ldd /usr/sbin/php-fpm记录动态库依赖。最后把这些信息连同core文件一起复制到分析目录,并计算md5值,防止后面分析时文件被误改。

file /var/crash/core_php-fpm_12345_1701000000 php -v php -m ldd /usr/sbin/php-fpm md5sum /var/crash/core_php-fpm_12345_1701000000

这么做是因为GDB打开core文件时,需要找到匹配的php-fpm二进制和调试符号。如果系统被升级过、PHP二进制变了,core文件就可能无法正确解析。存档这些上下文信息,能让分析过程少走弯路。

4. 用GDB解剖崩溃现场:从调用栈到根因

4.1 GDB核心命令:从bt开始

GDB打开core文件的命令格式是:

gdb /usr/sbin/php-fpm /var/crash/core_php-fpm_12345_1701000000

进入GDB后,第一件事执行bt,也就是backtrace,查看崩溃时的函数调用栈。如果栈比较深,可以bt full看每个栈帧的局部变量和参数。如果崩溃发生在子线程,需要thread apply all bt打印所有线程的调用栈。有时候核心文件很大,GDB启动会有点慢,可以用非交互模式直接批量输出关键信息,比较适合在服务器上快速验证:

gdb -batch -ex 'bt' -ex 'info sharedlibrary' /usr/sbin/php-fpm /var/crash/core_php-fpm_12345_1701000000

4.2 三条关键线索:栈、扩展库、内存地址

分析调用栈时,我一般看三件事。

第一,崩溃点到底在哪个函数。如果栈顶是zend_execute_ex、execute_ex这类Zend引擎内部函数,说明进程是在执行PHP脚本时崩的,可能是业务代码触发引擎bug,也可能是扩展改写了执行流程。如果栈顶是某个扩展的导出函数,比如redis_...、memcache_...、ioncube_...,那基本可以断定是扩展的问题。

第二,用info sharedlibrary查看当前加载了哪些动态库。这一步特别有用,因为很多coredump的调用栈表面上看都指向zend_execute,看不出业务关系,但加载列表里会暴露所有可疑的扩展。我这次就是在共享库里看到了一个版本明显不对的商密扩展,才把注意力从业务代码上移开。

第三,用info registers看寄存器内容,再用x/i $pc查看当前指令。如果$pc指向0x0或者非常小的地址,几乎可以断定是空指针解引用,也就是某个函数通过NULL指针调用了方法。

4.3 本案例的崩溃栈与最终定位

核心文件导出来后,先用GDB跑了一次非交互bt,栈大概长这样:

#0 zend_execute_ex (execute_data=0x7f...) at zend_execute.c:... #1 ioncube_loader_lin_7.0_execute (execute_data=...) at ... #2 zend_execute_scripts (...) #3 php_execute_script (...) #4 main (...)

看到ioncube_loader_lin_7.0_execute这一帧,问题就很明显了。ionCube Loader是一个商业PHP扩展,常用于解密和加载加密过的PHP文件,TP3老项目很多会用ionCube加密商业代码。这个扩展对PHP版本极度敏感,每一个PHP小版本都要有对应的Loader版本。这台机器之前做过PHP版本升级,但ionCube Loader的版本没有同步升级,于是在处理TP3里经过加密的类文件时,直接触发了底层的段错误。

继续用info sharedlibrary确认,加载的是ioncube_loader_lin_7.0.so,而实际PHP版本虽然是7.0,但Loader的具体构建版本和PHP patch level不匹配。这解释了为什么崩溃时业务日志干干净净,因为问题压根不在TP3代码逻辑里。

4.4 为什么崩溃栈看起来总像PHP框架的“锅”

很多人遇到这类问题,看到zend_execute_ex就以为是框架的递归或死循环导致的,其实不是。zend_execute_ex是PHP执行任何脚本都会经过的入口,任意扩展想要拦截PHP函数调用,都会在这一层插入自己的hook。ionCube Loader、Zend Guard Loader、Xdebug这类扩展,全都通过修改zend_execute_ex的函数指针来介入执行流程。只要它本身有bug,或者版本和PHP不匹配,调用栈看起来就都是“PHP执行脚本时崩溃”,特别迷惑人。

这个案例给咱们的教训是:看到zend_execute_ex先不要陷入业务代码,优先排查扩展层。判断方法是bt里是否出现了非PHP官方扩展的符号,配合info sharedlibrary把动态库列表拉出来,排除法做一轮,比对着TP3源码猜快得多。

5. TP3老项目与coredump的两种典型关系

5.1 老框架调用不兼容扩展的触发路径

搞清楚coredump的直接原因是扩展之后,还得想一个问题:为什么偏偏是TP3项目触发,其他项目不崩?我的理解是,TP3这类老框架和现代PHP环境之间存在大量“边缘路径”。比如TP3的数据库驱动、URL路由、模板引擎,很多都是早期PHP 5.x时代的写法,在PHP 7上运行靠的是各种兼容层和魔术方法。当ionCube这类扩展对函数执行做了包装,一旦走到这些不规范但能跑的旧代码分支,就可能踩到扩展的兼容性边界。

换句话说,框架代码本身没有直接导致SIGSEGV的能力,但它提供了一条让不兼容扩展“翻车”的路径。这有点像老化的电路,灯本身没坏,但每次开灯时开关处就会打火,换掉打火的开关才是正解。

5.2 业务代码把Zend引擎“逼死”的几种写法

除了扩展版本问题,TP3项目中确实也存在一些纯业务层导致coredump的极端情况,常见的有以下几种。

第一,无限递归。两个函数互相调用,栈空间被撑爆,最终触发SIGSEGV。但PHP通常会在栈耗尽前抛出Maximum function nesting level错误,只有某些特殊情况下才会直接崩,比如递归发生在析构函数里,或者配合Xdebug使用时。

第二,preg_replace的/e修饰符执行代码。这种写法在PHP 5时代很流行,TP3老代码里偶尔能看到。PHP 7已经移除/e,如果项目通过兼容补丁强行保留,某些输入会让内部状态异常,进而引发崩溃。

第三,循环引用和GC的极端组合。TP3的Model和关联查询经常构造复杂对象图,假如在析构函数里再触发新的对象操作,Zend引擎的垃圾回收机制在特定情况下可能出现问题,导致段错误。这类问题最难查,但GDB里通常能看出栈顶是gc_...相关函数。

5.3 项目层面怎么取舍:升级、兼容还是锁版本

TP3项目遇到这类崩溃,修复手段不只是卸载某个扩展。如果项目还能继续维护,建议先把PHP版本固定在一个经过验证的版本区间,别轻易跟着系统升级。TP3 3.2.x在PHP 5.6和PHP 7.0下相对稳定,越往上升,老代码的兼容风险越大。如果业务代码无法重写,与其把框架迁到新版本,不如把运行环境锁死,并保证所有扩展和运行时版本严格匹配。

如果一定要升级,至少先跑一轮自动化测试,把TP3所有路由、数据库操作、缓存操作都覆盖一遍,而不是只测首页能打开。另外,尽量把PHP-FPM放进容器或者专用环境,避免宿主机系统升级时连带PHP和扩展一起变,正是这次崩溃的诱因之一。

6. 修复、验证和长期监控的实战方案

6.1 修复操作步骤:从禁用扩展到改代码

定位到ionCube Loader版本不匹配后,我做了两个操作。第一步,确认业务上是否真的需要ionCube。很多老项目用ionCube是为了保护商业源码,但如果你有源码或者项目已经不需要加密模块,直接停用最干净。第二步,如果必须用,就去ionCube官网下载和当前PHP版本完全对应的Loader,替换掉旧文件。

修改方式是在php.ini里找到zend_extension=ioncube_loader_lin_7.0.so,先注释掉,重启PHP-FPM观察崩溃是否消失:

cd /etc/php/7.0/fpm/conf.d sed -i 's/^zend_extension=ioncube/;zend_extension=ioncube/' 20-ioncube.ini systemctl restart php-fpm php -m | grep ionCube

注意:如果只是临时停用扩展,必须确认业务代码没有依赖ionCube才能执行的文件,否则页面会直接白屏或报错。

6.2 验证修复效果:压测、观察coredump列表

修复不是改完配置就算完。我用之前的压测命令再跑一遍,确认/var/crash目录连续几个小时内没有新增core文件,coredumpctl list里也没有新的SIGSEGV记录。如果只测试几分钟就宣布修复,大概率会被“偶发”问题打脸。我的习惯是至少用两轮压测,每轮跑20分钟以上,并观察PHP-FPM的slow log和error log有没有新的异常。

另外,还要检查旧的core文件是否影响系统稳定性。如果之前已经生成了好几个大文件,记得及时清理,释放磁盘空间。清理前先确认分析完毕,别把还没研究的证据删了。

6.3 长期监控:让下次崩溃第一时间被发现

这类问题很可能改完又出现,尤其是当系统或PHP组件再次升级时。我建议对PHP-FPM做三个层面的监控。

第一层,系统层:用cron定时检查/var/crash目录里的文件数量和最近修改时间,一旦发现新core文件就发告警。脚本很简单:

find /var/crash -name 'core_*' -mmin 10 | wc -l

第二层,服务日志层:定期grep系统日志或journalctl中PHP-FPM的segfault记录:

journalctl -u php-fpm --since "1 hour ago" | grep -i "signal 11\|segfault"

第三层,业务可见性层:在Nginx层面监控502比例,一旦异常直接关联到PHP-FPM的崩溃事件。这不是为了事后分析,而是为了下次能更快拿到core文件——监控告警越早,coredump被抓到的概率越高。

7. 排查coredump常见问题速查表

症状可能原因应对方法
进程崩了但没有core文件ulimit -c为0,或core_pattern被systemd覆盖,或进程无写权限检查/proc/<pid>/limits和cat /proc/sys/kernel/core_pattern
core文件生成到根目录或未知目录core_pattern配置的是相对路径,PHP-FPM的工作目录非预期统一使用绝对路径,例如/var/crash/core_%e_%p_%t
coredumpctl list里能看到记录,但找不到实体文件systemd-coredump压缩存储用coredumpctl dump <pid> -o /tmp/core_dump导出
GDB提示“not in executable format”core文件被截断或导出时没有解压用file确认格式;检查磁盘空间;重新导出
调用栈全是zend_execute_ex,看不到业务函数扩展hook了执行流程info sharedlibrary列出动态库,重点排查非官方扩展
栈顶是gc相关函数循环引用、析构函数触发垃圾回收异常检查TP3模型对象的析构逻辑,尝试关闭gc或调整gc阈值
压测时崩溃复现,正常流量不崩并发场景触发扩展bug用并发压测提高触发概率,在测试环境反复复现
系统日志显示Out of memory而非segfaultOOM Killer误杀排查PHP内存使用,增加memory_limit或优化代码,不要和coredump混淆

最后分享两个我被坑过才记住的细节。第一,Linux开启coredump不是配一个ulimit就完事,必须同时确认systemd的LimitCORE、kernel.core_pattern、落盘目录权限,一条链断掉就白干。第二,GDB看到的崩溃栈如果没有业务函数,别急着找框架背锅,先用info sharedlibrary把扩展列出来,十次coredump里至少有八次是扩展版本和PHP版本不一致。这一套流程跑下来,这类“幽灵崩溃”基本都能在半天内定位,不会再看着一堆502日志无从下手。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询