PHP-FPM 与 CLI 的 disable_functions 不对称:SSH 实测通过不代表线上能用
某天在给一个后台管理系统加「读取最新 cron 日志时间戳」的功能。逻辑很简单:SSH 到服务器,用 shell_exec 跑一句 grep | tail -1,本地测试、SSH 直接调 CLI 全部 OK,信心满满部署上线。
结果第一次 curl 接口就返回 500,日志只有一行冷冰冰的:shell_exec() has been disabled for security reasons。
现象
ssh root@server 'php -r "echo function_exists(\"shell_exec\");"'输出1- 直接
grep disable_functions /www/server/php/82/etc/php-cli.ini看不到shell_exec - 但 Web 请求到 PHP-FPM 处理的接口,立刻 500
根因
PHP 同一版本下,CLI 和 FPM 使用两份完全独立的 php.ini:
CLI → /www/server/php/82/etc/php-cli.ini
FPM → /www/server/php/82/etc/php.ini ← web 请求走这里
生产环境 PHP-FPM(宝塔 / 通用 LNMP 一键包)默认会在 FPM 的 ini 里禁用一大批"危险函数":
passthru, exec, system, shell_exec, popen, proc_open,
putenv, chroot, chgrp, chown, pcntl_exec,
ini_alter, ini_restore, dl, openlog, syslog,
readlink, symlink, imap_open, apache_setenv, pcntl_*
而 CLI 模式默认不禁,因为 CLI 通常用来跑批处理任务,需要这些能力。
php -r 'function_exists("shell_exec")' 跑的是 CLI 模式,读的是 php-cli.ini,与 FPM 的配置毫无关系。这个命令的返回值对"web 接口能不能用这个函数"没有任何参考价值。
正解
验证某函数在 FPM 路径是否可用,有三种方式,任选其一:
1. 直读 FPM ini(最快)
# 宝塔通用路径(注意是 php.ini 不是 php-cli.ini)
ssh root@server "grep '^disable_functions' /www/server/php/82/etc/php.ini"
# Debian/Ubuntu apt 安装
ssh root@server "grep '^disable_functions' /etc/php/8.2/fpm/php.ini"2. webroot 探测脚本(100% 还原 FPM 现场)
<?php // /www/wwwroot/yoursite/probe.php ← 用完必须立刻 rm!
header('Content-Type: text/plain');
foreach (['shell_exec','exec','system','passthru','proc_open','popen'] as $f) {
echo "$f: " . (function_exists($f) ? 'available' : 'DISABLED') . "\n";
}
echo 'sapi: ' . PHP_SAPI . "\n"; // 应输出 fpm-fcgicurl https://yoursite.com/probe.php
ssh root@server "rm /www/wwwroot/yoursite/probe.php"3. 临时 controller endpoint
public function probe() {
return $this->success('ok', [
'sapi' => PHP_SAPI,
'disabled' => ini_get('disable_functions'),
]);
}绝大多数"只是要读文件"的场景完全不需要 shell_exec,PHP 原生文件操作既快又不受 disable_functions 限制。
实战热修:替换 shell_exec 读日志最后一行
// ❌ 原版(FPM 下报 disabled)
$line = trim(shell_exec("grep -E '^\\[' $log | tail -1"));
// ✅ 替代(FPM 完全可用)
$lines = @file($log, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
for ($i = count($lines) - 1; $i >= 0; $i--) {
if (preg_match('/^\[(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\]/', $lines[$i], $m)) {
return $m[1];
}
}常见等价替换速查:
| 想用 | PHP 原生替代 |
|------|-------------|
| shell_exec("grep PATTERN file") | preg_grep('/PATTERN/', file($f)) |
| shell_exec("tail -n file") | file() 反向遍历 |
| exec("ls dir") | scandir($dir) 或 glob($dir.'/*') |
| proc_open 启子进程 | 消息队列 / curl 调内部 endpoint |
排查清单(FPM 路径出现莫名 500 时)
- 先看错误日志有没有
has been disabled grep '^disable_functions' /www/server/php/$(php -r 'echo PHP_MAJOR_VERSION.PHP_MINOR_VERSION;')/etc/php.ini- 确认调用链中每个 PHP 内置函数都不在禁用列表
- 凡用了
shell_exec / exec / system / passthru / proc_open / popen→ 视为 FPM 高危,必须用 webroot 探测脚本二次核实
一句话外卖
PHP 的 CLI 和 FPM 读的是两份 ini,SSH 实测永远只能证明 CLI 路径,验证 FPM 行为必须用 web 请求触发。