GlusterFS 复制卷文件内容被清零(0 字节 / 全 0 稀疏空洞):摊还式全量自愈 O_TRUNC 目标文件的坑
关键词:GlusterFS / replica 2 / 副本卷 / self-heal / 全量自愈 / 脑裂 split-brain /
.glusterfs.heal/ 数据静默损坏 silent data corruption / 文件内容变成 0 / 全 0 文件 / 稀疏文件 sparse / O_TRUNC / 文件大小正常但内容没了 / stat 正常内容为空 /storage.self-heal-window-size/cluster.data-self-heal-algorithm/gluster volume heal full越修越糟 / 分布式存储排障
技术栈:GlusterFS(replica 2 卷)· Docker · Debian bookworm · Python 3(fcntl.flock / os)
难度:★★★★☆ 可复现:✅ 全部命令可跑 环境:Docker + docker compose
一、问题背景:文件"看着还在",内容却没了
一套跑了很久的网盘/对象存储后端,底层是 GlusterFS replica 2 卷(两块 brick 互为副本)。 容量告急,做了两件"标准动作":
gluster volume add-brick data replica 2 replica-3:/data/brick1-data replica-3:/data/brick2-data commit
gluster volume status data # 一切 Online
然后出现:
- 大量文件
ls -l大小正常、文件名正常、mtime 正常; - 一读内容 要么 0 字节,要么整段
\x00; - 部分文件"前半段有内容、后半段全 0";
- 业务侧没有任何报错,还在"写入成功";
- brick 上
.glusterfs.heal持续增长; - 反复
gluster volume heal data full,完全没好,甚至更糟。
核心症状一句话:stat 说文件在,read 说数据没了,而且怎么修都修不好。
二、复现步骤(Docker 一键)
本仓库容器里没装 glusterfs(跑 glusterd 需要 fuse + 特权,CI 不可控),
所以用 Python 内置 fcntl.flock + os 精确模拟 self-heal 的
【摊还式扫描】+【独占锁】+【O_TRUNC 清零后按 _gf_unit 复制】模型。
模拟器与真实实现的逐行对应见 scenario/2026-09-12-0800/README.md 第二节。
git fetch origin && git checkout 2026-09-12-0800
cd scenario/2026-09-12-0800
docker compose up -d --build
docker compose exec app bash ./scripts/run_all.sh # 一键完整复现 + 对照 + 判据
坑 0(环境):
Dockerfile的CMD就是run_all.sh,容器一起来就跑完退出, 于是docker compose exec app …会报service "app" is not running。 想交互式拆解,加个 override 让容器常驻:printf 'services:\n app:\n command: ["bash","-c","while true; do sleep 3600; done"]\n' > /tmp/override.yml docker compose -f docker-compose.yml -f /tmp/override.yml up -d只想跑一次的话,直接
docker compose up(前台看 logs)也行。
关键输出:
== 触发自愈:glusterfs 摊还式全量 heal ==
heal source(脏砖)= /data/brick1-g1
1) open(source) : /data/brick1-g1/data.bin
2) open(target,O_TRUNC) : /data/brick2-g1/data.bin ← 坑就在这一行:先把目标清零
3) 对 target 加 flock(LOCK_EX):客户端的该文件 io 会被拖住
4) 循环 pread(source) → pwrite(target),_gf_unit=1048575 字节
[中断] 没有谁会在自愈中途重启 brick —— brick 不是 0/1 状态机
此时 target:size=0 head=b''
❗ heal 返回 success,但 target 已经是 **0 字节** —— 数据物理消失
brick1-g1 size=131094
brick2-g1 size=0
[PASS] 复现成功:目标砖上文件被清零 → 逻辑卷读到的文件不可用
PASS=2 FAIL=0
最短复现(只看现象):
docker compose exec app bash ./scripts/reproduce.sh
三、根本原因
3.1 open(target, "wb") = O_TRUNC:打开即清零
自愈对目标文件的真身是(Python 里等价于 open(path,"wb")):
dst = open(target, O_WRONLY|O_CREAT|O_TRUNC, 0644); /* ① 先截断为 0 */
for (off = 0; off < size; off += _gf_unit) { /* ② 再按块灌回 */
pread(src, buf, _gf_unit, off);
pwrite(dst, buf, _gf_unit, off);
}
容器里直接验证 O_TRUNC 语义:
>>> import os
>>> os.O_WRONLY|os.O_CREAT|os.O_TRUNC
577
>>> open("/tmp/x","wb").close() # 打开即截断
>>> os.path.getsize("/tmp/x")
0 # ← 数据在 open 的瞬间就没了,跟后面写多少无关
只要在"①清零完成、②没搬完"之间被打断,目标文件就永久少了数据。
3.2 自愈是「摊还式(amortized)」的,且没有断点续传
- GlusterFS self-heal 不是坏了立刻修,而是等下一次访问/全量扫描才触发;
- 进度只记录在
.glusterfs.heal的存在性上,不记录"搬到第几块了"; - brick 不是 0/1 状态机:中断后没有任何机制会"重启中途的自愈"。
复现器留下的状态文件证明了这一点:
magic=b'GLUSTERFS-HEAL-STATE-V1' advance=0 size=0
advance=0 —— 中断后状态等于"什么都没搬",下一次又从 offset 0 重来。
3.3 于是「越修越糟」
反复 heal → 每次都从 O_TRUNC 清零开始 → 目标永远是"清零 + 部分灌回"
业务继续写 → 写的是自己的偏移,头部仍是 \x00,历史数据回不来
复现实测:
第 1 次 heal 后 brick2-g1: size=0 md5=d41d8cd98f00
第 2 次 heal 后 brick2-g1: size=0 md5=d41d8cd98f00
第 3 次 heal 后 brick2-g1: size=0 md5=d41d8cd98f00
3.4 为什么「前半段有内容、后半段全 0」
把 source 撑到 3.5 个 _gf_unit、只搬 1 块就中断:
此时 target:size=1048575 head=b'NEWDATA|col1,col2,col3..' tail=b'\x00…'
⚠️ heal 返回 success,但 target 只有前 1 个块有内容,offset 1048575 之后全是 0
/data/brick1-g1 size=3670012 空洞=96.4% chunk1=b'MARKER_A…'
/data/brick2-g1 size=1048575 空洞=87.5% (MARKER_A/B 永久丢失)
O_TRUNC 把目标从 5 块砍到 0,只搬回 1 块 → 目标变成 1 块大,后面全是空洞。
目标甚至比原来更小(size 都在变,所以"size 正常"也是错觉)。
3.5 为什么 stat / ls -l / du 全都看不出来
du -sh /data/brick1-g1 → 140K
du -sh /data/brick2-g1 → 8.0K # 两块砖"都在",但装的东西完全不同
find /data -name '.glusterfs.heal' → 40 bytes
.glusterfs.heal 只有 40 字节,只说明"文件还没被处理"——
一个只差 4 字节的文件和一个已经归零的文件,标记大小一模一样。
而且坏的那块砖 mtime 反而更新(heal 动过它),拿时间判新旧必然误判。
四、解决办法
4.1 第一步:区分「数据损坏」和「同步延迟」(detect_corruption.sh)
三条原则:
- 同一时刻把每个 replica 的副本都读出来,逐副本对比;
- 比
size+md5,绝对不要比 mtime; - 看"非零字节比例"识别稀疏空洞。
docker compose exec app bash ./scripts/detect_corruption.sh /data/g1
三种判定实测:
# 场景 1 健康
副本 size 是否一致 : True [131094]
副本 md5 是否一致 : True ['368ec1a0cfbd…']
✅ 判定:两个副本 size 与 md5 全一致,数据可信。
# 场景 2 一砖被清零
/data/brick1-g1 size=131094 md5=368ec1a0cfbd…
/data/brick2-g1 size=0 md5=d41d8cd98f00…
💥 判定:数据损坏(有副本为 0 字节)—— 不是同步延迟,重跑 heal 无用。
# 场景 3 稀疏空洞
/data/brick1-g1 size=3670012 非零字节 131111/3670012 ← 稀疏空洞!
/data/brick2-g1 size=1048575 非零字节 131094/1048575 ← 稀疏空洞!
💥 判定:数据损坏(存在稀疏空洞,前半段有内容后半段全 0)。
真实 GlusterFS 里的等价命令:
getfattr -d -m . -e hex /data/data.bin # 看 trusted.glusterfs.heal 等 xattr
gluster volume heal data info # 待自愈清单
gluster volume heal data info split-brain # 脑裂清单
4.2 第二步:正确恢复 = 停写 → 备份 → 原子替换(safe_recover.sh)
为什么正确动作是「先停写 → 再从备份恢复」,而不是反复 heal full:
因为 heal 是幂等的"清零 + 部分灌回",它根本不打算"找回"数据,
它只负责"让两块砖再一致一次"——而这次一致性是以牺牲目标为代价的。
docker compose exec app bash ./scripts/safe_recover.sh /data/g1 /data/backup-g1
安全恢复与故障路径逐条对照:
| 故障 heal | 安全恢复 |
|---|---|
open(target, O_TRUNC) 原地清零 | 写 *.restore-tmp + 原子 rename(不存在清零窗口) |
| 中断后没人管 | 每块 flush+fsync,逐块 md5,校验不过删临时文件 |
| 只信"存在性"标记 | 先验 manifest.sha256,备份坏了就换快照 |
| 不区分哪块砖对 | 从校验通过的备份挑源,不挑 mtime 最新 |
实测恢复结果:
== 2. 从备份里挑一个【校验通过】的源 ==
size=131094 md5=368ec1a0cfbd… 备份 manifest 校验:通过 ✅
== 3. 写临时文件 + 原子 rename ==
原子替换完成:/data/brick2-g1/data.bin size=131094
== 4. 回读复检所有副本 ==
✅ /data/brick1-g1/data.bin size=131094 md5=368ec1a0cfbd…
✅ /data/brick2-g1/data.bin size=131094 md5=368ec1a0cfbd…
🎉 恢复完成:2 个副本与备份逐字节一致
恢复后再跑 detect_corruption.sh → ✅ 数据可信,闭环。
4.3 第三步:预防配置与流程
cluster.data-self-heal-algorithm:评估fullvsdiff。diff只搬差异块,风险窗口更小;full就是本文这个"清零 + 全量灌回"。storage.self-heal-window-size:调小自愈窗口/限速,避免大文件长时间停在 "已清零、未搬完"的状态。- 扩容/重启严格滚动:一次只动一台,等
gluster volume heal <vol> info清空再动下一台。 - 备份必须带校验清单(
manifest.sha256),并定期抽样恢复演练—— 否则连备份是不是坏的都不知道。 - 监控:对 brick 上文件的"非零字节比例"做巡检,比监控
ls -l大小有效得多。
五、验证
cd scenario/2026-09-12-0800
docker compose up -d --build
docker compose exec app bash ./scripts/run_all.sh # PASS=2 FAIL=0,exit 0
docker compose exec app bash ./scripts/reproduce.sh # 最小复现,exit 0
docker compose exec app bash ./scripts/detect_corruption.sh /data/g1
docker compose exec app bash ./scripts/safe_recover.sh /data/g1 /data/backup-g1
docker compose down -v
通过标准:
run_all.sh输出PASS=2 FAIL=0,且能看到目标砖data.bin被清零、.glusterfs.heal仍在;detect_corruption.sh在"健康 / 归零 / 空洞"三种场景分别给出正确判定;safe_recover.sh恢复后所有副本 md5 与备份一致; 传不存在的备份路径 →exit 1,传错卷路径 →exit 2(不许假成功)。
六、经验与避坑
排障避坑
- 别被
.glusterfs.heal骗:它只反映"还有哪些文件没处理",不反映缺口大小。 差 4 字节和已经归零的文件,标记一样大。 - 别只看单块砖:单砖视角下文件"还在",必须同一时刻把所有副本读出来 diff/md5。
- 别以为"再 heal 一次就好":heal 是幂等的"清零 → 部分灌回",重跑不会找回数据。
- 别拿 mtime 当判据:heal 会更新目标 mtime,坏的那块砖时间反而更新。
- 别用
du/目录大小:空洞/sparse 的du会显得很小,看起来像"文件就这点"。 size也不可信:O_TRUNC+ 部分搬运会让目标 size 变小(5 块 → 1 块), 但ls -l显示的仍是"一个正常大小的文件"。
写脚本避坑(本次真实踩到)
- 恢复脚本必须拒绝假成功。我第一版用
for b in "$VOL"/*/遍历副本, 但砖是卷的兄弟目录(/data/brick1-g1vs 卷/data/g1),一个都没匹配到, 脚本却照样打印🎉 恢复完成——什么都没干却报成功,比原故障更危险。 修复:复用同一套「卷 → 砖」映射函数 + 硬断言(0 个 brick / 0 个副本 → 非 0 退出)- 统计
CHECKED,CHECKED=0不允许报成功。
- 统计
- 环境与文档不一致要记下来:
CMD直接跑复现脚本会让容器退出、docker compose exec报service "app" is not running。 交互式实验要加commandoverride 让容器常驻,或用docker compose up前台看 logs。 --heal-start不是真实 GlusterFS 开关:它只是把"自愈中途被打断"的窗口 放大成秒级,方便稳定复现。真实环境里这个窗口来自 RSS/OOM-kill、 网络抖动、brick 重启、大文件扫描时长。
一句话总结:O_TRUNC 先清零 + 摊还式自愈无断点续传 → 自愈中断 = 数据静默丢失;
正确动作是"停写 + 从校验过的备份恢复",不是反复 heal full。
