POSCMS 在 IIS + PHP 5.6 下 php-cgi 进程持续暴涨的排查与处理

老 POSCMS 站点在 IIS + PHP 5.6 上 php-cgi.exe 进程不断暴涨并周期性拖垮 MySQL。根因不是程序主动起了多进程,而是「每请求无条件启动文件 session + 7×24 扫描机器人流量 + Windows 下 session GC 极慢」三者叠加。

现象与结论

Windows 服务器(IIS + FastCGI + PHP 5.6)上的一个多年未升级的 POSCMS 站点,IIS 站点一启动 php-cgi.exe 进程就不断增多,伴随偶发数据库连接失败。

PHP 5.6 本身不会 fork,多个 php-cgi.exe 是 FastCGI 为并发请求分配进程槽位的正常机制。真正的问题是:每个请求都可能被拖住最长 60 秒释放不掉,而站点被扫描机器人 7×24 轰炸,请求不断堆积,IIS 只能不停拉新进程接客。把请求拖死的元凶是 PHP 文件 Session 的垃圾回收(GC)。

排查过程

先搜 pcntl_fork/exec/fsockopen 等调用,确认程序没有主动起进程,也排除了 POSCMS 自带的任务队列(SYS_CRON_QUEUE 为关闭)和附件同步的自我 HTTP 调用(未启用),顺手扫了一遍无 webshell。

决定性证据来自 cache/errorlog/ 的运行日志:从两年前持续到排查当天,几乎每天都是同一种致命错误,最重的日子一天 200+ 条:

ERROR --> Maximum execution time of 60 seconds exceeded
C:\wwwroot\<站点目录>\diy\system\libraries\Session\drivers\Session_files_driver.php 380
----> http://<域名>/index.php/xin/2023-11-05 14:37:57/ben/amp/a/xxxxxxx.ppt

报错行号 375/380/387 全落在 Session_files_driver.php 的 gc() 里——遍历整个 session 目录逐文件 filemtime+unlink 的循环。请求卡在"清扫 session 目录"上整整 60 秒,被入口文件的 set_time_limit(60) 强杀。一天 200 次即 200 分钟被记录在案的卡死,且凌晨也在持续(机器人不睡觉)。

session 目录为什么这么大,三个条件叠加:

  1. POSCMS 基础控制器对每个请求无条件 $this->load->library('session');
  2. 文件驱动写入 cache/session/;
  3. 机器人不带 cookie,CI3 对新访客必定写一个 session 文件(文件名 = cookie 名 + 会话 ID),只能等 GC 删。

目录膨胀到数万文件后,PHP 默认约 1% 的请求触发 GC 全目录扫描,Windows/NTFS 上逐文件操作极慢(再叠杀软更甚)。这里有个 Windows 特有的坑:max_execution_time 在 Windows 下算墙钟时间(含 IO 等待),所以这种 IO 型卡死在 Linux 上往往不触发超时,在 Windows 上必然 60 秒被杀。

请求侧还有一个细节:日志里被卡死的全是 /index.php/xxx.ppt 这类扫描 URL,而 POSCMS 的伪静态解析把 index.php 开头的 URI 一律置空——这类请求全部被当首页处理,每个垃圾 URL 都完整跑一遍 CMS + MySQL + session,程序没有廉价的 404 出口。

连锁反应随之而来:并发 php-cgi 滚起来后 MySQL 连接被拒(日志成串的"由于目标计算机积极拒绝"),其他请求又在等连接超时,进程进一步堆住。

机器人 7×24 轰炸(无 cookie)
  → 每请求完整跑 CMS + 生成一个 session 文件
  → 目录膨胀到数万文件
  → 1% 请求触发 GC 全目录扫描,60 秒被强杀
  → 每个 GC 请求钉住一个 php-cgi 一分钟,新请求还在涌入
  → IIS 不停拉新进程,进程数暴涨,MySQL 被拖垮

修复方案

机器人永远不携带 session cookie,真人在第一次 POST/登录/验证码后就会带上。改前需把代码里所有 session 使用点过一遍,本例只需动三处。下表行号为本站代码的原始行号,套到其他 POSCMS 站时若版本有偏移,按"定位方法"列搜索即可:

#文件位置定位方法
①diy/poscms/core/Common.php第 126 行搜索 library('session')(全文件唯一一处)
②diy/poscms/model/Attachment.php第 18 行(__construct() 内)搜索 userdata('siteid')
③diy/poscms/model/Member.php第 1113 行之后(member_uid() 内)搜索 function member_uid,找到 if (!$uid) { return NULL; } 的闭合 }

① Common.php 第 126 行(基础控制器构造函数内,define('SYS_ADMIN_PAGESIZE', ...) 下方):把 $this->load->library('session'); 这一行替换为:

if (IS_POST || IS_ADMIN || IS_MEMBER
    || isset($_COOKIE[config_item('sess_cookie_name')])
    || strtolower((string)$this->input->get('m')) == 'captcha'   // 验证码图片
    || strtolower((string)$this->input->get('m')) == 'aslogin'   // 后台免登录握手
    || $this->input->get('auth_code')) {                          // 移动端api授权
    $this->load->library('session');
}

注意 IS_MEMBER 在 Init/Rewrite 阶段恒有定义(非会员为 FALSE),判断要用常量值而非 defined('IS_MEMBER')(后者恒真)。

② Attachment.php 第 18 行(附件模型构造函数,全站唯一无条件下读 session 的点,而附件模型每个内容页都会实例化):把

$this->siteid = (int)$this->session->userdata('siteid');

替换为(紧随其后的 SITE_ID 兜底行保持不变):

$this->siteid = $this->load->is_loaded('session') ? (int)$this->session->userdata('siteid') : 0;

③ Member.php 第 1113 行之后(member_uid() 方法内):处理"记住登录"用户 session 过期(2 小时)后回访的边界情况。在该方法里 if (!$uid) { return NULL; } 的闭合 } 与 if (!$this->session->userdata('uid')) 之间插入一行:

$this->load->is_loaded('session') || $this->load->library('session');

其余调用点天然安全:无 cookie 机器人走不到 session 读取就提前返回;评论防灌水等都是 POST;OAuth 回调在会员域被 IS_MEMBER 覆盖。上线后用无痕窗口回归:前台页面不再新增 sess 文件,登录/验证码/评论/后台正常。

方案二(治流量):IIS 层把机器人挡在 PHP 之前

利用"/index.php/* 被当首页处理、对站点毫无价值"这一事实,URL Rewrite 整段 404(正常链接是 /index.php?c=xx 格式,不受影响),加在站点 web.config 的 <rewrite> 节点内:

<rule name="blockPathInfoJunk" stopProcessing="true">
  <match url="^index\.php/.+" ignoreCase="true" />
  <action type="CustomResponse" statusCode="404" statusReason="Not Found" />
</rule>

再开 IIS 自带的动态 IP 限制,把高频扫描源直接封掉。先执行两条前置命令(装角色服务 + 解锁站点级配置,不解锁的话 web.config 会报 500.19):

Install-WindowsFeature Web-IP-Security
%windir%\system32\inetsrv\appcmd unlock config -section:system.webServer/security/dynamicIpSecurity

然后在同一个 web.config 的 <system.webServer> 内、与 <rewrite> 并列加上:

<security>
  <!-- 单IP并发超过10个请求、或1秒内超过30个请求即拒绝 -->
  <dynamicIpSecurity denyByConcurrentRequests="10"
                     denyByRequestRate="30"
                     requestRateInInterval="1000"
                     denyAction="Forbidden"
                     enableProxyMode="false">
  </dynamicIpSecurity>
</security>

套了 CDN/反代的站必须把 enableProxyMode 改为 true 并放宽阈值,否则所有访客都记在代理 IP 一个头上,会把代理整个封掉。

宝塔 Windows 面板用户:面板只是壳,底层仍是 IIS,最终都落到站点根目录 web.config——URL 重写用"网站 → 设置 → URL重写"或文件管理直接编辑(先备份);动态 IP 限制按上面命令操作;session 定时清理用面板的 计划任务跑 forfiles 删一天前的旧文件(注意 CI3 文件前缀是 cookie 名如 poscms_ci_session*,不是 sess_*)。

配套参数(php.ini)

session.gc_probability = 0 禁用请求时 GC(靠计划任务清理),这是消灭 60 秒卡死最见效的一条。另外建议入口文件里 memory_limit 从 1024M 降到 256M、set_time_limit 从 60 降到 30。

验证清单

/index.php/anything 返回 404;站点日志出现 403.501/403.502(IP 限制生效);php-cgi.exe 数量回落;cache/session/ 不再增长;错误日志里 Session 超时基本消失;前台/登录/后台回归正常。

复盘要点

  • 多 php-cgi 进程 ≠ 程序在起进程。进程数暴涨几乎总是"请求变慢 + 流量不退",要找的是什么把请求拖住了;
  • “每请求无条件 session + 文件驱动"在 Windows 上有放大效应:墙钟超时 + NTFS 小文件遍历慢 + 杀软,GC 慢到荒谬且每次钉死一个 FastCGI 进程一分钟;
  • 排查这类问题,错误日志里致命错误的文件名 + 行号比通读代码快得多;
  • 无 cookie 访客跳过 session 是普适模式:机器人在入口就被识别,真人首次需要 session 时才创建;
  • 挡扫描流量优先在 Web 服务器层静态拒绝,让恶意请求的成本从"完整跑一遍 PHP + MySQL"降到一次内核态 404。