问题背景
我们的聊天系统使用原版ejabberd来进行负载,对于一些过滤聊天消息等业务需求,我们开发了一个代理服务反向代理ejabberd的TCP连接,用于对客户端发来的消息进行过滤等操作,然后再发往ejabberd。所以对于一个用户,会维持2条TCP连接,两个连接的生命周期相互绑定,同生共死,这个服务以多容器的方式运行在docker swarm集群中。
问题发现
随着业务扩张,我们发现,该服务的socket连接数占用一直呈上升趋势,部分时间段虽然略有下降,但是整体趋势一直上涨。一直到单副本socket超过4k,单机器socket超过1w2的时候,我们认为与业务实际消耗相差太多,断定有socket泄露的情况。如果持续下去,会将单机端口数量耗尽,遂开始着手排查问题。这里需要补充一点,客户端与代理服务的socket连接并不会额外消耗端口号,但是代理服务会作为客户端向ejabberd发起连接,每个用户都会消耗1个端口号。
问题排查
该服务作为代理服务,socket基本上消耗在与客户端和ejabberd创建的TCP连接上,那么问题有两个方向:
客户端创建了多余的TCP连接并未使用,也没有关闭。
-
我们排查了服务端代码,在客户端到代理服务握手的过程中,发现几处可能会存在连接泄露的点,也就是当客户端创建TCP连接并发出登录请求的时候,在某些极端情况下,代理服务会将这个TCP连接的引用遗失,没有显式调用TCP连接的关闭方法。修改后上线,问题依然存在。
我们把目光转向了调查客户端是否保持了多余的连接没有关闭,由于我们的服务基础镜像是scratch,所以需要替换成某个linux发行版以便于进入容器进行调试;由于我们的容器运行在docker swarm overlay网络上,客户端IP无法直接在容器内看到,所以需要以host网络部署裸容器来进行调试。
生产环境不便于直接排查,在测试环境的监控中我们观察到了同样的现象,所以选择在测试环境排查问题。
通过在测试环境进入容器,并使用
netstat -atp
发现,确实存在许多 ESTABLISHED 状态的连接,这证明客户端发起了并建立了正常的连接,这些连接因为未知的原因并没有关闭,这个跟网络上普遍遇到的 TIME_WAIT 问题并不一样。对这些已经 ESTABLISHED 的连接通过IP进行统计,同一个IP维持了多个 ESTABLISHED 连接,和预期的1条TCP连接不一致。开始与客户端排查连接问题。
客户端排查的结论是:由于聊天相关的模块设计问题,TCP连接的生命周期是跟随开发工具进程的生命周期,当开发工具中的客户端在进行开发调试,反复重启的情况下,确实会存在残留并维持多个连接的情况,客户端作为app发布运行的话不会出现这个问题。
这个问题启发了我们,如果 ESTABLISHED 状态的连接不是客户端在“维持”,客户端直接消失,并没有发任何挥手包或者RST包,只是服务端“一厢情愿”的认为连接存在呢?
我们设计了一个实验,通过nc命令手动创建TCP连接到服务端,在容器内确认连接后,客户端直接断网,不让挥手包发出来。结果是:服务端确实认为这个连接处于 ESTABLISHED 状态,并且至少持续2天没有改变。
问题似乎清晰了一些:不断增长的连接都是 ESTABLISHED 状态,客户端在实际运行的过程中并不会“维持”多个TCP连接,是服务端“一厢情愿”的认为连接还存在。就算客户端因为各种原因已经消失了,服务端仍然保持了这个连接的状态,导致出现socket数量“泄露”的现象。
这里需要简单说明一下TCP连接的状态转换:TCP连接的建立并不是真的有一个线把两端连接起来,一旦线断掉双方都有感知。TCP连接经过三次握手,客户端和服务端的TCP连接管理模块里把这个连接标记为“ESTABLISHED”,那么这个连接就被“建立”起来了,开始交换数据。但是如果建立连接后,没有数据交换,其中一方直接消失,或者网线直接物理断掉,另一方是没有任何感知的,“ESTABLISHED”状态会一直持续,直到超时。
经过实际测试和抓包我们发现,ESTABLISHED至少会持续2天以上,这个是我们不能容忍的。
在测试过程中我们意外发现,docker swarm的负载均衡机制也会加重这个问题的表现。docker swarm集群依赖IPVS实现容器的负载均衡,当客户端首次发送数据包到docker swarm节点时,会选择一个容器转发数据包,并写入一个新的转发规则到一个路由表中,后续的数据包按照路由表已有的记录进行转发。这条转发记录上存在超时,如果15min内没有任何数据包通过来重置超时,那么这个转发记录会被删除,一旦客户端有新的数据包发来,那么只会收到一个无情的RST,容器内服务收不到任何数据;服务端内这条连接以 ESTABLISHED 的状态被封闭在容器内,直到超时才被回收。
客户端是移动端,网络环境十分不稳定,不能保证对于每个连接都能妥善关闭,现在需要有一个机制,让服务端主动发现客户端已经不在线了,把连接回收,其中一个方式就是修改 ESTABLISHED 超时时间。这种方式我们认为太hack,希望能通过业务层解决这个问题;另一个方式就是有一个心跳机制,连接建立后,服务端定期主动发出心跳包,探测客户端,如果客户端没有及时回复心跳包,则服务端认为客户端掉线,直接回收连接。
所以最后的解决方案是:在业务层代码中手动开启TCP KeepAlive机制并设置合适的探测时间间隔,以及时回收连接。
KeepAlive并不是TCP协议规范的一部分,但在几乎所有的TCP/IP协议栈(不管是Linux还是Windows)中,都实现了KeepAlive功能。
部署后效果显著,socket连接数不再持续增长,而是跟随业务负载在一个区间内起伏。
结论
这是我们第二次遇到需要开启TCP KeepAlive得以解决的问题,所以在开发TCP相关的服务时,考虑直接开启这一特性以避免后续的关于连接的奇怪问题。