logrotate 沉默地失败了数周:一个 `|| true` 如何藏起 3 个 bug 并停掉了所有日志轮转
发布于 2026年6月23日

症状:磁盘在涨,日志不轮转
一台服务器的磁盘无明显原因地缓慢上涨。深入排查后发现 /var/log 里的日志不再轮转:几周的旧文件,有的已达数 GB,却没有平常的 .1、.2.gz 后缀。logrotate 已安装、已排程,而且——按 systemd 的说法——"在运行"。是在运行,也在失败。
棘手的细节是:logrotate 按顺序处理配置文件,一旦遇到致命错误就返回退出码 1。对调度器(cron / systemd timer)来说,这只是"该单元失败了"——没有告警,没有呼叫。而由于单个 stanza 的错误会污染整个 run 的状态,给人的印象是"什么都没轮转",实际上只是几个损坏的配置毒化了结果。
让沉默现形:以 debug 模式运行 logrotate
logrotate 默认不会大声报错。但 debug 模式(-d,即 dry-run)加 verbose(-v)能精确显示它在哪里绊倒,且不改动任何东西:
# -d = debug/dry-run(不写入任何内容),-v = verbose
sudo logrotate -dv /etc/logrotate.conf 2>&1 | less
# 只看错误:
sudo logrotate -dv /etc/logrotate.conf 2>&1 | grep -iE "error|warning|skipping|duplicate"结果跳出三个不同的错误,来自三个相互独立的原因。每一个单独都足以返回退出码 1。
Bug 1:两个 include 目录之间的重复配置
主配置 include 了两个目录:标准的 /etc/logrotate.d/ 和一个内部自动化目录 /opt/ops/logrotate.d/。两者对相同文件(syslog 和 AIDE 日志)都有 stanza。logrotate 把同一个 logfile 被声明两次当作致命错误:
error: /opt/ops/logrotate.d/aide:1 duplicate log entry for /var/log/aide/aide.log修复:一个 logfile,一个归属。我们从自动化目录里删掉重复项,让每个文件只在一个 stanza 中。简单的规则:如果你 include 了多个目录,确保它们互不重叠。
Bug 2:logrotate 拒绝处理的 world-writable 目录
PHP-FPM 日志目录的权限是 drwxrwxrwx(777)。出于安全,logrotate 拒绝轮转任何人都可写目录中的文件——除非你通过 su 指令明确告诉它以哪个用户/组运行:
error: skipping "/var/log/php-fpm/error.log" because parent directory has insecure permissions
(It's world writable or writable by group which is not "root") Set "su" directive in config file
to tell logrotate which user/group should be used for rotation.有两条路:修正目录权限(更好的做法)或在 stanza 中声明 su。我们两者都做了——把权限收紧到 755,并加上该指令作为兜底:
/var/log/php-fpm/*.log {
su root adm
weekly
rotate 8
compress
missingok
notifempty
}Bug 3:即使有 missingok 也致命的空 glob
最微妙的坑。一个 stanza 指向 /var/log/aide/*.log。目录存在但为空——没有文件匹配该 glob。直觉会说"我有 missingok,所以没事"。错了:missingok 覆盖的是缺失的字面路径,而一个什么都匹配不到的 glob,在很多版本的 logrotate 中被当作致命错误:
error: /opt/ops/logrotate.d/aide:1 glob failed for /var/log/aide/*.log由于 AIDE 还没在该路径生成日志,整个 stanza 就在那里等着失败。修复:在文件存在之前删掉该 stanza,或指向正确的字面路径。乐观的 glob + 空目录 = 沉默的退出码 1。
原因背后的原因:让一切失明的 `|| true`
即便轮转恢复正常,我们仍需弄清为什么之前没人发现问题。答案在某个 stanza 的 postrotate 里——一个运行 Python 脚本、把日志指标上传到 bucket 的钩子:
postrotate
/usr/bin/python3 /opt/ops/ship-logs.py >/dev/null 2>&1 || true
endscript那个 >/dev/null 2>&1 || true 把所有输出丢进垃圾桶,并无论如何都强制成功。手动运行脚本,被藏起来的东西立刻现形:
$ /usr/bin/python3 /opt/ops/ship-logs.py
Traceback (most recent call last):
File "/opt/ops/ship-logs.py", line 3, in <module>
import pandas as pd
ModuleNotFoundError: No module named 'pandas'Python 环境在某次升级中被重建,丢失了依赖。脚本每次运行都失败——但 || true 吞掉了错误,postrotate 就"通过"了。修复:重装依赖,并停止掩盖错误:
pip install pandas pyarrow boto3
# pandas 2.3.3, Python 3.9.18
# 并在 postrotate 中:让错误浮现(logrotate 会记录钩子的失败)
postrotate
/usr/bin/python3 /opt/ops/ship-logs.py
endscript|| true 不是错误处理——它是错误抑制。只在失败确实无关紧要时使用,并且永远不要把它和同样抹掉消息的 2>&1 粘在一起。否则你是在构建一种沉默,它会在某个糟糕的日子让你付出高昂代价。
如何在轮转咬到你之前测试它
对 logrotate 配置做任何改动后,不要等凌晨 3 点的 cron,直接验证:
# 1. dry-run:显示它会做什么,不写入
sudo logrotate -dv /etc/logrotate.conf
# 2. 强制真正运行一次并检查退出码
sudo logrotate -fv /etc/logrotate.conf ; echo "exit: $?"
# 3. systemd 中 timer 的健康状况(失败不会自动变成告警!)
systemctl status logrotate.timer
journalctl -u logrotate.service --since "yesterday" | grep -i error那里一个非零的退出码是你唯一的预警。如果没人盯着这个退出码,失败就一直隐形,直到磁盘被填满。
经验教训
1. 一个损坏的 stanza 会污染整个 run。logrotate 在第一个致命错误时返回退出码 1;对 cron/systemd 来说只是"失败了",不提示是哪个配置。把退出码当作可监控的信号。
2. missingok 不覆盖空 glob。缺失的字面路径:没事。匹配不到任何东西的 glob:致命错误。不要为还不存在的日志创建乐观的 stanza。
3. world-writable 目录需要 `su`(或正确权限)。出于安全,logrotate 拒绝轮转 777 目录中的日志。收紧权限,并/或声明 su user group。
4. `|| true` 配 `2>&1` 是一颗定时炸弹。它同时藏起失败和消息。如果一个钩子可能以重要的方式失败,就让错误浮现并被记录。
当绿色信号说谎时,这个模式会在各层重复出现——比如 WAF 健康但站点返回 500。仪表说"正常";现实并非如此。
结论
这三个 bug 没有一个是难的——一个重复 include、一个过松的权限、一个空 glob。让它们变危险的,是它们与一个抹掉唯一证据的 || true 之和。基础设施运维在很大程度上就是拒绝便利的沉默:让错误今天就在 dry-run 中浮现,而不是三周后自己冒出来——那时磁盘已 100%,服务正在宕机。