1. 故障现象:三个服务连环崩溃,排查方向一度跑偏
1.1 凌晨的报警与最典型的"假象"
凌晨1点47分,监控弹出一条告警:Java服务连续三次重启失败。我登录服务器,systemctl status一看,服务处于failed状态,日志最后几行写着java.io.IOException: No space left on device。还没等我缓过神,MySQL也开始异常,错误日志里不停刷Disk I/O error。紧接着Nginx直接返回502,前端页面全部打不开。
这个组合看起来非常像"程序Bug导致资源耗尽"或者"服务器被入侵",我第一反应是查Java的GC日志、查MySQL慢查询、看进程数有没有异常飙升,甚至一度怀疑有人在跑挖矿脚本。结果在错误方向上忙活了大半天:CPU正常、内存正常、进程数正常、网络连接数也没有异常。直到我冷静下来,重新验证最基础的东西——df -h——才看到根分区已经100%。
这个经历我想很多人都有过。磁盘空间不足引发的问题,表象往往极具迷惑性,它会伪装成OOM、伪装成死锁、伪装成网络故障,让你觉得是应用层出了妖。所以这篇文章我想完整还原一次"磁盘满盘事故"的清理过程,把我用到的命令、踩过的坑、以及最后沉淀下来的预防方案都写出来,希望能给正在被类似问题折磨的运维和开发同学一些参考。
1.2 我踩过的弯路:先从应用层找原因
回头复盘,我浪费了大约四十分钟在错误方向上。为什么?因为我默认服务器的基础资源是健康的,总觉得"空间监控虽然没配,但总不至于说满就满"。结果就是,先入为主的排查方式,让一个一行命令就能确认的问题绕了一大圈。
当多个服务同时出问题,优先检查系统基础资源——磁盘、内存、文件句柄——然后再去查具体应用。
这个教训非常值得强调。磁盘满的症状会同时影响多个服务,但不会在应用层面留下明确的"共性日志",你只会看到每个服务各自报各自的错。Java报磁盘IO异常,MySQL报IO error,Nginx报上游连接失败,这些错误之间没有任何共同点,恰恰是这种"各自为战"的表象,把排查者往应用层引。所以,多服务同时异常,第一站永远是系统资源。
下面这张表是我后来整理的"磁盘满的伪装症状",方便大家对号入座:
| 表象 | 实际原因 |
|---|---|
| Java报No space left on device | 日志或临时文件写不进磁盘 |
| MySQL报Disk I/O error | 刷盘失败,事务无法提交 |
| Nginx大量502/499 | 后端服务已挂,或nginx写日志失败 |
| SSH登录缓慢、命令卡顿 | 文件系统元数据操作阻塞 |
| 定时任务/备份突然失败 | 无法创建临时文件或写日志 |
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 定位真相:df、du、lsof三件套把"元凶"揪出来
2.1 df -h:一分钟确认磁盘是否满
登录服务器后,第一件事就是看磁盘整体情况:
bash复制df -h
df -i
df -h查看空间用量,df -i查看inode用量。很多文章只提空间,但inode耗尽同样会导致No space left on device。所谓inode,简单理解就是文件系统里用来记录每个文件元数据的"登记表";哪怕你的磁盘还剩几十G,如果inode被成千上万的小文件占满了,一样无法创建任何新文件。这两个命令必须一起看。
我当时看到的结果是这样:
text复制Filesystem Size Used Avail Use% Mounted on
/dev/vda1 50G 50G 0G 100% /
根分区满,服务器必然出各种诡异问题。这里多说一句,如果你的系统盘和数据盘是分开的,一定要确认是哪个挂载点满了。业务日志写在数据盘、系统日志写在根分区,两者造成的故障场景完全不同,处理方式也不一样——数据盘满了优先找应用大文件,根分区满了优先清理系统和日志文件。
2.2 du逐层定位:哪个目录在膨胀
确认磁盘满之后,需要找出谁占用了空间。我习惯从根目录逐层往下看:
bash复制du -h --max-depth=1 / 2>/dev/null | sort -rh | head -20
![du根目录扫描示例]
这样能快速看到/var、/usr、/home等一级目录的占用。然后对可疑目录继续下钻:
bash复制du -h --max-depth=1 /var 2>/dev/null | sort -rh | head -20
du -h --max-depth=1 /var/log 2>/dev/null | sort -rh | head -20
我这次的情况是/var/log/journal占了接近18G,/var/log/nginx占了6G,/tmp下还有几个系统重启前残留的大文件,加起来远超根分区的可用空间,磁盘就是这样一点一点被写满的。
这里有个实操小技巧:du扫描大目录时可能比较慢,用--max-depth限制层级可以避免它一层层钻进深目录;同时把错误输出丢弃(2>/dev/null),避免大量Permission denied刷屏干扰阅读。如果目录实在太大,可以配合timeout 60 du ...控制扫描时长,防止命令本身卡死。
2.3 lsof +L1:发现隐藏的"磁盘黑洞"
用du扫完一遍,你以为就能定位所有空间?不一定。还有一种情况非常容易漏:文件已经被删除,但进程仍然持有文件句柄,空间不会释放。这种情况du统计不到,但df依然显示磁盘满,甚至你删了一圈文件之后使用率纹丝不动。
排查命令:
bash复制lsof +L1
lsof | grep deleted
+L1表示列出链接数小于1(即已被删除)但被打开的文件。我在线上不止一次看到过:某个Java进程的日志文件被rm删除后没有重启,文件句柄一直占着,磁盘空间被持续"吃"着。这种"幽灵空间"要等到对应进程重启或杀掉后才会释放。
记住这句话:
df看的是整个文件系统的真实占用,du看的是目录树里能看到的文件总和。两者对不上时,优先怀疑"已删除但未释放"的文件。
这部分原因比较复杂,我会在第五部分单独展开。定位到这里,方向已经清晰:日志和临时文件占满了磁盘。下面就开始动手清理。
3. 日志是主要占空间大户:journald和logrotate的默认配置坑
3.1 journald:一条命令解决90%的烦恼
在CentOS 7.6这样的systemd系统上,journald是系统日志的核心。很多人对它的理解是"日志存在内存里,重启就丢",这其实是个大坑。只要/var/log/journal目录存在,日志就会持久化到磁盘,而且默认情况下没有强制的总大小上限,或者上限非常宽松(通常是文件系统大小的10%)。一台50G的系统盘,journal最多能膨胀到好几个G甚至二十个G。
先看看当前journal占了多少:
bash复制journalctl --disk-usage
然后直接限制大小。灵活度最高的是--vacuum-size,它会把日志总量压到指定值以下:
bash复制journalctl --vacuum-size=200M
也可以按时间清理,保留最近七天的日志:
bash复制journalctl --vacuum-time=7d
还可以限制文件个数:
bash复制journalctl --vacuum-files=5
这三个参数可以组合使用,执行完再用journalctl --disk-usage验证。我当时执行--vacuum-size=200M之后,瞬间释放了约17G空间,效果立竿见影。
需要说明的是,--vacuum-size是立即清理现有日志的操作,不会影响正在运行的服务,执行过程也非常快,生产环境可以直接用。但如果你想一劳永逸,必须修改配置文件,把日志上限写死,这个我在第6节"预防体系"里会给出完整参数。
3.2 logrotate:轮转策略为什么"生效了却没用"
logrotate是Linux日志轮转的标准工具,CentOS 7.6默认安装并配合cron每天执行。按理说日志不应该无限膨胀,但实际环境里它经常"失效"。我总结了四种最常见的原因:
第一种,/etc/logrotate.conf里默认的weekly轮转周期太长。对于访问量大的服务,一周就能产生几十G日志,轮转速度完全跟不上写入速度。
第二种,应用自己往里写日志,但logrotate的配置文件里没有匹配到这个日志路径。很多第三方应用或者手动部署的服务,日志压根不受logrotate管理,需要你手动建配置。
第三种,配置了轮转,但rotate保留份数太多。比如保留30份周日志,那就是接近半年的日志量,对中小磁盘来说依然是巨大负担。
第四种,日志文件被进程占用,logrotate尝试轮转时无法重命名文件。这种情况最常见于Java应用和Nginx,日志文件句柄没有正确重新打开。
排查方法很简单:手动强制执行一次轮转并看输出:
bash复制logrotate -vf /etc/logrotate.conf
-v打印过程,-f强制执行。看到具体报错就能定位问题。另外一个实用技巧是查看/var/lib/logrotate/logrotate.status,确认哪些日志文件确实在执行轮转、哪些被跳过了。
以Nginx为例,一份比较合理的配置如下:
bash复制cat > /etc/logrotate.d/nginx <<'EOF'
/var/log/nginx/*.log {
daily
rotate 7
compress
delaycompress
missingok
notifempty
create 640 nginx adm
sharedscripts
postrotate
[ -f /var/run/nginx.pid ] && kill -USR1 $(cat /var/run/nginx.pid)
endscript
}
EOF
这里的关键点有两个:一是daily加rotate 7,最多保留7天日志,适合访问量中等的站点;二是postrotate部分给Nginx发送USR1信号,让它重新打开日志文件。如果漏掉这一步,轮转后Nginx还在往旧文件句柄里写,日志照样不"轮转",新日志还写到已经被重命名的旧文件里,等于白配。
3.3 其他高频日志目录清理手册
除了journald,业务服务器上还有几个高频日志目录,清理时不要漏:
| 目录/文件 | 典型占用 | 清理方式 |
|---|---|---|
/var/log/messages、/var/log/secure |
数G | truncate -s 0 或调整logrotate |
/var/log/nginx/ |
视访问量而定 | logrotate或truncate -s 0 |
/var/log/tomcat/、catalina.out |
可达数十G | 配置catalina.out轮转 |
/var/lib/mysql/ 下的binlog |
数G到数十G | PURGE BINARY LOGS BEFORE NOW()-INTERVAL 7 DAY |
/var/log/audit/audit.log |
数G | 调整auditd.conf的max_log_file |
对于还在被进程写入的日志,最安全、也最快的办法是truncate -s 0而不是rm。因为rm之后进程还持有旧句柄,磁盘空间并不释放,反而让系统出现"越删越满"的错觉;而truncate -s 0把文件内容直接清空,进程继续往里写也不受影响,空间立即回收。这个操作对绝大多数应用日志都适用,实测下来比"杀掉进程再清"温和得多,也是我处理满盘事故时最常用的招数。
4. 临时文件与缓存清理:能安全删除的远比想象的多
4.1 /tmp三件套与systemd私有临时目录
日志清理完之后,再看临时文件。/tmp、/var/tmp是最常见的位置,很多安装包、上传的临时文件、解压残留都会堆在这里,而且CentOS默认不会自动清理/tmp里所有内容——systemd-tmpfiles只负责清理特定规则下的文件,并不是无差别清空。
先看看占用:
bash复制du -sh /tmp /var/tmp 2>/dev/null
find /tmp -type f -mtime +7 -exec ls -lh {} \; | head -30
清理超过7天没改动的临时文件:
bash复制find /tmp -type f -mtime +7 -delete
find /var/tmp -type f -mtime +7 -delete
这里我建议用-mtime而不是直接rm -rf /tmp/*,原因有两个:一是直接删除可能误伤正在被其他进程使用的临时文件,二是有些临时目录权限特殊,-delete碰到不该删的会报错,但不会硬删。更保守的做法是先把find结果列出来人工扫一眼,确认没有重要的东西再执行删除。
还有一个容易被忽略的地方:以/tmp/systemd-private-*开头的目录,这是systemd给某些服务创建的私有临时目录,里面往往是应用的临时文件或者core dump碎片。确认对应服务已经不运行之后,这些目录可以安全删除。
4.2 yum缓存与core dump
/var/cache/yum是yum安装软件时下载的RPM包缓存。对于长期运行的服务器,这里也能积累好几个G。清理命令:
bash复制yum clean all
这条命令顺带会清理metadata缓存,磁盘空间充足的时候可以用来提升后续yum操作速度,空间紧张时则是实打实的回收空间。如果你是那种频繁装包、升级的"折腾型"服务器,这里甚至能清出好几个G。
core dump也要留意。如果你之前手动开启过core dump,或者使用了systemd-coredump,崩溃进程生成的核心转储文件可能异常庞大。JVM OOM时自动生成的/tmp/java_pid*.hprof、C程序崩溃产生的core.*,动辄就是几个G起步:
bash复制du -sh /var/lib/systemd/coredump 2>/dev/null
find / -xdev -name "core.*" -type f -size +100M -exec ls -lh {} \; 2>/dev/null
确认是无业务价值的崩溃残留后,直接删。这里有个经验:core dump文件往往带着诡异的权限,直接使用普通用户删除会被拒绝,建议确认后用root清理。
4.3 清理前后对比:多少空间是可以"抢回来"的
我把一次实际清理的对比记录放在这里,给读者一个直观感受:
| 清理项 | 清理前占用 | 清理后占用 |
|---|---|---|
| /var/log/journal | 17.8G | 约200M |
| /var/log/nginx | 6.1G | 约300M |
| /tmp下过期文件 | 2.4G | 约0 |
| /var/cache/yum | 1.2G | 约0 |
| MySQL binlog | 8.6G | 保留7天约2G |
一次下来释放超过30G,根分区使用率从100%直接降到30%左右。这个效果对于中小型服务器来说几乎是"起死回生"。
当然,不同环境差异会很大。有的服务器真正的占用者是数据库数据文件、Docker镜像、JVM堆转储,或者应用自身的数据目录,这时候清理日志和临时文件只能缓解,不能根治。要学会用du找出真正的大头,而不是只盯着常规目录。我见过一台服务器根分区被某个应用的快照文件占满,排查时所有人都在清日志,结果空间一点没多,最后才发现是快照文件躺在/home下,与日志毫无关系。
5. 清不完的"幽灵空间":已删除文件为何还占磁盘
5.1 为什么df满了,du却统计不到
清理日志的时候,我发现一个非常反直觉的现象:df -h显示根分区仍然100%,但用du把整个根目录扫了一遍,加起来远小于df统计的已用空间。两个命令统计到的磁盘差异非常大。
这种"对不上账"的情况,绝大部分是因为有文件被删除了,但仍有进程持有它的句柄。Linux的文件系统机制是:文件通过目录项(dentry)和文件句柄(fd)两层引用。rm只是把目录项这条链接删掉了,但只要还有进程打开着这个文件,文件数据块就不会真正释放。直到最后一个打开它的进程关闭句柄或者进程退出,空间才回归系统。
用生活化的类比来解释:文件就像一间屋子,目录项是门牌号,进程拿着钥匙住在里面。你把门牌号摘了(rm),但屋里的人没退房(句柄没关),这间屋子依然被占着,外人进不去,系统也收不回来。
也就是说,你du扫描的是目录树里能看到的文件,而df统计的是整个文件系统的实际已用块,包括这些"被删除但还活着"的数据块。这也是为什么网上很多"清理了却还是满"的求助帖,明明删了十几个G,df -h却纹丝不动——因为这些空间被看不见的句柄占着。
5.2 安全定位与释放被占用的"死文件"
定位这类"幽灵空间",用lsof:
bash复制lsof +L1
+L1的意思是列出link count小于1的文件。如果输出太多,可以配合grep过滤:
bash复制lsof +L1 | grep -E "deleted|/tmp|/var/log"
输出里一般包含进程PID、文件名、大小。我常见到的几种情况:
- Java应用删除了log文件但没有重启,占用持续存在;
- 程序先写临时文件再删除,但fd没关;
- JVM在OOM时生成了hprof文件,被某个监控脚本发现后随手
rm了,但JVM进程的句柄还开着; - 某些服务把文件unlink之后继续读写。
确认是哪个进程后,处理方式按优先级排序:
- 如果进程能重启,
systemctl restart最干净——句柄释放、空间回收、服务状态也恢复健康; - 如果不能马上重启,可以先
ls -l /proc/<PID>/fd/<FD>确认文件,再用truncate -s 0把对应文件清空,让空间尽快回收,等服务窗口再重启; - 实在要保进程,也有用
gdb等工具去关fd的方案,但风险高,不推荐在生产环境操作,稍不留神就把进程搞崩了。
遇到"删了文件空间没释放",先别急着继续删,用
lsof +L1查一下是不是有进程占着,比盲目扩大清理范围要高效得多。
我处理那次故障时,定位到一个Java进程持有/tmp/java_pid*.hprof堆转储文件,这个文件占了足足好几个G,正因为JVM进程一直活着,删除并没有真正释放空间。重启应用之后,空间才真正回到系统里。这里提醒一下:JVM的-XX:+HeapDumpOnOutOfMemoryError会在OOM时自动生成hprof,如果你没确认过堆转储的位置,它可能落在/tmp或者进程工作目录,既占空间,还可能包含敏感的上层业务数据。排查磁盘时,多加留意这类文件。
6. 复盘后的预防体系:日志上限、监控脚本和应急清单
6.1 journald与logrotate永久配置
空间清理干净只是第一步,如果不做限制,过几个月大概率会再次发生同样的问题。先给journald加上硬性上限。编辑/etc/systemd/journald.conf:
ini复制[Journal]
Storage=persistent
SystemMaxUse=500M
RuntimeMaxUse=200M
MaxFileSec=1week
说明一下:SystemMaxUse=500M意味着系统日志总大小最多500M,超过后journald会自动清理最老的日志;RuntimeMaxUse=200M限制内存日志;MaxFileSec=1week确保单个日志文件不超过一周。改完重启服务:
bash复制systemctl restart systemd-journald
再配合logrotate,给业务日志做好轮转策略。前面已经给了Nginx的例子,这里提醒一个常见误区:不是配了logrotate就万事大吉,还要确认cron任务在跑。CentOS 7的logrotate由/etc/cron.daily/logrotate触发,如果你之前把cron服务停掉过,logrotate自然失效。检查方式:
bash复制systemctl status crond
cat /etc/cron.daily/logrotate
另外,如果你的服务器是CentOS 7.6且长期不重启,另一个隐蔽的定时清理点也要检查:/etc/systemd/journald.conf改完之后,部分场景需要重启systemd-journald才会生效,否则journald还是按旧配置运行。这类"改了配置不重启等于没改"的坑,在systemd体系里特别常见。
6.2 一个可靠的磁盘空间监控脚本
光有清理策略不够,还要有监控。我自己在服务器上放了一个很简短的脚本,配合crontab每5分钟检查一次,超过阈值就告警。核心逻辑如下:
bash复制#!/bin/bash
# /usr/local/bin/disk-check.sh
THRESHOLD=85
CURRENT=$(df / | awk 'NR==2 {print $5}' | sed 's/%//')
if [ "$CURRENT" -ge "$THRESHOLD" ]; then
echo "$(date) 磁盘使用率已达 ${CURRENT}%" >> /var/log/disk-check.log
# 这里可以换成你的告警渠道:邮件、企业微信机器人、钉钉机器人、短信网关
curl -s -X POST "https://your-alert-url" -H "Content-Type: application/json" -d "{\"msg\":\"disk usage ${CURRENT}%\"}"
fi
加入crontab:
bash复制chmod +x /usr/local/bin/disk-check.sh
echo "*/5 * * * * root /usr/local/bin/disk-check.sh" >> /etc/crontab
这个脚本足够简单、稳定,不容易出问题。如果希望更精细,可以用du针对/var/log等具体目录做二次检查,或者直接上Prometheus + node_exporter做可视化监控——但那是另一套架构了。对单机服务器来说,上面的脚本已经能挡住90%的"满盘事故"。这里的告警URL建议在实际使用中替换成你自己的告警服务地址,别直接照抄。
6.3 应急响应清单:下次再满,十分钟内处理完
几次踩坑之后,我把"磁盘满"的排查整理成了一份固定清单,分享给大家:
df -h和df -i确认整体状况;du -h --max-depth=1 /逐级定位大目录;journalctl --disk-usage和journalctl --vacuum-size=200M清理journald;du -sh /var/log /tmp /var/tmp /var/cache /var/lib/mysql检查业务日志与临时文件;lsof +L1排查已删除但未释放的文件;- 清理完成后用
df -h确认空间恢复。
这套清单看起来没有任何高深技巧,但正是这些"朴实无华"的操作,能在最短时间内把服务救回来。我在实际处理过的几起"服务连环崩溃"事件里,每一件最终都落到了同一句话上:磁盘满了。问题本身不难,难的是第一时间把思路从应用层拉回系统层。
如果你有测试机,我强烈建议主动做一次演练:把磁盘灌到90%以上,观察服务表现,再按上面的清单完整走一遍。经历过一次之后,你对"磁盘空间不足"的症状会变得异常敏感,再遇到类似问题,基本能省下所有绕路的时间。说实话,这种问题出过一次之后,最该改的不是命令怎么敲,而是对基础资源监控的敬畏心——STOP把"磁盘空间充裕"当成理所当然的前提,它就是会在你最意想不到的凌晨三点,给你上一课。
