mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-17 22:53:42 +08:00
del
This commit is contained in:
296
极客时间专栏/geek/etcd实战课/实践篇/12 | 一致性:为什么基于Raft实现的etcd还会出现数据不一致?.md
Normal file
296
极客时间专栏/geek/etcd实战课/实践篇/12 | 一致性:为什么基于Raft实现的etcd还会出现数据不一致?.md
Normal file
@@ -0,0 +1,296 @@
|
||||
<audio id="audio" title="12 | 一致性:为什么基于Raft实现的etcd还会出现数据不一致?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a9/05/a979b8d1d943be1617e8da31f2c94d05.mp3"></audio>
|
||||
|
||||
你好,我是唐聪。
|
||||
|
||||
今天我要和你分享的主题是关于etcd数据一致性的。
|
||||
|
||||
我们都知道etcd是基于Raft实现的高可用、强一致分布式存储。但是有一天我和小伙伴王超凡却遭遇了一系列诡异的现象:用户在更新Kubernetes集群中的Deployment资源镜像后,无法创建出新Pod,Deployment控制器莫名其妙不工作了。更令人细思极恐的是,部分Node莫名其妙消失了。
|
||||
|
||||
我们当时随便找了一个etcd节点查看存储数据,发现Node节点却在。这究竟是怎么一回事呢? 今天我将和你分享这背后的故事,以及由它带给我们的教训和启发。希望通过这节课,能帮助你搞懂为什么基于Raft实现的etcd有可能出现数据不一致,以及我们应该如何提前规避、预防类似问题。
|
||||
|
||||
## 从消失的Node说起
|
||||
|
||||
故事要从去年1月的时候说起,某日晚上我们收到一个求助,有人反馈Kubernetes集群出现了Deployment滚动更新异常、节点莫名其妙消失了等诡异现象。我一听就感觉里面可能大有文章,于是开始定位之旅。
|
||||
|
||||
我首先查看了下Kubernetes集群APIServer、Controller Manager、Scheduler等组件状态,发现都是正常。
|
||||
|
||||
然后我查看了下etcd集群各节点状态,也都是健康的,看了一个etcd节点数据也是正常,于是我开始怀疑是不是APIServer出现了什么诡异的Bug了。
|
||||
|
||||
我尝试重启APIServer,可Node依旧消失。百思不得其解的同时,只能去确认各个etcd节点上数据是否存在,结果却有了颠覆你固定思维的发现,那就是基于Raft实现的强一致存储竟然出现不一致、数据丢失。除了第一个节点含有数据,另外两个节点竟然找不到。那么问题就来了,另外两个节点数据是如何丢失的呢?
|
||||
|
||||
## 一步步解密真相
|
||||
|
||||
在进一步深入分析前,我们结合基础篇[03](https://time.geekbang.org/column/article/336766)对etcd写流程原理的介绍(如下图),先大胆猜测下可能的原因。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8b/72/8b6dfa84bf8291369ea1803387906c72.png" alt="">
|
||||
|
||||
猜测1:etcd集群出现分裂,三个节点分裂成两个集群。APIServer配置的后端etcd server地址是三个节点,APIServer并不会检查各节点集群ID是否一致,因此如果分裂,有可能会出现数据“消失”现象。这种故障之前在Kubernetes社区的确也见到过相关issue,一般是变更异常导致的,显著特点是集群ID会不一致。
|
||||
|
||||
猜测2:Raft日志同步异常,其他两个节点会不会因为Raft模块存在特殊Bug导致未收取到相关日志条目呢?这种怀疑我们可以通过etcd自带的WAL工具来判断,它可以显示WAL日志中收到的命令(流程四、五、六)。
|
||||
|
||||
猜测3:如果日志同步没问题,那有没有可能是Apply模块出现了问题,导致日志条目未被应用到MVCC模块呢(流程七)?
|
||||
|
||||
猜测4:若Apply模块执行了相关日志条目到MVCC模块,MVCC模块的treeIndex子模块会不会出现了特殊Bug, 导致更新失败(流程八)?
|
||||
|
||||
猜测5:若MVCC模块的treeIndex模块无异常,写请求到了boltdb存储模块,有没有可能boltdb出现了极端异常导致丢数据呢(流程九)?
|
||||
|
||||
带着以上怀疑和推测,让我们不断抽丝剥茧、去一步步探寻真相。
|
||||
|
||||
首先还是从故障定位第一工具“日志”开始。我们查看etcd节点日志没发现任何异常日志,但是当查看APIServer日志的时候,发现持续报"required revision has been compacted",这个错误根据我们基础篇11节介绍,我们知道原因一般是APIServer请求etcd版本号被压缩了。
|
||||
|
||||
于是我们通过如下命令查看etcd节点详细的状态信息:
|
||||
|
||||
```
|
||||
etcdctl endpoint status --cluster -w json | python -m
|
||||
json.tool
|
||||
|
||||
```
|
||||
|
||||
获得以下结果:
|
||||
|
||||
```
|
||||
[
|
||||
{
|
||||
"Endpoint":"A",
|
||||
"Status":{
|
||||
"header":{
|
||||
"cluster_id":17237436991929493444,
|
||||
"member_id":9372538179322589801,
|
||||
"raft_term":10,
|
||||
"revision":1052950
|
||||
},
|
||||
"leader":9372538179322589801,
|
||||
"raftAppliedIndex":1098420,
|
||||
"raftIndex":1098430,
|
||||
"raftTerm":10,
|
||||
"version":"3.3.17"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Endpoint":"B",
|
||||
"Status":{
|
||||
"header":{
|
||||
"cluster_id":17237436991929493444,
|
||||
"member_id":10501334649042878790,
|
||||
"raft_term":10,
|
||||
"revision":1025860
|
||||
},
|
||||
"leader":9372538179322589801,
|
||||
"raftAppliedIndex":1098418,
|
||||
"raftIndex":1098428,
|
||||
"raftTerm":10,
|
||||
"version":"3.3.17"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Endpoint":"C",
|
||||
"Status":{
|
||||
"header":{
|
||||
"cluster_id":17237436991929493444,
|
||||
"member_id":18249187646912138824,
|
||||
"raft_term":10,
|
||||
"revision":1028860
|
||||
},
|
||||
"leader":9372538179322589801,
|
||||
"raftAppliedIndex":1098408,
|
||||
"raftIndex":1098428,
|
||||
"raftTerm":10,
|
||||
"version":"3.3.17"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
```
|
||||
|
||||
从结果看,我们获得了如下信息:
|
||||
|
||||
第一,集群未分裂,3个节点A、B、C cluster_id都一致,集群分裂的猜测被排除。
|
||||
|
||||
第二,初步判断集群Raft日志条目同步正常,raftIndex表示Raft日志索引号,raftAppliedIndex表示当前状态机应用的日志索引号。这两个核心字段显示三个节点相差很小,考虑到正在写入,未偏离正常范围,Raft同步Bug导致数据丢失也大概率可以排除(不过最好还是用WAL工具验证下现在日志条目同步和写入WAL是否正常)。
|
||||
|
||||
第三,观察三个节点的revision值,相互之间最大差距接近30000,明显偏离标准值。在[07](https://time.geekbang.org/column/article/340226)中我给你深入介绍了revision的含义,它是etcd逻辑时钟,每次写入,就会全局递增。为什么三个节点之间差异如此之大呢?
|
||||
|
||||
接下来我们就一步步验证猜测、解密真相,猜测1集群分裂说被排除后,猜测2Raft日志同步异常也初步被我们排除了,那如何真正确认Raft日志同步正常呢?
|
||||
|
||||
你可以使用下面这个方法验证Raft日志条目同步是否正常。
|
||||
|
||||
首先我们写入一个值,比如put hello为world,然后马上在各个节点上用WAL工具etcd-dump-logs搜索hello。如下所示,各个节点上都可找到我们刚刚写入的命令。
|
||||
|
||||
```
|
||||
$ etcdctl put hello world
|
||||
OK
|
||||
$ ./bin/tools/etcd-dump-logs ./Node1.etcd/ | grep hello
|
||||
10 70 norm header:<ID:3632562852862290438 > put:<key:"hello" value:"world" >
|
||||
$ ./bin/tools/etcd-dump-logs ./Node2.etcd/ | grep hello
|
||||
10 70 norm header:<ID:3632562852862290438 > put:<key:"hello" value:"world" >
|
||||
$ ./bin/tools/etcd-dump-logs ./Node3.etcd/ | grep hello
|
||||
10 70 norm header:<ID:3632562852862290438 > put:<key:"hello" value:"world" >
|
||||
|
||||
```
|
||||
|
||||
Raft日志同步异常猜测被排除后,我们再看下会不会是Apply模块出现了问题。但是raftAppliedIndex却显示三个节点几乎无差异,那我们能不能通过这个指标来判断Apply流程是否正常呢?
|
||||
|
||||
源码面前了无秘密,etcd更新raftAppliedIndex核心代码如下所示,你会发现这个指标其实并不靠谱。Apply流程出现逻辑错误时,并没重试机制。etcd无论Apply流程是成功还是失败,都会更新raftAppliedIndex值。也就是一个请求在Apply或MVCC模块即便执行失败了,都依然会更新raftAppliedIndex。
|
||||
|
||||
```
|
||||
// ApplyEntryNormal apples an EntryNormal type Raftpb request to the EtcdServer
|
||||
func (s *EtcdServer) ApplyEntryNormal(e *Raftpb.Entry) {
|
||||
shouldApplyV3 := false
|
||||
if e.Index > s.consistIndex.ConsistentIndex() {
|
||||
// set the consistent index of current executing entry
|
||||
s.consistIndex.setConsistentIndex(e.Index)
|
||||
shouldApplyV3 = true
|
||||
}
|
||||
defer s.setAppliedIndex(e.Index)
|
||||
....
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
而三个节点revision差异偏离标准值,恰好又说明异常etcd节点可能未成功应用日志条目到MVCC模块。我们也可以通过查看MVCC的相关metrics(比如etcd_mvcc_put_total),来排除请求是否到了MVCC模块,事实是丢数据节点的metrics指标值的确远远落后正常节点。
|
||||
|
||||
于是我们将真凶锁定在Apply流程上。我们对Apply流程在未向MVCC模块提交请求前可能提前返回的地方,都加了日志。
|
||||
|
||||
同时我们查看Apply流程还发现,Apply失败的时候并不会打印任何日志。这也解释了为什么出现了数据不一致严重错误,但三个etcd节点却并没有任何异常日志。为了方便定位问题,我们因此增加了Apply错误日志。
|
||||
|
||||
同时我们测试发现,写入是否成功还跟client连接的节点有关,连接不同节点会出现不同的写入结果。我们用debug版本替换后,马上就输出了一条错误日志auth: revision in header is old。
|
||||
|
||||
原来数据不一致是因为鉴权版本号不一致导致的,节点在Apply流程的时候,会判断Raft日志条目中的请求鉴权版本号是否小于当前鉴权版本号,如果小于就拒绝写入。
|
||||
|
||||
那为什么各个节点的鉴权版本号会出现不一致呢?那就需要从可能修改鉴权版本号的源头分析。我们发现只有鉴权相关接口才会修改它,同时各个节点鉴权版本号之间差异已经固定不再增加,要成功解决就得再次复现。
|
||||
|
||||
然后还了解到,当时etcd进程有过重启,我们怀疑会不会重启触发了什么Bug,手动尝试复现一直失败。然而我们并未放弃,随后我们基于混沌工程,不断模拟真实业务场景、访问鉴权接口、注入故障(停止etcd进程等),最终功夫不负有心人,实现复现成功。
|
||||
|
||||
真相终于浮出水面,原来当你无意间重启etcd的时候,如果最后一条命令是鉴权相关的,它并不会持久化consistent index(KV接口会持久化)。consistent index在[03](https://time.geekbang.org/column/article/336766)里我们详细介绍了,它具有幂等作用,可防止命令重复执行。consistent index的未持久化最终导致鉴权命令重复执行。
|
||||
|
||||
恰好鉴权模块的RoleGrantPermission接口未实现幂等,重复执行会修改鉴权版本号。一连串的Bug最终导致鉴权号出现不一致,随后又放大成MVCC模块的key-value数据不一致,导致严重的数据毁坏。
|
||||
|
||||
这个Bug影响etcd v3所有版本长达3年之久。查清楚问题后,我们也给社区提交了解决方案,合并到master后,同时cherry-pick到etcd 3.3和3.4稳定版本中。etcd v3.3.21和v3.4.8后的版本已经修复此Bug。
|
||||
|
||||
## 为什么会不一致
|
||||
|
||||
详细了解完这个案例的不一致后,我们再从本质上深入分析下为什么会出现不一致,以及还有哪些场景会导致类似问题呢?
|
||||
|
||||
首先我们知道,etcd各个节点数据一致性基于Raft算法的日志复制实现的,etcd是个基于复制状态机实现的分布式系统。下图是分布式复制状态机原理架构,核心由3个组件组成,一致性模块、日志、状态机,其工作流程如下:
|
||||
|
||||
- client发起一个写请求(set x = 3);
|
||||
- server向一致性模块(假设是Raft)提交请求,一致性模块生成一个写提案日志条目。若server是Leader,把日志条目广播给其他节点,并持久化日志条目到WAL中;
|
||||
- 当一半以上节点持久化日志条目后,Leader的一致性模块将此日志条目标记为已提交(committed),并通知其他节点提交;
|
||||
- server从一致性模块获取已经提交的日志条目,异步应用到状态机持久化存储中(boltdb等),然后返回给client。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5c/4f/5c7a3079032f90120a6b309ee401fc4f.png" alt="">
|
||||
|
||||
从图中我们可以了解到,在基于复制状态机实现的分布式存储系统中,Raft等一致性算法它只能确保各个节点的日志一致性,也就是图中的流程二。
|
||||
|
||||
而对于流程三来说,server从日志里面获取已提交的日志条目,将其应用到状态机的过程,跟Raft算法本身无关,属于server本身的数据存储逻辑。
|
||||
|
||||
**也就是说有可能存在server应用日志条目到状态机失败,进而导致各个节点出现数据不一致。但是这个不一致并非Raft模块导致的,它已超过Raft模块的功能界限。**
|
||||
|
||||
比如在上面Node莫名其妙消失的案例中,就是应用日志条目到状态机流程中,出现逻辑错误,导致key-value数据未能持久化存储到boltdb。
|
||||
|
||||
这种逻辑错误即便重试也无法解决,目前社区也没有彻底的根治方案,只能根据具体案例进行针对性的修复。同时我给社区增加了Apply日志条目失败的警告日志。
|
||||
|
||||
## 其他典型不一致Bug
|
||||
|
||||
还有哪些场景可能还会导致Apply流程失败呢?我再以一个之前升级etcd 3.2集群到3.3集群时,遇到的数据不一致的故障事件为例给你讲讲。
|
||||
|
||||
这个故障对外的表现也是令人摸不着头脑,有服务不调度的、有service下的endpoint不更新的。最终我经过一番排查发现,原来数据不一致是由于etcd 3.2和3.3版本Lease模块的Revoke Lease行为不一致造成。
|
||||
|
||||
etcd 3.2版本的RevokeLease接口不需要鉴权,而etcd 3.3 RevokeLease接口增加了鉴权,因此当你升级etcd集群的时候,如果etcd 3.3版本收到了来自3.2版本的RevokeLease接口,就会导致因为没权限出现Apply失败,进而导致数据不一致,引发各种诡异现象。
|
||||
|
||||
除了重启etcd、升级etcd可能会导致数据不一致,defrag操作也可能会导致不一致。
|
||||
|
||||
对一个defrag碎片整理来说,它是如何触发数据不一致的呢? 触发的条件是defrag未正常结束时会生成db.tmp临时文件。这个文件可能包含部分上一次defrag写入的部分key/value数据,。而etcd下次defrag时并不会清理它,复用后就可能会出现各种异常场景,如重启后key增多、删除的用户数据key再次出现、删除user/role再次出现等。
|
||||
|
||||
etcd 3.2.29、etcd 3.3.19、etcd 3.4.4后的版本都已经修复这个Bug。我建议你根据自己实际情况进行升级,否则踩坑后,数据不一致的修复工作是非常棘手的,风险度极高。
|
||||
|
||||
从以上三个案例里,我们可以看到,**算法一致性不代表一个庞大的分布式系统工程实现中一定能保障一致性,工程实现上充满着各种挑战,从不可靠的网络环境到时钟、再到人为错误、各模块间的复杂交互等,几乎没有一个存储系统能保证任意分支逻辑能被测试用例100%覆盖。**
|
||||
|
||||
复制状态机在给我们带来数据同步的便利基础上,也给我们上层逻辑开发提出了高要求。也就是说任何接口逻辑变更etcd需要保证兼容性,否则就很容易出现Apply流程失败,导致数据不一致。
|
||||
|
||||
同时除了Apply流程可能导致数据不一致外,我们从defrag案例中也看到了一些维护变更操作,直接针对底层存储模块boltdb的,也可能会触发Bug,导致数据不一致。
|
||||
|
||||
## 最佳实践
|
||||
|
||||
在了解了etcd数据不一致的风险和原因后,我们在实践中有哪些方法可以提前发现和规避不一致问题呢?
|
||||
|
||||
下面我为你总结了几个最佳实践,它们分别是:
|
||||
|
||||
- 开启etcd的数据毁坏检测功能;
|
||||
- 应用层的数据一致性检测;
|
||||
- 定时数据备份;
|
||||
- 良好的运维规范(比如使用较新稳定版本、确保版本一致性、灰度变更)。
|
||||
|
||||
### 开启etcd的数据毁坏检测功能
|
||||
|
||||
首先和你介绍下etcd的数据毁坏检测功能。etcd不仅支持在启动的时候,通过--experimental-initial-corrupt-check参数检查各个节点数据是否一致,也支持在运行过程通过指定--experimental-corrupt-check-time参数每隔一定时间检查数据一致性。
|
||||
|
||||
那么它的一致性检测原理是怎样的?如果出现不一致性,etcd会采取什么样动作去降低数据不一致影响面呢?
|
||||
|
||||
其实我们无非就是想确定boltdb文件里面的内容跟其他节点内容是否一致。因此我们可以枚举所有key value,然后比较即可。
|
||||
|
||||
etcd的实现也就是通过遍历treeIndex模块中的所有key获取到版本号,然后再根据版本号从boltdb里面获取key的value,使用crc32 hash算法,将bucket name、key、value组合起来计算它的hash值。
|
||||
|
||||
如果你开启了--experimental-initial-corrupt-check,启动的时候每个节点都会去获取peer节点的boltdb hash值,然后相互对比,如果不相等就会无法启动。
|
||||
|
||||
而定时检测是指Leader节点获取它当前最新的版本号,并通过Raft模块的ReadIndex机制确认Leader身份。当确认完成后,获取各个节点的revision和boltdb hash值,若出现Follower节点的revision大于Leader等异常情况时,就可以认为不一致,发送corrupt告警,触发集群corruption保护,拒绝读写。
|
||||
|
||||
从etcd上面的一致性检测方案我们可以了解到,目前采用的方案是比较简单、暴力的。因此可能随着数据规模增大,出现检测耗时增大等扩展性问题。而DynamoDB等使用了merkle tree来实现增量hash检测,这也是etcd未来可能优化的一个方向。
|
||||
|
||||
最后你需要特别注意的是,etcd数据毁坏检测的功能目前还是一个试验(experimental)特性,在比较新的版本才趋于稳定、成熟(推荐v3.4.9以上),预计在未来的etcd 3.5版本中才会变成稳定特性,因此etcd 3.2/3.3系列版本就不能使用此方案。
|
||||
|
||||
### 应用层的数据一致性检测
|
||||
|
||||
那要如何给etcd 3.2/3.3版本增加一致性检测呢? 其实除了etcd自带数据毁坏检测,我们还可以通过在应用层通过一系列方法来检测数据一致性,它们适用于etcd所有版本。
|
||||
|
||||
接下来我给你讲讲应用层检测的原理。
|
||||
|
||||
从上面我们对数据不一致性案例的分析中,我们知道数据不一致在MVCC、boltdb会出现很多种情况,比如说key数量不一致、etcd逻辑时钟版本号不一致、MVCC模块收到的put操作metrics指标值不一致等等。因此我们的应用层检测方法就是基于它们的差异进行巡检。
|
||||
|
||||
首先针对key数量不一致的情况,我们可以实现巡检功能,定时去统计各个节点的key数,这样可以快速地发现数据不一致,从而及时介入,控制数据不一致影响,降低风险。
|
||||
|
||||
在你统计节点key数时,记得查询的时候带上WithCountOnly参数。etcd从treeIndex模块获取到key数后就及时返回了,无需访问boltdb模块。如果你的数据量非常大(涉及到百万级别),那即便是从treeIndex模块返回也会有一定的内存开销,因为它会把key追加到一个数组里面返回。
|
||||
|
||||
而在WithCountOnly场景中,我们只需要统计key数即可。因此我给社区提了优化方案,目前已经合并到master分支。对百万级别的key来说,WithCountOnly时内存开销从数G到几乎零开销,性能也提升数十倍。
|
||||
|
||||
其次我们可以基于endpoint各个节点的revision信息做一致性监控。一般情况下,各个节点的差异是极小的。
|
||||
|
||||
最后我们还可以基于etcd MVCC的metrics指标来监控。比如上面提到的mvcc_put_total,理论上每个节点这些MVCC指标是一致的,不会出现偏离太多。
|
||||
|
||||
### 定时数据备份
|
||||
|
||||
etcd数据不一致的修复工作极其棘手。发生数据不一致后,各个节点可能都包含部分最新数据和脏数据。如果最终我们无法修复,那就只能使用备份数据来恢复了。
|
||||
|
||||
因此备份特别重要,备份可以保障我们在极端场景下,能有保底的机制去恢复业务。**请记住,在做任何重要变更前一定先备份数据,以及在生产环境中建议增加定期的数据备份机制(比如每隔30分钟备份一次数据)。**
|
||||
|
||||
你可以使用开源的etcd-operator中的backup-operator去实现定时数据备份,它可以将etcd快照保存在各个公有云的对象存储服务里面。
|
||||
|
||||
### 良好的运维规范
|
||||
|
||||
最后我给你介绍几个运维规范,这些规范可以帮助我们尽量少踩坑(即便你踩坑后也可以控制故障影响面)。
|
||||
|
||||
首先是确保集群中各节点etcd版本一致。若各个节点的版本不一致,因各版本逻辑存在差异性,这就会增大触发不一致Bug的概率。比如我们前面提到的升级版本触发的不一致Bug就属于此类问题。
|
||||
|
||||
其次是优先使用较新稳定版本的etcd。像上面我们提到的3个不一致Bug,在最新的etcd版本中都得到了修复。你可以根据自己情况进行升级,以避免下次踩坑。同时你可根据实际业务场景以及安全风险,来评估是否有必要开启鉴权,开启鉴权后涉及的逻辑更复杂,有可能增大触发数据不一致Bug的概率。
|
||||
|
||||
最后是你在升级etcd版本的时候,需要多查看change log,评估是否存在可能有不兼容的特性。在你升级集群的时候注意先在测试环境多验证,生产环境务必先灰度、再全量。
|
||||
|
||||
## 小结
|
||||
|
||||
最后,我来总结下我们今天的内容。
|
||||
|
||||
我从消失的Node案例为例,介绍了etcd中定位一个复杂不一致问题的思路和方法工具。核心就是根据我们对etcd读写原理的了解,对每个模块可能出现的问题进行大胆猜想。
|
||||
|
||||
同时我们要善于借助日志、metrics、etcd tool等进行验证排除。定位到最终模块问题后,如果很难复现,我们可以借助混沌工程等技术注入模拟各类故障。**遇到复杂Bug时,请永远不要轻言放弃,它一定是一个让你快速成长的机会。**
|
||||
|
||||
其次我介绍了etcd数据不一致的核心原因:Raft算法只能保证各个节点日志同步的一致性,但Apply流程是异步的,它从一致性模块获取日志命令,应用到状态机的准确性取决于业务逻辑,这块是没有机制保证的。
|
||||
|
||||
同时,defrag等运维管理操作,会直接修改底层存储数据,异常场景处理不严谨也会导致数据不一致。
|
||||
|
||||
数据不一致的风险是非常大的,轻则业务逻辑异常,重则核心数据丢失。我们需要机制去提前发现和规避它,因此最后我详细给你总结了etcd本身和应用层的一致性监控、定时备份数据、良好的运维规范等若干最佳实践,这些都是宝贵的实践总结,希望你能有所收获。
|
||||
|
||||
## 思考题
|
||||
|
||||
掌握好最佳实践、多了解几个已知Bug,能让你少交很多昂贵的学费,针对数据不一致问题,你是否还有更好的建议呢? 同时,你在使用etcd过程中是否还有其他令你记忆深刻的问题和Bug呢?欢迎你在留言区与我讨论。
|
||||
|
||||
感谢你的阅读,如果你认为这节课的内容有收获,也欢迎把它分享给你的朋友,我们下一讲见。
|
||||
233
极客时间专栏/geek/etcd实战课/实践篇/13 | db大小:为什么etcd社区建议db大小不超过8G?.md
Normal file
233
极客时间专栏/geek/etcd实战课/实践篇/13 | db大小:为什么etcd社区建议db大小不超过8G?.md
Normal file
@@ -0,0 +1,233 @@
|
||||
<audio id="audio" title="13 | db大小:为什么etcd社区建议db大小不超过8G?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/yy/c7/yyf0d0c184b613121b41e199fa798ac7.mp3"></audio>
|
||||
|
||||
你好,我是唐聪。
|
||||
|
||||
在[03](https://time.geekbang.org/column/article/336766)写流程中我和你分享了etcd Quota模块,那么etcd为什么需要对db增加Quota限制,以及不建议你的etcd集群db大小超过8G呢? 过大的db文件对集群性能和稳定性有哪些影响?
|
||||
|
||||
今天我要和你分享的主题就是关于db大小。我将通过一个大数据量的etcd集群为案例,为你剖析etcd db大小配额限制背后的设计思考和过大的db潜在隐患。
|
||||
|
||||
希望通过这节课,帮助你理解大数据量对集群的各个模块的影响,配置合理的db Quota值。同时,帮助你在实际业务场景中,遵循最佳实践,尽量减少value大小和大key-value更新频率,避免db文件大小不断增长。
|
||||
|
||||
## 分析整体思路
|
||||
|
||||
为了帮助你直观地理解大数据量对集群稳定性的影响,我首先将为你写入大量数据,构造一个db大小为14G的大集群。然后通过此集群为你分析db大小的各个影响面,db大小影响面如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ab/11/ab657951310461c835963c38e43fdc11.png" alt="">
|
||||
|
||||
首先是**启动耗时**。etcd启动的时候,需打开boltdb db文件,读取db文件所有key-value数据,用于重建内存treeIndex模块。因此在大量key导致db文件过大的场景中,这会导致etcd启动较慢。
|
||||
|
||||
其次是**节点内存配置**。etcd在启动的时候会通过mmap将db文件映射内存中,若节点可用内存不足,小于db文件大小时,可能会出现缺页文件中断,导致服务稳定性、性能下降。
|
||||
|
||||
接着是**treeIndex**索引性能。因etcd不支持数据分片,内存中的treeIndex若保存了几十万到上千万的key,这会增加查询、修改操作的整体延时。
|
||||
|
||||
然后是**boltdb性能**。大db文件场景会导致事务提交耗时增长、抖动。
|
||||
|
||||
再次是**集群稳定性**。大db文件场景下,无论你是百万级别小key还是上千个大value场景,一旦出现expensive request后,很容易导致etcd OOM、节点带宽满而丢包。
|
||||
|
||||
最后是**快照。**当Follower节点落后Leader较多数据的时候,会触发Leader生成快照重建发送给Follower节点,Follower基于它进行还原重建操作。较大的db文件会导致Leader发送快照需要消耗较多的CPU、网络带宽资源,同时Follower节点重建还原慢。
|
||||
|
||||
## 构造大集群
|
||||
|
||||
简单介绍完db大小的六个影响面后,我们下面来构造一个大数据量的集群,用于后续各个影响面的分析。
|
||||
|
||||
首先,我通过一系列如下[benchmark](https://github.com/etcd-io/etcd/tree/v3.4.9/tools/benchmark)命令,向一个8核32G的3节点的集群写入120万左右key。key大小为32,value大小为256到10K,用以分析大db集群案例中的各个影响面。
|
||||
|
||||
```
|
||||
./benchmark put --key-size 32 --val-size 10240 --total
|
||||
1000000 --key-space-size 2000000 --clients 50 --conns 50
|
||||
|
||||
```
|
||||
|
||||
执行完一系列benchmark命令后,db size达到14G,总key数达到120万,其监控如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/67/60/67aa0c0fe078byy681fe4c55a3983f60.png" alt="">
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/33/88/331ac3c759578b297546f1651385be88.png" alt="">
|
||||
|
||||
## 启动耗时
|
||||
|
||||
在如上的集群中,我通过benchmark工具将etcd集群db大小压测到14G后,在重新启动etcd进程的时候,如下日志所示,你会发现启动比较慢,为什么大db文件会影响etcd启动耗时呢?
|
||||
|
||||
```
|
||||
2021-02-15 02:25:55.273712 I | etcdmain: etcd Version: 3.4.9
|
||||
2021-02-15 02:26:58.806882 I | etcdserver: recovered store from snapshot at index 2100090
|
||||
2021-02-15 02:26:58.808810 I | mvcc: restore compact to 1000002
|
||||
2021-02-15 02:27:19.120141 W | etcdserver: backend quota 26442450944 exceeds maximum recommended quota 8589934592
|
||||
2021-02-15 02:27:19.297363 I | embed: ready to serve client requests
|
||||
|
||||
```
|
||||
|
||||
通过对etcd启动流程增加耗时统计,我们可以发现核心瓶颈主要在于打开db文件和重建内存treeIndex模块。
|
||||
|
||||
这里我重点先和你介绍下etcd启动后,重建内存treeIndex的原理。
|
||||
|
||||
我们知道treeIndex模块维护了用户key与boltdb key的映射关系,boltdb的key、value又包含了构建treeIndex的所需的数据。因此etcd启动的时候,会启动不同角色的goroutine并发完成treeIndex构建。
|
||||
|
||||
**首先是主goroutine。**它的职责是遍历boltdb,获取所有key-value数据,并将其反序列化成etcd的mvccpb.KeyValue结构。核心原理是基于etcd存储在boltdb中的key数据有序性,按版本号从1开始批量遍历,每次查询10000条key-value记录,直到查询数据为空。
|
||||
|
||||
**其次是构建treeIndex索引的goroutine。**它从主goroutine获取mvccpb.KeyValue数据,基于key、版本号、是否带删除标识等信息,构建keyIndex对象,插入到treeIndex模块的B-tree中。
|
||||
|
||||
因可能存在多个goroutine并发操作treeIndex,treeIndex的Insert函数会加全局锁,如下所示。etcd启动时只有一个**构建treeIndex索引的goroutine**,因此key多时,会比较慢。之前我尝试优化成多goroutine并发构建,但是效果不佳,大量耗时会消耗在此锁上。
|
||||
|
||||
```
|
||||
func (ti *treeIndex) Insert(ki *keyIndex) {
|
||||
ti.Lock()
|
||||
defer ti.Unlock()
|
||||
ti.tree.ReplaceOrInsert(ki)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 节点内存配置
|
||||
|
||||
etcd进程重启完成后,在没任何读写QPS情况下,如下所示,你会发现etcd所消耗的内存比db大小还大一点。这又是为什么呢?如果etcd db文件大小超过节点内存规格,会导致什么问题吗?
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/02/a1/027ef8e1759a2800f1a2c1c105d7d7a1.png" alt="">
|
||||
|
||||
在[10](https://time.geekbang.org/column/article/342527)介绍boltdb存储原理的时候,我和你分享过boltdb文件的磁盘布局结构和其对外提供的API原理。
|
||||
|
||||
etcd在启动的时候,会通过boltdb的Open API获取数据库对象,而Open API它会通过mmap机制将db文件映射到内存中。
|
||||
|
||||
由于etcd调用boltdb Open API的时候,设置了mmap的MAP_POPULATE flag,它会告诉Linux内核预读文件,将db文件内容全部从磁盘加载到物理内存中。
|
||||
|
||||
因此在你节点内存充足的情况下,启动后你看到的etcd占用内存,一般是db文件大小与内存treeIndex之和。
|
||||
|
||||
在节点内存充足的情况下,启动后,client后续发起对etcd的读操作,可直接通过内存获取boltdb的key-value数据,不会产生任何磁盘IO,具备良好的读性能、稳定性。
|
||||
|
||||
而当你的db文件大小超过节点内存配置时,若你查询的key所相关的branch page、leaf page不在内存中,那就会触发主缺页中断,导致读延时抖动、QPS下降。
|
||||
|
||||
因此为了保证etcd集群性能的稳定性,我建议你的etcd节点内存规格要大于你的etcd db文件大小。
|
||||
|
||||
## treeIndex
|
||||
|
||||
当我们往集群中写入了一百多万key时,此时你再读取一个key范围操作的延时会出现一定程度上升,这是为什么呢?我们该如何分析耗时是在哪一步导致的?
|
||||
|
||||
在etcd 3.4中提供了trace特性,它可帮助我们定位、分析请求耗时过长问题。不过你需要特别注意的是,此特性在etcd 3.4中,因为依赖zap logger,默认为关闭。你可以通过设置etcd启动参数中的--logger=zap来开启。
|
||||
|
||||
开启之后,我们可以在etcd日志中找到类似如下的耗时记录。
|
||||
|
||||
```
|
||||
{
|
||||
"msg":"trace[331581563] range",
|
||||
"detail":"{range_begin:/vip/a; range_end:/vip/b; response_count:19304; response_revision:1005564; }",
|
||||
"duration":"146.432768ms",
|
||||
"steps":[
|
||||
"trace[331581563] 'range keys from in-memory treeIndex' (duration: 95.925033ms)",
|
||||
"trace[331581563] 'range keys from bolt db' (duration: 47.932118ms)"
|
||||
]
|
||||
|
||||
```
|
||||
|
||||
此日志记录了查询请求"etcdctl get --prefix /vip/a"。它在treeIndex中查询相关key耗时95ms,从boltdb遍历key时47ms。主要原因还是此查询涉及的key数较多,高达一万九。
|
||||
|
||||
也就是说若treeIndex中存储了百万级的key时,它可能也会产生几十毫秒到数百毫秒的延时,对于期望业务延时稳定在较小阈值内的业务,就无法满足其诉求。
|
||||
|
||||
## boltdb性能
|
||||
|
||||
当db文件大小持续增长到16G乃至更大后,从etcd事务提交监控metrics你可能会观察到,boltdb在提交事务时偶尔出现了较高延时,那么延时是怎么产生的呢?
|
||||
|
||||
在[10](https://time.geekbang.org/column/article/342527)介绍boltdb的原理时,我和你分享了db文件的磁盘布局,它是由meta page、branch page、leaf page、free list、free页组成的。同时我给你介绍了boltdb事务提交的四个核心流程,分别是B+ tree的重平衡、分裂,持久化dirty page,持久化freelist以及持久化meta data。
|
||||
|
||||
事务提交延时抖动的原因主要是在B+ tree树的重平衡和分裂过程中,它需要从freelist中申请若干连续的page存储数据,或释放空闲的page到freelist。
|
||||
|
||||
freelist后端实现在boltdb中是array。当申请一个连续的n个page存储数据时,它会遍历boltdb中所有的空闲页,直到找到连续的n个page。因此它的时间复杂度是O(N)。若db文件较大,又存在大量的碎片空闲页,很可能导致超时。
|
||||
|
||||
同时事务提交过程中,也可能会释放若干个page给freelist,因此需要合并到freelist的数组中,此操作时间复杂度是O(NLog N)。
|
||||
|
||||
假设我们db大小16G,page size 4KB,则有400万个page。经过各种修改、压缩后,若存在一半零散分布的碎片空闲页,在最坏的场景下,etcd每次事务提交需要遍历200万个page才能找到连续的n个page,同时还需要持久化freelist到磁盘。
|
||||
|
||||
为了优化boltdb事务提交的性能,etcd社区在bbolt项目中,实现了基于hashmap来管理freelist。通过引入了如下的三个map数据结构(freemaps的key是连续的页数,value是以空闲页的起始页pgid集合,forwardmap和backmap用于释放的时候快速合并页),将申请和释放时间复杂度降低到了O(1)。
|
||||
|
||||
freelist后端实现可以通过bbolt的FreeListType参数来控制,支持array和hashmap。在etcd 3.4版本中目前还是array,未来的3.5版本将默认是hashmap。
|
||||
|
||||
```
|
||||
freemaps map[uint64]pidSet // key is the size of continuous pages(span),value is a set which contains the starting pgids of same size
|
||||
forwardMap map[pgid]uint64 // key is start pgid,value is its span size
|
||||
backwardMap map[pgid]uint64 // key is end pgid,value is its span size
|
||||
|
||||
```
|
||||
|
||||
另外在db中若存在大量空闲页,持久化freelist需要消耗较多的db大小,并会导致额外的事务提交延时。
|
||||
|
||||
若未持久化freelist,bbolt支持通过重启时扫描全部page来构造freelist,降低了db大小和提升写事务提交的性能(但是它会带来etcd启动延时的上升)。此行为可以通过bbolt的NoFreelistSync参数来控制,默认是true启用此特性。
|
||||
|
||||
## 集群稳定性
|
||||
|
||||
db文件增大后,另外一个非常大的隐患是用户client发起的expensive request,容易导致集群出现各种稳定性问题。
|
||||
|
||||
本质原因是etcd不支持数据分片,各个节点保存了所有key-value数据,同时它们又存储在boltdb的一个bucket里面。当你的集群含有百万级以上key的时候,任意一种expensive read请求都可能导致etcd出现OOM、丢包等情况发生。
|
||||
|
||||
那么有哪些expensive read请求会导致etcd不稳定性呢?
|
||||
|
||||
**首先是简单的count only查询。**如下图所示,当你想通过API统计一个集群有多少key时,如果你的key较多,则有可能导致内存突增和较大的延时。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/44/a1/44ee247e9a31a455aca28459e5bb45a1.png" alt="">
|
||||
|
||||
在etcd 3.5版本之前,统计key数会遍历treeIndex,把key追加到数组中。然而当数据规模较大时,追加key到数组中的操作会消耗大量内存,同时数组扩容时涉及到大量数据拷贝,会导致延时上升。
|
||||
|
||||
**其次是limit查询。**当你只想查询若干条数据的时候,若你的key较多,也会导致类似count only查询的性能、稳定性问题。
|
||||
|
||||
原因是etcd 3.5版本之前遍历index B-tree时,并未将limit参数下推到索引层,导致了无用的资源和时间消耗。优化方案也很简单,etcd 3.5中我提的优化PR将limit参数下推到了索引层,实现查询性能百倍提升。
|
||||
|
||||
**最后是大包查询。**当你未分页批量遍历key-value数据或单key-value数据较大的时候,随着请求QPS增大,etcd OOM、节点出现带宽瓶颈导致丢包的风险会越来越大。
|
||||
|
||||
问题主要由以下两点原因导致:
|
||||
|
||||
第一,etcd需要遍历treeIndex获取key列表。若你未分页,一次查询万级key,显然会消耗大量内存并且高延时。
|
||||
|
||||
第二,获取到key列表、版本号后,etcd需要遍历boltdb,将key-value保存到查询结果数据结构中。如下trace日志所示,一个请求可能在遍历boltdb时花费很长时间,同时可能会消耗几百M甚至数G的内存。随着请求QPS增大,极易出现OOM、丢包等。etcd这块未来的优化点是实现流式传输。
|
||||
|
||||
```
|
||||
{
|
||||
"level":"info",
|
||||
"ts":"2021-02-15T03:44:52.209Z",
|
||||
"caller":"traceutil/trace.go:145",
|
||||
"msg":"trace[1908866301] range",
|
||||
"detail":"{range_begin:; range_end:; response_count:1232274; response_revision:3128500; }",
|
||||
"duration":"9.063748801s",
|
||||
"start":"2021-02-15T03:44:43.145Z",
|
||||
"end":"2021-02-15T03:44:52.209Z",
|
||||
"steps":[
|
||||
"trace[1908866301] 'range keys from in-memory index tree' (duration: 693.262565ms)",
|
||||
"trace[1908866301] 'range keys from bolt db' (duration: 8.22558566s)",
|
||||
"trace[1908866301] 'assemble the response' (duration: 18.810315ms)"
|
||||
]
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 快照
|
||||
|
||||
大db文件最后一个影响面是快照。它会影响db备份文件生成速度、Leader发送快照给Follower节点的资源开销、Follower节点通过快照重建恢复的速度。
|
||||
|
||||
我们知道etcd提供了快照功能,帮助我们通过API即可备份etcd数据。当etcd收到snapshot请求的时候,它会通过boltdb接口创建一个只读事务Tx,随后通过事务的WriteTo接口,将meta page和data page拷贝到buffer即可。
|
||||
|
||||
但是随着db文件增大,快照事务执行的时间也会越来越长,而长事务则会导致db文件大小发生显著增加。
|
||||
|
||||
也就是说当db大时,生成快照不仅慢,生成快照时可能还会触发db文件大小持续增长,最终达到配额限制。
|
||||
|
||||
为什么长事务可能会导致db大小增长呢? 这个问题我先将它作为思考题,你可以分享一下你的想法,后续我将为你详细解答。
|
||||
|
||||
快照的另一大作用是当Follower节点异常的时候,Leader生成快照发送给Follower节点,Follower使用快照重建并追赶上Leader。此过程涉及到一定的CPU、内存、网络带宽等资源开销。
|
||||
|
||||
同时,若快照和集群写QPS较大,Leader发送快照给Follower和Follower应用快照到状态机的流程会耗费较长的时间,这可能会导致基于快照重建后的Follower依然无法通过正常的日志复制模式来追赶Leader,只能继续触发Leader生成快照,进而进入死循环,Follower一直处于异常中。
|
||||
|
||||
## 小结
|
||||
|
||||
最后我们来小结下今天的内容。大db文件首先会影响etcd启动耗时,因为etcd需要打开db文件,初始化db对象,并遍历boltdb中的所有key-value以重建内存treeIndex。
|
||||
|
||||
其次,较大db文件会导致etcd依赖更高配置的节点内存规格,etcd通过mmap将db文件映射到内存中。etcd启动后,正常情况下读etcd过程不涉及磁盘IO,若节点内存不够,可能会导致缺页中断,引起延时抖动、服务性能下降。
|
||||
|
||||
接着treeIndex维护了所有key的版本号信息,当treeIndex中含有百万级key时,在treeIndex中搜索指定范围的key的开销是不能忽略的,此开销可能高达上百毫秒。
|
||||
|
||||
然后当db文件过大后,boltdb本身连续空闲页的申请、释放、存储都会存在一定的开销。etcd社区已通过新的freelist管理数据结构hashmap对其进行优化,将时间复杂度降低到了O(1),同时支持事务提交时不持久化freelist,而是通过重启时扫描page重建,以提升etcd写性能、降低db大小。
|
||||
|
||||
随后我给你介绍了db文件过大后,count only、limit、大包查询等expensive request对集群稳定性的影响。建议你的业务尽量避免任何expensive request请求。
|
||||
|
||||
最后我们介绍了大db文件对快照功能的影响。大db文件意味着更长的备份时间,而更长的只读事务则可能会导致db文件增长。同时Leader发送快照与Follower基于快照重建都需要较长时间,在集群写请求较大的情况下,可能会陷入死循环,导致落后的Follower节点一直无法追赶上Leader。
|
||||
|
||||
## 思考题
|
||||
|
||||
在使用etcd过程中,你遇到了哪些案例导致了etcd db大小突增呢? 它们的本质原因是什么呢?
|
||||
|
||||
感谢你的阅读,如果你认为这节课的内容有收获,也欢迎把它分享给你的朋友,谢谢。
|
||||
345
极客时间专栏/geek/etcd实战课/实践篇/14 | 延时:为什么你的etcd请求会出现超时?.md
Normal file
345
极客时间专栏/geek/etcd实战课/实践篇/14 | 延时:为什么你的etcd请求会出现超时?.md
Normal file
@@ -0,0 +1,345 @@
|
||||
<audio id="audio" title="14 | 延时:为什么你的etcd请求会出现超时?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/21/f0/21d36461b07a5b02ec87e8b1ae7191f0.mp3"></audio>
|
||||
|
||||
你好,我是唐聪。
|
||||
|
||||
在使用etcd的过程中,你是否被日志中的"apply request took too long"和“etcdserver: request timed out"等高延时现象困扰过?它们是由什么原因导致的呢?我们应该如何来分析这些问题?
|
||||
|
||||
这就是我今天要和你分享的主题:etcd延时。希望通过这节课,帮助你掌握etcd延时抖动、超时背后的常见原因和分析方法,当你遇到类似问题时,能独立定位、解决。同时,帮助你在实际业务场景中,合理配置集群,遵循最佳实践,尽量减少expensive request,避免etcd请求出现超时。
|
||||
|
||||
## 分析思路及工具
|
||||
|
||||
首先,当我们面对一个高延时的请求案例后,如何梳理问题定位思路呢?
|
||||
|
||||
知彼知己,方能百战不殆,定位问题也是类似。首先我们得弄清楚产生问题的原理、流程,在[02](https://time.geekbang.org/column/article/335932)、[03](https://time.geekbang.org/column/article/336766)、[04](https://time.geekbang.org/column/article/337604)中我已为你介绍过读写请求的核心链路。其次是熟练掌握相关工具,借助它们,可以帮助我们快速攻破疑难杂症。
|
||||
|
||||
这里我们再回顾下03中介绍的,Leader收到一个写请求,将一个日志条目复制到集群多数节点并应用到存储状态机的流程(如下图所示),通过此图我们看看写流程上哪些地方可能会导致请求超时呢?
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/df/2c/df9yy18a1e28e18295cfc15a28cd342c.png" alt="">
|
||||
|
||||
首先是流程四,一方面,Leader需要并行将消息通过网络发送给各Follower节点,依赖网络性能。另一方面,Leader需持久化日志条目到WAL,依赖磁盘I/O顺序写入性能。
|
||||
|
||||
其次是流程八,应用日志条目到存储状态机时,etcd后端key-value存储引擎是boltdb。正如我们[10](https://time.geekbang.org/column/article/342527)所介绍的,它是一个基于B+ tree实现的存储引擎,当你写入数据,提交事务时,它会将dirty page持久化到磁盘中。在这过程中boltdb会产生磁盘随机I/O写入,因此事务提交性能依赖磁盘I/O随机写入性能。
|
||||
|
||||
最后,在整个写流程处理过程中,etcd节点的CPU、内存、网络带宽资源应充足,否则肯定也会影响性能。
|
||||
|
||||
初步了解完可能导致延时抖动的瓶颈处之后,我给你总结了etcd问题定位过程中常用的工具,你可以参考下面这幅图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b5/fc/b5bb69c8effda97f2ef78b067ab1aafc.png" alt="">
|
||||
|
||||
图的左边是读写请求链路中可能出现瓶颈或异常的点,比如上面流程分析中提到的磁盘、内存、CPU、网络资源。
|
||||
|
||||
图的右边是常用的工具,分别是metrics、trace日志、etcd其他日志、WAL及boltdb分析工具等。
|
||||
|
||||
接下来,我基于读写请求的核心链路和其可能出现的瓶颈点,结合相关的工具,为你深入分析etcd延时抖动的定位方法和原因。
|
||||
|
||||
## 网络
|
||||
|
||||
首先我们来看看流程图中第一个提到可能瓶颈点,网络模块。
|
||||
|
||||
在etcd中,各个节点之间需要通过2380端口相互通信,以完成Leader选举、日志同步等功能,因此底层网络质量(吞吐量、延时、稳定性)对上层etcd服务的性能有显著影响。
|
||||
|
||||
网络资源出现异常的常见表现是连接闪断、延时抖动、丢包等。那么我们要如何定位网络异常导致的延时抖动呢?
|
||||
|
||||
一方面,我们可以使用常规的ping/traceroute/mtr、ethtool、ifconfig/ip、netstat、tcpdump网络分析工具等命令,测试网络的连通性、延时,查看网卡的速率是否存在丢包等错误,确认etcd进程的连接状态及数量是否合理,抓取etcd报文分析等。
|
||||
|
||||
另一方面,etcd应用层提供了节点之间网络统计的metrics指标,分别如下:
|
||||
|
||||
- etcd_network_active_peer,表示peer之间活跃的连接数;
|
||||
- etcd_network_peer_round_trip_time_seconds,表示peer之间RTT延时;
|
||||
- etcd_network_peer_sent_failures_total,表示发送给peer的失败消息数;
|
||||
- etcd_network_client_grpc_sent_bytes_total,表示server发送给client的总字节数,通过这个指标我们可以监控etcd出流量;
|
||||
- etcd_network_client_grpc_received_bytes_total,表示server收到client发送的总字节数,通过这个指标可以监控etcd入流量。
|
||||
|
||||
client入流量监控如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/26/ff/26617a4c08e7c1e155c4332058451cff.png" alt="">
|
||||
|
||||
client出流量如下图监控所示。 从图中你可以看到,峰值接近140MB/s(1.12Gbps),这是非常不合理的,说明业务中肯定有大量expensive read request操作。若etcd集群读写请求开始出现超时,你可以用ifconfig等命令查看是否出现丢包等错误。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4c/0b/4c8659e621305200b8f761b1e319460b.png" alt="">
|
||||
|
||||
etcd metrics指标名由namespace和subsystem、name组成。namespace为etcd, subsystem是模块名(比如network、name具体的指标名)。你可以在Prometheus里搜索etcd_network找到所有network相关的metrics指标名。
|
||||
|
||||
下面是一个集群中某节点异常后的metrics指标:
|
||||
|
||||
```
|
||||
etcd_network_active_peers{Local="fd422379fda50e48",Remote="8211f1d0f64f3269"} 1
|
||||
etcd_network_active_peers{Local="fd422379fda50e48",Remote="91bc3c398fb3c146"} 0
|
||||
etcd_network_peer_sent_failures_total{To="91bc3c398fb3c146"} 47774
|
||||
etcd_network_client_grpc_sent_bytes_total 513207
|
||||
|
||||
```
|
||||
|
||||
从以上metrics中,你可以看到91bc3c398fb3c146节点出现了异常。在etcd场景中,网络质量导致etcd性能下降主要源自两个方面:
|
||||
|
||||
一方面,expensive request中的大包查询会使网卡出现瓶颈,产生丢包等错误,从而导致etcd吞吐量下降、高延时。expensive request导致网卡丢包,出现超时,这在etcd中是非常典型且易发生的问题,它主要是因为业务没有遵循最佳实践,查询了大量key-value。
|
||||
|
||||
另一方面,在跨故障域部署的时候,故障域可能是可用区、城市。故障域越大,容灾级别越高,但各个节点之间的RTT越高,请求的延时更高。
|
||||
|
||||
## 磁盘I/O
|
||||
|
||||
了解完网络问题的定位方法和导致网络性能下降的因素后,我们再看看最核心的磁盘I/O。
|
||||
|
||||
正如我在开头的Raft日志复制整体流程图中和你介绍的,在etcd中无论是Raft日志持久化还是boltdb事务提交,都依赖于磁盘I/O的性能。
|
||||
|
||||
**当etcd请求延时出现波动时,我们往往首先关注disk相关指标是否正常。**我们可以通过etcd磁盘相关的metrics(etcd_disk_wal_fsync_duration_seconds和etcd_disk_backend_commit_duration_seconds)来观测应用层数据写入磁盘的性能。
|
||||
|
||||
etcd_disk_wal_fsync_duration_seconds(简称disk_wal_fsync)表示WAL日志持久化的fsync系统调用延时数据。一般本地SSD盘P99延时在10ms内,如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9a/52/9a08490980abb23f90d8e59a83543e52.png" alt="">
|
||||
|
||||
etcd_disk_backend_commit_duration_seconds(简称disk_backend_commit)表示后端boltdb事务提交的延时,一般P99在120ms内。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/29/db/294600a0a144be38e9d7b69d9403f3db.png" alt="">
|
||||
|
||||
这里你需要注意的是,一般监控显示的磁盘延时都是P99,但实际上etcd对磁盘特别敏感,一次磁盘I/O波动就可能产生Leader切换。如果你遇到集群Leader出现切换、请求超时,但是磁盘指标监控显示正常,你可以查看P100确认下是不是由于磁盘I/O波动导致的。
|
||||
|
||||
同时etcd的WAL模块在fdatasync操作超过1秒时,也会在etcd中打印如下的日志,你可以结合日志进一步定位。
|
||||
|
||||
```
|
||||
if took > warnSyncDuration {
|
||||
if w.lg != nil {
|
||||
w.lg.Warn(
|
||||
"slow fdatasync",
|
||||
zap.Duration("took", took),
|
||||
zap.Duration("expected-duration", warnSyncDuration),
|
||||
)
|
||||
} else {
|
||||
plog.Warningf("sync duration of %v, expected less than %v", took, warnSyncDuration)
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
当disk_wal_fsync指标异常的时候,一般是底层硬件出现瓶颈或异常导致。当然也有可能是CPU高负载、cgroup blkio限制导致的,我们具体应该如何区分呢?
|
||||
|
||||
你可以通过iostat、blktrace工具分析瓶颈是在应用层还是内核层、硬件层。其中blktrace是blkio层的磁盘I/O分析利器,它可记录IO进入通用块层、IO请求生成插入请求队列、IO请求分发到设备驱动、设备驱动处理完成这一系列操作的时间,帮助你发现磁盘I/O瓶颈发生的阶段。
|
||||
|
||||
当disk_backend_commit指标的异常时候,说明事务提交过程中的B+ tree树重平衡、分裂、持久化dirty page、持久化meta page等操作耗费了大量时间。
|
||||
|
||||
disk_backend_commit指标异常,能说明是磁盘I/O发生了异常吗?
|
||||
|
||||
若disk_backend_commit较高、disk_wal_fsync却正常,说明瓶颈可能并非来自磁盘I/O性能,也许是B+ tree的重平衡、分裂过程中的较高时间复杂度逻辑操作导致。比如etcd目前所有stable版本(etcd 3.2到3.4),从freelist中申请和回收若干连续空闲页的时间复杂度是O(N),当db文件较大、空闲页碎片化分布的时候,则可能导致事务提交高延时。
|
||||
|
||||
那如何区分事务提交过程中各个阶段的耗时呢?
|
||||
|
||||
etcd还提供了disk_backend_commit_rebalance_duration和
|
||||
|
||||
disk_backend_commit_spill_duration两个metrics,分别表示事务提交过程中B+ tree的重平衡和分裂操作耗时分布区间。
|
||||
|
||||
最后,你需要注意disk_wal_fsync记录的是WAL文件顺序写入的持久化时间,disk_backend_commit记录的是整个事务提交的耗时。后者涉及的磁盘I/O是随机的,为了保证你etcd集群的稳定性,建议使用SSD磁盘以确保事务提交的稳定性。
|
||||
|
||||
## expensive request
|
||||
|
||||
若磁盘和网络指标都很正常,那么延时高还有可能是什么原因引起的呢?
|
||||
|
||||
从[02](https://time.geekbang.org/column/article/335932)介绍的读请求链路我们可知,一个读写请求经过Raft模块处理后,最终会走到MVCC模块。那么在MVCC模块会有哪些场景导致延时抖动呢?时间耗在哪个处理流程上了?
|
||||
|
||||
etcd 3.4版本之前,在应用put/txn等请求到状态机的apply和处理读请求range流程时,若一个请求执行超过100ms时,默认会在etcd log中打印一条"apply request took too long"的警告日志。通过此日志我们可以知道集群中apply流程产生了较慢的请求,但是不能确定具体是什么因素导致的。
|
||||
|
||||
比如在Kubernetes中,当集群Pod较多的时候,若你频繁执行List Pod,可能会导致etcd出现大量的"apply request took too long"警告日志。
|
||||
|
||||
因为对etcd而言,List Pod请求涉及到大量的key查询,会消耗较多的CPU、内存、网络资源,此类expensive request的QPS若较大,则很可能导致OOM、丢包。
|
||||
|
||||
当然,除了业务发起的expensive request请求导致延时抖动以外,也有可能是etcd本身的设计实现存在瓶颈。
|
||||
|
||||
比如在etcd 3.2和3.3版本写请求完成之前,需要更新MVCC的buffer,进行升级锁操作。然而此时若集群中出现了一个long expensive read request,则会导致写请求执行延时抖动。因为expensive read request事务会一直持有MVCC的buffer读锁,导致写请求事务阻塞在升级锁操作中。
|
||||
|
||||
在了解完expensive request对请求延时的影响后,接下来要如何解决请求延时较高问题的定位效率呢?
|
||||
|
||||
为了提高请求延时分布的可观测性、延时问题的定位效率,etcd社区在3.4版本后中实现了trace特性,详细记录了一个请求在各个阶段的耗时。若某阶段耗时流程超过默认的100ms,则会打印一条trace日志。
|
||||
|
||||
下面是我将trace日志打印的阈值改成1纳秒后读请求执行过程中的trace日志。从日志中你可以看到,trace日志记录了以下阶段耗时:
|
||||
|
||||
- agreement among raft nodes before linearized reading,此阶段读请求向Leader发起readIndex查询并等待本地applied index >= Leader的committed index, 但是你无法区分是readIndex慢还是等待本地applied index > Leader的committed index慢。在etcd 3.5中新增了trace,区分了以上阶段;
|
||||
- get authentication metadata,获取鉴权元数据;
|
||||
- range keys from in-memory index tree,从内存索引B-tree中查找key列表对应的版本号列表;
|
||||
- range keys from bolt db,根据版本号列表从boltdb遍历,获得用户的key-value信息;
|
||||
- filter and sort the key-value pairs,过滤、排序key-value列表;
|
||||
- assemble the response,聚合结果。
|
||||
|
||||
```
|
||||
{
|
||||
"level":"info",
|
||||
"ts":"2020-12-16T08:11:43.720+0800",
|
||||
"caller":"traceutil/trace.go:145",
|
||||
"msg":"trace[789864563] range",
|
||||
"detail":"{range_begin:a; range_end:; response_count:1; response_revision:32011; }",
|
||||
"duration":"318.774µs",
|
||||
"start":"2020-12-16T08:11:43.719+0800",
|
||||
"end":"2020-12-16T08:11:43.720+0800",
|
||||
"steps":[
|
||||
"trace[789864563] 'agreement among raft nodes before linearized reading' (duration: 255.227µs)",
|
||||
"trace[789864563] 'get authentication metadata' (duration: 2.97µs)",
|
||||
"trace[789864563] 'range keys from in-memory index tree' (duration: 44.578µs)",
|
||||
"trace[789864563] 'range keys from bolt db' (duration: 8.688µs)",
|
||||
"trace[789864563] 'filter and sort the key-value pairs' (duration: 578ns)",
|
||||
"trace[789864563] 'assemble the response' (duration: 643ns)"
|
||||
]
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
那么写请求流程会记录哪些阶段耗时呢?
|
||||
|
||||
下面是put写请求的执行trace日志,记录了以下阶段耗时:
|
||||
|
||||
- process raft request,写请求提交到Raft模块处理完成耗时;
|
||||
- get key's previous created_revision and leaseID,获取key上一个创建版本号及leaseID的耗时;
|
||||
- marshal mvccpb.KeyValue,序列化KeyValue结构体耗时;
|
||||
- store kv pair into bolt db,存储kv数据到boltdb的耗时;
|
||||
- attach lease to kv pair,将lease id关联到kv上所用时间。
|
||||
|
||||
```
|
||||
{
|
||||
"level":"info",
|
||||
"ts":"2020-12-16T08:25:12.707+0800",
|
||||
"caller":"traceutil/trace.go:145",
|
||||
"msg":"trace[1402827146] put",
|
||||
"detail":"{key:16; req_size:8; response_revision:32030; }",
|
||||
"duration":"6.826438ms",
|
||||
"start":"2020-12-16T08:25:12.700+0800",
|
||||
"end":"2020-12-16T08:25:12.707+0800",
|
||||
"steps":[
|
||||
"trace[1402827146] 'process raft request' (duration: 6.659094ms)",
|
||||
"trace[1402827146] 'get key's previous created_revision and leaseID' (duration: 23.498µs)",
|
||||
"trace[1402827146] 'marshal mvccpb.KeyValue' (duration: 1.857µs)",
|
||||
"trace[1402827146] 'store kv pair into bolt db' (duration: 30.121µs)",
|
||||
"trace[1402827146] 'attach lease to kv pair' (duration: 661ns)"
|
||||
]
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
通过以上介绍的trace特性,你就可以快速定位到高延时读写请求的原因。比如当你向etcd发起了一个涉及到大量key或value较大的expensive request请求的时候,它会产生如下的warn和trace日志。
|
||||
|
||||
从以下日志中我们可以看到,此请求查询的vip前缀下所有的kv数据总共是250条,但是涉及的数据包大小有250MB,总耗时约1.85秒,其中从boltdb遍历key消耗了1.63秒。
|
||||
|
||||
```
|
||||
{
|
||||
"level":"warn",
|
||||
"ts":"2020-12-16T23:02:53.324+0800",
|
||||
"caller":"etcdserver/util.go:163",
|
||||
"msg":"apply request took too long",
|
||||
"took":"1.84796759s",
|
||||
"expected-duration":"100ms",
|
||||
"prefix":"read-only range ",
|
||||
"request":"key:"vip" range_end:"viq" ",
|
||||
"response":"range_response_count:250 size:262150651"
|
||||
}
|
||||
{
|
||||
"level":"info",
|
||||
"ts":"2020-12-16T23:02:53.324+0800",
|
||||
"caller":"traceutil/trace.go:145",
|
||||
"msg":"trace[370341530] range",
|
||||
"detail":"{range_begin:vip; range_end:viq; response_count:250; response_revision:32666; }",
|
||||
"duration":"1.850335038s",
|
||||
"start":"2020-12-16T23:02:51.473+0800",
|
||||
"end":"2020-12-16T23:02:53.324+0800",
|
||||
"steps":[
|
||||
"trace[370341530] 'range keys from bolt db' (duration: 1.632336981s)"
|
||||
]
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
最后,有两个注意事项。
|
||||
|
||||
第一,在etcd 3.4中,logger默认为capnslog,trace特性只有在当logger为zap时才开启,因此你需要设置--logger=zap。
|
||||
|
||||
第二,trace特性并不能记录所有类型的请求,它目前只覆盖了MVCC模块中的range/put/txn等常用接口。像Authenticate鉴权请求,涉及到大量CPU计算,延时是非常高的,在trace日志中目前没有相关记录。
|
||||
|
||||
如果你开启了密码鉴权,在连接数增多、QPS增大后,若突然出现请求超时,如何确定是鉴权还是查询、更新等接口导致的呢?
|
||||
|
||||
etcd默认参数并不会采集各个接口的延时数据,我们可以通过设置etcd的启动参数--metrics为extensive来开启,获得每个gRPC接口的延时数据。同时可结合各个gRPC接口的请求数,获得QPS。
|
||||
|
||||
如下是某节点的metrics数据,251个Put请求,返回码OK,其中有240个请求在100毫秒内完成。
|
||||
|
||||
```
|
||||
grpc_server_handled_total{grpc_code="OK",
|
||||
grpc_method="Put",grpc_service="etcdserverpb.KV",
|
||||
grpc_type="unary"} 251
|
||||
|
||||
grpc_server_handling_seconds_bucket{grpc_method="Put",grpc_service="etcdserverpb.KV",grpc_type="unary",le="0.005"} 0
|
||||
grpc_server_handling_seconds_bucket{grpc_method="Put",grpc_service="etcdserverpb.KV",grpc_type="unary",le="0.01"} 1
|
||||
grpc_server_handling_seconds_bucket{grpc_method="Put",grpc_service="etcdserverpb.KV",grpc_type="unary",le="0.025"} 51
|
||||
grpc_server_handling_seconds_bucket{grpc_method="Put",grpc_service="etcdserverpb.KV",grpc_type="unary",le="0.05"} 204
|
||||
grpc_server_handling_seconds_bucket{grpc_method="Put",grpc_service="etcdserverpb.KV",grpc_type="unary",le="0.1"} 240
|
||||
|
||||
```
|
||||
|
||||
## 集群容量、节点CPU/Memory瓶颈
|
||||
|
||||
介绍完网络、磁盘I/O、expensive request导致etcd请求延时较高的原因和分析方法后,我们再看看容量和节点资源瓶颈是如何导致高延时请求产生的。
|
||||
|
||||
若网络、磁盘I/O正常,也无expensive request,那此时高延时请求是怎么产生的呢?它的trace日志会输出怎样的耗时结果?
|
||||
|
||||
下面是一个社区用户反馈的一个读接口高延时案例的两条trace日志。从第一条日志中我们可以知道瓶颈在于线性读的准备步骤,readIndex和wait applied index。
|
||||
|
||||
那么是其中具体哪个步骤导致的高延时呢?通过在etcd 3.5版本中细化此流程,我们获得了第二条日志,发现瓶颈在于等待applied index >= Leader的committed index。
|
||||
|
||||
```
|
||||
{
|
||||
"level": "info",
|
||||
"ts": "2020-08-12T08:24:56.181Z",
|
||||
"caller": "traceutil/trace.go:145",
|
||||
"msg": "trace[677217921] range",
|
||||
"detail": "{range_begin:/...redacted...; range_end:; response_count:1; response_revision:2725080604; }",
|
||||
"duration": "1.553047811s",
|
||||
"start": "2020-08-12T08:24:54.628Z",
|
||||
"end": "2020-08-12T08:24:56.181Z",
|
||||
"steps": [
|
||||
"trace[677217921] 'agreement among raft nodes before linearized reading' (duration: 1.534322015s)"
|
||||
]
|
||||
}
|
||||
|
||||
{
|
||||
"level": "info",
|
||||
"ts": "2020-09-22T12:54:01.021Z",
|
||||
"caller": "traceutil/trace.go:152",
|
||||
"msg": "trace[2138445431] linearizableReadLoop",
|
||||
"detail": "",
|
||||
"duration": "855.447896ms",
|
||||
"start": "2020-09-22T12:54:00.166Z",
|
||||
"end": "2020-09-22T12:54:01.021Z",
|
||||
"steps": [
|
||||
"trace[2138445431] read index received (duration: 824.408µs)",
|
||||
"trace[2138445431] applied index is now lower than readState.Index (duration: 854.622058ms)"
|
||||
]
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
为什么会发生这样的现象呢?
|
||||
|
||||
首先你可以通过etcd_server_slow_apply_total指标,观查其值快速增长的时间点与高延时请求产生的日志时间点是否吻合。
|
||||
|
||||
其次检查是否存在大量写请求。线性读需确保本节点数据与Leader数据一样新, 若本节点的数据与Leader差异较大,本节点追赶Leader数据过程会花费一定时间,最终导致高延时的线性读请求产生。
|
||||
|
||||
**etcd适合读多写少的业务场景,若写请求较大,很容易出现容量瓶颈,导致高延时的读写请求产生。**
|
||||
|
||||
最后通过ps/top/mpstat/perf等CPU、Memory性能分析工具,检查etcd节点是否存在CPU、Memory瓶颈。goroutine饥饿、内存不足都会导致高延时请求产生,若确定CPU和Memory存在异常,你可以通过开启debug模式,通过pprof分析CPU和内存瓶颈点。
|
||||
|
||||
## 小结
|
||||
|
||||
最后小结下我们今天的内容,我按照前面介绍的读写请求原理、以及丰富的实战经验,给你整理了可能导致延时抖动的常见原因。
|
||||
|
||||
如下图所示,我从以下几个方面给你介绍了会导致请求延时上升的原因:
|
||||
|
||||
- 网络质量,如节点之间RTT延时、网卡带宽满,出现丢包;
|
||||
- 磁盘I/O抖动,会导致WAL日志持久化、boltdb事务提交出现抖动,Leader出现切换等;
|
||||
- expensive request,比如大包请求、涉及到大量key遍历、Authenticate密码鉴权等操作;
|
||||
- 容量瓶颈,太多写请求导致线性读请求性能下降等;
|
||||
- 节点配置,CPU繁忙导致请求处理延时、内存不够导致swap等。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/93/a3/9375f08cebd596b87b92623c10786fa3.png" alt="">
|
||||
|
||||
并在分析这些案例的过程中,给你介绍了etcd问题核心工具:metrics、etcd log、trace日志、blktrace、pprof等。
|
||||
|
||||
希望通过今天的内容,能帮助你从容应对etcd延时抖动。
|
||||
|
||||
## 思考题
|
||||
|
||||
在使用etcd过程中,你遇到过哪些高延时的请求案例呢?你是如何解决的呢?
|
||||
|
||||
感谢你的阅读,如果你认为这节课的内容有收获,也欢迎把它分享给你的朋友,谢谢。
|
||||
314
极客时间专栏/geek/etcd实战课/实践篇/15 | 内存:为什么你的etcd内存占用那么高?.md
Normal file
314
极客时间专栏/geek/etcd实战课/实践篇/15 | 内存:为什么你的etcd内存占用那么高?.md
Normal file
@@ -0,0 +1,314 @@
|
||||
<audio id="audio" title="15 | 内存:为什么你的etcd内存占用那么高?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/09/a9/0970df599e44da98ee3cb32b4c7393a9.mp3"></audio>
|
||||
|
||||
你好,我是唐聪。
|
||||
|
||||
在使用etcd的过程中,你是否被异常内存占用等现象困扰过?比如etcd中只保存了1个1MB的key-value,但是经过若干次修改后,最终etcd内存可能达到数G。它是由什么原因导致的?如何分析呢?
|
||||
|
||||
这就是我今天要和你分享的主题:etcd的内存。 希望通过这节课,帮助你掌握etcd内存抖动、异常背后的常见原因和分析方法,当你遇到类似问题时,能独立定位、解决。同时,帮助你在实际业务场景中,为集群节点配置充足的内存资源,遵循最佳实践,尽量减少expensive request,避免etcd内存出现突增,导致OOM。
|
||||
|
||||
## 分析整体思路
|
||||
|
||||
当你遇到etcd内存占用较高的案例时,你脑海中第一反应是什么呢?
|
||||
|
||||
也许你会立刻重启etcd进程,尝试将内存降低到合理水平,避免线上服务出问题。
|
||||
|
||||
也许你会开启etcd debug模式,重启etcd进程等复现,然后采集heap profile分析内存占用。
|
||||
|
||||
以上措施都有其合理性。但作为团队内etcd高手的你,在集群稳定性还不影响业务的前提下,能否先通过内存异常的现场,结合etcd的读写流程、各核心模块中可能会使用较多内存的关键数据结构,推测出内存异常的可能原因?
|
||||
|
||||
全方位的分析内存异常现场,可以帮助我们节省大量复现和定位时间,也是你专业性的体现。
|
||||
|
||||
下图是我以etcd写请求流程为例,给你总结的可能导致etcd内存占用较高的核心模块与其数据结构。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c2/49/c2673ebb2db4b555a9fbe229ed1bda49.png" alt="">
|
||||
|
||||
从图中你可以看到,当etcd收到一个写请求后,gRPC Server会和你建立连接。连接数越多,会导致etcd进程的fd、goroutine等资源上涨,因此会使用越来越多的内存。
|
||||
|
||||
其次,基于我们[04](https://time.geekbang.org/column/article/337604)介绍的Raft知识背景,它需要将此请求的日志条目保存在raftLog里面。etcd raftLog后端实现是内存存储,核心就是数组。因此raftLog使用的内存与其保存的日志条目成正比,它也是内存分析过程中最容易被忽视的一个数据结构。
|
||||
|
||||
然后当此日志条目被集群多数节点确认后,在应用到状态机的过程中,会在内存treeIndex模块的B-tree中创建、更新key与版本号信息。 在这过程中treeIndex模块的B-tree使用的内存与key、历史版本号数量成正比。
|
||||
|
||||
更新完treeIndex模块的索引信息后,etcd将key-value数据持久化存储到boltdb。boltdb使用了mmap技术,将db文件映射到操作系统内存中。因此在未触发操作系统将db对应的内存page换出的情况下,etcd的db文件越大,使用的内存也就越大。
|
||||
|
||||
同时,在这个过程中还有两个注意事项。
|
||||
|
||||
一方面,其他client可能会创建若干watcher、监听这个写请求涉及的key, etcd也需要使用一定的内存维护watcher、推送key变化监听的事件。
|
||||
|
||||
另一方面,如果这个写请求的key还关联了Lease,Lease模块会在内存中使用数据结构Heap来快速淘汰过期的Lease,因此Heap也是一个占用一定内存的数据结构。
|
||||
|
||||
最后,不仅仅是写请求流程会占用内存,读请求本身也会导致内存上升。尤其是expensive request,当产生大包查询时,MVCC模块需要使用内存保存查询的结果,很容易导致内存突增。
|
||||
|
||||
基于以上读写流程图对核心数据结构使用内存的分析,我们定位问题时就有线索、方法可循了。那如何确定是哪个模块、场景导致的内存异常呢?
|
||||
|
||||
接下来我就通过一个实际案例,和你深入介绍下内存异常的分析方法。
|
||||
|
||||
## 一个key使用数G内存的案例
|
||||
|
||||
我们通过goreman启动一个3节点etcd集群(linux/etcd v3.4.9),db quota为6G,执行如下的命令并观察etcd内存占用情况:
|
||||
|
||||
- 执行1000次的put同一个key操作,value为1MB;
|
||||
- 更新完后并进行compact、defrag操作;
|
||||
|
||||
```
|
||||
# put同一个key,执行1000次
|
||||
for i in {1..1000}; do dd if=/dev/urandom bs=1024
|
||||
count=1024 | ETCDCTL_API=3 etcdctl put key || break; done
|
||||
|
||||
# 获取最新revision,并压缩
|
||||
etcdctl compact `(etcdctl endpoint status --write-out="json" | egrep -o '"revision":[0-9]*' | egrep -o '[0-9].*')`
|
||||
|
||||
# 对集群所有节点进行碎片整理
|
||||
etcdctl defrag --cluster
|
||||
|
||||
```
|
||||
|
||||
在执行操作前,空集群etcd db size 20KB,etcd进程内存36M左右,分别如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c1/e6/c1fb89ae1d6218a66cf1db30c41d9be6.png" alt="">
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6c/6d/6ce074583f39cd9a19bdcb392133426d.png" alt="">
|
||||
|
||||
你预测执行1000次同样key更新后,etcd进程占用了多少内存呢? 约37M? 1G? 2G?3G? 还是其他呢?
|
||||
|
||||
执行1000次的put操作后,db大小和etcd内存占用分别如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d6/45/d6dc86f76f52dfed73ab1771ebbbf545.png" alt="">
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9d/70/9d97762851c18a0c4cd89aa5a7bb0270.png" alt="">
|
||||
|
||||
当我们执行compact、defrag命令后,如下图所示,db大小只有1M左右,但是你会发现etcd进程实际却仍占用了2G左右内存。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/93/bd/937c3fb0bf12595928e8ae4b05b7a5bd.png" alt="">
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8d/58/8d2d9fb3c0193745d80fe68b0cb4a758.png" alt="">
|
||||
|
||||
整个集群只有一个key,为什么etcd占用了这么多的内存呢?是etcd发生了内存泄露吗?
|
||||
|
||||
## raftLog
|
||||
|
||||
当你发起一个put请求的时候,etcd需通过Raft模块将此请求同步到其他节点,详细流程你可结合下图再次了解下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/df/2c/df9yy18a1e28e18295cfc15a28cd342c.png" alt="">
|
||||
|
||||
从图中你可以看到,Raft模块的输入是一个消息/Msg,输出统一为Ready结构。etcd会把此请求封装成一个消息,提交到Raft模块。
|
||||
|
||||
Raft模块收到此请求后,会把此消息追加到raftLog的unstable存储的entry内存数组中(图中流程2),并且将待持久化的此消息封装到Ready结构内,通过管道通知到etcdserver(图中流程3)。
|
||||
|
||||
etcdserver取出消息,持久化到WAL中,并追加到raftLog的内存存储storage的entry数组中(图中流程5)。
|
||||
|
||||
下面是[raftLog](https://github.com/etcd-io/etcd/blob/v3.4.9/raft/log.go#L24:L45)的核心数据结构,它由storage、unstable、committed、applied等组成。storage存储已经持久化到WAL中的日志条目,unstable存储未持久化的条目和快照,一旦持久化会及时删除日志条目,因此不存在过多内存占用的问题。
|
||||
|
||||
```
|
||||
type raftLog struct {
|
||||
// storage contains all stable entries since the last snapshot.
|
||||
storage Storage
|
||||
|
||||
|
||||
// unstable contains all unstable entries and snapshot.
|
||||
// they will be saved into storage.
|
||||
unstable unstable
|
||||
|
||||
|
||||
// committed is the highest log position that is known to be in
|
||||
// stable storage on a quorum of nodes.
|
||||
committed uint64
|
||||
// applied is the highest log position that the application has
|
||||
// been instructed to apply to its state machine.
|
||||
// Invariant: applied <= committed
|
||||
applied uint64
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
从上面raftLog结构体中,你可以看到,存储稳定的日志条目的storage类型是Storage,Storage定义了存储Raft日志条目的核心API接口,业务应用层可根据实际场景进行定制化实现。etcd使用的是Raft算法库本身提供的MemoryStorage,其定义如下,核心是使用了一个数组来存储已经持久化后的日志条目。
|
||||
|
||||
```
|
||||
// MemoryStorage implements the Storage interface backed
|
||||
// by an in-memory array.
|
||||
type MemoryStorage struct {
|
||||
// Protects access to all fields. Most methods of MemoryStorage are
|
||||
// run on the raft goroutine, but Append() is run on an application
|
||||
// goroutine.
|
||||
sync.Mutex
|
||||
|
||||
hardState pb.HardState
|
||||
snapshot pb.Snapshot
|
||||
// ents[i] has raftLog position i+snapshot.Metadata.Index
|
||||
ents []pb.Entry
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
那么随着写请求增多,内存中保留的Raft日志条目会越来越多,如何防止etcd出现OOM呢?
|
||||
|
||||
etcd提供了快照和压缩功能来解决这个问题。
|
||||
|
||||
首先你可以通过调整--snapshot-count参数来控制生成快照的频率,其值默认是100000(etcd v3.4.9,早期etcd版本是10000),也就是每10万个写请求触发一次快照生成操作。
|
||||
|
||||
快照生成完之后,etcd会通过压缩来删除旧的日志条目。
|
||||
|
||||
那么是全部删除日志条目还是保留一小部分呢?
|
||||
|
||||
答案是保留一小部分Raft日志条目。数量由DefaultSnapshotCatchUpEntries参数控制,默认5000,目前不支持自定义配置。
|
||||
|
||||
保留一小部分日志条目其实是为了帮助慢的Follower以较低的开销向Leader获取Raft日志条目,以尽快追上Leader进度。若raftLog中不保留任何日志条目,就只能发送快照给慢的Follower,这开销就非常大了。
|
||||
|
||||
通过以上分析可知,如果你的请求key-value比较大,比如上面我们的案例中是1M,1000次修改,那么etcd raftLog至少会消耗1G的内存。这就是为什么内存随着写请求修改次数不断增长的原因。
|
||||
|
||||
除了raftLog占用内存外,MVCC模块的treeIndex/boltdb模块又是如何使用内存的呢?
|
||||
|
||||
## treeIndex
|
||||
|
||||
一个put写请求的日志条目被集群多数节点确认提交后,这时etcdserver就会从Raft模块获取已提交的日志条目,应用到MVCC模块的treeIndex和boltdb。
|
||||
|
||||
我们知道treeIndex是基于google内存btree库实现的一个索引管理模块,在etcd中每个key都会在treeIndex中保存一个索引项(keyIndex),记录你的key和版本号等信息,如下面的数据结构所示。
|
||||
|
||||
```
|
||||
type keyIndex struct {
|
||||
key []byte
|
||||
modified revision // the main rev of the last modification
|
||||
generations []generation
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
同时,你每次对key的修改、删除操作都会在key的索引项中追加一条修改记录(revision)。因此,随着修改次数的增加,etcd内存会一直增加。那么如何清理旧版本,防止过多的内存占用呢?
|
||||
|
||||
答案也是压缩。正如我在[11](https://time.geekbang.org/column/article/342891)压缩篇和你介绍的,当你执行compact命令时,etcd会遍历treeIndex中的各个keyIndex,清理历史版本号记录与已删除的key,释放内存。
|
||||
|
||||
从上面的keyIndex数据结构我们可知,一个key的索引项内存开销跟你的key大小、保存的历史版本数、compact策略有关。为了避免内存索引项占用过多的内存,key的长度不应过长,同时你需要配置好合理的压缩策略。
|
||||
|
||||
## boltdb
|
||||
|
||||
在treeIndex模块中创建、更新完keyIndex数据结构后,你的key-value数据、各种版本号、lease等相关信息会保存到如下的一个mvccpb.keyValue结构体中。它是boltdb的value,key则是treeIndex中保存的版本号,然后通过boltdb的写接口保存到db文件中。
|
||||
|
||||
```
|
||||
kv := mvccpb.KeyValue{
|
||||
Key: key,
|
||||
Value: value,
|
||||
CreateRevision: c,
|
||||
ModRevision: rev,
|
||||
Version: ver,
|
||||
Lease: int64(leaseID),
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
前面我们在介绍boltdb时,提到过etcd在启动时会通过mmap机制,将etcd db文件映射到etcd进程地址空间,并设置mmap的MAP_POPULATE flag,它会告诉Linux内核预读文件,让Linux内核将文件内容拷贝到物理内存中。
|
||||
|
||||
在节点内存足够的情况下,后续读请求可直接从内存中获取。相比read系统调用,mmap少了一次从page cache拷贝到进程内存地址空间的操作,因此具备更好的性能。
|
||||
|
||||
若etcd节点内存不足,可能会导致db文件对应的内存页被换出。当读请求命中的页未在内存中时,就会产生缺页异常,导致读过程中产生磁盘IO。这样虽然避免了etcd进程OOM,但是此过程会产生较大的延时。
|
||||
|
||||
从以上boltdb的key-value和mmap机制介绍中我们可知,我们应控制boltdb文件大小,优化key-value大小,配置合理的压缩策略,回收旧版本,避免过多内存占用。
|
||||
|
||||
## watcher
|
||||
|
||||
在你写入key的时候,其他client还可通过etcd的Watch监听机制,获取到key的变化事件。
|
||||
|
||||
那创建一个watcher耗费的内存跟哪些因素有关呢?
|
||||
|
||||
在[08](https://time.geekbang.org/column/article/341060)Watch机制设计与实现分析中,我和你介绍过创建watcher的整体流程与架构,如下图所示。当你创建一个watcher时,client与server建立连接后,会创建一个gRPC Watch Stream,随后通过这个gRPC Watch Stream发送创建watcher请求。
|
||||
|
||||
每个gRPC Watch Stream中etcd WatchServer会分配两个goroutine处理,一个是sendLoop,它负责Watch事件的推送。一个是recvLoop,负责接收client的创建、取消watcher请求消息。
|
||||
|
||||
同时对每个watcher来说,etcd的WatchableKV模块需将其保存到相应的内存管理数据结构中,实现可靠的Watch事件推送。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/42/bf/42575d8d0a034e823b8e48d4ca0a49bf.png" alt="">
|
||||
|
||||
因此watch监听机制耗费的内存跟client连接数、gRPC Stream、watcher数(watching)有关,如下面公式所示:
|
||||
|
||||
- c1表示每个连接耗费的内存;
|
||||
- c2表示每个gRPC Stream耗费的内存;
|
||||
- c3表示每个watcher耗费的内存。
|
||||
|
||||
```
|
||||
memory = c1 * number_of_conn + c2 *
|
||||
avg_number_of_stream_per_conn + c3 *
|
||||
avg_number_of_watch_stream
|
||||
|
||||
```
|
||||
|
||||
根据etcd社区的[压测报告](https://etcd.io/docs/v3.4.0/benchmarks/etcd-3-watch-memory-benchmark/),大概估算出Watch机制中c1、c2、c3占用的内存分别如下:
|
||||
|
||||
- 每个client连接消耗大约17kb的内存(c1);
|
||||
- 每个gRPC Stream消耗大约18kb的内存(c2);
|
||||
- 每个watcher消耗大约350个字节(c3);
|
||||
|
||||
当你的业务场景大量使用watcher的时候,应提前估算下内存容量大小,选择合适的内存配置节点。
|
||||
|
||||
注意以上估算并不包括watch事件堆积的开销。变更事件较多,服务端、客户端高负载,网络阻塞等情况都可能导致事件堆积。
|
||||
|
||||
在etcd 3.4.9版本中,每个watcher默认buffer是1024。buffer内保存watch响应结果,如watchID、watch事件(watch事件包含key、value)等。
|
||||
|
||||
若大量事件堆积,将产生较高昂的内存的开销。你可以通过etcd_debugging_mvcc_pending_events_total指标监控堆积的事件数,etcd_debugging_slow_watcher_total指标监控慢的watcher数,来及时发现异常。
|
||||
|
||||
## expensive request
|
||||
|
||||
当你写入比较大的key-value后,如果client频繁查询它,也会产生高昂的内存开销。
|
||||
|
||||
假设我们写入了100个这样1M大小的key, 通过Range接口一次查询100个key, 那么boltdb遍历、反序列化过程将花费至少100MB的内存。如下面代码所示,它会遍历整个key-value,将key-value保存到数组kvs中。
|
||||
|
||||
```
|
||||
kvs := make([]mvccpb.KeyValue, limit)
|
||||
revBytes := newRevBytes()
|
||||
for i, revpair := range revpairs[:len(kvs)] {
|
||||
revToBytes(revpair, revBytes)
|
||||
_, vs := tr.tx.UnsafeRange(keyBucketName, revBytes, nil, 0)
|
||||
if len(vs) != 1 {
|
||||
......
|
||||
}
|
||||
if err := kvs[i].Unmarshal(vs[0]); err != nil {
|
||||
.......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
也就是说,一次查询就耗费了至少100MB的内存、产生了至少100MB的流量,随着你QPS增大后,很容易OOM、网卡出现丢包。
|
||||
|
||||
count-only、limit查询在key百万级以上时,也会产生非常大的内存开销。因为它们在遍历treeIndex的过程中,会将相关key保存在数组里面。当key多时,此开销不容忽视。
|
||||
|
||||
正如我在[13](https://time.geekbang.org/column/article/343245) db大小中讲到的,在master分支,我已提交相关PR解决count-only和limit查询导致内存占用突增的问题。
|
||||
|
||||
## etcd v2/goroutines/bug
|
||||
|
||||
除了以上介绍的核心模块、expensive request场景可能导致较高的内存开销外,还有以下场景也会导致etcd内存使用较高。
|
||||
|
||||
首先是**etcd中使用了v2的API写入了大量的key-value数据**,这会导致内存飙高。我们知道etcd v2的key-value都是存储在内存树中的,同时v2的watcher不支持多路复用,内存开销相比v3多了一个数量级。
|
||||
|
||||
在etcd 3.4版本之前,etcd默认同时支持etcd v2/v3 API,etcd 3.4版本默认关闭了v2 API。 你可以通过etcd v2 API和etcd v2内存存储模块的metrics前缀etcd_debugging_store,观察集群中是否有v2数据导致的内存占用高。
|
||||
|
||||
其次是**goroutines泄露**导致内存占用高。此问题可能会在容器化场景中遇到。etcd在打印日志的时候,若出现阻塞则可能会导致goroutine阻塞并持续泄露,最终导致内存泄露。你可以通过观察、监控go_goroutines来发现这个问题。
|
||||
|
||||
最后是**etcd bug**导致的内存泄露。当你基本排除以上场景导致的内存占用高后,则很可能是etcd bug导致的内存泄露。
|
||||
|
||||
比如早期etcd clientv3的lease keepalive租约频繁续期bug,它会导致Leader高负载、内存泄露,此bug已在3.2.24/3.3.9版本中修复。
|
||||
|
||||
还有最近我修复的etcd 3.4版本的[Follower节点内存泄露](https://github.com/etcd-io/etcd/pull/11731)。具体表现是两个Follower节点内存一直升高,Leader节点正常,已在3.4.6版本中修复。
|
||||
|
||||
若内存泄露并不是已知的etcd bug导致,那你可以开启pprof, 尝试复现,通过分析pprof heap文件来确定消耗大量内存的模块和数据结构。
|
||||
|
||||
## 小节
|
||||
|
||||
今天我通过一个写入1MB key的实际案例,给你介绍了可能导致etcd内存占用高的核心数据结构、场景,同时我将可能导致内存占用较高的因素总结为了下面这幅图,你可以参考一下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/aa/90/aaf7b4f5f6f568dc70c1a0964fb92790.png" alt="">
|
||||
|
||||
首先是raftLog。为了帮助slow Follower同步数据,它至少要保留5000条最近收到的写请求在内存中。若你的key非常大,你更新5000次会产生较大的内存开销。
|
||||
|
||||
其次是treeIndex。 每个key-value会在内存中保留一个索引项。索引项的开销跟key长度、保留的历史版本有关,你可以通过compact命令压缩。
|
||||
|
||||
然后是boltdb。etcd启动的时候,会通过mmap系统调用,将文件映射到虚拟内存中。你可以通过compact命令回收旧版本,defrag命令进行碎片整理。
|
||||
|
||||
接着是watcher。它的内存占用跟连接数、gRPC Watch Stream数、watcher数有关。watch机制一个不可忽视的内存开销其实是事件堆积的占用缓存,你可以通过相关metrics及时发现堆积的事件以及slow watcher。
|
||||
|
||||
最后我介绍了一些典型的场景导致的内存异常,如大包查询等expensive request,etcd中存储了v2 API写入的key, goroutines泄露以及etcd lease bug等。
|
||||
|
||||
希望今天的内容,能够帮助你从容应对etcd内存占用高的问题,合理配置你的集群,优化业务expensive request,让etcd跑得更稳。
|
||||
|
||||
## 思考题
|
||||
|
||||
在一个key使用数G内存的案例中,最后执行compact和defrag后的结果是2G,为什么不是1G左右呢?在macOS下行为是否一样呢?
|
||||
|
||||
欢迎你动手做下这个小实验,分析下原因,分享你的观点。
|
||||
|
||||
感谢你的阅读,如果你认为这节课的内容有收获,也欢迎把它分享给你的朋友,谢谢。
|
||||
263
极客时间专栏/geek/etcd实战课/实践篇/16 | 性能及稳定性(上):如何优化及扩展etcd性能?.md
Normal file
263
极客时间专栏/geek/etcd实战课/实践篇/16 | 性能及稳定性(上):如何优化及扩展etcd性能?.md
Normal file
@@ -0,0 +1,263 @@
|
||||
<audio id="audio" title="16 | 性能及稳定性(上):如何优化及扩展etcd性能?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c0/c9/c05620e9dc01fa69d510ca5b99779ec9.mp3"></audio>
|
||||
|
||||
你好,我是唐聪。
|
||||
|
||||
在使用etcd的过程中,你是否吐槽过etcd性能差呢? 我们知道,etcd社区线性读[压测结果](https://etcd.io/docs/v3.4.0/op-guide/performance/)可以达到14w/s,那为什么在实际业务场景中有时却只有几千,甚至几百、几十,还会偶发超时、频繁抖动呢?
|
||||
|
||||
我相信不少人都遇到过类似的问题。要解决这些问题,不仅需要了解症结所在,还需要掌握优化和扩展etcd性能的方法,对症下药。因为这部分内容比较多,所以我分成了两讲内容,分别从读性能、写性能和稳定性入手,为你详细讲解如何优化及扩展etcd性能及稳定性。
|
||||
|
||||
希望通过这两节课的学习,能让你在使用etcd的时候,设计出良好的业务存储结构,遵循最佳实践,让etcd稳定、高效地运行,获得符合预期的性能。同时,当你面对etcd性能瓶颈的时候,也能自己分析瓶颈原因、选择合适的优化方案解决它,而不是盲目甩锅etcd,甚至更换技术方案去etcd化。
|
||||
|
||||
今天这节课,我将重点为你介绍如何提升读的性能。
|
||||
|
||||
我们说读性能差,其实本质是读请求链路中某些环节出现了瓶颈。所以,接下来我将通过一张读性能分析链路图,为你从上至下分析影响etcd性能、稳定性的若干因素,并给出相应的压测数据,最终为你总结出一系列的etcd性能优化和扩展方法。
|
||||
|
||||
## 性能分析链路
|
||||
|
||||
为什么在你的业务场景中读性能不如预期呢? 是读流程中的哪一个环节出现了瓶颈?
|
||||
|
||||
在下图中,我为你总结了一个开启密码鉴权场景的读性能瓶颈分析链路图,并在每个核心步骤数字旁边,标出了影响性能的关键因素。我之所以选用密码鉴权的读请求为案例,是因为它使用较广泛并且请求链路覆盖最全,同时它也是最容易遇到性能瓶颈的场景。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7f/52/7f8c66ded3e151123b18768b880a2152.png" alt="">
|
||||
|
||||
接下来我将按照这张链路分析图,带你深入分析一个使用密码鉴权的线性读请求,和你一起看看影响它性能表现的核心因素以及最佳优化实践。
|
||||
|
||||
## 负载均衡
|
||||
|
||||
首先是流程一负载均衡。在[02节](https://time.geekbang.org/column/article/335932)时我和你提到过,在etcd 3.4以前,client为了节省与server节点的连接数,clientv3负载均衡器最终只会选择一个sever节点IP,与其建立一个长连接。
|
||||
|
||||
但是这可能会导致对应的server节点过载(如单节点流量过大,出现丢包), 其他节点却是低负载,最终导致业务无法获得集群的最佳性能。在etcd 3.4后,引入了Round-robin负载均衡算法,它通过轮询的方式依次从endpoint列表中选择一个endpoint访问(长连接),使server节点负载尽量均衡。
|
||||
|
||||
所以,如果你使用的是etcd低版本,那么我建议你通过Load Balancer访问后端etcd集群。因为一方面Load Balancer一般支持配置各种负载均衡算法,如连接数、Round-robin等,可以使你的集群负载更加均衡,规避etcd client早期的固定连接缺陷,获得集群最佳性能。
|
||||
|
||||
另一方面,当你集群节点需要替换、扩缩容集群节点的时候,你不需要去调整各个client访问server的节点配置。
|
||||
|
||||
## 选择合适的鉴权
|
||||
|
||||
client通过负载均衡算法为请求选择好etcd server节点后,client就可调用server的Range RPC方法,把请求发送给etcd server。在此过程中,如果server启用了鉴权,那么就会返回无权限相关错误给client。
|
||||
|
||||
如果server使用的是密码鉴权,你在创建client时,需指定用户名和密码。etcd clientv3库发现用户名、密码非空,就会先校验用户名和密码是否正确。
|
||||
|
||||
client是如何向sever请求校验用户名、密码正确性的呢?
|
||||
|
||||
client是通过向server发送Authenticate RPC鉴权请求实现密码认证的,也就是图中的流程二。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9e/61/9e1fb86567b351641db9586081c0e361.png" alt="">
|
||||
|
||||
根据我们[05](https://time.geekbang.org/column/article/338524)介绍的密码认证原理,server节点收到鉴权请求后,它会从boltdb获取此用户密码对应的算法版本、salt、cost值,并基于用户的请求明文密码计算出一个hash值。
|
||||
|
||||
在得到hash值后,就可以对比db里保存的hash密码是否与其一致了。如果一致,就会返回一个token给client。 这个token是client访问server节点的通行证,后续server只需要校验“通行证”是否有效即可,无需每次发起昂贵的Authenticate RPC请求。
|
||||
|
||||
讲到这里,不知道你有没有意识到,若你的业务在访问etcd过程中未复用token,每次访问etcd都发起一次Authenticate调用,这将是一个非常大的性能瓶颈和隐患。因为正如我们05所介绍的,为了保证密码的安全性,密码认证(Authenticate)的开销非常昂贵,涉及到大量CPU资源。
|
||||
|
||||
那这个Authenticate接口究竟有多慢呢?
|
||||
|
||||
为了得到Authenticate接口的性能,我们做过这样一个测试:
|
||||
|
||||
- 压测集群etcd节点配置是16核32G;
|
||||
- 压测方式是我们通过修改etcd clientv3库、benchmark工具,使benchmark工具支持Authenticate接口压测;
|
||||
- 然后设置不同的client和connection参数,运行多次,观察结果是否稳定,获取测试结果。
|
||||
|
||||
最终的测试结果非常惊人。etcd v3.4.9之前的版本,Authenticate接口性能不到16 QPS,并且随着client和connection增多,该性能会继续恶化。
|
||||
|
||||
当client和connection的数量达到200个的时候,性能会下降到8 QPS,P99延时为18秒,如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bc/c4/bc6336b93de53e6650bd7a5565ef8ec4.png" alt="">
|
||||
|
||||
对此,我和小伙伴王超凡通过一个[减少锁的范围PR](https://github.com/etcd-io/etcd/pull/11735)(该PR已经cherry-pick到了etcd 3.4.9版本),将性能优化到了约200 QPS,并且P99延时在1秒内,如下图所示。
|
||||
|
||||
由于导致Authenticate接口性能差的核心瓶颈,是在于密码鉴权使用了bcrpt计算hash值,因此Authenticate性能已接近极限。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/44/aa/449bb47bef89a7cf1d2fbb1205a15faa.png" alt="">
|
||||
|
||||
最令人头疼的是,Auenticate的调用由clientv3库默默发起的,etcd中也没有任何日志记录其耗时等。当大家开启密码鉴权后,遇到读写接口超时的时候,未详细了解etcd的同学就会非常困惑,很难定位超时本质原因。
|
||||
|
||||
我曾多次收到小伙伴的求助,协助他们排查etcd异常超时问题。通过metrics定位,我发现这些问题大都是由比较频繁的Authenticate调用导致,只要临时关闭鉴权或升级到etcd v3.4.9版本就可以恢复。
|
||||
|
||||
为了帮助大家快速发现Authenticate等特殊类型的expensive request,我在etcd 3.5版本中提交了一个PR,通过gRPC拦截器的机制,当一个请求超过300ms时,就会打印整个请求信息。
|
||||
|
||||
讲到这里,你应该会有疑问,密码鉴权的性能如此差,可是业务又需要使用它,我们该怎么解决密码鉴权的性能问题呢?对此,我有三点建议。
|
||||
|
||||
第一,如果你的生产环境需要开启鉴权,并且读写QPS较大,那我建议你不要图省事使用密码鉴权。最好使用证书鉴权,这样能完美避坑认证性能差、token过期等问题,性能几乎无损失。
|
||||
|
||||
第二,确保你的业务每次发起请求时有复用token机制,尽可能减少Authenticate RPC调用。
|
||||
|
||||
第三,如果你使用密码鉴权时遇到性能瓶颈问题,可将etcd升级到3.4.9及以上版本,能适当提升密码鉴权的性能。
|
||||
|
||||
## 选择合适的读模式
|
||||
|
||||
client通过server的鉴权后,就可以发起读请求调用了,也就是我们图中的流程三。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/58/9a/5832f5da0f916b941b1d832e9fe2e29a.png" alt="">
|
||||
|
||||
在这个步骤中,读模式对性能有着至关重要的影响。我们前面讲过etcd提供了串行读和线性读两种读模式。前者因为不经过ReadIndex模块,具有低延时、高吞吐量的特点;而后者在牺牲一点延时和吞吐量的基础上,实现了数据的强一致性读。这两种读模式分别为不同场景的读提供了解决方案。
|
||||
|
||||
关于串行读和线性读的性能对比,下图我给出了一个测试结果,测试环境如下:
|
||||
|
||||
- 机器配置client 16核32G,三个server节点8核16G、SSD盘,client与server节点都在同可用区;
|
||||
- 各节点之间RTT在0.1ms到0.2ms之间;
|
||||
- etcd v3.4.9版本;
|
||||
- 1000个client。
|
||||
|
||||
执行如下串行读压测命令:
|
||||
|
||||
```
|
||||
benchmark --endpoints=addr --conns=100 --clients=1000 \
|
||||
range hello --consistency=s --total=500000
|
||||
|
||||
```
|
||||
|
||||
得到串行读压测结果如下,32万 QPS,平均延时2.5ms。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3d/9a/3d18aafb016a93e8d2f07a4193cb6b9a.png" alt="">
|
||||
|
||||
执行如下线性读压测命令:
|
||||
|
||||
```
|
||||
benchmark --endpoints=addr --conns=100 --clients=1000 \
|
||||
range hello --consistency=l --total=500000
|
||||
|
||||
```
|
||||
|
||||
得到线性读压测结果如下,19万 QPS,平均延时4.9ms。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/83/0d/831338d142bc44999cc6c3b04147yy0d.png" alt="">
|
||||
|
||||
从两个压测结果图中你可以看到,在100个连接时,串行读性能比线性读性能高近11万/s,串行读请求延时(2.5ms)比线性读延时约低一半(4.9ms)。
|
||||
|
||||
**需要注意的是,以上读性能数据是在1个key、没有任何写请求、同可用区的场景下压测出来的,实际的读性能会随着你的写请求增多而出现显著下降,这也是实际业务场景性能与社区压测结果存在非常大差距的原因之一。**所以,我建议你使用etcd benchmark工具在你的etcd集群环境中自测一下,你也可以参考下面的[etcd社区压测结果](https://etcd.io/docs/v3.4.0/op-guide/performance/)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/58/ca/58135ebf14a25e3f74004929369867ca.png" alt="">
|
||||
|
||||
如果你的业务场景读QPS较大,但是你又不想通过etcd proxy等机制来扩展性能,那你可以进一步评估业务场景对数据一致性的要求高不高。如果你可以容忍短暂的不一致,那你可以通过串行读来提升etcd的读性能,也可以部署Learner节点给可能会产生expensive read request的业务使用,实现cheap/expensive read request隔离。
|
||||
|
||||
## 线性读实现机制、网络延时
|
||||
|
||||
了解完读模式对性能的影响后,我们继续往下分析。在我们这个密码鉴权读请求的性能分析案例中,读请求使用的是etcd默认线性读模式。线性读对应图中的流程四、流程五,其中流程四对应的是ReadIndex,流程五对应的是等待本节点数据追上Leader的进度(ApplyWait)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f0/f1/f018b98629360e7c6eef6f9cfb0241f1.png" alt="">
|
||||
|
||||
在早期的etcd 3.0版本中,etcd线性读是基于Raft log read实现的。每次读请求要像写请求一样,生成一个Raft日志条目,然后提交给Raft一致性模块处理,基于Raft日志执行的有序性来实现线性读。因为该过程需要经过磁盘I/O,所以性能较差。
|
||||
|
||||
为了解决Raft log read的线性读性能瓶颈,etcd 3.1中引入了ReadIndex。ReadIndex仅涉及到各个节点之间网络通信,因此节点之间的RTT延时对其性能有较大影响。虽然同可用区可获取到最佳性能,但是存在单可用区故障风险。如果你想实现高可用区容灾的话,那就必须牺牲一点性能了。
|
||||
|
||||
跨可用区部署时,各个可用区之间延时一般在2毫秒内。如果跨城部署,服务性能就会下降较大。所以一般场景下我不建议你跨城部署,你可以通过Learner节点实现异地容灾。如果异地的服务对数据一致性要求不高,那么你甚至可以通过串行读访问Learner节点,来实现就近访问,低延时。
|
||||
|
||||
各个节点之间的RTT延时,是决定流程四ReadIndex性能的核心因素之一。
|
||||
|
||||
## 磁盘IO性能、写QPS
|
||||
|
||||
到了流程五,影响性能的核心因素就是磁盘IO延时和写QPS。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/73/bc/732ec57338e1yy1d932e959ed776c0bc.png" alt="">
|
||||
|
||||
如下面代码所示,流程五是指节点从Leader获取到最新已提交的日志条目索引(rs.Index)后,它需要等待本节点当前已应用的Raft日志索引,大于等于Leader的已提交索引,确保能在本节点状态机中读取到最新数据。
|
||||
|
||||
```
|
||||
if ai := s.getAppliedIndex(); ai < rs.Index {
|
||||
select {
|
||||
case <-s.applyWait.Wait(rs.Index):
|
||||
case <-s.stopping:
|
||||
return
|
||||
}
|
||||
}
|
||||
// unblock all l-reads requested at indices before rs.Index
|
||||
nr.notify(nil)
|
||||
|
||||
```
|
||||
|
||||
而应用已提交日志条目到状态机的过程中又涉及到随机写磁盘,详情可参考我们[03](https://time.geekbang.org/column/article/336766)中介绍过etcd的写请求原理。
|
||||
|
||||
因此我们可以知道,**etcd是一个对磁盘IO性能非常敏感的存储系统,磁盘IO性能不仅会影响Leader稳定性、写性能表现,还会影响读性能。线性读性能会随着写性能的增加而快速下降。如果业务对性能、稳定性有较大要求,我建议你尽量使用SSD盘。**
|
||||
|
||||
下表我给出了一个8核16G的三节点集群,在总key数只有一个的情况下,随着写请求增大,线性读性能下降的趋势总结(基于benchmark工具压测结果),你可以直观感受下读性能是如何随着写性能下降。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/40/5a/4069e72370942764ef4905715267c05a.jpg" alt="">
|
||||
|
||||
当本节点已应用日志条目索引大于等于Leader已提交的日志条目索引后,读请求就会接到通知,就可通过MVCC模块获取数据。
|
||||
|
||||
## RBAC规则数、Auth锁
|
||||
|
||||
读请求到了MVCC模块后,首先要通过鉴权模块判断此用户是否有权限访问请求的数据路径,也就是流程六。影响流程六的性能因素是你的RBAC规则数和锁。
|
||||
|
||||
首先是RBAC规则数,为了解决快速判断用户对指定key范围是否有权限,etcd为每个用户维护了读写权限区间树。基于区间树判断用户访问的范围是否在用户的读写权限区间内,时间复杂度仅需要O(logN)。
|
||||
|
||||
另外一个因素则是AuthStore的锁。在etcd 3.4.9之前的,校验密码接口可能会占用较长时间的锁,导致授权接口阻塞。etcd 3.4.9之后合入了缩小锁范围的PR,可一定程度降低授权接口被阻塞的问题。
|
||||
|
||||
## expensive request、treeIndex锁
|
||||
|
||||
通过流程六的授权后,则进入流程七,从treeIndex中获取整个查询涉及的key列表版本号信息。在这个流程中,影响其性能的关键因素是treeIndex的总key数、查询的key数、获取treeIndex锁的耗时。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9d/da/9dfe22355a9fd841943fb1c4556db9da.png" alt="">
|
||||
|
||||
首先,treeIndex中总key数过多会适当增大我们遍历的耗时。
|
||||
|
||||
其次,若要访问treeIndex我们必须获取到锁,但是可能其他请求如compact操作也会获取锁。早期的时候,它需要遍历所有索引,然后进行数据压缩工作。这就会导致其他请求阻塞,进而增大延时。
|
||||
|
||||
为了解决这个性能问题,优化方案是compact的时候会将treeIndex克隆一份,以空间来换时间,尽量降低锁阻塞带来的超时问题。
|
||||
|
||||
接下来我重点给你介绍下查询key数较多等expensive read request时对性能的影响。
|
||||
|
||||
假设我们链路分析图中的请求是查询一个Kubernetes集群所有Pod,当你Pod数一百以内的时候可能对etcd影响不大,但是当你Pod数千甚至上万的时候, 流程七、八就会遍历大量的key,导致请求耗时突增、内存上涨、性能急剧下降。你可结合[13](https://time.geekbang.org/column/article/343245)db大小、[14](https://time.geekbang.org/column/article/343645)延时、[15](https://time.geekbang.org/column/article/344621)内存三节一起看看,这里我就不再重复描述。
|
||||
|
||||
如果业务就是有这种expensive read request逻辑,我们该如何应对呢?
|
||||
|
||||
首先我们可以尽量减少expensive read request次数,在程序启动的时候,只List一次全量数据,然后通过etcd Watch机制去获取增量变更数据。比如Kubernetes的Informer机制,就是典型的优化实践。
|
||||
|
||||
其次,在设计上评估是否能进行一些数据分片、拆分等,不同场景使用不同的etcd prefix前缀。比如在Kubernetes中,不要把Pod全部都部署在default命名空间下,尽量根据业务场景按命名空间拆分部署。即便每个场景全量拉取,也只需要遍历自己命名空间下的资源,数据量上将下降一个数量级。
|
||||
|
||||
再次,如果你觉得Watch改造大、数据也无法分片,开发麻烦,你可以通过分页机制按批拉取,尽量减少一次性拉取数万条数据。
|
||||
|
||||
最后,如果以上方式都不起作用的话,你还可以通过引入cache实现缓存expensive read request的结果,不过应用需维护缓存数据与etcd的一致性。
|
||||
|
||||
## 大key-value、boltdb锁
|
||||
|
||||
从流程七获取到key列表及版本号信息后,我们就可以访问boltdb模块,获取key-value信息了。在这个流程中,影响其性能表现的,除了我们上面介绍的expensive read request,还有大key-value和锁。
|
||||
|
||||
首先是大key-value。我们知道etcd设计上定位是个小型的元数据存储,它没有数据分片机制,默认db quota只有2G,实践中往往不会超过8G,并且针对每个key-value大小,它也进行了大小限制,默认是1.5MB。
|
||||
|
||||
大key-value非常容易导致etcd OOM、server 节点出现丢包、性能急剧下降等。
|
||||
|
||||
那么当我们往etcd集群写入一个1MB的key-value时,它的线性读性能会从17万QPS具体下降到多少呢?
|
||||
|
||||
我们可以执行如下benchmark命令:
|
||||
|
||||
```
|
||||
benchmark --endpoints=addr --conns=100 --clients=1000 \
|
||||
range key --consistency=l --total=10000
|
||||
|
||||
```
|
||||
|
||||
得到其结果如下,从下图你可以看到,读取一个1MB的key-value,线性读性能QPS下降到1163,平均延时上升到818ms,可见大key-value对性能的巨大影响。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a0/c7/a0735af4c2efd4156d392f75yyf132c7.png" alt="">
|
||||
|
||||
同时,从下面的etcd监控图上你也可以看到内存出现了突增,若存在大量大key-value时,可想而知,etcd内存肯定暴涨,大概率会OOM。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/95/78/9599ec869c1496e8f9a8e5e54acb5b78.png" alt="">
|
||||
|
||||
其次是锁,etcd为了提升boltdb读的性能,从etcd 3.1到etcd 3.4版本,分别进行过几次重大优化,在下一节中我将和你介绍。
|
||||
|
||||
以上就是一个开启密码鉴权场景,线性读请求的性能瓶颈分析过程。
|
||||
|
||||
## 小结
|
||||
|
||||
今天我通过从上至下的请求流程分析,介绍了各个流程中可能存在的瓶颈和优化方法、最佳实践等。
|
||||
|
||||
优化读性能的核心思路是首先我们可通过etcd clientv3自带的Round-robin负载均衡算法或者Load Balancer,尽量确保整个集群负载均衡。
|
||||
|
||||
然后,在开启鉴权场景时,建议你尽量使用证书而不是密码认证,避免校验密码的昂贵开销。
|
||||
|
||||
其次,根据业务场景选择合适的读模式,串行读比线性度性能提高30%以上,延时降低一倍。线性读性能受节点之间RTT延时、磁盘IO延时、当前写QPS等多重因素影响。
|
||||
|
||||
最容易被大家忽视的就是写QPS对读QPS的影响,我通过一系列压测数据,整理成一个表格,让你更直观感受写QPS对读性能的影响。多可用区部署会导致节点RTT延时增高,读性能下降。因此你需要在高可用和高性能上做取舍和平衡。
|
||||
|
||||
最后在访问数据前,你的读性能还可能会受授权性能、expensive read request、treeIndex及boltdb的锁等影响。你需要遵循最佳实践,避免一个请求查询大量key、大key-value等,否则会导致读性能剧烈下降。
|
||||
|
||||
希望你通过本文当遇到读etcd性能问题时,能从请求执行链路去分析瓶颈,解决问题,让业务和etcd跑得更稳、更快。
|
||||
|
||||
## 思考题
|
||||
|
||||
你在使用etcd过程中遇到了哪些读性能问题?又是如何解决的呢?
|
||||
|
||||
欢迎分享你的性能优化经历,感谢你阅读,也欢迎你把这篇文章分享给更多的朋友一起阅读。
|
||||
244
极客时间专栏/geek/etcd实战课/实践篇/17 | 性能及稳定性(下):如何优化及扩展etcd性能?.md
Normal file
244
极客时间专栏/geek/etcd实战课/实践篇/17 | 性能及稳定性(下):如何优化及扩展etcd性能?.md
Normal file
@@ -0,0 +1,244 @@
|
||||
<audio id="audio" title="17 | 性能及稳定性(下):如何优化及扩展etcd性能?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/11/02/112fcd23e3459f670c0df3469ec61802.mp3"></audio>
|
||||
|
||||
你好,我是唐聪。
|
||||
|
||||
我们继续来看如何优化及扩展etcd性能。上一节课里我为你重点讲述了如何提升读的性能,今天我将重点为你介绍如何提升写性能和稳定性,以及如何基于etcd gRPC Proxy扩展etcd性能。
|
||||
|
||||
当你使用etcd写入大量key-value数据的时候,是否遇到过etcd server返回"etcdserver: too many requests"错误?这个错误是怎么产生的呢?我们又该如何来优化写性能呢?
|
||||
|
||||
这节课我将通过写性能分析链路图,为你从上至下分析影响写性能、稳定性的若干因素,并为你总结出若干etcd写性能优化和扩展方法。
|
||||
|
||||
## 性能分析链路
|
||||
|
||||
为什么你写入大量key-value数据的时候,会遇到Too Many Request限速错误呢? 是写流程中的哪些环节出现了瓶颈?
|
||||
|
||||
和读请求类似,我为你总结了一个开启鉴权场景的写性能瓶颈及稳定性分析链路图,并在每个核心步骤数字旁边标识了影响性能、稳定性的关键因素。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/14/0a/14ac1e7f1936f2def67b7fa24914070a.png" alt="">
|
||||
|
||||
下面我将按照这个写请求链路分析图,和你深入分析影响etcd写性能的核心因素和最佳优化实践。
|
||||
|
||||
## db quota
|
||||
|
||||
首先是流程一。在etcd v3.4.9版本中,client会通过clientv3库的Round-robin负载均衡算法,从endpoint列表中轮询选择一个endpoint访问,发起gRPC调用。
|
||||
|
||||
然后进入流程二。etcd收到gRPC写请求后,首先经过的是Quota模块,它会影响写请求的稳定性,若db大小超过配额就无法写入。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/89/e8/89c9ccbf210861836cc3b5929b7ebae8.png" alt="">
|
||||
|
||||
etcd是个小型的元数据存储,默认db quota大小是2G,超过2G就只读无法写入。因此你需要根据你的业务场景,适当调整db quota大小,并配置的合适的压缩策略。
|
||||
|
||||
正如我在[11](https://time.geekbang.org/column/article/342891)里和你介绍的,etcd支持按时间周期性压缩、按版本号压缩两种策略,建议压缩策略不要配置得过于频繁。比如如果按时间周期压缩,一般情况下5分钟以上压缩一次比较合适,因为压缩过程中会加一系列锁和删除boltdb数据,过于频繁的压缩会对性能有一定的影响。
|
||||
|
||||
一般情况下db大小尽量不要超过8G,过大的db文件和数据量对集群稳定性各方面都会有一定的影响,详细你可以参考[13](https://time.geekbang.org/column/article/343245)。
|
||||
|
||||
## 限速
|
||||
|
||||
通过流程二的Quota模块后,请求就进入流程三KVServer模块。在KVServer模块里,影响写性能的核心因素是限速。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/78/14/78062ff5b8c5863d8802bdfacf32yy14.png" alt="">
|
||||
|
||||
KVServer模块的写请求在提交到Raft模块前,会进行限速判断。如果Raft模块已提交的日志索引(committed index)比已应用到状态机的日志索引(applied index)超过了5000,那么它就返回一个"etcdserver: too many requests"错误给client。
|
||||
|
||||
那么哪些情况可能会导致committed Index远大于applied index呢?
|
||||
|
||||
首先是long expensive read request导致写阻塞。比如etcd 3.4版本之前长读事务会持有较长时间的buffer读锁,而写事务又需要升级锁更新buffer,因此出现写阻塞乃至超时。最终导致etcd server应用已提交的Raft日志命令到状态机缓慢。堆积过多时,则会触发限速。
|
||||
|
||||
其次etcd定时批量将boltdb写事务提交的时候,需要对B+ tree进行重平衡、分裂,并将freelist、dirty page、meta page持久化到磁盘。此过程需要持有boltdb事务锁,若磁盘随机写性能较差、瞬间大量写入,则也容易写阻塞,应用已提交的日志条目缓慢。
|
||||
|
||||
最后执行defrag等运维操作时,也会导致写阻塞,它们会持有相关锁,导致写性能下降。
|
||||
|
||||
## 心跳及选举参数优化
|
||||
|
||||
写请求经过KVServer模块后,则会提交到流程四的Raft模块。我们知道etcd写请求需要转发给Leader处理,因此影响此模块性能和稳定性的核心因素之一是集群Leader的稳定性。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/66/2c/660a03c960cd56610e3c43e15c14182c.png" alt="">
|
||||
|
||||
那如何判断Leader的稳定性呢?
|
||||
|
||||
答案是日志和metrics。
|
||||
|
||||
一方面,在你使用etcd过程中,你很可能见过如下Leader发送心跳超时的警告日志,你可以通过此日志判断集群是否有频繁切换Leader的风险。
|
||||
|
||||
另一方面,你可以通过etcd_server_leader_changes_seen_total metrics来观察已发生Leader切换的次数。
|
||||
|
||||
```
|
||||
21:30:27 etcd3 | {"level":"warn","ts":"2021-02-23T21:30:27.255+0800","caller":"wal/wal.go:782","msg":"slow fdatasync","took":"3.259857956s","expected-duration":"1s"}
|
||||
21:30:30 etcd3 | {"level":"warn","ts":"2021-02-23T21:30:30.396+0800","caller":"etcdserver/raft.go:390","msg":"leader failed to send out heartbeat on time; took too long, leader is overloaded likely from slow disk","to":"91bc3c398fb3c146","heartbeat-interval":"100ms","expected-duration":"200ms","exceeded-duration":"827.162111ms"}
|
||||
|
||||
```
|
||||
|
||||
那么哪些因素会导致此日志产生以及发生Leader切换呢?
|
||||
|
||||
首先,我们知道etcd是基于Raft协议实现数据复制和高可用的,各节点会选出一个Leader,然后Leader将写请求同步给各个Follower节点。而Follower节点如何感知Leader异常,发起选举,正是依赖Leader的心跳机制。
|
||||
|
||||
在etcd中,Leader节点会根据heartbeart-interval参数(默认100ms)定时向Follower节点发送心跳。如果两次发送心跳间隔超过2*heartbeart-interval,就会打印此警告日志。超过election timeout(默认1000ms),Follower节点就会发起新一轮的Leader选举。
|
||||
|
||||
哪些原因会导致心跳超时呢?
|
||||
|
||||
一方面可能是你的磁盘IO比较慢。因为etcd从Raft的Ready结构获取到相关待提交日志条目后,它需要将此消息写入到WAL日志中持久化。你可以通过观察etcd_wal_fsync_durations_seconds_bucket指标来确定写WAL日志的延时。若延时较大,你可以使用SSD硬盘解决。
|
||||
|
||||
另一方面也可能是CPU使用率过高和网络延时过大导致。CPU使用率较高可能导致发送心跳的goroutine出现饥饿。若etcd集群跨地域部署,节点之间RTT延时大,也可能会导致此问题。
|
||||
|
||||
最后我们应该如何调整心跳相关参数,以避免频繁Leader选举呢?
|
||||
|
||||
etcd默认心跳间隔是100ms,较小的心跳间隔会导致发送频繁的消息,消耗CPU和网络资源。而较大的心跳间隔,又会导致检测到Leader故障不可用耗时过长,影响业务可用性。一般情况下,为了避免频繁Leader切换,建议你可以根据实际部署环境、业务场景,将心跳间隔时间调整到100ms到400ms左右,选举超时时间要求至少是心跳间隔的10倍。
|
||||
|
||||
## 网络和磁盘IO延时
|
||||
|
||||
当集群Leader稳定后,就可以进入Raft日志同步流程。
|
||||
|
||||
我们假设收到写请求的节点就是Leader,写请求通过Propose接口提交到Raft模块后,Raft模块会输出一系列消息。
|
||||
|
||||
etcd server的raftNode goroutine通过Raft模块的输出接口Ready,获取到待发送给Follower的日志条目追加消息和待持久化的日志条目。
|
||||
|
||||
raftNode goroutine首先通过HTTP协议将日志条目追加消息广播给各个Follower节点,也就是流程五。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8d/eb/8dd9d414eb4ef3ba9a7603fayy991aeb.png" alt="">
|
||||
|
||||
流程五涉及到各个节点之间网络通信,因此节点之间RTT延时对其性能有较大影响。跨可用区、跨地域部署时性能会出现一定程度下降,建议你结合实际网络环境使用benchmark工具测试一下。etcd Raft网络模块在实现上,也会通过流式发送和pipeline等技术优化来降低延时、提高网络性能。
|
||||
|
||||
同时,raftNode goroutine也会将待持久化的日志条目追加到WAL中,它可以防止进程crash后数据丢失,也就是流程六。注意此过程需要同步等待数据落地,因此磁盘顺序写性能决定着性能优异。
|
||||
|
||||
为了提升写吞吐量,etcd会将一批日志条目批量持久化到磁盘。etcd是个对磁盘IO延时非常敏感的服务,如果服务对性能、稳定性有较大要求,建议你使用SSD盘。
|
||||
|
||||
那使用SSD盘的etcd集群和非SSD盘的etcd集群写性能差异有多大呢?
|
||||
|
||||
下面是SSD盘集群,执行如下benchmark命令的压测结果,写QPS 51298,平均延时189ms。
|
||||
|
||||
```
|
||||
benchmark --endpoints=addr --conns=100 --clients=1000 \
|
||||
put --key-size=8 --sequential-keys --total=10000000 --
|
||||
val-size=256
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/91/14/913e9875ef32df415426a3e5e7cff814.png" alt="">
|
||||
|
||||
下面是非SSD盘集群,执行同样benchmark命令的压测结果,写QPS 35255,平均延时279ms。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/17/2f/1758a57804be463228e6431a388c552f.png" alt="">
|
||||
|
||||
## 快照参数优化
|
||||
|
||||
在Raft模块中,正常情况下,Leader可快速地将我们的key-value写请求同步给其他Follower节点。但是某Follower节点若数据落后太多,Leader内存中的Raft日志已经被compact了,那么Leader只能发送一个快照给Follower节点重建恢复。
|
||||
|
||||
在快照较大的时候,发送快照可能会消耗大量的CPU、Memory、网络资源,那么它就会影响我们的读写性能,也就是我们图中的流程七。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1a/38/1ab7a084e61d84f44b893a0fbbdc0138.png" alt="">
|
||||
|
||||
一方面, etcd Raft模块引入了流控机制,来解决日志同步过程中可能出现的大量资源开销、导致集群不稳定的问题。
|
||||
|
||||
另一方面,我们可以通过快照参数优化,去降低Follower节点通过Leader快照重建的概率,使其尽量能通过增量的日志同步保持集群的一致性。
|
||||
|
||||
etcd提供了一个名为--snapshot-count的参数来控制快照行为。它是指收到多少个写请求后就触发生成一次快照,并对Raft日志条目进行压缩。为了帮助slower Follower赶上Leader进度,etcd在生成快照,压缩日志条目的时候也会至少保留5000条日志条目在内存中。
|
||||
|
||||
那snapshot-count参数设置多少合适呢?
|
||||
|
||||
snapshot-count值过大它会消耗较多内存,你可以参考15内存篇中Raft日志内存占用分析。过小则的话在某节点数据落后时,如果它请求同步的日志条目Leader已经压缩了,此时我们就不得不将整个db文件发送给落后节点,然后进行快照重建。
|
||||
|
||||
快照重建是极其昂贵的操作,对服务质量有较大影响,因此我们需要尽量避免快照重建。etcd 3.2版本之前snapshot-count参数值是1万,比较低,短时间内大量写入就较容易触发慢的Follower节点快照重建流程。etcd 3.2版本后将其默认值调大到10万,老版本升级的时候,你需要注意配置文件是否写死固定的参数值。
|
||||
|
||||
## 大value
|
||||
|
||||
当写请求对应的日志条目被集群多数节点确认后,就可以提交到状态机执行了。etcd的raftNode goroutine就可通过Raft模块的输出接口Ready,获取到已提交的日志条目,然后提交到Apply模块的FIFO待执行队列。因为它是串行应用执行命令,任意请求在应用到状态机时阻塞都会导致写性能下降。
|
||||
|
||||
当Raft日志条目命令从FIFO队列取出执行后,它会首先通过授权模块校验是否有权限执行对应的写操作,对应图中的流程八。影响其性能因素是RBAC规则数和锁。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/53/f6/5303f1b003480d2ddfe7dbd56b05b3f6.png" alt="">
|
||||
|
||||
然后通过权限检查后,写事务则会从treeIndex模块中查找key、更新的key版本号等信息,对应图中的流程九,影响其性能因素是key数和锁。
|
||||
|
||||
更新完索引后,我们就可以把新版本号作为boltdb key, 把用户key/value、版本号等信息组合成一个value,写入到boltdb,对应图中的流程十,影响其性能因素是大value、锁。
|
||||
|
||||
如果你在应用中保存1Mb的value,这会给etcd稳定性带来哪些风险呢?
|
||||
|
||||
首先会导致读性能大幅下降、内存突增、网络带宽资源出现瓶颈等,上节课我已和你分享过一个1MB的key-value读性能压测结果,QPS从17万骤降到1100多。
|
||||
|
||||
那么写性能具体会下降到多少呢?
|
||||
|
||||
通过benchmark执行如下命令写入1MB的数据时候,集群几乎不可用(三节点8核16G,非SSD盘),事务提交P99延时高达4秒,如下图所示。
|
||||
|
||||
```
|
||||
benchmark --endpoints=addr --conns=100 --clients=1000 \
|
||||
put --key-size=8 --sequential-keys --total=500 --val-
|
||||
size=1024000
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0c/bb/0c2635d617245f5d4084fbe48820e4bb.png" alt="">
|
||||
|
||||
因此只能将写入的key-value大小调整为100KB。执行后得到如下结果,写入QPS 仅为1119/S,平均延时高达324ms。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a7/63/a745af37d76208c08be147ac46018463.png" alt="">
|
||||
|
||||
其次etcd底层使用的boltdb存储,它是个基于COW(Copy-on-write)机制实现的嵌入式key-value数据库。较大的value频繁更新,因为boltdb的COW机制,会导致boltdb大小不断膨胀,很容易超过默认db quota值,导致无法写入。
|
||||
|
||||
那如何优化呢?
|
||||
|
||||
首先,如果业务已经使用了大key,拆分、改造存在一定客观的困难,那我们就从问题的根源之一的写入对症下药,尽量不要频繁更新大key,这样etcd db大小就不会快速膨胀。
|
||||
|
||||
你可以从业务场景考虑,判断频繁的更新是否合理,能否做到增量更新。之前遇到一个case, 一个业务定时更新大量key,导致被限速,最后业务通过增量更新解决了问题。
|
||||
|
||||
如果写请求降低不了, 就必须进行精简、拆分你的数据结构了。将你需要频繁更新的数据拆分成小key进行更新等,实现将value值控制在合理范围以内,才能让你的集群跑的更稳、更高效。
|
||||
|
||||
Kubernetes的Node心跳机制优化就是这块一个非常优秀的实践。早期kubelet会每隔10s上报心跳更新Node资源。但是此资源对象较大,导致db大小不断膨胀,无法支撑更大规模的集群。为了解决这个问题,社区做了数据拆分,将经常变更的数据拆分成非常细粒度的对象,实现了集群稳定性提升,支撑住更大规模的Kubernetes集群。
|
||||
|
||||
## boltdb锁
|
||||
|
||||
了解完大value对集群性能的影响后,我们再看影响流程十的另外一个核心因素boltdb锁。
|
||||
|
||||
首先我们回顾下etcd读写性能优化历史,它经历了以下流程:
|
||||
|
||||
- 3.0基于Raft log read实现线性读,线性读需要经过磁盘IO,性能较差;
|
||||
- 3.1基于ReadIndex实现线性读,每个节点只需要向Leader发送ReadIndex请求,不涉及磁盘IO,提升了线性读性能;
|
||||
- 3.2将访问boltdb的锁从互斥锁优化到读写锁,提升了并发读的性能;
|
||||
- 3.4实现全并发读,去掉了buffer锁,长尾读几乎不再影响写。
|
||||
|
||||
并发读特性的核心原理是创建读事务对象时,它会全量拷贝当前写事务未提交的buffer数据,并发的读写事务不再阻塞在一个buffer资源锁上,实现了全并发读。
|
||||
|
||||
最重要的是,写事务也不再因为expensive read request长时间阻塞,有效的降低了写请求的延时,详细测试结果你可以参考[并发读特性实现PR](https://github.com/etcd-io/etcd/pull/10523),因篇幅关系就不再详细描述。
|
||||
|
||||
## 扩展性能
|
||||
|
||||
当然有不少业务场景你即便用最高配的硬件配置,etcd可能还是无法解决你所面临的性能问题。etcd社区也考虑到此问题,提供了一个名为[gRPC proxy](https://etcd.io/docs/v3.4.0/op-guide/grpc_proxy/)的组件,帮助你扩展读、扩展watch、扩展Lease性能的机制,如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4a/b1/4a13ec9a4f93931e6e0656c600c2d3b1.png" alt="">
|
||||
|
||||
### 扩展读
|
||||
|
||||
如果你的client比较多,etcd集群节点连接数大于2万,或者你想平行扩展串行读的性能,那么gRPC proxy就是良好一个解决方案。它是个无状态节点,为你提供高性能的读缓存的能力。你可以根据业务场景需要水平扩容若干节点,同时通过连接复用,降低服务端连接数、负载。
|
||||
|
||||
它也提供了故障探测和自动切换能力,当后端etcd某节点失效后,会自动切换到其他正常节点,业务client可对此无感知。
|
||||
|
||||
### 扩展Watch
|
||||
|
||||
大量的watcher会显著增大etcd server的负载,导致读写性能下降。etcd为了解决这个问题,gRPC proxy组件里面提供了watcher合并的能力。如果多个client Watch同key或者范围(如上图三个client Watch同key)时,它会尝试将你的watcher进行合并,降低服务端的watcher数。
|
||||
|
||||
然后当它收到etcd变更消息时,会根据每个client实际Watch的版本号,将增量的数据变更版本,分发给你的多个client,实现watch性能扩展及提升。
|
||||
|
||||
### 扩展Lease
|
||||
|
||||
我们知道etcd Lease特性,提供了一种客户端活性检测机制。为了确保你的key不被淘汰,client需要定时发送keepalive心跳给server。当Lease非常多时,这就会导致etcd服务端的负载增加。在这种场景下,gRPC proxy提供了keepalive心跳连接合并的机制,来降低服务端负载。
|
||||
|
||||
## 小结
|
||||
|
||||
今天我通过从上至下的写请求流程分析,介绍了各个流程中可能存在的瓶颈和优化方法、最佳实践。最后我从分层的角度,为你总结了一幅优化思路全景图,你可以参考一下下面这张图,它将我们这两节课讨论的etcd性能优化、扩展问题分为了以下几类:
|
||||
|
||||
- 业务应用层,etcd应用层的最佳实践;
|
||||
- etcd内核层,etcd参数最佳实践;
|
||||
- 操作系统层,操作系统优化事项;
|
||||
- 硬件及网络层,不同的硬件设备对etcd性能有着非常大的影响;
|
||||
- 扩展性能,基于gRPC proxy扩展读、Watch、Lease的性能。
|
||||
|
||||
希望你通过这节课的学习,以后在遇到etcd性能问题时,能分别从请求执行链路和分层的视角去分析、优化瓶颈,让业务和etcd跑得更稳、更快。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/92/87/928a4f1e66200531f5ee73aab000ce87.png" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我还给你留了一个思考题。
|
||||
|
||||
watcher较多的情况下,会不会对读写请求性能有影响呢?如果会,是在什么场景呢?gRPC proxy能安全的解决watcher较多场景下的扩展性问题吗?
|
||||
|
||||
欢迎分享你的性能优化经历,感谢你阅读,也欢迎你把这篇文章分享给更多的朋友一起阅读。
|
||||
@@ -0,0 +1,455 @@
|
||||
<audio id="audio" title="18 | 实战:如何基于Raft从0到1构建一个支持多存储引擎分布式KV服务?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/85/11/859d136469cbee1454035d0086fe1011.mp3"></audio>
|
||||
|
||||
你好,我是唐聪。
|
||||
|
||||
通过前面课程的学习,我相信你已经对etcd基本架构、核心特性有了一定理解。如果让你基于Raft协议,实现一个简易的类etcd、支持多存储引擎的分布式KV服务,并能满足读多写少、读少写多的不同业务场景诉求,你知道该怎么动手吗?
|
||||
|
||||
纸上得来终觉浅,绝知此事要躬行。
|
||||
|
||||
今天我就和你聊聊如何实现一个类etcd、支持多存储引擎的KV服务,我们将基于etcd自带的[raftexample](https://github.com/etcd-io/etcd/tree/v3.4.9/contrib/raftexample)项目快速构建它。
|
||||
|
||||
为了方便后面描述,我把它命名为metcd(表示微型的etcd),它是raftexample的加强版。希望通过metcd这个小小的实战项目,能够帮助你进一步理解etcd乃至分布式存储服务的核心架构、原理、典型问题解决方案。
|
||||
|
||||
同时在这个过程中,我将详细为你介绍etcd的Raft算法工程实现库、不同类型存储引擎的优缺点,拓宽你的知识视野,为你独立分析etcd源码,夯实基础。
|
||||
|
||||
## 整体架构设计
|
||||
|
||||
在和你深入聊代码细节之前,首先我和你从整体上介绍下系统架构。
|
||||
|
||||
下面是我给你画的metcd整体架构设计,它由API层、Raft层的共识模块、逻辑层及存储层组成的状态机组成。
|
||||
|
||||
接下来,我分别和你简要分析下API设计及复制状态机。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5e/03/5e9f6882a6f6e357e5c2c5yyffda4e03.png" alt="">
|
||||
|
||||
### API设计
|
||||
|
||||
API是软件系统对外的语言,它是应用编程接口的缩写,由一组接口定义和协议组成。
|
||||
|
||||
在设计API的时候,我们往往会考虑以下几个因素:
|
||||
|
||||
- 性能。如etcd v2使用的是简单的HTTP/1.x,性能上无法满足大规模Kubernetes集群等场景的诉求,因此etcd v3使用的是基于HTTP/2的gRPC协议。
|
||||
- 易用性、可调试性。如有的内部高并发服务为了满足性能等诉求,使用的是UDP协议。相比HTTP协议,UDP协议显然在易用性、可调试性上存在一定的差距。
|
||||
- 开发效率、跨平台、可移植性。相比基于裸UDP、TCP协议设计的接口,如果你使用Protobuf等IDL语言,它支持跨平台、代码自动自动生成,开发效率更高。
|
||||
- 安全性。如相比HTTP协议,使用HTTPS协议可对通信数据加密更安全,可适用于不安全的网络环境(比如公网传输)。
|
||||
- 接口幂等性。幂等性简单来说,就是同样一个接口请求一次与多次的效果一样。若你的接口对外保证幂等性,则可降低使用者的复杂度。
|
||||
|
||||
因为我们场景的是POC(Proof of concept)、Demo开发,因此在metcd项目中,我们优先考虑点是易用性、可调试性,选择HTTP/1.x协议,接口上为了满足key-value操作,支持Get和Put接口即可。
|
||||
|
||||
假设metcd项目使用3379端口,Put和Get接口,如下所示。
|
||||
|
||||
- Put接口,设置key-value
|
||||
|
||||
```
|
||||
curl -L http://127.0.0.1:3379/hello -XPUT -d world
|
||||
|
||||
```
|
||||
|
||||
- Get接口,查询key-value
|
||||
|
||||
```
|
||||
curl -L http://127.0.0.1:3379/hello
|
||||
world
|
||||
|
||||
```
|
||||
|
||||
### 复制状态机
|
||||
|
||||
了解完API设计,那最核心的复制状态机是如何工作的呢?
|
||||
|
||||
我们知道etcd是基于下图复制状态机实现的分布式KV服务,复制状态机由共识模块、日志模块、状态机组成。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5c/4f/5c7a3079032f90120a6b309ee401fc4f.png" alt="">
|
||||
|
||||
我们的实战项目metcd,也正是使用与之一样的模型,并且使用etcd项目中实现的Raft算法库作为共识模块,此算法库已被广泛应用在etcd、cockroachdb、dgraph等开源项目中。
|
||||
|
||||
以下是复制状态机的写请求流程:
|
||||
|
||||
- client发起一个写请求(put hello = world);
|
||||
- server向Raft共识模块提交请求,共识模块生成一个写提案日志条目。若server是Leader,则把日志条目广播给其他节点,并持久化日志条目到WAL中;
|
||||
- 当一半以上节点持久化日志条目后,Leader的共识模块将此日志条目标记为已提交(committed),并通知其他节点提交;
|
||||
- server从共识模块获取已经提交的日志条目,异步应用到状态机存储中(boltdb/leveldb/memory),然后返回给client。
|
||||
|
||||
### 多存储引擎
|
||||
|
||||
了解完复制状态机模型后,我和你再深入介绍下状态机。状态机中最核心模块当然是存储引擎,那要如何同时支持多种存储引擎呢?
|
||||
|
||||
metcd项目将基于etcd本身自带的raftexample项目进行快速开发,而raftexample本身只支持内存存储。
|
||||
|
||||
因此我们通过将KV存储接口进行抽象化设计,实现支持多存储引擎。KVStore interface的定义如下所示。
|
||||
|
||||
```
|
||||
type KVStore interface {
|
||||
// LookUp get key value
|
||||
Lookup(key string) (string, bool)
|
||||
|
||||
// Propose propose kv request into raft state machine
|
||||
Propose(k, v string)
|
||||
|
||||
// ReadCommits consume entry from raft state machine into KvStore map until error
|
||||
ReadCommits(commitC <-chan *string, errorC <-chan error)
|
||||
|
||||
// Snapshot return KvStore snapshot
|
||||
Snapshot() ([]byte, error)
|
||||
|
||||
// RecoverFromSnapshot recover data from snapshot
|
||||
RecoverFromSnapshot(snapshot []byte) error
|
||||
|
||||
// Close close backend databases
|
||||
Close() err
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
基于KV接口抽象化的设计,我们只需要针对具体的存储引擎,实现对应的操作即可。
|
||||
|
||||
我们期望支持三种存储引擎,分别是内存map、boltdb、leveldb,并做一系列简化设计。一组metcd实例,通过metcd启动时的配置来决定使用哪种存储引擎。不同业务场景不同实例,比如读多写少的存储引擎可使用boltdb,写多读少的可使用leveldb。
|
||||
|
||||
接下来我和你重点介绍下存储引擎的选型及原理。
|
||||
|
||||
#### boltdb
|
||||
|
||||
boltdb是一个基于B+ tree实现的存储引擎库,在[10](https://time.geekbang.org/column/article/342527?utm_term=zeus18YAD&utm_source=app&utm_medium=geektime)中我已和你详细介绍过原理。
|
||||
|
||||
boltdb为什么适合读多写少?
|
||||
|
||||
对于读请求而言,一般情况下它可直接从内存中基于B+ tree遍历,快速获取数据返回给client,不涉及经过磁盘I/O。
|
||||
|
||||
对于写请求,它基于B+ tree查找写入位置,更新key-value。事务提交时,写请求包括B+ tree重平衡、分裂、持久化ditry page、持久化freelist、持久化meta page流程。同时,ditry page可能分布在文件的各个位置,它发起的是随机写磁盘I/O。
|
||||
|
||||
因此在boltdb中,完成一个写请求的开销相比读请求是大很多的。正如我在[16](https://time.geekbang.org/column/article/345588)和[17](https://time.geekbang.org/column/article/346471)中给你介绍的一样,一个3节点的8核16G空集群,线性读性能可以达到19万QPS,而写QPS仅为5万。
|
||||
|
||||
#### leveldb
|
||||
|
||||
那要如何设计适合写多读少的存储引擎呢?
|
||||
|
||||
最简单的思路当然是写内存最快。可是内存有限的,无法支撑大容量的数据存储,不持久化数据会丢失。
|
||||
|
||||
那能否直接将数据顺序追加到文件末尾(AOF)呢?因为磁盘的特点是顺序写性能比较快。
|
||||
|
||||
当然可以。[Bitcask](https://en.wikipedia.org/wiki/Bitcask)存储模型就是采用AOF模式,把写请求顺序追加到文件。Facebook的图片存储[Haystack](https://www.usenix.org/legacy/event/osdi10/tech/full_papers/Beaver.pdf)根据其论文介绍,也是使用类似的方案来解决大规模写入痛点。
|
||||
|
||||
那在AOF写入模型中如何实现查询数据呢?
|
||||
|
||||
很显然通过遍历文件一个个匹配key是可以的,但是它的性能是极差的。为了实现高性能的查询,最理想的解决方案从直接从内存中查询,但是内存是有限的,那么我们能否通过内存索引来记录一个key-value数据在文件中的偏移量,实现从磁盘快速读取呢?
|
||||
|
||||
是的,这正是[Bitcask](https://en.wikipedia.org/wiki/Bitcask)存储模型的查询的实现,它通过内存哈希表维护各个key-value数据的索引,实现了快速查找key-value数据。不过,内存中虽然只保存key索引信息,但是当key较多的时候,其对内存要求依然比较高。
|
||||
|
||||
快速了解完存储引擎提升写性能的核心思路(随机写转化为顺序写)之后,那leveldb它的原理是怎样的呢?与Bitcask存储模型有什么不一样?
|
||||
|
||||
leveldb是基于LSM tree(log-structured merge-tree)实现的key-value存储,它的架构如下图所示([引用自微软博客](https://microsoft.github.io/MLOS/notebooks/LevelDbTuning/))。
|
||||
|
||||
它提升写性能的核心思路同样是将随机写转化为顺序写磁盘WAL文件和内存,结合了我们上面讨论的写内存和磁盘两种方法。数据持久化到WAL文件是为了确保机器crash后数据不丢失。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/05/50/05f01951fe5862a62624b81e2ceea150.png" alt="">
|
||||
|
||||
那么它要如何解决内存不足和查询的痛点问题呢?
|
||||
|
||||
核心解决方案是分层的设计和基于一系列对象的转换和压缩。接下来我给你分析一下上面架构图写流程和后台compaction任务:
|
||||
|
||||
- 首先写请求顺序写入Log文件(WAL);
|
||||
- 更新内存的Memtable。leveldb Memtable后端数据结构实现是skiplist,skiplist相比平衡二叉树,实现简单却同样拥有高性能的读写;
|
||||
- 当Memtable达到一定的阈值时,转换成不可变的Memtable,也就是只读不可写;
|
||||
- leveldb后台Compact任务会将不可变的Memtable生成SSTable文件,它有序地存储一系列key-value数据。注意SST文件按写入时间进行了分层,Level层次越小数据越新。Manifest文件记录了各个SSTable文件处于哪个层级、它的最小与最大key范围;
|
||||
- 当某个level下的SSTable文件数目超过一定阈值后,Compact任务会从这个level的SSTable中选择一个文件(level>0),将其和高一层级的level+1的SSTable文件合并;
|
||||
- 注意level 0是由Immutable直接生成的,因此level 0 SSTable文件中的key-value存在相互重叠。而level > 0时,在和更高一层SSTable合并过程中,参与的SSTable文件是多个,leveldb会确保各个SSTable中的key-value不重叠。
|
||||
|
||||
了解完写流程,读流程也就简单了,核心步骤如下:
|
||||
|
||||
- 从Memtable跳跃表中查询key;
|
||||
- 未找到则从Immutable中查找;
|
||||
- Immutable仍未命中,则按照leveldb的分层属性,因level 0 SSTable文件是直接从Immutable生成的,level 0存在特殊性,因此你需要从level 0遍历SSTable查找key;
|
||||
- level 0中若未命中,则从level 1乃至更高的层次查找。level大于0时,各个SSTable中的key是不存在相互重叠的。根据manifest记录的key-value范围信息,可快递定位到具体的SSTable。同时leveldb基于[bloom filter](https://en.wikipedia.org/wiki/Bloom_filter)实现了快速筛选SSTable,因此查询效率较高。
|
||||
|
||||
更详细原理你可以参考一下[leveldb](https://github.com/google/leveldb)源码。
|
||||
|
||||
## 实现分析
|
||||
|
||||
从API设计、复制状态机、多存储引擎支持等几个方面你介绍了metcd架构设计后,接下来我就和你重点介绍下共识模块、状态机支持多存储引擎模块的核心实现要点。
|
||||
|
||||
### Raft算法库
|
||||
|
||||
共识模块使用的是etcd [Raft算法库](https://github.com/etcd-io/etcd/tree/v3.4.9/raft),它是一个经过大量业务生产环境检验、具备良好可扩展性的共识算法库。
|
||||
|
||||
它提供了哪些接口给你使用? 如何提交一个提案,并且获取Raft共识模块输出结果呢?
|
||||
|
||||
#### Raft API
|
||||
|
||||
Raft作为一个库,它对外最核心的对象是一个名为[Node](https://github.com/etcd-io/etcd/blob/v3.4.9/raft/node.go#L125:L203)的数据结构。Node表示Raft集群中的一个节点,它的输入与输出接口如下图所示,下面我重点和你介绍它的几个接口功能:
|
||||
|
||||
- Campaign,状态转换成Candidate,发起新一轮Leader选举;
|
||||
- Propose,提交提案接口;
|
||||
- Ready,Raft状态机输出接口,它的返回是一个输出Ready数据结构类型的管道,应用需要监听此管道,获取Ready数据,处理其中的各个消息(如持久化未提交的日志条目到WAL中,发送消息给其他节点等);
|
||||
- Advance,通知Raft状态机,应用已处理上一个输出的Ready数据,等待发送下一个Ready数据;
|
||||
- TransferLeaderShip,尝试将Leader转移到某个节点;
|
||||
- Step,向Raft状态机提交收到的消息,比如当Leader广播完MsgApp消息给Follower节点后,Leader收到Follower节点回复的MsgAppResp消息时,就通过Step接口将此消息提交给Raft状态机驱动其工作;
|
||||
- ReadIndex,用于实现线性读。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a7/39/a79a97f8cc8294dcb93f9552fb638f39.png" alt="">
|
||||
|
||||
上面提到的Raft状态机的输出[Ready结构](https://github.com/etcd-io/etcd/blob/v3.4.9/raft/node.go#L52:L90)含有哪些信息呢? 下图是其详细字段,含义如下:
|
||||
|
||||
- SoftState,软状态。包括集群Leader和节点状态,不需要持久化到WAL;
|
||||
- pb.HardState,硬状态。与软状态相反,包括了节点当前Term、Vote等信息,需要持久化到WAL中;
|
||||
- ReadStates,用于线性一致性读;
|
||||
- Entries,在向其他节点发送消息之前需持久化到WAL中;
|
||||
- Messages,持久化Entries后,发送给其他节点的消息;
|
||||
- Committed Entries,已提交的日志条目,需要应用到存储状态机中;
|
||||
- Snapshot,快照需保存到持久化存储中;
|
||||
- MustSync,HardState和Entries是否要持久化到WAL中;
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c0/d6/c0f0b8046a7c8c67c277fed9548251d6.png" alt="">
|
||||
|
||||
了解完API后,我们接下来继续看看代码如何使用Raft的Node API。
|
||||
|
||||
正如我在[04](https://time.geekbang.org/column/article/337604)中和你介绍的,etcd Raft库的设计抽象了网络、Raft日志存储等模块,它本身并不会进行网络、存储相关的操作,上层应用需结合自己业务场景选择内置的模块或自定义实现网络、存储、日志等模块。
|
||||
|
||||
因此我们在使用Raft库时,需要先自定义好相关网络、存储等模块,再结合上面介绍的Raft Node API,就可以完成一个Node的核心操作了。其数据结构定义如下:
|
||||
|
||||
```
|
||||
// A key-value stream backed by raft
|
||||
type raftNode struct {
|
||||
proposeC <-chan string // proposed messages (k,v)
|
||||
confChangeC <-chan raftpb.ConfChange // proposed cluster config changes
|
||||
commitC chan<- *string // entries committed to log (k,v)
|
||||
errorC chan<- error // errors from raft session
|
||||
id int // client ID for raft session
|
||||
......
|
||||
node raft.Node
|
||||
raftStorage *raft.MemoryStorage
|
||||
wal *wal.WAL
|
||||
transport *rafthttp.Transport
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个数据结构名字叫raftNode,它表示Raft集群中的一个节点。它是由我们业务应用层设计的一个组合结构。从结构体定义中你可以看到它包含了Raft核心数据结构Node(raft.Node)、Raft日志条目内存存储模块(raft.MemoryStorage)、WAL持久化模块(wal.WAL)以及网络模块(rafthttp.Transport)。
|
||||
|
||||
同时,它提供了三个核心的管道与业务逻辑模块、存储状态机交互:
|
||||
|
||||
- proposeC,它用来接收client发送的写请求提案消息;
|
||||
- confChangeC,它用来接收集群配置变化消息;
|
||||
- commitC,它用来输出Raft共识模块已提交的日志条目消息。
|
||||
|
||||
在metcd项目中因为我们是直接基于raftexample定制开发,因此日志持久化存储、网络都使用的是etcd自带的WAL和rafthttp模块。
|
||||
|
||||
[WAL](https://github.com/etcd-io/etcd/blob/v3.4.9/wal/wal.go)模块中提供了核心的保存未持久化的日志条目和快照功能接口,你可以参考[03](https://time.geekbang.org/column/article/336766)节写请求中我和你介绍的原理。
|
||||
|
||||
[rafthttp](https://github.com/etcd-io/etcd/tree/v3.4.9/etcdserver/api/rafthttp)模块基于HTTP协议提供了各个节点间的消息发送能力,metcd使用如下:
|
||||
|
||||
```
|
||||
rc.transport = &rafthttp.Transport{
|
||||
Logger: zap.NewExample(),
|
||||
ID: types.ID(rc.id),
|
||||
ClusterID: 0x1000,
|
||||
Raft: rc,
|
||||
ServerStats: stats.NewServerStats("", ""),
|
||||
LeaderStats: stats.NewLeaderStats(strconv.Itoa(rc.id)),
|
||||
ErrorC: make(chan error),
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
搞清楚Raft模块的输入、输出API,设计好raftNode结构,复用etcd的WAL、网络等模块后,接下来我们就只需要实现如下两个循环逻辑,处理业务层发送给proposeC和confChangeC消息、将Raft的Node输出Ready结构进行相对应的处理即可。精简后的代码如下所示:
|
||||
|
||||
```
|
||||
func (rc *raftNode) serveChannels() {
|
||||
// send proposals over raft
|
||||
go func() {
|
||||
confChangeCount := uint64(0)
|
||||
for rc.proposeC != nil && rc.confChangeC != nil {
|
||||
select {
|
||||
case prop, ok := <-rc.proposeC:
|
||||
if !ok {
|
||||
rc.proposeC = nil
|
||||
} else {
|
||||
// blocks until accepted by raft state machine
|
||||
rc.node.Propose(context.TODO(), []byte(prop))
|
||||
}
|
||||
|
||||
case cc, ok := <-rc.confChangeC:
|
||||
if !ok {
|
||||
rc.confChangeC = nil
|
||||
} else {
|
||||
confChangeCount++
|
||||
cc.ID = confChangeCount
|
||||
rc.node.ProposeConfChange(context.TODO(), cc)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// event loop on raft state machine updates
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
rc.node.Tick()
|
||||
|
||||
// store raft entries to wal, then publish over commit channel
|
||||
case rd := <-rc.node.Ready():
|
||||
rc.wal.Save(rd.HardState, rd.Entries)
|
||||
if !raft.IsEmptySnap(rd.Snapshot) {
|
||||
rc.saveSnap(rd.Snapshot)
|
||||
rc.raftStorage.ApplySnapshot(rd.Snapshot)
|
||||
rc.publishSnapshot(rd.Snapshot)
|
||||
}
|
||||
rc.raftStorage.Append(rd.Entries)
|
||||
rc.transport.Send(rd.Messages)
|
||||
if ok := rc.publishEntries(rc.entriesToApply(rd.CommittedEntries)); !ok {
|
||||
rc.stop()
|
||||
return
|
||||
}
|
||||
rc.maybeTriggerSnapshot()
|
||||
rc.node.Advance()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
代码简要分析如下:
|
||||
|
||||
- 从proposeC中取出提案消息,通过raft.Node.Propose API提交提案;
|
||||
- 从confChangeC取出配置变更消息,通过raft.Node.ProposeConfChange API提交配置变化消息;
|
||||
- 从raft.Node中获取Raft算法状态机输出到Ready结构中,将rd.Entries和rd.HardState通过WAL模块持久化,将rd.Messages通过rafthttp模块,发送给其他节点。将rd.CommittedEntries应用到业务存储状态机。
|
||||
|
||||
以上就是Raft实现的核心流程,接下来我来和你聊聊业务存储状态机。
|
||||
|
||||
### 支持多存储引擎
|
||||
|
||||
在整体架构设计时,我和你介绍了为了使metcd项目能支撑多存储引擎,我们将KVStore进行了抽象化设计,因此我们只需要实现各个存储引擎相对应的API即可。
|
||||
|
||||
这里我以Put接口为案例,分别给你介绍下各个存储引擎的实现。
|
||||
|
||||
#### boltdb
|
||||
|
||||
首先是boltdb存储引擎,它的实现如下,你也可以去[10](https://time.geekbang.org/column/article/342527)里回顾一下它的API和原理。
|
||||
|
||||
```
|
||||
func (s *boltdbKVStore) Put(key, value string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
// Start a writable transaction.
|
||||
tx, err := s.db.Begin(true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Use the transaction...
|
||||
bucket, err := tx.CreateBucketIfNotExists([]byte("keys"))
|
||||
if err != nil {
|
||||
log.Printf("failed to put key %s, value %s, err is %v", key, value, err)
|
||||
return err
|
||||
}
|
||||
err = bucket.Put([]byte(key), []byte(value))
|
||||
if err != nil {
|
||||
log.Printf("failed to put key %s, value %s, err is %v", key, value, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Commit the transaction and check for error.
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.Printf("failed to commit transaction, key %s, err is %v", key, err)
|
||||
return err
|
||||
}
|
||||
log.Printf("backend:%s,put key:%s,value:%s succ", s.config.backend, key, value)
|
||||
return nil
|
||||
|
||||
```
|
||||
|
||||
#### leveldb
|
||||
|
||||
其次是leveldb,我们使用的是[goleveldb](https://github.com/syndtr/goleveldb),它基于Google开源的c++ [leveldb](https://github.com/google/leveldb)版本实现。它提供的常用API如下所示。
|
||||
|
||||
- 通过OpenFile API创建或打开一个leveldb数据库。
|
||||
|
||||
```
|
||||
db, err := leveldb.OpenFile("path/to/db", nil)
|
||||
...
|
||||
defer db.Close()
|
||||
|
||||
```
|
||||
|
||||
- 通过DB.Get/Put/Delete API操作数据。
|
||||
|
||||
```
|
||||
data, err := db.Get([]byte("key"), nil)
|
||||
...
|
||||
err = db.Put([]byte("key"), []byte("value"), nil)
|
||||
...
|
||||
err = db.Delete([]byte("key"), nil)
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
了解其接口后,通过goleveldb的库,client调用就非常简单了,下面是metcd项目中,leveldb存储引擎Put接口的实现。
|
||||
|
||||
```
|
||||
func (s *leveldbKVStore) Put(key, value string) error {
|
||||
err := s.db.Put([]byte(key), []byte(value), nil)
|
||||
if err != nil {
|
||||
log.Printf("failed to put key %s, value %s, err is %v", key, value, err)
|
||||
return err
|
||||
}
|
||||
log.Printf("backend:%s,put key:%s,value:%s succ", s.config.backend, key, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### 读写流程
|
||||
|
||||
介绍完在metcd项目中如何使用Raft共识模块、支持多存储引擎后,我们再从整体上介绍下在metcd中写入和读取一个key-value的流程。
|
||||
|
||||
#### 写流程
|
||||
|
||||
当你通过如下curl命令发起一个写操作时,写流程如下面架构图序号所示:
|
||||
|
||||
```
|
||||
curl -L http://127.0.0.1:3379/hello -XPUT -d world
|
||||
|
||||
|
||||
```
|
||||
|
||||
- client通过curl发送HTTP PUT请求到server;
|
||||
- server收到后,将消息写入到KVStore的ProposeC管道;
|
||||
- raftNode循环逻辑将消息通过Raft模块的Propose接口提交;
|
||||
- Raft模块输出Ready结构,server将日志条目持久化后,并发送给其他节点;
|
||||
- 集群多数节点持久化此日志条目后,这个日志条目被提交给存储状态机KVStore执行;
|
||||
- KVStore根据启动的backend存储引擎名称,调用对应的Put接口即可。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9b/c1/9b84a7e312165de46749e1c4046fc9c1.png" alt="">
|
||||
|
||||
#### 读流程
|
||||
|
||||
当你通过如下curl命令发起一个读操作时,读流程如下面架构图序号所示:
|
||||
|
||||
```
|
||||
curl -L http://127.0.0.1:3379/hello
|
||||
world
|
||||
|
||||
```
|
||||
|
||||
- client通过curl发送HTTP Get请求到server;
|
||||
- server收到后,根据KVStore的存储引擎,从后端查询出对应的key-value数据。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/17/b2/1746fbd9e9435d8607e44bea2d2c39b2.png" alt="">
|
||||
|
||||
## 小结
|
||||
|
||||
最后,我来总结下我们今天的内容。我这节课分别从整体架构设计和实现分析,给你介绍了如何基于Raft从0到1构建一个支持多存储引擎的分布式key-value数据库。
|
||||
|
||||
在整体架构设计上,我给你介绍了API设计核心因素,它们分别是性能、易用性、开发效率、安全性、幂等性。其次我和你介绍了复制状态机的原理,它由共识模块、日志模块、存储状态机模块组成。最后我和你深入分析了多存储引擎设计,重点介绍了leveldb原理,它将随机写转换为顺序写日志和内存,通过一系列分层、创新的设计实现了优异的写性能,适合读少写多。
|
||||
|
||||
在实现分析上,我和你重点介绍了Raft算法库的核心对象Node API。对于一个库而言,我们重点关注的是其输入、输出接口,业务逻辑层可通过Propose接口提交提案,通过Ready结构获取Raft算法状态机的输出内容。其次我和你介绍了Raft算法库如何与WAL模块、Raft日志存储模块、网络模块协作完成一个写请求。
|
||||
|
||||
最后为了支持多存储引擎,我们分别基于boltdb、leveldb实现了KVStore相关接口操作,并通过读写流程图,从整体上为你介绍了一个读写请求在metcd中是如何工作的。
|
||||
|
||||
麻雀虽小,五脏俱全。希望能通过这个迷你项目解答你对如何构建一个简易分布式KV服务的疑问,以及让你对etcd的工作原理有更深的理解。
|
||||
|
||||
## 思考题
|
||||
|
||||
你知道[raftexample](https://github.com/etcd-io/etcd/tree/v3.4.9/contrib/raftexample)启动的时候是如何工作的吗?它的存储引擎内存map是如何保证数据不丢失的呢?
|
||||
|
||||
感谢你的阅读,如果你认为这节课的内容有收获,也欢迎把它分享给你的朋友,我们下一讲见。
|
||||
@@ -0,0 +1,436 @@
|
||||
<audio id="audio" title="19 | Kubernetes基础应用:创建一个Pod背后etcd发生了什么?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/24/f6/24c38d4fd64c427f396abedb7ee608f6.mp3"></audio>
|
||||
|
||||
你好,我是唐聪。
|
||||
|
||||
今天我将通过在Kubernetes集群中创建一个Pod的案例,为你分析etcd在其中发挥的作用,带你深入了解Kubernetes是如何使用etcd的。
|
||||
|
||||
希望通过本节课,帮助你从etcd的角度更深入理解Kubernetes,让你知道在Kubernetes集群中每一步操作的背后,etcd会发生什么。更进一步,当你在Kubernetes集群中遇到etcd相关错误的时候,能从etcd角度理解错误含义,高效进行故障诊断。
|
||||
|
||||
## Kubernetes基础架构
|
||||
|
||||
在带你详细了解etcd在Kubernetes里的应用之前,我先和你简单介绍下Kubernetes集群的整体架构,帮你搞清楚etcd在Kubernetes集群中扮演的角色与作用。
|
||||
|
||||
下图是Kubernetes集群的架构图([引用自Kubernetes官方文档](https://kubernetes.io/docs/concepts/overview/components/)),从图中你可以看到,它由Master节点和Node节点组成。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b1/c0/b13d665a0e5be852c050d09c8602e4c0.png" alt="">
|
||||
|
||||
控制面Master节点主要包含以下组件:
|
||||
|
||||
- kube-apiserver,负责对外提供集群各类资源的增删改查及Watch接口,它是Kubernetes集群中各组件数据交互和通信的枢纽。kube-apiserver在设计上可水平扩展,高可用Kubernetes集群中一般多副本部署。当收到一个创建Pod写请求时,它的基本流程是对请求进行认证、限速、授权、准入机制等检查后,写入到etcd即可。
|
||||
- kube-scheduler是调度器组件,负责集群Pod的调度。基本原理是通过监听kube-apiserver获取待调度的Pod,然后基于一系列筛选和评优算法,为Pod分配最佳的Node节点。
|
||||
- kube-controller-manager包含一系列的控制器组件,比如Deployment、StatefulSet等控制器。控制器的核心思想是监听、比较资源实际状态与期望状态是否一致,若不一致则进行协调工作使其最终一致。
|
||||
- etcd组件,Kubernetes的元数据存储。
|
||||
|
||||
Node节点主要包含以下组件:
|
||||
|
||||
- kubelet,部署在每个节点上的Agent的组件,负责Pod的创建运行。基本原理是通过监听APIServer获取分配到其节点上的Pod,然后根据Pod的规格详情,调用运行时组件创建pause和业务容器等。
|
||||
- kube-proxy,部署在每个节点上的网络代理组件。基本原理是通过监听APIServer获取Service、Endpoint等资源,基于Iptables、IPVS等技术实现数据包转发等功能。
|
||||
|
||||
从Kubernetes基础架构介绍中你可以看到,kube-apiserver是唯一直接与etcd打交道的组件,各组件都通过kube-apiserver实现数据交互,它们极度依赖kube-apiserver提供的资源变化**监听机制**。而kube-apiserver对外提供的监听机制,也正是由我们基础篇[08](https://time.geekbang.org/column/article/341060)中介绍的etcd **Watch特性**提供的底层支持。
|
||||
|
||||
## 创建Pod案例
|
||||
|
||||
接下来我们就以在Kubernetes集群中创建一个nginx服务为例,通过这个案例来详细分析etcd在Kubernetes集群创建Pod背后是如何工作的。
|
||||
|
||||
下面是创建一个nginx服务的YAML文件,Workload是Deployment,期望的副本数是1。
|
||||
|
||||
```
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: nginx-deployment
|
||||
labels:
|
||||
app: nginx
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: nginx
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: nginx
|
||||
spec:
|
||||
containers:
|
||||
- name: nginx
|
||||
image: nginx:1.14.2
|
||||
ports:
|
||||
- containerPort: 80
|
||||
|
||||
```
|
||||
|
||||
假设此YAML文件名为nginx.yaml,首先我们通过如下的kubectl create -f nginx.yml命令创建Deployment资源。
|
||||
|
||||
```
|
||||
$ kubectl create -f nginx.yml
|
||||
deployment.apps/nginx-deployment created
|
||||
|
||||
```
|
||||
|
||||
创建之后,我们立刻通过如下命令,带标签查询Pod,输出如下:
|
||||
|
||||
```
|
||||
$ kubectl get po -l app=nginx
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
nginx-deployment-756d9fd5f9-fkqnf 1/1 Running 0 8s
|
||||
|
||||
```
|
||||
|
||||
那么在kubectl create命令发出,nginx Deployment资源成功创建的背后,kube-apiserver是如何与etcd打交道的呢? 它是通过什么接口**安全写入**资源到etcd的?
|
||||
|
||||
同时,使用kubectl带标签查询Pod背后,kube-apiserver是直接从**缓存读取**还是向etcd发出一个**线性读**或**串行读**请求呢? 若同namespace下存在大量的Pod,此操作性能又是怎样的呢?
|
||||
|
||||
接下来我就和你聊聊kube-apiserver收到创建和查询请求后,是如何与etcd交互的。
|
||||
|
||||
## kube-apiserver请求执行链路
|
||||
|
||||
kube-apiserver作为Kubernetes集群交互的枢纽、对外提供API供用户访问的组件,因此保障集群安全、保障本身及后端etcd的稳定性的等重任也是非它莫属。比如校验创建请求发起者是否合法、是否有权限操作相关资源、是否出现Bug产生大量写和读请求等。
|
||||
|
||||
[下图是kube-apiserver的请求执行链路](https://speakerdeck.com/sttts/kubernetes-api-codebase-tour?slide=18)(引用自sttts分享的PDF),当收到一个请求后,它主要经过以下处理链路来完成以上若干职责后,才能与etcd交互。
|
||||
|
||||
核心链路如下:
|
||||
|
||||
- 认证模块,校验发起的请求的用户身份是否合法。支持多种方式,比如x509客户端证书认证、静态token认证、webhook认证等。
|
||||
- 限速模块,对请求进行简单的限速,默认读400/s写200/s,不支持根据请求类型进行分类、按优先级限速,存在较多问题。Kubernetes 1.19后已新增Priority and Fairness特性取代它,它支持将请求重要程度分类进行限速,支持多租户,可有效保障Leader选举之类的高优先级请求得到及时响应,能防止一个异常client导致整个集群被限速。
|
||||
- 审计模块,可记录用户对资源的详细操作行为。
|
||||
- 授权模块,检查用户是否有权限对其访问的资源进行相关操作。支持多种方式,RBAC(Role-based access control)、ABAC(Attribute-based access control)、Webhhook等。Kubernetes 1.12版本后,默认授权机制使用的RBAC。
|
||||
- 准入控制模块,提供在访问资源前拦截请求的静态和动态扩展能力,比如要求镜像的拉取策略始终为AlwaysPullImages。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/56/bc/561f38086df49d17ee4e12ec3c5220bc.png" alt="">
|
||||
|
||||
经过上面一系列的模块检查后,这时kube-apiserver就开始与etcd打交道了。在了解kube-apiserver如何将我们创建的Deployment资源写入到etcd前,我先和你介绍下Kubernetes的资源是如何组织、存储在etcd中。
|
||||
|
||||
## Kubernetes资源存储格式
|
||||
|
||||
我们知道etcd仅仅是个key-value存储,但是在Kubernetes中存在各种各样的资源,并提供了以下几种灵活的资源查询方式:
|
||||
|
||||
- 按具体资源名称查询,比如PodName、kubectl get po/PodName。
|
||||
- 按namespace查询,获取一个namespace下的所有Pod,比如kubectl get po -n kube-system。
|
||||
- 按标签名,标签是极度灵活的一种方式,你可以为你的Kubernetes资源打上各种各样的标签,比如上面案例中的kubectl get po -l app=nginx。
|
||||
|
||||
你知道以上这几种查询方式它们的性能优劣吗?假设你是Kubernetes开发者,你会如何设计存储格式来满足以上功能点?
|
||||
|
||||
首先是按具体资源名称查询。它本质就是个key-value查询,只需要写入etcd的key名称与资源key一致即可。
|
||||
|
||||
其次是按namespace查询。这种查询也并不难。因为我们知道etcd支持范围查询,若key名称前缀包含namespace、资源类型,查询的时候指定namespace和资源类型的组合的最小开始区间、最大结束区间即可。
|
||||
|
||||
最后是标签名查询。这种查询方式非常灵活,业务可随时添加、删除标签,各种标签可相互组合。实现标签查询的办法主要有以下两种:
|
||||
|
||||
- 方案一,在etcd中存储标签数据,实现通过标签可快速定位(时间复杂度O(1))到具体资源名称。然而一个标签可能容易实现,但是在Kubernetes集群中,它支持按各个标签组合查询,各个标签组合后的数量相当庞大。在etcd中维护各种标签组合对应的资源列表,会显著增加kube-apiserver的实现复杂度,导致更频繁的etcd写入。
|
||||
- 方案二,在etcd中不存储标签数据,而是由kube-apiserver通过范围遍历etcd获取原始数据,然后基于用户指定标签,来筛选符合条件的资源返回给client。此方案优点是实现简单,但是大量标签查询可能会导致etcd大流量等异常情况发生。
|
||||
|
||||
那么Kubernetes集群选择的是哪种实现方式呢?
|
||||
|
||||
下面是一个Kubernetes集群中的coredns一系列资源在etcd中的存储格式:
|
||||
|
||||
```
|
||||
/registry/clusterrolebindings/system:coredns
|
||||
/registry/clusterroles/system:coredns
|
||||
/registry/configmaps/kube-system/coredns
|
||||
/registry/deployments/kube-system/coredns
|
||||
/registry/events/kube-system/coredns-7fcc6d65dc-6njlg.1662c287aabf742b
|
||||
/registry/events/kube-system/coredns-7fcc6d65dc-6njlg.1662c288232143ae
|
||||
/registry/pods/kube-system/coredns-7fcc6d65dc-jvj26
|
||||
/registry/pods/kube-system/coredns-7fcc6d65dc-mgvtb
|
||||
/registry/pods/kube-system/coredns-7fcc6d65dc-whzq9
|
||||
/registry/replicasets/kube-system/coredns-7fcc6d65dc
|
||||
/registry/secrets/kube-system/coredns-token-hpqbt
|
||||
/registry/serviceaccounts/kube-system/coredns
|
||||
|
||||
```
|
||||
|
||||
从中你可以看到,一方面Kubernetes资源在etcd中的存储格式由prefix + "/" + 资源类型 + "/" + namespace + "/" + 具体资源名组成,基于etcd提供的范围查询能力,非常简单地支持了按具体资源名称查询和namespace查询。
|
||||
|
||||
kube-apiserver提供了如下参数给你配置etcd prefix,并支持将资源存储在多个etcd集群。
|
||||
|
||||
```
|
||||
--etcd-prefix string Default: "/registry"
|
||||
The prefix to prepend to all resource paths in etcd.
|
||||
--etcd-servers stringSlice
|
||||
List of etcd servers to connect with (scheme://ip:port), comma separated.
|
||||
--etcd-servers-overrides stringSlice
|
||||
Per-resource etcd servers overrides, comma separated. The individual override format: group/resource#servers, where servers are URLs,
|
||||
semicolon separated.
|
||||
|
||||
```
|
||||
|
||||
另一方面,我们未看到任何标签相关的key。Kubernetes实现标签查询的方式显然是方案二,即由kube-apiserver通过范围遍历etcd获取原始数据,然后基于用户指定标签,来筛选符合条件的资源返回给client(资源key的value中记录了资源YAML文件内容等,如标签)。
|
||||
|
||||
也就是当你执行"kubectl get po -l app=nginx"命令,按标签查询Pod时,它会向etcd发起一个范围遍历整个default namespace下的Pod操作。
|
||||
|
||||
```
|
||||
$ kubectl get po -l app=nginx -v 8
|
||||
I0301 23:45:25.597465 32411 loader.go:359] Config loaded from file /root/.kube/config
|
||||
I0301 23:45:25.603182 32411 round_trippers.go:416] GET https://ip:port/api/v1/namespaces/default/pods?
|
||||
labelSelector=app%3Dnginx&limit=500
|
||||
|
||||
```
|
||||
|
||||
etcd收到的请求日志如下,由此可见当一个namespace存在大量Pod等资源时,若频繁通过kubectl,使用标签查询Pod等资源,后端etcd将出现较大的压力。
|
||||
|
||||
```
|
||||
{
|
||||
"level":"debug",
|
||||
"ts":"2021-03-01T23:45:25.609+0800",
|
||||
"caller":"v3rpc/interceptor.go:181",
|
||||
"msg":"request stats",
|
||||
"start time":"2021-03-01T23:45:25.608+0800",
|
||||
"time spent":"1.414135ms",
|
||||
"remote":"127.0.0.1:44664",
|
||||
"response type":"/etcdserverpb.KV/Range",
|
||||
"request count":0,
|
||||
"request size":61,
|
||||
"response count":11,
|
||||
"response size":81478,
|
||||
"request content":"key:"/registry/pods/default/" range_end:"/registry/pods/default0" limit:500 "
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
了解完Kubernetes资源的存储格式后,我们再看看nginx Deployment资源是如何由kube-apiserver写入etcd的。
|
||||
|
||||
## 通用存储模块
|
||||
|
||||
kube-apiserver启动的时候,会将每个资源的APIGroup、Version、Resource Handler注册到路由上。当请求经过认证、限速、授权、准入控制模块检查后,请求就会被转发到对应的资源逻辑进行处理。
|
||||
|
||||
同时,kube-apiserver实现了类似数据库ORM机制的通用资源存储机制,提供了对一个资源创建、更新、删除前后的hook能力,将其封装成策略接口。当你新增一个资源时,你只需要编写相应的创建、更新、删除等策略即可,不需要写任何etcd的API。
|
||||
|
||||
下面是kube-apiserver通用存储模块的创建流程图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4d/09/4d8fa0f1d6afd89cf6463cf22c56b709.png" alt="">
|
||||
|
||||
从图中你可以看到,创建一个资源主要由BeforeCreate、Storage.Create以及AfterCreate三大步骤组成。
|
||||
|
||||
当收到创建nginx Deployment请求后,通用存储模块首先会回调各个资源自定义实现的BeforeCreate策略,为资源写入etcd做一些初始化工作。
|
||||
|
||||
下面是Deployment资源的创建策略实现,它会进行将deployment.Generation设置为1等操作。
|
||||
|
||||
```
|
||||
// PrepareForCreate clears fields that are not allowed to be set by end users on creation.
|
||||
func (deploymentStrategy) PrepareForCreate(ctx context.Context, obj runtime.Object) {
|
||||
deployment := obj.(*apps.Deployment)
|
||||
deployment.Status = apps.DeploymentStatus{}
|
||||
deployment.Generation = 1
|
||||
|
||||
pod.DropDisabledTemplateFields(&deployment.Spec.Template, nil)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
执行完BeforeCreate策略后,它就会执行Storage.Create接口,也就是由它真正开始调用底层存储模块etcd3,将nginx Deployment资源对象写入etcd。
|
||||
|
||||
那么Kubernetes是使用etcd Put接口写入资源key-value的吗?如果是,那要如何防止同名资源并发创建被覆盖的问题?
|
||||
|
||||
### 资源安全创建及更新
|
||||
|
||||
我们知道etcd提供了Put和Txn接口给业务添加key-value数据,但是Put接口在并发场景下若收到key相同的资源创建,就会导致被覆盖。
|
||||
|
||||
因此Kubernetes很显然无法直接通过etcd Put接口来写入数据。
|
||||
|
||||
而我们[09](https://time.geekbang.org/column/article/341935)节中介绍的etcd事务接口Txn,它正是为了多key原子更新、并发操作安全性等而诞生的,它提供了丰富的冲突检查机制。
|
||||
|
||||
Kubernetes集群使用的正是事务Txn接口来防止并发创建、更新被覆盖等问题。当执行完BeforeCreate策略后,这时kube-apiserver就会调用Storage的模块的Create接口写入资源。1.6版本后的Kubernete集群默认使用的存储是etcd3,它的创建接口简要实现如下:
|
||||
|
||||
```
|
||||
// Create implements storage.Interface.Create.
|
||||
func (s *store) Create(ctx context.Context, key string, obj, out runtime.Object, ttl uint64) error {
|
||||
......
|
||||
key = path.Join(s.pathPrefix, key)
|
||||
|
||||
opts, err := s.ttlOpts(ctx, int64(ttl))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newData, err := s.transformer.TransformToStorage(data, authenticatedDataString(key))
|
||||
if err != nil {
|
||||
return storage.NewInternalError(err.Error())
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
txnResp, err := s.client.KV.Txn(ctx).If(
|
||||
notFound(key),
|
||||
).Then(
|
||||
clientv3.OpPut(key, string(newData), opts...),
|
||||
).Commit
|
||||
|
||||
```
|
||||
|
||||
从上面的代码片段中,我们可以得出首先它会按照我们介绍的Kubernetes资源存储格式拼接key。
|
||||
|
||||
然后若TTL非0,它会根据TTL从leaseManager获取可复用的Lease ID。Kubernetes集群默认若不同key(如Kubernetes的Event资源对象)的TTL差异在1分钟内,可复用同一个Lease ID,避免大量Lease影响etcd性能和稳定性。
|
||||
|
||||
其次若开启了数据加密,在写入etcd前数据还将按加密算法进行转换工作。
|
||||
|
||||
最后就是使用etcd的Txn接口,向etcd发起一个创建deployment资源的Txn请求。
|
||||
|
||||
那么etcd收到kube-apiserver的请求是长什么样子的呢?
|
||||
|
||||
下面是etcd收到创建nginx deployment资源的请求日志:
|
||||
|
||||
```
|
||||
{
|
||||
"level":"debug",
|
||||
"ts":"2021-02-11T09:55:45.914+0800",
|
||||
"caller":"v3rpc/interceptor.go:181",
|
||||
"msg":"request stats",
|
||||
"start time":"2021-02-11T09:55:45.911+0800",
|
||||
"time spent":"2.697925ms",
|
||||
"remote":"127.0.0.1:44822",
|
||||
"response type":"/etcdserverpb.KV/Txn",
|
||||
"request count":1,
|
||||
"request size":479,
|
||||
"response count":0,
|
||||
"response size":44,
|
||||
"request content":"compare:<target:MOD key:"/registry/deployments/default/nginx-deployment" mod_revision:0 > success:<request_put:<key:"/registry/deployments/default/nginx-deployment" value_size:421 >> failure:<>"
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
从这个请求日志中,你可以得到以下信息:
|
||||
|
||||
- 请求的模块和接口,KV/Txn;
|
||||
- key路径,/registry/deployments/default/nginx-deployment,由prefix + "/" + 资源类型 + "/" + namespace + "/" + 具体资源名组成;
|
||||
- 安全的并发创建检查机制,mod_revision为0时,也就是此key不存在时,才允许执行put更新操作。
|
||||
|
||||
通过Txn接口成功将数据写入到etcd后,kubectl create -f nginx.yml命令就执行完毕,返回给client了。在以上介绍中你可以看到,kube-apiserver并没有任何逻辑去真正创建Pod,但是为什么我们可以马上通过kubectl get命令查询到新建并成功运行的Pod呢?
|
||||
|
||||
这就涉及到了基础架构图中的控制器、调度器、Kubelet等组件。下面我就为你浅析它们是如何基于etcd提供的Watch机制工作,最终实现创建Pod、调度Pod、运行Pod的。
|
||||
|
||||
## Watch机制在Kubernetes中应用
|
||||
|
||||
正如我们基础架构中所介绍的,kube-controller-manager组件中包含一系列WorkLoad的控制器。Deployment资源就由其中的Deployment控制器来负责的,那么它又是如何感知到新建Deployment资源,最终驱动ReplicaSet控制器创建出Pod的呢?
|
||||
|
||||
获取数据变化的方案,主要有轮询和推送两种方案组成。轮询会产生大量expensive request,并且存在高延时。而etcd Watch机制提供的流式推送能力,赋予了kube-apiserver对外提供数据监听能力。
|
||||
|
||||
我们知道在etcd中版本号是个逻辑时钟,随着client对etcd的增、删、改操作而全局递增,它被广泛应用在MVCC、事务、Watch特性中。
|
||||
|
||||
尤其是在Watch特性中,版本号是数据增量同步的核心。当client因网络等异常出现连接闪断后,它就可以通过版本号从etcd server中快速获取异常后的事件,无需全量同步。
|
||||
|
||||
那么在Kubernetes集群中,它提供了什么概念来实现增量监听逻辑呢?
|
||||
|
||||
答案是Resource Version。
|
||||
|
||||
### Resource Version与etcd版本号
|
||||
|
||||
Resource Version是Kubernetes API中非常重要的一个概念,顾名思义,它是一个Kubernetes资源的内部版本字符串,client可通过它来判断资源是否发生了变化。同时,你可以在Get、List、Watch接口中,通过指定Resource Version值来满足你对数据一致性、高性能等诉求。
|
||||
|
||||
那么Resource Version有哪些值呢?跟etcd版本号是什么关系?
|
||||
|
||||
下面我分别以Get和Watch接口中的Resource Version参数值为例,为你剖析它与etcd的关系。
|
||||
|
||||
在Get请求查询案例中,ResourceVersion主要有以下这三种取值:
|
||||
|
||||
第一种是未指定ResourceVersion,默认空字符串。kube-apiserver收到一个此类型的读请求后,它会向etcd发出共识读/线性读请求获取etcd集群最新的数据。
|
||||
|
||||
第二种是设置ResourceVersion="0",赋值字符串0。kube-apiserver收到此类请求时,它可能会返回任意资源版本号的数据,但是优先返回较新版本。一般情况下它直接从kube-apiserver缓存中获取数据返回给client,有可能读到过期的数据,适用于对数据一致性要求不高的场景。
|
||||
|
||||
第三种是设置ResourceVersion为一个非0的字符串。kube-apiserver收到此类请求时,它会保证Cache中的最新ResourceVersion大于等于你传入的ResourceVersion,然后从Cache中查找你请求的资源对象key,返回数据给client。基本原理是kube-apiserver为各个核心资源(如Pod)维护了一个Cache,通过etcd的Watch机制来实时更新Cache。当你的Get请求中携带了非0的ResourceVersion,它会等待缓存中最新ResourceVersion大于等于你Get请求中的ResoureVersion,若满足条件则从Cache中查询数据,返回给client。若不满足条件,它最多等待3秒,若超过3秒,Cache中的最新ResourceVersion还小于Get请求中的ResourceVersion,就会返回ResourceVersionTooLarge错误给client。
|
||||
|
||||
你要注意的是,若你使用的Get接口,那么kube-apiserver会取资源key的ModRevision字段填充Kubernetes资源的ResourceVersion字段(v1.meta/ObjectMeta.ResourceVersion)。若你使用的是List接口,kube-apiserver会在查询时,使用etcd当前版本号填充ListMeta.ResourceVersion字段(v1.meta/ListMeta.ResourceVersion)。
|
||||
|
||||
那么当我们执行kubectl get po查询案例时,它的ResouceVersion是什么取值呢? 查询的是kube-apiserver缓存还是etcd最新共识数据?
|
||||
|
||||
如下所示,你可以通过指定kubectl日志级别为6,观察它向kube-apiserver发出的请求参数。从下面请求日志里你可以看到,默认是未指定Resource Version,也就是会发出一个共识读/线性读请求给etcd,获取etcd最新共识数据。
|
||||
|
||||
```
|
||||
kubectl get po -l app=nginx -v 6
|
||||
4410 loader.go:359] Config loaded from file /root/.kube/config
|
||||
4410 round_trippers.go:438] GET https://*.*.*.*:*/api/v1/namespaces/default/pods?labelSelector=app%3Dnginx&limit=500 200 OK in 8 milliseconds
|
||||
|
||||
```
|
||||
|
||||
这里要提醒下你,在规模较大的集群中,尽量不要使用kubectl频繁查询资源。正如我们上面所分析的,它会直接查询etcd数据,可能会产生大量的expensive request请求,之前我就有见过业务这样用,然后导致了集群不稳定。
|
||||
|
||||
介绍完查询案例后,我们再看看Watch案例中,它的不同取值含义是怎样的呢?
|
||||
|
||||
它同样含有查询案例中的三种取值,官方定义的含义分别如下:
|
||||
|
||||
- 未指定ResourceVersion,默认空字符串。一方面为了帮助client建立初始状态,它会将当前已存在的资源通过Add事件返回给client。另一方面,它会从etcd当前版本号开始监听,后续新增写请求导致数据变化时可及时推送给client。
|
||||
- 设置ResourceVersion="0",赋值字符串0。它同样会帮助client建立初始状态,但是它会从任意版本号开始监听(当前kube-apiserver的实现指定ResourceVersion=0和不指定行为一致,在获取初始状态后,都会从cache最新的ResourceVersion开始监听),这种场景可能会导致集群返回陈旧的数据。
|
||||
- 设置ResourceVersion为一个非0的字符串。从精确的版本号开始监听数据,它只会返回大于等于精确版本号的变更事件。
|
||||
|
||||
Kubernetes的控制器组件就基于以上的Watch特性,在快速感知到新建Deployment资源后,进入一致性协调逻辑,创建ReplicaSet控制器,整体交互流程如下所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/89/54/89c610a5e5bc2bf5eda466a5a0e18e54.png" alt="">
|
||||
|
||||
Deployment控制器创建ReplicaSet资源对象的日志如下所示。
|
||||
|
||||
```
|
||||
{
|
||||
"level":"debug",
|
||||
"ts":"2021-02-11T09:55:45.923+0800",
|
||||
"caller":"v3rpc/interceptor.go:181",
|
||||
"msg":"request stats",
|
||||
"start time":"2021-02-11T09:55:45.917+0800",
|
||||
"time spent":"5.922089ms",
|
||||
"remote":"127.0.0.1:44828",
|
||||
"response type":"/etcdserverpb.KV/Txn",
|
||||
"request count":1,
|
||||
"request size":766,
|
||||
"response count":0,
|
||||
"response size":44,
|
||||
"request content":"compare:<target:MOD key:"/registry/replicasets/default/nginx-deployment-756d9fd5f9" mod_revision:0 > success:<request_put:<key:"/registry/replicasets/default/nginx-deployment-756d9fd5f9" value_size:697 >> failure:<>"
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
真正创建Pod则是由ReplicaSet控制器负责,它同样基于Watch机制感知到新的RS资源创建后,发起请求创建Pod,确保实际运行Pod数与期望一致。
|
||||
|
||||
```
|
||||
{
|
||||
"level":"debug",
|
||||
"ts":"2021-02-11T09:55:46.023+0800",
|
||||
"caller":"v3rpc/interceptor.go:181",
|
||||
"msg":"request stats",
|
||||
"start time":"2021-02-11T09:55:46.019+0800",
|
||||
"time spent":"3.519326ms",
|
||||
"remote":"127.0.0.1:44664",
|
||||
"response type":"/etcdserverpb.KV/Txn",
|
||||
"request count":1,
|
||||
"request size":822,
|
||||
"response count":0,
|
||||
"response size":44,
|
||||
"request content":"compare:<target:MOD key:"/registry/pods/default/nginx-deployment-756d9fd5f9-x6r6q" mod_revision:0 > success:<request_put:<key:"/registry/pods/default/nginx-deployment-756d9fd5f9-x6r6q" value_size:754 >> failure:<>"
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这过程中也产生了若干Event,下面是etcd收到新增Events资源的请求,你可以看到Event事件key关联了Lease,这个Lease正是由我上面所介绍的leaseManager所负责创建。
|
||||
|
||||
```
|
||||
{
|
||||
"level":"debug",
|
||||
"ts":"2021-02-11T09:55:45.930+0800",
|
||||
"caller":"v3rpc/interceptor.go:181",
|
||||
"msg":"request stats",
|
||||
"start time":"2021-02-11T09:55:45.926+0800",
|
||||
"time spent":"3.259966ms",
|
||||
"remote":"127.0.0.1:44632",
|
||||
"response type":"/etcdserverpb.KV/Txn",
|
||||
"request count":1,
|
||||
"request size":449,
|
||||
"response count":0,
|
||||
"response size":44,
|
||||
"request content":"compare:<target:MOD key:"/registry/events/default/nginx-deployment.16628eb9f79e0ab0" mod_revision:0 > success:<request_put:<key:"/registry/events/default/nginx-deployment.16628eb9f79e0ab0" value_size:369 lease:5772338802590698925 >> failure:<>"
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
Pod创建出来后,这时kube-scheduler监听到待调度的Pod,于是为其分配Node,通过kube-apiserver的Bind接口,将调度后的节点IP绑定到Pod资源上。kubelet通过同样的Watch机制感知到新建的Pod后,发起Pod创建流程即可。
|
||||
|
||||
以上就是当我们在Kubernetes集群中创建一个Pod后,Kubernetes和etcd之间交互的简要分析。
|
||||
|
||||
## 小结
|
||||
|
||||
最后我们来小结下今天的内容。我通过一个创建Pod案例,首先为你解读了Kubernetes集群的etcd存储格式,每个资源的保存路径为prefix + "/" + 资源类型 + "/" + namespace + "/" + 具体资源名组成。结合etcd3的范围查询,可快速实现按namesapace、资源名称查询。按标签查询则是通过kube-apiserver遍历指定namespace下的资源实现的,若未从kube-apiserver的Cache中查询,请求较频繁,很可能导致etcd流量较大,出现不稳定。
|
||||
|
||||
随后我和你介绍了kube-apiserver的通用存储模块,它通过在创建、查询、删除、更新操作前增加一系列的Hook机制,实现了新增任意资源只需编写相应的Hook策略即可。我还重点和你介绍了创建接口,它主要由拼接key、获取Lease ID、数据转换、写入etcd组成,重点是它通过使用事务接口实现了资源的安全创建及更新。
|
||||
|
||||
最后我给你讲解了Resoure Version在Kubernetes集群中的大量应用,重点和你分析了Get和Watch请求案例中的Resource Version含义,帮助你了解Resource Version本质,让你能根据业务场景和对一致性的容忍度,正确的使用Resource Version以满足业务诉求。
|
||||
|
||||
## 思考题
|
||||
|
||||
我还给你留了一个思考题,有哪些原因可能会导致kube-apiserver报“too old Resource Version”错误呢?
|
||||
|
||||
感谢你的阅读,如果你认为这节课的内容有收获,也欢迎把它分享给你的朋友,谢谢。
|
||||
@@ -0,0 +1,291 @@
|
||||
<audio id="audio" title="20 | Kubernetes高级应用:如何优化业务场景使etcd能支撑上万节点集群?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5c/5d/5c1551b62a3e4900b75107ea0f482c5d.mp3"></audio>
|
||||
|
||||
你好,我是唐聪。
|
||||
|
||||
你知道吗? 虽然Kubernetes社区官网文档目前声称支持最大集群节点数为5000,但是云厂商已经号称支持15000节点的Kubernetes集群了,那么为什么一个小小的etcd能支撑15000节点Kubernetes集群呢?
|
||||
|
||||
今天我就和你聊聊为了支撑15000节点,Kubernetes和etcd的做的一系列优化。我将重点和你分析Kubernetes针对etcd的瓶颈是如何从应用层采取一系列优化措施,去解决大规模集群场景中各个痛点。
|
||||
|
||||
当你遇到etcd性能瓶颈时,希望这节课介绍的大规模Kubernetes集群的最佳实践经验和优化技术,能让你获得启发,帮助你解决类似问题。
|
||||
|
||||
## 大集群核心问题分析
|
||||
|
||||
在大规模Kubernetes集群中会遇到哪些问题呢?
|
||||
|
||||
大规模Kubernetes集群的外在表现是节点数成千上万,资源对象数量高达几十万。本质是更频繁地查询、写入更大的资源对象。
|
||||
|
||||
首先是查询相关问题。在大集群中最重要的就是如何最大程度地减少expensive request。因为对几十万级别的对象数量来说,按标签、namespace查询Pod,获取所有Node等场景时,很容易造成etcd和kube-apiserver OOM和丢包,乃至雪崩等问题发生。
|
||||
|
||||
其次是写入相关问题。Kubernetes为了维持上万节点的心跳,会产生大量写请求。而按照我们基础篇介绍的etcd MVCC、boltdb、线性读等原理,etcd适用场景是读多写少,大量写请求可能会导致db size持续增长、写性能达到瓶颈被限速、影响读性能。
|
||||
|
||||
最后是大资源对象相关问题。etcd适合存储较小的key-value数据,etcd本身也做了一系列硬限制,比如key的value大小默认不能超过1.5MB。
|
||||
|
||||
本讲我就和你重点分析下Kubernetes是如何优化以上问题,以实现支撑上万节点的。以及我会简单和你讲下etcd针对Kubernetes场景做了哪些优化。
|
||||
|
||||
## 如何减少expensive request
|
||||
|
||||
首先是第一个问题,Kubernetes如何减少expensive request?
|
||||
|
||||
在这个问题中,我将Kubernetes解决此问题的方案拆分成几个核心点和你分析。
|
||||
|
||||
### 分页
|
||||
|
||||
首先List资源操作是个基本功能点。各个组件在启动的时候,都不可避免会产生List操作,从etcd获取集群资源数据,构建初始状态。因此优化的第一步就是要避免一次性读取数十万的资源操作。
|
||||
|
||||
解决方案是Kubernetes List接口支持分页特性。分页特性依赖底层存储支持,早期的etcd v2并未支持分页被饱受诟病,非常容易出现kube-apiserver大流量、高负载等问题。在etcd v3中,实现了指定返回Limit数量的范围查询,因此也赋能kube-apiserver 对外提供了分页能力。
|
||||
|
||||
如下所示,在List接口的ListOption结构体中,Limit和Continue参数就是为了实现分页特性而增加的。
|
||||
|
||||
Limit表示一次List请求最多查询的对象数量,一般为500。如果实际对象数量大于Limit,kube-apiserver则会更新ListMeta的Continue字段,client发起的下一个List请求带上这个字段就可获取下一批对象数量。直到kube-apiserver返回空的Continue值,就获取完成了整个对象结果集。
|
||||
|
||||
```
|
||||
// ListOptions is the query options to a standard REST
|
||||
list call.
|
||||
type ListOptions struct {
|
||||
...
|
||||
Limit int64 `json:"limit,omitempty"
|
||||
protobuf:"varint,7,opt,name=limit"`
|
||||
Continue string `json:"continue,omitempty"
|
||||
protobuf:"bytes,8,opt,name=continue"`
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
了解完kube-apiserver的分页特性后,我们接着往下看Continue字段具体含义,以及它是如何影响etcd查询结果的。
|
||||
|
||||
我们知道etcd分页是通过范围查询和Limit实现,ListOption中的Limit对应etcd查询接口中的Limit参数。你可以大胆猜测下,Continue字段是不是跟查询的范围起始key相关呢?
|
||||
|
||||
Continue字段的确包含查询范围的起始key,它本质上是个结构体,还包含APIVersion和ResourceVersion。你之所以看到的是一个奇怪字符串,那是因为kube-apiserver使用base64库对其进行了URL编码,下面是它的原始结构体。
|
||||
|
||||
```
|
||||
type continueToken struct {
|
||||
APIVersion string `json:"v"`
|
||||
ResourceVersion int64 `json:"rv"`
|
||||
StartKey string `json:"start"`
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
当kube-apiserver收到带Continue的分页查询时,解析Continue,获取StartKey、ResourceVersion,etcd查询Range接口指定startKey,增加clienv3.WithRange、clientv3.WithLimit、clientv3.WithRev即可。
|
||||
|
||||
当你通过分页多次查询Kubernetes资源对象,得到的最终结果集合与不带Limit查询结果是一致的吗?kube-apiserver是如何保证分页查询的一致性呢? 这个问题我把它作为了思考题,我们一起讨论。
|
||||
|
||||
### 资源按namespace拆分
|
||||
|
||||
通过分页特性提供机制避免一次拉取大量资源对象后,接下来就是业务最佳实践上要避免同namespace存储大量资源,尽量将资源对象拆分到不同namespace下。
|
||||
|
||||
为什么拆分到不同namespace下有助于提升性能呢?
|
||||
|
||||
正如我在[19](https://time.geekbang.org/column/article/347992)中所介绍的,Kubernetes资源对象存储在etcd中的key前缀包含namespace,因此它相当于是个高效的索引字段。etcd treeIndex模块从B-tree中匹配前缀时,可快速过滤出符合条件的key-value数据。
|
||||
|
||||
Kubernetes社区承诺[SLO](https://github.com/kubernetes/community/blob/master/sig-scalability/slos/slos.md)达标的前提是,你在使用Kubernetes集群过程中必须合理配置集群和使用扩展特性,并遵循[一系列条件限制](https://github.com/kubernetes/community/blob/master/sig-scalability/configs-and-limits/thresholds.md)(比如同namespace下的Service数量不超过5000个)。
|
||||
|
||||
### Informer机制
|
||||
|
||||
各组件启动发起一轮List操作加载完初始状态数据后,就进入了控制器的一致性协调逻辑。在一致性协调逻辑中,在19讲Kubernetes 基础篇中,我和你介绍了Kubernetes使用的是Watch特性来获取数据变化通知,而不是List定时轮询,这也是减少List操作一大核心策略。
|
||||
|
||||
Kubernetes社区在client-go项目中提供了一个通用的Informer组件来负责client与kube-apiserver进行资源和事件同步,显著降低了开发者使用Kubernetes API、开发高性能Kubernetes扩展组件的复杂度。
|
||||
|
||||
Informer机制的Reflector封装了Watch、List操作,结合本地Cache、Indexer,实现了控制器加载完初始状态数据后,接下来的其他操作都只需要从本地缓存读取,极大降低了kube-apiserver和etcd的压力。
|
||||
|
||||
下面是Kubernetes社区给出的一个控制器使用Informer机制的架构图。黄色部分是控制器相关基础组件,蓝色部分是client-go的Informer机制的组件,它由Reflector、Queue、Informer、Indexer、Thread safe store(Local Cache)组成。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fb/99/fb7caaa37a6a860422825d2199217899.png" alt="">
|
||||
|
||||
Informer机制的基本工作流程如下:
|
||||
|
||||
- client启动或与kube-apiserver出现连接中断再次Watch时,报"too old resource version"等错误后,通过Reflector组件的List操作,从kube-apiserver获取初始状态数据,随后通过Watch机制实时监听数据变化。
|
||||
- 收到事件后添加到Delta FIFO队列,由Informer组件进行处理。
|
||||
- Informer将delta FIFO队列中的事件转发给Indexer组件,Indexer组件将事件持久化存储在本地的缓存中。
|
||||
- 控制器开发者可通过Informer组件注册Add、Update、Delete事件的回调函数。Informer组件收到事件后会回调业务函数,比如典型的控制器使用场景,一般是将各个事件添加到WorkQueue中,控制器的各个协调goroutine从队列取出消息,解析key,通过key从Informer机制维护的本地Cache中读取数据。
|
||||
|
||||
通过以上流程分析,你可以发现除了启动、连接中断等场景才会触发List操作,其他时候都是从本地Cache读取。
|
||||
|
||||
那连接中断等场景为什么触发client List操作呢?
|
||||
|
||||
### Watch bookmark机制
|
||||
|
||||
要搞懂这个问题,你得了解kube-apiserver Watch特性的原理。
|
||||
|
||||
接下来我就和你介绍下Kubernetes的Watch特性。我们知道Kubernetes通过全局递增的Resource Version来实现增量数据同步逻辑,尽量避免连接中断等异常场景下client发起全量List同步操作。
|
||||
|
||||
那么在什么场景下会触发全量List同步操作呢?这就取决于client请求的Resource Version以及kube-apiserver中是否还保存了相关的历史版本数据。
|
||||
|
||||
在[08](https://time.geekbang.org/column/article/341060)Watch特性中,我和你提到实现历史版本数据存储两大核心机制,滑动窗口和MVCC。与etcd v3使用MVCC机制不一样的是,Kubernetes采用的是滑动窗口机制。
|
||||
|
||||
kube-apiserver的滑动窗口机制是如何实现的呢?
|
||||
|
||||
它通过为每个类型资源(Pod,Node等)维护一个cyclic buffer,来存储最近的一系列变更事件实现。
|
||||
|
||||
下面Kubernetes核心的watchCache结构体中的cache数组、startIndex、endIndex就是用来实现cyclic buffer的。滑动窗口中的第一个元素就是cache[startIndex%capacity],最后一个元素则是cache[endIndex%capacity]。
|
||||
|
||||
```
|
||||
// watchCache is a "sliding window" (with a limited capacity) of objects
|
||||
// observed from a watch.
|
||||
type watchCache struct {
|
||||
sync.RWMutex
|
||||
|
||||
// Condition on which lists are waiting for the fresh enough
|
||||
// resource version.
|
||||
cond *sync.Cond
|
||||
|
||||
// Maximum size of history window.
|
||||
capacity int
|
||||
|
||||
// upper bound of capacity since event cache has a dynamic size.
|
||||
upperBoundCapacity int
|
||||
|
||||
// lower bound of capacity since event cache has a dynamic size.
|
||||
lowerBoundCapacity int
|
||||
|
||||
// cache is used a cyclic buffer - its first element (with the smallest
|
||||
// resourceVersion) is defined by startIndex, its last element is defined
|
||||
// by endIndex (if cache is full it will be startIndex + capacity).
|
||||
// Both startIndex and endIndex can be greater than buffer capacity -
|
||||
// you should always apply modulo capacity to get an index in cache array.
|
||||
cache []*watchCacheEvent
|
||||
startIndex int
|
||||
endIndex int
|
||||
|
||||
// store will effectively support LIST operation from the "end of cache
|
||||
// history" i.e. from the moment just after the newest cached watched event.
|
||||
// It is necessary to effectively allow clients to start watching at now.
|
||||
// NOTE: We assume that <store> is thread-safe.
|
||||
store cache.Indexer
|
||||
|
||||
// ResourceVersion up to which the watchCache is propagated.
|
||||
resourceVersion uint64
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
下面我以Pod资源的历史事件滑动窗口为例,和你聊聊它在什么场景可能会触发client全量List同步操作。
|
||||
|
||||
如下图所示,kube-apiserver启动后,通过List机制,加载初始Pod状态数据,随后通过Watch机制监听最新Pod数据变化。当你不断对Pod资源进行增加、删除、修改后,携带新Resource Version(简称RV)的Pod事件就会不断被加入到cyclic buffer。假设cyclic buffer容量为100,RV1是最小的一个Watch事件的Resource Version,RV 100是最大的一个Watch事件的Resource Version。
|
||||
|
||||
当版本号为RV101的Pod事件到达时,RV1就会被淘汰,kube-apiserver维护的Pod最小版本号就变成了RV2。然而在Kubernetes集群中,不少组件都只关心cyclic buffer中与自己相关的事件。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/29/29/29deb02b3724edef274ce71d6a758b29.png" alt="">
|
||||
|
||||
比如图中的kubelet只关注运行在自己节点上的Pod,假设只有RV1是它关心的Pod事件版本号,在未实现Watch bookmark特性之前,其他RV2到RV101的事件是不会推送给它的,因此它内存中维护的Resource Version依然是RV1。
|
||||
|
||||
若此kubelet随后与kube-apiserver连接出现异常,它将使用版本号RV1发起Watch重连操作。但是kube-apsierver cyclic buffer中的Pod最小版本号已是RV2,因此会返回"too old resource version"错误给client,client只能发起List操作,在获取到最新版本号后,才能重新进入监听逻辑。
|
||||
|
||||
那么我们能否定时将最新的版本号推送给各个client来解决以上问题呢?
|
||||
|
||||
是的,这就是Kubernetes的Watch bookmark机制核心思想。即使队列中无client关注的更新事件,Informer机制的Reflector组件中Resource Version也需要更新。
|
||||
|
||||
Watch bookmark机制通过新增一个bookmark类型的事件来实现的。kube-apiserver会通过定时器将各类型资源最新的Resource Version推送给kubelet等client,在client与kube-apiserver网络异常重连等场景,大大降低了client重建Watch的开销,减少了relist expensive request。
|
||||
|
||||
### 更高效的Watch恢复机制
|
||||
|
||||
虽然Kubernetes社区通过Watch bookmark机制缓解了client与kube-apiserver重连等场景下可能导致的relist expensive request操作,然而在kube-apiserver重启、滚动更新时,它依然还是有可能导致大量的relist操作,这是为什么呢? 如何进一步减少kube-apiserver重启场景下的List操作呢?
|
||||
|
||||
如下图所示,在kube-apiserver重启后,kubelet等client会立刻带上Resource Version发起重建Watch的请求。问题就在kube-apiserver重启后,watchCache中的cyclic buffer是空的,此时watchCache中的最小Resource Version(listResourceVersion)是etcd的最新全局版本号,也就是图中的RV200。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e1/d2/e1694c3dce75b310b9950f3e3yydd2d2.png" alt="">
|
||||
|
||||
在不少场景下,client请求重建Watch的Resource Version是可能小于listResourceVersion的。
|
||||
|
||||
比如在上面的这个案例图中,集群内Pod稳定运行未发生变化,kubelet假设收到了最新的RV100事件。然而这个集群其他资源如ConfigMap,被管理员不断的修改,它就会导致导致etcd版本号新增,ConfigMap滑动窗口也会不断存储变更事件,从图中可以看到,它记录最大版本号为RV200。
|
||||
|
||||
因此kube-apiserver重启后,client请求重建Pod Watch的Resource Version是RV100,而Pod watchCache中的滑动窗口最小Resource Version是RV200。很显然,RV100不在Pod watchCache所维护的滑动窗口中,kube-apiserver就会返回"too old resource version"错误给client,client只能发起relist expensive request操作同步最新数据。
|
||||
|
||||
为了进一步降低kube-apiserver重启对client Watch中断的影响,Kubernetes在1.20版本中又进一步实现了[更高效的Watch恢复机制](https://github.com/kubernetes/enhancements/tree/master/keps/sig-api-machinery/1904-efficient-watch-resumption)。它通过etcd Watch机制的Notify特性,实现了将etcd最新的版本号定时推送给kube-apiserver。kube-apiserver在将其转换成ResourceVersion后,再通过bookmark机制推送给client,避免了kube-apiserver重启后client可能发起的List操作。
|
||||
|
||||
## 如何控制db size
|
||||
|
||||
分析完Kubernetes如何减少expensive request,我们再看看Kubernetes是如何控制db size的。
|
||||
|
||||
首先,我们知道Kubernetes的kubelet组件会每隔10秒上报一次心跳给kube-apiserver。
|
||||
|
||||
其次,Node资源对象因为包含若干个镜像、数据卷等信息,导致Node资源对象会较大,一次心跳消息可能高达15KB以上。
|
||||
|
||||
最后,etcd是基于COW(Copy-on-write)机制实现的MVCC数据库,每次修改都会产生新的key-value,若大量写入会导致db size持续增长。
|
||||
|
||||
早期Kubernetes集群由于以上原因,当节点数成千上万时,kubelet产生的大量写请求就较容易造成db大小达到配额,无法写入。
|
||||
|
||||
那么如何解决呢?
|
||||
|
||||
本质上还是Node资源对象大的问题。实际上我们需要更新的仅仅是Node资源对象的心跳状态,而在etcd中我们存储的是整个Node资源对象,并未将心跳状态拆分出来。
|
||||
|
||||
因此Kuberentes的解决方案就是将Node资源进行拆分,把心跳状态信息从Node对象中剥离出来,通过下面的Lease对象来描述它。
|
||||
|
||||
```
|
||||
// Lease defines a lease concept.
|
||||
type Lease struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
metav1.ObjectMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"`
|
||||
Spec LeaseSpec `json:"spec,omitempty" protobuf:"bytes,2,opt,name=spec"`
|
||||
}
|
||||
|
||||
// LeaseSpec is a specification of a Lease.
|
||||
type LeaseSpec struct {
|
||||
HolderIdentity *string `json:"holderIdentity,omitempty" protobuf:"bytes,1,opt,name=holderIdentity"`
|
||||
LeaseDurationSeconds *int32 `json:"leaseDurationSeconds,omitempty" protobuf:"varint,2,opt,name=leaseDurationSeconds"`
|
||||
AcquireTime *metav1.MicroTime `json:"acquireTime,omitempty" protobuf:"bytes,3,opt,name=acquireTime"`
|
||||
RenewTime *metav1.MicroTime `json:"renewTime,omitempty" protobuf:"bytes,4,opt,name=renewTime"`
|
||||
LeaseTransitions *int32 `json:"leaseTransitions,omitempty" protobuf:"varint,5,opt,name=leaseTransitions"`
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
因为Lease对象非常小,更新的代价远小于Node对象,所以这样显著降低了kube-apiserver的CPU开销、etcd db size,Kubernetes 1.14版本后已经默认启用Node心跳切换到Lease API。
|
||||
|
||||
## 如何优化key-value大小
|
||||
|
||||
最后,我们再看看Kubernetes是如何解决etcd key-value大小限制的。
|
||||
|
||||
在成千上万个节点的集群中,一个服务可能背后有上万个Pod。而服务对应的Endpoints资源含有大量的独立的endpoints信息,这会导致Endpoints资源大小达到etcd的value大小限制,etcd拒绝更新。
|
||||
|
||||
另外,kube-proxy等组件会实时监听Endpoints资源,一个endpoint变化就会产生较大的流量,导致kube-apiserver等组件流量超大、出现一系列性能瓶颈。
|
||||
|
||||
如何解决以上Endpoints资源过大的问题呢?
|
||||
|
||||
答案依然是拆分、化大为小。Kubernetes社区设计了EndpointSlice概念,每个EndpointSlice最大支持保存100个endpoints,成功解决了key-value过大、变更同步导致流量超大等一系列瓶颈。
|
||||
|
||||
## etcd优化
|
||||
|
||||
Kubernetes社区在解决大集群的挑战的同时,etcd社区也在不断优化、新增特性,提升etcd在Kubernetes场景下的稳定性和性能。这里我简单列举两个,一个是etcd并发读特性,一个是Watch特性的Notify机制。
|
||||
|
||||
### 并发读特性
|
||||
|
||||
通过以上介绍的各种机制、策略,虽然Kubernetes能大大缓解expensive read request问题,但是它并不是从本质上来解决问题的。
|
||||
|
||||
为什么etcd无法支持大量的read expensive request呢?
|
||||
|
||||
除了我们一直强调的容易导致OOM、大流量导致丢包外,etcd根本性瓶颈是在etcd 3.4版本之前,expensive read request会长时间持有MVCC模块的buffer读锁RLock。而写请求执行完后,需升级锁至Lock,expensive request导致写事务阻塞在升级锁过程中,最终导致写请求超时。
|
||||
|
||||
为了解决此问题,etcd 3.4版本实现了并发读特性。核心解决方案是去掉了读写锁,每个读事务拥有一个buffer。在收到读请求创建读事务对象时,全量拷贝写事务维护的buffer到读事务buffer中。
|
||||
|
||||
通过并发读特性,显著降低了List Pod和CRD等expensive read request对写性能的影响,延时不再突增、抖动。
|
||||
|
||||
### 改善Watch Notify机制
|
||||
|
||||
为了配合Kubernetes社区实现更高效的Watch恢复机制,etcd改善了Watch Notify机制,早期Notify消息发送间隔是固定的10分钟。
|
||||
|
||||
在etcd 3.4.11版本中,新增了--experimental-watch-progress-notify-interval参数使Notify间隔时间可配置,最小支持为100ms,满足了Kubernetes业务场景的诉求。
|
||||
|
||||
最后,你要注意的是,默认通过clientv3 Watch API创建的watcher是不会开启此特性的。你需要创建Watcher的时候,设置clientv3.WithProgressNotify选项,这样etcd server就会定时发送提醒消息给client,消息中就会携带etcd当前最新的全局版本号。
|
||||
|
||||
## 小结
|
||||
|
||||
最后我们来小结下今天的内容。
|
||||
|
||||
首先我和你剖析了大集群核心问题,即expensive request、db size、key-value大小。
|
||||
|
||||
针对expensive request,我分别为你阐述了Kubernetes的分页机制、资源按namespace拆分部署策略、核心的Informer机制、优化client与kube-apiserver连接异常后的Watch恢复效率的bookmark机制、以及进一步优化kube-apiserver重建场景下Watch恢复效率的Notify机制。从这个问题优化思路中我们可以看到,优化无止境。从大方向到边界问题,Kubernetes社区一步步将expensive request降低到极致。
|
||||
|
||||
针对db size和key-value大小,Kubernetes社区的解决方案核心思想是拆分,通过Lease和EndpointSlice资源对象成功解决了大规模集群过程遇到db size和key-value瓶颈。
|
||||
|
||||
最后etcd社区也在努力提升、优化相关特性,etcd 3.4版本中的并发读特性和可配置化的Watch Notify间隔时间就是最典型的案例。自从etcd被redhat捐赠给CNCF后,etcd核心就围绕着Kubernetes社区展开工作,努力打造更快、更稳的etcd。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后我给你留了两个思考题。
|
||||
|
||||
首先,在Kubernetes集群中,当你通过分页API分批多次查询得到全量Node资源的时候,它能保证Node全量数据的完整性、一致性(所有节点时间点一致)吗?如果能,是如何保证的呢?
|
||||
|
||||
其次,你在使用Kubernetes集群中是否有遇到一些稳定性、性能以及令你困惑的问题呢?欢迎留言和我一起讨论。
|
||||
|
||||
感谢你的阅读,如果你认为这节课的内容有收获,也欢迎把它分享给你的朋友,谢谢。
|
||||
325
极客时间专栏/geek/etcd实战课/实践篇/21 | 分布式锁:为什么基于etcd实现分布式锁比Redis锁更安全?.md
Normal file
325
极客时间专栏/geek/etcd实战课/实践篇/21 | 分布式锁:为什么基于etcd实现分布式锁比Redis锁更安全?.md
Normal file
@@ -0,0 +1,325 @@
|
||||
<audio id="audio" title="21 | 分布式锁:为什么基于etcd实现分布式锁比Redis锁更安全?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/09/b4/09366811dbe46c29964612d885f6d6b4.mp3"></audio>
|
||||
|
||||
你好,我是唐聪。
|
||||
|
||||
在软件开发过程中,我们经常会遇到各种场景要求对共享资源进行互斥操作,否则整个系统的数据一致性就会出现问题。典型场景如商品库存操作、Kubernertes调度器为Pod分配运行的Node。
|
||||
|
||||
那要如何实现对共享资源进行互斥操作呢?
|
||||
|
||||
锁就是其中一个非常通用的解决方案。在单节点多线程环境,你使用本地的互斥锁就可以完成资源的互斥操作。然而单节点存在单点故障,为了保证服务高可用,你需要多节点部署。在多节点部署的分布式架构中,你就需要使用分布式锁来解决资源互斥操作了。
|
||||
|
||||
但是为什么有的业务使用了分布式锁还会出现各种严重超卖事故呢?分布式锁的实现和使用过程需要注意什么?
|
||||
|
||||
今天,我就和你聊聊分布式锁背后的故事,我将通过一个茅台超卖的案例,为你介绍基于Redis实现的分布锁优缺点,引出分布式锁的核心要素,对比分布式锁的几种业界典型实现方案,深入剖析etcd分布式锁的实现。
|
||||
|
||||
希望通过这节课,让你了解etcd分布式锁的应用场景、核心原理,在业务开发过程中,优雅、合理的使用分布式锁去解决各类资源互斥、并发操作问题。
|
||||
|
||||
## 从茅台超卖案例看分布式锁要素
|
||||
|
||||
首先我们从去年一个因Redis分布式锁实现问题导致[茅台超卖案例](https://juejin.cn/post/6854573212831842311)说起,在这个网友分享的真实案例中,因茅台的稀缺性,事件最终定级为P0级生产事故,后果影响严重。
|
||||
|
||||
那么它是如何导致超卖的呢?
|
||||
|
||||
首先和你简单介绍下此案例中的Redis简易分布式锁实现方案,它使用了Redis SET命令来实现。
|
||||
|
||||
```
|
||||
SET key value [EX seconds|PX milliseconds|EXAT timestamp|PXAT milliseconds-timestamp|KEEPTTL] [NX|XX]
|
||||
[GET]
|
||||
|
||||
```
|
||||
|
||||
简单给你介绍下SET命令重点参数含义:
|
||||
|
||||
- `EX` 设置过期时间,单位秒;
|
||||
- `NX` 当key不存在的时候,才设置key;
|
||||
- `XX` 当key存在的时候,才设置key。
|
||||
|
||||
此业务就是基于Set key value EX 10 NX命令来实现的分布式锁,并通过JAVA的try-finally语句,执行Del key语句来释放锁,简易流程如下:
|
||||
|
||||
```
|
||||
# 对资源key加锁,key不存在时创建,并且设置,10秒自动过期
|
||||
SET key value EX 10 NX
|
||||
业务逻辑流程1,校验用户身份
|
||||
业务逻辑流程2,查询并校验库存(get and compare)
|
||||
业务逻辑流程3,库存>0,扣减库存(Decr stock),生成秒杀茅台订单
|
||||
|
||||
# 释放锁
|
||||
Del key
|
||||
|
||||
```
|
||||
|
||||
以上流程中其实存在以下思考点:
|
||||
|
||||
- NX参数有什么作用?
|
||||
- 为什么需要原子的设置key及过期时间?
|
||||
- 为什么基于Set key value EX 10 NX命令还出现了超卖呢?
|
||||
- 为什么大家都比较喜欢使用Redis作为分布式锁实现?
|
||||
|
||||
首先来看第一个问题,NX参数的作用。NX参数是为了保证当分布式锁不存在时,只有一个client能写入此key成功,获取到此锁。我们使用分布式锁的目的就是希望在高并发系统中,有一种互斥机制来防止彼此相互干扰,保证数据的一致性。
|
||||
|
||||
**因此分布式锁的第一核心要素就是互斥性、安全性。在同一时间内,不允许多个client同时获得锁。**
|
||||
|
||||
再看第二个问题,假设我们未设置key自动过期时间,在Set key value NX后,如果程序crash或者发生网络分区后无法与Redis节点通信,毫无疑问其他client将永远无法获得锁。这将导致死锁,服务出现中断。
|
||||
|
||||
有的同学意识到这个问题后,使用如下SETNX和EXPIRE命令去设置key和过期时间,这也是不正确的,因为你无法保证SETNX和EXPIRE命令的原子性。
|
||||
|
||||
```
|
||||
# 对资源key加锁,key不存在时创建
|
||||
SETNX key value
|
||||
# 设置KEY过期时间
|
||||
EXPIRE key 10
|
||||
业务逻辑流程
|
||||
|
||||
# 释放锁
|
||||
Del key
|
||||
|
||||
```
|
||||
|
||||
**这就是分布式锁第二个核心要素,活性。在实现分布式锁的过程中要考虑到client可能会出现crash或者网络分区,你需要原子申请分布式锁及设置锁的自动过期时间,通过过期、超时等机制自动释放锁,避免出现死锁,导致业务中断。**
|
||||
|
||||
再看第三个问题,为什么使用了Set key value EX 10 NX命令,还出现了超卖呢?
|
||||
|
||||
原来是抢购活动开始后,加锁逻辑中的业务流程1访问的用户身份服务出现了高负载,导致阻塞在校验用户身份流程中(超时30秒),然而锁10秒后就自动过期了,因此其他client能获取到锁。关键是阻塞的请求执行完后,它又把其他client的锁释放掉了,导致进入一个恶性循环。
|
||||
|
||||
因此申请锁时,写入的value应确保唯一性(随机值等)。client在释放锁时,应通过Lua脚本原子校验此锁的value与自己写入的value一致,若一致才能执行释放工作。
|
||||
|
||||
更关键的是库存校验是通过get and compare方式,它压根就无法防止超卖。正确的解决方案应该是通过LUA脚本实现Redis比较库存、扣减库存操作的原子性(或者在每次只能抢购一个的情况下,通过判断[Redis Decr命令](https://redis.io/commands/DECR)的返回值即可。此命令会返回扣减后的最新库存,若小于0则表示超卖)。
|
||||
|
||||
**从这个问题中我们可以看到,分布式锁实现具备一定的复杂度,它不仅依赖存储服务提供的核心机制,同时依赖业务领域的实现。无论是遭遇高负载、还是宕机、网络分区等故障,都需确保锁的互斥性、安全性,否则就会出现严重的超卖生产事故。**
|
||||
|
||||
再看最后一个问题,为什么大家都比较喜欢使用Redis做分布式锁的实现呢?
|
||||
|
||||
考虑到在秒杀等业务场景上存在大量的瞬间、高并发请求,加锁与释放锁的过程应是高性能、高可用的。而Redis核心优点就是快、简单,是随处可见的基础设施,部署、使用也及其方便,因此广受开发者欢迎。
|
||||
|
||||
**这就是分布式锁第三个核心要素,高性能、高可用。加锁、释放锁的过程性能开销要尽量低,同时要保证高可用,确保业务不会出现中断。**
|
||||
|
||||
那么除了以上案例中人为实现问题导致的锁不安全因素外,基于Redis实现的以上分布式锁还有哪些安全性问题呢?
|
||||
|
||||
## Redis分布式锁问题
|
||||
|
||||
我们从茅台超卖案例中为你总结出的分布式核心要素(互斥性、安全性、活性、高可用、高性能)说起。
|
||||
|
||||
首先,如果我们的分布式锁跑在单节点的Redis Master节点上,那么它就存在单点故障,无法保证分布式锁的高可用。
|
||||
|
||||
于是我们需要一个主备版的Redis服务,至少具备一个Slave节点。
|
||||
|
||||
我们又知道Redis是基于主备异步复制协议实现的Master-Slave数据同步,如下图所示,若client A执行SET key value EX 10 NX命令,redis-server返回给client A成功后,Redis Master节点突然出现crash等异常,这时候Redis Slave节点还未收到此命令的同步。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/cd/45/cd3d4ab1af45c6eb76e7dccd9c666245.png" alt="">
|
||||
|
||||
若你部署了Redis Sentinel等主备切换服务,那么它就会以Slave节点提升为主,此时Slave节点因并未执行SET key value EX 10 NX命令,因此它收到client B发起的加锁的此命令后,它也会返回成功给client。
|
||||
|
||||
那么在同一时刻,集群就出现了两个client同时获得锁,分布式锁的互斥性、安全性就被破坏了。
|
||||
|
||||
除了主备切换可能会导致基于Redis实现的分布式锁出现安全性问题,在发生网络分区等场景下也可能会导致出现脑裂,Redis集群出现多个Master,进而也会导致多个client同时获得锁。
|
||||
|
||||
如下图所示,Master节点在可用区1,Slave节点在可用区2,当可用区1和可用区2发生网络分区后,部署在可用区2的Redis Sentinel服务就会将可用区2的Slave提升为Master,而此时可用区1的Master也在对外提供服务。因此集群就出现了脑裂,出现了两个Master,都可对外提供分布式锁申请与释放服务,分布式锁的互斥性被严重破坏。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/cb/b1/cb4cb52cf2244d2000884ef5f5ff3db1.png" alt="">
|
||||
|
||||
**主备切换、脑裂是Redis分布式锁的两个典型不安全的因素,本质原因是Redis为了满足高性能,采用了主备异步复制协议,同时也与负责主备切换的Redis Sentinel服务是否合理部署有关。**
|
||||
|
||||
有没有其他方案解决呢?
|
||||
|
||||
当然有,Redis作者为了解决SET key value [EX] 10 [NX]命令实现分布式锁不安全的问题,提出了[RedLock算法](https://redis.io/topics/distlock)。它是基于多个独立的Redis Master节点的一种实现(一般为5)。client依次向各个节点申请锁,若能从多数个节点中申请锁成功并满足一些条件限制,那么client就能获取锁成功。
|
||||
|
||||
它通过独立的N个Master节点,避免了使用主备异步复制协议的缺陷,只要多数Redis节点正常就能正常工作,显著提升了分布式锁的安全性、可用性。
|
||||
|
||||
但是,它的实现建立在一个不安全的系统模型上的,它依赖系统时间,当时钟发生跳跃时,也可能会出现安全性问题。你要有兴趣的话,可以详细阅读下分布式存储专家Martin对[RedLock的分析文章](https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html),Redis作者的也专门写了[一篇文章进行了反驳](http://antirez.com/news/101)。
|
||||
|
||||
## 分布式锁常见实现方案
|
||||
|
||||
了解完Redis分布式锁的一系列问题和实现方案后,我们再看看还有哪些典型的分布式锁实现。
|
||||
|
||||
除了Redis分布式锁,其他使用最广的应该是ZooKeeper分布式锁和etcd分布式锁。
|
||||
|
||||
ZooKeeper也是一个典型的分布式元数据存储服务,它的分布式锁实现基于ZooKeeper的临时节点和顺序特性。
|
||||
|
||||
首先什么是临时节点呢?
|
||||
|
||||
临时节点具备数据自动删除的功能。当client与ZooKeeper连接和session断掉时,相应的临时节点就会被删除。
|
||||
|
||||
其次ZooKeeper也提供了Watch特性可监听key的数据变化。
|
||||
|
||||
[使用Zookeeper加锁的伪代码如下](https://www.usenix.org/legacy/event/atc10/tech/full_papers/Hunt.pdf):
|
||||
|
||||
```
|
||||
Lock
|
||||
1 n = create(l + “/lock-”, EPHEMERAL|SEQUENTIAL)
|
||||
2 C = getChildren(l, false)
|
||||
3 if n is lowest znode in C, exit
|
||||
4 p = znode in C ordered just before n
|
||||
5 if exists(p, true) wait for watch event
|
||||
6 goto 2
|
||||
Unlock
|
||||
1 delete(n)
|
||||
|
||||
```
|
||||
|
||||
接下来我重点给你介绍一下基于etcd的分布式锁实现。
|
||||
|
||||
## etcd分布式锁实现
|
||||
|
||||
那么基于etcd实现的分布式锁是如何确保安全性、互斥性、活性的呢?
|
||||
|
||||
### 事务与锁的安全性
|
||||
|
||||
从Redis案例中我们可以看到,加锁的过程需要确保安全性、互斥性。比如,当key不存在时才能创建,否则查询相关key信息,而etcd提供的事务能力正好可以满足我们的诉求。
|
||||
|
||||
正如我在[09](https://time.geekbang.org/column/article/341935)中给你介绍的事务特性,它由IF语句、Then语句、Else语句组成。其中在IF语句中,支持比较key的是修改版本号mod_revision和创建版本号create_revision。
|
||||
|
||||
在分布式锁场景,你就可以通过key的创建版本号create_revision来检查key是否已存在,因为一个key不存在的话,它的create_revision版本号就是0。
|
||||
|
||||
若create_revision是0,你就可发起put操作创建相关key,具体代码如下:
|
||||
|
||||
```
|
||||
txn := client.Txn(ctx).If(v3.Compare(v3.CreateRevision(k),
|
||||
"=", 0))
|
||||
|
||||
```
|
||||
|
||||
你要注意的是,实现分布式锁的方案有多种,比如你可以通过client是否成功创建一个固定的key,来判断此client是否获得锁,你也可以通过多个client创建prefix相同,名称不一样的key,哪个key的revision最小,最终就是它获得锁。至于谁优谁劣,我作为思考题的一部分,留给大家一起讨论。
|
||||
|
||||
相比Redis基于主备异步复制导致锁的安全性问题,etcd是基于Raft共识算法实现的,一个写请求需要经过集群多数节点确认。因此一旦分布式锁申请返回给client成功后,它一定是持久化到了集群多数节点上,不会出现Redis主备异步复制可能导致丢数据的问题,具备更高的安全性。
|
||||
|
||||
### Lease与锁的活性
|
||||
|
||||
通过事务实现原子的检查key是否存在、创建key后,我们确保了分布式锁的安全性、互斥性。那么etcd是如何确保锁的活性呢? 也就是发生任何故障,都可避免出现死锁呢?
|
||||
|
||||
正如在[06](https://time.geekbang.org/column/article/339337)租约特性中和你介绍的,Lease就是一种活性检测机制,它提供了检测各个客户端存活的能力。你的业务client需定期向etcd服务发送"特殊心跳"汇报健康状态,若你未正常发送心跳,并超过和etcd服务约定的最大存活时间后,就会被etcd服务移除此Lease和其关联的数据。
|
||||
|
||||
通过Lease机制就优雅地解决了client出现crash故障、client与etcd集群网络出现隔离等各类故障场景下的死锁问题。一旦超过Lease TTL,它就能自动被释放,确保了其他client在TTL过期后能正常申请锁,保障了业务的可用性。
|
||||
|
||||
具体代码如下:
|
||||
|
||||
```
|
||||
txn := client.Txn(ctx).If(v3.Compare(v3.CreateRevision(k), "=", 0))
|
||||
txn = txn.Then(v3.OpPut(k, val, v3.WithLease(s.Lease())))
|
||||
txn = txn.Else(v3.OpGet(k))
|
||||
resp, err := txn.Commit()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### Watch与锁的可用性
|
||||
|
||||
当一个持有锁的client crash故障后,其他client如何快速感知到此锁失效了,快速获得锁呢,最大程度降低锁的不可用时间呢?
|
||||
|
||||
答案是Watch特性。正如在08 Watch特性中和你介绍的,Watch提供了高效的数据监听能力。当其他client收到Watch Delete事件后,就可快速判断自己是否有资格获得锁,极大减少了锁的不可用时间。
|
||||
|
||||
具体代码如下所示:
|
||||
|
||||
```
|
||||
var wr v3.WatchResponse
|
||||
wch := client.Watch(cctx, key, v3.WithRev(rev))
|
||||
for wr = range wch {
|
||||
for _, ev := range wr.Events {
|
||||
if ev.Type == mvccpb.DELETE {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### etcd自带的concurrency包
|
||||
|
||||
为了帮助你简化分布式锁、分布式选举、分布式事务的实现,etcd社区提供了一个名为concurrency包帮助你更简单、正确地使用分布式锁、分布式选举。
|
||||
|
||||
下面我简单为你介绍下分布式锁[concurrency](https://github.com/etcd-io/etcd/tree/v3.4.9/clientv3/concurrency)包的使用和实现,它的使用非常简单,如下代码所示,核心流程如下:
|
||||
|
||||
- 首先通过concurrency.NewSession方法创建Session,本质是创建了一个TTL为10的Lease。
|
||||
- 其次得到session对象后,通过concurrency.NewMutex创建了一个mutex对象,包含Lease、key prefix等信息。
|
||||
- 然后通过mutex对象的Lock方法尝试获取锁。
|
||||
- 最后使用结束,可通过mutex对象的Unlock方法释放锁。
|
||||
|
||||
```
|
||||
cli, err := clientv3.New(clientv3.Config{Endpoints: endpoints})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer cli.Close()
|
||||
// create two separate sessions for lock competition
|
||||
s1, err := concurrency.NewSession(cli, concurrency.WithTTL(10))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer s1.Close()
|
||||
m1 := concurrency.NewMutex(s1, "/my-lock/")
|
||||
// acquire lock for s1
|
||||
if err := m1.Lock(context.TODO()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println("acquired lock for s1")
|
||||
if err := m1.Unlock(context.TODO()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println("released lock for s1")
|
||||
|
||||
```
|
||||
|
||||
那么mutex对象的Lock方法是如何加锁的呢?
|
||||
|
||||
核心还是使用了我们上面介绍的事务和Lease特性,当CreateRevision为0时,它会创建一个prefix为/my-lock的key( /my-lock + LeaseID),并获取到/my-lock prefix下面最早创建的一个key(revision最小),分布式锁最终是由写入此key的client获得,其他client则进入等待模式。
|
||||
|
||||
详细代码如下:
|
||||
|
||||
```
|
||||
m.myKey = fmt.Sprintf("%s%x", m.pfx, s.Lease())
|
||||
cmp := v3.Compare(v3.CreateRevision(m.myKey), "=", 0)
|
||||
// put self in lock waiters via myKey; oldest waiter holds lock
|
||||
put := v3.OpPut(m.myKey, "", v3.WithLease(s.Lease()))
|
||||
// reuse key in case this session already holds the lock
|
||||
get := v3.OpGet(m.myKey)
|
||||
// fetch current holder to complete uncontended path with only one RPC
|
||||
getOwner := v3.OpGet(m.pfx, v3.WithFirstCreate()...)
|
||||
resp, err := client.Txn(ctx).If(cmp).Then(put, getOwner).Else(get, getOwner).Commit()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
那未获得锁的client是如何等待的呢?
|
||||
|
||||
答案是通过Watch机制各自监听prefix相同,revision比自己小的key,因为只有revision比自己小的key释放锁,我才能有机会,获得锁,如下代码所示,其中waitDelete会使用我们上面的介绍的Watch去监听比自己小的key,详细代码可参考[concurrency mutex](https://github.com/etcd-io/etcd/blob/v3.4.9/clientv3/concurrency/mutex.go)的实现。
|
||||
|
||||
```
|
||||
// wait for deletion revisions prior to myKey
|
||||
hdr, werr := waitDeletes(ctx, client, m.pfx, m.myRev-1)
|
||||
// release lock key if wait failed
|
||||
if werr != nil {
|
||||
m.Unlock(client.Ctx())
|
||||
} else {
|
||||
m.hdr = hdr
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 小结
|
||||
|
||||
最后我们来小结下今天的内容。
|
||||
|
||||
今天我通过一个Redis分布式锁实现问题——茅台超卖案例,给你介绍了分布式锁的三个主要核心要素,它们分别如下:
|
||||
|
||||
- 安全性、互斥性。在同一时间内,不允许多个client同时获得锁。
|
||||
- 活性。无论client出现crash还是遭遇网络分区,你都需要确保任意故障场景下,都不会出现死锁,常用的解决方案是超时和自动过期机制。
|
||||
- 高可用、高性能。加锁、释放锁的过程性能开销要尽量低,同时要保证高可用,避免单点故障。
|
||||
|
||||
随后我通过这个案例,继续和你分析了Redis SET命令创建分布式锁的安全性问题。单Redis Master节点存在单点故障,一主多备Redis实例又因为Redis主备异步复制,当Master节点发生crash时,可能会导致同时多个client持有分布式锁,违反了锁的安全性问题。
|
||||
|
||||
为了优化以上问题,Redis作者提出了RedLock分布式锁,它基于多个独立的Redis Master节点工作,只要一半以上节点存活就能正常工作,同时不依赖Redis主备异步复制,具有良好的安全性、高可用性。然而它的实现依赖于系统时间,当发生时钟跳变的时候,也会出现安全性问题。
|
||||
|
||||
最后我和你重点介绍了etcd的分布式锁实现过程中的一些技术点。它通过etcd事务机制,校验CreateRevision为0才能写入相关key。若多个client同时申请锁,则client通过比较各个key的revision大小,判断是否获得锁,确保了锁的安全性、互斥性。通过Lease机制确保了锁的活性,无论client发生crash还是网络分区,都能保证不会出现死锁。通过Watch机制使其他client能快速感知到原client持有的锁已释放,提升了锁的可用性。最重要的是etcd是基于Raft协议实现的高可靠、强一致存储,正常情况下,不存在Redis主备异步复制协议导致的数据丢失问题。
|
||||
|
||||
## 思考题
|
||||
|
||||
这节课到这里也就结束了,最后我给你留了两个思考题。
|
||||
|
||||
第一,死锁、脑裂、惊群效应是分布式锁的核心问题,你知道它们各自是怎么一回事吗?ZooKeeper和etcd是如何应对这些问题的呢?
|
||||
|
||||
第二,若你锁设置的10秒,如果你的某业务进程抢锁成功后,执行可能会超过10秒才成功,在这过程中如何避免锁被自动释放而出现的安全性问题呢?
|
||||
|
||||
感谢你的阅读,也欢迎你把这篇文章分享给更多的朋友一起阅读。
|
||||
@@ -0,0 +1,317 @@
|
||||
<audio id="audio" title="22 | 配置及服务发现:解析etcd在API Gateway开源项目中应用" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0a/5c/0a5a0df51e06568f94d7520ceb6d225c.mp3"></audio>
|
||||
|
||||
你好,我是唐聪。
|
||||
|
||||
在软件开发的过程中,为了提升代码的灵活性和开发效率,我们大量使用配置去控制程序的运行行为。
|
||||
|
||||
从简单的数据库账号密码配置,到[confd](https://github.com/kelseyhightower/confd)支持以etcd为后端存储的本地配置及模板管理,再到[Apache APISIX](https://github.com/apache/apisix)等API Gateway项目使用etcd存储服务配置、路由信息等,最后到Kubernetes更实现了Secret和ConfigMap资源对象来解决配置管理的问题。
|
||||
|
||||
那么它们是如何实现实时、动态调整服务配置而不需要重启相关服务的呢?
|
||||
|
||||
今天我就和你聊聊etcd在配置和服务发现场景中的应用。我将以开源项目Apache APISIX为例,为你分析服务发现的原理,带你了解etcd的key-value模型,Watch机制,鉴权机制,Lease特性,事务特性在其中的应用。
|
||||
|
||||
希望通过这节课,让你了解etcd在配置系统和服务发现场景工作原理,帮助你选型适合业务场景的配置系统、服务发现组件。同时,在使用Apache APISIX等开源项目过程中遇到etcd相关问题时,你能独立排查、分析,并向社区提交issue和PR解决。
|
||||
|
||||
## 服务发现
|
||||
|
||||
首先和你聊聊服务发现,服务发现是指什么?为什么需要它呢?
|
||||
|
||||
为了搞懂这个问题,我首先和你分享下程序部署架构的演进。
|
||||
|
||||
### 单体架构
|
||||
|
||||
在早期软件开发时使用的是单体架构,也就是所有功能耦合在同一个项目中,统一构建、测试、发布。单体架构在项目刚启动的时候,架构简单、开发效率高,比较容易部署、测试。但是随着项目不断增大,它具有若干缺点,比如:
|
||||
|
||||
- 所有功能耦合在同一个项目中,修复一个小Bug就需要发布整个大工程项目,增大引入问题风险。同时随着开发人员增多、单体项目的代码增长、各模块堆砌在一起、代码质量参差不齐,内部复杂度会越来越高,可维护性差。
|
||||
- 无法按需针对仅出现瓶颈的功能模块进行弹性扩容,只能作为一个整体继续扩展,因此扩展性较差。
|
||||
- 一旦单体应用宕机,将导致所有服务不可用,因此可用性较差。
|
||||
|
||||
### 分布式及微服务架构
|
||||
|
||||
如何解决以上痛点呢?
|
||||
|
||||
当然是将单体应用进行拆分,大而化小。如何拆分呢? 这里我就以一个我曾经参与重构建设的电商系统为案例给你分析一下。在一个单体架构中,完整的电商系统应包括如下模块:
|
||||
|
||||
- 商城系统,负责用户登录、查看及搜索商品、购物车商品管理、优惠券管理、订单管理、支付等功能。
|
||||
- 物流及仓储系统,根据用户订单,进行发货、退货、换货等一系列仓储、物流管理。
|
||||
- 其他客服系统、客户管理系统等。
|
||||
|
||||
因此在分布式架构中,你可以按整体功能,将单体应用垂直拆分成以上三大功能模块,各个功能模块可以选择不同的技术栈实现,按需弹性扩缩容,如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ca/20/ca6090e229dde9a0361d6yy2c3df8d20.png" alt="">
|
||||
|
||||
那什么又是微服务架构呢?
|
||||
|
||||
它是对各个功能模块进行更细立度的拆分,比如商城系统模块可以拆分成:
|
||||
|
||||
- 用户鉴权模块;
|
||||
- 商品模块;
|
||||
- 购物车模块;
|
||||
- 优惠券模块;
|
||||
- 支付模块;
|
||||
- ……
|
||||
|
||||
在微服务架构中,每个模块职责更单一、独立部署、开发迭代快,如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/cf/4a/cf62b7704446c05d8747b4672b5fb74a.png" alt="">
|
||||
|
||||
那么在分布式及微服务架构中,各个模块之间如何及时知道对方网络地址与端口、协议,进行接口调用呢?
|
||||
|
||||
### 为什么需要服务发现中间件?
|
||||
|
||||
其实这个知道的过程,就是服务发现。在早期的时候我们往往通过硬编码、配置文件声明各个依赖模块的网络地址、端口,然而这种方式在分布式及微服务架构中,其运维效率、服务可用性是远远不够的。
|
||||
|
||||
那么我们能否实现通过一个特殊服务就查询到各个服务的后端部署地址呢? 各服务启动的时候,就自动将IP和Port、协议等信息注册到特殊服务上,当某服务出现异常的时候,特殊服务就自动删除异常实例信息?
|
||||
|
||||
是的,当然可以,这个特殊服务就是注册中心服务,你可以基于etcd、ZooKeeper、consul等实现。
|
||||
|
||||
### etcd服务发现原理
|
||||
|
||||
那么如何基于etcd实现服务发现呢?
|
||||
|
||||
下面我给出了一个通用的服务发现原理架构图,通过此图,为你介绍下服务发现的基本原理。详细如下:
|
||||
|
||||
- 整体上分为四层,client层、proxy层(可选)、业务server、etcd存储层组成。引入proxy层的原因是使client更轻、逻辑更简单,无需直接访问存储层,同时可通过proxy层支持各种协议。
|
||||
- client层通过负载均衡访问proxy组件。proxy组件启动的时候,通过etcd的Range RPC方法从etcd读取初始化服务配置数据,随后通过Watch接口持续监听后端业务server扩缩容变化,实时修改路由。
|
||||
- proxy组件收到client的请求后,它根据从etcd读取到的对应服务的路由配置、负载均衡算法(比如Round-robin)转发到对应的业务server。
|
||||
- 业务server启动的时候,通过etcd的写接口Txn/Put等,注册自身地址信息、协议到高可用的etcd集群上。业务server缩容、故障时,对应的key应能自动从etcd集群删除,因此相关key需要关联lease信息,设置一个合理的TTL,并定时发送keepalive请求给Leader续租,以防止租约及key被淘汰。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/26/e4/26d0d18c0725de278eeb7505f20642e4.png" alt="">
|
||||
|
||||
当然,在分布式及微服务架构中,我们面对的问题不仅仅是服务发现,还包括如下痛点:
|
||||
|
||||
- 限速;
|
||||
- 鉴权;
|
||||
- 安全;
|
||||
- 日志;
|
||||
- 监控;
|
||||
- 丰富的发布策略;
|
||||
- 链路追踪;
|
||||
- ......
|
||||
|
||||
为了解决以上痛点,各大公司及社区开发者推出了大量的开源项目。这里我就以国内开发者广泛使用的Apache APISIX项目为例,为你分析etcd在其中的应用,了解下它是怎么玩转服务发现的。
|
||||
|
||||
### Apache APISIX原理
|
||||
|
||||
Apache APISIX它具备哪些功能呢?
|
||||
|
||||
它的本质是一个无状态、高性能、实时、动态、可水平扩展的API网关。核心原理就是基于你配置的服务信息、路由规则等信息,将收到的请求通过一系列规则后,正确转发给后端的服务。
|
||||
|
||||
Apache APISIX其实就是上面服务发现原理架构图中的proxy组件,如下图红色虚线框所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/20/fd/20a539bdd37db2d4632c7b0c5f4119fd.png" alt="">
|
||||
|
||||
Apache APISIX详细架构图如下([引用自社区项目文档](https://github.com/apache/apisix))。从图中你可以看到,它由控制面和数据面组成。
|
||||
|
||||
控制面顾名思义,就是你通过Admin API下发服务、路由、安全配置的操作。控制面默认的服务发现存储是etcd,当然也支持consul、nacos等。
|
||||
|
||||
你如果没有使用过Apache APISIX的话,可以参考下这个[example](https://github.com/apache/apisix-docker/tree/master/example),快速、直观的了解下Apache APISIX是如何通过Admin API下发服务和路由配置的。
|
||||
|
||||
数据面是在实现基于服务路由信息数据转发的基础上,提供了限速、鉴权、安全、日志等一系列功能,也就是解决了我们上面提的分布式及微服务架构中的典型痛点。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/83/f4/834502c6ed7e59fe0b4643c11b2d31f4.png" alt="">
|
||||
|
||||
那么当我们通过控制面API新增一个服务时,Apache APISIX是是如何实现实时、动态调整服务配置,而不需要重启网关服务的呢?
|
||||
|
||||
下面,我就和你聊聊etcd在Apache APISIX项目中的应用。
|
||||
|
||||
### etcd在Apache APISIX中的应用
|
||||
|
||||
在搞懂这个问题之前,我们先看看Apache APISIX在etcd中,都存储了哪些数据呢?它的数据存储格式是怎样的?
|
||||
|
||||
#### 数据存储格式
|
||||
|
||||
下面我参考Apache APISIX的[example](https://github.com/apache/apisix-docker/tree/master/example)案例(apisix:2.3),通过Admin API新增了两个服务、路由规则后,执行如下查看etcd所有key的命令:
|
||||
|
||||
```
|
||||
etcdctl get "" --prefix --keys-only
|
||||
|
||||
```
|
||||
|
||||
etcd输出结果如下:
|
||||
|
||||
```
|
||||
/apisix/consumers/
|
||||
/apisix/data_plane/server_info/f7285805-73e9-4ce4-acc6-a38d619afdc3
|
||||
/apisix/global_rules/
|
||||
/apisix/node_status/
|
||||
/apisix/plugin_metadata/
|
||||
/apisix/plugins
|
||||
/apisix/plugins/
|
||||
/apisix/proto/
|
||||
/apisix/routes/
|
||||
/apisix/routes/12
|
||||
/apisix/routes/22
|
||||
/apisix/services/
|
||||
/apisix/services/1
|
||||
/apisix/services/2
|
||||
/apisix/ssl/
|
||||
/apisix/ssl/1
|
||||
/apisix/ssl/2
|
||||
/apisix/stream_routes/
|
||||
/apisix/upstreams/
|
||||
|
||||
```
|
||||
|
||||
然后我们继续通过etcdctl get命令查看下services都存储了哪些信息呢?
|
||||
|
||||
```
|
||||
root@e9d3b477ca1f:/opt/bitnami/etcd# etcdctl get /apisix/services --prefix
|
||||
/apisix/services/
|
||||
init_dir
|
||||
/apisix/services/1
|
||||
{"update_time":1614293352,"create_time":1614293352,"upstream":{"type":"roundrobin","nodes":{"172.18.5.12:80":1},"hash_on":"vars","scheme":"http","pass_host":"pass"},"id":"1"}
|
||||
/apisix/services/2
|
||||
{"update_time":1614293361,"create_time":1614293361,"upstream":
|
||||
{"type":"roundrobin","nodes":{"172.18.5.13:80":1},"hash_on":"vars","scheme":"http","pass_host":"pass"},"id":"2"}
|
||||
|
||||
```
|
||||
|
||||
从中我们可以总结出如下信息:
|
||||
|
||||
- Apache APSIX 2.x系列版本使用的是etcd3。
|
||||
- 服务、路由、ssl、插件等配置存储格式前缀是/apisix + "/" + 功能特性类型(routes/services/ssl等),我们通过Admin API添加的路由、服务等配置就保存在相应的前缀下。
|
||||
- 路由和服务配置的value是个Json对象,其中服务对象包含了id、负载均衡算法、后端节点、协议等信息。
|
||||
|
||||
了解完Apache APISIX在etcd中的数据存储格式后,那么它是如何动态、近乎实时地感知到服务配置变化的呢?
|
||||
|
||||
#### Watch机制的应用
|
||||
|
||||
与Kubernetes一样,它们都是通过etcd的**Watch机制**来实现的。
|
||||
|
||||
Apache APISIX在启动的时候,首先会通过Range操作获取网关的配置、路由等信息,随后就通过Watch机制,获取增量变化事件。
|
||||
|
||||
使用Watch机制最容易犯错的地方是什么呢?
|
||||
|
||||
答案是不处理Watch返回的相关错误信息,比如已压缩ErrCompacted错误。Apache APISIX项目在从etcd v2中切换到etcd v3早期的时候,同样也犯了这个错误。
|
||||
|
||||
去年某日收到小伙伴求助,说使用Apache APISIX后,获取不到新的服务配置了,是不是etcd出什么Bug了?
|
||||
|
||||
经过一番交流和查看日志,发现原来是Apache APISIX未处理ErrCompacted错误导致的。根据我们[07](https://time.geekbang.org/column/article/340226)Watch原理的介绍,当你请求Watch的版本号已被etcd压缩后,etcd就会取消这个watcher,这时你需要重建watcher,才能继续监听到最新数据变化事件。
|
||||
|
||||
查清楚问题后,小伙伴向社区提交了issue反馈,随后Apache APISIX相关同学通过[PR 2687](https://github.com/apache/apisix/pull/2687)修复了此问题,更多信息你可参考Apache APISIX访问etcd[相关实现代码文件](https://github.com/apache/apisix/blob/v2.3/apisix/core/etcd.lua)。
|
||||
|
||||
#### 鉴权机制的应用
|
||||
|
||||
除了Watch机制,Apache APISIX项目还使用了鉴权,毕竟配置网关是个高危操作,那它是如何使用etcd鉴权机制的呢? **etcd鉴权机制**中最容易踩的坑是什么呢?
|
||||
|
||||
答案是不复用client和鉴权token,频繁发起Authenticate操作,导致etcd高负载。正如我在[17](https://time.geekbang.org/column/article/346471)和你介绍的,一个8核32G的高配节点在100个连接时,Authenticate QPS仅为8。可想而知,你如果不复用token,那么出问题就很自然不过了。
|
||||
|
||||
Apache APISIX是否也踩了这个坑呢?
|
||||
|
||||
Apache APISIX是基于Lua构建的,使用的是[lua-resty-etcd](https://github.com/api7/lua-resty-etcd/blob/master/lib/resty/etcd/v3.lua)这个项目访问etcd,从相关[issue](https://github.com/apache/apisix/issues/2899)反馈看,的确也踩了这个坑。社区用户反馈后,随后通过复用client、更完善的token复用机制解决了Authenticate的性能瓶颈,详细信息你可参考[PR 2932](https://github.com/apache/apisix/pull/2932)、[PR 100](https://github.com/api7/lua-resty-etcd/pull/100)。
|
||||
|
||||
除了以上介绍的Watch机制、鉴权机制,Apache APISIX还使用了etcd的Lease特性和事务接口。
|
||||
|
||||
#### Lease特性的应用
|
||||
|
||||
为什么Apache APISIX项目需要Lease特性呢?
|
||||
|
||||
服务发现的核心工作原理是服务启动的时候将地址信息登录到注册中心,服务异常时自动从注册中心删除。
|
||||
|
||||
这是不是跟我们前面[05](https://time.geekbang.org/column/article/338524)节介绍的<Lease特性: 如何检测客户端的存活性>应用场景很匹配呢?
|
||||
|
||||
没错,Apache APISIX通过etcd v2的TTL特性、etcd v3的Lease特性来实现类似的效果,它提供的增加服务路由API,支持设置TTL属性,如下面所示:
|
||||
|
||||
```
|
||||
# Create a route expires after 60 seconds, then it's deleted automatically
|
||||
$ curl http://127.0.0.1:9080/apisix/admin/routes/2?ttl=60 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -i -d '
|
||||
{
|
||||
"uri": "/aa/index.html",
|
||||
"upstream": {
|
||||
"type": "roundrobin",
|
||||
"nodes": {
|
||||
"39.97.63.215:80": 1
|
||||
}
|
||||
}
|
||||
}'
|
||||
|
||||
```
|
||||
|
||||
当一个路由设置非0 TTL后,Apache APISIX就会为它创建Lease,关联key,相关代码如下:
|
||||
|
||||
```
|
||||
-- lease substitute ttl in v3
|
||||
local res, err
|
||||
if ttl then
|
||||
local data, grant_err = etcd_cli:grant(tonumber(ttl))
|
||||
if not data then
|
||||
return nil, grant_err
|
||||
end
|
||||
res, err = etcd_cli:set(prefix .. key, value, {prev_kv = true, lease = data.body.ID})
|
||||
else
|
||||
res, err = etcd_cli:set(prefix .. key, value, {prev_kv = true})
|
||||
end
|
||||
|
||||
```
|
||||
|
||||
#### 事务特性的应用
|
||||
|
||||
介绍完Lease特性在Apache APISIX项目中的应用后,我们再来思考两个问题。为什么它还依赖etcd的事务特性呢?简单的执行put接口有什么问题?
|
||||
|
||||
答案是它跟Kubernetes是一样的使用目的。使用事务是为了防止并发场景下的数据写冲突,比如你可能同时发起两个Patch Admin API去修改配置等。如果简单地使用put接口,就会导致第一个写请求的结果被覆盖。
|
||||
|
||||
Apache APISIX是如何使用事务接口提供的乐观锁机制去解决并发冲突的问题呢?
|
||||
|
||||
核心依然是我们前面课程中一直强调的mod_revision,它会比较事务提交时的mod_revision与预期是否一致,一致才能执行put操作,Apache APISIX相关使用代码如下:
|
||||
|
||||
```
|
||||
local compare = {
|
||||
{
|
||||
key = key,
|
||||
target = "MOD",
|
||||
result = "EQUAL",
|
||||
mod_revision = mod_revision,
|
||||
}
|
||||
}
|
||||
local success = {
|
||||
{
|
||||
requestPut = {
|
||||
key = key,
|
||||
value = value,
|
||||
lease = lease_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
local res, err = etcd_cli:txn(compare, success)
|
||||
if not res then
|
||||
return nil, err
|
||||
end
|
||||
|
||||
```
|
||||
|
||||
关于Apache APISIX事务特性的引入、背景以及更详细的实现,你也可以参考[PR 2216](https://github.com/apache/apisix/pull/2216)。
|
||||
|
||||
## 小结
|
||||
|
||||
最后我们来小结下今天的内容。今天我给你介绍了服务部署架构的演进,我们从单体架构的缺陷开始、到分布式及微服务架构的诞生,和你分享了分布式及微服务架构中面临的一系列痛点(如服务发现,鉴权,安全,限速等等)。
|
||||
|
||||
而开源项目Apache APISIX正是一个基于etcd的项目,它为后端存储提供了一系列的解决方案,我通过它的架构图为你介绍了其控制面和数据面的工作原理。
|
||||
|
||||
随后我从数据存储格式、Watch机制、鉴权机制、Lease特性以及事务特性维度,和你分析了它们在Apache APISIX项目中的应用。
|
||||
|
||||
数据存储格式上,APISIX采用典型的prefix + 功能特性组织格式。key是相关配置id,value是个json对象,包含一系列业务所需要的核心数据。你需要注意的是Apache APISIX 1.x版本使用的etcd v2 API,2.x版本使用的是etcd v3 API,要求至少是etcd v3.4版本以上。
|
||||
|
||||
Watch机制上,APISIX依赖它进行配置的动态、实时更新,避免了传统的修改配置,需要服务重启等缺陷。
|
||||
|
||||
鉴权机制上,APISIX使用密码认证,进行多租户认证、授权,防止用户出现越权访问,保护网关服务的安全。
|
||||
|
||||
Lease及事务特性上,APISIX通过Lease来设置自动过期的路由规则,解决服务发现中的节点异常自动剔除等问题,通过事务特性的乐观锁机制来实现并发场景下覆盖更新等问题。
|
||||
|
||||
希望通过本节课的学习,让你从etcd角度更深入了解APISIX项目的原理,了解etcd各个特性在其中的应用,学习它的最佳实践经验和经历的各种坑,避免重复踩坑。在以后的工作中,在你使用APISIX等开源项目遇到etcd相关错误时,能独立分析、排查,甚至给社区提交PR解决。
|
||||
|
||||
## 思考题
|
||||
|
||||
好了,这节课到这里也就结束了,最后我给你留了一个开放的配置系统设计思考题。
|
||||
|
||||
假设老板让你去设计一个大型配置系统,满足公司各个业务场景的诉求,期望的设计目标如下:
|
||||
|
||||
- 高可靠。配置系统的作为核心基础设施,期望可用性能达到99.99%。
|
||||
- 高性能。公司业务多,规模大,配置系统应具备高性能、并能水平扩容。
|
||||
- 支持多业务、多版本管理、多种发布策略。
|
||||
|
||||
你认为etcd适合此业务场景吗?如果适合,分享下你的核心想法、整体架构,如果不适合,你心目中的理想存储和架构又是怎样的呢?
|
||||
|
||||
欢迎大家留言一起讨论,后面我也将在答疑篇中分享我的一些想法和曾经大规模TO C业务中的实践经验。
|
||||
|
||||
感谢你阅读,也欢迎你把这篇文章分享给更多的朋友一起阅读。
|
||||
@@ -0,0 +1,234 @@
|
||||
<audio id="audio" title="23 | 选型:etcd/ZooKeeper/Consul等我们该如何选择?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/bb/f1/bb96b3a33d18db1d5dca53e4f477d6f1.mp3"></audio>
|
||||
|
||||
你好,我是唐聪。
|
||||
|
||||
在软件开发过程中,当我们需要解决配置、服务发现、分布式锁等业务痛点,在面对[etcd](https://github.com/etcd-io/etcd)、[ZooKeeper](https://github.com/apache/zookeeper)、[Consul](https://github.com/hashicorp/consul)、[Nacos](https://github.com/alibaba/nacos)等一系列候选开源项目时,我们应该如何结合自己的业务场景,选择合适的分布式协调服务呢?
|
||||
|
||||
今天,我就和你聊聊主要分布式协调服务的对比。我将从基本架构、共识算法、数据模型、重点特性、容灾能力等维度出发,带你了解主要分布式协调服务的基本原理和彼此之间的差异性。
|
||||
|
||||
希望通过这节课,让你对etcd、ZooKeeper、Consul原理和特性有一定的理解,帮助你选型适合业务场景的配置系统、服务发现组件。
|
||||
|
||||
## 基本架构及原理
|
||||
|
||||
在详细和你介绍对比etcd、ZooKeeper、Consul特性之前,我们先从整体架构上来了解一下各开源项目的核心架构及原理。
|
||||
|
||||
### etcd架构及原理
|
||||
|
||||
首先是etcd,etcd我们知道它是基于复制状态机实现的分布式协调服务。如下图所示,由Raft共识模块、日志模块、基于boltdb持久化存储的状态机组成。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5c/4f/5c7a3079032f90120a6b309ee401fc4f.png" alt="">
|
||||
|
||||
以下是etcd基于复制状态机模型的写请求流程:
|
||||
|
||||
- client发起一个写请求(put x = 3);
|
||||
- etcdserver模块向Raft共识模块提交请求,共识模块生成一个写提案日志条目。若server是Leader,则把日志条目广播给其他节点,并持久化日志条目到WAL中;
|
||||
- 当一半以上节点持久化日志条目后,Leader的共识模块将此日志条目标记为已提交(committed),并通知其他节点提交;
|
||||
- etcdserver模块从Raft共识模块获取已经提交的日志条目,异步应用到boltdb状态机存储中,然后返回给client。
|
||||
|
||||
更详细的原理我就不再重复描述,你可以参考[02](https://time.geekbang.org/column/article/335932)读和[03](https://time.geekbang.org/column/article/336766)写两节原理介绍。
|
||||
|
||||
### ZooKeeper架构及原理
|
||||
|
||||
接下来我和你简要介绍下[ZooKeeper](https://zookeeper.apache.org/doc/current/zookeeperOver.html)原理,下图是它的架构图。
|
||||
|
||||
如下面架构图所示,你可以看到ZooKeeper中的节点与etcd类似,也划分为Leader节点、Follower节点、Observer节点(对应的Raft协议的Learner节点)。同时,写请求统一由Leader处理,读请求各个节点都能处理。
|
||||
|
||||
不一样的是它们的读行为和共识算法。
|
||||
|
||||
- 在读行为上,ZooKeeper默认读可能会返回stale data,而etcd使用的线性读,能确保读取到反应集群共识的最新数据。
|
||||
- 共识算法上,etcd使用的是Raft,ZooKeeper使用的是Zab。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7a/d3/7a84bcaef9e53ba19d7d88e6ed6504d3.png" alt="">
|
||||
|
||||
那什么是Zab协议呢?
|
||||
|
||||
Zab协议可以分为以下阶段:
|
||||
|
||||
- Phase 0,Leader选举(Leader Election)。一个节点只要求获得半数以上投票,就可以当选为准Leader;
|
||||
- Phase 1,发现(Discovery)。准Leader收集其他节点的数据信息,并将最新的数据复制到自身;
|
||||
- Phase 2,同步(Synchronization)。准Leader将自身最新数据复制给其他落后的节点,并告知其他节点自己正式当选为Leader;
|
||||
- Phase 3,广播(Broadcast)。Leader正式对外服务,处理客户端写请求,对消息进行广播。当收到一个写请求后,它会生成Proposal广播给各个Follower节点,一半以上Follower节点应答之后,Leader再发送Commit命令给各个Follower,告知它们提交相关提案;
|
||||
|
||||
ZooKeeper是如何实现的Zab协议的呢?
|
||||
|
||||
ZooKeeper在实现中并未严格按[论文](https://marcoserafini.github.io/papers/zab.pdf)定义的分阶段实现,而是对部分阶段进行了整合,分别如下:
|
||||
|
||||
- Fast Leader Election。首先ZooKeeper使用了一个名为Fast Leader Election的选举算法,通过Leader选举安全规则限制,确保选举出来的Leader就含有最新数据, 避免了Zab协议的Phase 1阶段准Leader收集各个节点数据信息并复制到自身,也就是将Phase 0和Phase 1进行了合并。
|
||||
- Recovery Phase。各个Follower发送自己的最新数据信息给Leader,Leader根据差异情况,选择发送SNAP、DIFF差异数据、Truncate指令删除冲突数据等,确保Follower追赶上Leader数据进度并保持一致。
|
||||
- Broadcast Phase。与Zab论文Broadcast Phase一致。
|
||||
|
||||
总体而言,从分布式系统CAP维度来看,ZooKeeper与etcd类似的是,它也是一个CP系统,在出现网络分区等错误时,它优先保障的数据一致性,牺牲的是A可用性。
|
||||
|
||||
### Consul架构及原理
|
||||
|
||||
了解完ZooKeeper架构及原理后,我们再看看Consul,它的架构和原理是怎样的呢?
|
||||
|
||||
下图是[Consul架构图](https://www.consul.io/docs/architecture)(引用自HashiCorp官方文档)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c4/90/c4feaebbdbe19d3f4e09899f8cd52190.png" alt="">
|
||||
|
||||
从图中你可以看到,它由Client、Server、Gossip协议、Raft共识算法、两个数据中心组成。每个数据中心内的Server基于Raft共识算法复制日志,Server节点分为Leader、Follower等角色。Client通过Gossip协议发现Server地址、分布式探测节点健康状态等。
|
||||
|
||||
那什么是Gossip协议呢?
|
||||
|
||||
Gossip中文名称叫流言协议,它是一种消息传播协议。它的核心思想其实源自我们生活中的八卦、闲聊。我们在日常生活中所看到的劲爆消息其实源于两类,一类是权威机构如国家新闻媒体发布的消息,另一类则是大家通过微信等社交聊天软件相互八卦,一传十,十传百的结果。
|
||||
|
||||
Gossip协议的基本工作原理与我们八卦类似,在Gossip协议中,如下图所示,各个节点会周期性地选择一定数量节点,然后将消息同步给这些节点。收到消息后的节点同样做出类似的动作,随机的选择节点,继续扩散给其他节点。
|
||||
|
||||
最终经过一定次数的扩散、传播,整个集群的各个节点都能感知到此消息,各个节点的数据趋于一致。Gossip协议被广泛应用在多个知名项目中,比如Redis Cluster集群版,Apache Cassandra,AWS Dynamo。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/84/4d/847ae4bcb531065c2797f1c91d4f464d.png" alt="">
|
||||
|
||||
了解完Gossip协议,我们再看看架构图中的多数据中心,Consul支持数据跨数据中心自动同步吗?
|
||||
|
||||
你需要注意的是,虽然Consul天然支持多数据中心,但是多数据中心内的服务数据并不会跨数据中心同步,各个数据中心的Server集群是独立的。不过,Consul提供了[Prepared Query](https://www.consul.io/api-docs/query)功能,它支持根据一定的策略返回多数据中心下的最佳的服务实例地址,使你的服务具备跨数据中心容灾。
|
||||
|
||||
比如当你的API网关收到用户请求查询A服务,API网关服务优先从缓存中查找A服务对应的最佳实例。若无缓存则向Consul发起一个Prepared Query请求查询A服务实例,Consul收到请求后,优先返回本数据中心下的服务实例。如果本数据中心没有或异常则根据数据中心间 RTT 由近到远查询其它数据中心数据,最终网关可将用户请求转发给最佳的数据中心下的实例地址。
|
||||
|
||||
了解完Consul的Gossip协议、多数据中心支持,我们再看看Consul是如何处理读请求的呢?
|
||||
|
||||
Consul支持以下三种模式的读请求:
|
||||
|
||||
- 默认(default)。默认是此模式,绝大部分场景下它能保证数据的强一致性。但在老的Leader出现网络分区被隔离、新的Leader被选举出来的一个极小时间窗口内,可能会导致stale read。这是因为Consul为了提高读性能,使用的是基于Lease机制来维持Leader身份,避免了与其他节点进行交互确认的开销。
|
||||
- 强一致性(consistent)。强一致性读与etcd默认线性读模式一样,每次请求需要集群多数节点确认Leader身份,因此相比default模式读,性能会有所下降。
|
||||
- 弱一致性(stale)。任何节点都可以读,无论它是否Leader。可能读取到陈旧的数据,类似etcd的串行读。这种读模式不要求集群有Leader,因此当集群不可用时,只要有节点存活,它依然可以响应读请求。
|
||||
|
||||
## 重点特性比较
|
||||
|
||||
初步了解完etcd、ZooKeeper、Consul架构及原理后,你可以看到,他们都是基于共识算法实现的强一致的分布式存储系统,并都提供了多种模式的读机制。
|
||||
|
||||
除了以上共性,那么它们之间有哪些差异呢? 下表是etcd开源社区总结的一个[详细对比项](https://etcd.io/docs/current/learning/why/),我们就从并发原语、健康检查及服务发现、数据模型、Watch特性等功能上详细比较下它们功能和区别。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4d/50/4d0d9a05790f8ee9b66daf66ea741a50.jpg" alt="">
|
||||
|
||||
### 并发原语
|
||||
|
||||
etcd和ZooKeeper、Consul的典型应用场景都是分布式锁、Leader选举,以上场景就涉及到并发原语控制。然而etcd和ZooKeeper并未提供原生的分布式锁、Leader选举支持,只提供了核心的基本数据读写、并发控制API,由应用上层去封装。
|
||||
|
||||
为了帮助开发者更加轻松的使用etcd去解决分布式锁、Leader选举等问题,etcd社区提供了[concurrency包](https://github.com/etcd-io/etcd/tree/v3.4.9/clientv3/concurrency)来实现以上功能。同时,在etcdserver中内置了Lock和Election服务,不过其也是基于concurrency包做了一层封装而已,clientv3并未提供Lock和Election服务API给Client使用。 ZooKeeper所属的Apache社区提供了[Apache Curator Recipes](http://curator.apache.org/curator-recipes/index.html)库来帮助大家快速使用分布式锁、Leader选举功能。
|
||||
|
||||
相比etcd、ZooKeeper依赖应用层基于API上层封装,Consul对分布式锁就提供了[原生的支持](https://www.consul.io/commands/lock),可直接通过命令行使用。
|
||||
|
||||
总体而言,etcd、ZooKeeper、Consul都能解决分布式锁、Leader选举的痛点,在选型时,你可能会重点考虑其提供的API语言是否与业务服务所使用的语言一致。
|
||||
|
||||
### 健康检查、服务发现
|
||||
|
||||
分布式协调服务的另外一个核心应用场景是服务发现、健康检查。
|
||||
|
||||
与并发原语类似,etcd和ZooKeeper并未提供原生的服务发现支持。相反,Consul在服务发现方面做了很多解放用户双手的工作,提供了服务发现的框架,帮助你的业务快速接入,并提供了HTTP和DNS两种获取服务方式。
|
||||
|
||||
比如下面就是通过DNS的方式获取服务地址:
|
||||
|
||||
```
|
||||
$ dig @127.0.0.1 -p 8600 redis.service.dc1.consul. ANY
|
||||
|
||||
```
|
||||
|
||||
最重要的是它还集成了分布式的健康检查机制。与etcd和ZooKeeper健康检查不一样的是,它是一种基于client、Gossip协议、分布式的健康检查机制,具备低延时、可扩展的特点。业务可通过Consul的健康检查机制,实现HTTP接口返回码、内存乃至磁盘空间的检测。
|
||||
|
||||
Consul提供了[多种机制给你注册健康检查](https://learn.hashicorp.com/tutorials/consul/service-registration-health-checks),如脚本、HTTP、TCP等。
|
||||
|
||||
脚本是怎么工作的呢?介绍Consul架构时,我们提到过的Agent角色的任务之一就是执行分布式的健康检查。
|
||||
|
||||
比如你将如下脚本放在Agent相应目录下,当Linux机器内存使用率超过70%的时候,它会返回告警状态。
|
||||
|
||||
```
|
||||
{
|
||||
"check":
|
||||
"id": "mem-util"
|
||||
"name": "Memory utilization"
|
||||
"args":
|
||||
"/bin/sh"
|
||||
"-c"
|
||||
"/usr/bin/free | awk '/Mem/{printf($3/$2*100)}' | awk '{ print($0); if($1 > 70) exit 1;}'
|
||||
]
|
||||
"interval": "10s"
|
||||
"timeout": "1s
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
相比Consul,etcd、ZooKeeper它们提供的健康检查机制和能力就非常有限了。
|
||||
|
||||
etcd提供了Lease机制来实现活性检测。它是一种中心化的健康检查,依赖用户不断地发送心跳续租、更新TTL。
|
||||
|
||||
ZooKeeper使用的是一种名为临时节点的状态来实现健康检查。当client与ZooKeeper节点连接断掉时,ZooKeeper就会删除此临时节点的key-value数据。它比基于心跳机制更复杂,也给client带去了更多的复杂性,所有client必须维持与ZooKeeper server的活跃连接并保持存活。
|
||||
|
||||
### 数据模型比较
|
||||
|
||||
从并发原语、健康检查、服务发现等维度了解完etcd、ZooKeeper、Consul的实现区别之后,我们再从数据模型上对比下三者。
|
||||
|
||||
首先etcd正如我们在[07](https://time.geekbang.org/column/article/340226)节MVCC和[10](https://time.geekbang.org/column/article/342527)节boltdb所介绍的,它是个扁平的key-value模型,内存索引通过B-tree实现,数据持久化存储基于B+ tree的boltdb,支持范围查询、适合读多写少,可容纳数G的数据。
|
||||
|
||||
[ZooKeeper的数据模型](https://www.usenix.org/legacy/event/atc10/tech/full_papers/Hunt.pdf)如下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/93/fb/93edd0575e5a5a1080dac40415b779fb.png" alt="">
|
||||
|
||||
如上图所示,它是一种层次模型,你可能已经发现,etcd v2的内存数据模型与它是一样的。ZooKeeper作为分布式协调服务的祖师爷,早期etcd v2的确就是参考它而设计的。
|
||||
|
||||
ZooKeeper的层次模型中的每个节点叫Znode,它分为持久性和临时型两种。
|
||||
|
||||
- 持久性顾名思义,除非你通过API删除它,否则它将永远存在。
|
||||
- 临时型是指它与客户端会话绑定,若客户端会话结束或出现异常中断等,它都将被ZooKeeper server自动删除,被广泛应用于活性检测。
|
||||
|
||||
同时你创建节点的时候,还可以指定一个顺序标识,这样节点名创建出来后就具有顺序性,一般应用于分布式选举等场景中。
|
||||
|
||||
那ZooKeeper是如何实现以上层次模型的呢?
|
||||
|
||||
ZooKeeper使用的是内存ConcurrentHashMap来实现此数据结构,因此具有良好的读性能。但是受限于内存的瓶颈,一般ZooKeeper的数据库文件大小是几百M左右。
|
||||
|
||||
Consul的数据模型及存储是怎样的呢?
|
||||
|
||||
它也提供了常用key-value操作,它的存储引擎是基于[Radix Tree](https://en.wikipedia.org/wiki/Radix_tree#)实现的[go-memdb](https://github.com/hashicorp/go-memdb),要求value大小不能超过512个字节,数据库文件大小一般也是几百M左右。与boltdb类似,它也支持事务、MVCC。
|
||||
|
||||
### Watch特性比较
|
||||
|
||||
接下来我们再看看Watch特性的比较。
|
||||
|
||||
正在我在08节Watch特性中所介绍的,etcd v3的Watch是基于MVCC机制实现的,而Consul是采用滑动窗口实现的。Consul存储引擎是基于[Radix Tree](https://en.wikipedia.org/wiki/Radix_tree#)实现的,因此它不支持范围查询和监听,只支持前缀查询和监听,而etcd都支持。
|
||||
|
||||
相比etcd、Consul,ZooKeeper的Watch特性有更多的局限性,它是个一次性触发器。
|
||||
|
||||
在ZooKeeper中,client对Znode设置了Watch时,如果Znode内容发生改变,那么client就会获得Watch事件。然而此Znode再次发生变化,那client是无法收到Watch事件的,除非client设置了新的Watch。
|
||||
|
||||
### 其他比较
|
||||
|
||||
最后我们再从其他方面做些比较。
|
||||
|
||||
<li>
|
||||
线性读。etcd和Consul都支持线性读,而ZooKeeper并不具备。
|
||||
</li>
|
||||
<li>
|
||||
权限机制比较。etcd实现了RBAC的权限校验,而ZooKeeper和Consul实现的ACL。
|
||||
</li>
|
||||
<li>
|
||||
事务比较。etcd和Consul都提供了简易的事务能力,支持对字段进行比较,而ZooKeeper只提供了版本号检查能力,功能较弱。
|
||||
</li>
|
||||
<li>
|
||||
多数据中心。在多数据中心支持上,只有Consul是天然支持的,虽然它本身不支持数据自动跨数据中心同步,但是它提供的服务发现机制、[Prepared Query](https://www.consul.io/api-docs/query)功能,赋予了业务在一个可用区后端实例故障时,可将请求转发到最近的数据中心实例。而etcd和ZooKeeper并不支持。
|
||||
</li>
|
||||
|
||||
## 小结
|
||||
|
||||
最后我们来小结下今天的内容。首先我和你从顶层视角介绍了etcd、ZooKeeper、Consul基本架构及核心原理。
|
||||
|
||||
从共识算法角度上看,etcd、Consul是基于Raft算法实现的数据复制,ZooKeeper则是基于Zab算法实现的。Raft算法由Leader选举、日志同步、安全性组成,而Zab协议则由Leader选举、发现、同步、广播组成。无论Leader选举还是日志复制,它们都需要集群多数节点存活、确认才能继续工作。
|
||||
|
||||
从CAP角度上看,在发生网络分区时,etcd、Consul、ZooKeeper都是一个CP系统,无法写入新数据。同时,etcd、Consul、ZooKeeper提供了各种模式的读机制,总体上可分为强一致性读、非强一致性读。
|
||||
|
||||
其中etcd和Consul则提供了线性读,ZooKeeper默认是非强一致性读,不过业务可以通过sync()接口,等待Follower数据追赶上Leader进度,以读取最新值。
|
||||
|
||||
接下来我从并发原语、健康检查、服务发现、数据模型、Watch特性、多数据中心比较等方面和你重点介绍了三者的实现与区别。
|
||||
|
||||
其中Consul提供了原生的分布式锁、健康检查、服务发现机制支持,让业务可以更省心,不过etcd和ZooKeeper也都有相应的库,帮助你降低工作量。Consul最大的亮点则是对多数据中心的支持。
|
||||
|
||||
最后如果业务使用Go语言编写的,国内一般使用etcd较多,文档、书籍、最佳实践案例丰富。Consul在国外应用比较多,中文文档及实践案例相比etcd较少。ZooKeeper一般是Java业务使用较多,广泛应用在大数据领域。另外Nacos也是个非常优秀的开源项目,支持服务发现、配置管理等,是Java业务的热门选择。
|
||||
|
||||
## 思考题
|
||||
|
||||
好了,这节课到这里也就结束了,最后我给你留了一个思考题。
|
||||
|
||||
越来越多的业务要求跨可用区乃至地区级的容灾,如果你是核心系统开发者,你会如何选型合适的分布式协调服务,设计跨可用区、地区的容灾方案呢? 如果选用etcd,又该怎么做呢?
|
||||
|
||||
感谢你阅读,也欢迎你把这篇文章分享给更多的朋友一起阅读。
|
||||
380
极客时间专栏/geek/etcd实战课/实践篇/24 | 运维:如何构建高可靠的etcd集群运维体系?.md
Normal file
380
极客时间专栏/geek/etcd实战课/实践篇/24 | 运维:如何构建高可靠的etcd集群运维体系?.md
Normal file
@@ -0,0 +1,380 @@
|
||||
<audio id="audio" title="24 | 运维:如何构建高可靠的etcd集群运维体系?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9e/98/9eac7f19fb53a803f5f49010b6166998.mp3"></audio>
|
||||
|
||||
你好,我是唐聪。
|
||||
|
||||
在使用etcd过程中,我们经常会面临着一系列问题与选择,比如:
|
||||
|
||||
- etcd是使用虚拟机还是容器部署,各有什么优缺点?
|
||||
- 如何及时发现etcd集群隐患项(比如数据不一致)?
|
||||
- 如何及时监控及告警etcd的潜在隐患(比如db大小即将达到配额)?
|
||||
- 如何优雅的定时、甚至跨城备份etcd数据?
|
||||
- 如何模拟磁盘IO等异常来复现Bug、故障?
|
||||
|
||||
今天,我就和你聊聊如何解决以上问题。我将通过从etcd集群部署、集群组建、监控体系、巡检、备份及还原、高可用、混沌工程等维度,带你了解如何构建一个高可靠的etcd集群运维体系。
|
||||
|
||||
希望通过这节课,让你对etcd集群运维过程中可能会遇到的一系列问题和解决方案有一定的了解,帮助你构建高可靠的etcd集群运维体系,助力业务更快、更稳地运行。
|
||||
|
||||
## 整体解决方案
|
||||
|
||||
那要如何构建高可靠的etcd集群运维体系呢?
|
||||
|
||||
我通过下面这个思维脑图给你总结了etcd运维体系建设核心要点,它由etcd集群部署、成员管理、监控及告警体系、备份及还原、巡检、高可用及自愈、混沌工程等维度组成。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/80/c2/803b20362b21d13396ee099f413968c2.png" alt="">
|
||||
|
||||
## 集群部署
|
||||
|
||||
要想使用etcd集群,我们面对的第一个问题是如何选择合适的方案去部署etcd集群。
|
||||
|
||||
首先是计算资源的选择,它本质上就是计算资源的交付演进史,分别如下:
|
||||
|
||||
- 物理机;
|
||||
- 虚拟机;
|
||||
- 裸容器(如Docker实例);
|
||||
- Kubernetes容器编排。
|
||||
|
||||
物理机资源交付慢、成本高、扩缩容流程费时,一般情况下大部分业务团队不再考虑物理机,除非是超大规模的上万个节点的Kubernetes集群,对CPU、内存、网络资源有着极高诉求。
|
||||
|
||||
虚拟机是目前各个云厂商售卖的主流实例,无论是基于KVM还是Xen实现,都具有良好的稳定性、隔离性,支持故障热迁移,可弹性伸缩,被etcd、数据库等存储业务大量使用。
|
||||
|
||||
在基于物理机和虚拟机的部署方案中,我推荐你使用ansible、puppet等自动运维工具,构建标准、自动化的etcd集群搭建、扩缩容流程。基于ansible部署etcd集群可以拆分成以下若干个任务:
|
||||
|
||||
- 下载及安装etcd二进制到指定目录;
|
||||
- 将etcd加入systemd等服务管理;
|
||||
- 为etcd增加配置文件,合理设置相关参数;
|
||||
- 为etcd集群各个节点生成相关证书,构建一个安全的集群;
|
||||
- 组建集群版(静态配置、动态配置,发现集群其他节点);
|
||||
- 开启etcd服务,启动etcd集群。
|
||||
|
||||
详细你可以参考digitalocean[这篇博客文章](https://www.digitalocean.com/community/tutorials/how-to-set-up-and-secure-an-etcd-cluster-with-ansible-on-ubuntu-18-04),它介绍了如何使用ansible去部署一个安全的etcd集群,并给出了对应的yaml任务文件。
|
||||
|
||||
容器化部署则具有极速的交付效率、更灵活的资源控制、更低的虚拟化开销等一系列优点。自从Docker诞生后,容器化部署就风靡全球。有的业务直接使用裸Docker容器来跑etcd集群。然而裸Docker容器不具备调度、故障自愈、弹性扩容等特性,存在较大局限性。
|
||||
|
||||
随后为了解决以上问题,诞生了以Kubernetes、Swarm为首的容器编排平台,Kubernetes成为了容器编排之战中的王者,大量业务使用Kubernetes来部署etcd、ZooKeeper等有状态服务。在开源社区中,也诞生了若干个etcd的Kubernetes容器化解决方案,分别如下:
|
||||
|
||||
- etcd-operator;
|
||||
- bitnami etcd/statefulset;
|
||||
- etcd-cluster-operator;
|
||||
- openshit/cluster-etcd-operator;
|
||||
- kubeadm。
|
||||
|
||||
[etcd-operator](https://github.com/coreos/etcd-operator)目前已处于Archived状态,无人维护,基本废弃。同时它是基于裸Pod实现的,要做好各种备份。在部分异常情况下存在集群宕机、数据丢失风险,我仅建议你使用它的数据备份etcd-backup-operator。
|
||||
|
||||
[bitnami etcd](https://bitnami.com/stack/etcd/helm)提供了一个helm包一键部署etcd集群,支持各个云厂商,支持使用PV、PVC持久化存储数据,底层基于StatefulSet实现,较稳定。目前不少开源项目使用的是它。
|
||||
|
||||
你可以通过如下helm命令,快速在Kubernete集群中部署一个etcd集群。
|
||||
|
||||
```
|
||||
helm repo add bitnami https://charts.bitnami.com/bitnami
|
||||
helm install my-release bitnami/etcd
|
||||
|
||||
```
|
||||
|
||||
[etcd-cluster-operator](https://github.com/improbable-eng/etcd-cluster-operator)和openshit/[cluster-etcd-operator](https://github.com/openshift/cluster-etcd-operator)比较小众,目前star不多,但是有相应的开发者维护,你可参考下它们的实现思路,与etcd-operator基于Pod、bitnami etcd基于Statefulset实现不一样的是,它们是基于ReplicaSet和Static Pod实现的。
|
||||
|
||||
最后要和你介绍的是[kubeadm](https://kubernetes.io/docs/setup/production-environment/tools/kubeadm/setup-ha-etcd-with-kubeadm/),它是Kubernetes集群中的etcd高可用部署方案的提供者,kubeadm是基于Static Pod部署etcd集群的。Static Pod相比普通Pod有其特殊性,它是直接由节点上的kubelet进程来管理,无需通过kube-apiserver。
|
||||
|
||||
创建Static Pod方式有两种,分别是配置文件和HTTP。kubeadm使用的是配置文件,也就是在kubelet监听的静态Pod目录下(一般是/etc/kubernetes/manifests)放置相应的etcd Pod YAML文件即可,如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d7/05/d7c28814d3f83ff4ef474df72b10b305.png" alt="">
|
||||
|
||||
注意在这种部署方式中,部署etcd的节点需要部署docker、kubelet、kubeadm组件,依赖较重。
|
||||
|
||||
## 集群组建
|
||||
|
||||
和你聊完etcd集群部署的几种模式和基本原理后,我们接下来看看在实际部署过程中最棘手的部分,那就是集群组建。因为集群组建涉及到etcd成员管理的原理和节点发现机制。
|
||||
|
||||
在[特别放送](https://time.geekbang.org/column/article/349619)里,超凡已通过一个诡异的故障案例给你介绍了成员管理的原理,并深入分析了etcd集群添加节点、新建集群、从备份恢复等场景的核心工作流程。etcd目前通过一次只允许添加一个节点的方式,可安全的实现在线成员变更。
|
||||
|
||||
你要特别注意,当变更集群成员节点时,节点的initial-cluster-state参数的取值可以是new或existing。
|
||||
|
||||
- new,一般用于初始化启动一个新集群的场景。当设置成new时,它会根据initial-cluster-token、initial-cluster等参数信息计算集群ID、成员ID信息。
|
||||
- existing,表示etcd节点加入一个已存在的集群,它会根据peerURLs信息从Peer节点获取已存在的集群ID信息,更新自己本地配置、并将本身节点信息发布到集群中。
|
||||
|
||||
那么当你要组建一个三节点的etcd集群的时候,有哪些方法呢?
|
||||
|
||||
在etcd中,无论是Leader选举还是日志同步,都涉及到与其他节点通信。因此组建集群的第一步得知道集群总成员数、各个成员节点的IP地址等信息。
|
||||
|
||||
这个过程就是发现(Discovery)。目前etcd主要通过两种方式来获取以上信息,分别是**static configuration**和**dynamic service discovery**。
|
||||
|
||||
**static configuration**是指集群总成员节点数、成员节点的IP地址都是已知、固定的,根据我们上面介绍的initial-cluster-state原理,有如下两个方法可基于静态配置组建一个集群。
|
||||
|
||||
- 方法1,三个节点的initial-cluster-state都配置为new,静态启动,initial-cluster参数包含三个节点信息即可,详情你可参考[社区文档](https://etcd.io/docs/v3.4.0/op-guide/clustering/)。
|
||||
- 方法2,第一个节点initial-cluster-state设置为new,独立成集群,随后第二和第三个节点都为existing,通过扩容的方式,不断加入到第一个节点所组成的集群中。
|
||||
|
||||
如果成员节点信息是未知的,你可以通过**dynamic service discovery**机制解决。
|
||||
|
||||
etcd社区还提供了通过公共服务来发现成员节点信息,组建集群的方案。它的核心是集群内的各个成员节点向公共服务注册成员地址等信息,各个节点通过公共服务来发现彼此,你可以参考[官方详细文档。](https://etcd.io/docs/v3.4.0/dev-internal/discovery_protocol/)
|
||||
|
||||
## 监控及告警体系
|
||||
|
||||
当我们把集群部署起来后,在业务开始使用之前,部署监控是必不可少的一个环节,它是我们保障业务稳定性,提前发现风险、隐患点的重要核心手段。那么要如何快速监控你的etcd集群呢?
|
||||
|
||||
正如我在[14](https://time.geekbang.org/column/article/343645)和[15](https://time.geekbang.org/column/article/344621)里和你介绍延时、内存时所提及的,etcd提供了丰富的metrics来展示整个集群的核心指标、健康度。metrics按模块可划分为磁盘、网络、MVCC事务、gRPC RPC、etcdserver。
|
||||
|
||||
磁盘相关的metrics及含义如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7b/a5/7b3df60d26f5363e36100525a44472a5.png" alt="">
|
||||
|
||||
网络相关的metrics及含义如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/da/32/da489a9796a016dc2yy99e101d9ab832.png" alt="">
|
||||
|
||||
mvcc相关的较多,我在下图中列举了部分其含义,如下所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d1/51/d17446f657b110afd874yyea87176051.png" alt="">
|
||||
|
||||
etcdserver相关的如下,集群是否有leader、堆积的proposal数等都在此模块。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/cb/6e/cbb95c525a6748bfaee48e95ca622f6e.png" alt="">
|
||||
|
||||
更多metrics,你可以通过如下方法查看。
|
||||
|
||||
```
|
||||
curl 127.0.0.1:2379/metrics
|
||||
|
||||
```
|
||||
|
||||
了解常见的metrics后,我们只需要配置Prometheus服务,采集etcd集群的2379端口的metrics路径。
|
||||
|
||||
采集的方案一般有两种,[静态配置](https://etcd.io/docs/v3.4.0/op-guide/monitoring/)和动态配置。
|
||||
|
||||
静态配置是指添加待监控的etcd target到Prometheus配置文件,如下所示。
|
||||
|
||||
```
|
||||
global:
|
||||
scrape_interval: 10s
|
||||
scrape_configs:
|
||||
- job_name: test-etcd
|
||||
static_configs:
|
||||
- targets:
|
||||
['10.240.0.32:2379','10.240.0.33:2379','10.240.0.34:2379']
|
||||
|
||||
```
|
||||
|
||||
静态配置的缺点是每次新增集群、成员变更都需要人工修改配置,而动态配置就可解决这个痛点。
|
||||
|
||||
动态配置是通过Prometheus-Operator的提供ServiceMonitor机制实现的,当你想采集一个etcd实例时,若etcd服务部署在同一个Kubernetes集群,你只需要通过Kubernetes的API创建一个如下的ServiceMonitor资源即可。若etcd集群与Promehteus-Operator不在同一个集群,你需要去创建、更新对应的集群Endpoint。
|
||||
|
||||
那Prometheus是如何知道该采集哪些服务的metrics信息呢?
|
||||
|
||||
答案ServiceMonitor资源通过Namespace、Labels描述了待采集实例对应的Service Endpoint。
|
||||
|
||||
```
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: ServiceMonitor
|
||||
metadata:
|
||||
name: prometheus-prometheus-oper-kube-etcd
|
||||
namespace: monitoring
|
||||
spec:
|
||||
endpoints:
|
||||
- bearerTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token
|
||||
port: http-metrics
|
||||
scheme: https
|
||||
tlsConfig:
|
||||
caFile: /etc/prometheus/secrets/etcd-certs/ca.crt
|
||||
certFile: /etc/prometheus/secrets/etcd-certs/client.crt
|
||||
insecureSkipVerify: true
|
||||
keyFile: /etc/prometheus/secrets/etcd-certs/client.key
|
||||
jobLabel: jobLabel
|
||||
namespaceSelector:
|
||||
matchNames:
|
||||
- kube-system
|
||||
selector:
|
||||
matchLabels:
|
||||
app: prometheus-operator-kube-etcd
|
||||
release: prometheus
|
||||
|
||||
```
|
||||
|
||||
采集了metrics监控数据后,下一步就是要基于metrics监控数据告警了。你可以通过Prometheus和[Alertmanager](https://github.com/prometheus/alertmanager)组件实现,那你应该为哪些核心指标告警呢?
|
||||
|
||||
当然是影响集群可用性的最核心的metric。比如是否有Leader、Leader切换次数、WAL和事务操作延时。etcd社区提供了一个[丰富的告警规则](https://github.com/etcd-io/etcd/blob/v3.4.9/Documentation/op-guide/etcd3_alert.rules),你可以参考下。
|
||||
|
||||
最后,为了方便你查看etcd集群运行状况和提升定位问题的效率,你可以基于采集的metrics配置个[grafana可视化面板](https://github.com/etcd-io/etcd/blob/v3.4.9/Documentation/op-guide/grafana.json)。下面我给你列出了集群是否有Leader、总的key数、总的watcher数、出流量、WAL持久化延时的可视化面板。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a3/9f/a3b42d1e81dd706897edf32ecbc65f9f.png" alt=""><br>
|
||||
<img src="https://static001.geekbang.org/resource/image/d3/7d/d3bc1f984ea8b2e301471ef2923d1b7d.png" alt=""><img src="https://static001.geekbang.org/resource/image/yy/9f/yy73b00dd4d48d473c1d900c96dd0a9f.png" alt=""><br>
|
||||
<img src="https://static001.geekbang.org/resource/image/2d/25/2d28317yyc38957ae2125e460b83f825.png" alt=""><br>
|
||||
<img src="https://static001.geekbang.org/resource/image/9c/b9/9c471d05b1452c4f0aa8yy24c79915b9.png" alt="">
|
||||
|
||||
## 备份及还原
|
||||
|
||||
监控及告警就绪后,就可以提供给业务在生产环境使用了吗?
|
||||
|
||||
当然不行,数据是业务的安全红线,所以你还需要做好最核心的数据备份工作。
|
||||
|
||||
如何做呢?
|
||||
|
||||
主要有以下方法,首先是通过etcdctl snapshot命令行人工备份。在发起重要变更的时候,你可以通过如下命令进行备份,并查看快照状态。
|
||||
|
||||
```
|
||||
ETCDCTL_API=3 etcdctl --endpoints $ENDPOINT
|
||||
snapshot save snapshotdb
|
||||
ETCDCTL_API=3 etcdctl --write-out=table snapshot status snapshotdb
|
||||
|
||||
```
|
||||
|
||||
其次是通过定时任务进行定时备份,建议至少每隔1个小时备份一次。
|
||||
|
||||
然后是通过[etcd-backup-operator](https://github.com/coreos/etcd-operator/blob/master/doc/user/walkthrough/backup-operator.md#:~:text=etcd%20backup%20operator%20backs%20up,storage%20such%20as%20AWS%20S3.)进行自动化的备份,类似ServiceMonitor,你可以通过创建一个备份任务CRD实现。CRD如下:
|
||||
|
||||
```
|
||||
apiVersion: "etcd.database.coreos.com/v1beta2"
|
||||
kind: "EtcdBackup"
|
||||
metadata:
|
||||
name: example-etcd-cluster-periodic-backup
|
||||
spec:
|
||||
etcdEndpoints: [<etcd-cluster-endpoints>]
|
||||
storageType: S3
|
||||
backupPolicy:
|
||||
# 0 > enable periodic backup
|
||||
backupIntervalInSecond: 125
|
||||
maxBackups: 4
|
||||
s3:
|
||||
# The format of "path" must be: "<s3-bucket-name>/<path-to-backup-file>"
|
||||
# e.g: "mybucket/etcd.backup"
|
||||
path: <full-s3-path>
|
||||
awsSecret: <aws-secret>
|
||||
|
||||
```
|
||||
|
||||
最后你可以通过给etcd集群增加Learner节点,实现跨地域热备。因Learner节点属于非投票成员的节点,因此它并不会影响你集群的性能。它的基本工作原理是当Leader收到写请求时,它会通过Raft模块将日志同步给Learner节点。你需要注意的是,在etcd 3.4中目前只支持1个Learner节点,并且只允许串行读。
|
||||
|
||||
## 巡检
|
||||
|
||||
完成集群部署、了解成员管理、构建好监控及告警体系并添加好定时备份策略后,这时终于可以放心给业务使用了。然而在后续业务使用过程中,你可能会遇到各类问题,而这些问题很可能是metrics监控无法发现的,比如如下:
|
||||
|
||||
- etcd集群因重启进程、节点等出现数据不一致;
|
||||
- 业务写入大 key-value 导致 etcd 性能骤降;
|
||||
- 业务异常写入大量key数,稳定性存在隐患;
|
||||
- 业务少数 key 出现写入 QPS 异常,导致 etcd 集群出现限速等错误;
|
||||
- 重启、升级 etcd 后,需要人工从多维度检查集群健康度;
|
||||
- 变更 etcd 集群过程中,操作失误可能会导致 etcd 集群出现分裂;
|
||||
|
||||
......
|
||||
|
||||
因此为了实现高效治理etcd集群,我们可将这些潜在隐患总结成一个个自动化检查项,比如:
|
||||
|
||||
- 如何高效监控 etcd 数据不一致性?
|
||||
- 如何及时发现大 key-value?
|
||||
- 如何及时通过监控发现 key 数异常增长?
|
||||
- 如何及时监控异常写入 QPS?
|
||||
- 如何从多维度的对集群进行自动化的健康检测,更安心变更?
|
||||
- ......
|
||||
|
||||
如何将这些 etcd 的最佳实践策略反哺到现网大规模 etcd 集群的治理中去呢?
|
||||
|
||||
答案就是巡检。
|
||||
|
||||
参考ServiceMonitor和EtcdBackup机制,你同样可以通过CRD的方式描述此巡检任务,然后通过相应的Operator实现此巡检任务。比如下面就是一个数据一致性巡检的YAML文件,其对应的Operator组件会定时、并发检查其关联的etcd集群各个节点的key差异数。
|
||||
|
||||
```
|
||||
apiVersion: etcd.cloud.tencent.com/v1beta1
|
||||
kind: EtcdMonitor
|
||||
metadata:
|
||||
creationTimestamp: "2020-06-15T12:19:30Z"
|
||||
generation: 1
|
||||
labels:
|
||||
clusterName: gz-qcloud-etcd-03
|
||||
region: gz
|
||||
source: etcd-life-cycle-operator
|
||||
name: gz-qcloud-etcd-03-etcd-node-key-diff
|
||||
namespace: gz
|
||||
spec:
|
||||
clusterId: gz-qcloud-etcd-03
|
||||
metricName: etcd-node-key-diff
|
||||
metricProviderName: cruiser
|
||||
name: gz-qcloud-etcd-03
|
||||
productName: tke
|
||||
region: gz
|
||||
status:
|
||||
records:
|
||||
- endTime: "2021-02-25T11:22:26Z"
|
||||
message: collectEtcdNodeKeyDiff,etcd cluster gz-qcloud-etcd-03,total key num is
|
||||
122143,nodeKeyDiff is 0
|
||||
startTime: "2021-02-25T12:39:28Z"
|
||||
updatedAt: "2021-02-25T12:39:28Z"
|
||||
|
||||
```
|
||||
|
||||
## 高可用及自愈
|
||||
|
||||
通过以上机制,我们已经基本建设好一个高可用的etcd集群运维体系了。最后再给你提供几个集群高可用及自愈的小建议:
|
||||
|
||||
- 若etcd集群性能已满足业务诉求,可容忍一定的延时上升,建议你将etcd集群做高可用部署,比如对3个节点来说,把每个节点部署在独立的可用区,可容忍任意一个可用区故障。
|
||||
- 逐步尝试使用Kubernetes容器化部署etcd集群。当节点出现故障时,能通过Kubernetes的自愈机制,实现故障自愈。
|
||||
- 设置合理的db quota值,配置合理的压缩策略,避免集群db quota满从而导致集群不可用的情况发生。
|
||||
|
||||
## 混沌工程
|
||||
|
||||
在使用etcd的过程中,你可能会遇到磁盘、网络、进程异常重启等异常导致的故障。如何快速复现相关故障进行问题定位呢?
|
||||
|
||||
答案就是混沌工程。一般常见的异常我们可以分为如下几类:
|
||||
|
||||
- 磁盘IO相关的。比如模拟磁盘IO延时上升、IO操作报错。之前遇到的一个底层磁盘硬件异常导致IO延时飙升,最终触发了etcd死锁的Bug,我们就是通过模拟磁盘IO延时上升后来验证的。
|
||||
- 网络相关的。比如模拟网络分区、网络丢包、网络延时、包重复等。
|
||||
- 进程相关的。比如模拟进程异常被杀、重启等。之前遇到的一个非常难定位和复现的数据不一致Bug,我们就是通过注入进程异常重启等故障,最后成功复现。
|
||||
- 压力测试相关的。比如模拟CPU高负载、内存使用率等。
|
||||
|
||||
开源社区在混沌工程领域诞生了若干个优秀的混沌工程项目,如chaos-mesh、chaos-blade、litmus。这里我重点和你介绍下[chaos-mesh](https://github.com/chaos-mesh/chaos-mesh),它是基于Kubernetes实现的云原生混沌工程平台,下图是其架构图(引用自社区)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b8/a7/b87d187ea2ab60d824223662fd6033a7.png" alt="">
|
||||
|
||||
为了实现以上异常场景的故障注入,chaos-mesh定义了若干种资源类型,分别如下:
|
||||
|
||||
- IOChaos,用于模拟文件系统相关的IO延时和读写错误等。
|
||||
- NetworkChaos,用于模拟网络延时、丢包等。
|
||||
- PodChaos,用于模拟业务Pod异常,比如Pod被杀、Pod内的容器重启等。
|
||||
- StressChaos,用于模拟CPU和内存压力测试。
|
||||
|
||||
当你希望给etcd Pod注入一个磁盘IO延时的故障时,你只需要创建此YAML文件就好。
|
||||
|
||||
```
|
||||
apiVersion: chaos-mesh.org/v1alpha1
|
||||
kind: IoChaos
|
||||
metadata:
|
||||
name: io-delay-example
|
||||
spec:
|
||||
action: latency
|
||||
mode: one
|
||||
selector:
|
||||
labelSelectors:
|
||||
app: etcd
|
||||
volumePath: /var/run/etcd
|
||||
path: '/var/run/etcd/**/*'
|
||||
delay: '100ms'
|
||||
percent: 50
|
||||
duration: '400s'
|
||||
scheduler:
|
||||
cron: '@every 10m'
|
||||
|
||||
```
|
||||
|
||||
## 小结
|
||||
|
||||
最后我们来小结下今天的内容。
|
||||
|
||||
今天我通过从集群部署、集群组建、监控及告警体系、备份、巡检、高可用、混沌工程几个维度,和你深入介绍了如何构建一个高可靠的etcd集群运维体系。
|
||||
|
||||
在集群部署上,当你的业务集群规模非常大、对稳定性有着极高的要求时,推荐使用大规格、高性能的物理机、虚拟机独占部署,并且使用ansible等自动化运维工具,进行标准化的操作etcd,避免人工一个个修改操作。
|
||||
|
||||
对容器化部署来说,Kubernetes场景推荐你使用kubeadm,其他场景可考虑分批、逐步使用bitnami提供的etcd helm包,它是基于statefulset、PV、PVC实现的,各大云厂商都广泛支持,建议在生产环境前,多验证各个极端情况下的行为是否符合你的预期。
|
||||
|
||||
在集群组建上,各个节点需要一定机制去发现集群中的其他成员节点,主要可分为**static configuration**和**dynamic service discovery**。
|
||||
|
||||
static configuration是指集群中各个成员节点信息是已知的,dynamic service discovery是指你可以通过服务发现组件去注册自身节点信息、发现集群中其他成员节点信息。另外我和你介绍了重要参数initial-cluster-state的含义,它也是影响集群组建的一个核心因素。
|
||||
|
||||
在监控及告警体系上,我和你介绍了etcd网络、磁盘、etcdserver、gRPC核心的metrics。通过修改Prometheues配置文件,添加etcd target,你就可以方便的采集etcd的监控数据。我还给你介绍了ServiceMonitor机制,你可通过它实现动态新增、删除、修改待监控的etcd实例,灵活的、高效的采集etcd Metrcis。
|
||||
|
||||
备份及还原上,重点和你介绍了etcd snapshot命令,etcd-backup-operator的备份任务CRD机制,推荐使用后者。
|
||||
|
||||
最后是巡检、混沌工程,它能帮助我们高效治理etcd集群,及时发现潜在隐患,低成本、快速的复现Bug和故障等。
|
||||
|
||||
## 思考题
|
||||
|
||||
好了,这节课到这里也就结束了,最后我给你留了一个思考题。
|
||||
|
||||
你在生产环境中目前是使用哪种方式部署etcd集群的呢?若基于Kubernetes容器化部署的,是否遇到过容器化后的相关问题?
|
||||
|
||||
感谢你的阅读,也欢迎你把这篇文章分享给更多的朋友一起阅读。
|
||||
Reference in New Issue
Block a user