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 去重、Pythonos.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_bytes | 7375025393664 | 0(真实 0,偏差 0) |
空卷 total_bytes | 65583375671296 | 1073741824(1 GiB) |
写 4 MiB 后 used_bytes | 7375169802240 | 4194304 |
mount_count | 55 | 1 |
卷 85% 时 under_pressure | false | true |
| 高压下写 1 MiB | written: 1048576(放行) | written: 0(背压拒绝) |
verify.sh | PASS=3 FAIL=5 / BAD | PASS=8 FAIL=0 / OK |
client 退出码 | 1 | 0 |
端到端背压证据:
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、clientExited (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 三条核心认知
statvfs(mount_point)回答的是「那个文件系统的用量」,不是「本节点的用量」。/proc/self/mountinfo是整个挂载命名空间(含宿主机 bind mount 与伪文件系统), 两者之间必须有一层过滤,依据就是部署契约。- 契约函数存在 ≠ 契约被执行。 本次
is_injected_mount()逻辑、注释全对, 但调用方从没调 —— 靠 review 很难发现。要靠上报字段自证 (source、mount_count、volume_total_bytes这类能一眼看出「统计对象错没错」的字段)。 - 阈值的语义对象必须与判据的统计对象一致。
PRESSURE_RATIO=0.80是「卷的占用率」,就不能拿「全命名空间累加比率」去比, 否则背压/告警/限流会全线静默失效,而且零报错、退出码 0、JSON 合法。
6.2 避坑清单(7 条)
- README 的脚本路径可能不准:写
/ops/repro.sh,实际/srv/ops/repro.sh(WORKDIR /srv)。 先ls -la /srv/ops确认,别照抄到底。 client是一次性容器:跑完即Exited (0/1),docker compose exec client报service "client" is not running。用ps -a+logs,退出码本身就是结论。- 改
COPY进镜像的文件必须--build,restart不会重新COPY。 - 基线必须
down -v:卷里残留上一轮 filler 会让断言侥幸通过,拿到的不是干净基线。 statvfs对伪文件系统不报错,try/except OSError拦不住;/sys/fs/cgroup/*的used=0是巧合,不能当「不用排」的证据。- 单一白名单会漏:
/sys/fs/cgroup的 fstype 是tmpfs(不是cgroup), 还会注入fuse.lxcfs。要叠「白名单 +ro选项 + 内核注入前缀 +major:minor去重」多重兜底。 - 同一块盘会 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
