• etcd备份恢复原理详解及踩坑实录


    工作需要,这周研究了一下etcd备份恢复的方案。看起来还是挺简单的,但是在实际演练过程中,操作中的失误导致了etcd的数据全丢完了,幸好是在测试环境,在线上环境已经卷铺盖走人了。简单记录一下遇到的一些问题。

    1. 备份恢复流程

    备份需要使用etcdctl工具:

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

    恢复时使用etcdutl工具,旧版本的恢复功能也集成在etcdctl中,使用如下指令,就可以从snapshot.db文件上恢复起来一个新的etcd集群

    $ etcdutl snapshot restore snapshot.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
      --data-dir 
    $ etcdutl snapshot restore snapshot.db \
      --name m2 \
      --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://host2:2380
    $ etcdutl snapshot restore snapshot.db \
      --name m3 \
      --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://host3:2380
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • name是etcd节点的name,集群中name必须不同
    • initial-cluster是恢复集群的配置
    • initial-cluster-token 会影响计算cluster member id,不是必须的参数
    • initial-advertise-peer-urls节点本身的数据信息
    • data-dir 将备份信息恢复到指定路径

    2. 原理介绍

    2.1 备份原理

    etcd server 收到snapshot请求后,将调用backend存储引擎的snapshot接口,获得一份snapshot数据,然后将snapshot数据写入到pipe中,释放掉snapshot(防止长时间ping住snapshot导致过期page无法被释放),后面的发送逻辑会从pipe中读取数据发送回给客户端。

    func (ms *maintenanceServer) Snapshot(sr *pb.SnapshotRequest, srv pb.Maintenance_SnapshotServer) error {
    	snap := ms.bg.Backend().Snapshot()
    	pr, pw := io.Pipe()
    
    	defer pr.Close()
    
    	go func() {
    		snap.WriteTo(pw)
    		if err := snap.Close(); err != nil {
    			ms.lg.Warn("failed to close snapshot", zap.Error(err))
    		}
    		pw.Close()
    	}()
    	// 发送snapshot数据
    	...
    	...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    再来看看backend引擎的snapshot逻辑,先调用了一次事务提交,这个和etcd本身的事务逻辑有关,etcd的已提交事务并不是立即写入到持久化引擎boltdb中的,会先写到backend的缓存中,定期刷到boltdb中,此时要做snapshot,需要先将cache的事务提交到boltdb中,然后调用boltdb的事务接口,创建一个读事务,返回给上层,上层可以通过这个读事务获取到一个snapshot文件。boltdb的后续单独介绍。

    func (b *backend) Snapshot() Snapshot {
    	b.batchTx.Commit()
    
    	b.mu.RLock()
    	defer b.mu.RUnlock()
    	tx, err := b.db.Begin(false)
    	if err != nil {
    		b.lg.Fatal("failed to begin tx", zap.Error(err))
    	}
    
    	stopc, donec := make(chan struct{}), make(chan struct{})
    	dbBytes := tx.Size()
    	...
    	...
    	return &snapshot{tx, stopc, donec}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    2.2 恢复原理

    恢复的功能是有etcdutl工具独立完成的,核心函数为restore,除去大量的参数校验和准备工作外,最核心的就是剩下的这三个函数。

    // Restore restores a new etcd data directory from given snapshot file.
    func (s *v3Manager) Restore(cfg RestoreConfig) error {
    	...
    	...
    	// 清理备份文件中的raft元信息
    	if err = s.saveDB(); err != nil {
    		return err
    	}
    	// 将备份文件恢复为raft启动需要的wal和snapshot文件
    	hardstate, err := s.saveWALAndSnap()
    	if err != nil {
    		return err
    	}
    	// 更新index信息到boltdb中
    	if err := s.updateCIndex(hardstate.Commit, hardstate.Term); err != nil {
    		return err
    	}
    	...
    	...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    咱们一个一个函数看,在saveDB函数中会将备份数据拷贝到对应目录中,然后删除备份数据中的raft元信息。备份恢复的目的是在当前数据集上恢复一个新的raft集群起来,所以只需要备份数据中的用户数据,raft元数据相关的信息直接抹除即可。

    func (s *v3Manager) saveDB() error {
        // 将备份数据放到对应目录中
    	err := s.copyAndVerifyDB()
    	if err != nil {
    		return err
    	}
    
    	be := backend.NewDefaultBackend(s.lg, s.outDbPath())
    	defer be.Close()
    
        // 删除备份数据中的raft元信息
    	err = schema.NewMembershipBackend(s.lg, be).TrimMembershipFromBackend()
    	if err != nil {
    		return err
    	}
    
    	return nil
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    来看下一个函数,最终要的过程,如何从备份数据上恢复出来对应的wal文件和snapshot文件。简单来看,这个函数就干了几件事儿:

    1. 将新的raft信息写入到boltdb中
    2. 创建wal并写入node的meta数据,包括node id和cluster id(这个会在后续详细介绍)
    3. 为准备恢复出的集群中的每个节点配置创建一个raft 配置变更log
    4. 将日志信息和raft hard state信息写到wal中
    5. 为当前的状态机(恢复出的数据集)创建一份快照(思考:为什么需要为新集群创建快照呢?启动一个新的raft节点不可以吗?)
    func (s *v3Manager) saveWALAndSnap() (*raftpb.HardState, error) {
    	...
    	...
    	// 将raft信息写入到boltdb中
    	for _, m := range s.cl.Members() {
    		s.cl.AddMember(m, true)
    	}
    
        // 初始化集群的meta信息,nodeID和clusterID,创建wal文件并写入meta信息
    	m := s.cl.MemberByName(s.name)
    	md := &etcdserverpb.Metadata{NodeID: uint64(m.ID), ClusterID: uint64(s.cl.ID())}
    	metadata, merr := md.Marshal()
    	w, walerr := wal.Create(s.lg, s.walDir, metadata)
    	
    	// 为每个节点初始化配置变更日志
    	ents := make([]raftpb.Entry, len(peers))
    	nodeIDs := make([]uint64, len(peers))
    	for i, p := range peers {
    		nodeIDs[i] = p.ID
    		cc := raftpb.ConfChange{
    			Type:    raftpb.ConfChangeAddNode,
    			NodeID:  p.ID,
    			Context: p.Context,
    		}
    		d, err := cc.Marshal()
    		if err != nil {
    			return nil, err
    		}
    		ents[i] = raftpb.Entry{
    			Type:  raftpb.EntryConfChange,
    			Term:  1,
    			Index: uint64(i + 1),
    			Data:  d,
    		}
    	}
    
        // 初始化raft 的term和日志提交信息,并保存到hardState中
    	commit, term := uint64(len(ents)), uint64(1)
    	hardState := raftpb.HardState{
    		Term:   term,
    		Vote:   peers[0].ID,
    		Commit: commit,
    	}
    	// 将日志和hard state持久化到wal中
    	if err := w.Save(hardState, ents); err != nil {
    		return nil, err
    	}
    
        // 为当前的状态机(恢复出的数据)创建一份raft snapshot,并将对应的snapshot信息写入到wal日志中。
    	b, berr := st.Save()
    	if berr != nil {
    		return nil, berr
    	}
    	confState := raftpb.ConfState{
    		Voters: nodeIDs,
    	}
    	raftSnap := raftpb.Snapshot{
    		Data: b,
    		Metadata: raftpb.SnapshotMetadata{
    			Index:     commit,
    			Term:      term,
    			ConfState: confState,
    		},
    	}
    	sn := snap.New(s.lg, s.snapDir)
    	if err := sn.SaveSnap(raftSnap); err != nil {
    		return nil, err
    	}
    	snapshot := walpb.Snapshot{Index: commit, Term: term, ConfState: &confState}
    	return &hardState, w.SaveSnapshot(snapshot)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71

    2.3 cluster member id

    在上述备份恢复的过程中,中间有个步骤会为集群生成一个cluster id。在某些时候错误部署etcd集群后,经常也能看到一个报错信息,“remote cluster member id mismatch”。我们来具体看看这个cluster id到底是什么。根据官网的解释,cluster id是一个集群的标识符,每个集群都有一个cluster id,如果两个节点间的cluster id不一致,说明他们不是一个集群的。下面来看看这个cluster id是怎么生成的

    2.3.1 新集群

    新集群的cluster id生成非常简单,直接看代码,首先会根据用户的配置信息生成集群member信息,然后根据集群member信息生成一个hash值作为cluster id,所以多个机器上,以同一个集群配置启动多节点etcd,他们之间的cluster id是一样的,所以他们之间是可以通信并且组成raft集群的。
    参数中还有个token参数,在创建集群时由–initial-cluster-token参数指定,如不指定会使用默认值,这个参数相当于在hash计算cluster id时加盐,即最终:clusterID = hash(集群初始配置… , initial-cluster-token)
    这个逻辑和上述备份恢复时etcdutl工具的逻辑是一样的,恢复工具也会用参数中的集群配置生成cluster id。

    func NewClusterFromURLsMap(lg *zap.Logger, token string, urlsmap types.URLsMap, opts ...ClusterOption) (*RaftCluster, error) {
    	c := NewCluster(lg, opts...)
    	// 根据配置信息初始化集群信息
    	for name, urls := range urlsmap {
    		...
    		...
    		c.members[m.ID] = m
    	}
    	// 根据集群信息生成一个hash值作为member id
    	c.genID()
    	return c, nil
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    2.3.2 重启节点

    对于有数据的节点,重启后,不需要重新计算cluster id,直接从wal中读取即可,etcdutl工具对备份数据恢复的过程中也能看到将生成的cluster id写到了wal中。也就是说只有集群初始化才会生成cluster id,一旦生成,不再变更,即使集群节点有配置变更,也不会再影响cluster id。

    2.3.3 加入已有集群的节点

    启动一个节点加入到已有集群中,需要在启动时设着标记位 initial-cluster-state为existing,代码中会判断这个标记位,如果是新加入到集群中的节点会走到如下逻辑中,从远端节点中拉取集群信息,并将cluster id赋值给本地,当然中间有很多校验逻辑,比如比较远端cluster中的配置节点是否和本地的一致。

    func getClusterFromRemotePeers(lg *zap.Logger, urls []string, timeout time.Duration, logerr bool, rt http.RoundTripper) (*membership.RaftCluster, error) {
    	if lg == nil {
    		lg = zap.NewNop()
    	}
    	cc := &http.Client{
    		Transport: rt,
    		Timeout:   timeout,
    	}
    	// 尝试从一个节点获取cluster 配置信息
    	for _, u := range urls {
    		addr := u + "/members"
    		resp, err := cc.Get(addr)
    		...
    		...
    		//使用远端的cluster 配置初始化本地节点,中间有很多校验逻辑,如果成功,那么会将cluster id赋值给本地节点
    		return membership.NewClusterFromMembers(lg, id, membs), nil
    		...
    	}
    	return nil, fmt.Errorf("could not retrieve cluster information from the given URLs")
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    3. 踩坑实录,不当备份恢复操作导致etcd数据丢了

    我自己维护了一个3副本的etcd集群,有三个节点分别是e1,e2,e3,因为一些意外,其中e1和e3挂掉了,而且数据文件被破坏了,无法重启这两个进程。然后就开始了修复:

    1. 最开始并不了解etcd的member id机制,我想着直接删除e1节点上的数据,将e1当作一个空raft节点重启起来,这之后e1应该能够和e2组成raft两副本,然后恢复集群服务,但是尝试这样做之后,发现e1和e2之间通信都报错“cluster member id mismatch”,后来排查发现,我的集群经历过节点变更,最初的三个节点是e0,e1,e2,后来节点变更变成了,e1,e2,e3,也就是说这个集群的cluster id是hash(e0,e1,e2),而我删除e1数据后,以当前配置启动e1,e1会计算一个新的cluster id,即hash(e1,e2,e3),所以两边是不match的。
    2. 无能为力,之后采用备份恢复的方案,先从存活的e2节点上获取了一份snapshot文件,然后对e1进行了备份恢复操作,并将e1重启起来,发现e1和e2通信依然出现cluster id mismatch,原因同上,备份恢复工具使用当前配置计算出的cluster id与e2上的cluster id是不符合的。
    3. 无奈,将e2也挺掉,然后删除数据文件,走了一遍备份恢复流程后,重新将e2启动起来,这时候e1和e2都能正常通信,形成raft两副本。
    4. 上述一切都很正常,这时候我就放松警惕了,导致最后一步操作出错了,我将e3的数据文件清理之后,忘记使用备份恢复工具为其恢复数据,就将e3启动起来了,而且此时e3也能正常启动。原因是e3是作为空节点启动,cluster id是使用配置计算得到的,即hash(e1,e2,e3),这和备份恢复出的e1和e2是一致的,但是e3上数据是空的。
    5. 集群正常工作一段时间后,因为运维操作将 etcd leader切换到了e3,这时候发现etcd中数据全没了。原因就是e3数据是空的

    提问:这时候有出现了一个令人疑惑的问题,为什么e3加入了raft集群后,没有从e1或e2上同步数据?raft说好的保证数据强一致呢?
    回答:raft确实不背这个锅,因为e1和e2也是备份恢复出的节点,从上面恢复逻辑能看到,恢复出来的节点只有几条日志,此时e3启动,从leader节点同步日志,很快就同步完成了,并不能通过install snapshot的操作实现数据同步。除非等待e1和e2运行一段时间,让日志被compact掉,再启动e3,就会触发raft的install snapshot逻辑,最终让e3得到全量的数据。

  • 相关阅读:
    刷题笔记day10-栈和队列01
    Docker通信全视角:原理、实践与技术洞察
    【MySQL】SQL语句
    Grafana监控系统的构建与实践
    独孤九剑第四式-K近邻模型(KNN)
    typora|将绝对路径的图片,全部替换为相对路径
    IntelliJ IDEA 2023:创新不止步,开发更自由 mac/win版
    LoaderRunner压力测试
    数据仓库基础
    什么是重入锁?
  • 原文地址:https://blog.csdn.net/qq_35102066/article/details/125344806