凌晨两点,手机连着震了三下。我迷迷糊糊划开屏幕,看到监控面板上某业务服务A所在主机的磁盘使用率已经冲到98%,再过半小时就得写满。这种告警一年多少要碰上几回,但这次有点反常——前天才刚做过一次大清理,磁盘明明还剩60%空间,怎么一夜之间又满了?爬起来登录服务器,df -h一看,/var/log 目录像被什么东西一口气灌满了。顺着du -sh /var/log/*查下去,一个熟悉的 app.log 文件已经膨胀到 80 多G,还在以肉眼可见的速度增长。
这是一次极其典型的日志增长吞掉磁盘的事故,也是我处理过的案例里最值得写下来复盘的一次。整个过程涉及文件句柄、日志轮转机制、inode 耗尽、journald 独立治理等多个环节,每走一步都踩到了常规文档里不会写清楚的坑。这篇就按照我当时完整排查到收尾的顺序,把定位思路、logrotate 配置细节、后续监控改进全部掰开讲一遍,希望能帮同样被日志打爆磁盘的人少走一点弯路。
1. 磁盘告警那晚:从 100% 占用到定位日志元凶
1.1 告警现场与第一波排查命令
进了服务器之后,我没有直接去看日志目录,而是先做了一套标准的三连排查,这个顺序很重要,能帮你快速判断是"文件占空间"还是"被删除文件未释放空间"。
# 第一看:磁盘整体情况 df -h # 第二看:哪些目录占了大头 du -sh /var/log/* 2>/dev/null | sort -rh | head -20 # 第三看:有没有已经被删除但仍旧被进程占用的文件 lsof | grep deleted | head -30第一眼df -h显示/根分区已经满到 98%,/var 挂载点同样告急。du -sh /var/log/*的结果里,app.log 独占 80G 多,简直像个小水库。第三项lsof | grep deleted没有输出,说明不是"删了没释放"的经典问题,磁盘空间确实是被这个文件真实占据了。
我当时的第一反应是直接删掉 app.log 先止损。幸好手停了一下,因为直接rm是有隐患的——如果那个应用进程还在持续写入这个文件,删除之后空间并不会立刻释放,只有句柄关闭后空间才能回来,而且文件会变成"幽灵文件",后续日志轮转也没法正常工作。正确做法是先确认哪个进程在写,再决定怎么处理。
1.2 为什么日志会在一夜之间吃掉半个磁盘
八十多G一个文件,光靠正常业务日志很难堆到这种量级。我先看了写入进程,确认是服务的主进程在持续往 app.log 里追加数据。然后我用tail -n 200 app.log扫了一眼日志内容,发现几乎全是同一类报错刷屏,每隔几百毫秒就喷几条堆栈信息,每条一两KB。算一笔账:假设平均每秒写入 30 条、每条 1.5KB,一天就是 30 × 1.5KB × 86400 秒,约等于 3.8GB 左右。但如果应用本身遇到某种故障循环,比如连接池反复重试、某个下游接口持续超时,日志频率可以翻几倍——每秒写个几十条甚至上百条都很正常,80G 一晚上完全可能。
这里要引出第一个容易被忽略的"为什么":为什么排障的时候日志会突然这么猛?排查后发现,是服务在凌晨被流量波动触发了一个异常分支,某个模块进入了死循环式重试,每次重试都带着完整参数和堆栈打日志。代码里没有做频率限制,也没有熔断降级,日志就变成了无底洞。
单纯把日志文件删掉或者清空,外部流量还在涌入,问题根源没解决,日志会立刻再次膨胀。所以我的处理分两步:第一步,暂停这个异常模块的流量或者重启进程,让日志喷涌先停下来(止损);第二步,才是把现有大文件归档、清理、配置轮转(恢复和预防)。
这个案例充分说明了一个核心原则:日志治理的第一优先级是控制日志产生速率,而不是控制日志存储空间。没有这个认知,你做再多轮转配置也只是把爆炸时间点往后推。
2. 日志轮转的底层逻辑:rotate、compression 与延迟删除
2.1 logrotate 的触发链路
止损之后,磁盘虽然腾出来了,但我知道不把轮转机制补齐,这种事迟早还会重演。Linux 生态里处理日志轮转的标准工具就是 logrotate,绝大多数发行版都默认安装了它。它的工作机制并不复杂:系统每天或每周定时触发一次 logrotate 主流程,主流程读取/etc/logrotate.conf和各应用放在/etc/logrotate.d/下的碎片配置,然后按每个配置里声明的规则,对日志文件执行重命名、压缩、删除旧文件等动作。
一条日志文件从产生到被轮转掉,完整生命周期大致是这样:
- 应用把日志写入 app.log;
- logrotate 到点被触发,把 app.log 重命名成 app.log.1;
- 应用继续写入一个新建的 app.log(或者继续写被重命名的文件,取决于轮转模式);
- 下一个周期到了,app.log.1 变成 app.log.2,新的 app.log 又重命名成 app.log.1;
- 设置保留 N 份,那么第 N+1 份会被自动删除,或者压缩之后删除。
这里的核心参数是rotate N、daily/weekly/size、compress、delaycompress和copytruncate。我见过不少配置文档,大多只是在写参数是什么,但真正决定轮转行为成败的,恰恰是这些参数之间的组合关系,尤其copytruncate和create的选择,搞错了会引出完全不同的坑。
2.2 copytruncate 与 create 的权衡
logrotate 有几种工作模式,最常见的是create和copytruncate。
create模式是"重命名加重建"。logrotate 先把 app.log 重命名为 app.log.1,然后在原位置新建一个空的 app.log,接着信号通知应用重新打开日志文件。这套逻辑干净利落,文件句柄指向新文件,不会丢数据。但前提是应用必须能响应信号并重新打开文件。很多应用没有做这个处理,结果就是:文件被重命名了,进程仍然握着旧文件句柄继续往 app.log.1 里写,新的 app.log 反而是个空壳。日志越轮转越乱,排查问题的时候你都不知道数据到底在哪个文件里。
copytruncate模式就粗暴不少。它先把 app.log 复制一份成 app.log.1,然后通过truncate把原 app.log 截断为 0,应用之前持有的文件句柄不失效,继续往同一个 inode 里写,但这个 inode 指向的文件已经被清空了,所以不会出现"数据写到旧文件"的问题。
代价也很明显:复制和截断之间存在一个时间窗口。如果日志写入非常频繁,这个窗口内的日志会丢失,虽然通常只有毫秒级或者几十毫秒级的量,但对日志完整性要求极高的场景需要评估这个损失。另外copytruncate在复制大文件时会占用大量 I/O,一个 80G 的文件去复制,CPU 和磁盘的瞬时压力都非常大,这一点在生产环境里必须提前考虑。
我当时那套场景,进程是一个自己封装的 Java 服务,虽然理论上它响应了 USR1 信号就能重新打开文件,但由于进程是通过一个脚本启动的,信号转发链路没打通,用create模式反而容易出岔子。稳妥起见,我选择了copytruncate。如果你要问我经验:除非你能确认应用会响应信号并重开日志文件,否则生产环境优先用copytruncate,日志丢掉的那一点点窗口数据,比你整个日志体系混乱要划算得多。
3. 让轮转配置落地:一份可以直接抄作业的配置
3.1 配置逐行拆解
下面这份配置是我在多个环境里验证过的写法,适用于大多数"单进程写单个日志文件"的服务,无论是 Java、Go、Python 还是 Node 都通用。你只需要把/data/logs/app/app.log替换成你实际路径。
# /etc/logrotate.d/app /data/logs/app/app.log { daily rotate 7 maxsize 500M missingok notifempty compress delaycompress copytruncate dateext dateformat .%Y%m%d-%H%M%S extension .log su root root }逐个解释一下,因为照着抄是小事,理解了随时能改才是关键。
daily表示每天轮转一次。配合后面的maxsize,实际规则是"每天至少轮转一次,但如果文件提前达到 500M,立即触发轮转"。这个组合非常适合日志量不稳定、可能突然暴涨的服务。rotate 7表示保留最近 7 份轮转文件。加上昨天的原始文件,一共 8 份,配合压缩策略,磁盘占用基本可控。missingok是指文件不存在时不要报错退出,避免日志服务没启动时长目录屁用没有却天天报警。notifempty是文件为空时不轮转。防止某些低频服务在日志没有任何内容时也频繁触发轮转。compress开启压缩,默认使用 gzip。80G 文本日志压缩完可能只有几G,效果立竿见影。delaycompress会推迟一份再压缩。它的意义在于:很多服务可能会在轮转后继续往 app.log.1 写入一点尾部内容(比如旧句柄的残留数据),延迟一天压缩能把这些零碎内容也收进去,避免丢失最后那几百毫秒的日志。这个参数配合copytruncate尤其重要,可以弥补复制截断窗口期的数据损失。copytruncate前面讲过,这是保障"进程不需要重启"的关键参数。dateext给轮转文件加上日期后缀,而不是默认的.1、.2。这样文件名看起来是 app.log.20250516-023000.log,查找某一天的日志非常直观,而且避免了文件重命名时同名覆盖的风险。su root root是指定 logrotate 以 root 身份执行文件操作。如果你的日志目录属主是别的用户,请改成su 用户名 用户组,否则会出现权限不足的诡异问题。
3.2 手动强制执行与验证闭环
配置写完后不要干等第二天,先手动执行一次验证语法和权限是否正常。这个动作叫"试运行",很多第一次用 logrotate 的人会漏掉:
# 先看配置语法是否正确 logrotate -d /etc/logrotate.d/app # 再强制执行一次(-f 强制忽略 daily 等时间条件) logrotate -vf /etc/logrotate.d/app-d是 debug 模式,会打印出 logrotate 准备执行的动作,但不会真实执行。确认无报错后,再加-v -f强制执行。执行完立刻去看日志目录:
ls -l /data/logs/app/看到类似这样的输出就说明轮转闭环成功:
-rw-r--r-- 1 root root 500M May 16 02:30 app.log -rw-r--r-- 1 root root 480M May 15 02:30 app.log.20250515-023000.log -rw-r--r-- 1 root root 460M May 14 02:30 app.log.20250514-023000.log.gzapp.log已经是新建的 0 字节文件,应用进程还在正常运行没有重启,app.log.20250515-023000.log还没被压缩(昨天的,delaycompress 生效),更早的已经变成.gz。这就对了。
这里我想额外分享一个经验:配置完轮转之后,一定要用日志量最大的一天来验证压缩效果和保留策略。我曾经在另一个项目上配置了rotate 3,当时日志每天才 200M,觉得三个文件绰绰有余。结果一个月后业务翻了几倍,日志量涨到每天 1G 多,三个压缩文件加原始文件都快把 /var 撑爆了,被迫紧急调整保留份数。所以配置数字不是拍脑袋定的,你可以这样估算占用:
保留文件总大小 = 当前原始日志大小 × (1 + 1/压缩比 × rotate 份数)如果原始日志每天 500M、gzip 压缩比大约 10:1、rotate 7,那么磁盘占用大约是 500M + 7 × 50M = 850M 左右,在可控范围内。日志量大的服务可以把rotate调小、把maxsize调低,甚至把daily改成按小时轮转,核心原则是"总占用不能超过你预期的水位"。
4. 轮转之外的隐性坑:inode、journald 与多副本日志
4.1 inode 耗尽比磁盘满更隐蔽
配置好轮转之后,我以为事情告一段落,但正好借着这次机会把同一台服务器上另一类隐患也查了一遍,其中一个就是 inode 耗尽问题。
磁盘空间满和 inode 满经常被混为一谈,但它们其实是两回事。磁盘满意味着数据块不够了,inode 满意味着文件系统里"索引节点"耗尽了,即使磁盘还剩很多空间,你也无法新建任何文件。很多文件系统默认每 16KB 左右的容量才分配一个 inode,如果上面全是几十字节的小文件,很容易把 inode 池耗光。
排查方法很简单:
df -i如果 IUse% 接近 100%,你就知道问题不在空间而在文件数量。有次我在另一个环境就撞上过这种情况:临时目录里堆积了上千万个未清理的 socket 文件,磁盘使用率才 20%,但没有任何进程能创建新文件了,服务全部卡死。场景不同,本质完全一样。
回到日志场景,为什么 logrotate 也会制造 inode 压力?因为轮转本身就是不断创建新文件的过程,每个日志文件都要占一个 inode。如果rotate设置得很大,且轮转频率很高,几天之内就会生成大量小日志文件。加上如果不配置dateext而是用默认编号、某些服务还在同一个目录下按 PID 或者按时间戳拆多个日志文件,inode 消耗就会更加失控。所以建议运维同学把df -i也加到日常巡检脚本里,这个指标和磁盘使用率一样重要,却几乎没人看。
4.2 journald 日志的独立治理
排查那台服务器的时候,我还顺手发现 systemd 日志目录/var/log/journal/也占了几个G的空间。很多部署在 systemd 下的服务如果没配置 stdout 日志重定向,日志会直接进 journald,而不是写到你业务配置的文件里。这意味着你会同时有两套日志体系,业务日志归 logrotate 管,journald 的日志走它自己的配额策略,互不干扰,但空间占用是叠加的。
journald 默认的配额策略曾经有版本差异,某些版本在系统盘大的时候会允许 journal 目录一直涨到跟系统盘容量挂钩的比例,这个值如果没限制,几十个G也不是不可能。限制方式是在/etc/systemd/journald.conf里显式加规则:
[Journal] SystemMaxUse=2G SystemMaxFileSize=200M MaxRetentionSec=14d其中SystemMaxUse=2G是所有 journal 文件加起来最多 2G,SystemMaxFileSize=200M限制单个日志文件大小,MaxRetentionSec=14d表示只保留 14 天的日志。改完配置后记得重启服务:
systemctl restart systemd-journald有同学会担心重启 journald 会不会影响正在跑的业务,一般来说不会,journald 重启后旧的日志文件还在,只是它会按新的配额策略开始清理超龄和超量的日志。这是我强烈建议你在每台 systemd 主机上都加的一段配置,尤其是根分区不大的机器,经常遇到业务日志还没占多少空间,journal 反而把 /var 吃满了这种事。
4.3 不要忽略同一应用的"多写者"问题
最后还有一个非常隐蔽的坑,是我在调整完 logrotate 之后继续观察时才想到的:如果同一个日志文件同时被多个进程写入,轮转的copytruncate模型就会产生"撕裂"问题。
什么叫多写者?比如某个应用通过 supervisor 之类工具被拉起了多个 worker 进程,它们都往同一个 app.log 追加日志。另外,有些部署形态里会有日志采集 Agent 同时读取这个文件并转发到远端。这种情况下 logrotate 复制文件、截断原文件时,其他进程可能正拿着旧的文件句柄或者正在读取某个偏移位置,日志数据就可能在轮转时被切得七零八落,甚至出现"轮转后某几个进程还在往旧 inode 里写几个小时"的现象。
解决思路无非几种:一是从源头统一写日志路径,确保一个进程一个日志文件,不要乱共享;二是如果必须共享,考虑采集层用更完善的文件追踪方案,而不是依赖copytruncate轮转;三是日志量允许的话,让应用自己按大小主动切割日志文件,logrotate 只负责压缩和清理归档文件,业务层切割永远比外部工具周期触发更及时。
这个多写者问题平时不太显眼,但它发生的时候,排障体验非常痛苦。你按时间线查日志,发现中间有将近一个小时的记录出现在昨天轮转走的压缩包里,而今天的文件里却有一段空白,你会误以为业务出了故障。实际上就是文件句柄和轮转之间的错位造成的。所以从第一天设计日志采集方案的时候,就要把"谁在写、谁在读、谁来切"这三件事想清楚,不要都让 logrotate 兜底。
5. 事后复盘:监控阈值与告警分级的调整
5.1 从"磁盘满"到"趋势预测"的告警设计
问题解决后,我做的第一件事是回顾那晚为什么会被动挨打。凌晨两点收到告警,意味着这个状态已经持续了好几个小时,如果早点发现趋势,处理成本会低得多。原来的监控策略是"磁盘使用率超过 90% 才触发告警",但这本质上是一个事态发生后的告警,等它响起来,你能操作的时间窗口已经很窄了。
我把告警策略调整成了三级:
| 级别 | 触发条件 | 响应动作 |
|---|---|---|
| 预警 | 磁盘使用率 24 小时环比增长超过 10% | 检查是否有异常文件增长 |
| 次要告警 | 磁盘使用率超过 75% | 计划性清理、扩容评估 |
| 紧急告警 | 磁盘使用率超过 90% | 立即处理 |
在实际操作里,我还加了两个非常有用的监控维度。第一,实时监控/var/log下最大的三个文件的增长速度,而不是只盯分区使用率。这个指标能直接暴露"哪份日志在异常膨胀",为排障省掉一半时间。第二,给日志目录的文件总数设置告警基线,一旦 inode 数异常飙升就提醒,防止触发 inode 耗尽的次生灾害。
5.2 快速恢复与长尾优化的平衡
回归到事故处理本身,还有一个思路值得做:既然日志量的暴涨几乎总是和业务异常强相关,那么在轮转机制之外,还应该加一道"日志写入限流"的保险。
这个思路说起来也不复杂,很多语言都提供了日志库限流能力。比如 Java 的 logback 可以配置SizeAndTimeBasedRollingPolicy,Python 的 logging 可以做 handler 级别的缓冲和丢弃策略。限流的目标不是丢掉所有日志,而是在异常流量爆发时,保证日志系统自身的稳定性,同时保留足够多的关键上下文用于排障。
我当时的做法是在服务里加了一个"单日日志熔断"配置:如果某个 logger 在 1 分钟内写入了超过设定条数的日志,后续日志进入采样模式,只保留前 N 条和堆栈摘要,其余丢弃或计数。这个策略上线后,那台服务器的磁盘使用率曲线从「偶尔冲高、经常告警」变成了「持续平缓」,效果非常明显。
最后再回到配置本身的一个小细节。maxsize 500M这个值需要结合你服务的实际日志量调整,如果日志每天稳定在 100M 左右,设成 500M 就是空摆设,每天轮转一次足够;如果日志经常在几分钟内冲到几G,maxsize反而是第一道防线,触发得越早越安全。我在另一个约 60 个节点的集群里统一定成了maxsize 200M、rotate 3、daily,加上压缩后,每台机器的日志总占用都控制在 1G 以内,配合上面的三级告警,已经连续跑了大半年没有出过一起日志撑爆磁盘的故障。
这次的整个处理链路,从凌晨被告警叫醒,到定位 app.log 膨胀,再到 logrotate 配置、journald 治理、inode 排查、监控阈值重构,每一步其实都是对一个核心问题的反复确认:日志系统的每一层都要有明确的容量边界和处置策略,不能只依赖某一个工具“自动就好”。把边界画清楚之后,日志就不再是随时引爆的雷,而只是一个普通的、可管理的数据流。