logrotate 配置实战:create 与 copytruncate、删了日志还在占空间的真相

带日志的服务器跑上一年,/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 里记录状态,这对判断下次的行为很重要)。跑完之后立刻确认三件事:

  1. ls -l /var/log/nginx/ 看是否出现了带日期的旧文件,以及新的 access.log 是否被创建、权限对不对。
  2. curl 访问一下站点,产生一条新日志,然后 tail -1 /var/log/nginx/access.log 确认新日志写进了新文件——这一步能验证 postrotate 的信号是否真的生效了。如果新文件一直是空的,而旧文件还在增长,就是信号没发到位。
  3. 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 里反复出现的道理。

Last modification:November 10th, 2026 at 12:25 pm

Leave a Comment