Skip to content

필사 모드: 明明还有空间却报 No space left on device — inode 耗尽、已删除但仍被打开的文件、保留块

中文
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

引言 — 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 的 maildropdeferred)、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:已删除但仍被打开的文件

如果 dfdu 差得很多,就是这一类。

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 如何呈现这种情况,可以少走很多弯路。dfAvail 是扣掉保留部分之后的值,而 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

如果 dfdu 对不上,但又没有已删除但仍被打开的文件,剩下的候选就是它。在已经放着文件的目录上挂载另一个文件系统,原来的文件就看不见了。可那些文件依然占着下层文件系统的空间。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
已删除但仍被打开的文件dfdu 相差很大,NLINK 为 0lsof -nP +L1让进程重新打开,修改 logrotate 配置
保留块root 写入成功,只有普通账号失败tune2fs -ltune2fs -m 1
挂载遮蔽dfdu 对不上但没有打开的文件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。

把诊断顺序压缩成一条线,就是这样。

  1. findmnt -T 确定路径所属的文件系统。一半的情况到这里就结束了。
  2. df -i 看 inode。它是与块相互独立的资源。
  3. 比较 dfdu -x。对不上就是已删除但仍被打开的文件,或者是挂载遮蔽。前者用 lsof +L1,后者用绑定挂载来区分。
  4. 用 root 可以、用服务账号不行,那就是保留块。用 tune2fs -m 调整。
  5. 文件系统好端端的,只有某个特定进程报这个错,就怀疑 inotify。
  6. 告警要设块和 inode 两条。而且预计耗尽时刻比使用率更有用。

현재 단락 (1/146)

症状是这样开始的。应用写不了日志,部署在创建临时文件时失败,数据库写不了 WAL。登进去一看,磁盘还有余量。

작성 글자: 0원문 글자: 10,294작성 단락: 0/146