问题背景
Kubernetes 集群出现了 Deployment 滚动更新异常、节点莫名其妙消失了等诡异现象。
首先查看了下 Kubernetes 集群 APIServer、Controller Manager、Scheduler 等组件状态,发现都是正常。
然后查看了下 etcd 集群各节点状态,也都是健康的,看了一个 etcd 节点数据也是正常,于是怀疑是不是 APIServer 出现了什么诡异的 Bug 了。
重启 APIServer,可 Node 依旧消失。去确认各个 etcd 节点上数据是否存在,发现 etcd 节点出现不一致、数据丢失。除了第一个节点含有数据,另外两个节点竟然找不到。
分析可能的原因
etcd 写请求的流程如下:

猜测 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 出现了极端异常导致丢数据呢(流程九)?
etcd 故障排除思路
查看 etcd 节点状态
通过如下命令可以查看 etcd 节点详细的状态信息,包括每个节点的 cluster_id, raft_term, revision, leader, raftAppliedIndex, raftIndex 等。
etcdctl endpoint status --cluster -w json | python -m json.tool[
{
"Endpoint":"A",
"Status":{
"header":{
"cluster_id":17237436991929493444,
"member_id":9372538179322589801,
"raft_term":10,
"revision":1052950
},
"leader":9372538179322589801,
"raftAppliedIndex":1098420,
"raftIndex":1098430,
"raftTerm":10,
"version":"3.3.17"
}
},
{
"Endpoint":"B",
...- 节点的 cluster_id 来判断是否出现集群节点分裂现象。
- 节点的 raftIndex 和 raftAppliedIndex 可以判断节点的日志信息同步状况。
- 节点的 revision 为 etcd 的逻辑时钟,可以判断节点是否将数据推送到 MVCC 中。
校验 Raft 日志同步
除了上面的模糊校验,最好的办法是通过 WAL 工具 etcd-dump-logs 来判断。向 etcd 写入值后,在其他各节点查看日志中是否有记录。
$ etcdctl put hello world
OK
$ ./bin/tools/etcd-dump-logs ./Node1.etcd/ | grep hello
10 70 norm header:<ID:3632562852862290438 > put:<key:"hello" value:"world" >
$ ./bin/tools/etcd-dump-logs ./Node2.etcd/ | grep hello
10 70 norm header:<ID:3632562852862290438 > put:<key:"hello" value:"world" >
$ ./bin/tools/etcd-dump-logs ./Node3.etcd/ | grep hello
10 70 norm header:<ID:3632562852862290438 > put:<key:"hello" value:"world" >
查看源码
etcd 更新 raftAppliedIndex 核心代码如下所示,你会发现这个指标其实并不靠谱。Apply 流程出现逻辑错误时,并没重试机制。etcd 无论 Apply 流程是成功还是失败,都会更新 raftAppliedIndex 值。也就是一个请求在 Apply 或 MVCC 模块即便执行失败了,都依然会更新 raftAppliedIndex。
// ApplyEntryNormal apples an EntryNormal type Raftpb request to the EtcdServer
func (s *EtcdServer) ApplyEntryNormal(e *Raftpb.Entry) {
shouldApplyV3 := false
if e.Index > s.consistIndex.ConsistentIndex() {
// set the consistent index of current executing entry
s.consistIndex.setConsistentIndex(e.Index)
shouldApplyV3 = true
}
defer s.setAppliedIndex(e.Index)
....
}
而三个节点 revision 差异偏离标准值,恰好又说明异常 etcd 节点可能未成功应用日志条目到 MVCC 模块。也可以通过查看 MVCC 的相关 metrics(比如 etcd_mvcc_put_total),来排除请求是否到了 MVCC 模块。
通过对 Apply 流程在未向 MVCC 模块提交请求前可能提前返回的地方增加日志、增加 Apply 错误日志等,发现错误日志 auth: revision in header is old。
节点在 Apply 流程的时候,会判断 Raft 日志条目中的请求鉴权版本号是否小于当前鉴权版本号,如果小于就拒绝写入。
那为什么各个节点的鉴权版本号会出现不一致呢?那就需要从可能修改鉴权版本号的源头分析。我们发现只有鉴权相关接口才会修改它,同时各个节点鉴权版本号之间差异已经固定不再增加,要成功解决就得再次复现。
然后还了解到,当时 etcd 进程有过重启,我们怀疑会不会重启触发了什么 Bug,手动尝试复现一直失败。然而我们并未放弃,随后我们基于混沌工程,不断模拟真实业务场景、访问鉴权接口、注入故障(停止 etcd 进程等),最终功夫不负有心人,实现复现成功。
真相终于浮出水面,原来当你无意间重启 etcd 的时候,如果最后一条命令是鉴权相关的,它并不会持久化 consistent index(KV 接口会持久化)。consistent index 具有幂等作用,可防止命令重复执行。consistent index 的未持久化最终导致鉴权命令重复执行。
恰好鉴权模块的 RoleGrantPermission 接口未实现幂等,重复执行会修改鉴权版本号。一连串的 Bug 最终导致鉴权号出现不一致,随后又放大成 MVCC 模块的 key-value 数据不一致,导致严重的数据毁坏。
etcd v3.3.21 和 v3.4.8 后的版本已经修复此 Bug。
raft 一致但 etcd 不一致
基于 raft 算法实现的服务,虽然 raft 内部可以做到一致,或者说 raft 节点一致,但是 raft 节点将数据提供给外部服务时,并不能保证外部服务能够将这个数据保持一致。即,server 和 raft 的操作是无法做到一致性的,只能保证 raft 的一致性。
其他典型不一致 Bug
接口兼容性问题
etcd 3.2 版本的 RevokeLease 接口不需要鉴权,而 etcd 3.3 RevokeLease 接口增加了鉴权,因此当你升级 etcd 集群的时候,如果 etcd 3.3 版本收到了来自 3.2 版本的 RevokeLease 接口,就会导致因为没权限出现 Apply 失败,进而导致数据不一致,引发各种诡异现象。
数据变更逻辑问题
defrag 未正常结束时会生成 db.tmp 临时文件。这个文件可能包含部分上一次 defrag 写入的部分 key/value 数据,。而 etcd 下次 defrag 时并不会清理它,复用后就可能会出现各种异常场景,如重启后 key 增多、删除的用户数据 key 再次出现、删除 user/role 再次出现等。
最佳实践
在了解了 etcd 数据不一致的风险和原因后,我们在实践中有哪些方法可以提前发现和规避不一致问题呢?
下面我为你总结了几个最佳实践,它们分别是:
- 开启 etcd 的数据毁坏检测功能;
- 应用层的数据一致性检测;
- 定时数据备份;
- 良好的运维规范(比如使用较新稳定版本、确保版本一致性、灰度变更)。
etcd 数据毁坏检测
数据一致性的检查时机:
--experimental-initial-corrupt-check参数,etcd 启动时检查节点数据一致性。--experimental-corrupt-check-time参数,etcd 运行时定期检查数据一致性。
数据一致性的检查方式:
etcd 的实现也就是通过遍历 treeIndex 模块中的所有 key 获取到版本号,然后再根据版本号从 boltdb 里面获取 key 的 value,使用 crc32 hash 算法,将 bucket name、key、value 组合起来计算它的 hash 值。
如果开启了 --experimental-initial-corrupt-check,启动的时候每个节点都会去获取 peer 节点的 boltdb hash 值,然后相互对比,如果不相等就会无法启动。
而定时检测是指 Leader 节点获取它当前最新的版本号,并通过 Raft 模块的 ReadIndex 机制确认 Leader 身份。当确认完成后,获取各个节点的 revision 和 boltdb hash 值,若出现 Follower 节点的 revision 大于 Leader 等异常情况时,就可以认为不一致,发送 corrupt 告警,触发集群 corruption 保护,拒绝读写。
应用层的数据一致性检测
- 通过巡检功能,定时去统计各个节点的 key 数。查询时带上 WithCountOnly 参数,从而避免访问 boltdb 模块。
- 基于 endpoint 各个节点的 revision 信息做一致性监控。
- etcd MVCC 的 metrics 指标来监控,比如 mvcc_put_total。
定时数据备份
- 重要变更前需要备份数据
- 生产环境下定期备份数据(如 30 分钟)
良好的运维规范
etcd 版本一致、选择稳定的 etcd、升级时查看 changelog 避免不兼容问题、升级集群前先多测试后正式环境灰度到全量。