返回博客

Root Cron 污染 PHP-FPM 共享 runtime——看板绿灯、业务全断 38 小时

那天运营找过来,说某个推送功能"今天所有人都没收到"。我第一反应是看监控看板——24 小时失败 0 条,上次心跳时间显示正常。我差点认为运营搞错了。

好在我没相信看板。一条 SELECT MAX(sent_at) FROM xxx_send_log 打出来,最后一行是 38 小时前。看板在说谎。

现象

  • cron 任务上线后最初几小时一切正常,业务表持续有写入
  • 某个时间点之后业务表彻底停写,无任何 5xx,无报警
  • 后台看板仍显示「24h 失败 0 条」「最近一次成功:X 分钟前」
  • ls -la runtime/cache/ 发现大量 root:root 拥有的文件

根因

这个坑是三层机制叠加造成的,缺任何一层都不会这么隐蔽:

crontab 默认以 root 身份运行(除非显式 -u 指定用户)
↓
0 2 * * * cd /www/wwwroot/project && php think xxx >> runtime/log/cron.log 2>&1
                                                      ↑
                                           重定向是 root 打开的文件描述符
↓ 触发三路 root 写入:
  1. think 命令业务逻辑写 cache(如 access_token 缓存文件)
  2. STDOUT/STDERR 重定向落 runtime/log/cron.log
  3. ThinkPHP Log facade 内部 fwrite 写 runtime/log/202505/xx.log
↓
TTL 过期前:PHP-FPM (www) file_get_contents 能读 root:root 0644 文件 → 业务正常
TTL 过期那刻:PHP-FPM (www) file_put_contents 试图覆写 → Permission denied (EACCES)
             → PHP fatal error
             → 上层 catch 吞掉 → 不写业务日志 → 看板看不到失败
             → 业务静默全断

为什么这个坑极难发现:

  1. TTL 滞后:cron 跑完后不会立即崩,而是等 cache 过期才崩,与部署时间的关联性丢失
  2. catch 静默化:框架层 catch 只写 Log,不写业务表,看板由「业务表写入」驱动,所以永远成功
  3. 三路污染:cache + log STDOUT + log facade 三路同时被 root 污染,只治一路不够
坑:重定向写在 sudo 外层,cron.log 还是 root 拥有
# 看起来修了,其实没修——重定向仍是 root 打开 fd
0 2 * * * sudo -u www -- php /path/to/think xxx >> runtime/log/cron.log 2>&1

外层 shell 在 cron 里是 root,>> 由 root 打开文件描述符,即使进程切换到 www,cron.log 这个文件本身的属主依然是 root。下次 www 想续写就 Permission denied。

正解

治本:把重定向包进 bash -c 内层

# 正确写法:整段命令(含重定向)都在 www 身份下执行
0 2 * * * sudo -u www -- bash -c 'cd /www/wwwroot/project && php think xxx >> runtime/log/cron.log 2>&1'

如果环境没有 sudo(部分裸 CentOS),改用:

0 2 * * * su www -s /bin/bash -c 'cd /www/wwwroot/project && php think xxx >> runtime/log/cron.log 2>&1'

注意 -s /bin/bash,防止 www 用户默认 shell 是 nologin 导致 su 失败。

最干净的方式是直接在 www 用户的 crontab 里加任务:

sudo crontab -u www -e

上线前必须先清存量污染

只改 cron entry 不够,历史已写入的 root 文件还在。TTL 没过期前 www 还能读,过期那刻照样崩。

# 1. 整棵 runtime 树 chown 回 www
sudo chown -R www:www /www/wwwroot/project/runtime/
 
# 2. 验证干净(应该输出 0)
sudo find /www/wwwroot/project/runtime -uid 0 | wc -l
 
# 3. 删除 cache 让 www 重新生成
sudo rm -rf /www/wwwroot/project/runtime/cache/*
 
# 4. 如果 cron.log 也是 root 拥有,删掉让 www 重新创建
sudo rm /www/wwwroot/project/runtime/log/cron.log

ThinkPHP 5/6 的 runtime 树还包括 temp/session/lock/,全树 chown 一次最稳。

正解:三路同治——cron 改身份 + 清存量 + 加业务表写入告警
  • cron entry 用 bash -c '...' 把重定向包进 sudo 内层
  • 上线前 chown -R www:www runtime/ + 删 root 残留文件
  • 给业务关键表加「N 分钟无写入则告警」,不依赖「成功路径写日志」的看板

反模式备忘

  • chmod 0666 临时扩权:下次 cron 跑完又变 root:root,没用
  • 只 chown 不改 cron:下次 cron 再污染回来
  • 只改 cron 不清存量:TTL 过期前一切正常,过期后又崩
  • 以为只污染了 log 没污染 cache:两路一起看

一句话外卖

root cron 跑 PHP CLI 时,STDOUT 重定向、Log facade、业务 cache 三路同时以 root 落盘;把整段命令用 bash -c '...' 包进 sudo -u www 内层,上线前清存量,否则等 cache TTL 过期你才会知道它悄悄断了多久。