本文旨在帮助大家了解 etcd集群场景下稳定性与性能优化经验引的容量,避免给后面留坑。
背景与挑战
数据不一致 内存泄露 死锁 进程Crash 大包请求导致etcd OOM及丢包 较大数据量场景下启动慢 鉴权及查询key数量、查询指定数量记录接口性能较差
稳定性优化案例剖析
数据不一致(Data Inconsistency)
数据不一致最恐怖之处在于client写入是成功的,但可能在部分节点读取到空或者是旧数据,client无法感知到写入在部分节点是失败的和可能读到旧数据 读到空可能会导致业务Node消失、Pod消失、Node上Service路由规则消失,一般场景下,只会影响用户变更的服务 读到老数据会导致业务变更不生效,如服务扩缩容、Service rs替换、变更镜像异常等待,一般场景下,只会影响用户变更的服务 在etcd平台迁移场景下,client无法感知到写入失败,若校验数据一致性也无异常时(校验时连接到了正常节点),会导致迁移后整个集群全面故障(apiserver连接到了异常节点),用户的Node、部署的服务、lb等会被全部删除,严重影响用户业务
算法理论数据一致性,不代表整体服务实现能保证数据一致性,目前业界对于这种基于日志复制状态机实现的分布式存储系统,没有一个核心的机制能保证raft、wal、mvcc、snapshot等模块协作不出问题,raft只能保证日志状态机的一致性,不能保证应用层去执行这些日志对应的command都会成功 etcd版本升级存在一定的风险,需要仔细review代码评估是否存在不兼容的特性,如若存在是否影响鉴权版本号及mvcc版本号,若影响则升级过程中可能会导致数据不一致性,同时一定要灰度变更现网集群 对所有etcd集群增加了一致性巡检告警,如revision差异监控、key数量差异监控等 定时对etcd集群数据进行备份,再小概率的故障,根据墨菲定律都可能会发生,即便etcd本身虽具备完备的自动化测试(单元测试、集成测试、e2e测试、故障注入测试等),但测试用例仍有不少场景无法覆盖,我们需要为最坏的场景做准备(如3个节点wal、snap、db文件同时损坏),降低极端情况下的损失, 做到可用备份数据快速恢复 etcd v3.4.4后的集群灰度开启data corruption检测功能,当集群出现不一致时,拒绝集群写入、读取,及时止损,控制不一致的数据范围 继续完善我们的chaos monkey和使用etcd本身的故障注入测试框架functional,以协助我们验证、压测新版本稳定性(长时间持续跑),复现隐藏极深的bug, 降低线上采坑的概率。
内存泄露(OOM)
goroutine泄露 deferring function calls(如for循环里面未使用匿名函数及时调用defer释放资源,而是整个for循环结束才调用) 获取string/slice中的一段导致长string/slice未释放(会共享相同的底层内存块) 应用内存数据结构管理不周导致内存泄露(如为及时清理过期、无效的数据)
QPS及流量监控显示都较低,因此排除高负载及慢查询因素 一个集群3个节点只有两个follower节点出现异常,leader 4g,follower节点高达23G goroutine、fd等资源未出现泄漏 go runtime memstats指标显示各个节点申请的内存是一致的,但是follower节点go_memstats_heap_release_bytes远低于leader节点,说明某数据结构可能长期未释放 生产集群默认关闭了pprof,开启pprof,等待复现, 并在社区上搜索释放有类似案例, 结果发现有多个用户1月份就报了,没引起社区重视,使用场景和现象跟我们一样 通过社区的heap堆栈快速定位到了是由于etcd通过一个heap堆来管理lease的状态,当lease过期时需要从堆中删除,但是follower节点却无此操作,因此导致follower内存泄露, 影响所有3.4版本。 问题分析清楚后,我提交的修复方案是follower节点不需要维护lease heap,当leader发生选举时确保新的follower节点能重建lease heap,老的leader节点则清空lease heap.
持续关注社区issue和pr, 别人今天的问题很可能我们明天就会遇到 etcd本身测试无法覆盖此类需要一定时间运行的才能触发的资源泄露bug,我们内部需要加强此类场景的测试与压测 持续完善、丰富etcd平台的各类监控告警,机器留足足够的内存buffer以扛各种意外的因素。
存储层死锁(Mvcc Deadlock)
不经过raft及mvcc模块的rpc请求如member list可以正常返回结果,而经过的rpc请求全部context timeout etcd health健康监测返回503,503的报错逻辑也是经过了raft及mvcc 通过tcpdump和netstat排除raft网络模块异常,可疑目标缩小到mvcc 分析日志发现卡住的时候因数据落后leader较多,接收了一个数据快照,然后执行更新快照的时候卡住了,没有输出快照加载完毕的日志,同时确认日志未丢失 排查快照加载的代码,锁定几个可疑的锁和相关goroutine,准备获取卡住的goroutine堆栈 通过kill或pprof获取goroutine堆栈,根据goroutine卡住的时间和相关可疑点的代码逻辑,成功找到两个相互竞争资源的goroutine,其中一个正是执行快照加载,重建db的主goroutine,它获取了一把mvcc锁等待所有异步任务结束,而另外一个goroutine则是执行历史key压缩任务,当它收到stop的信号后,立刻退出,调用一个compactBarrier逻辑,而这个逻辑又恰恰需要获取mvcc锁,因此出现死锁,堆栈如下。
多并发场景的组合的etcd自动化测试用例覆盖不到,也较难构建,因此也容易出bug, 是否还有其他类似场景存在同样的问题?需要参与社区一起继续提高etcd测试覆盖率(etcd之前官方博客介绍一大半代码已经是测试代码),才能避免此类问题。 监控虽然能及时发现异常节点宕机,但是死锁这种场景之前我们不会自动重启etcd,因此需要完善我们的健康探测机制(比如curl /health来判断服务是否正常),出现死锁时能够保留堆栈、自动重启恢复服务。 对于读请求较高的场景,需评估3节点集群在一节点宕机后,剩余两节点提供的QPS容量是否能够支持业务,若不够则考虑5节点集群。
Wal crash (Panic)
首先crash报错是walpb: crc mismatch, 而我们并未提交代码修改wal相关逻辑,排除自己的锅。 其次通过review新版本pr, 目标锁定到google一位大佬在修复一个wal在写入成功后,而snapshot写入失败导致的crash bug的时候引入的. 但是具体是怎么引入的?pr中包含多个测试用例验证新加逻辑,本地创建空集群和使用存量集群(数据比较小)也无法复现. 错误日志信息太少,导致无法确定是哪个函数报的错,因此首先还是加日志,对各个可疑点增加错误日志后,在我们测试集群随便找了个老节点替换版本,然后很容易就复现了,并确定是新加的验证快照文件合法性的锅,那么它为什么会出现crc mismatch呢? 首先我们来简单了解下wal文件。 etcd任何经过raft的模块的请求在写入etcd mvcc db前都会通过wal文件持久化,若进程在apply command过程中出现被杀等异常,重启时可通过wal文件重放将数据补齐,避免数据丢失。wal文件包含各种请求命令如成员变化信息、涉及key的各个操作等,为了保证数据完整性、未损坏,wal每条记录都会计算其的crc32,写入wal文件。重启后解析wal文件过程中,会校验记录的完整性,如果数据出现损坏或crc32计算算法出现变化则会出现crc32 mismatch. 硬盘及文件系统并未出现异常,排除了数据损坏,经过深入排查crc32算法的计算,发现是新增逻辑未处理crc32类型的数据记录,它会影响crc32算法的值,导致出现差异,而且只有在当etcd集群创建产生后的第一个wal文件被回收才会触发,因此对存量运行一段时间的集群,100%复现。 解决方案就是通过增加crc32算法的处理逻辑以及增加单元测试覆盖wal文件被回收的场景,社区已合并并发布了新的3.4和3.3版本(v3.4.9/v3.3.22).
单元测试用例非常有价值,然而编写完备的单元测试用例并不容易,需要考虑各类场景。 etcd社区对存量集群升级、各版本之间兼容性测试用例几乎是0,需要大家一起来为其舔砖加瓦,让测试用例覆盖更多场景。 新版本上线内部流程标准化、自动化, 如测试环境压测、混沌测试、不同版本性能对比、优先在非核心场景使用(如event)、灰度上线等流程必不可少。
数配额及限速(Quota&QoS)
基于K8s apiserver上层限速能力,如apiserver默认写100/s,读200/s 基于K8s resource quota控制不合理的Pod/configmap/crd数 基于K8s controller-manager的-terminated-Pod-gc-threshold参数控制无效Pod数量(此参数默认值高达12500,有很大优化空间) 基于K8s的apiserver各类资源可独立的存储的特性, 将event/configmap以及其他核心数据分别使用不同的etcd集群,在提高存储性能的同时,减少核心主etcd故障因素 基于event admission webhook对读写event的apiserver请求进行速率控制 基于不同业务情况,灵活调整event-ttl时间,尽量减少event数 基于etcd开发QoS特性,目前也已经向社区提交了初步设计方案,支持基于多种对象类型设置QoS规则(如按grpcMethod、grpcMethod+请求key前缀路径、traffic、cpu-intensive、latency) 通过多维度的集群告警(etcd集群lb及节点本身出入流量告警、内存告警、精细化到每个K8s集群的资源容量异常增长告警、集群资源读写QPS异常增长告警)来提前防范、规避可能出现的集群稳定性问题
性能优化案例剖析
启动耗时及查询key数量、查询指定记录数性能优化
查询key数量时etcd之前实现是遍历整个内存btree,把key对应的revision存放在slice数组里面 问题就在于key数量较多时,slice扩容涉及到数据拷贝,以及slice也需要大量的内存开销 因此优化方案是新增一个CountRevision来统计key的数量即可,不需要使用slice,此方案优化后性能从21s降低到了7s,同时无任何内存开销 对于查询指定记录数据耗时和内存开销非常大的问题,通过分析发现是limit记录数并未下推到索引层,通过将查询limit参数下推到索引层,大数据场景下limit查询性能提升百倍,同时无额外的内存开销。
启动的时候机器上的cpu资源etcd进程未能充分利用 9%耗时在打开后端db时,如将整个db文件mmap到内存 91%耗时在重建内存索引btree上。当etcd收到一个请求Get Key时,请求被层层传递到了mvcc层后,它首先需要从内存索引btree中查找key对应的版本号,随后从boltdb里面根据版本号查出对应的value, 然后返回给client. 重建内存索引btree数的时候,恰恰是相反的流程,遍历boltdb,从版本号0到最大版本号不断遍历,从value里面解析出对应的key、revision等信息,重建btree,因为这个是个串行操作,所以操作及其耗时 尝试将串行构建btree优化成高并发构建,尽量把所有核计算力利用起来,编译新版本测试后发现效果甚微,于是编译新版本打印重建内存索引各阶段的详细耗时分析,结果发现瓶颈在内存btree的插入上,而这个插入拥有一个全局锁,因此几乎无优化空间 继续分析91%耗时发现重建内存索引竟然被调用了两次,第一处是为了获取一个mvcc的关键的consistent index变量,它是用来保证etcd命令不会被重复执行的关键数据结构,而我们前面提到的一个数据不一致bug恰好也是跟consistent index有密切关系。 consistent index实现不合理,封装在mvcc层,因此我前面提了一个pr将此特性重构,做为了一个独立的包,提供各类方法给etcdserver、mvcc、auth、lease等模块调用。 特性重构后的consistent index在启动的时候就不再需要通过重建内存索引数等逻辑来获取了,优化成调用cindex包的方法快速获取到consistent index,就将整个耗时从5min从缩短到2分30秒左右。因此优化同时依赖的consistent index特性重构,改动较大暂未backport到3.4/3.3分支,在未来3.5版本中、数据量较大时可以享受到启动耗时的显著提升。
密码鉴权性能提升12倍
现象的确很诡异,db延时相关指标显示没任何异常,日志无任何有效信息 业务反馈大量读请求超时,甚至可以通过etcdctl客户端工具简单复现,可是metric对应的读请求相关指标数竟然是0 引导用户开启trace日志和metrics开启extensive模式,开启后发现无任何trace日志,然而开启extensive后,我发现耗时竟然全部花在了Authenticate接口,业务反馈是通过密码鉴权,而不是基于证书的鉴权 尝试让业务同学短暂关闭鉴权测试业务是否恢复,业务同学找了一个节点关闭鉴权后,此节点立刻恢复了正常,于是选择临时通过关闭鉴权来恢复现网业务 那鉴权为什么耗时这么慢?我们对可疑之处增加了日志,打印了鉴权各个步骤的耗时,结果发现是在等待锁的过程中出现了超时,而这个锁为什么耗时这么久呢?排查发现是因为加锁过程中会调用bcrpt加密函数计算密码hash值,每次耗费60ms左右,数百并发下等待此锁的最高耗时高达5s+。 于是我们编写新版本将锁的范围减少,降低持锁阻塞时间,用户使用新版本后,开启鉴权后,业务不再超时,恢复正常。 随后我们将修复方案提交给了社区,并编写了压测工具,测试提升后的性能高达近12倍(8核32G机器,从18/s提升到202/s),但是依然是比较慢,主要是鉴权过程中涉及密码校验计算, 社区上也有用户反馈密码鉴权慢问题, 目前最新的v3.4.9版本已经包含此优化, 同时可以通过调整bcrpt-cost参数来进一步提升性能。