Storforge
里程碑预计 50 分钟

里程碑③:拔掉一个节点

3 节点集群持续读写,当场 kill 掉一个节点:写入不中断、已有数据全能读、节点回归后自动补齐。用你熟悉的运维手法验收自己的系统。

学完这节你能做到

  • 编排三节点故障演练脚本并全部通过
  • 观测故障切换期间的延迟毛刺并解释来源
  • 写出运维手册第一版:部署、扩容、换盘

这次演练,桌子两边都是你

你做过很多次故障演练:拔盘、断网、kill 进程,然后盯着别人写的系统看它的表现。 这次不一样 —— 被演练的系统是你写的,验收标准也是你定的。里程碑③的目标一句话: 3 节点 forge 集群,持续读写中 kill 掉任意一个节点,客户端无感知;节点回来,数据自动补齐。

先盘点手里的零件:forge-store(单机引擎)、forge-proto(协议)、forge-node (守护进程:RPC + gossip + Raft)、forge-cluster(成员、slot 表、EC 编解码)、 forge-bench(L2 的压测,直方图这次接着用)、forge-cli(运维入口)。 本课把它们接成完整的写路径:客户端从任一节点拿 slot 表(带 epoch), 本地 EC 编码出 6 片,按表直连并行落盘,收齐 6 个确认返回 —— 元数据走 Raft, 数据一个字节都不碰 leader。

3 节点跑 4+2:先把这笔账摊开

一个绕不开的矛盾:4+2 需要 6 个独立故障域,我们只有 3 个节点。硬凑会怎样? 每节点放 2 片,单节点故障同时丢 2 片 —— 刚好贴着容错上限,再坏一块盘就是数据丢失。

forge 的取舍是把故障域从「节点」降级到「盘」:每个节点挂 2 块盘(实验环境用 2 个数据目录代替),集群共 6 个盘级故障域,放置约束改成「6 片落 6 个不同的盘, 且每节点至多 2 片」。这笔账要算得诚实:

方案容量开销容忍节点故障容忍盘故障与生产代码路径的差异
2+1(每节点 1 片)1.5 倍1 个1 块条带宽度不同,聚合与重建逻辑退化
4+2(每节点 2 片)1.5 倍1 个任意 2 块无,与 6 节点生产形态同一套代码

两个方案容量开销相同、都只容忍 1 个节点故障,4+2 多出「任意 2 块盘」的容错, 且代码路径和真正的生产形态(6 节点以上、故障域回升为节点)完全一致 —— 所以选 4+2。 但必须白纸黑字写进运维手册:3 节点形态下,同时挂 2 个节点等于数据丢失, 这是实验拓扑的天花板,不是 4+2 的天花板。生产部署 4+2 的下限是 6 个节点, 建议 7 个以上(留一个热备故障域,重建有处可去)。

Checkpoint单选

3 节点、每节点 2 盘、按盘做故障域的 4+2 集群,下列哪种故障会造成数据丢失?

演练准备:拓扑、部署与基线负载

三台虚机(或本机三个进程、三组端口),每台两个数据目录。部署长这样:

# 每个节点(以 n1 为例;n2、n3 改 ID 和地址)
forge-node --id n1 \
  --listen 10.0.0.1:7301 --raft-listen 10.0.0.1:7401 \
  --data /data/n1a,/data/n1b

# 任一节点上初始化集群并拉入成员
forge-cli cluster init --node n1=10.0.0.1
forge-cli cluster add-node n2=10.0.0.2
forge-cli cluster add-node n3=10.0.0.3
forge-cli cluster status   # 预期:leader 已选出,epoch 稳定,256 个 slot 分配完毕

基线负载是所有验收项的观测底座 —— 没有持续负载,「无感知」三个字无从谈起:

# forge-bench 的集群模式:1 写 4 读混合,1MiB 对象,每次操作记录时间戳、延迟、结果
forge-bench cluster --peers 10.0.0.1,10.0.0.2,10.0.0.3 \
  --mix put=1,get=4 --object-size 1MiB \
  --duration 900s --log /tmp/ops.csv

先空跑 2 分钟记下基线:预期 put P99 在 10ms 上下(6 片并行落盘,受最慢分片拖尾), get P99 在 5ms 上下。你的绝对数字会不同,先有基线再演练,这是你的老规矩。

验收项 1:kill 数据节点,客户端无感知

# 1. 确认 n2 不是 Raft leader(leader 演练是验收项 2,变量要一次只动一个)
forge-cli cluster status | grep leader
# 2. 负载运行中,直接 kill -9
ssh 10.0.0.2 'pkill -9 forge-node'
# 3. 观察 3 分钟,然后分析 ops.csv

判定标准,每条都要在 ops.csv 里找到证据:

  • get 错误数为 0。条带缺 2 片(n2 的两块盘),客户端凑齐剩下 4 片 RS 解码 —— 降级读。P99 允许从 5ms 抬到 15ms 左右:多了一次重试发现、加解码计算
  • put 错误数为 0,允许一个 3 秒以内的延迟尖峰。尖峰来源:故障检测(phi 判 SUSPECT 到 Raft 提交成员变更约 2~3 秒)期间,发往 n2 的分片在吃超时。检测完成后进入降级写: 6 片落到剩余 4 块盘上(必有两块盘各接 2 片,暂时违反故障域约束),条带标记 degraded, 等 n2 回归后再摆正 —— 写可用性优先,容错暂降,这个取舍要出现在你的运维手册里
  • forge-cli cluster status 显示 n2 为 DEAD,epoch 增加(Raft 记录了这次事件)

验收项 2:kill Raft leader,写恢复小于 2 秒

# 1. 找到当前 leader(假设是 n1)
forge-cli raft status
# 2. kill -9,掐表
ssh 10.0.0.1 'pkill -9 forge-node'
# 3. 从 ops.csv 计算 put 操作最大时间戳间隙
awk -F, '$2=="put" && $4=="ok"' /tmp/ops.csv | sort -t, -k1n \
  | awk -F, 'NR>1 && $1-prev>gap {gap=$1-prev} {prev=$1} END {print gap, "ms"}'

判定:put 成功记录的最大间隙小于 2000ms。拆开算这笔账:选举超时 150300ms 随机触发,一轮投票一个 RTT,新 leader 上任后客户端一次重试退避(最多几百毫秒)—— 正常应落在 0.51 秒。超过 2 秒说明有问题:常见是选举超时配得过大, 或客户端退避上限太高把恢复时间吃掉了。

还有一个必须解释的现象:get 几乎看不到毛刺。想想为什么 —— 读路径拿着缓存的 slot 表直连数据节点,根本不经过 leader;掉的又是 leader 而非数据节点,数据面毫发无损。 这就是元数据面与数据面分离的价值在监控曲线上的直接投影,截图留好,这是你的论据。

验收项 3:节点回归,自动重建校验

# 1. 重启 n2(数据还在,只是落后);换盘场景则先清空一个数据目录再启动
ssh 10.0.0.2 'forge-node --id n2 --listen ... --data /data/n2a,/data/n2b &'
# 2. 观察回归与重建
forge-cli node ls          # n2:DEAD -> ALIVE,incarnation 加一
forge-cli repair status    # 待补齐分片数,应持续下降到 0
# 3. 重建完成后全量校验
forge-bench verify --peers ... --all   # 逐对象读回,校验 crc32c 与长度

判定:repair 待办归零后,verify 零错误;degraded 条带(验收项 1 里降级写产生的) 全部重新按故障域摆正。背后的重建循环是声明式的,你可以按 K8s controller 理解: 期望状态(slot 表说分片该在哪)减去实际状态(scrub 扫出来分片实际在哪)等于任务队列, 每补一片先 RS 重建、再验 crc、落盘、最后更新条带元数据。注意 forge 现在的重建是 不限速的全速冲 —— 3 节点数据量小无所谓,规模一大就是你熟悉的重建风暴,限速留给 L4。

AI 结对:把三个验收项编排成一个脚本pair with ai

这个脚本以后每次改动集群代码都要跑 —— 它就是 L3 的回归测试。review 时重点核对 断言的方向:AI 很容易把「get 错误数为 0」写成「get 成功数大于 0」, 后者在 99% 请求失败时也能通过。另外确认 leader 解析真的每次现查, 演练顺序打乱后脚本仍然正确。

为 forge 写验收演练脚本 scripts/drill.sh,自动化里程碑③的三个验收项:

前置:读取环境变量 FORGE_PEERS(三个 host:port,逗号分隔)与 FORGE_SSH_USER;脚本任何断言失败立即退出并打印失败项。

阶段 0 基线:启动 forge-bench cluster(mix put=1,get=4,1MiB,后台运行,日志 /tmp/ops.csv),空跑 120s,记录 put/get 的 P99 作基线;
阶段 1 数据节点:选一个非 leader 节点(通过 forge-cli raft status 解析,不许写死),ssh 过去 pkill -9 forge-node,观察 180s。断言:窗口内 get 错误数为 0;put 错误数为 0;put 最大时间戳间隙小于 3000ms;
阶段 2 leader:解析出当前 leader,pkill -9,观察 60s。断言:put 成功记录最大间隙小于 2000ms;get P99 相比基线抬升不超过 50%;
阶段 3 回归:重启阶段 1 杀掉的节点,轮询 forge-cli repair status 直到待补齐为 0(超时 600s 判失败),然后跑 forge-bench verify --all,断言零错误;
收尾:输出一页汇总:每阶段耗时、关键指标对比基线、通过与否。

用 bash 写,set -euo pipefail,解析 ops.csv 用 awk。写完后逐段解释脚本里每个断言对应正文哪个验收标准。

复盘:你以前运维的系统在这些场景下表现如何

演练全绿之后,做一件只有你这种背景的人能做好的事:横向对照。

本次演练现象Ceph 里的对应物forge 还缺什么
降级读,P99 抬 3 倍PG degraded,EC 池降级读读时优先选本地分片、并行取 5 片留冗余
故障检测 2~3 秒mon 判 osd down(默认要 20 秒以上)我们更激进,但误判代价还没被大集群检验
leader 切换 1 秒内恢复写mon 选举期间 osdmap 冻结多 Raft 组(元数据本身分片,L4 的方向)
回归后全速重建backfill/recovery,有限速参数限速、优先级、业务流量隔离(L4)

再回答三个复盘问题,写进项目的 docs/(这也是 objectives 里运维手册的雏形): 当年你运维的系统在这三个场景里哪个表现更好、为什么;forge 的 2~3 秒故障检测 在 500 节点集群上敢不敢用;如果验收项 1 里检测期间的 put 尖峰不可接受, 你有哪些手段把它压下去(提示:副本式写日志先返回、后台转 EC —— Weka 的答案)。

AI 结对:运维手册第一版pair with ai

让 AI 写手册的隐藏价值在最后一条:它为了描述行为会去读代码, 读出来的「实际行为」和你脑中的「设计意图」对不上的地方,十有八九是 bug。 review 时把第 4 节已知限制逐条和正文的容错账核对 —— 手册里的容错承诺 写大一个字,都是未来事故报告里的一行。

基于 forge 仓库当前代码,起草 docs/ops-manual.md 运维手册第一版,只写已实现的行为,不许描绘愿景:

1. 部署:3 节点拓扑、每节点参数含义、初始化命令序列、健康检查清单;
2. 扩容:add-node 之后发生什么(slot 迁移由哪条 Raft 命令驱动、搬迁期间读写走哪),以及当前实现的限制;
3. 换盘:单盘故障的现象、更换步骤、重建观测命令、预计时长的估算方法(按分片数与重建读放大 4 倍推算);
4. 已知限制(诚实清单):3 节点形态同时挂 2 节点即数据丢失;降级写期间容错下降;重建不限速;故障检测参数未经大规模检验;
5. 故障速查表:症状、可能原因、诊断命令三列,至少覆盖本课三个验收项的场景。

写完后列出你在写手册过程中发现的代码与文档不一致之处(如果有),这些就是 bug 线索。

小结

  • 3 节点跑 4+2 靠故障域降级到盘:容任意 2 盘、仅容 1 节点,和 2+1 容量相同但代码路径与生产一致 —— 取舍写进手册
  • 验收项 1:kill 数据节点,get 零错误(降级读),put 一个 3 秒内尖峰后进入降级写
  • 验收项 2:kill leader,put 最大间隙小于 2 秒;get 无感 —— 数据面不经过 leader 的直接证据
  • 验收项 3:回归后声明式重建(期望减实际等于任务),补齐后全量 crc 校验归零
  • 复盘的价值是横向对照:forge 比 Ceph 检测快但未经规模检验,重建还没有限速 —— 债都记在 L4 的账上
  • 里程碑③达成:forge 是一个真正的分布式存储系统了。L4 把它长成文件系统