暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

管理etcd cluster成员的几点注意事项

零君聊软件 2021-09-02
1121

本文简要总结了管理etcd cluster成员的套路以及几点注意事项。

成员数量(cluser size)

这里主要涉及到两点。首先是一个etcd cluster的节点数量最好是奇数,两个原因:

  1. 对应的偶数个节点(奇数+1)能容忍的失败节点数量还是一样的。例如3个节点时,能容忍一个节点down,4个节点时同样只能容忍一个节点down。但是节点增多,导致失败的概率会增大;

  2. 在发生网络分区,将cluster隔离成两个小团体时,总有一个团体能满足quorum(法定票数),从而能继续提供服务。但是对于偶数个节点,则有可能两个隔离的分区都无法提供服务。


另外一点就是关于cluster节点数量的上限问题。虽然上限没有限制,但是一个etcd cluster最好不要超过7个节点。一方面,节点数量增加的确可以增加容错率,例如7个节点可以容忍3个down,9个则可以容忍4个down。但是另一方面,节点数量增加,同样也会导致quorum增大,例如7个节点的quorum是4,9个节点的quorum则是5;因为etcd是强一致性K/V DB,每一个写请求必须至少quorum个节点都写成功才能返回成功,所以节点数量增加会降低写请求的性能。


替换节点的正确姿势

当cluster中某个节点down了,一般需要把这个节点从cluster中移除,并增加一个新的节点到cluster中去。


这里一定要注意,要先删除老节点,然后再增加新节点。先删除后增加,先删除后增加,重要的事情说三遍!这个顺序不能反了,如果先增加新节点,然后再删除老节点,有可能导致cluster无法工作,并且永远也无法自动恢复,就必须人工干预了。


这里举一个例子来说明。假设cluster有三个节点,其中一个节点突然发生故障。这时cluster中还有两个节点健康,依然满足quorum,所以cluster还可以正常提供服务。这时如果先增加一个新节点到cluster中,因为那个down的节点还没有从cluster中移除,所以cluster的节点数量就变成了4。如果一切顺利,新节点正常启动,倒也还好。但是如果发生了意外(比如配置错误),导致新增加的节点启动失败,这时cluster的quorum已经从2变成了3,可是cluster中依然只有两个节点能工作,不符合quorum要求,从而导致cluster无法对外提供服务,而且永远无法自动恢复。这个时候,当你再想从cluster中删除任何节点,都不会成功了,因为删除节点也需要向cluster中发送一个写请求,这时cluster已经因为quorum不达标而无法提供服务了,所以删除节点的操作不会成功。这就尴尬了!


如何解决"cluster ID mismatch"

每个etcd cluster都有一个cluster ID,当一个节点的cluster ID与peers的cluster ID不同时,就会看到错误消息。就类似于你回家时走到邻居家去了。


那么每个节点的clusterID到底是怎么产生的呢?我简单画了一个图:


首先判断本地是否存在WAL file,如果有,就从本地 WAL file中读取cluster ID。如果本地没有WAL file, 则判断启动参数 "--initial-cluster-state"的值,如果是“existing",意思是要加入一个已经存在的cluster,所以需要从peers那里获取clusterID;如果是"new",意思是要创建一个新的cluster,所以就需要自己生成cluster ID了。


这里又产生一个新的问题,对于一个全新的cluster,每个节点都自己生成clusterID,如何保证它们生成的clusterID完全相同呢?答案也很简单,每个成员节点采用相同的算法即可,同时这个算法中不能使用随机数或者当前时间等变量。具体生成clusterID的代码如下:

    // https://github.com/etcd-io/etcd/blob/release-3.5/server/etcdserver/api/membership/cluster.go#L231-L239
    func (c *RaftCluster) genID() {
    mIDs := c.MemberIDs()
    b := make([]byte, 8*len(mIDs))
    for i, id := range mIDs {
    binary.BigEndian.PutUint64(b[8*i:], uint64(id))
    }
    hash := sha1.Sum(b)
    c.cid = types.ID(binary.BigEndian.Uint64(hash[:8]))
    }


    将每个成员节点的ID拼在一起,计算出一个sha1摘要,取前8个byte作为clusterID。那么每个成员的ID又是怎么生成的呢?对应的代码如下:

      // https://github.com/etcd-io/etcd/blob/release-3.5/server/etcdserver/api/membership/member.go#L64-L77
      func computeMemberId(peerURLs types.URLs, clusterName string, now *time.Time) types.ID {
      peerURLstrs := peerURLs.StringSlice()
      sort.Strings(peerURLstrs)
      joinedPeerUrls := strings.Join(peerURLstrs, "")
      b := []byte(joinedPeerUrls)


      b = append(b, []byte(clusterName)...)
      if now != nil {
      b = append(b, []byte(fmt.Sprintf("%d", now.Unix()))...)
      }


      hash := sha1.Sum(b)
      return types.ID(binary.BigEndian.Uint64(hash[:8]))
      }


      显然,每个成员节点的ID是由peerURLs和clusterName决定的。上面函数中最后一个参数now实际传入的是nil,所以可以忽略。peerURLs对应的就是启动参数"--initial-cluster"中的URL,而clusterName对应的就是启动参数"--initial-cluster-token"的值。举个例子,假设有三个成员节点的cluster,每个节点的启动命令分别如下:

        $ etcd --name infra1 --listen-client-urls http://127.0.0.1:2379 --advertise-client-urls http://127.0.0.1:2379 --listen-peer-urls http://127.0.0.1:12380 --initial-advertise-peer-urls http://127.0.0.1:12380 --initial-cluster-token etcd-cluster-1 --initial-cluster 'infra1=http://127.0.0.1:12380,infra2=http://127.0.0.1:22380' --initial-cluster-state new --enable-pprof --logger=zap --log-outputs=stderr
        $ etcd --name infra2 --listen-client-urls http://127.0.0.1:22379 --advertise-client-urls http://127.0.0.1:22379 --listen-peer-urls http://127.0.0.1:22380 --initial-advertise-peer-urls http://127.0.0.1:22380 --initial-cluster-token etcd-cluster-1 --initial-cluster 'infra1=http://127.0.0.1:12380,infra2=http://127.0.0.1:22380' --initial-cluster-state new --enable-pprof --logger=zap --log-outputs=stderr
        $ etcd --name infra3 --listen-client-urls http://127.0.0.1:32379 --advertise-client-urls http://127.0.0.1:32379 --listen-peer-urls http://127.0.0.1:32380 --initial-advertise-peer-urls http://127.0.0.1:32380 --initial-cluster-token etcd-cluster-1 --initial-cluster 'infra1=http://127.0.0.1:12380,infra2=http://127.0.0.1:22380' --initial-cluster-state new --enable-pprof --logger=zap --log-outputs=stderr

        那么第一个节点"infra1"的ID就是"http://127.0.0.1:12380"和"etcd-cluster-1"拼在一起后,计算出一个sha1摘要,最后取前8个字节。以此类推。


        回到本节最开始的问题,什么情况下会遇到"cluster ID mismatch"的错误呢?一种可能就是两个cluster中的成员存在交集。就是部分成员同时配置在了两个cluster中。当然,一般不会是故意的,都是peerURL不小心配置错误。遇到这种情况,将peerURL改过来即可。


        另外一种可能的情况就是在向一个cluster增加一个新成员节点时,给参数"--initial-cluster-state"传入了错误的值。这个参数有两个可能的值:new或者existing。这种情况下,这个参数应该传入什么值?答案是"existing"。因为之前在计算clusterID的时候,这个新节点的memberID并没有考虑在内。如果这个新节点自己产生clusterID,会多考虑一个memberID(也就是自己)在内,所以最后得出的clusterID自然会不同。所以对于新增加的节点,不能自己生成clusterID,而需要从peers那里获取。


        最后打一个补丁,对于上面说的“增加新成员节点”的情况,有人可能会发现,将"--initial-cluster-state"的值改成了"existing",还是会遇到“cluster ID mismatch”错误。这个问题请读者结合上面的图自行思考,欢迎私信或者留言。


        为什么有时无法增加新成员

        当一个etcd cluster的quorum没有达标时,是无法进行任何写操作的,自然也无法增加新成员节点。除了这种众所周知的情况之外,其实还有一种情况。


        当有可能导致cluster无法满足quorum时,etcd也会拒绝“增加新成员节点”的请求。注意,这里只是有可能,并不是真的会导致无法满足quorum。这就是启动参数“--strict-reconfig-check”的作用。默认值是true,也就是默认会开启这个检查功能。


        问题来了,什么情况下etcd会认为有可导致quorum不达标?主要有两种情况:

        1. 当前节点与所有peers是否处于连接状态,并且长达至少5秒?如果答案为否,那么拒绝“增加新成员节点”的请求;

        2. 假设新加入的成员不能正常启动,加入新成员后是否会导致quorum不达标?如果是,那么同样拒绝。


        第一种情况很好验证,随便杀掉一个节点(但是并不从cluster中移除),然后尝试加入一个新的节点,就会看到如下错误日志:

          rejecting member add request; local member has not been connected to all peers, reconfigure breaks active quorum


          第二种情况的验证方式,先启动一个包含两个成员的cluster,然后加入一个新的节点,注意这里只是执行"etcdctl member add ..."命令,但是并没有真正启动这个新节点。然后再尝试加入第四个新节点时候,就会遇到如下错误日志:

            rejecting member add request; not enough healthy members


            恢复(reset)cluster成员的杀手锏

            有时候会陷入一种极为尴尬的场景,就是cluster的所有成员一直处于leader election的过程中,但又由于quorum不达标而始终无法选出leader,从而导致cluster再也无法提供服务了。


            讨论一个在现实世界中压根就不可能发生的场景是没有意义的。所以,我们首先要明白什么情况下导致这种进退两难的境地。这个问题也比较直观,例如对于一个5节点的cluster,其中三个VM由于某种原因突然就挂了,对于剩下的两个节点,因为quorum不达标,所以就连"从cluster中移除这三个down的节点"这种操作都无法完成了,自然就导致了这种尴尬的境地了。


            对于这种进退两难的情况,就必须使出最后的杀手锏了。那就是"--force-new-cluster"。给其中一个etcd节点加上这个启动参数,强制其删除其它所有成员,启动一个单节点的cluster。然后再逐步将其它新节点加入该cluster;加入新节点之前,需要先清除新节点的本地数据。

            注意做好数据备份!


            这里还是要打个补丁,对于这种情况,其实还有另外一个办法。就是使用snapshot备份/恢复的功能。当quorum不达标时,虽然无法处理写请求,但是可以正常处理snapshot备份命令。具体来说,就是先执行备份命令:

              $ ETCDCTL_API=3 etcdctl --endpoints $ENDPOINT snapshot save my.db

              然后再逐个节点执行恢复命令:

                $ ETCDCTL_API=3 etcdctl snapshot restore my.db \
                --name m1 \
                --initial-cluster m1=http://host1:2380,m2=http://host2:2380,m3=http://host3:2380 \
                --initial-cluster-token etcd-cluster-1 \
                --initial-advertise-peer-urls http://host1:2380

                最后再逐个节点启动etcd。对于snapshot备份/恢复,具体参考如下链接:

                  https://etcd.io/docs/v3.5/op-guide/recovery/


                  总结

                  本文只是简要总结了关于管理etcd cluster member时的注意事项。其实管理etcd cluster有很多方面需要考虑,以后有机会再逐一总结分享!


                  --END--

                  文章转载自零君聊软件,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

                  评论