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

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)

三条原则:

  1. 同一时刻把每个 replica 的副本都读出来,逐副本对比;
  2. 比 size + md5,绝对不要比 mtime;
  3. 看"非零字节比例"识别稀疏空洞。
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:评估 full vs diff。 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(不许假成功)。

六、经验与避坑

排障避坑

  1. 别被 .glusterfs.heal 骗:它只反映"还有哪些文件没处理",不反映缺口大小。 差 4 字节和已经归零的文件,标记一样大。
  2. 别只看单块砖:单砖视角下文件"还在",必须同一时刻把所有副本读出来 diff/md5。
  3. 别以为"再 heal 一次就好":heal 是幂等的"清零 → 部分灌回",重跑不会找回数据。
  4. 别拿 mtime 当判据:heal 会更新目标 mtime,坏的那块砖时间反而更新。
  5. 别用 du/目录大小:空洞/sparse 的 du 会显得很小,看起来像"文件就这点"。
  6. size 也不可信:O_TRUNC + 部分搬运会让目标 size 变小(5 块 → 1 块), 但 ls -l 显示的仍是"一个正常大小的文件"。

写脚本避坑(本次真实踩到)

  1. 恢复脚本必须拒绝假成功。我第一版用 for b in "$VOL"/*/ 遍历副本, 但砖是卷的兄弟目录(/data/brick1-g1 vs 卷 /data/g1),一个都没匹配到, 脚本却照样打印 🎉 恢复完成——什么都没干却报成功,比原故障更危险。 修复:复用同一套「卷 → 砖」映射函数 + 硬断言(0 个 brick / 0 个副本 → 非 0 退出)
    • 统计 CHECKED,CHECKED=0 不允许报成功。
  2. 环境与文档不一致要记下来:CMD 直接跑复现脚本会让容器退出、 docker compose exec 报 service "app" is not running。 交互式实验要加 command override 让容器常驻,或用 docker compose up 前台看 logs。
  3. --heal-start 不是真实 GlusterFS 开关:它只是把"自愈中途被打断"的窗口 放大成秒级,方便稳定复现。真实环境里这个窗口来自 RSS/OOM-kill、 网络抖动、brick 重启、大文件扫描时长。

一句话总结:O_TRUNC 先清零 + 摊还式自愈无断点续传 → 自愈中断 = 数据静默丢失; 正确动作是"停写 + 从校验过的备份恢复",不是反复 heal full。

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-09-12-0800-glusterfs-replica2-self-heal-zero-file

分享这篇文章