先看现象:磁盘是被谁吃掉的
运维一年以上的人大概都遇过一次:df -h 显示 / 或 /var 已经 100%,站点开始报 500,MySQL 写不进去,Nginx 也起不来。第一反应是 du -sh /*,结果把所有目录加起来只有 12G,而分区是 40G,剩下 28G 凭空消失了。这个「差额」不是幻觉,几乎都是被删掉但没真正释放的文件,或者被保留的大文件,比如 binlog、系统日志、数据库临时文件。
这篇文章按我自己的排查顺序,把「磁盘不明不白被吃满」这件事拆成四类来源:已删除但被进程占用的文件、MySQL 的 binlog 与 undo 与临时表、systemd-journald 的持久化日志、以及容易忽略的 Docker 层与备份文件。每一类都给一条可以直接执行的定位命令和一套安全清理方式,最后再给一份巡检脚本。
第一类:df 和 du 差出几十 G,多半是 deleted 文件
Linux 的文件被 unlink 之后,只有引用计数归零才会释放磁盘块。只要还有进程握着这个文件描述符,空间就一直占着。典型场景是:日志被 rm 掉但 Nginx 没有 reload、MySQL 的临时表被删但连接还没断、某个进程用 O_TMPFILE 创建了大文件。
定位命令只有一条,加上 +L1 表示「链接数为 1 以下」,也就是已删除但仍被打开的文件:
lsof +L1 2>/dev/null | awk 'NR==1 || $7>104857600' | sort -k7 -rn | head -20第七列是文件大小(字节),我常用 100MB 作为过滤门槛。输出里会看到类似这样的行:
nginx 1234 www-data 12w REG 253,0 8589934592 131074 /var/log/nginx/access.log (deleted)看到 (deleted) 就确认了。接下来不要急着重启服务,先做三件事:
第一,确认这个进程能不能承受一次 reload 或重启。Nginx 用 nginx -s reopen 或 kill -USR1 $(cat /run/nginx.pid) 就会重新打开日志文件,旧 inode 立刻释放,业务完全无感。这是成本最低的处置。
第二,如果是应用的 PHP-FPM 子进程握着某个被删的临时文件,单独 kill 那个 worker 没用多久又会重新出现,得找到写这个文件的代码路径。我遇到过一次是 file_put_contents('/tmp/xxx.log', ...) 没清理,配合 systemd 的 PrivateTmp,日志写进了私有的 /tmp,运维在宿主机上根本看不到,只能靠 du -sh /proc/PID/root/tmp 去看。
第三,把「rm 日志」这个动作从习惯里删掉。正确的做法是让 logrotate 用 postrotate 发信号,或者干脆用 copytruncate。如果已经养成 rm -f 的习惯,迟早会再吃一次同样的亏。
还有一种更隐蔽的情况:进程被删了但还活着(通常是升级之后没重启),lsof 里显示 /usr/sbin/nginx (deleted)。这种不是空间问题,但会解释「配置文件改了不生效」的怪现象,因为跑的还是旧二进制。用 ps -o lstart= -p PID 对比包的安装时间就能看出来。
第二类:MySQL 的 binlog、undo 与临时表
做完第一类排查后如果差额还在,第二个怀疑对象一定是 MySQL。它的 /var/lib/mysql 目录会同时增长三样东西:binlog、undo 表空间、临时表空间。du 能看到的只是已有文件,但 binlog 的问题在于「每天生成几百兆、没人清理」。
先确认 binlog 总量和过期设置:
SELECT @@log_bin, @@binlog_expire_logs_seconds, @@max_binlog_size;
SHOW BINARY LOGS;MySQL 8.0.29 之后推荐用 binlog_expire_logs_seconds,单位是秒,默认 30 天(2592000)。如果这一项是 0 而 log_bin=ON,那就是「永不自动清理」,主从环境下一两个月就能吃掉几十 G。把它设成 7 天:
SET GLOBAL binlog_expire_logs_seconds = 604800;并写进 my.cnf 的 [mysqld] 段,否则重启就丢。max_binlog_size 建议控制在 500M 以内,太大不利于归档。
更严重的情况是有从库还在用这些 binlog。直接 PURGE BINARY LOGS 断掉复制会引发一次事故,所以清理前必须先看从库位置:
SHOW REPLICA STATUS\G看 Source_Log_File 与 Read_Source_Log_Pos。安全做法是用 PURGE BINARY LOGS BEFORE 指定一个绝对早于所有从库已消费位置的时间点,或者用 PURGE BINARY LOGS TO 'mysql-bin.000123' 指定文件名。后者更精确,我一般先 SHOW BINARY LOGS 列出所有文件,找到从库正在读的那个文件的前一个,再执行。
undo 表空间是第二号嫌疑人。MySQL 8.0 默认有 2 个 undo 表空间,innodb_undo_tablespaces 不再允许改小。查询实际占用:
SELECT name, file_size / 1024 / 1024 AS mb FROM information_schema.innodb_tablespaces WHERE name LIKE '%undo%';undo 文件不会自动缩小(innodb_undo_log_truncate 要开启并且至少 2 个文件才会触发)。如果某个长事务跑了几小时,undo 会一路膨胀,事务结束之后也不回落。开启 truncate 的正确配置是:innodb_undo_log_truncate=ON 与 innodb_max_undo_log_size=1G,然后需要一次重启让它生效。此后再有膨胀,后台线程会自动裁到初始大小。
临时表空间 ibtmp1 也可能膨胀到几 G 到几十 G,典型的元凶是一个不带索引条件的 GROUP BY 或者 ALTER TABLE。它只在 MySQL 重启时才会重建,运行期无法缩小。查一下有没有大额磁盘临时表:
SELECT SUM(created_tmp_disk_tables) AS disk_tmp, SUM(created_tmp_tables) AS total_tmp FROM performance_schema.global_status WHERE variable_name IN ('Created_tmp_disk_tables','Created_tmp_tables');比例超过 25% 就说明很多查询在往盘上写临时表,收益最高的事是给相关查询补索引,而不是清理磁盘。
第三类:systemd-journald 的持久化日志
Debian 和 Ubuntu 默认的 journald 是把日志写到 /var/log/journal,并且有容量上限。上限默认是「文件系统大小的 10%」,但不超过 4G。在 40G 的机器上就是 4G,看起来不多。问题出在两处:一是容器里跑的服务也往这里写;二是有服务死循环刷错误日志,一小时就能写满 2G。
先看实际占用和上限:
journalctl --disk-usage
grep -E 'SystemMaxUse|RuntimeMaxUse|MaxRetentionSec' /etc/systemd/journald.conf如果 --disk-usage 已经接近上限,而你又不想减少保留天数,可以直接按时间砍:
journalctl --vacuum-time=7d
journalctl --vacuum-size=500M这两条是即时生效的,不需要重启。要长期生效就改 /etc/systemd/journald.conf:
[Journal]
SystemMaxUse=500M
SystemMaxFileSize=50M
MaxRetentionSec=2week
Compress=yes改完 systemctl restart systemd-journald。这里有个容易搞错的点:SystemMaxUse 是总量上限,SystemMaxFileSize 是单个文件大小,前者缺省时后者不生效。两个都要写。
比清理更重要的是找到「谁在刷日志」。一条命令就能按 unit 排出写入量:
journalctl --since "24 hours ago" --no-pager -o json \
| jq -r '.SYSLOG_IDENTIFIER // ._COMM // "unknown"' \
| sort | uniq -c | sort -rn | head -15我遇到最多的是 PHP 的 Warning: Undefined array key、MySQL 的 Aborted connection、以及某个自写 cron 脚本每五分钟报一次错。这些都不该出现在生产日志里,修掉它们比调 journald 参数有价值得多。
第四类:Docker 与备份文件
如果站点跑在 Docker 里,还有两个额外的空间黑洞。第一是 /var/lib/docker/overlay2:每次 docker build 都会留一层,docker rmi 之后如果还有容器引用就不释放。查看真实占用:
docker system df -v
docker builder prune --filter until=168h注意 docker system prune -a 会删掉所有未运行容器的镜像,在有 restart: always 的容器因为故障停止时,这一条命令可能导致「容器起不来,因为镜像没了」。我一般只清 builder cache 和 dangling 镜像。
第二是容器日志。json-file 驱动默认不限大小,一个 debug 级别输出的容器能写几十 G。检查:
du -sh /var/lib/docker/containers/*/*-json.log 2>/dev/null | sort -h | tail -5根治办法是在 /etc/docker/daemon.json 里设全局上限:
{
"log-driver": "json-file",
"log-opts": { "max-size": "50m", "max-file": "3" }
}改完要重启 docker daemon,已有容器需要重建才生效,这点必须提前告知。
备份文件是第四类中最容易被忽视的。曾经的备份如果没设置保留策略,会一天一个 3G 全备,最后把磁盘吃完。查一下最近的大文件:
du -ah / --exclude=/proc --exclude=/sys 2>/dev/null | sort -h | tail -25我自己的策略是:数据库每日全备保留 7 天、每周一份保留 4 周、每月一份放对象存储,本地只留 7 天。脚本里用 find /backup -type f -mtime +7 -delete 收尾,并把这行放在 set -e 之前,避免备份失败时误删。
一套能提前发现的巡检脚本
上面四类问题,靠人工发现都太晚。我在每台机器上放了一个每天跑一次的只读巡检,超过阈值才发邮件:
#!/bin/bash
set -uo pipefail
HOST=$(hostname -s)
ALERT=""
# 1) 分区使用率超过 85%
while read -r pct mnt; do
p=${pct%\%}
[ "$p" -ge 85 ] && ALERT+="分区 $mnt 使用率 $pct"$'\n'
done < <(df -P -x tmpfs -x devtmpfs | awk 'NR>1{print $5, $6}')
# 2) deleted 文件合计超过 1G
DEL=$(lsof +L1 2>/dev/null | awk 'NR>1{s+=$7} END{printf "%d", s/1048576}')
[ "${DEL:-0}" -ge 1024 ] && ALERT+="已删除未释放文件 ${DEL}MB,检查 lsof +L1"$'\n'
# 3) journald 超过 400M
JD=$(journalctl --disk-usage 2>/dev/null | grep -oE '[0-9.]+[MG]' | head -1)
case "$JD" in
*G) ALERT+="journald 占用 $JD"$'\n' ;;
esac
# 4) inode 使用率
while read -r pct mnt; do
p=${pct%\%}
[ "$p" -ge 80 ] && ALERT+="inode $mnt 已用 $pct"$'\n'
done < <(df -Pi -x tmpfs | awk 'NR>1{print $5, $6}')
[ -n "$ALERT" ] && printf '主机 %s 磁盘巡检\n%s' "$HOST" "$ALERT" | mail -s "[$HOST] 磁盘告警" ops@example.com
exit 0脚本本身只读,不会删任何东西,这点很重要:自动清理脚本在故障时反而容易造成二次事故。所有清理动作都应该有人看一眼 lsof 输出再执行。
清理顺序与三条红线
真的满了、必须在十分钟内腾出空间时,我按这个顺序做:先 journalctl --vacuum-size=200M(最安全,无损),再 nginx -s reopen 释放 deleted 日志(无损),然后清 /var/cache/apt/archives 与 PHP 的 opcache 目录,接着按保留策略删旧备份,最后才考虑 binlog。临时表空间与 undo 不在这条快路径上,除非能安排重启。
三条红线:不要在磁盘满的时候跑 fsck;不要用 rm -rf /var/lib/mysql/* 这种命令去「清临时文件」;也不要指望重启能解决所有问题,因为 ibtmp1 释放之后,同一个查询第二天照样把它撑起来。
磁盘告警这件事的价值不在「满了怎么救」,而在于把 85% 这条线当成一个需要在一周内处理的待办。真正吃过一次「凌晨三点磁盘写满、数据库进入只读」的亏之后,就会明白巡检脚本那十几行代码,比任何一次英勇的救火都值得。