2. 核心细节解析与实操要点
2.1 核心机制拆解:包管理器、依赖目录与全局激活
系统环境的PHP有3个版本(7.4 / 8.0 / 8.1),不同项目分别跑在不同PHP版本上,但用系统包管理器安装的PHP扩展(比如Redis、ImageMagick、Intl、Sodium等)只存在于系统扩展目录/usr/lib/php/{version}/下,CGI模式下php.ini虽然被读取,但无法直接把这些系统扩展加载到用户目录的PHP运行环境中。表层问题是启动报出Call to undefined function,底层原因其实是两个环境扫描不到同一个扩展目录。
以PHP 8.0为例,系统自带的扩展路径是/usr/lib/php/20200930/,而CGI项目根目录下的php.ini查找到的extension_dir指向了/opt/php-ext/这类自定义目录。要让CGI模式正常加载扩展,只能启用Bolt扩展(.so文件)并激活对应扩展名称。
选Bolt而不是手工改extension=redis.so,核心原因有三个:第一,逐行手动写extension=指令没法精确匹配PHP小版本对应的ABI(应用二进制接口),而Bolt通过CPU型号、PHP版本、编译日期的交叉校验能最大程度防冲突;第二,CGI环境每收到一次请求就启动一个新进程,加载了未定义扩展时会产生大量class not found异常日志;第三,Bolt必须放在runtime目录才能被正确检索到,这恰好与用户目录(部署目录)分离,避免误删。
2.2 配置引擎与继承规则:php-cli 里永远“看起来正常”的陷阱
配置解析这一环节最容易掉进“CLI看似OK,CGI实际失效”的陷阱。原因是PHP CLI和CGI读取的php.ini路径不同:CLI读的是/etc/php/8.0/cli/php.ini,CGI读的是/etc/php/8.0/cgi/php.ini,当你用php -v或者写个phpinfo()临时脚本测试时,看到的结果往往是系统扩展全部正常加载,但真正跑在CGI容器里的PHP却缺少同样的扩展。
我在模拟项目X的配置脚本里明确区分了CLI和CGI两套配置任务,用ls -la /etc/php/8.0/cgi/conf.d/去确认CGI环境的扩展片段是否真实存在。实际操作中,我曾经在一台机器上反复执行apt install php8.0-redis,装完后CLI能看到Redis扩展,但CGI就是识别不到,排查到最后发现CGI的conf.d目录下压根没有对应的20-redis.ini软链。这种“装上了不等于生效”的情况,在重装系统环境或误清理软链时特别常见。
正确做法:检查CGI独立的扩展软链状态,并且在CGI启动入口页直接输出真实的phpinfo(),不要看CLI的。由于CGI每次请求都要重新解析配置,任何对php.ini或扩展软链的修改在保存后下一个请求就会生效,这比FPM的“重启后才生效”要灵活得多,但也要求配置必须一次写对。
2.3 安全模式与资源受控:CGI环境的两个硬性前提
CGI模式没有常驻进程的管控机制,每个请求都是从零开始执行PHP,并直接以Web服务器用户(比如www-data)的身份运行,因此安全问题必须前置到配置层。跑模拟项目X的CGI入口时,我给PHP配置文件设置了两个硬性开关:disable_functions和open_basedir。前者用于禁掉exec、shell_exec、system、passthru、proc_open这类高危函数;后者把PHP的文件读取范围锁死在项目根目录和上传目录里,避免任意文件读取。
同时,CGI模式一定要把cgi.fix_pathinfo设为0,否则解析http://域名/test.php/任意路径这类URL时可能走路径穿越逻辑,导致源码泄露或执行非预期脚本。还有个很隐蔽的点:CGI模式下memory_limit和max_execution_time一旦没设置,脚本的超时控制会退回到Web服务器层,无法精准熔断单次请求,我建议显式设置max_execution_time = 30和memory_limit = 256M,把故障爆炸半径控制在单进程中。
Bolt扩展的策略是调整顺序:先载入Bolt内核扩展(方便收集异常),再逐个加载业务扩展,异常处理逻辑优先于业务逻辑,避免二次请求时带病执行。
3. 实操过程与核心环节实现
3.1 完整部署步骤:从扩展安装到CGI入口全流程
第一步:安装CGI版本的PHP及所需扩展。以PHP 8.0为例,需要安装php8.0-cgi、php8.0-cli、php8.0-fpm(虽然不用FPM,但很多扩展依赖它)、php8.0-common、php8.0-mysql、php8.0-redis、php8.0-curl、php8.0-gd、php8.0-mbstring、php8.0-xml、php8.0-zip。安装完成后,确认/usr/bin/php-cgi8.0符号链接存在,这个可执行文件就是CGI模式的入口。
第二步:定位CGI模式的扩展和配置目录。先执行:
php-cgi8.0 -i | grep 'extension_dir'输出路径(例如/usr/lib/php/20200930)作为extension_dir的基准值。再执行:
php-cgi8.0 --ini找到CGI模式下实际使用的php.ini完整路径(通常位于/etc/php/8.0/cgi/php.ini)。
第三步:配置php.ini的扩展加载。核心不在于手动写全部扩展,而在于用Bolt统一管理。在php.ini最前面加入:
extension=Bolt.so bolt.enabled=1 bolt.runtime_dir=/tmp/cgi-bolt-runtime/ bolt.log_level=2Bolt的runtime_dir需要在CGI进程启动前创建好,并确认Web服务器用户有读写权限。如果目录不存在或权限不对,Bolt会静默降级为“不拦截”,所有扩展管理规则全部失效却不报错,这是最容易被忽略的坑。
第四步:把扩展配置导入到CGI的conf.d目录。为了不污染全局PHP,我只在CGI的conf.d/下写入扩展激活配置。例如创建/etc/php/8.0/cgi/conf.d/20-bolt.ini,内容为Bolt参数;再创建一系列30-redis.ini、40-intl.ini之类的扩展配置。如果你发现conf.d里没有对应软链,不要靠猜测,直接对比/etc/php/8.0/cli/conf.d/和/etc/php/8.0/cgi/conf.d/的差异,缺哪个补哪个软链。
第五步:验证CGI执行。直接命令行调用:
php-cgi8.0 -v然后创建一个临时PHP文件,用CGI方式执行:
echo '<?php phpinfo(); ?>' > /tmp/info.php SCRIPT_FILENAME=/tmp/info.php REQUEST_METHOD=GET /usr/bin/php-cgi8.0如果页面输出里能看到Bolt和Redis两个扩展,说明系统扩展已经成功加载到CGI环境;如果只看到Bolt却看不到Redis,则重点检查conf.d下Redis的软链和Bolt的扩展加载策略是否匹配。
3.2 与Web服务器对接:正确传递SCRIPT_FILENAME
CGI模式必须由Web服务器通过CGI协议把请求转化成环境变量,再由php-cgi去执行。最容易出错的变量是SCRIPT_FILENAME,它必须指向磁盘上真实存在的PHP文件路径,否则PHP-CGI会直接报Primary script unknown。
我用的对接方式是:
server { listen 8088; root /var/www/domain-a/; index index.php; location ~ \.php$ { include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param SCRIPT_NAME $fastcgi_script_name; fastcgi_pass 127.0.0.1:9100; } }监听端口9100对应php-cgi进程实际绑定的地址。启动php-cgi时我用它自带的监听能力绑定到TCP:
/usr/bin/php-cgi8.0 -b 127.0.0.1:9100注意,PHP-CGI默认不会自动多进程,如果你需要并发处理多个请求,必须用进程托管工具或php-cgi -b加多实例配合调度器拉起多个子进程,否则在高并发下只能排队处理,性能会非常差。我在模拟项目X里用进程守护程序同时管理了多个php-cgi8.0子进程,并把max_requests(每个进程处理请求上限)设置成200,防止单进程内存泄漏积累。
这里尤其要留意:当你使用fastcgi_pass指向CGI端口时,Web服务器(如Nginx)与PHP-CGI之间的交互协议是FastCGI,而PHP-CGI模式本质上是“每次请求重建进程”,与FPM常驻进程池的管理方式不同,所以pm配置不生效,所有进程生命周期都由外部的进程守护托管来处理。
3.3 服务调度与进程托管:保证CGI并发能力的关键
为了让CGI进程具备基本的并发服务能力,我用进程守护配置横向拉起四个PHP-CGI子进程,每个进程绑定不同端口(9100、9101、9102、9103),然后由一个负载调度规则按IP哈希分发请求。
[program:php-cgi-9100] command=/usr/bin/php-cgi8.0 -b 127.0.0.1:9100 autostart=true autorestart=true user=www-data numprocs=1为什么不用单个进程直接处理所有请求?因为CGI单进程一次只能处理一个请求,任何慢查询或阻塞型IO都会拖垮整个入口。横向拉起多个进程后,某个进程崩溃也不会影响其他进程。
进程数量建议按CPU核心数确定:单核场景两个,双核四个。再多也不会带来线性收益,反而因为PHP-CGI每进程占用约30至60MB内存,会导致整体内存水位偏高。
3.4 迁移与切流:以站点为单位灰度切换
模拟项目X的平滑切换思路是把它拆成两个独立入口:旧入口继续走PHP-FPM,新入口走PHP-CGI。Web服务器的上游组同时配置FPM后端和CGI后端,权重先设为0,验证通过后再调高CGI权重。
upstream php_runner { server 127.0.0.1:9000 weight=10; # 原 FPM server 127.0.0.1:9100 weight=0; # CGI 实例1 server 127.0.0.1:9101 weight=0; # CGI 实例2 server 127.0.0.1:9102 weight=0; # CGI 实例3 }调整权重的过程必须小步快跑,先说weight=1,观察一小时错误率,再逐步放大。这里有个注意事项:CGI实例的临时目录在每次重启后都会清空,Bolt的runtime_dir如果建在/tmp下,重启后可能出现权限错乱,我建议把它建在独立目录/var/lib/cgi-bolt/下,配合所有者和权限固定,避免重启掉链子。
4. 常见问题与排查技巧实录
4.1 扩展反复无法加载
排查这类问题,我一般按三步走。第一步确认CGI可执行文件本身能识别扩展路径,执行php-cgi8.0 -i | grep extension_dir,如果路径和你预期不一致,检查是否修改了错误的php.ini(常见于只改了cli的,没改cgi的)。第二步看CGI的conf.d软链,确认php8.0-redis这类扩展包对应的.ini片段存在于CGI目录。第三步是执行一次真实请求并抓取日志,大部分扩展加载失败的细节都会写进PHP错误日志。
一个很容易被忽略的情况:Bolt的优先级机制会控制扩展加载顺序,如果Bolt的内核扩展未启动,即使是系统扩展也可能被拦截。所以你先要在phpinfo()里确认Bolt状态为enabled,再谈其他扩展。
4.2 502 和 504 的边界判定
502和504在CGI模式下分别对应不同故障原因。502通常是PHP-CGI进程没有正常运行,比如端口没监听、进程崩溃、启动失败。用ss -lnt | grep 9100确认TCP端口是否存在监听;如果端口没了,再看进程守护日志是不是PHP-CGI启动时报错。504一般是PHP-CGI进程还活着,但脚本执行时间过长,超过Web服务器网关超时(例如fastcgi_read_timeout),此时需要优化脚本本身或调大超时,而不是盲目加进程。
实际项目里我发现一件反直觉的事:CGI模式下如果max_execution_time设成0,PHP脚本会一直执行到系统层面被回收,Nginx侧等不到响应就报504的同时还会连着报502。所以配置超时千万不能“放开”,必须两个维度兼顾。
4.3 高并发下临目录爆炸与权限异常
CGI模式每次请求都会产生临时文件,如果Bolt的runtime_dir或PHP的sys_temp_dir设置在一个不干净的位置,时间一长会出现“磁盘写满但df -h看起来还有空间”的怪异现象。原因是inode被大量小文件耗尽,磁盘块还有剩余。排查方法:
df -i /var/lib/cgi-bolt/ find /var/lib/cgi-bolt/ -type f | wc -l如果inode使用率超过90%,就要把临时目录清理掉或者改成按天分目录存储。另外,所有PHP-CGI子进程共享同一个临时目录时,文件权限容易错乱,导致Bolt无法写入锁文件。解决办法是把临时目录权限设为www-data专属,并避免用chmod 777粗暴放开。
4.4 动态扩展在CGI模式下“上午正常、下午失效”
这类问题最容易伪装成“随机bug”。Bolt的扩展策略文件是按日期滚动的,我的策略里配置了每日自动校验扩展与PHP版本的兼容性,一旦某次后台更新PHP版本补丁导致ABI变化,Bolt会拒绝继续加载旧扩展,表现就是上午正常、下午全部undefined function。排查方式不是去看PHP日志,而是直接看Bolt的运行日志,它通常会明确写出“ABI mismatch”或“extension disabled due to version mismatch”。
平时做版本升级时,要有意识地先停掉Bolt的自动策略或者先做扩展回归测试,否则一次性大批量升级PHP后,业务会瞬间不可用。我自己就把升级步骤固定成:先全量备份CGI配置目录,再升级PHP包,再逐项查看Bolt日志确认扩展加载结果,最后跑一遍冒烟测试。
5. 实用经验与后续扩展建议
CGI模式最颠覆认知的一点是:它没有真正的“运行池”,进程生命周期完全由外部控制,因此它的资源模型更贴近“每次执行即销毁”。这让它在安全性上优于常驻FPM,在稳定性上却弱于FPM。如果你的核心目标是隔离不同站的扩展环境,CGI模式配合Bolt效果很好;如果你的核心目标是高并发吞吐,FPM仍然是更合适的选择。
我在模拟项目X的对比测试里得到一个数据参考:同一台2核4G的服务器,跑WordPress类应用,FPM动态模式在并发200时响应时间开始爬升,CGI模式(4个子进程)在并发30时响应时间就出现明显抖动。这印证了CGI模式的定位是兼容性和隔离性,而不是性能。
如果你后续想扩展,我建议尝试三个方向:第一,按客户站点动态生成Bolt策略文件,实现“同一PHP版本、不同扩展集合”的轻量租户隔离;第二,用CGI模式跑计划任务脚本,用进程守护控制单次任务的超时和重试策略;第三,把CGI入口继续作为新PHP版本小流量试运行的环境,验证扩展兼容性后再整体迁移到FPM。
我在真实操作过程中最大的体会就是:模式本身没有绝对好坏,就看你能不能用工具把它的生命周期、扩展加载、资源边界管住。Bolt那套统一管理扩展的策略放到CGI模式里,第一次配置时要多花半小时,但后续遇到环境变更时,节省的排查时间远远大于这点投入。最后再分享一个小经验:CGI模式的配置目录一定要纳入版本管理,别只备份php.ini,要把整个/etc/php/8.0/cgi/目录都纳入备份和审计范围,否则环境漂移后很难追溯改动来源。