【编者按】在微服务系统中,服务注册中心是最基础的组件,它的稳定性将直接影响到整个服务系统的稳定性。本文主要介绍爱奇艺微服务平台基于Consul的服务注册中心搭建方法,与内部容器平台和API网关的集成,重点记录Consul遇到的一个故障,分析解决的过程,并解决这个问题。故障从架构上优化调整。Consul是近几年流行的服务发现工具,用于实现分布式系统的服务发现和配置。与其他分布式服务注册和发现方案相比,Consul的方案更加“一站式”,更易于使用。他的主要应用场景是:服务发现、服务隔离、服务配置。注册中心背景及Consul的使用从微服务平台的角度,我们希望提供一个统一的服务注册中心,让任何业务或团队都可以使用这个基础设施来发现彼此,只需要协商服务名称;它还需要支持业务进行多DC部署和故障转移。由于在扩展性和多DC支持方面的良好设计,我们选择了Consul,并采用了Consul推荐的架构。单个DC中有ConsulServer和ConsulAgent,DC之间为WAN模式,相互对等。结构如下图所示:注:图中只画了四个DC。实际生产环境根据公司机房的建设和第三方云的接入,总共有十几台DC。与QAE容器应用平台集成爱奇艺内部容器应用平台QAE已与Consul集成。由于早期开发基于Mesos/Marathon系统,没有Pod容器组概念,无法友好注入sidecar容器。因此,我们在微服务模式下选择了第三方注册方式,即QAE系统将注册信息实时同步到Consul,如下图所示;并且使用了Consul的对外服务模式,可以避免两个系统不一致导致的故障。比如Consul已经判断节点或者服务实例不健康,但是QAE没有感知到,所以不会重启或者重启Scheduling,导致没有可用的健康实例。QAE应用与服务的关系表述如下:每个QAE应用代表一组容器,应用与服务之间的映射关系是松耦合的。根据应用的实际DC,关联对应的ConsulDC即可。后续应用容器的更新、扩缩容、重启失败等状态变化都会实时反映在Consul的注册数据中。与API网关集成微服务平台API网关是服务注册中心最重要的用户之一。网关会根据地域、运营商等因素部署多个集群。每个网关集群会根据内网位置对应一个Consul集群,查询距离Consul最近的服务实例,如下图所示:这里我们使用了Consul的PreparedQueryFunction,对于所有的服务,返回这个的服务实例先DC,如果本DC没有,则根据DC之间的RTT从近到远查询其他DC的数据。故障分析与优化Consul故障Consul自2016年底上线以来,已经稳定运行了三年多,但是最近遇到了故障。我们收到报告说某DC的多台ConsulServer没有响应请求,大量的ConsulAgent无法连接到Server。报警,并没有自动恢复。服务端观察到的现象主要有:raft协议一直选举失败,获取不到leader;大量的HTTP&DNS查询接口超时,观察到有的需要几十秒以上才返回(正常情况下应该是毫秒级返回);goroutine快速线性增长,内存同步上升,最终触发系统OOM;日志中没有发现明显的问题,从监控指标上观察到PreparedQuery的执行时间异常增加,如下图所示:此时API网关查询服务信息也超时失败,我们将对应的网关集群切换到另一个DC,然后重启Consul进程即可恢复正常。故障分析通过查看日志,发现故障前DC之间存在网络抖动(RTT增加,伴有丢包),持续时间约1分钟。我们初步分析是正常接收到的PreparedQuery请求,由于DC之间的网络抖动,导致服务器积压无法快速返回,随着时间的积累越来越多,goroutine越来越多,内存被占用,最终导致服务器异常。按照这个思路,尝试在测试环境中复现。总共有4个DC。单台服务器PreparedQueryQPS为1.5K。每个PreparedQuery查询会触发3次跨DC查询,然后使用tc-netem工具模拟DC之间RTT的增加。这样的话,得到如下结果:当DC之间的RTT从正常的2ms变为800ms时,ConsulServer的goroutine和内存确实会线性增长,PreparedQuery的执行时间也会线性增长,如下图下图:虽然goroutine和内存存在增长,但是在OOM之前,ConsulServer的其他功能没有受到影响,Raft协议正常工作,这个DC的数据查询请求也可以正常响应;在DC之间的RTT恢复到2ms的那一刻,ConsulServer失去了leader,随后Raft不停的选举失败无法恢复。通过以上操作,可以稳定地重现故障,使分析工作有了方向。首先基本确认goroutine和内存的增长是由于PreparedQuery请求积压造成的,而积压的原因是前期网络请求阻塞,后期积压的原因仍然未知网络恢复。此时,整个流程应该处于异常状态;那么,为什么网络恢复后Consul会失效呢?Raft只有DC内部的网络通信,为什么会不正常?这是最让我们困惑的问题。一开始,我们关注的是Raft问题。通过跟踪社区问题,我们发现了hashicorp/raft#6852,其中描述了我们的版本在高负载和网络抖动的情况下可能会出现raft死锁。这种现象与我们非常相似。相似。但根据问题更新Raft库和Consul相关代码后,测试环境重现故障依旧。之后,我们尝试在Raft库中添加日志,查看Raft的工作详情。这次我们发现Raft成员进入了Candidate状态,请求peer节点给自己投票。日志间隔10s,代码只执行了一行metricsupdate,如下图:因此怀疑是metrics调用被阻塞,导致整个系统运行异常。之后我们在发布历史中发现了相关的优化。armon/go-metrics低版本在Prometheus实现中使用全局锁sync.Mutex,所有metrics更新需要先获取这个锁,v0.3.3版本使用sync.Map代替,每个metric作为key字典的,只有在key初始化的时候才需要获取全局锁,然后不同的metrics更新value的时候没有锁竞争。同一个metric更新时,使用sync.Atomic保证原子操作,整体效率更高。更新对应的依赖库,重现网络抖动后,ConsulServer可以自行恢复正常。看来是因为metrics代码的阻塞导致整个系统异常。但我们仍有疑问。复现环境下单机PreparedQueryQPS为1.5K,稳定网络环境下单机QPS达到2.8K时仍正常工作。也就是说,正常情况下,原始代码是满足性能要求的,只有出现故障时,才会出现性能问题。接下来的调查就麻烦了。经过反复试验,我们发现了一个有趣的现象:使用go1.9编译的版本(也是生产环境使用的版本)可以重现该故障;无法重现使用go1.14编译的相同代码。出了些问题。仔细查看后,在go的发布历史中发现了如下两条记录:根据代码,我们发现用户反馈在go1.9~1.13版本中,当大量goroutine同时竞争一个sync.Mutex时时间,性能会出现急剧下降的情况,这就很好地说明了我们的问题。由于Consul代码依赖go1.9新内置的库,我们无法用低版本编译,所以我们去掉go1.14中sync.Mutex相关的优化,如下图,再编译Consul使用这个版本的go。果然,我们的失败又可以重现了。回顾语言的更新历史,go1.9版本加入了公平锁特性,在原有的普通模式基础上加入了饥饿模式,避免了锁等待的长尾效应。但是在普通模式下,新的goroutine在运行过程中有更高的概率成功竞争锁,从而免去了goroutine的切换,整体效率更高;而在饥饿模式下,新的goroutine不会直接去竞争锁,而是会自己排到等待队列的尾部,然后休眠等待唤醒。锁是按照等待队列的FIFO分配的,获取锁的goroutine被调度执行,会增加goroutine调度和切换的成本。在go1.14中,性能问题得到了改善。在饥饿模式下,当该goroutine执行解锁操作时,会直接把CPU时间让给下一个等待锁的goroutine,这样会整体加速受锁保护的代码。实施。这次失败的原因很清楚。第一,网络抖动导致大量PreparedQuery请求积压在Server中,同时也造成大量的goroutines和内存占用;网络恢复后,积压的PreparedQuery继续执行。在我们复现的场景下,goroutines的积压会超过150K。这些goroutines会在执行过程中更新指标以获得全局的sync.Mutex。这时候切换到饥饿模式,性能会下降。很多时间在等待sync.Mutex,请求阻塞超时;除了goroutines的积压,newPreparedQuery还在接收,获取锁的时候也是阻塞的。结果,sync.Mutex一直处于饥饿模式,无法自动恢复;另一方面,raft代码的运行依赖于定时器、超时以及节点间消息的及时传递和处理,而这些超时通常在秒级和毫秒级,但是metrics代码被阻塞的时间太长,这直接导致时序相关逻辑无法正常运行。然后我们更新了生产环境中发现的所有问题,升级到go1.14、armon/go-metricsv0.3.3、hashicorp/raftv1.1.2版本,使Consul达到稳定状态。此外,还编制和完善了监测指标。核心监控包括以下几个维度:进程:CPU、内存、goroutine、连接数Raft:成员状态变化、提交率、提交时间、同步心跳、同步延迟RPC:连接数、跨DC请求数写入负载:注册&注销率读取负载:Catalog/Health/PreparedQuery请求量,冗余注册耗时根据Consul失效过程中的失效现象,我们重新审视了服务注册中心的架构。在Consul的架构中,如果某个DC的所有ConsulServer都出现故障,则说明该DC出现故障,必须依赖其他DC进行容灾。但是在现实中,很多不在关键路径上的业务,对SLA要求不是特别高的业务,并没有部署在多个数据中心。如果他们所在DC的Consul出现故障,整个服务就会失败。对于没有多DC部署的服务,如果可以注册到冗余DC,当单个DCConsul失效时,其他DC仍然可以正常发现。因此,我们修改了QAE注册关系表。对于只部署在单个DC的服务,系统会自动在其他DC注册一个副本,如下图:QAE的冗余注册相当于在上层做了多次数据写操作。Consul本身不会在DC之间同步服务注册数据,所以直接通过ConsulAgent方式注册的服务没有很好的冗余注册方式,部署多个DC还是依赖服务本身。保证API网关目前API网关的正常运行依赖于ConsulPreparedQuery查询结果的本地缓存。目前的交互方式有两个问题:网关缓存是惰性的,网关只会在第一次使用时从Consul查询中加载。查询失败在失败的情况下会导致请求转发失败;PreparedQuery可能涉及多次跨DC查询,耗时长,查询复杂。由于每个网关节点都需要构建单独的缓存,而缓存有TTL,会导致同一个PreparedQuery查询被执行多次,查询QPS会随着网关集群的规模线性增长。为了提高网关查询Consul的稳定性和效率,我们选择为每个网关集群单独部署一个Consul集群,如下图所示:图中红色的是原来的Consul集群,绿色的是Consul是为网关Cluster单独部署的,它只在单个DC内部工作。我们开发了Gateway-Consul-Sync组件,定时从公共Consul集群读取服务的PreparedQuery查询结果,然后写入绿色Consul集群,网关直接访问绿色Consul进行数据查询。经过这次改造后,有以下优势:从支持网关的角度来看,公有集群的负载本来是随着网关节点的数量线性增加的,改造后变成了随着服务数量的增加而线性增加,单个服务只会在同步周期执行一次PreparedQuery查询,降低整体负载;图中绿色的Consul仅供网关使用。PreparedQuery执行时,所有数据都是本地的,不涉及跨DC查询。因此降低了复杂度,并且不受跨DC网络的影响。而且集群整体的读写负载更可控,稳定性更好;当公有集群出现故障时,Gateway-Consul-Sync无法正常工作,但绿色Consul仍然可以返回之前同步的数据,网关可以继续工作;因为网关在改造前后查询Consul的接口和数据格式是完全一致的。当图中绿色的Consul集群出现故障时,可以切换回公有的Consul集群作为备份方案。总结与展望作为统一的服务注册中心,稳定性和可靠性始终是我们的首要目标。一方面保证了服务注册中心本身的稳定性,另一方面也通过架构中的部署、数据、组件等多维冗余,提升了整个技术体系的稳定性。目前我们有一系列的监控指标可以帮助我们评估系统的整体容量和饱和度。随着接入服务越来越多,需要不断完善服务维度的监测指标。当系统负载发生意外变化时,可以快速定位到特定的服务和节点。
