朕喜技术(ZhenxiJS)
2026.09.25
1 分钟阅读

只写 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% 触发背压、写入被拒、清理后立刻恢复。

六、经验与避坑

核心认知

  1. /proc/self/mountinfo(以及 /proc/mounts、mount 命令)展示的是「整个挂载命名空间」,不等于「本容器占了多少盘」。 容器里绝大多数条目是宿主机的、或者是只读伪文件系统。要对它做任何聚合统计之前,先问「这些条目里哪些是我的」。
  2. statvfs 对伪文件系统不报错。 它会把宿主机的块设备统计原样返回给你。「没有异常」不代表「取到了正确的值」——这是本类问题的隐身衣。同理,/sys/fs/cgroup/* 返回 used=0 是巧合,不能当成「伪文件系统可以不管」的证据。
  3. 同一块宿主机设备可能被多个挂载点重复计入。 /etc/hosts、/etc/hostname、/etc/resolv.conf 的 major_minor 都是 9:0,是同一块盘。按 major_minor 去重能兜住这类重复,但在「只保留自己的写路径」这个更贴近语义的判据下它们已经被排除了。
  4. 契约读到了 ≠ 契约被执行了。 mounts.py 里 is_injected_mount() 早就写好了,注释也写对了,但 scan_status() 从没调用它。排查「值不对但一切正常」的问题时,grep 一遍「判据函数有没有被调用」,比读实现更快。
  5. used_ratio 这类「比率型」指标要问清分子分母是谁。 本案例里 ratio = used_total / total_total 把 57 TB 外来文件系统混进去,卷 89% 满算出来是 0.126,阈值永远碰不到。阈值的语义对象必须与判据的统计对象一致,否则背压、告警、限流全线静默失效。
  6. 「零报错 + 合法 JSON + 退出码 0」不是健康证明。 这类 bug 只会以「数字不对」的形式出现,不会以「异常」的形式出现。监控要断言期望值,而不是「格式合法」或「值非零」。

排查手法

  • du -sb <写路径> vs 上报值:这是最快的一刀。两者差三个数量级就直接定性为「统计口径错」而不是「真有垃圾文件」。
  • 把上报的明细摊开看:/stats 里的 partitions 列表逐条打印,一眼就能看到 /etc/hosts 这种根本不该出现的条目。
  • 盯 major_minor:同一个设备号出现在多个挂载点上,就是重复计入的信号。
  • python3 -c "import os; print(os.statvfs('/sys/fs/cgroup'))":手动跑一下,确认它不报错、返回的是宿主机的数字。

操作层面的坑

  1. 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)。
  2. 改 COPY 进镜像的 app/*.py 必须 docker compose up -d --build,restart / exec 都不会生效。
  3. ops/verify.sh 的 A3 在问题态是 [PASS] —— 它写成「非高压就跳过」。别被 PASS>0 迷惑,要看 FAIL 数和 RESULT/退出码。
  4. 基线必须 docker compose down -v:卷里有旧 filler 数据时 A1 有可能侥幸通过(残留数据让偏差比例看起来小)。
  5. 上游数据清理别忘: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 求和」的脚本。

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-09-25-0000-statvfs-7-6tib

分享这篇文章