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

statvfs 统计挂载点选错:容器里只写 4 MiB 却上报「已用 7.6 TiB」,卷 89% 满仍不触发背压

关键词:statvfs 统计错挂载点、磁盘用量虚高、磁盘水位上报 7.6 TiB、/proc/self/mountinfo 全量遍历、 容器注入挂载 /etc/hosts /etc/hostname /etc/resolv.conf、bind mount 宿主盘被计多遍、 伪文件系统 proc/sysfs/cgroup/devpts/fuse.lxcfs、只读根文件系统 read_only、 水位背压失效 under_pressure 永远 false、Docker 磁盘监控、容器磁盘用量统计、 Linux mount namespace、major:minor 去重、Python os.statvfs


一、问题背景

一个「磁盘水位守护」服务部署在只读根文件系统 + 单块 1 GiB 写卷的容器里:

  • read_only: true,根文件系统结构上写不进去;
  • 唯一业务写路径是 1 GiB 卷 /mnt/volume;
  • 容器运行时被注入 /etc/hosts、/etc/hostname、/etc/resolv.conf。

守护原本用 du -sb 扫写路径统计用量,卷里文件到百万级后一次要跑几分钟, 于是改成「读 /proc/self/mountinfo 找出挂载点,再对每个挂载点调一次 statvfs」—— 一次系统调用拿到用量,看起来是个漂亮的优化。

上线后的现象:

  • 服务刚起、一个业务字节都没写,/stats 就上报 used_bytes: 8188616966144(约 7.6 TiB), 而卷容量只有 1 GiB;
  • 写 4 MiB 业务数据,上报值只跟着涨 4 MiB 的零头;
  • 删掉卷里 900 MiB 数据,上报值纹丝不动;
  • 卷已经 89% 满了,守护依然说没事:under_pressure: false,背压不触发,新写入照常成功;
  • 全程零报错、退出码 0、status.json 是合法 JSON,运维面板上「一切正常」。

这是典型的「静默错误」:不是服务崩了,而是服务在认真地报错误的数。


二、复现步骤(Docker,可照着跑)

cd scenario/2026-09-25-0800

docker compose up -d --build
docker compose ps -a
docker compose logs client

问题态输出:

空卷: 上报 used_bytes=7374969933824 真实=0 偏差=7374969933824
写 4MiB: 上报增量=4272128 真实增量=4194304
填到水位以上: 上报 used_ratio=0.112465 卷自身占用率=686926.1% under_pressure=False 卷真实用量=843055104
高压下写 1MiB: ok=True written=1048576 errors=[]
RESULT: BAD
docker compose exec -T node bash -lc '/bin/bash /srv/ops/verify.sh'
# PASS=3 FAIL=5
# RESULT: BAD

注意脚本路径:README 写的是 /ops/repro.sh,实际是 /srv/ops/repro.sh(WORKDIR /srv)。 写错的报错是 /bin/bash: /ops/repro.sh: No such file or directory。

一行命令定位「谁在虚报」

docker compose exec -T node python3 -c "
import os
rows=[]
for line in open('/proc/self/mountinfo'):
    p=line.split()
    try: sep=p.index('-')
    except ValueError: continue
    mp=p[4].replace('\\\\040',' ')
    try:
        st=os.statvfs(mp)
        rows.append((mp,p[sep+1],st.f_blocks*st.f_frsize-st.f_bfree*st.f_frsize,st.f_blocks*st.f_frsize))
    except OSError: pass
rows.sort(key=lambda r:-r[2])
for mp,fs,u,t in rows[:5]: print(f'{mp:22} {fs:9} used={u/2**30:>9.2f}GB total={t/2**30:>10.2f}GB')
"
/etc/resolv.conf   xfs      used=  2289.40GB total= 19995.49GB
/etc/hostname      xfs      used=  2289.40GB total= 19995.49GB
/etc/hosts         xfs      used=  2289.40GB total= 19995.49GB
/                  overlay  used=     0.32GB total=   256.00GB
/mnt/volume        tmpfs    used=     0.00GB total=     1.00GB

一眼看出:三条 /etc/* 的 statvfs 返回的是同一块宿主盘(major:minor 全是 9:0), 各报 2.29 TB / 19.5 TB,被计了 3 遍 → 假 7.6 TiB。


三、根本原因

3.1 statvfs(mount_point) 回答的不是「本节点的用量」

statvfs 返回的是那个文件系统(那个 major:minor 设备)的整体统计。 而 /proc/self/mountinfo 描述的是整个挂载命名空间:

  • 只读根文件系统 /(overlay,ro);
  • 容器运行时从宿主机根盘 bind mount 进来的 /etc/hosts、/etc/hostname、/etc/resolv.conf ——三者 major:minor 相同;
  • proc、sysfs、cgroup、devpts、mqueue、fuse.lxcfs 等伪文件系统 ——statvfs 对它们不报错,会老实返回宿主机块设备统计。

所以「遍历全部挂载点逐个 statvfs 再求和」= 把宿主机上每一块盘都记到本节点账上。

3.2 契约函数写了,但调用方从没调它

def is_injected_mount(mount_point):
    """部署契约:容器运行时注入的挂载点不是本节点的业务写路径,不计入本节点用量。"""
    return mount_point in INJECTED_MOUNTS          # ← 函数在、注释对、判决对

def scan_status(write_path):
    used = 0; total = 0
    rows = read_mountinfo()
    for mount_point, fstype, _opts in rows:        # ← 但这里全量遍历
        mp = rec_unescape(mount_point)
        u, t = statvfs_used(mp)
        used += u; total += t                      # ← 把所有盘都加进来

is_injected_mount() / is_pseudo_mount() 都存在,scan_status() 一次都没调用。write_path 参数收下了,却只用于返回值的 source 字符串,完全没参与统计。 这类「死代码型契约」是本次故障的核心——review 时很难发现,因为函数本身完全正确。

3.3 背压为什么静默失效

under_pressure = used_ratio >= PRESSURE_RATIO      # 0.80
used_ratio = used / total                          # 分子分母都是「全命名空间累加」

阈值 PRESSURE_RATIO=0.80 的语义是「卷自己的占用率」, 但 used_ratio 的分子分母被 61 TB 外来文件系统稀释 —— 卷 89% 满算出来只有 0.11, 永远碰不到 0.80。于是:

  • 背压永不触发,新写入照常成功,卷被填到满;
  • 删除文件也不影响(删除只改卷,外来盘的数把它淹没了);
  • 零报错、退出码 0、JSON 合法,面板上看不出任何异常。

这是本题最危险的部分:数字错了只是「难看」,背压失效是「该保护的时候没保护」。

3.4 为什么没有异常可抓

statvfs 对伪文件系统不返回错误,/sys/fs/cgroup/* 报 used=0 是巧合 (不是「本来就没有」的证明)。所以 try/except OSError: continue 完全拦不住这件事:

「没有异常」≠「取到了正确的值」。这类 bug 只会以「数字不对」出现,不会以「异常」出现。


四、解决办法

只改 app/mounts.py + app/diskguard.py, conf/node.env、docker-compose.yml、Dockerfile 一字未动 (不改部署形态、不缩阈值、不加配置来「蒙混」)。

4.1 把「自己的写路径」筛出来

SKIP_PREFIXES = ("/proc", "/sys", "/dev/pts", "/dev/mqueue", "/dev/shm", "/run/lock")

def own_write_mounts(write_path, root_readonly=True):
    out, seen_dev = [], set()
    for row in read_mountinfo_full():                        # 带回 major:minor 与 options
        mp = rec_unescape(row["mount_point"])
        fstype = row["fstype"]
        opts = row["options"].split(",")

        if root_readonly and mp == "/" and fstype == "overlay": continue  # ① 只读根
        if is_injected_mount(mp):                              continue  # ② 注入挂载(终于被调用)
        if is_pseudo_mount(fstype):                            continue  # ③ proc/sysfs/cgroup/fuse.*
        if mp.startswith(SKIP_PREFIXES):                       continue  # ④ 内核注入前缀
        if "ro" in opts:                                       continue  # ⑤ 只读挂载不算业务量
        try:
            u, t = statvfs_used(mp)
        except OSError:
            continue
        if row["major_minor"] in seen_dev:                     continue  # ⑥ 同一块盘 bind 到多处只算一次
        seen_dev.add(row["major_minor"])
        out.append({**row, "mount_point": mp, "used_bytes": u, "total_bytes": t})
    return out

is_pseudo_mount() 补 fuse.*:本环境实测注入了 fuse.lxcfs(挂到 /proc/*、/sys/devices/system/cpu), 只靠 fstype in PSEUDO_FS 白名单会漏。同理 /sys/fs/cgroup 的 fstype 其实是 tmpfs 而非 cgroup。

4.2 只统计「写路径所属的那个文件系统」

def mount_of(write_path):
    """找 write_path 落在哪个挂载点上(最长匹配)。"""
    target = os.path.realpath(write_path)
    best = None
    for row in read_mountinfo_full():
        mp = rec_unescape(row["mount_point"])
        if target == mp or target.startswith(mp.rstrip("/") + "/"):
            if best is None or len(mp) > len(best["mount_point"]):
                best = {**row, "mount_point": mp}
    ...
    best["used_bytes"], best["total_bytes"] = statvfs_used(best["mount_point"])
    return best

def scan_status(write_path):
    vol = mount_of(write_path)
    used, total = vol["used_bytes"], vol["total_bytes"]
    return {
        "used_bytes": used, "total_bytes": total,
        "used_ratio": round((used / total) if total else 0.0, 6),
        "volume_used_bytes": used,            # ← 显式自证:背压是按谁算的
        "volume_total_bytes": total,
        "volume_mount_point": vol["mount_point"],
        "mount_count": 1,
        "source": f"statvfs(own write mount: {vol['mount_point']})",
        ...
    }

4.3 让背压判据显式绑定到「卷自己的占用率」

vol_ratio = snap["used_ratio"]              # 现在这个比率就是卷自身的
_state.update({
    ...
    "volume_used_bytes": snap["volume_used_bytes"],
    "volume_total_bytes": snap["volume_total_bytes"],
    "volume_mount_point": snap["volume_mount_point"],
    "under_pressure": vol_ratio >= PRESSURE_RATIO,   # 阈值语义与判据对象终于一致
})

4.4 加「自证字段」,让对象错没错一眼可见

mount_count(55 → 1)、source(statvfs(own write mount: /mnt/volume))、 volume_total_bytes(1 GiB 而不是 61 TB)—— 这三个字段才是这次故障里真正能救命的观测面:原始实现根本没有「我统计的是谁」的表达。


五、验证

docker compose down -v                     # 基线必须删卷,否则残留 filler 会让断言侥幸
docker compose up -d --build               # COPY 进镜像的文件必须 --build,restart 不生效
docker compose logs client                 # Exited (0) / RESULT: OK
docker compose exec -T node bash -lc '/bin/bash /srv/ops/verify.sh'
观察项问题态修复后
空卷 used_bytes73750253936640(真实 0,偏差 0)
空卷 total_bytes655833756712961073741824(1 GiB)
写 4 MiB 后 used_bytes73751698022404194304
mount_count551
卷 85% 时 under_pressurefalsetrue
高压下写 1 MiBwritten: 1048576(放行)written: 0(背压拒绝)
verify.shPASS=3 FAIL=5 / BADPASS=8 FAIL=0 / OK
client 退出码10

端到端背压证据:

used_bytes=912261120 total_bytes=1073741824 used_ratio=0.849609 under_pressure=True
{"ok": false, "written": 0, "errors": ["backpressure: volume under pressure"], "under_pressure": true}
清理后: used=0 ratio=0.0 under=false

追加验证:

  • 幂等:连跑 3 次 verify.sh 均 PASS=8 FAIL=0;
  • 干净重建:down -v 后重建,仍 PASS=8 FAIL=0、client Exited (0);
  • 反向注入证明断言有判别力:把 scan_status() 改回「全量累加」, 立刻回到 PASS=3 FAIL=5 / RESULT: BAD,与问题态基线逐项吻合,换回修复版重新全绿。

一个容易误判的点

client/check_space.py 只写 n=800(约 78.5%),在 PRESSURE_RATIO=0.80 之下, 所以它那条 if ratio_src >= PRESSURE_RATIO 分支根本不执行—— 它只验证了「数准不准」,背压那条断言是空转的。 真正验背压的是 verify.sh 的 B 组(写 n=870)。 所以「client 退出码 0」不能单独作为结论,两个脚本都要看。


六、经验与避坑

6.1 三条核心认知

  1. statvfs(mount_point) 回答的是「那个文件系统的用量」,不是「本节点的用量」。/proc/self/mountinfo 是整个挂载命名空间(含宿主机 bind mount 与伪文件系统), 两者之间必须有一层过滤,依据就是部署契约。
  2. 契约函数存在 ≠ 契约被执行。 本次 is_injected_mount() 逻辑、注释全对, 但调用方从没调 —— 靠 review 很难发现。要靠上报字段自证 (source、mount_count、volume_total_bytes 这类能一眼看出「统计对象错没错」的字段)。
  3. 阈值的语义对象必须与判据的统计对象一致。PRESSURE_RATIO=0.80 是「卷的占用率」,就不能拿「全命名空间累加比率」去比, 否则背压/告警/限流会全线静默失效,而且零报错、退出码 0、JSON 合法。

6.2 避坑清单(7 条)

  1. README 的脚本路径可能不准:写 /ops/repro.sh,实际 /srv/ops/repro.sh(WORKDIR /srv)。 先 ls -la /srv/ops 确认,别照抄到底。
  2. client 是一次性容器:跑完即 Exited (0/1),docker compose exec client 报 service "client" is not running。用 ps -a + logs,退出码本身就是结论。
  3. 改 COPY 进镜像的文件必须 --build,restart 不会重新 COPY。
  4. 基线必须 down -v:卷里残留上一轮 filler 会让断言侥幸通过,拿到的不是干净基线。
  5. statvfs 对伪文件系统不报错,try/except OSError 拦不住; /sys/fs/cgroup/* 的 used=0 是巧合,不能当「不用排」的证据。
  6. 单一白名单会漏:/sys/fs/cgroup 的 fstype 是 tmpfs(不是 cgroup), 还会注入 fuse.lxcfs。要叠「白名单 + ro 选项 + 内核注入前缀 + major:minor 去重」多重兜底。
  7. 同一块盘会 bind mount 到多处:/etc/hosts、/etc/hostname、/etc/resolv.conf 的 major:minor 全是 9:0。即使逐个 statvfs,也要按 major:minor 去重, 否则同一块盘被计 N 遍。

6.3 同类影响面(同一个坑的其他形态)

  • cgroup 配额:容器里读 CPU/内存限制读到了外层挂载点(固定路径 /sys/fs/cgroup/... 不是自己那层);
  • /proc/cpuinfo、sched_getaffinity()、cpuset.cpus:都是「整机/允许集合」,不是配额;
  • /proc/meminfo 的 MemTotal:整机物理内存,不是容器配额;
  • 任何「扫 /proc/self/mountinfo / /proc/mounts 就全量统计」的写法: 监控 Agent、日志轮转、容量规划、备份工具都会中;
  • 通用形态:「读到的值本身不假,但它描述的对象不是我」。

七、复现指令(一行版)

cd scenario/2026-09-25-0800
docker compose up -d --build
docker compose logs client                                                    # RESULT: BAD(问题态)
docker compose exec -T node bash -lc '/bin/bash /srv/ops/verify.sh'           # PASS=3 FAIL=5
docker compose down -v

版权声明

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

分享这篇文章