当前位置:网站首页>etcd备份恢复原理详解及踩坑实录
etcd备份恢复原理详解及踩坑实录
2022-06-24 06:56:00 【东东儿】
工作需要,这周研究了一下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得到全量的数据。
边栏推荐
- Introduction to software engineering - Chapter 3 - Requirements Analysis
- 5-if语句(选择结构)
- Online education fades
- Part 1: building OpenGL environment
- Leetcode 207: course schedule (topological sorting determines whether the loop is formed)
- The first exposure of Alibaba cloud's native security panorama behind the only highest level in the whole domain
- 从 jsonpath 和 xpath 到 SPL
- Atguigu---16-custom instruction
- Application of JDBC in performance test
- ImportError: cannot import name ‘process_ pdf‘ from ‘pdfminer. Pdfinterp 'error completely resolved
猜你喜欢

GraphMAE----論文快速閱讀

Screenshot recommendation - snipaste

SCM stm32f103rb, BLDC DC motor controller design, schematic diagram, source code and circuit scheme

从 jsonpath 和 xpath 到 SPL

解决笔记本键盘禁用失败问题
![3D数学基础[十七] 平方反比定理](/img/59/bef931d96883288766fc94e38e0ace.png)
3D数学基础[十七] 平方反比定理

2022 PMP project management examination agile knowledge points (1)

疫情下更合适的开发模式

The first exposure of Alibaba cloud's native security panorama behind the only highest level in the whole domain

Swift Extension ChainLayout(UI的链式布局)(源码)
随机推荐
Utilisation de la fermeture / bloc de base SWIFT (source)
有关iframe锚点,锚点出现上下偏移,锚点出现页面显示问题.iframe的srcdoc问题
Svn actual measurement common operation record operation
Swift 基础 闭包/Block的使用(源码)
VsCode主题推荐
你还只知道测试金字塔?
Search and recommend those things
研究生英语期末考试复习
"Adobe international certification" about Adobe Photoshop, creating and modifying brush tutorials?
C语言_字符串与指针的爱恨情仇
Standing at the center of the storm: how to change the engine of Tencent
AWTK 最新动态:Grid 控件新用法
QOpenGL显示点云文件
[测试开发]初识软件测试
软件工程导论——第二章——可行性研究
Pagoda panel installation php7.2 installation phalcon3.3.2
Model effect optimization, try a variety of cross validation methods (system operation)
redolog和binlog
Auto usage example
[008] filter the table data row by row, jump out of the for cycle and skip this cycle VBA