本文联合作者 @牧原
问题的背景
某个名字很喜气的大客户的前线同学在一个傍晚找到了我们团队,反馈网络出现了严重的卡顿现象:
“这个节点上所有的服务都很卡,扩容之后没几天还会出现!本来以为是AMD的问题,现在换了机型还是一样。”
从客户的表述中我们已经了解到,在此之前他们做了很多的尝试,但是现象很明显:
-
新节点调度业务Pod上去后,过一段时间就会出现。
-
和机型没啥关系。
随后客户反馈了一个比较关键的信息,他们有注意到TCP OOM出现,但是因为流量很小,觉得和这个没关系。
问题的排查过程
排查客户问题的第一步,就是不要轻易相信客户说的话,于是我们开始验证客户所说的现象。
首先是速率很卡,这个非常好验证,在VNC的操作过程中,我们能很明显的感觉到有卡顿。
随后是客户反馈的TCP OOM,查看了客户异常节点的dmesg信息,确实,客户的节点在问题出现的时候持续有这个现象:
TCP OOM指的是TCP协议能够使用的内存触发了配置的阈值,在Linux内核中,作为socket子系统的一个实现,TCP协议能够使用的内存通过net.ipv4.tcp_mem这个sysctl设置三个值,分别代表着min,pressure,max。
TCP OOM的出现,意味着TCP协议使用的内存已经到达了max,除了fin相关的报文之外,TCP协议只能释放一点内存才能使用一点,所以出现卡顿的原因也就不难理解了。
然后我们开始检查客户的连接数量,对于容器网络来说,不同的Pod通过Linux的netns进行网络上的隔离,因此,我们重点针对不同的Pod开始统计netstat中的TCP连接数量,然而结果显示,确实如客户所说,所有的Pod的连接数都不多,其中能看到有明显的流量出入连接仅仅是个位数:
那么,有没有可能是这些连接本身有巨大的吞吐量呢?我们针对上面有较大流量的Pod进行了观测,结果显示,流量也很小。
到了这一步,能确定的现象是:
-
客户确实存在TCP协议消耗了大量的内存。
-
客户的流量不高。
两个现象结合在一起,就不得不怀疑客户的节点上出现了socket的泄漏,随后我们查看了节点级别的socket状态:
果然,客户的节点上出现了明显的socket泄漏,而TCP协议的socket占用的内存总量也只比tcp_mem默认的配置上限。sockstat信息和tcp_mem的配置都是节点级别生效的,从上图中的信息不难得出:
-
TCP协议正常使用的socket(通常就是ESTABLISHED状态的连接)是38,tw状态(TIMEWAIT)也只有49,但是alloc状态(分配成功的socket)的socket却有7385个。
-
orphan状态的socket是0个,很少,orphan状态比较特殊,他指的是“没有被用户进程持有的socket”,也就是说,已经释放但是还没有被内核回收的socket都归为此类,通常在连接建立频繁的业务上会出现较多,显然客户这个节点不属于这种情况。
那么,alloc但是却不是tw,inuse和orphan的socket,到底去哪儿了呢?
我们回顾以上上面几个socket状态的语义:
-
inUse,这个很好理解,在内核中通过sock_prot_inuse_get进行统计,属于实时的状态。
-
tw,这个也不难理解,tw_count也是内核直接维护的变量。
-
orphans,如上所述,除了inUse和tw,其他没有用户程序持有fd的socket都归类在这里,在内核的tcp_close中增长,在inet_csk_destroy_sock中减少。
那么在这三者之外,最有可能的去处是哪里呢?那就是,已经被关闭了连接,但是仍然被用户程序持有的socket。
理解这个概念其实需要明白socket与TCP连接之间的关系。在Linux中,一切皆文件,socket本身也是sockfs这个文件系统实现的一类文件:
-
用户程序可以通过操作文件的方式,例如open/close等操作一个socket文件,相应的也会持有socket的fd。
-
TCP作为socket的一种类型,将响应的操作实现为TCP协议的动作,例如open会打开一个TCP的socket,write可以实现向TCP流写入数据等。
如果存在socket文件还在用户程序的持有状态,但是TCP会话已经正常关闭,那么我们将能够看到还存在大量的socket类型的fd在某一个进程的活跃句柄中,随机我们对节点上所有进程进行了统计:
for i in $(ps aux |awk '{print $2}');do echo $i;ls -l /proc/$i/fd |awk '/socket/' |wc -l;done
果然我们找到了这样的进程:
可以看到53129这个进程持有了5336而socket类型的fd,看起来socket泄漏的主要元凶就这这个进程。
随后我们和客户确认了这个进程,是一个直播业务,在这个Pod中,只有一条ESTABLISHED状态的连接,出现如此巨大的socket泄漏,显然是客户代码写的有问题,问题排查到这里,客户已经了解了问题的前因后果,但是拒绝提供代码让我们进行分析,于是我们复现了这个场景,以下代码实现了和客户一样的bug:
import socket
def main():
# 创建socket对象
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 绑定地址和端口
sock.bind(('0.0.0.0', 8888))
# 开始监听
sock.listen(5)
count = 0
connections = {}
while True:
# 接受连接请求
conn, addr = sock.accept()
# 发送连接总数
count += 1
connections[addr] = conn
conn.send(f'当前连接总数:{count}'.encode())
# 关闭连接
#conn.close()
if __name__ == '__main__':
main()
如代码中所示,我们提供了一个监听8888端口的TCP服务,在每次接受请求后,将返回的socket文件描述符宝存在一个dict中防止被自动释放,随后我们再提供一个client脚本用于模拟客户端:
import socket
import time
def client():
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(('10.1.18.154', 8888))
print(sock.recv(1024).decode())
sock.close()
while True:
client()
time.sleep(2)
客户端也很简单:
-
发起对服务端的连接,然后接受并打印服务端的信息也就是服务端当前已经保存的socket数量,随后主动关闭连接。
-
每间隔2s重复一次上述的行为,用来产生泄漏的socket。
我们将server部署到kubernetes中,然后开始进行测试,果不其然,很快就能看到socket数量在上升:
看一下服务端的状态,我们通过kubeskoop-exporter的Pod进行更加高效的检查:
# 选择为与上面的server的Pod同节点kubeskoop-exporter实例即可
kubectl exec -it -n kubeskoop kubeskoop-exporter-ns9kz -- sh
可以发现,此时我们的server的Pod没有任何处于ESTABLISHED状态的连接,而查看进程打开的socket,则会发现有很多:
分析到这里,其实客户此次出现socket泄漏的原因已经非常清晰了,客户还有一个疑问:明明内核已经关闭了TCP连接,所有的数据都被用户程序读取了,为什么泄漏的socket还会占据大量内存呢?
其实这个问题在上面的文章中已经提到过,对于TCP协议而言,他所统计的内存并不仅仅指的是内核占据的部分,对于socket这个文件在用户态申请的内存,也就是用户视角的socket这个文件中存在的内容,也会计算在其中,所以当socket泄漏时,很大一部分的用户态的内存占据了TCP协议使用内存的额度,产生了这个问题。
问题的背后
尽管这个问题是一个客户代码引入的问题,我们还是能够看到,从ECS时代走向云原生时代,容器的抽象带来了排查问题的挑战,对于不了解容器底层原理的同学来说,排查容器的网络问题,往往会有这样的痛点:
-
那些参数是节点级别,那些参数又只针对容器生效?
-
如何更加高效的收集数据,尤其是在netstat这些传统工具无法应对容器场景的情况下。
为了解决这样的痛苦,我们在kubeskoop项目中提供了大量的功能来帮助不熟悉网络底层原理的同学更加高效的进行排查,例如,在这个案例中的socket类型的fd的打开数量,我们已经在kubeskoop新版本中做了支持:
现在你可以通过一个简单的命令就可以清晰地查看每个Pod打开的fd数量和socket类型的fd数量,结合上面已经就绪的针对TCP和socket的支持,排查这一类问题,将会事半功倍。欢迎大家就kubeskoop项目与我们交流,更多的信息,可以直接查看项目的主页 https://github.com/alibaba/kubeskoop。