带日志的服务器跑上一年,/var/log 里会堆出几十上百 G 的东西,但真正在用的可能只有最近两三天的。于是很多人写个 find /var/log -name "*.log" -mtime +7 -delete 扔进定时任务,从此觉得日志问题解决了。直到某天磁盘满了去查原因,打开 du -sh /var/log 发现日志还是几十 G——因为正在写的那个文件,从来没被删掉过。
这篇文章讲 logrotate 的完整机制:它按什么规则转、转完之后旧文件去哪、为什么「删了还在占空间」、以及一套我觉得够用一年的生产配置。最后会说两个比 logrotate 更值得考虑的做法,因为 logrotate 在容器时代有些地方确实力不从心了。
一、logrotate 到底做了什么
logrotate 本身不是一个常驻进程,它由 cron 或 systemd timer 每天触发一次(Debian/Ubuntu 上是 /etc/cron.daily/logrotate,CentOS 上由 logrotate.timer 驱动)。每次运行时,它读配置,对每个匹配的文件判断是否满足轮转条件,满足就执行轮转动作。
一次轮转包含四步,顺序很重要:
第一步,重命名。如果配置了 rotate 7,当前文件 app.log 会被重命名为 app.log.1,原来的 app.log.1 变成 app.log.2,依此类推,第 7 个往后的被删除。
第二步,创建新文件。按配置决定是否新建一个空的 app.log,以及它的属主、属组、权限。
第三步,通知进程重新打开文件(postrotate 阶段)。这一步是整个机制的核心,也是最容易出错的地方——见下一节。
第四步,可选的压缩(compress),把 .1 压成 .1.gz。
关键点在这里:第一步的「重命名」只是改了目录项,文件本身还在磁盘上,仍然被进程打开着。如果你的应用还拿着旧的文件描述符在往里写,那它写的就是那个已经改名叫 app.log.1 的文件,而新建的 app.log 一直是空的。这就是「配置看起来没问题,日志却查不到新内容」的典型症状。
二、copytruncate 与 create 的取舍
logrotate 提供两种创建新文件的方式,决定了上面第三步怎么处理。
方式一:create(默认)。重命名旧文件,创建一个新文件,然后通过 postrotate 里的一条命令,让应用重新打开日志路径。Nginx 是 kill -USR1 $(cat /run/nginx.pid),PHP-FPM 是 kill -USR2,很多自研服务是自己实现一个 SIGHUP 处理器。
这个方式的优点是:切换是原子的,旧文件的写入在重命名那一刻停止,数据不会丢失也不会重复。缺点是:应用必须有「重新打开日志」的能力。如果一个 Python 脚本用 logging.FileHandler 打开日志后从不处理信号,那它就永远写着旧的文件描述符。
方式二:copytruncate。先复制旧文件到 app.log.1,然后把原文件截断(truncate)到 0 字节,不重命名、不创建新文件。这样应用的文件描述符一直有效,不需要任何信号配合。
这个方式的优点是兼容一切应用。缺点有两个:一是在复制和截断之间有个时间窗口,这段时间写入的日志可能丢失(通常只有几毫秒到几百毫秒,但高流量的服务不能忽略);二是如果日志文件很大(比如几十 G),复制这一下会消耗大量 IO 和时间,轮转期间服务会有明显卡顿。
我的选择规则:
- Nginx、MySQL、PHP-FPM 这类有标准信号的服务 → 用
create加postrotate发信号,这是最干净的做法。 - 自研脚本、老旧程序、找不到怎么发信号的服务 → 用
copytruncate,配合把日志按天切分保持单个文件不太大(控制在几百 M 以内)。 - 日志量特别大的(每天几 G)→ 两者都别用,改由应用自己按大小切分(见最后一节)。
三、一个能撑一年的配置骨架
我不建议直接改 /etc/logrotate.conf,那个文件是全局默认值,改坏了影响所有服务。正确做法是在 /etc/logrotate.d/ 下按服务建独立文件。下面是给一个典型 LNMP 站点用的配置:
# /etc/logrotate.d/myapp
/var/log/nginx/*.log {
daily
rotate 14
missingok
notifempty
compress
delaycompress
dateext
dateformat -%Y%m%d
sharedscripts
create 0640 www-data adm
postrotate
[ -f /run/nginx.pid ] && kill -USR1 $(cat /run/nginx.pid) || true
endscript
}
/var/log/myapp/*.log {
size 200M
rotate 10
missingok
notifempty
compress
delaycompress
copytruncate
}逐条解释几个不那么直观的:
delaycompress 的意思是「上一次轮转的文件先不压缩,等下一次再压」。为什么要这样?有些程序在收到信号前仍在往旧文件写,如果你在轮转的同一瞬间就压缩了,程序写入端可能拿到一个已经变成 .gz 的路径而出错。延后一轮是最稳的。
dateext 加 dateformat 让文件名带上日期(access.log-20261110),而不是 .1 .2 .3 那种滚动编号。好处是有两个:一是旧日志不会再被「滚动」改名,减少了误删和混淆;二是查「上周三的日志」时你能一眼找到文件。
sharedscripts 的意思是:这个配置块匹配多个文件时,postrotate 只执行一次,而不是每个文件执行一次。nginx 的 *.log 通常匹配 access.log 和 error.log 两个文件,如果不加这句,USR1 会被发两次——虽然 Nginx 能承受,但对别的服务就不一定了。
missingok 加 notifempty 是一对:前者说「文件不存在别报错」,后者说「文件是空的就不要转了」。两句都能消除大量无意义的告警邮件。
size 200M 用于第二个块,是「按大小轮转」。注意 size 和 daily 同时出现时,只要满足其中一个就会转。这个行为有时候会被误解,配置时要清楚自己要的是「每天一次」还是「满了就转」。
还有两个容易被忽视的配置项:
maxsize 和 size 的区别。size 是「达到就转,不管时间」,maxsize 是「即使还没到轮转时间,超过这个大小也转」。如果你想要「每天转一次,但如果白天就涨到 1 G 也先转」,就写 daily 加 maxsize 1G。
minsize 与 daily 组合,是「每天检查一次,但只有超过这个大小才真的转」。适合日志量不稳定的服务,避免一天转出一堆几百 K 的小文件。
四、验证:不要相信「配置写完就完事」
改完配置必须做两件事,缺一不可。
第一,语法检查。
logrotate -d /etc/logrotate.d/myapp-d 是 debug 模式,只打印将要执行的动作,不做实际改动。输出里会明确写出「considering log /var/log/nginx/access.log」「log needs rotating」这类判断,以及会把文件重命名成什么。如果这里显示「log does not need rotating」而你预期它该转,那就是判定条件的问题(比如时间没到、或者 notifempty 拦住了空文件)。
第二,强制跑一次真实轮转。
logrotate -vf /etc/logrotate.d/myapp-f 是强制,-v 是详细输出(会在 logrotate.state 里记录状态,这对判断下次的行为很重要)。跑完之后立刻确认三件事:
ls -l /var/log/nginx/看是否出现了带日期的旧文件,以及新的access.log是否被创建、权限对不对。curl访问一下站点,产生一条新日志,然后tail -1 /var/log/nginx/access.log确认新日志写进了新文件——这一步能验证postrotate的信号是否真的生效了。如果新文件一直是空的,而旧文件还在增长,就是信号没发到位。ps -o lsof不行的话用lsof +D /var/log/nginx,看 nginx 的 worker 打开的是哪个文件。这一步虽然啰嗦,但它是唯一能「看到真相」的方式。
还有一点:如果用了 copytruncate,验证完要顺手确认旧文件的大小。有时候 copytruncate 会因为文件被其他进程持有而没能真正截断(比如两个进程同时写同一个日志),表现是新日志有内容了,但原文件也没变小。这种情况要查是不是有多个实例在写同一个路径。
五、为什么「删了日志磁盘还是满的」
这个问题值得单独讲,因为它和 logrotate 的原理是同一件事的两面。
在 Linux 里,文件有两个东西:目录项(名字)和 inode(实际的数据块)。rm 只删掉了目录项,inode 的引用计数要降到 0 才会真正释放数据块。只要还有进程打开着这个 inode,引用计数就不为 0,数据块就一直占着。
所以「日志删了空间没释放」的原因是:某个进程(通常是 Nginx worker、PHP-FPM、或者一个 tail -f 的会话)还打开着那个已删除的文件。查证用这条:
lsof +L1 | grep -i log+L1 的意思是「列出链接数小于 1 的打开文件」,也就是已被删除但仍被打开的文件。输出里会有 NLINK 0 的标记和文件大小。找到 PID 之后,处理方式有三种:重启那个进程(最彻底,但有服务中断)、kill -USR1让它重新打开日志(对支持的服务最优雅)、或者直接把一个命令写进 /proc/PID/fd/N 把它截断,最暴力也最快:
: > /proc/12345/fd/3注意最后一种方式会直接丢失该文件里的内容,而且如果进程的 fd 偏移量还在文件末尾,后续写入会在文件头部之后留下大量空洞(sparse file),看起来大小没变但实际占用变小——这也是一种「看到的现象和实际不符」的来源。
预防的办法就是别用 rm 管理正在写的日志,全部交给 logrotate 的 rotate N 做保留策略。rm 只用于清理已经轮转完成的归档文件。
六、logrotate 管不住的两个场景
这两种情况下,我更倾向于绕开 logrotate:
场景一,容器里的应用。容器的最佳实践是「日志写到 stdout」,由 Docker 的日志驱动接管,在宿主机上落到 /var/lib/docker/containers/<id>/<id>-json.log。这个文件 logrotate 转不了(容器内的进程还通过 pipe 持有它),要用 Docker 自己的 log-opts:
{
"log-driver": "json-file",
"log-opts": {
"max-size": "50m",
"max-file": "3"
}
}改完要重启容器才生效。max-size 50m 加 max-file 3 意味着单个容器最多占 150 M 日志。这个配置在 /etc/docker/daemon.json 里全局设置,覆盖所有新创建的容器;已经存在的容器需要重建才生效——这一点几乎所有人都踩过。
场景二,日志量极大或者需要结构化查询。当天几 G 的日志,靠 logrotate 切分只能解决「磁盘不被写满」,解决不了「我要查昨天 14:23 那个 502 是哪个上游」。这时候更好的方案是把日志直接送进一个索引系统,比如 Loki + Promtail,或者用 goaccess 做实时聚合。Nginx 侧可以保持写文件(作为原始记录),同时让采集器 tail 走。
这种情况下 logrotate 仍然要配,但可以配得更激进:size 500M 加 rotate 5,只为「万一采集器挂了」留个短期缓冲,而不是当长期归档。
七、几个真实踩过的坑
坑一,postrotate 脚本里的命令失败但没有报错。logrotate 默认不会因为 postrotate 里的命令返回非 0 就中止,所以即便 kill 失败了(比如 pid 文件路径变了),轮转依然「成功」完成,但应用没重新打开日志。防法是加 || true 之外的显式检查,或者在验证流程里加上「新文件是否真的有新内容」这一条。
坑二,配置文件里的通配符被 shell 提前展开。logrotate 的配置不是 shell 脚本,路径里的 * 由 logrotate 自己展开。但如果有人在 postrotate 里写 chown www-data *.log,那个 * 是由 sh -c 执行的,展开的工作目录不一定是 /var/log/nginx,结果 chown 了一堆莫名其妙的文件。要写绝对路径。
坑三,多个配置文件匹配同一个日志。比如 /etc/logrotate.d/nginx 里写了 /var/log/nginx/*.log,又有一个自定义文件写了 /var/log/nginx/access.log。logrotate 会尝试轮转两次,第二次会以奇怪的方式失败或者产生重复的归档。排查方式是 grep -r "access.log" /etc/logrotate.d/,确保每个路径只被一个配置块覆盖。
坑四,权限变了导致应用写不进去。用 create 0640 www-data adm 时,新建的日志文件属主是 www-data。但如果应用是以另一个用户(比如 nginx 或 php-fpm)运行的,它就没法写入了,表现是轮转之后日志完全停止增长,一段时间后又因为「文件被删除但还开着」而堆积成另一个问题。查证方式就是轮转后立刻 ls -l 看属主,再对照 ps -o user= -p $(pgrep nginx)。
坑五,状态文件被意外重置。logrotate 靠 /var/lib/logrotate/status(或 /var/lib/logrotate.status)记住每个文件上次轮转的时间。如果这个文件被清掉(比如某些「清理」脚本顺手删了),logrotate 会认为所有日志都「很久没转了」,一次性全部轮转,可能瞬间产生几十个归档文件把 inode 和磁盘吃掉。保护方式是把 /var/lib/logrotate 加进备份,或者至少别让清理脚本碰它。
八、我现在的默认做法
总结一下我自己服务器上稳定用了一年多的方案:
- 所有系统服务(nginx、mysql、redis、php-fpm、系统日志)用
/etc/logrotate.d/下的独立配置,统一用daily+dateext+compress+delaycompress。 - 保留期按业务定:访问日志 14 天,错误日志 30 天,系统日志 7 天。这个数字取决于你有没有别的地方存这些数据,如果接了 Loki 之类,本地保留 7 天就够。
- 所有容器都配置
max-size和max-file,并且把这行写进部署模板的检查清单,新建机器时逐项打勾。 - 数据库的日志(MySQL 的 slow.log 和 error.log)单独配,用
size而不是daily,因为慢日志的量波动极大。 - 每季度跑一次
logrotate -d把所有配置 dry-run 一遍,确认没有语法错误和路径失效。这个检查不花钱,但能提前发现「服务改名了但配置没跟着改」这类问题。
日志这事的本质是:它在系统正常的时候毫无存在感,在系统出问题的时候是唯一的证据。所以对它做配置的标准不应该是「省磁盘」,而是「出问题时能找到想要的东西,同时不会把机器撑爆」。这两件事 logrotate 都能做到,前提是你真的理解它那四步动作的顺序,以及「重命名不等于删除」这个在 Linux 里反复出现的道理。