在云计算场景下的大规模分布式系统中,网络异常、磁盘IO异常、时钟跳变、操作系统异常、软件本身的BUG都对分布式系统的正确运行提出了挑战。在构建稳定、高可用的分布式系统的过程中,持续监控和告警改进是一项非常重要的工作。这也需要我们研发同学从细节做起。本文介绍的场景是在线告警中发现一丝异常,发现内存泄漏。追根溯源,全程48小时跟踪修复风险隐患,进一步丰富了完善监控报警体系的流程。1.问题最先出现。2019年10月下旬某日,隐患开始显现,不到24小时,值班同学接连接到多个在线电话,提示某业务集群分布式协同服务流程异常。:14:04:28,告警提示有Follower意外退出当前Quorum,通过选举重新加入Quorum;16:06:35,告警提示某Follower意外重启,daemon进程启动后重新加入Quorum;02:56:42,告警提示某个Follower意外重启,daemon进程开始重新加入Quorum;12:21:04,告警提示有Follower意外退出当前Quorum,通过选举重新加入Quorum;…下图为分布式协同服务的系统架构搭建完成。后端是基于Paxos的一致性维护功能模块,前端作为代理客户端与一致性服务单元通信,支持服务能力的横向扩展。由于后端分布式一致性服务单元由5台Master机器组成,可以容忍2台机器同时出现故障,所以目前没有发现以上告警对服务可用性有影响。但是,单个Master服务进程在短时间内频繁出现异常,对服务的稳定性是一个很大的隐患,尤其是对于作业调度强烈依赖分布式协同服务的业务。于是,我们开始集中人力调查这个问题。我们先排除网络问题,通过tsar命令查看机器上的网络指标,通过内网平台查看机器连接的网络设备和网络链路,也都处于健康状态。回到日志分析,我们从Leader日志中找到了蛛丝马迹。在上述报警时间点,发生了“Leader主动关闭与Follower的通讯通道”等事件。自然而然,我们想知道为什么Leader会频繁关闭与Follower的通信通道。日志里也有答案:Follower已经很久没有向Leader发送过请求了,包括Leader发送的心跳包的回复,所以被Leader认定是一个异常的Follower,然后关闭与它的通讯通道,并将其踢出当前Quorum。好了,现在我们可以直观的解释一下触发告警的原因:Follower长时间与Leader失去联系,从而触发退出Quorum的逻辑(如果退出Quorum的过程比较慢,会进一步触发直接退出的逻辑过程并迅速恢复)。那么新的问题来了,为什么这些Follower不响应轻量级的心跳请求呢?这次没有直接登录来解答我们的疑惑。好在有间接信息:follower的日志输出在问题发生前很久。中断(超过触发退出Quorum的阈值),这在频繁请求访问分布式协调服务的业务集群中几乎是不可想象的!我们更愿意相信后端进程挂了,而不是根本没有用户请求调用这里。在没有其他排查思路的情况下,基于后端分布式一致性服务单元是基于java实现的,查看follower出现问题的时间段的gc日志,发现原因:ParNew消耗和javagc相关的太久了(那天已经清理了日志,下图是机器上类似的日志),我们知道javagc进程有一个STW(Stop-The-World)机制,除了垃圾收集器,其他所有线程都被挂起,这可以解释为什么后端Follower线程会短时间挂起。虽然我们java程序申请的初始内存比较大,但实际分配的是虚拟内存。如果ParNew时间过长,很大可能是机器实际物理内存不足。按照这个思路,我们进一步在Follower机器上使用top命令查看进程的内存使用情况,发现机器上前端Proxy进程混合部署的内存已经达到了整体的66%+machine(此时后端一致进程实际内存占用物理内存也达到了30%左右)。进一步查看系统日志,我们发现部分机器上的前端Proxy进程已经因为内存不足OOM错误被系统kill掉了。至此,问题已经初步定位,开始排查前端Proxy内存泄露问题。2.业务风险该业务严重依赖分布式协调服务的服务发现功能。以本次调研的业务集群为例,单个集群注册的服务地址数量达到240K,地址解析活跃会话总数达到450W。因此,分布式协调服务的稳定性直接影响到集群中业务运行的健康运行。在确认分布式协同服务的Proxy进程存在内存泄露风险后,我们紧急排查了线上其他集群,发现该问题并非孤例。大促在即,这个隐患不能留到双十一。gcore安装好前端代理站点后,我们进行了紧急改动,将上述风险集群的前端代理进程一一重启。风险。3、深入调查,继续回来调查问题。在我们重启Proxy进程之前,gcore保留站点(这里我要强调的是在线gcore一定要谨慎,尤其是对于一个内存占用这么大的进程,很容易导致请求处理挂掉。我们的考虑是分布式协调服务的客户端有超时重试机制,可以承受一定时间的gcore操作)。因为前端Proxy的主要内存开销是基于订阅的高效地址缓存,所以我们首先通过gdb查看了维护缓存的unordered_map的大小,结果大小符合预期(监控指标显示,估计空间占用不会超过1GB),远不能支撑这样的内存泄漏。我们通过字符串核心文件进一步证实了这一点。字符串对象占用的空间并不多。一时间,我们的调查陷入了进退两难的境地。这时我们想到了师兄组的一位大神的杰作,引入线上环境调查C/C++应用内存泄漏问题(可能有同学提到为什么不用valgrind这个工具?首先,这个神器在测试环境下是必须的,但是毕竟发布和上线可能会遗漏一些场景,导致线上内存泄露,另外大型项目会暴露出valgrind运行速度太慢的问题,以及甚至导致程序无法正常运行),这里换个角度考察内存泄漏:虚拟表。每个有虚函数的类都有一个虚表,同一个类的所有对象都会指向同一个虚表(通常是每个对象的前8个字节),所以统计每个虚表指针出现的频率就可以知道有多少这样的对象以一定的速度分配。如果数量异常,则有内存泄漏的可能。大神提供了一个内存泄露排查工具(说明一下,这个工具是基于常规的tcmalloc内存管理方式来分析的),通过符号表找到每一个vtable,这样就可以知道虚表的地址,也就是每一个的对象virtualfunctionclass对于前8字节的内容,这个工具强大的特点是摆脱了gdb依赖,直接分析应用程序请求的所有内存块,找到所有泄漏内存块的地址,进一步统计每个虚表对应类的对象个数。该工具的实现细节不再赘述。最后我们统计了所有出现频率超过10W的虚表信息,找到了罪魁祸首:这个common::closure对象泄漏量高达16亿+。根据闭包的参数类型信息,我们很快定位到具体类CheckCall:$grepClosure-rproxy|grepEnvproxy/io_handler.h:typedefcommon::ClosureCheckCall;关于这个对象的大规模泄露,最终定位的原因其实和我们有关关于Proxy日志分析,我们发现日志中有大量的非法访问请求:客户端试图解析一个注册的服务地址一定的作用,但使用了错误的集群名称参数。单台Proxy机器1秒内最多可以产生4000+条这样的错误日志,那么有没有可能是继续往这样的错误路径走导致对象内存泄露?对比这块的代码,仔细研究了一下,果然,CheckCall对象正常会走到执行逻辑(common::closure执行完会自动析构,释放内存),但是在异常路径,比如上面非法的簇名,那么这个函数会直接返回。对应的CheckCall对象不会被析构,随着业务的不断访问,内存泄漏会不断发生。4.风险修复定位到这个问题的根源后,摆在我们面前的修复方式有两种:1)业务方停止错误的访问行为,阻止分布式协调服务前端Proxy继续走到错误的路径,引发内存泄漏;2)从前端Proxy代码层面彻底修复这个bug,然后对在线分布式协同服务Proxy做一轮升级;第二种方案动静比较大,大促前没有足够的升级和灰度窗口,最终我们选择了方案一,根据日志中不断出现的非法访问路径,联系业务协助排查确认业务的哪些客户端进程使用了??错误的集群名称访问分布式协调服务,并进一步查找原因。最终业务方通过紧急上线hotfix解决了集群名称错误的访问行为。从而控制了业务线分布式协同服务前端Proxy进程的内存泄漏趋势,消除了风险。当然,根本的修复方法还是处理前端ProxyCheckCall的异常路径。我们的修复方式是遵循函数的原则,实现单退出,同样执行异常路径下的闭包,直接在执行逻辑中判断错误码。return,即不执行实际的CheckCall逻辑,只是触发自毁行为。补丁将于双十一后发布上线。五、问题总结稳定工作需要从细节做起。我们需要能够对线上服务的每一个告警或者服务指标的一丝异常都能够追根溯源,找到根源,并持续跟进风险修复,这样我们一定能够锤炼出一个更加稳定的分布式系统。“路漫漫其修远兮,我将上下求索。”我与你分享我的鼓励。【本文为专栏作者《阿里巴巴官方技术》原创稿件,转载请联系原作者】点此查看作者更多好文
