只写 4 MiB 数据,磁盘水位守护却上报「已用 7.6 TiB」—— statvfs 统计挂载点选错(只读根 / 宿主注入 / 伪文件系统全被算进来)
日期:2026-09-25 | 分支:
2026-09-25-0000| 第 81 题 关键词:statvfs统计错误、/proc/self/mountinfo遍历所有挂载点、容器磁盘用量虚高、overlay 只读根文件系统、/etc/hosts注入挂载、/sys/fs/cgroup伪文件系统、du太慢改 statvfs、f_blocks - f_bfree、磁盘水位背压失效、Docker 只读根文件系统、used_bytes 上报 7.6TiB、rename 伪文件系统 statvfs 返回宿主机 技术栈:Linux mount namespace /statvfs(3)//proc/self/mountinfo/ Python 3.12 / Docker Compose / tmpfs 卷
一、问题背景
一个「磁盘水位守护」服务(代号 3day-81-node),部署形态是线上很常见的只读根文件系统 + 单块写卷:
- 容器
read_only: true—— 根文件系统结构上写不进去; - 唯一业务写路径是一块 1 GiB 的卷
/mnt/volume(tmpfs,size=1g); - 容器运行时还从宿主机注入
/etc/hosts、/etc/hostname、/etc/resolv.conf。
守护周期性统计「本节点占用了多少磁盘」并写 status.json,卷压力大时给应用发背压(拒绝写入)。历史上它用 du -sb 扫写路径,卷里文件到百万级时 du 要跑几分钟,于是改成了「读 /proc/self/mountinfo 找出挂载点,再对每个挂载点调一次 statvfs」——一次系统调用拿到用量,看起来是个漂亮的优化。
上线后:
- 服务刚起来、一个业务字节都还没写,
/stats就上报used_bytes: 8188616966144(约 7.6 TiB),而卷的容量只有 1 GiB; - 往卷里写 4 MiB 业务数据,上报值只跟着涨 4 MiB 的零头;
- 删掉卷里 900 MiB 数据,上报值纹丝不动;
- 全程零报错、退出码 0、
status.json是合法 JSON、under_pressure: false,运维面板上「一切正常」。
最要命的一条:卷已经 89% 满了,守护依然说没事,背压完全不触发,新写入照常成功。
二、复现步骤
环境:Docker + Docker Compose(本机实测 Client/Server 29.6.2,compose v5.3.1)。
git clone <repo> && cd <repo>
git checkout 2026-09-25-0000
cd scenario/2026-09-25-0000
docker compose up -d --build
docker compose exec node bash -lc '/bin/bash /ops/repro.sh' # 一键复现
docker compose exec node bash -lc '/bin/bash /ops/verify.sh' # 判定,问题态 PASS=1 FAIL=3
docker compose logs client # 独立客户端,问题态 RESULT: BAD
问题态关键输出:
=== 2. 启动时的上报(还没写业务数据)===
"used_bytes": 8188616966144 # ≈ 7.45 TiB,而卷只有 1 GiB
"used_ratio": 0.126006
"under_pressure": false
"source": "sum(statvfs(mount_point) for mount_point in /proc/self/mountinfo)"
=== 4. 真相:卷里到底占了多少 ===
卷物理占用 : 952107008 字节
tmpfs 1.0G 908M 116M 89% /mnt/volume
=== 5. 判定 ===
上报=8191487025152 卷真实=952107008 分区数=53 使用率=0.126051
[FAIL] A1 上报 8191487025152 与卷真实已用 952107008 不一致(把不属于本节点的分区算进来了)
[FAIL] A2 上报混入了不属于本节点写路径的分区: /,/etc/resolv.conf,/etc/hostname,/etc/hosts
[PASS] A3 当前非高压 (ratio=0.126051),跳过
[FAIL] A4 已删掉 900 MiB 数据,上报仍为 8192791437312(清理前 8191487025152)
PASS=1 FAIL=3
把每一笔「账」摊开看,/stats 里的分区明细是这样的:
/mnt/volume tmpfs used=952107008 ← 唯一属于本节点
/etc/resolv.conf xfs used=2730061467648 ← 宿主机的 /dev/md0
/etc/hostname xfs used=2730061467648 ← 同一块设备
/etc/hosts xfs used=2730061467648 ← 同一块设备,计了 3 遍
/ overlay used=350486528 ← 只读根文件系统
/proc /sys /sys/fs/cgroup* ... used=0
2730061467648 × 3 ≈ 8.19 TB,正好就是那个 7.6 TiB。
三、根本原因
/proc/self/mountinfo 描述的是整个挂载命名空间,不是「本容器占了多少盘」。统计代码对它全量遍历:
entries = mounts.read_mountinfo() # 53 条
for ent in entries:
used, total = mount_used_bytes(ent["mount_point"]) # 每个挂载点都 statvfs
used_total += used
而部署契约 conf/node.env 明明写着:
VOLUME_DIR=/mnt/volume # 唯一业务写路径
ROOTFS_READONLY=1 # 根文件系统只读,不计入本节点用量
PRESSURE_RATIO=0.80
mounts.py 里甚至已经写好了 is_injected_mount(),注释也写着「统计本容器占了多少盘时必须把它们排除掉」——但这个函数从未被 scan_status() 调用,只被 if __name__ == "__main__" 的打印语句用了一下。契约读到了、判据写好了、就是没接进统计链路。
三条互相独立的错误来源:
1)只读伪文件系统(proc / sysfs / cgroup / devpts / mqueue)statvfs 对它们不报错,返回的是宿主机块设备的统计。/sys/fs/cgroup/* 恰好 used=0 是巧合,/(overlay)的 350486528 却是真数字——这就是为什么「试一下发现是 0」不能当成「不用排」的理由。
2)容器运行时注入挂载(/etc/hosts / /etc/hostname / /etc/resolv.conf)
它们的 major_minor 全是 9:0,指向宿主机同一块 /dev/md0。同一个 2.7 TB 文件系统被计入 3 遍,这是 7.6 TiB 的主要来源。
3)只读根文件系统 /ROOTFS_READONLY=1 已经声明本节点不写它,它属于宿主机镜像层。
为什么零报错:os.statvfs() 对这些路径全部成功(它们确实存在、确实是挂载点),没有 OSError 可进 except;mountinfo 解析正确、major_minor/fs_type 都对;输出是合法 JSON、used_ratio 落在 [0,1]、退出码 0。链路上每个组件都在正常工作,组合出来的结论是错的。
为什么背压失效:used_ratio 的计算是 used_total / total_total,分子分母都被那 57 TB 的外来文件系统稀释了。卷已经 89% 满,算出来的却是 0.126,永远碰不到 0.80 的阈值。
四、解决办法
只改 app/mounts.py 与 app/diskguard.py,conf/node.env、docker-compose.yml、Dockerfile 一字未动。
4.1 mounts.py:新增 own_write_mounts(),把「自己的写路径」筛出来
PSEUDO_FS = frozenset((
"proc", "procfs", "sysfs", "cgroup", "cgroupfs", "cgroup2", "devpts",
"mqueue", "debugfs", "tracefs", "securityfs", "bpf", "pstore",
"configfs", "fusectl", "hugetlbfs", "autofs", "binfmt_misc",
))
SKIP_PREFIXES = ("/proc", "/sys", "/dev/pts", "/dev/mqueue", "/run/lock")
def own_write_mounts(root_mount_point="/", root_readonly=True):
out = []
for ent in read_mountinfo():
mp = ent["mount_point"]
if root_readonly and mp == root_mount_point:
continue # 1. 只读根文件系统
if is_injected_mount(ent):
continue # 2. 容器运行时注入的挂载
if ent["fs_type"] in PSEUDO_FS:
continue # 3. 只读伪文件系统
if mp.startswith(SKIP_PREFIXES):
continue # 3b. 内核注入路径
if "ro" in ent["options"]:
continue # 4. 标了 ro 的挂载不产生用量
out.append(ent)
return out
筛选效果(53 条 → 11 条):
KEEP /dev /dev/shm /tmp /mnt/volume /var/lib/diskguard /dev/null ... /dev/urandom
SKIP / /proc /sys /sys/fs/cgroup* /dev/pts /dev/mqueue
SKIP /etc/hosts /etc/hostname /etc/resolv.conf
4.2 diskguard.py:统计口径与水位判据分开
entries = mounts.own_write_mounts() # ← 唯一的改动点:不再遍历全量
...
# 背压水位按「业务卷」自己的占用率算
vol = next((p for p in partitions if p["mount_point"] == target_dir), None)
if vol and vol["total_bytes"]:
ratio = vol["used_bytes"] / float(vol["total_bytes"])
else:
ratio = used_total / float(total_total) if total_total else 0.0
并新增 volume_used_bytes / volume_total_bytes,让上报可以自证「背压是按谁算的」。
为什么水位不能用累加值的比率:used_total 里还含 /tmp(16 MB)、/dev(64 MB) 这类容量小、抖动大的 tmpfs,分子分母都被稀释。契约 PRESSURE_RATIO=0.80 的语义是「卷用了 80% 就背压」,判据必须落在 VOLUME_DIR 对应的挂载点上。
4.3 应用 + 重跑
cd scenario/2026-09-25-0000
docker compose up -d --build # 改的是 COPY 进镜像的文件,必须 --build
docker compose exec node bash -lc '/bin/bash /ops/verify.sh'
docker compose logs client # client 是一次性容器,看日志而非 exec
五、验证
ops/verify.sh:PASS=1 FAIL=3 → PASS=4 FAIL=0(rc=0)
上报=943722496 卷真实=943718400 分区数=11 使用率=0.878906
[PASS] A1 上报 943722496 与卷真实已用 943718400 一致
[PASS] A2 上报不含只读根文件系统与宿主注入挂载
[PASS] A3 卷已用 0.878906 超阈值,under_pressure=true
[PASS] A4 清理数据后上报由 943722496 回落到 4096
PASS=4 FAIL=0
上报 943722496 vs 卷真实 943718400,偏差 4 字节(/var/lib/diskguard/status.json 自己占的一个块);分区数 53 → 11。
client/check_space.py:RESULT: BAD → RESULT: OK(rc=0)
上报分区块数 : 11
判定 A: 上报值与卷真实已用空间一致
判定 B: 上报只包含本容器的写路径
外部写入 50331648 字节,上报差值 50331648 字节
判定 C: 上报差值能反映外部进程写入
RESULT: OK
上报值真的能跟着涨落
写 4 MiB 业务数据后 上报已用空间 : 4198400 字节(= 4194304 + 4096 块对齐),跟着涨。
背压端到端(本题最有业务价值的一条)
docker compose exec node bash -lc '/bin/bash /ops/testdata.sh 900 >/dev/null 2>&1
curl -s -X POST localhost:8080/rescan >/dev/null; sleep 1
curl -s localhost:8080/stats | python3 -c "import json,sys;s=json.load(sys.stdin)
print(\"volume_used=%d volume_total=%d ratio=%.4f under=%s\"%(
s[\"volume_used_bytes\"],s[\"volume_total_bytes\"],s[\"used_ratio\"],s[\"under_pressure\"]))"'
# volume_used=943718400 volume_total=1073741824 ratio=0.8789 under=True
# 高压下尝试写 1 MiB:
# written=0 errors=['backpressure: volume under pressure']
# 清理后:
# 清理后 ratio=0.0000 under=False
问题态下这一段是 under=False + 写入照常成功;修复后 89% 触发背压、写入被拒、清理后立刻恢复。
六、经验与避坑
核心认知
/proc/self/mountinfo(以及/proc/mounts、mount命令)展示的是「整个挂载命名空间」,不等于「本容器占了多少盘」。 容器里绝大多数条目是宿主机的、或者是只读伪文件系统。要对它做任何聚合统计之前,先问「这些条目里哪些是我的」。statvfs对伪文件系统不报错。 它会把宿主机的块设备统计原样返回给你。「没有异常」不代表「取到了正确的值」——这是本类问题的隐身衣。同理,/sys/fs/cgroup/*返回used=0是巧合,不能当成「伪文件系统可以不管」的证据。- 同一块宿主机设备可能被多个挂载点重复计入。
/etc/hosts、/etc/hostname、/etc/resolv.conf的major_minor都是9:0,是同一块盘。按major_minor去重能兜住这类重复,但在「只保留自己的写路径」这个更贴近语义的判据下它们已经被排除了。 - 契约读到了 ≠ 契约被执行了。
mounts.py里is_injected_mount()早就写好了,注释也写对了,但scan_status()从没调用它。排查「值不对但一切正常」的问题时,grep一遍「判据函数有没有被调用」,比读实现更快。 used_ratio这类「比率型」指标要问清分子分母是谁。 本案例里ratio = used_total / total_total把 57 TB 外来文件系统混进去,卷 89% 满算出来是0.126,阈值永远碰不到。阈值的语义对象必须与判据的统计对象一致,否则背压、告警、限流全线静默失效。- 「零报错 + 合法 JSON + 退出码 0」不是健康证明。 这类 bug 只会以「数字不对」的形式出现,不会以「异常」的形式出现。监控要断言期望值,而不是「格式合法」或「值非零」。
排查手法
du -sb <写路径>vs 上报值:这是最快的一刀。两者差三个数量级就直接定性为「统计口径错」而不是「真有垃圾文件」。- 把上报的明细摊开看:
/stats里的partitions列表逐条打印,一眼就能看到/etc/hosts这种根本不该出现的条目。 - 盯
major_minor:同一个设备号出现在多个挂载点上,就是重复计入的信号。 python3 -c "import os; print(os.statvfs('/sys/fs/cgroup'))":手动跑一下,确认它不报错、返回的是宿主机的数字。
操作层面的坑
client是一次性容器:docker compose exec client ...会报service "client" is not running(它跑完check_space.py就Exited (0))。用docker compose logs client看结果,或docker compose ps -a看退出码——退出码本身就是结论(0=OK,1=BAD)。- 改
COPY进镜像的app/*.py必须docker compose up -d --build,restart/exec都不会生效。 ops/verify.sh的 A3 在问题态是[PASS]—— 它写成「非高压就跳过」。别被PASS>0迷惑,要看 FAIL 数和RESULT/退出码。- 基线必须
docker compose down -v:卷里有旧 filler 数据时 A1 有可能侥幸通过(残留数据让偏差比例看起来小)。 - 上游数据清理别忘:
ops/cleanup.sh清filler_*.bin/blob_*.bin/external_probe.bin,down -v之后卷才真正干净。
通用修法模板
任何「统计本容器/本进程占用资源」的场景,都按这个顺序筛挂载点:
读 /proc/self/mountinfo
→ 排除根文件系统(当 ROOTFS_READONLY=1)
→ 排除白名单注入路径(/etc/hosts、/etc/hostname、/etc/resolv.conf、/etc/mtab…)
→ 排除只读伪文件系统(proc/sysfs/cgroup/devpts/mqueue/debugfs/tracefs/bpf…)
→ 排除内核注入前缀(/proc、/sys、/dev/pts、/dev/mqueue、/run/lock…)
→ 排除 options 里带 ro 的挂载
→ 剩下才是自己的写路径
对称的影响面(同一个 bug 家族):df 在容器里报宿主机容量、Prometheus node_filesystem_* 指标把宿主盘算进容器配额、k8s 里 ephemeral-storage 用量算错、Java File.getUsableSpace() 看重叠的挂载点、任何「遍历 /proc/mounts 求和」的脚本。
