去年下半年,我的一台跑了六年的博客 VPS 出了个怪毛病:每天早上七点到八点之间,总有十几分钟无法写文件,SSH 上去一切正常,df -h 显示根分区还有 8G 空间,top 里也没有高负载进程,可 touch /var/log/test 就是报 No space left on device。折腾了两天才找到原因——不是磁盘满了,是文件系统的 inode 用完了。那时候我刚好在考虑要不要把日志和临时文件的管理重新做一遍,这次故障算是把问题送到了眼前。
临时文件与日志的清理,看起来是运维里最没有技术含量的一块,实际上它有两个极端:做得太少,磁盘早晚会满;做得太多,某天会删掉本该保留的东西,或者把正在写的文件句柄删成了孤儿。这篇把我这几年踩过的坑、以及现在这套已经跑了一年多的方案完整写一遍,包括怎么判断该不该删、怎么删得安全、怎么在不中断服务的情况下释放空间。
一、先搞清楚:到底是谁在吃磁盘
清理之前必须定位。这一步做错,后面全是瞎删。df -h 告诉你哪个分区满了,du -sh /* 告诉你哪个目录大,但这两个命令之间经常有一块「对不上」的差额,而这块差额恰恰是最值得追的。
标准的三步定位:
df -h
du -sh /var/* 2>/dev/null | sort -rh | head -10
lsof +L1 | head -20第一步看分区,第二步看目录,第三步看已被删除但仍被占用的文件。第三步是很多人不知道的一招:Linux 上删除一个文件,如果还有进程握着它的文件描述符,磁盘空间不会释放,直到进程关闭句柄。lsof +L1 列出的就是这类「链接数为 0 但还开着」的文件,典型场景就是你 rm 了一个巨大的日志文件,df 却没变——因为 Nginx 还握着它。这类文件的处理方式在第六节说。
还有一个特别容易被忽略的维度:inode。
df -i这条命令的输出格式和 df -h 一样,但看的是 inode 使用率。我那次故障就是这里 100%、而空间只用了 70%。inode 耗尽最常见的原因是海量小文件:PHP 的 session 文件、邮件队列里的单封信、某些程序的缓存分片、用户上传的小图、以及临时目录里成千上万个没被清理的碎片。定位 inode 大户:
for d in /var /tmp /home /usr; do echo "$d $(find $d -xdev -type f 2>/dev/null | wc -l)"; done注意 -xdev,它限制不跨越文件系统边界,否则 /usr 下面挂载的其他磁盘也会被算进来。/var/lib/php/sessions、/var/spool/postfix、/var/lib/docker/overlay2 这三个地方是最常见的 inode 黑洞,遇到 inode 满先看这三处。
二、/tmp 的真实寿命:systemd-tmpfiles 才是它的主人
很多老教程会说「/tmp 会在重启时被清空」,这个说法在现代发行版上基本不成立。实际上 /tmp 的清理由 systemd-tmpfiles 负责,规则在 /usr/lib/tmpfiles.d/tmp.conf 里,典型内容是这样的:
# 每 10 天清理一次
d /tmp 1777 root root 10d
q /tmp 1777 root root 10d那个 10d 是「最后修改时间超过 10 天的文件会被删除」。也就是说 /tmp 里的文件不是重启就没了,而是躺着十天才会被清理。这个配置在家用机器上问题不大,在服务器上就意味着:一个程序如果把几百 MB 的东西丢进 /tmp 不删除,你要等十天才能回收。
要不要改?可以,但要非常小心。缩短到 3d 相对安全,因为绝大多数正常的临时文件生命周期是分钟级的。不要缩到 1d 以下,因为有程序会用「先写 /tmp 再 mv 到目标位置」的模式写大文件,一个跨天的备份任务会被你半路删掉。
更重要的是在 /etc/tmpfiles.d/ 里自己加规则,指定哪些东西必须清、留多久:
# /etc/tmpfiles.d/site.conf
d /tmp/php-upload 0750 www-data www-data 1d
d /var/tmp/backup-stage 0750 root root 2d这样比自己写 find /tmp -mtime +1 -delete 更规范,因为 systemd 会正确处理「正在被打开的文件」(它按 atime/mtime/ctime 判断,且有配置能跳过近期访问过的文件),也能统一管理权限。而且它不需要你操心 cron 有没有跑。
三、日志三兄弟:syslog、journald、应用日志
日志占磁盘这件事,麻烦在于它有三个来源,每个都有自己的清理机制,经常互不知情。
第一层是 journald。现代发行版的 systemd 会接管大部分服务的日志,存在 /var/log/journal/ 下,按机器 ID 分目录。默认配置下它最多占用磁盘的 10%,并且有上限 4G。查看当前占用:
journalctl --disk-usage控制它的配置在 /etc/systemd/journald.conf,三个关键项:SystemMaxUse(总上限)、SystemMaxFileSize(单文件上限)、MaxRetentionSec(保留时长)。一台 1G 内存的小 VPS 上,我一般设成:
SystemMaxUse=300M
SystemKeepFree=200M
MaxRetentionSec=2weekSystemKeepFree 是「无论如何要给磁盘留多少空闲」,这个值在小磁盘机器上比 SystemMaxUse 更有用,因为它会在磁盘快满时自动开始清旧日志。改完 systemctl restart systemd-journald,然后 journalctl --vacuum-size=250M 立刻执行一次收缩。要注意:--vacuum 是按归档文件删的,不会影响当前正在写的那个。
第二层是传统 syslog(rsyslog 或 syslog-ng),管的是 /var/log/syslog、/var/log/messages、/var/log/auth.log、/var/log/kern.log 这些。它们由 logrotate 轮转,配置在 /etc/logrotate.d/rsyslog,默认是每周轮转、保留 4 份、压缩。logrotate 由 systemd timer logrotate.timer 触发,每天跑一次。看它最近有没有正常跑:
systemctl status logrotate.timer
journalctl -u logrotate --since "7 days ago"轮转失败的典型原因是权限:目录本身 755、文件 640 由 syslog 组可读,如果某次改动把这些权限动了,logrotate 会静默失败,日志就无限增长。
第三层是应用日志,也就是 Nginx、MySQL、PHP-FPM、你自己的脚本写出来的那些。这一层最危险,因为它不走 logrotate,没有配置就是无限增长。典型受害者:/var/log/nginx/access.log(高流量站点一天几个 G)、/var/log/mysql/slow.log、/www/wwwlogs/*.log(面板站点的日志)、以及各种自定义脚本的 output 文件。
这一层必须自己配 logrotate。一个覆盖所有站点日志的配置大概长这样:
# /etc/logrotate.d/site-logs
/var/log/nginx/*.log /www/wwwlogs/*.log {
daily
rotate 14
compress
delaycompress
missingok
notifempty
sharedscripts
postrotate
[ -f /run/nginx.pid ] && kill -USR1 $(cat /run/nginx.pid)
endscript
}几个参数值得解释。daily 而不是 weekly:高流量站一周的 access log 可能就有几 G,按天切更好管理。rotate 14 保留两周,配合 compress 实际占用不大(文本日志压缩比通常 10:1 以上)。delaycompress 让最新的那个归档文件先不压缩,方便你最近排查时直接读。postrotate 里的 kill -USR1 是给 Nginx 发「重开日志文件」的信号——这是关键,否则 Nginx 会继续往已被重命名的旧文件句柄里写,新文件一直是 0 字节,磁盘也不会释放。MySQL 对应的信号是 kill -USR1 $(cat /var/run/mysqld/mysqld.pid) 或者 mysqladmin flush-logs;PHP-FPM 用 kill -USR2。
四、日志分级留存:不是所有日志都值得留一年
保留多久合适?这要看日志的用途。用法律合规的口径要求所有日志留半年,对个人站来说成本太高;完全不留,出事时又无从查起。我的做法是按来源和用途分三档:
七天档:访问日志(access.log)、调试日志、应用输出的普通业务日志。这类日志的价值在「最近几天某个请求发生了什么」,过了这个窗口几乎不会被翻。保留 7 天、压缩后通常只占几百 MB。
三十天档:错误日志(error.log)、慢查询日志、SSH 登录日志(auth.log)、防火墙日志。这类日志用于发现「持续几天才暴露的问题」——比如某个爬虫连续两周爬爆你、某个接口从上周开始变慢。30 天要覆盖一个完整的月度周期。
一年档:只存关键事件。具体讲,把每天的「异常摘要」而不是原始日志归档——比如每天一条记录:失败登录次数、5xx 数量、扫描 IP 数、慢查询条数。这是几百字节一天,一年也不过几十 KB,但能让你在半年后回看「那段时间是不是一直在被攻击」。
实现上不需要复杂工具。logrotate 的 rotate 参数就控制天数,不同文件可以用不同的 rotate 值(可以写多条配置块)。关键事件摘要用一个小脚本每天跑一次,把统计结果追加到一个按月切分的文件里,配一条 monthly 加 rotate 12 的 logrotate 规则即可。
一个要提醒的点:不要把 access.log 当分析数据源做长期统计。它有两个问题,一是体积大,二是格式会随配置变化导致解析困难(加个 log_format 就全变了)。统计需求应该走「每天定时解析、写入数据库或独立文件」的路子,之后原始日志按七天轮转掉即可。这样既能长期看趋势,又不用囤几个 G 的原始文本。
五、临时文件清理:三类文件,三种处置
临时文件比日志更需要谨慎,因为「临时」这个词的边界很模糊。/tmp 里的东西不一定都能删。
第一类,确定可以删的。上传过程的残留、被中断的下载、各种 *.tmp、编辑器产生的交换文件、compiler 的中间产物。这类文件有一个共同特征:创建后超过一定时间没有被访问。判断方式用 atime:
find /tmp -xdev -type f -atime +2 -size +10M -print-atime +2 表示最后访问在两天天前,-size +10M 限定大文件,先 -print 看看会删什么,确认无误后再换成 -delete。这条命令的关键是「先打印后删除」,永远不要一步到位。
第二类,看起来是临时文件其实是运行状态。最典型的是 PHP 的 session 文件和 Unix socket。session 文件放在 /var/lib/php/sessions 或 /tmp(取决于 session.save_path),文件名形如 sess_xxxxxxxx。它不是垃圾,是用户登录状态。删掉它不会崩,但所有在线用户会被踢下线。所以清理 session 必须按 gc_maxlifetime 匹配——PHP 自己会清理,你只需要确保它的清理机制在正常工作(gc_probability 不要配成 0),不要额外加一条 find -delete。
Unix socket 更危险。/tmp/mysql.sock、/var/run/php-fpm.sock、/run/nginx.pid 这些名字看起来都在「tmp」目录里,但删掉它们会让服务立刻失去连接能力(虽然重启能恢复)。所以任何清理脚本都必须有排除名单,至少排除 *.sock、*.pid、以及 /tmp/systemd-private-*(systemd 给服务分配的私有临时目录,里面装的是运行中的东西)。
第三类,你不知道是什么的。一律不删。正确的做法是记录:把 /tmp 里超过三天的文件按目录聚合,每周看一次有什么在长期驻留。发现某目录持续有几万个文件,再去查是哪个程序干的,从源头解决(改配置让它写到别处或者自己清理),而不是加一条 cron 把痕迹抹掉。
六、删了但空间不释放:文件句柄的坑
这是这一整套里最容易让人困惑的现象,也是我在第一节提到的 lsof +L1。场景:du 显示 /var/log/nginx 只有 20M,df 却显示根分区少了 20G。rm 过、rm -rf 过,都没用。
原因在于 Unix 的删除语义:rm 只是把目录项删掉、把链接数减 1,文件的数据块要等所有打开它的进程都关闭句柄才释放。Nginx 主进程一直开着 access.log 的 fd 往里面写,你删掉了目录项,它还在写那块已经不存在的空间。
确认方法:
lsof +L1 | awk '{ if ($8 > 1000000) print }'$8 是文件大小字段(部分版本是 $7,看表头对齐),过滤掉小文件,大的那个就是元凶。lsof 输出里的 NAME 会带 (deleted) 后缀,一眼能认出。
处理方式有三种,按伤害从小到大排:
方式一,发信号让它重开日志。kill -USR1 <pid>(Nginx、Apache)、kill -USR2 <pid>(PHP-FPM)、mysqladmin flush-logs(MySQL)。这是最干净的做法,服务不受影响。
方式二,重启那个服务。简单粗暴但有效,代价是一次短暂中断。systemctl restart nginx 通常几百毫秒,对访问量不大的站可以接受。
方式三,用 gdb 直接关掉句柄。不推荐,容易把进程搞崩,而且过程难复现。除非万不得已,不要用。
真正的解法是「一开始就删对」。用 truncate 而不是 rm:
truncate -s 0 /var/log/nginx/access.logtruncate 把文件长度设为 0 但保留 inode 和目录项,句柄不受影响,程序继续往里写,磁盘空间立刻释放。这是应急清空一个正在写的日志文件的标准做法。注意它在并发写入下可能留下少量空洞(文件大小显示 0 但 du 还是有点),完全无害。
七、磁盘水位告警:三个数字,配一条脚本
清理做完,还得有人盯着。告警不需要复杂系统,一个 cron 脚本足够,关键是要看三个数字而不是一个。
第一个是空间使用率,超过 85% 提示、超过 92% 紧急。第二个是inode 使用率,阈值同上——我的那次故障就是这里被漏掉了。第三个是增长率,也就是过去 24 小时用掉了多少个 G。这个数字最有预警价值:使用率只有 60% 但一天涨 5G,比使用率 88% 但一周涨 1G 危险得多,因为前者会在几天内爆掉。
#!/bin/bash
# /usr/local/bin/disk_check.sh
WARN=85; CRIT=92
for m in $(df -P | awk 'NR>1 && $1 ~ /^\/dev/ {print $6}'); do
sp=$(df -P "$m" | awk 'NR==2 {gsub("%","");print $5}')
ip=$(df -Pi "$m" | awk 'NR==2 {gsub("%","");print $5}')
if [ "$sp" -ge "$CRIT" ] || [ "$ip" -ge "$CRIT" ]; then
echo "CRIT $m space=${sp}% inode=${ip}%"
elif [ "$sp" -ge "$WARN" ] || [ "$ip" -ge "$WARN" ]; then
echo "WARN $m space=${sp}% inode=${ip}%"
fi
done增长率可以这样算:每天记录一次 df 的 used 值到 /var/lib/diskcheck/history,和前一天比较,差值超过阈值(比如 2G)就一并告警。这个文件的最后一行永远保留,用来做下一次比较。
这里要提醒一个在写脚本时容易踩的坑:df 的输出如果磁盘名太长会被折行,导致 awk $6 拿到错的字段。加 -P(POSIX 格式)就强制单行输出,这一条能省掉很多诡异的解析错误。
八、一次真实的清理:从 40G 回到 12G
把上面的东西串起来,讲一个实际做的过程,比只列清单清楚。
起因是 df -h 报根分区 92%,站点开始间歇性报 500。第一步定位:du -sh /var/* 发现 /var/log 占了 28G,其中 nginx/access.log 单个文件 19G。但 ls -lh 看它只有 19G,df 却显示用掉了 40G——差额就在别处。
第二步查句柄:lsof +L1 找到三个 deleted 的大文件,加起来 9G,都是之前有人 rm 过但 Nginx 和 MySQL 还开着的。用 kill -USR1 和 mysqladmin flush-logs 让它们重开日志后,这 9G 立刻回来了。
第三步处理那个 19G 的 access.log。它已经写了七个月没轮转——原因是 /etc/logrotate.d/nginx 存在但里面写的是 /var/log/nginx/*.log,而实际的日志路径是 /www/wwwlogs/(换了面板之后路径变了,规则没跟着改)。这就是 logrotate 最典型的静默失效:规则存在、timer 正常跑、日志却从来没被处理。修复方式是把路径补上,或者直接改掉旧规则。然后 truncate -s 0 清空当前文件,避免下次轮转时压缩一个 19G 的文件(那会很慢,还可能因为磁盘空间不足失败)。
第四步是顺手清理 inode。df -i 当时是 82%,/var/lib/php/sessions 里有 60 多万个 session 文件,全是半年前的——PHP 的 session.gc_maxlifetime 是 1440 秒,但 gc_probability 被设成了 0(有人为了「优化性能」关掉了自动清理),于是没有任何人清理。打开 gc_probability、当天手动删掉三个月前的文件,inode 降到 20%。这一步要小心:服务运行中删 session 会让部分用户掉线,如果站点用户量大,最好挑深夜做。
最后一步是把缺失的规则补齐:给 /www/wwwlogs/*.log 加 logrotate 配置、journald 设置 SystemMaxUse=300M、加一条每日 /tmp 里超过 2 天的大文件清理、把 disk_check.sh 加进 cron。rotate 统一设成 14,观察一周后磁盘稳定在 12G 左右,之后一年的波动都在 12G 到 16G 之间。
九、小结:清理是设计问题,不是救火问题
临时文件与日志的治理,最难的部分从来不是「怎么删」,而是「删什么、留多久、怎么知道该删了」。三条经验可以带走。
第一,每一种会写磁盘的东西,都必须有一个明确的归属。它写在哪个目录、由谁来轮转、保留多久、超过上限怎么办。把这四个问题答清楚,就再也不会出现「某天突然满了」。答不清楚的,就是将来某天半夜叫醒你的那个东西。
第二,监控三个数字,不是两个。空间、inode、增长率,缺任何一个都会漏。空间和 inode 是两个独立的池子,任何一个满了表现都同样是 No space left on device,不看 df -i 会白查半天。
第三,清空正在写的文件用 truncate 不用 rm。这一条能避免「删了磁盘还是不释放」这个最常见的困惑,也能让服务不受任何影响。而当你看到「删了没释放」时,第一件事是 lsof +L1,不是重启。
最后一句实话:这套东西第一次做要花一两个下午,之后每天的成本接近零。而不做的成本,是每半年一次「磁盘满了、站点挂了、半夜爬起来救火」的晚上,以及那种「不知道系统里还有多少东西在偷偷长」的不踏实感。哪个更划算,其实很清楚。