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

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

1. 备份恢复流程

备份需要使用etcdctl工具:

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

恢复时使用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
  • 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数据
	...
	...
}

再来看看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}
}
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
	}
	...
	...
}

咱们一个一个函数看,在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
}

来看下一个函数,最终要的过程,如何从备份数据上恢复出来对应的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)
}
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
}
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")
}

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得到全量的数据。

  • 3
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值