那台机器是我一个客户的外网跑单系统,业务轻得很,一个 Nginx 反代加一个 MySQL,机器是 2核2G 的小规格,装着宝塔面板,磁盘一共就 40G,其中系统和数据盘占了 12G。开始跑得挺好,直到运维群里有人半夜收到告警,说网站打不开了。我登录上去一看,系统盘剩余空间 0,MySQL 早就因为磁盘写不进去自动停了,Nginx 那边也开始报 no space left on device。
问题出在哪呢?du -sh /www/wwwlogs/* 一看就明白了access.log 单个文件 9.1G,error.log 3.4G,而整个站点目录下的真实业务数据加起来才 200M。也就是说这台机器上百分之九十的空间是被日志吃掉的,而且这些日志从上线到现在压根没被切过。
客户问我最多的一个问题是:「日志我不删不就完了吗?删一下不就好了?」问题在于,很多人是直接 rm 掉正在被进程持有的日志文件。文件本身确实没了,但 inode 和磁盘块还在被那个进程占着,磁盘空间一秒都不会释放出来,直到那个写日志的进程重启。这就是我见过最多的"我明明删了怎么没腾出空间"的场景。
如果你也在处理服务器上的日志和磁盘问题,或者想给自己的机器提前做一套日志治理,这两家可以先看看:狗云DOGYUN(高性价比云服务器,机型齐全,适合长期跑日志采集与轮转任务)、RackNerd(全球 VPS,Linux 机型可选,多机部署日志汇聚很方便)。机器先备好,日志治理的方案再动手,能少走很多弯路。

一、日志为什么会在两周内吃掉一个磁盘
先说清楚原理,后面配置才好理解。Linux 下进程写日志有三种模式,对应三种完全不同的"清理"方式:
第一种,标准输出重定向到文件。也就是 ./app > /var/log/app.log 2>&1 这种写法。进程拿着这个文件的文件描述符一直往里写,文件永远不会自己变小。日志量取决于业务量,一天几百 M 还是几个 G,完全看你的服务有多能写。
第二种,程序自己管理日志文件。Java 的 logback/log4j、Python 的 logging 模块、Golang 的 lumberjack 这类库,自带按大小滚动、按天滚动的能力,配置写在应用里。这种情况下 logrotate 基本用不上,但你要确认滚动的时候有没有正确关闭并重新打开文件句柄,否则照样会漏日志。
第三种,写到 systemd-journald 或者 syslog。这类由系统组件统一接管,磁盘上限在 /etc/systemd/journald.conf 里改,比如 SystemMaxUse=1G,超出后自动丢弃最老的记录。很多人在服务器上装了 rsyslog,配置里又写了一份转发,于是同一份日志落盘两遍,空间双倍消耗,这种坑我也踩过。
我那台客户机器是第一类:宝塔默认生成的站点访问日志,通过 Nginx 的 access_log 直接写文件,没有任何轮转策略。这种配置在 1G 内存的小机器上尤其危险,因为一旦磁盘满,MySQL 会先崩,而崩之前的写入失败往往还伴随着一堆写入放大,磁盘曲线是突然往上蹿的。

二、logrotate 的三步标准配置
logrotate 是 Linux 上最经典的日志轮转工具,配置文件放在 /etc/logrotate.d/ 目录下,系统每天会跑一次(Debian 系是 /etc/cron.daily/logrotate,RHEL 系在 /etc/cron.daily/logrotate 里调用同一个二进制)。它做的事很简单:按你定的规则,把当前日志改名归档,压缩旧日志,创建新的日志文件继续写。
第一步,先拿一个最简单的模板跑通。比如给某个自建服务写一份配置,放到 /etc/logrotate.d/myapp:
/var/log/myapp/*.log {
daily
rotate 14
missingok
notifempty
compress
delaycompress
create 0640 www-data adm
dateext
dateformat -%Y%m%d
sharedscripts
}
这些参数逐个说清楚,比抄模板有用得多:
daily 表示按天轮转,对应的还有 weekly 和 hourly(老版本不支持 hourly,需要自己写 systemd timer)。rotate 14 表示保留 14 份历史归档,超过就删最老的。missingok 表示目录或文件不存在时不报错,这个一定要加,否则 cron 每天给你发一封错误邮件。notifempty 表示空文件不轮转,避免生成一堆 0 字节的归档。compress 压缩历史日志,delaycompress 表示最近一份先不压缩这个组合是有讲究的,因为很多程序还在往被改名的文件里写,延迟一轮压缩能减少踩坑概率。
create 0640 www-data adm 是关键中的关键:新日志文件由谁创建、权限多少、属主属组是谁,全靠这一行。少写这一行,轮转之后新文件会变成 root:root 644,业务进程没权限写,日志就断了这是 logrotate 最经典的事故。
dateext 让归档文件名带上日期,比如 access.log-20261102,比 access.log.1 好排查得多。配合 dateformat -%Y%m%d 可以指定日期格式,注意必须写 - 前缀,而且只能是 - 或 .,不能有斜杠。
sharedscripts 表示 pre/post 脚本只执行一次,而不是每个日志文件都跑一遍。如果你有几十个子目录的日志文件,不加这个 daily 脚本会被执行几十次。
第二步,加一条 postrotate 让服务重新打开日志文件。这是整个配置里最不能省的一行:
postrotate
/bin/kill -HUP `cat /var/run/myapp.pid 2>/dev/null` 2>/dev/null || true
endscript
改名之后,进程手里的文件描述符还指着那个已经被改名的文件(inode 没变),日志会继续写进归档文件里,新文件一个字节都收不到。发个 HUP 信号让服务重载配置、重新打开日志,这是标准做法。Nginx 是 nginx -s reopen,Apache 是 apachectl graceful,Redis 是 redis-cli CONFIG REWRITE 之后重启之类的,具体看你的服务怎么支持热加载。日志文件本身已经存在并且进程有权限时,这一行可以省略但我建议一律加上,稳妥。
第三步,先 dry-run 验证,再让它真的跑。logrotate 支持调试模式,强烈建议每次改完都先跑一遍:
logrotate -d /etc/logrotate.d/myapp
-d 是 debug,不会真的动文件,只是把将要执行的动作打印出来。你会看到类似 "rotating pattern /var/log/myapp/access.log 1 day, 14 archives"、"set default create mode to 0640" 这样几行输出。-v 加上会更嗦。确认没问题之后,可以立刻用 logrotate -f /etc/logrotate.d/myapp 强制执行一次,不必等到第二天。这是新配置上线的标准动作:先 -d,再 -f,然后去日志目录看文件是否按预期生成、权限对不对、进程是否还在正常写。
还有一个必须知道的坑:logrotate 有一个全局状态文件 /var/lib/logrotate/logrotate.status,它记录着每个配置上次轮转的时间,用来判断是不是该到周期了。这意味着你手改配置之后,行为可能和你预期不一样,因为状态文件里还留着旧记录。测试的时候可以直接删掉对应行,或者 logrotate -f 强制跑一次,不用去纠结状态。

三、copytruncate 与 create 到底用哪个
这是最多人搞不明白的一对选项,也是「日志莫名断了一段」和「空间不释放」两个问题的根源所在。
默认的 create 方式:logrotate 把 access.log 改名成 access.log.1,然后创建一个新的 access.log,权限按 create 那一行走。优点是干净利落,新文件从头开始写。缺点是有一个极短的窗口期,在这期间进程手里的句柄还指着旧文件,新日志会写进归档里,通常只丢几行。同时,如果你没写 create 那一行,新文件默认是 root:root 644,业务进程大概率写不进去。
copytruncate 方式:先把当前日志内容复制一份成 access.log.1,然后清空原文件(truncate -s 0),文件本身不动,inode 不变,进程继续往原来的文件写。优点是绝对不会丢日志、绝对不需要发信号重载。缺点有两个:一是复制期间新写入的日志会重复一份(先复制再清空,中间写进来的会同时出现在两份文件里),二是这个过程需要额外的一份磁盘空间你有一个 9G 的日志文件,就得有 9G 的空闲空间来复制它。如果磁盘本来就紧张,用 copytruncate 可能当场就把磁盘写满,反而加速了事故。
我的经验是:日志量不大、或者对不丢日志有硬要求(比如审计日志),用 copytruncate;日志量大、磁盘紧张、且服务支持热加载的,用 create 加 postrotate。另外还有第三种做法,就是让程序自己写 PID 文件,postrotate 里用 kill -USR1 让它自己重开文件,这个最干净,但需要改代码。
顺便说一个细节:如果你在宝塔面板或者某个一键脚本里看到 copytruncate 和 postrotate 同时存在,那基本可以确定是配置拼凑出来的,两种机制混用没有意义,选一种就好。
四、删文件为什么没腾出空间:inode 还在
回到开头那个「我明明删了」的疑问,这涉及到 Linux 文件系统的机制。进程打开一个文件时,内核里记的是一个 inode(索引节点)引用,而不是文件名。你 rm 掉文件名,只是把这个目录项(dentry)删了,inode 的引用计数里仍然有进程持有的那一份,所以数据块不会释放,磁盘空间自然也不释放。ls 里已经看不到文件了,但 df 依然是满的,这就是所谓的「幽灵空间」。
两个正确的做法:
做法一,truncate -s 0 /path/to/file。直接把文件清零,inode 不变,进程无感,空间立刻释放。这是最推荐的方式,也是 copytruncate 内部干的事。
做法二,> /path/to/file。效果和 truncate 一样,属于 shell 重定向清空。网上常见的那种 echo > file 的写法只写了一行,正确的空重定向是 : > file 或 > file。
如果你就是想彻底删掉文件,那必须让进程重启或重载,把文件句柄关掉。查一下是谁占着:
lsof | grep deleted
输出会显示 /var/log/nginx/access.log (deleted) 这种,重启对应的进程(比如 nginx -s reopen 或者 systemctl restart nginx),空间马上就回来了。这也是为什么很多运维规范里写着「不要直接 rm 日志文件」,说到底不是为了美观,是为了避免线上出现「删了没释放还得回滚重启」的尴尬。
五、磁盘到底被谁吃了:三件套排查
日志只是磁盘占用的一个来源。真要定位,得会这几条命令。
第一件,du 看目录。du -sh /* 扫一遍根目录,找到大头,然后一层层往下钻。du -sh /www/* 这样逐级缩小范围。记住加上 -h 让输出可读,加上 --max-depth=1 可以直接看到子目录各自的占用:
du -h --max-depth=1 /var/log | sort -h | tail -10
第二件,ncdu 看目录树。du 是逐个目录算的,扫一个几十 G 的目录可能要几十秒,中途你根本不知道自己在看什么。ncdu(ncduWin 是 Windows 上的等价工具)能实时扫描并按占用大小排序,上下键直接往下钻,定位大文件的速度比 du 快一个数量级。我后来在客户机器上装的就是 ncduWin,扫了一遍直接看到 /var/lib/docker/overlay2 占了 18G那是 Docker 的镜像层缓存,跟日志一点关系都没有。要是光盯着 access.log 看,能查到天亮。
第三件,lsof 查「已删除但没释放」的文件。前面说过的那个场景,用 lsof +L1 更快,它专门列出所有打开的、链接数已经归零的文件:
lsof +L1
输出里 SIZE 那一列就是它们各自占着的空间,加起来就是你的幽灵空间。
另外两个常用补充:df -h 看整体挂载情况(记得加 -i 看 inode 用量,几十万小文件也会把 inode 耗尽,df 显示还有空间但依然写不进去,就是 inode 满了);find /var/log -type f -size +500M -exec ls -lh {} \; 直接揪出大文件。

六、别等磁盘满了才处理:水位监控
这次之后,我给所有托管的机器都加了一条简单的巡检脚本:每 5 分钟检查一次根分区使用率,超过 85% 发告警,超过 92% 顺手列出最大的几个目录。脚本本身十几行,用 cron 或者 systemd timer 跑都行,比等业务挂了再排查便宜太多了。
如果你有 Prometheus,用 node_exporter 加 alertmanager 也能做,规则大概是这样:
node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"} < 0.1
再配合一个基于时间的预测告警更好,比如「按过去 6 小时的增长速度,24 小时后磁盘会满」。这种预测型告警的价值在于日志撑满磁盘这种事故,从来不是突然发生的,它是匀速发生的,匀速的东西就能预测。
最后把整件事收一下。日志轮转这事儿的正确顺序是:先配 logrotate(create + 正确权限 + postrotate),再配水位监控,最后才是定期清理旧归档。前两步做了,第三步基本用不上;而只做第三步(也就是大多数人做的事写个 cron 定时 rm),正好会踩到 inode 那个坑。
至于保留多久,这个没有标准答案,要看日志的内容和合规要求。个人的测试机器留 7 天够了,业务系统一般 14 到 30 天,涉及审计留存的按合规要求来。归档文件本身还可以用 zstd 压缩,压缩比能到 5 到 10 倍,比 gzip 强不少,logrotate 里写成 compresscmd /usr/bin/zstd 就行,前提是机器上装了 zstd。
我那台客户机器后来做了三件事:加了 logrotate 配置、给 Nginx 补了 postrotate、把 dateext 打开让归档按日期排。从那之后连续观察了两个月,磁盘使用率一直稳在 18% 左右,再没出现过半夜被电话叫醒的事。


喜欢
顶
难过
囧
围观
无聊



