从消失的 Node 说起

尝试重启 APIServer,可 Node 依旧消失。百思不得其解的同时,只能去确认各个 etcd 节点上数据是否存在,结果却有了颠覆你固定思维的发现,那就是基于 Raft 实现的强一致存储竟然出现不一致、数据丢失。除了第一个节点含有数据另外两个节点竟然找不到。那么问题就来了,另外两个节点数据是如何丢失的呢?

一步步解密真相

在进一步深入分析前,我们结合基础篇03对 etcd 写流程原理的介绍(如下图),先大胆猜测下可能的原因。

image.png

猜测 1:etcd 集群出现分裂,三个节点分裂成两个集群。APIServer 配置的后端 etcd server 地址是三个节点,APIServer 并不会检查各节点集群 ID 是否一致,因此如果分裂,有可能会出现数据“消失”现象。这种故障之前在 Kubernetes 社区的确也见到过相关 issue,一般是变更异常导致的,显著特点是集群 ID 会不一致。

猜测 2:Raft 日志同步异常,其他两个节点会不会因为 Raft 模块存在特殊 Bug 导致未收取到相关日志条目呢?这种怀疑我们可以通过 etcd 自带的 WAL 工具来判断,它可以显示 WAL 日志中收到的命令(流程四、五、六)。

猜测 3:如果日志同步没问题,那有没有可能是 Apply 模块出现了问题,导致日志条目未被应用到 MVCC 模块呢(流程七)?

猜测 4:若 Apply 模块执行了相关日志条目到 MVCC 模块,MVCC 模块的 treeIndex 子模块会不会出现了特殊 Bug, 导致更新失败(流程八)?

猜测 5:若 MVCC 模块的 treeIndex 模块无异常,写请求到了 boltdb 存储模块,有没有可能 boltdb 出现了极端异常导致丢数据呢(流程九)?

首先还是从故障定位第一工具“日志”开始。

查看 APIServer 日志的时候,发现持续报”required revision has been compacted”

通过如下命令查看 etcd 节点详细的状态信息::

  1. etcdctl endpoint status --cluster -w json | python -m
  2. json.tool

结果:

  1. [
  2. {
  3. "Endpoint":"A"
  4. "Status":{
  5. "header":{
  6. "cluster_id":17237436991929493444
  7. "member_id":9372538179322589801
  8. "raft_term":10
  9. "revision":1052950
  10. },
  11. "leader":9372538179322589801
  12. "raftAppliedIndex":1098420
  13. "raftIndex":1098430
  14. "raftTerm":10
  15. "version":"3.3.17"
  16. }
  17. },
  18. {
  19. "Endpoint":"B"
  20. "Status":{
  21. "header":{
  22. "cluster_id":17237436991929493444
  23. "member_id":10501334649042878790
  24. "raft_term":10
  25. "revision":1025860
  26. },
  27. "leader":9372538179322589801
  28. "raftAppliedIndex":1098418
  29. "raftIndex":1098428
  30. "raftTerm":10
  31. "version":"3.3.17"
  32. }
  33. },
  34. {
  35. "Endpoint":"C"
  36. "Status":{
  37. "header":{
  38. "cluster_id":17237436991929493444
  39. "member_id":18249187646912138824
  40. "raft_term":10
  41. "revision":1028860
  42. },
  43. "leader":9372538179322589801
  44. "raftAppliedIndex":1098408
  45. "raftIndex":1098428
  46. "raftTerm":10
  47. "version":"3.3.17"
  48. }
  49. }
  50. ]

获得了如下信息:

  • 集群未分裂,3 个节点 A、B、C cluster_id 都一致,集群分裂的猜测被排除
  • 初步判断集群 Raft 日志条目同步正常,raftIndex 表示 Raft 日志索引号raftAppliedIndex 表示当前状态机应用的日志索引号。这两个核心字段显示三个节点相差很小,考虑到正在写入,未偏离正常范围,Raft 同步 Bug 导致数据丢失也大概率可以排除(不过最好还是用 WAL 工具验证下现在日志条目同步和写入 WAL 是否正常)。
  • 观察三个节点的 revision 值,相互之间最大差距接近 30000,明显偏离标准值。在07中我给你深入介绍了 revision 的含义,它是 etcd 逻辑时钟,每次写入,就会全局递增。为什么三个节点之间差异如此之大呢?

如何真正确认 Raft 日志同步正常呢?

  • 首先我们写入一个值,比如 put hello 为 world,
  • 然后马上在各个节点上用 WAL 工具 etcd-dump-logs 搜索 hello。如下所示,各个节点上都可找到我们刚刚写入的命令。
  1. $ etcdctl put hello world
  2. OK
  3. $ ./bin/tools/etcd-dump-logs ./Node1.etcd/ | grep hello
  4. 10 70 norm header:<ID:3632562852862290438 > put:<key:"hello" value:"world" >
  5. $ ./bin/tools/etcd-dump-logs ./Node2.etcd/ | grep hello
  6. 10 70 norm header:<ID:3632562852862290438 > put:<key:"hello" value:"world" >
  7. $ ./bin/tools/etcd-dump-logs ./Node3.etcd/ | grep hello
  8. 10 70 norm header:<ID:3632562852862290438 > put:<key:"hello" value:"world" >

源码面前了无秘密,etcd 更新 raftAppliedIndex 核心代码如下所示,你会发现这个指标其实并不靠谱。Apply 流程出现逻辑错误时,并没重试机制。etcd 无论 Apply 流程是成功还是失败,都会更新 raftAppliedIndex 值。也就是一个请求在 Apply 或 MVCC 模块即便执行失败了,都依然会更新 raftAppliedIndex

  1. // ApplyEntryNormal apples an EntryNormal type Raftpb request to the EtcdServer
  2. func s *EtcdServer ApplyEntryNormale *Raftpb.Entry {
  3. shouldApplyV3 := false
  4. if e.Index > s.consistIndex.ConsistentIndex() {
  5. // set the consistent index of current executing entry
  6. s.consistIndex.setConsistentIndexe.Index
  7. shouldApplyV3 = true
  8. }
  9. defer s.setAppliedIndexe.Index
  10. ....
  11. }

而三个节点 revision 差异偏离标准值,恰好又说明异常 etcd 节点可能未成功应用日志条目到 MVCC 模块。我们也可以通过查看 MVCC 的相关 metrics(比如 etcd_mvcc_put_total),来排除请求是否到了 MVCC 模块,事实是丢数据节点的 metrics 指标值的确远远落后正常节点

"revision":1052950
"revision":1025860
"revision":1028860
  • 我们将真凶锁定在 Apply 流程上。我们对 Apply 流程在未向 MVCC 模块提交请求前可能提前返回的地方,都加了日志
  • 我们查看 Apply 流程还发现,Apply 失败的时候并不会打印任何日志。这也解释了为什么出现了数据不一致严重错误,但三个 etcd 节点却并没有任何异常日志。为了方便定位问题,我们因此增加了 Apply 错误日志。
  • 同时我们测试发现,写入是否成功还跟 client 连接的节点有关,连接不同节点会出现不同的写入结果。我们用 debug 版本替换后,马上就输出了一条错误日志 auth: revision in header is old。

真相终于浮出水面,原来当你无意间重启 etcd 的时候,如果最后一条命令是鉴权相关的,它并不会持久化 consistent index(KV 接口会持久化)。consistent index 在03里我们详细介绍了,它具有幂等作用,可防止命令重复执行。consistent index 的未持久化最终导致鉴权命令重复执行。

为什么会不一致

首先我们知道,etcd 各个节点数据一致性基于 Raft 算法的日志复制实现的,etcd 是个基于复制状态机实现的分布式系统。下图是分布式复制状态机原理架构,核心由 3 个组件组成,一致性模块、日志、状态机,其工作流程如下:

  1. client 发起一个写请求(set x = 3);
  2. server 向一致性模块(假设是 Raft)提交请求,一致性模块生成一个写提案日志条目。若 server 是 Leader,把日志条目广播给其他节点,并持久化日志条目到 WAL 中;
  3. 当一半以上节点持久化日志条目后,Leader 的一致性模块将此日志条目标记为已提交(committed),并通知其他节点提交;
  4. server 从一致性模块获取已经提交的日志条目,异步应用到状态机持久化存储中(boltdb 等),然后返回给 client。


image.png

从图中我们可以了解到,在基于复制状态机实现的分布式存储系统中,Raft 等一致性算法它只能确保各个节点的日志一致性,也就是图中的流程二。

也就是说有可能存在 server 应用日志条目到状态机失败,进而导致各个节点出现数据不一致。但是这个不一致并非 Raft 模块导致的,它已超过 Raft 模块的功能界限。

这种逻辑错误即便重试也无法解决,目前社区也没有彻底的根治方案,只能根据具体案例进行针对性的修复。同时我给社区增加了 Apply 日志条目失败的警告日志。
**

其他典型不一致 Bug

再以一个之前升级 etcd 3.2 集群到 3.3 集群时,遇到的数据不一致的故障事件为例给你讲讲。

这个故障对外的表现也是令人摸不着头脑,有服务不调度的、有 service 下的 endpoint 不更新的。最终我经过一番排查发现,原来数据不一致是由于 etcd 3.2 和 3.3 版本 Lease 模块的 Revoke Lease 行为不一致造成。

  • etcd 3.2 版本的 RevokeLease 接口不需要鉴权,而 etcd 3.3 RevokeLease 接口增加了鉴权,因此当你升级 etcd 集群的时候,如果 etcd 3.3 版本收到了来自 3.2 版本的 RevokeLease 接口,就会导致因为没权限出现 Apply 失败,进而导致数据不一致,引发各种诡异现象。

defrag 操作也可能会导致不一致:

  • 对一个 defrag 碎片整理来说,它是如何触发数据不一致的呢? 触发的条件是 defrag 未正常结束时会生成 db.tmp 临时文件。这个文件可能包含部分上一次 defrag 写入的部分 key/value 数据,。而 etcd 下次 defrag 时并不会清理它,复用后就可能会出现各种异常场景,如重启后 key 增多、删除的用户数据 key 再次出现、删除 user/role 再次出现等。

从以上三个案例里,我们可以看到,算法一致性不代表一个庞大的分布式系统工程实现中一定能保障一致性,工程实现上充满着各种挑战,从不可靠的网络环境到时钟、再到人为错误、各模块间的复杂交互等,几乎没有一个存储系统能保证任意分支逻辑能被测试用例 100% 覆盖。
**

最佳实践

实践中有哪些方法可以提前发现和规避不一致问题呢?

  • 开启 etcd 的数据毁坏检测功能;
  • 应用层的数据一致性检测;
  • 定时数据备份;
  • 良好的运维规范(比如使用较新稳定版本、确保版本一致性、灰度变更)。

开启 etcd 的数据毁坏检测功能

etcd 不仅支持在启动的时候,通过 —experimental-initial-corrupt-check 参数检查各个节点数据是否一致,也支持在运行过程通过指定 —experimental-corrupt-check-time 参数每隔一定时间检查数据一致性。

一致性检测原理是怎样的?

  • etcd 的实现也就是通过遍历 treeIndex 模块中的所有 key 获取到版本号,然后再根据版本号从 boltdb 里面获取 key 的 value,使用 crc32 hash 算法,将 bucket name、key、value 组合起来计算它的 hash 值。

如果出现不一致性,etcd 会采取什么样动作去降低数据不一致影响面呢?

  • 如果你开启了 —experimental-initial-corrupt-check,启动的时候每个节点都会去获取 peer 节点的 boltdb hash 值,然后相互对比,如果不相等就会无法启动。
  • 定时检测是指 Leader 节点获取它当前最新的版本号,并通过 Raft 模块的 ReadIndex 机制确认 Leader 身份。当确认完成后,获取各个节点的 revision 和 boltdb hash 值,若出现 Follower 节点的 revision 大于 Leader 等异常情况时,就可以认为不一致,发送 corrupt 告警,触发集群 corruption 保护,拒绝读写

应用层的数据一致性检测

从上面我们对数据不一致性案例的分析中,我们知道数据不一致在 MVCC、boltdb 会出现很多种情况,比如说 key 数量不一致、etcd 逻辑时钟版本号不一致、MVCC 模块收到的 put 操作 metrics 指标值不一致等等。因此我们的应用层检测方法就是基于它们的差异进行巡检。

定时数据备份

因此备份特别重要,备份可以保障我们在极端场景下,能有保底的机制去恢复业务。请记住,在做任何重要变更前一定先备份数据,以及在生产环境中建议增加定期的数据备份机制(比如每隔 30 分钟备份一次数据)。

  • 你可以使用开源的 etcd-operator 中的 backup-operator 去实现定时数据备份,它可以将 etcd 快照保存在各个公有云的对象存储服务里面。

良好的运维规范

  • 确保集群中各节点 etcd 版本一致
  • 优先使用较新稳定版本的 etcd
  • 在升级 etcd 版本的时候,需要多查看 change log,评估是否存在可能有不兼容的特性。在你升级集群的时候注意先在测试环境多验证,生产环境务必先灰度、再全量。

小结

遇到复杂 Bug 时,请永远不要轻言放弃,它一定是一个让你快速成长的机会。