引言 — df 说还剩 38%,一个 touch 却失败了
症状是这样开始的。应用写不了日志,部署在创建临时文件时失败,数据库写不了 WAL。登进去一看,磁盘还有余量。
df -h /var
# Filesystem Size Used Avail Use% Mounted on
# /dev/nvme0n1p2 100G 61G 35G 64% /
touch /var/log/test
# touch: cannot touch '/var/log/test': No space left on device
Avail 有 35GB,却连一个 0 字节的文件都创建不了。这时很容易下结论说“监控出问题了”,但 df 只是诚实地报告了块使用量而已。ENOSPC 是 errno 28,内核返回这个值的路径除了块不足之外还有好几条。
本文按概率顺序列出这些路径,给出确认每一条的命令、解决办法,以及如何防止复发。按顺序走一遍,5 分钟就能找出原因。
先确认一件事 — 你真的在往那个文件系统写吗
开始诊断之前先花 30 秒。最常见的错误是不带参数执行 df -h,用眼睛扫一遍输出的列表,然后猜失败的路径属于哪一项。请直接去问这个路径。
# 这个路径实际所属的文件系统
findmnt -T /var/log/app/application.log
# TARGET SOURCE FSTYPE OPTIONS
# /var /dev/nvme0n1p5 xfs rw,relatime,attr2,inode64,logbufs=8
df -h /var/log/app/
# Filesystem Size Used Avail Use% Mounted on
# /dev/nvme0n1p5 20G 20G 0 100% /var
不是 /,而是 /var 是单独的分区,满的是那一边。如果是这种情况,到这里就结束了。同样地,/tmp 是 tmpfs 的环境也很常见。
df -h /tmp /dev/shm
# Filesystem Size Used Avail Use% Mounted on
# tmpfs 3.2G 3.2G 0 100% /tmp
# tmpfs 7.9G 6.1G 1.8G 78% /dev/shm
tmpfs 用的是内存。这里满了就会与磁盘无关地报 ENOSPC,同时还在吃掉与该容量相当的物理内存。
如果路径确认完毕,Avail 仍然显示有余量,那么正题从这里开始。
原因 1:inode 耗尽 — 块还有剩余,文件个数先用光了
最常见的原因。确认只要一行。
df -i /
# Filesystem Inodes IUsed IFree IUse% Mounted on
# /dev/nvme0n1p2 6553600 6553600 0 100% /
IUse% 是 100%。ext2/3/4 在格式化时就确定了 inode 个数,之后无法增加。默认值由 mke2fs 的 -i 选项(bytes-per-inode)决定,默认配置大约是每 16KB 一个。也就是说 100GB 的文件系统上约有 655 万个。如果工作负载的平均文件大小小于 16KB,那么 inode 会比块先用完。
下面是找出罪魁目录的方法。du 数的是容量不是个数,所以在这里派不上用场。
# 按目录统计文件个数的前 10 名(只在同一个文件系统内)
sudo find / -xdev -printf '%h\n' 2>/dev/null | sort | uniq -c | sort -rn | head -10
# 2841193 /var/spool/postfix/maildrop
# 84120 /var/lib/php/sessions
# 38914 /home/deploy/.cache/pip/http
# 18422 /var/lib/apt/lists
# 9104 /usr/share/man/man3
-xdev 很关键。去掉它就会连挂载的其他文件系统一起扫,耗时翻好几倍,结果也被污染。如果目录树很大、上面这条命令跑得太久,就从最顶层开始逐步收窄。
for d in /*/; do
printf '%9s %s\n' "$(sudo find "$d" -xdev 2>/dev/null | wc -l)" "$d"
done | sort -rn | head -5
# 2925104 /var/
# 91208 /usr/
# 42011 /home/
典型的罪魁就那几个。邮件队列(postfix 的 maildrop、deferred)、PHP 会话目录、包管理器缓存、会留下会话或锁文件的应用,以及一直不清理的容器层。
解决分两步。眼下先删。文件数量太多时 rm -rf 会撞上参数长度限制,所以交给 find。
# 删除超过 30 天的会话文件(数量多时更安全的写法)
sudo find /var/lib/php/sessions -type f -mtime +30 -delete
# 一边看删除进度一边删(文件有几百万个时)
sudo find /var/spool/postfix/maildrop -type f -mtime +7 -print -delete | pv -l > /dev/null
根本解决办法是重建或迁移文件系统。在 ext4 上增加 inode 只能重新格式化。
# 每 4KB 一个 inode(默认的 4 倍)。数据会全部消失
sudo mkfs.ext4 -i 4096 /dev/nvme0n1p6
# 或者直接指定 inode 个数
sudo mkfs.ext4 -N 20000000 /dev/nvme0n1p6
迁到 XFS 也是务实的选择。XFS 动态分配 inode,所以这个问题事实上不存在。不过并非完全不存在,因为 imaxpct 会限制 inode 可以占用的空间比例。
xfs_info /var | grep -E 'imaxpct|isize'
# meta-data=/dev/nvme0n1p5 isize=512 agcount=4, agsize=1310720 blks
# = sectsz=512 attr=2, projid32bit=1
# data = bsize=4096 blocks=5242880, imaxpct=25
# 需要的话可以调高上限(在已挂载状态下即可执行)
sudo xfs_growfs -m 50 /var
原因 2:已删除但仍被打开的文件
如果 df 和 du 差得很多,就是这一类。
df -h /var
# Filesystem Size Used Avail Use% Mounted on
# /dev/nvme0n1p5 20G 20G 0 100% /var
sudo du -shx /var
# 3.1G /var
df 说用了 20GB,du 只找到 3.1GB。这 17GB 的差额就是目录项已经消失的文件。在 UNIX 中,只有当链接数为 0 且打开的描述符也为 0 时,文件才会被真正释放。rm 只删链接。只要有人还打开着那个文件,空间就不会归还,而 du 是沿路径遍历的,所以数不到那个文件。
下面是确认用的命令。
sudo lsof -nP +L1 | head
# COMMAND PID USER FD TYPE DEVICE SIZE/OFF NLINK NODE NAME
# java 21874 app 12w REG 259,5 8589934592 0 1183241 /var/log/app/application.log (deleted)
# nginx 14022 www 9w REG 259,5 4294967296 0 1183502 /var/log/nginx/access.log (deleted)
# rsyslogd 1288 root 7w REG 259,5 429496729 0 1183210 /var/log/syslog.1 (deleted)
+L1 的意思是只输出链接数小于 1 的已打开文件,NLINK 列里的 0 正是这个状态。SIZE/OFF 列就是没有被归还的字节数。上面三个加起来约 12GB。
在没装 lsof 的最小镜像里,就直接看 /proc。
sudo ls -l /proc/[0-9]*/fd/* 2>/dev/null | grep '(deleted)' | awk '{print $9, $10, $11}' | head
# /proc/21874/fd/12 -> /var/log/app/application.log
# /proc/14022/fd/9 -> /var/log/nginx/access.log
原因几乎总是同一个。日志轮转之后进程没有重新打开文件。logrotate 用 create 方式轮转时会把原文件挪走并创建新文件,而进程仍然在往旧的 inode 写。本该在 postrotate 脚本里发信号,可这一段要么漏了要么失败了。
解决办法有两个。
# A)给进程发重新打开的信号(最干净)
sudo systemctl reload nginx # nginx 用 USR1
sudo kill -HUP 1288 # rsyslogd
# B)如果无法重启,就通过 fd 直接清空
sudo truncate -s 0 /proc/21874/fd/12
B 方案有副作用。如果进程是以 O_APPEND 打开的,下一次写入会从文件末尾(现在是 0)接着写,没有问题;否则它会继续在旧的偏移量上写,文件就变成稀疏文件。实际占用的块确实减少了,但 ls -l 显示的大小依然很大。只把它当作应急处理,正式的解决办法是 A 方案。
修改 logrotate 配置才是防止复发的做法。
cat /etc/logrotate.d/app
# /var/log/app/*.log {
# daily
# rotate 14
# compress
# delaycompress
# missingok
# notifempty
# copytruncate
# }
copytruncate 会先复制原文件,然后把原文件截断为 0。inode 得以保留,所以进程什么都不用做。代价是复制和截断之间写入的日志有可能丢失。如果丢不起,就不要用 copytruncate,改在 postrotate 里发信号,并且不要让那个脚本失败了还悄悄放过去。
postrotate
systemctl reload app.service || systemctl kill -s USR1 app.service
endscript
原因 3:保留块 — 只有 root 能用的 5%
ext2/3/4 会把全部块中的一定比例留给 root 专用。默认值是 5%。目的有两个:一是磁盘写满时管理员仍能登录进去清理,二是给分配器留出寻找连续块的余地,从而推迟碎片化。
这种情况的症状很有特点。用 root 可以创建文件,用服务账号却不行。
sudo touch /var/lib/app/probe && echo "root: ok"
# root: ok
sudo -u appuser touch /var/lib/app/probe2
# touch: cannot touch '/var/lib/app/probe2': No space left on device
确认要靠 tune2fs。
sudo tune2fs -l /dev/nvme0n1p2 | grep -E 'Block count|Reserved block count|Block size|Free blocks'
# Block count: 26214400
# Reserved block count: 1310720
# Free blocks: 1310698
# Block size: 4096
Reserved block count 是 1310720,Free blocks 几乎相同。意思是剩下的空间全都是保留部分。1310720 乘以 4096 约等于 5.0GiB,26214400 分之 1310720 正好是 5%。
事先了解 df 如何呈现这种情况,可以少走很多弯路。df 的 Avail 是扣掉保留部分之后的值,而 Use% 是用量除以(用量加 Avail)算出来的。所以在只剩保留部分的那一刻,Avail 变成 0,Use% 变成 100%。也就是说,属于这个原因时 df 通常显示 100%,而“明明还有空间”的印象来自它与 du 的差异,或者来自用 root 账号写入成功。
在 100GB 的数据卷上,5% 就是 5GB。如果是非根文件系统的纯数据卷,这个比例就过头了。
# 降到 1%。在已挂载状态下立即生效,数据是安全的
sudo tune2fs -m 1 /dev/nvme0n1p2
# Setting reserved blocks percentage to 1% (262144 blocks)
# 如果想用绝对个数来指定
sudo tune2fs -r 262144 /dev/nvme0n1p2
建议的基准是这样。根文件系统保持 5%。没有理由制造一个管理员进不去的局面。日志或数据专用卷降到 1% 也无妨,若是数 TB 的卷可以设成 0,代之以扎实的监控。不过把保留设成 0 之后,文件系统快满时碎片化会加快,所以不建议用在长期运行在 90% 以上的卷上。
原因 4 和 5:挂载遮蔽、容器 overlay,以及假的 ENOSPC
如果 df 和 du 对不上,但又没有已删除但仍被打开的文件,剩下的候选就是它。在已经放着文件的目录上挂载另一个文件系统,原来的文件就看不见了。可那些文件依然占着下层文件系统的空间。du 只遍历挂载上来的那一层,所以永远找不到它们。
典型的场景是这样。/var/log 里已经堆了日志,这时接上新磁盘并挂到 /var/log。现在那 40GB 旧日志被困在根文件系统里,任何工具都看不见。
确认的办法是把根绑定挂载到别的地方,去看没有被遮住的原始内容。
sudo mkdir -p /mnt/rootfs
sudo mount --bind / /mnt/rootfs
# 被遮住的文件在这里就能看见
sudo du -xhd1 /mnt/rootfs/var | sort -h | tail -5
# 1.2G /mnt/rootfs/var/lib
# 2.8G /mnt/rootfs/var/cache
# 41G /mnt/rootfs/var/log
# 46G /mnt/rootfs/var
sudo ls -la /mnt/rootfs/var/log | head
# -rw-r----- 1 syslog adm 18253611008 Mar 2 04:11 syslog.1
# -rw-r----- 1 syslog adm 22091571200 Feb 18 03:52 kern.log.1
# 清理完之后卸载
sudo rm -f /mnt/rootfs/var/log/*.1
sudo umount /mnt/rootfs
想确认挂载本身是否重叠,就用 findmnt。
findmnt --list -o TARGET,SOURCE,FSTYPE,SIZE,USED,AVAIL | head
# TARGET SOURCE FSTYPE SIZE USED AVAIL
# / /dev/nvme0n1p2 ext4 100G 96G 0
# /var/log /dev/nvme1n1p1 ext4 200G 12G 178G
# /var/lib/docker /dev/nvme2n1p1 xfs 500G 310G 190G
在容器里还要再加一层。在容器内部执行 df 会看到 overlay 文件系统,而这个值通常是宿主机上放着 /var/lib/docker(或 containerd 根目录)的那个文件系统的值。
# 容器内部
df -h /
# Filesystem Size Used Avail Use% Mounted on
# overlay 500G 310G 190G 63% /
虽然显示还剩 190GB,实际能用的量可能不一样。因为有三种上限是分别生效的。
- 运行时的存储上限:
docker run --storage-opt size=10G。在 overlay2 驱动下,只有后端文件系统是以prjquota选项挂载的 XFS 时才起作用。条件不满足时这个选项本身就会被拒绝。 - Kubernetes 的 ephemeral-storage:它不是上限,而是驱逐标准。超过之后不会报 ENOSPC,而是 Pod 被 Evicted。错误信息完全不同,因此可以区分开。
- emptyDir 的 sizeLimit:内存后端的 emptyDir 就是 tmpfs,所以超过时会报真正的 ENOSPC。
宿主机这边是什么在吃空间,可以这样看。
docker system df -v | head -20
# TYPE TOTAL ACTIVE SIZE RECLAIMABLE
# Images 148 12 182.4GB 161.2GB (88%)
# Containers 31 12 41.2GB 38.1GB (92%)
# Local Volumes 22 6 84.9GB 71.3GB (83%)
# Build Cache 412 0 38.2GB 38.2GB
# 安全回收(正在使用的不会被动到)
docker image prune -a --filter 'until=168h'
docker builder prune --filter 'until=168h'
容器日志也常被忘记。json-file 驱动默认会无限增长。
sudo du -sh /var/lib/docker/containers/*/*-json.log | sort -h | tail -3
# 8.2G /var/lib/docker/containers/3f7b91ac.../3f7b91ac...-json.log
# 用守护进程全局配置加上限
cat /etc/docker/daemon.json
# {
# "log-driver": "json-file",
# "log-opts": { "max-size": "100m", "max-file": "5" }
# }
这个配置从新创建的容器开始生效。已有的容器需要重建。
原因 5:inotify 报出的同样文字
最后一个陷阱。inotify_add_watch 在达到 watch 数量上限时会返回 ENOSPC。于是使用文件监视的工具会和磁盘毫无关系地打印出“No space left on device”。如果文件系统好端端的,只有某个特定进程报这个错,那就往这边怀疑。
sysctl fs.inotify.max_user_watches fs.inotify.max_user_instances
# fs.inotify.max_user_watches = 65536
# fs.inotify.max_user_instances = 128
# 找出持有大量 watch 的进程
sudo find /proc/[0-9]*/fdinfo -type f 2>/dev/null \
| xargs grep -l '^inotify' 2>/dev/null \
| cut -d/ -f3 | sort -u \
| while read -r pid; do
n=$(grep -hc '^inotify' /proc/"$pid"/fdinfo/* 2>/dev/null | paste -sd+ | bc)
printf '%7s %6s %s\n' "$n" "$pid" "$(tr -d '\0' < /proc/"$pid"/comm)"
done | sort -rn | head -5
# 58210 9931 node
# 4102 3311 containerd
echo 'fs.inotify.max_user_watches = 524288' | sudo tee /etc/sysctl.d/60-inotify.conf
sudo sysctl --system
防止复发 — 一张判断表和两个告警
把全部内容整理成一张判断表。从上往下依次确认即可。
| 原因 | 决定性信号 | 确认命令 | 解决 |
|---|---|---|---|
| 认错路径 | 另一个挂载点是 100% | findmnt -T PATH | 清理或扩容该文件系统 |
| inode 耗尽 | IUse% 是 100%,块还有余量 | df -i | 清理小文件,重新格式化时增加 inode,改用 XFS |
| 已删除但仍被打开的文件 | df 与 du 相差很大,NLINK 为 0 | lsof -nP +L1 | 让进程重新打开,修改 logrotate 配置 |
| 保留块 | root 写入成功,只有普通账号失败 | tune2fs -l | tune2fs -m 1 |
| 挂载遮蔽 | df 与 du 对不上但没有打开的文件 | mount --bind / 之后 du -x | 删除被遮住的文件 |
| 容器上限 | 容器内 df 有余量,只有写入失败 | docker system df -v | 清理镜像与日志,给日志驱动设上限 |
| inotify watch 耗尽 | 只有特定进程失败,文件系统正常 | sysctl fs.inotify.max_user_watches | 调高上限,缩小监视范围 |
走到这一步,最后要做的就是让它不再发生。
同时监控块和 inode。大多数仪表盘只看块使用率。node_exporter 两个指标都会导出,所以只要加上第二条告警就行。
# 块
(1 - node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}
/ node_filesystem_size_bytes{fstype!~"tmpfs|overlay"}) > 0.85
# inode — 很多地方都没有这条告警
(1 - node_filesystem_files_free{fstype!~"tmpfs|overlay"}
/ node_filesystem_files{fstype!~"tmpfs|overlay"}) > 0.85
也要一起看增长率。哪怕在使用率 85% 时告警响了,如果 30 分钟后就会到 100%,那也来不及。用 predict_linear 以预计耗尽时刻为基准更实用。
predict_linear(node_filesystem_avail_bytes{mountpoint="/var"}[6h], 4 * 3600) < 0
真正去验证日志轮转。配置文件存在和它真的能工作是两回事。
# 不实际执行,只确认哪些文件是对象
sudo logrotate -d /etc/logrotate.conf 2>&1 | grep -E 'considering|rotating|error'
# 强制执行一次,连 postrotate 脚本一起验证
sudo logrotate -vf /etc/logrotate.d/app
# 执行完立刻确认还有没有残留的已删除但仍被打开的文件
sudo lsof -nP +L1 | grep -c deleted
# 0
把定期检查做成一行。与其在故障时靠回忆去翻,不如留成脚本。
#!/usr/bin/env bash
# fs-health.sh — 一次性排查 ENOSPC 的原因候选
set -u
echo "== 块使用率 =="
df -hT -x tmpfs -x devtmpfs
echo; echo "== inode 使用率 =="
df -i -x tmpfs -x devtmpfs
echo; echo "== 已删除但仍被打开的文件(前 5 个) =="
lsof -nP +L1 2>/dev/null | awk 'NR>1 {print $7, $1, $2, $NF}' | sort -rn | head -5
echo; echo "== 保留块比例 =="
for dev in $(lsblk -pnro NAME,FSTYPE | awk '$2 ~ /^ext[234]$/ {print $1}'); do
tune2fs -l "$dev" 2>/dev/null \
| awk -v d="$dev" '/Block count/{t=$3} /Reserved block count/{r=$3}
END {if (t) printf "%s %.1f%%\n", d, r*100/t}'
done
echo; echo "== 挂载遮蔽的候选(df 与 du 不一致) =="
findmnt --list -o TARGET,SOURCE,FSTYPE,USE% -t ext4,xfs
结语 — 不要只看 df 就下判断
如果只记住一件事,那就是这个。ENOSPC 的意思不是“没有块了”,而是“内核没有资源来接受这次写入”,而那个资源可能是块,可能是 inode,可能是扣除保留之后的空闲,也可能是 inotify watch。
把诊断顺序压缩成一条线,就是这样。
- 用
findmnt -T确定路径所属的文件系统。一半的情况到这里就结束了。 - 用
df -i看 inode。它是与块相互独立的资源。 - 比较
df和du -x。对不上就是已删除但仍被打开的文件,或者是挂载遮蔽。前者用lsof +L1,后者用绑定挂载来区分。 - 用 root 可以、用服务账号不行,那就是保留块。用
tune2fs -m调整。 - 文件系统好端端的,只有某个特定进程报这个错,就怀疑 inotify。
- 告警要设块和 inode 两条。而且预计耗尽时刻比使用率更有用。
현재 단락 (1/146)
症状是这样开始的。应用写不了日志,部署在创建临时文件时失败,数据库写不了 WAL。登进去一看,磁盘还有余量。