日志把磁盘吃满了:切割压缩与验证四件事

日志是服务器上「唯一会自动变大的东西」。不管站点有没有流量,不管你有没有改代码,只要服务在跑,日志就在写。很多站长第一次遇到「磁盘满了」都是在半夜:站点突然 500,SSH 上去一看 df -h 顶到 100%,再 du 一查,/var/log 或者 /www/wwwlogs 撑起了二十几个 G。

切分日志这件事本身不复杂,很多人也照着教程配了 logrotate。但配了不等于配对,我见过的日志事故里,大半不是「没配」,而是「配了但配错了」。下面从磁盘被谁吃掉讲起,把切割、压缩、保留、以及切割之后必须做的那个动作讲清楚。

一、先找出到底是谁在吃磁盘

上来不要盲目删,先定位。du 按目录排序看前十几名:

du -xhd2 /var /www 2>/dev/null | sort -rh | head -20

加上 -x 是为了不跨文件系统,否则挂载点会被重复计算;-d2 只看到第二层,够用且输出不至于刷屏。定位到具体目录后再看文件:

ls -lhS /var/log/nginx/ | head

通常的元凶有这几类。第一是访问日志本身:一个日 PV 十万的站,access.log 一天大概两三百兆,一年就是几十个 G。第二是错误日志:如果站点有个循环报错(比如某个插件在定时任务里反复抛 warning),error.log 一天能涨几个 G,而且往往没人注意。第三是数据库日志:MySQL 的慢查询日志、binlog、以及 general_log(这个如果被打开过又忘了关,写得极快)。第四个最隐蔽,是 journald:systemd 的日志默认放在 /var/log/journal,有自己的配额,但某些版本下配额是按全部文件系统算的,机器小的时候一样会撑满。

定位清楚之后再谈策略。不要一上来就 rm -rf /var/log/*,那会把正在写的文件句柄删掉,服务看起来正常,磁盘空间却不释放——这是 Linux 上一个经典的反直觉现象:文件被删除但进程还持有着句柄,空间要等进程重启才回收。网上流传的「lsof | grep deleted」就是查这个的,看到大文件被 deleted 标记还占着空间,唯一的解法是重启对应进程,或者用 truncate 手法让文件保留句柄但清空内容。

二、truncate 与 rotate 的根本区别

这两个动作经常被混为一谈,实际上完全不同。

truncate -s 0 access.log 是把文件内容清空,文件本身还在,inode 不变,进程持有的句柄依然有效。这个操作是安全的,进程会继续往同一个位置写,只是从 0 开始。缺点是丢失了历史内容。

mv access.log access.log.1 是改名,然后通常要通知进程「重新打开日志文件」。如果进程不重新打开,它会继续往 access.log.1 这个 inode 里写,你新建的 access.log 一直是空的。Nginx 靠 nginx -s reopen 或者 kill -USR1 来解决;很多其他程序靠 copytruncate 这个模式来绕过。

copytruncate 的做法是先复制一份,再清空原文件,进程不需要任何配合。缺点是复制与清空之间有一个短暂的时间窗,这期间写入的日志会丢;而且对大文件来说复制本身很费 IO,几 G 的文件复制一次就是几 G 的读写放大。

所以正确的选择是:能通知进程重开文件(Nginx、PHP-FPM、MySQL、Redis 都支持信号或者专门命令),就用 create 模式;不支持的第三方程序,才用 copytruncate。

三、一份能直接用的 logrotate 配置

系统自带的 /etc/logrotate.d/nginx 通常只切不压,保留期也长,对中小站点来说偏保守。我一般在 /etc/logrotate.d/ 下给站点单独写一份:

/www/wwwlogs/*.log {
    daily
    rotate 14
    dateext
    dateformat -%Y%m%d
    missingok
    notifempty
    compress
    delaycompress
    sharedscripts
    postrotate
        [ -s /run/nginx.pid ] && kill -USR1 $(cat /run/nginx.pid)
    endscript
}

逐项解释。 daily 是每天切一次,写在 /etc/cron.daily 里的 logrotate 每天触发一次,具体时刻由 cron 决定,可以调但一般无所谓。rotate 14 保留 14 份,配合压缩实际占用不大。dateext 让文件名带日期后缀(access.log-20261026),比 access.log.1 这种按序号滚动的方式好排查——你会知道「上个月 12 号那件事」在哪份文件里。

compress 开 gzip,日志文本压缩比通常能到 10:1 以上。delaycompress 是让最近一份不压,因为那份可能还有进程在写或者你马上要看。这个参数很关键:不加 delaycompress 而直接 compress,在某些 copytruncate 场景下会压到正在写的文件,产生截断的坏包。

sharedscripts 意思是匹配到多个文件时,postrotate 只执行一次,而不是每个文件执行一次。Nginx 有 8 个日志文件的话,不加这个参数会发 8 次 USR1 信号,虽然不出错但日志里会刷一堆「reopening logs」。

postrotate 里那句用了 [ -s ... ] && 做判断,是为了在 Nginx 没运行的时候不报错。logrotate 对 postrotate 的退出码是敏感的,脚本失败会记 warn,长期刷 warnings 会让人习惯性忽略日志,那就本末倒置了。

四、最容易出的三个错

第一个错:inode 耗尽,磁盘没满但写不进东西。 现象是 df -h 显示还有空间,但所有写入报「No space left on device」。用 df -i 看 inode 使用率,如果是 100%,说明小文件太多了。日志目录里成千上万个没被清掉的旧日志、临时文件、session 文件都会导致这个。logrotate 配对了不会出这个问题,但如果有人手动 cp access.log access.log.bak.1 备份了上百份,就会出现。

第二个错:压缩把 CPU 打满。 大站在凌晨切日志时对几百兆的文件做 gzip,单核可能跑满几十秒,如果这时候还有个备份任务在跑,机器会明显变卡。缓解办法是把切分时间与备份时间错开,或者对大文件设 compresscmd /usr/bin/nice -n 19 /bin/gzip 降低优先级。我一般直接把 logrotate 的触发时间从默认凌晨改到 4 点半,避开备份。

第三个错:切了但进程没重开,日志写进了旧文件。 这是最隐蔽的一种:ls -lh 看到 access.log 一直是 0 字节,你以为没流量,其实流量全写进了 access.log-20261026 那个旧文件里。验证方法很简单:ls -l /proc/$(cat /run/nginx.pid)/fd | grep log,看进程当前打开的到底是哪个文件。如果指向的是带日期后缀的那个,说明 USR1 没生效——常见原因是 pid 文件路径写错,或者用了 systemctl reload nginx 之外的方式启动,pid 不在预期位置。

五、切割之外:让日志别那么快地长起来

切分只是「打扫」,真正省钱的是「少产生垃圾」。有三个立竿见影的调整。

第一,关掉不需要的日志字段。Nginx 默认的 combined 格式里有 $http_referer 和 $http_user_agent,后者常常占整行的一半以上。如果你不做 UA 分析,完全可以自定义一个精简格式,只留时间、IP、方法、URI、状态码、耗时、字节数:

log_format brief '$time_iso8601 $remote_addr "$request" $status '
                  '$body_bytes_sent $request_time $upstream_response_time';
access_log /www/wwwlogs/access.log brief buffer=64k flush=5s;

体积能砍掉四到六成。buffer=64k 让 Nginx 攒够 64K 再落盘,减少系统调用次数;flush=5s 保证最多 5 秒落一次,不至于因为缓冲导致实时排查时看不到最新记录。这两个参数对高并发站点是纯收益。

第二,把静态资源的访问日志单独关掉或者拆到别的文件。图片、CSS、字体这些请求占了访问日志的绝大部分行数,但排查问题时几乎不看。给静态目录配 access_log off;,日志体积能再降一大截。如果你确实需要统计静态资源流量,那是 CDN 该干的活,不该让源站的磁盘承担。

第三,处理错误日志的刷屏。error.log 涨得快通常意味着应用在反复报同一个错。这时候正确的做法不是加快切割,而是 grep 一下错误的特征值做聚合,看看是哪个 URL、哪个插件在刷:

awk -F'client: ' '{print $2}' error.log | awk '{print $1}' | sort | uniq -c | sort -rn | head

按客户端 IP 聚合,一眼就能看出是不是某个爬虫或者某个被攻击的路径在制造日志。修掉根因,日志量自然回落。

六、journald 与数据库日志单独处理

systemd 的日志不归 logrotate 管,用 journalctl 自己清:

journalctl --vacuum-size=500M
journalctl --vacuum-time=30d

永久生效要改 /etc/systemd/journald.conf 里的 SystemMaxUse=500M,然后 systemctl restart systemd-journald。如果站点跑在容器里,容器日志默认由 docker 的 json-file driver 管理,不加限制会无限增长:在 /etc/docker/daemon.json 里配 log-opts 的 max-size 与 max-file,然后重启 docker——注意这个限制只对新建的容器生效,已有容器需要重建。

MySQL 的慢查询日志和 binlog 各有自己的清理机制,不要交给 logrotate 去 mv。binlog 用 PURGE BINARY LOGS BEFORE DATE_SUB(NOW(), INTERVAL 7 DAY),慢日志用 logrotate 但要把 postrotate 写成 mysqladmin flush-logs。这些细节写错会导致主从复制中断,比磁盘满严重得多。

七、切割之后必须验证的四件事

配好之后不要就走,第二天回来看一眼。四件事:一是 ls -lh 确认新文件出现了带日期后缀的旧文件,二是 ls -l /proc/$(cat /run/nginx.pid)/fd | grep log 确认进程写的是新文件,三是 zcat 一个有日期后缀的文件确认内容完整不是半截,四是看 /var/lib/logrotate/status 里这个配置的状态行,确认它记的是今天的日期而不是「没跑过」。

如果 logrotate 根本没跑,先看 /etc/cron.daily/logrotate 有没有执行权限,再看 cron 服务本身是不是开着——有些精简系统镜像里 cron 是没启用的,这一点在容器和最小化安装的系统上特别常见。用 systemctl status cron 确认,或者手动跑 logrotate -d /etc/logrotate.d/你的配置 看 debug 输出,-d 是 dry-run,不会真改文件,最适合排查。

把这些做完,日志这块基本就不会再半夜叫醒你了。

Last modification:October 26th, 2026 at 09:24 pm

Leave a Comment