You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

245 lines
26 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 特别放送 | 成员变更:为什么集群看起来正常,移除节点却会失败呢?
你好我是王超凡etcd项目贡献者腾讯高级工程师。目前我主要负责腾讯公有云大规模Kubernetes集群管理和etcd集群管理。
受唐聪邀请我将给你分享一个我前阵子遇到的有趣的故障案例并通过这个案例来给你介绍下etcd的成员变更原理。
在etcd的日常运营过程中大部分同学接触到最多的运维操作就是集群成员变更操作无论是节点出现性能瓶颈需要扩容还是节点故障需要替换亦或是需要从备份来恢复集群都离不开成员变更。
然而如果你对etcd不是非常了解在变更时未遵循一定的规范那么很容易在成员变更时出现问题导致集群恢复时间过长进而造成业务受到影响。今天这节课我们就从一次诡异的故障说起来和你聊聊etcd成员变更的实现和演进看看etcd是如何实现动态成员变更的。希望通过这节课帮助你搞懂etcd集群成员管理的原理安全的变更线上集群成员从容的应对与集群成员管理相关的各类问题。
## 从一次诡异的故障说起
首先让我们来看一个实际生产环境中遇到的案例。
某天我收到了一个小伙伴的紧急求助有一个3节点集群其中一个节点发生了故障后由于不规范变更没有先将节点剔除集群而是直接删除了数据目录然后重启了节点。
之后该节点就不停的panic此时其他两个节点正常。诡异的是此时执行member remove操作却报错集群没有Leader但是用endpoint status命令可以看到集群是有Leader存在的。更加奇怪的是过了几个小时后该节点又自动恢复了如下图
![](https://static001.geekbang.org/resource/image/47/3b/47e73f39f751f9a9430e8da4d2896f3b.png?wh=1920*314)
![](https://static001.geekbang.org/resource/image/82/bc/82689859250cayye421a9e4a821799bc.png?wh=1616*172)
![](https://static001.geekbang.org/resource/image/a9/ec/a972a5849409a8c8a2b8fb11880a5fec.png?wh=1920*101)
你可以先自己思考下,可能是什么原因导致了这几个问题?有没有办法能够在这种场景下快速恢复集群呢?
如果暂时没什么思路,不要着急,相信学完这节课的成员变更原理后,你就能够独立分析类似的问题,并快速地提供正确、安全的恢复方式。
## 静态配置变更 VS 动态配置变更
接下来我们就来看下,要实现成员变更,都有哪些方案。
最简单的方案就是将集群停服,更新所有节点配置,再重新启动集群。但很明显,这个方案会造成变更期间集群不可用。对于一个分布式高可用的服务来说,这是不可接受的。而且手工变更配置很容易因为人为原因造成配置修改错误,从而造成集群启动失败等问题发生。
既然将所有节点同时关闭来更新配置我们无法接受那么我们能否实现一个方案通过滚动更新的方式增删节点来逐个更新节点配置尽量减少配置更新对集群的影响呢zookeeper 3.5.0之前就是采用的这个方案来降低配置更新对集群可用性的影响。
但这种方案还是有一定的缺点。一是要对存量节点配置进行手动更新,没有一个很好的校验机制,如果配置更新错误的话很容易对集群造成影响。二是滚动更新配置的过程中节点要进行重启,存量的连接要断开重连。在连接数和负载较高的场景下,大量连接重连也会对集群稳定性造成一定的影响。
针对这两个问题,有没有进一步的优化空间呢?作为程序员,我们的目标肯定是要尽量消除人工操作,将手工操作自动化,这样才能避免人为错误。
那么我们能否能够在配置实际应用之前通过程序来做好一系列的检查工作当所有检查通过后再实际应用新的配置呢同样为了避免重启节点我们能否通过API和共识算法将新的配置动态同步到老的节点呢
etcd目前采用的正是上面这种实现方式。它将成员变更操作分为了两个阶段如下图
* 第一个阶段通过API提交成员变更信息在API层进行一系列校验尽量避免因为人为原因造成的配置错误。如果新的配置通过校验则通过Raft共识算法将新的配置信息同步到整个集群等到整个集群达成共识后再应用新的配置。
* 第二个阶段,启动新的节点,并实际加入到集群中(或者移除老的节点,然后老节点自动退出)。
![](https://static001.geekbang.org/resource/image/ce/06/ce969a18ea09b228d3e8fa50f2f12b06.png?wh=1200*804)
接下来我们就先来看下。etcd如何基于Raft来实现成员信息同步。
## 如何通过Raft实现成员信息同步
### 成员变更流程
在[04节课](https://time.geekbang.org/column/article/337604)中我们已经了解到Raft将一致性问题拆分成了3个子问题即Leader选举、日志复制以及安全性。基于日志复制我们可以将成员变更信息作为一个日志条目通过日志同步的方式同步到整个集群。那么问题来了日志同步后我们应该什么时候应用新的配置呢直接应用新的配置会造成什么问题吗
![](https://static001.geekbang.org/resource/image/86/25/867401d323bbac288a0304d43e75f325.png?wh=822*736)
如上图所示(参考自[Raft论文](https://web.stanford.edu/~ouster/cgi-bin/papers/raft-atc14)当我们将3个节点集群扩展到5个节点的时候我们可以看到对于老的3节点配置来说它的多数派是2个节点。而对于新的5节点配置集群来说它的多数派是3个节点。
在箭头指向的时刻新老配置同时生效老的配置中Server1和Server2组成了多数派新的配置中Server3、Server4、Server5组成了新的多数派。此时集群中存在两个多数派可能会选出两个Leader违背了安全性。
那么有没有方式能避免这个问题,保证变更的安全性呢?一方面我们可以引入**两阶段提交**来解决这个问题,另一方面我们可以通过增加一定约束条件来达到目标。如下图所示,当我们一次只变更一个节点的时候我们可以发现,无论是从奇数节点扩缩到偶数节点,还是从偶数节点扩缩到奇数节点,扩缩容前后配置中的多数派必然有一个节点存在交叉(既存在于老的配置的多数派中,也存在于新的配置的多数派中)。
我们知道在Raft里竞选出的Leader必须获得一半以上节点投票这就保证了选出的Leader必然会拥有重叠节点的投票。而一个节点在一轮投票中只能投票给一个候选者这就保证了新老配置最终选出的Leader必然是一致的。
![](https://static001.geekbang.org/resource/image/04/6f/047fa5420be1c48b5eacaabb5ff8956f.png?wh=1206*920)
因此,我们通过增加一次只变更一个成员这个约束,就可以得到一个很简单的成员变更实现方式:
* 在一次只变更一个节点的场景下每个节点只需要应用当前收到的日志条目中最新的成员配置即可即便该配置当前还没有commit
* 在一个变更未结束时,禁止提交新的成员变更。
这样就保证了一个成员变更可以安全地进行,同时在变更的过程中,不影响正常的读写请求,也不会造成老的节点重启,提升了服务的稳定性。
需要注意的是etcd并没有严格按照Raft论文来实现成员变更它应用新的配置时间点是在应用层apply时通知Raft模块进行ApplyConfChange操作来进行配置切换而不是在将配置变更追加到Raftlog时立刻进行切换。
到目前为止etcd就完整地实现了一个成员信息同步的流程。如果是扩容的话接下来只需要启动之前配置的新节点就可以了。
### 为什么需要Learner
那么这个实现方案有没有什么风险呢?我们一起来分析下。
举个例子当我们将集群从3节点扩容到4节点的时候集群的法定票数quorum就从2变成了3。而我们新加的节点在刚启动的时候是没有任何日志的这时就需要从Leader同步快照才能对外服务。
如果数据量比较大的话快照同步耗时会比较久。在这个过程中如果其他节点发生了故障那么集群可用节点就变成了2个。而在4节点集群中日志需要同步到3个以上节点才能够写入成功此时集群是无法写入的。
由于法定票数增加,同时新节点同步日志时间长不稳定,从而增大了故障的概率。那么我们是否能通过某种方式来尽量缩短日志同步的时间呢?
答案就是Learner节点在Raft论文中也叫catch up。etcd 3.4实现了Leaner节点的能力新节点可以以Learner的形式加入到集群中。Learner节点不参与投票即加入后不影响集群现有的法定票数不会因为它的加入而影响到集群原有的可用性。
Learner节点不能执行写操作和一致性读Leader会将日志同步给Learner节点当Learner节点的日志快追上Leader节点时etcd 3.4 Learner已同步的日志条目Index达到Leader的90%即认为ready它就成为Ready状态可被提升为Voting Member。此时将Learner提升为Voting Member可以大大缩短日志同步时间降低故障的概率。
另外由于Learner节点不参与投票因此即使因为网络问题同步慢也不会影响集群读写性能和可用性可以利用这个特性来方便的实现**异地热备**的能力。
### 联合一致性joint consensus
虽然一次添加一个节点在实现上可以降低很大的复杂度,但它同样也有一些缺陷。
例如在跨zone容灾的场景下假设一个集群有三个节点ABC分别属于不同的zone你无法在不影响跨多zone容灾能力的情况下替换其中一个节点。假设我们要用同一个zone的D节点来替换C节点如下图
* 如果我们采用**先增后减**的形式先将D加到集群中此时集群节点数变为了4法定票数变为了3。如果CD所在的zone挂掉则集群只剩下两个可用节点变为不可用状态。
* 如果我们采用**先减后增**的形式先将C节点移除此时集群中剩2个节点法定票数为2。如果A或者B所在的zone挂掉了集群同样不可用。
![](https://static001.geekbang.org/resource/image/28/f6/285e3e09e32bd26667fe211ddd5601f6.png?wh=1138*1284)
当然通过Learner节点可以很大程度上降低这个问题发生的概率。但我们如果能够实现多节点成员变更的话则可以从根本上解决这个问题。
多节点成员变更也是Raft论文中最初提到的实现成员变更的方式为了保证成员变更的安全性我们可以通过**两阶段提交**来实现同时变更多个成员两阶段提交的实现方式有多种在Raft中是通过引入一个过渡配置来实现的即引入**联合一致性joint consensus**来解决这个问题。如下图(引用自[Raft论文](https://web.stanford.edu/~ouster/cgi-bin/papers/OngaroPhD.pdf))所示:
![](https://static001.geekbang.org/resource/image/26/08/2654729e02a41e416eaf16c0bd27c508.png?wh=1374*580)
我们可以看到Raft引入了一个过渡配置Cold,new。当新的配置提案发起时Leader会先生成Cold,new状态的配置。当集群处于这个配置时需要Cold和Cnew的多数派都同意commit新的提案才能被commit。当Cold,new被commit后就可以安全切换到新的配置Cnew了当Cnew被提交后整个变更操作就完成了。
通过引入joint consensus我们可以看到不会存在Cold和Cnew同时独立做决定的情况保证了成员变更的安全性。
进一步推广的话通过引入joint consensus我们可以在多个成员变更过程中继续提交新的配置。但这么做不仅会带来额外的复杂度而且基本上不会带来实际的收益。因此在工程实现上我们一般还是只允许同一时间只能进行一次成员变更并且在变更过程中禁止提交新的变更。
etcd 3.4的Raft模块实现了joint consensus可以允许同时对多个成员或单个成员进行变更。但目前应用层并未支持这个能力还是只允许一次变更一个节点。它的实现仍然同Raft论文有一定的区别Raft论文是在配置变更提案追加到Raftlog时就切换配置而etcd的Raft实现是在apply过程才进行配置切换。当Cold,new配置apply之后就可以返回给客户端成功了。但此时变更还未完全结束新的日志条目仍然需要Cold和Cnew多数派都同意才能够提交Raft模块会通过追加一个空的配置变更条目将配置从Cold,new切换到Cnew。当Cnew apply后新的日志条目就只需要Cnew多数派同意即可整个成员变更信息同步完成。
## 集群扩容节点完整流程
上边讲完了成员信息同步流程,我们就可以来看下向一个已有集群扩容一个新节点的整体流程是怎样的(整体流程如下图)。
![](https://static001.geekbang.org/resource/image/91/a5/9104a0b63c45c97b26103ac47a20a3a5.png?wh=1514*1200)
首先我们可以通过etcdctl或者clientv3库提供的API来向成员管理模块发起一个MemberAdd请求。成员管理模块在收到请求后会根据你提供的peer-urls地址来构建一个Member成员注意此时构建的Member成员的Name为空然后请求etcdserver进行添加操作。
```
ETCDCTL_API=3 etcdctl --endpoints=http://1.1.1.1:2379
member add node-4 --peer-urls=http://4.4.4.4:2380
```
在开启strict-reconfig-check的情况下默认开启etcdserver会先进行一系列检查比如检查当前集群启动的节点数是否满足法定票数要求当前集群所有投票节点是否都存活等。
检查通过后则向Raft模块发起一个ProposeConfChange提案带上新增的节点信息。提案在apply时会通知Raft模块切换配置同时更新本节点server维护的member和peer节点信息如果是移除节点操作的话被移除节点apply之后延时1s etcd进程会主动退出并将当前的成员信息更新到etcdserver维护的ConfState结构中。在snapshot的时候会进行持久化具体作用我们后边会介绍然后返回给客户端成功。
如果你用的是etcdctl的话应该可以看到如下输出
```
Member 96af95420b65e5f5 added to cluster 81a549bdbfd5c3a8
ETCD_NAME="node-4"
ETCD_INITIAL_CLUSTER="node-1=http://1.1.1.1:2380,node-2=http://2.2.2.2:2380,node-3=http://3.3.3.3:2380,node-4=https://4.4.4.4:2380"
ETCD_INITIAL_ADVERTISE_PEER_URLS="https://4.4.4.4:2380"
ETCD_INITIAL_CLUSTER_STATE="existing"
```
通过使用命令返回的环境变量参数,我们就可以启动新的节点了(注意,这里一定要保证你的启动参数和命令返回的环境变量一致)。
新节点启动时会先校验一系列启动参数根据是否存在WAL目录来判断是否是新节点根据initial-cluster-state参数的值为new或existing来判断是加入新集群还是加入已存在集群。
如果是已存在集群添加新节点的情况也就是不存在WAL目录且initial-cluster-state值为existing。如果存在WAL目录则认为是已有节点会忽略启动参数中的initial-cluster-state和initial-cluster等参数直接从snapshot中和WAL中获取成员列表信息则会从配置的peerURLs中获取其他成员列表连接集群来获取已存在的集群信息和成员信息更新自己的本地配置。
然后会启动RaftNode进行一系列的初始化操作后etcdserver就可以启动了。启动时会通过goroutine异步执行publish操作通过Raft模块将自己发布到集群中。
在发布之前该节点在集群内的Name是空etcd会认为unstarted发布时会通过Raft模块更新节点的Name和clientURLs到集群中从而变成started状态。发布之后该节点就可以监听客户端端口对外提供服务了。在执行publish的同时会启动监听peer端口用于接收Leader发送的snapshot和日志。
## 新集群如何组建
上边介绍了已存在集群扩容的场景,那么新建集群时又是怎样的呢?
新建集群和加节点的启动流程大体上一致,这里有两个不同的点:
一个是在新集群创建时构建集群的member信息会直接从启动参数获取区别于加节点场景从已存在集群查询。这就要求新集群每个节点初始化配置的initial-cluster、initial-cluster-state、initial-cluster-token参数必须相同因为节点依赖这个来构建集群初始化信息参数相同才能保证编码出来的MemberId和ClusterId相同。
另一个需要注意的点是在启动Raft Node的过程中如果是新建集群的话会多一步BootStrap流程。该流程会将initial-cluster中声明的Peer节点转换为ConfChangeAddNode类型的ConfChange日志条目追加到Raftlog中并设置为commited状态。然后直接通过applyConfChange来应用该配置并在应用层开始apply流程时再次apply该配置变更命令这里重复应用相同配置不会有其他影响
你知道etcd为什么要这么做吗这么做的一个好处是命令会通过WAL持久化集群成员状态也会通过snapshot持久化。当我们遇到后续节点重启等场景时就可以直接应用snapshot和WAL中的配置进行重放来生成实际的成员配置而不用再从启动参数读取。因为启动参数可能因为动态重配置而不再准确而snapshot和WAL中的配置可以保证最新。
## 如何从备份恢复集群
除了新建集群和集群扩缩容外,备份恢复同样十分重要。在集群一半以上节点挂掉后,就只能从备份来恢复了。
我们可以通过etcdctl snapshot save命令或者clientv3库提供的snapshot API来对集群进行备份。备份后的数据除了包含业务数据外还包含一些集群的元数据信息例如成员信息
有了备份之后我们就可以通过etcdctl snapshot restore命令来进行数据恢复。这个命令的参数你一定不要搞错我建议你对照[官方文档](https://etcd.io/docs/v3.4.0/op-guide/recovery/)来。每个节点恢复数据时的name和initial-advertise-peer-urls是有区别的如果所有节点都用一样的话最后你可能会恢复成多个独立的集群我曾经就见到有业务这样搞出过问题。
我们接着来看下snapshot restore都干了哪些事情如下图
![](https://static001.geekbang.org/resource/image/f2/f3/f28bf73f76e00b62af659526f09575f3.png?wh=376*1316)
首先它会根据你提供的参数进行一系列校验检查snapshot的hash值等。如果检查通过的话会创建snap目录并将snapshot拷贝到v3的db文件设置consistentIndex值为当前提供的initial-cluster参数中包含的成员数量并从db中删除老的成员信息。
然后它会根据你提供的参数信息来构建WAL文件和snap文件。从你提供的配置中来获取peer节点信息并转换为ConfChangeAddNode类型的ConfChange日志条目写入WAL文件同时更新commit值并将term设置为1。
之后snapshot restore会将peer节点作为Voters写入snapshot metadata的ConfState中并更新Term和Index。snapshot保存后WAL会随后保存当前snapshot的Term和Index用于索引snapshot文件。
当每个节点的数据恢复后我们就可以正常启动节点了。因为restore命令构造了WAL和snapshot因此节点启动相当于一个正常集群进行重启。在启动Raft模块时会通过snapshot的ConfState来更新Raft模块的配置信息并在应用层apply时会重放从WAL中获取到的ConfChangeAddNode类型的ConfChange日志条目更新应用层和Raft模块配置。
至此,集群恢复完成。
## 故障分析
了解完etcd集群成员变更的原理后我们再回到开篇的问题不晓得现在你有没有一个大概的思路呢接下来就让我们运用这节课和之前学习的内容一起来分析下这个问题。
首先这个集群初始化时是直接启动的3节点集群且集群创建至今没有过成员变更。那么当删除数据重启时异常节点会认为自己是新建集群第一次启动所以在启动Raft模块时会将peer节点信息转换成ConfChangeAddNode类型的ConfChange日志条目追加到Raftlog中然后设置committed Index为投票节点数量。我们是3节点集群所以此时committed Index设置为3并设置term为1然后在本地apply该日志条目应用初始化配置信息然后启动etcdserver。
Leader在检测到该节点存活后会向该节点发送心跳信息同步日志条目。Leader本地会维护每个peer节点的Match和Next IndexMatch表示已经同步到该节点的日志条目IndexNext表示下一次要同步的Index。
当Leader向Follower节点发送心跳时会从Match和Leader当前的commit Index中选择一个较小的伴随心跳消息同步到Follower节点。Follower节点在收到Leader的commit Index时会更新自己本地的commit Index。
但Follower节点发现该commit Index比自己当前最新日志的Index还要新按照我们之前的分析异常节点当前最新的Index为3日志也证明了这一点而Leader发送的commit Index是之前节点正常时的commit值肯定比3这个值要大便认为raftlog肯定有损坏或者丢失于是异常节点就会直接panic退出。最后就出现了我们之前看到的不停重启不停panic的现象。
那么为什么执行member remove操作会报没有Leader呢我们之前提到过执行成员变更前会进行一系列前置检查如下图。在移除节点时etcd首先会检查移除该节点后剩余的活跃节点是否满足集群法定票数要求。满足要求后会检查该节点是否宕机连接不通。如果是宕机节点则可以直接移除。
![](https://static001.geekbang.org/resource/image/8e/39/8edb11c1c268eabda477597ce25e0f39.png?wh=828*1352)
但由于我们的节点不停重启每次重启建立peer连接时会激活节点状态因此没有统计到宕机的节点中。
最后会统计集群中当前可用的节点该统计方式要求节点必须在5s前激活因为节点刚启动5s内认为etcd还没有ready所以不会统计到可用节点中即当前可用节点数为2。
然后再判断移除一个可用节点后,当前剩余节点是否满足法定票数要求,我们这个案例中为 2 - 1 < 1+ ((3-1)/2),不满足法定票数要求,所以服务端会返回ErrUnhealthy报错给客户端(我们这个场景其实是由于etcd针对不可用节点的判断没有排除异常的要移除节点导致)。
由于用户当时使用的是etcdctl v2API,所以客户端最终会将该错误转换成http code 503,客户端识别到503,就会认为当前集群没Leader(这里v2客户端代码对v3 grpc错误码转换判断不是很准确,有误导性),打印我们之前看到的no Leader错误。
最后一个问题,为什么后来panic节点会自动恢复呢?答案是中间由于IO高负载,发生了心跳超时,造成了Leader选举。
新的Leader选举出来后,会重置自己维护的peer节点的Match Index0,因此发送给异常Follower心跳时带上的commit Index即为0。所以Follower不会再因为commit Index小于自己最新日志而panic。而Leader探测到FollowerIndex和自己差距太大后,就发送snapshotFollowerFollower接收snapshot后恢复正常。
这个case了解原理后,如果希望快速恢复的话也很简单:完全停掉异常Follower节点后,再执行member remove,然后将节点移除,清理数据再重新加入到集群(或者通过move-leader命令手动触发一次Leader切换,但该方式比较trick,并不通用)。
以上就是这个案例的完整分析,希望通过这个case,能让你认识到规范变更的重要性,在不了解原理的情况下,一定要按照官方文档来操作,不要凭感觉操作。
## 小结
![](https://static001.geekbang.org/resource/image/yy/1e/yy38094b6be35a476442fd3498eb511e.png?wh=1820*1028)
最后我们来小结下今天的内容,今天我从一个诡异的成员变更故障案例讲起,为你介绍了etcd实现成员变更的原理,分别为你分析了etcd成员变更在Raft层和应用层的实现,并分析了各个实现方案的优缺点。
其次我带你过了一遍etcd成员变更的演进方案:从只支持Member变更到支持Learner节点(non-voting MemberRaft层从只支持单节点变更到支持多节点变更。成员变更的方案越来越完善、稳定,运维人员在变更期间发生故障的概率也越来越低。
之后我以新增节点为例,深入为你分析了从配置提交到节点启动对外服务的完整流程,以及新集群启动和恢复过程中涉及到的成员变更原理。
最后,通过我们这节课和之前的课程学到的原理,我和你一步一步深入分析了下开篇的故障问题可能发生的原因以及快速恢复的方法。希望通过这节课,让你对etcd成员变更方案有一个深入的了解,在遇到类似的问题时能够快速定位问题并解决,提升业务的稳定性。
## 思考题
在组建etcd集群时,你是习惯于在initial-cluster参数中直接指定所有节点的配置启动,还是说先指定一个节点配置启动,然后再将剩余节点用添加到已存在集群的方式依次加入到集群中呢?这两种方式各存在哪些优缺点?欢迎把你的经验和想法分享到留言区,我们可以一起讨论下。
感谢你的阅读,如果你认为这节课的内容有所收获,也欢迎把它分享给更多的朋友一起阅读。