日志越滚越多:zstd 压缩、maxsize 与 90 天分层清理的落地策略

日志占用不是「多」,而是「长」。一个日 PV 三万的站点,配上 Nginx access log、PHP-FPM 慢日志、MySQL 错误日志和各应用的 debug 日志,一周就能写出十几 GB。很多人第一反应是装 logrotate 配置轮转,这没错——2113 那篇已经讲过 create 与 copytruncate 的取舍。但日志轮转只解决「单个文件长大」的问题,不解决「一个月后总量还在涨」的问题。真正能让日志盘稳下来的是三件事:控制写出量、压缩得够好、以及一条能自动删的分层策略。

一、先算清楚每个月到底要留多少

拍脑袋定「保留 30 天」是最容易出事的做法。正确的起点是量一下当前的日增量,再乘上想留的天数和压缩比。一条命令就能把最近七天的日增量列出来:

ls -l /var/log/nginx/*.log* | awk '{print $5, $6, $7, $9}' | sort -k2,3

更实用的是直接看目录的周增长。如果 access.log 每天 800M、gzip 之后大约 8%(文本日志的压缩率普遍在 8% 到 12% 之间),那么一天的存档约 64M,一年就是 23G。这个数字往往把老板吓一跳,但它是真实的——所以策略不是「留一年」,而是「明确定义哪些天必须留、哪些天可以滚掉」。

我自己的分层是这样的:最近 7 天不压缩,方便随时 grep;8 到 30 天保留 gzip 压缩件;31 到 90 天保留但迁到冷目录或者对象存储;90 天以上只留统计汇总的 CSV,原始日志删除。这条策略的前提是「你要知道日志到底用来干什么」,而绝大多数日志的用途只有四类:当天排查、周度/月度统计、合规审计、事故取证。四类之外的留存基本是心理安慰。

二、logrotate 的日期后缀与延迟压缩

默认的 dateext 后缀只是 -20261114,同一天被手动执行两次就会撞名。加上 dateformat -%Y%m%d-%s 之后,后缀带上了时间戳,撞名问题消失,排序也更清晰。

延迟压缩(delaycompress)是很多人漏掉的一项。不开启时,轮转的瞬间就会压缩上一份日志,而如果此时有进程还在往旧文件句柄写(比如没有 reload 的 Nginx),新数据会流进已经压缩过的文件里,直接损坏。开启之后,最新一份保留为未压缩,下一轮再压,风险窗口就消失了。

下面是一份可以直接用的骨架,重点看后半段:

/var/log/nginx/*.log {
    daily
    rotate 30
    dateext
    dateformat -%Y%m%d-%s
    missingok
    notifempty
    compress
    delaycompress
    compresscmd /usr/bin/zstd
    uncompresscmd /usr/bin/unzstd
    compressext .zst
    compressoptions -19 -T4
    create 0640 www-data adm
    sharedscripts
    postrotate
        [ -f /run/nginx.pid ] && kill -USR1 $(cat /run/nginx.pid)
    endscript
}

三、zstd 为什么值得换掉 gzip

gzip 是 1992 年的东西,解压快、压缩慢、压缩率一般。zstd 在日志这个场景里有三个明显优势:同样压缩率下速度是 gzip 的数倍;同样速度下压缩率高 15% 到 30%;最关键的是支持多线程,-T4 能把一块 800M 的日志压缩时间从十几秒压到三四秒。日志轮转本身是个定时任务,压缩时间太长会拖到下一轮,这在低配机器上是真实存在的。

这里必须提一个坑:compressoptions 的 -19 是最高级别,压缩率高但很吃 CPU。我一开始用 -19,结果在 2 核的小机器上轮转到一半,PHP 响应时间直接翻倍。降到 -9 之后,体积只大了不到 4%,CPU 峰值掉了一半。对于按天轮转的日志,-6 到 -9 是更合理的区间,-19 只适合离线归档。

另外,logrotate 默认的 compresscmd 只认 gzip,换 zstd 时必须同时配置 compresscmd、uncompresscmd、compressext 三项,少配一项就会出现「轮转成功但文件是空的」——logrotate 找不到命令,直接把原文件删了。发版之后一定要 logrotate -d /etc/logrotate.d/nginx 做一次 dry-run,看它打印的最终命令是否正确。

四、maxage 与 rotate 会打架

很多人同时写 rotate 30 和 maxage 30,以为更保险,实际上两者语义完全不同。rotate 是「保留几份」,靠删除第 N+1 份实现;maxage 是「文件修改时间超过 N 天就删」。当轮转不是每天准时执行(比如服务停了两天、或者 cron 错过了),两者会得到完全不同的结果:rotate 按份数删,可能删掉最新的日期;maxage 按天数删,可能把该留的删掉。

我的建议是二选一,且优先用 rotate 配合 daily,因为它的行为是确定性的。如果担心磁盘被意外占满,不要靠 logrotate,而应该在磁盘水位上做告警——那是另一层的事,用 logrotate 硬删是治不了根本的。

还有一个常被忽略的细节:size 和 daily 同时写时,logrotate 只在 size 满足时才轮转,daily 变成了摆设。如果你想要「每天轮转,且超过 500M 也提前轮转」,正确写法是只写 daily 再配 maxsize 500M——maxsize 是「上限」,与时间条件并存;而 size 是「唯一条件」,会覆盖掉时间条件。这一个字母差是无数人配错的原因。

五、副本、冷热分层与自动清理

轮转之后,日志分布在三个层次:当天的活动文件、近期的 .gz/.zst 存档、以及已经被搬走的冷数据。三层各自需要一条清理规则。

活动文件靠 logrotate 管。存档目录靠一条独立的清理脚本,按天算而不是按份数算,因为搬进来的文件时间可能不一致:

find /var/log/archive -type f -name '*.log.*' -mtime +90 -print -delete

-print -delete 的顺序很讲究:先打印再删除,留下的记录就是「删了哪些」的凭证。我给这条命令配了 cron,每天凌晨 4:10 跑,输出重定向到一份 cleanup.log,一个月看一次就够了。

冷数据层我一般用 rsync --remove-source-files 推到对象存储,或者干脆推到同机房另一台机器的挂载盘。这里要注意 --remove-source-files 只删「传输成功」的文件,比 mv 安全得多——如果中途断网,源文件还在,不会出现「既没传上去也没了」的局面。这一点 2140 那篇讲过 rsync 的校验,可以对照着用。

六、验证轮转真的生效了

配置写完不等于生效。我固定做四步验证,每次都做,因为 logrotate 的失败非常安静——它只往 /var/lib/logrotate/status 里记一行,不会发邮件。

第一步,dry-run 看命令。logrotate -d 不真正执行,只打印它打算做什么,能看到压缩命令、目标文件名、以及「log needs rotating」这一行有没有出现。第二步,强制轮转一次:logrotate -f /etc/logrotate.d/nginx,看 archive 目录是否多出一份带日期后缀的文件,以及原文件是否被 create 重新生成、属主是否正确。第三步,grep 一下新文件里有没有当天最新的访问记录,确认 Nginx 真的重新打开了日志文件而不是继续写旧句柄。第四步,第二天看 /var/lib/logrotate/status,确认这一项的时间戳更新了。

如果第二步发现新文件是空的、旧文件还在长,说明 postrotate 的 reload 信号没发出去。最常见的原因是 pid 文件路径不对(Ubuntu 是 /run/nginx.pid,CentOS 可能是 /var/run/nginx.pid),或者容器里的 Nginx 根本没有 pid 文件。容器场景下应该改成 copytruncate,代价是截断瞬间可能丢几行,但比「日志永远不轮转」好得多。

七、日志量本身的瘦身

压缩和删除都是下游手段,上游还有很大的减量空间,而且往往更有效。

一是 access log 的格式。很多人把 $request_body、$http_cookie 全打进去,一个请求写两三千字节。实际上排查只需要 remote_addr、time_local、request、status、body_bytes_sent、request_time、upstream_response_time、referer、user_agent 这几项,其余字段在需要时用临时抓包补。改格式之后我们站的日志体积直接降了 40%。

二是噪声请求的过滤。扫描器、爬虫、健康检查、静态资源请求会占掉很大比例。可以在 access log 上做条件判断,让 .well-known、健康检查路径、以及 400 类错误单独写一个低频日志文件,主日志只留有价值的请求。注意不要直接关掉——扫描日志恰恰是安全分析的原料,单独归档、保留更短时间即可。

三是应用日志的级别。线上开着 debug 是最常见的浪费,一条请求能写十几行。改成 warning 或者按模块单独打开,日志量能掉一个数量级。我们的 PHP 应用从 debug 降到 notice 之后,日增量从 2.1G 掉到 240M。

八、一份我现在在用的完整策略

总结一下落地的样子,可以直接对照自己的站改:Nginx 主日志 daily 轮转、留 30 份、zstd -9 压缩、delaycompress、postrotate 发 USR1;扫描与健康检查日志单独一个每月轮转的通道,只留 6 份;PHP-FPM 慢日志不轮转(只记慢请求,量很小),但要定期查看;MySQL 错误日志用 monthly、留 12 份,因为这个频率下一年才有参考价值。

存档目录设 90 天自动清理,冷数据推对象存储时用 --remove-source-files。磁盘水位告警设在 80%,不要等到满了再去手工删——满盘的时候连 MySQL 都写不进去,那时候想的就不是日志策略了。

最后一句提醒:任何删除类命令,第一次执行前先加上 -print 只打印不删,确认列表范围对了再改成 -delete。日志一旦删掉就找不回来了,而它偏偏是事后唯一能查的东西。

Last modification:November 14th, 2026 at 12:24 pm

Leave a Comment