工作需要,这周研究了一下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文件。简单来看,这个函数就干了几件事儿:
- 将新的raft信息写入到boltdb中
- 创建wal并写入node的meta数据,包括node id和cluster id(这个会在后续详细介绍)
- 为准备恢复出的集群中的每个节点配置创建一个raft 配置变更log
- 将日志信息和raft hard state信息写到wal中
- 为当前的状态机(恢复出的数据集)创建一份快照(思考:为什么需要为新集群创建快照呢?启动一个新的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挂掉了,而且数据文件被破坏了,无法重启这两个进程。然后就开始了修复:
- 最开始并不了解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的。
- 无能为力,之后采用备份恢复的方案,先从存活的e2节点上获取了一份snapshot文件,然后对e1进行了备份恢复操作,并将e1重启起来,发现e1和e2通信依然出现cluster id mismatch,原因同上,备份恢复工具使用当前配置计算出的cluster id与e2上的cluster id是不符合的。
- 无奈,将e2也挺掉,然后删除数据文件,走了一遍备份恢复流程后,重新将e2启动起来,这时候e1和e2都能正常通信,形成raft两副本。
- 上述一切都很正常,这时候我就放松警惕了,导致最后一步操作出错了,我将e3的数据文件清理之后,忘记使用备份恢复工具为其恢复数据,就将e3启动起来了,而且此时e3也能正常启动。原因是e3是作为空节点启动,cluster id是使用配置计算得到的,即hash(e1,e2,e3),这和备份恢复出的e1和e2是一致的,但是e3上数据是空的。
- 集群正常工作一段时间后,因为运维操作将 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得到全量的数据。