K8S部分业务POD内存持续泄露问题

线上K8S集群有极少量的PHP业务,它们的POD内存持续走高直到OOM,相信与特殊代码场景有关,需要展开分析。

我选择从POD的内存监控原理入手,分析到底内存用到了哪些地方。

分析过程

我把整个分析过程拆分成步骤,实际我也是按照这个步骤的逻辑逐渐展开的。

step 1

因为容器化依赖Cgroup限制内存资源,Docker采集容器的内存使用量也是基于Cgroup技术,因此需要先搞明白Cgroup,其核心原理如下:

cgroup需要先建树(实际就是目录),整个操作系统可以建多颗树,每棵树可以关联N个子系统(cpu、mem、io…),但是整个操作系统中每种子系统只能出现在1颗树中,不能出现在多个树中。

说白了,假设Cgroup有cpu、mem、io三种子系统,那么整个系统:

1)最多mount挂载3颗Cgroup树,每棵树只管理1种子系统。

2)最小mount挂载1颗Cgroup树,这棵树管理3种子系统。

step 2

实际上,Cgroup标准做法是把每个子系统作为一棵树(Hierarchy),然后在树里面创建子cgroup做 资源限制。

Centos默认创建了这样的N颗树,每棵树管理1个子系统,K8S就是在这些树中创建子目录来使用Cgroup能力。

step 3

以内存memory为例,我们知道POD可以设置resource limit,具体是什么原理呢?

1)首先docker ps找到目标pod的相关容器,至少有2个容器,一个是pause容器,一个是应用容器。2)拿着应用容器的container id,执行docker inspect 可以看到label里有一个pod唯一标识uid:

同时,该容器ID为:

另外,标签里也说明了同POD的pause容器ID是多少:

3)K8S创建了kubepods子cgroup,仍旧以memory为例:

K8S资源限制是POD级的,所以K8S还会在这个cgroup下创建POD的子memory cgroup,进行POD级具体的资源限制。

在继续深入POD级cgroup之前,我们看一下kubepods这一级的内存限制:

所有POD的总内存限制为30.23G,宿主机是32G内存,其他1G多内存没有纳入cgroup是因为kubelet配置的预留内存导致的。

step 4

根据上面找到的POD,就可以继续定位到POD级的cgroup了:

整个POD限制为2G,符合Deployment YAML定义。

step 5

再往POD下面一级就是container的cgroup了,这里的内存会限制为什么呢?

看样是继承了POD级的限制,反正POD级就那么多内存,里面的单个容器最多也就用这些。

为什么还要做container级的cgroup呢?这样做,至少memory的使用明细是可以具体到container去查看的:

会发现应用容器占了1.8G左右,快要把POD的内存限制用满了。(也可以通过docker stats命令查看到容器内存占用)

我们拿着之前发现的sandbox容器ID(实际就是pause容器)查看一下内存使用:

只用了1M左右,因此pause容器的内存占用可以忽略。

step 6

那么应用容器真的占用了1.8G吗?实际我们详细看应用容器的内存使用统计:

会发现total_rss和total_cache加起来不过300MB+,其他内存跑哪里去了?

step 7

经过了解,cgroup的memory.usage_in_bytes除了计算rss和swap外,还统计了kmem,也就是内核使用内存,我们查看一下实际kmem使用量:

果然1.5G左右,和rss加起来大概就是1.8G了,为什么这个应用容器大部分内存都被kernel使用了呢?用来做啥呢?

step 8

kmem体现在内核slab内存的分配使用,可以直接查看应用容器的slabinfo:

找到内存占用高的容器,查看其slabinfo:

找到内存占用低的容器,查看其slabinfo:

dentry占用内存的差距最大,可以通过7900536*192得出大概是1.4G,的确吻合内存占比,那么它的用途是什么呢?大概就是文件项缓存之类的用途,具体参考:https://zhuanlan.zhihu.com/p/43133085

step 9

上述容器使用了790万的dentry,占了1.4G内存;宿主机执行slabtop可以看到整机分配了3000万的dentry,占了6G左右内存。

我们只有个别的应用存在内存泄露情况,怀疑与代码特殊行为有关,尝试strace了一下php-fpm,看是否有大量文件操作导致dentry增加:

竟然真的在不停的创建临时文件。

进一步strace保存完整日志,找到创建/tmp文件的HTTP请求信息:

从debug.log中,可以明确创建临时文件的接口是/comment/bgm_bulk_index,POST长度102633,类型是application/x-www-form-urlencoded:

其行为是先读取socket读进来16384字节的数据:

然后才创建了1个临时文件开始写入后续数据:

最后再把所有数据从临时文件里读进内存,才开始进入PHP脚本的处理逻辑。

step 10

我高频抓取了一下/tmp目录,抓到1个临时文件看了一下内容:

发现内容就是/comment/bgm_bulk_index接口的POST body体,怀疑PHP-FPM遇到太大的POST体会走临时文件。

找到PHP源码SAPI.c文件,函数sapi_read_standard_form_data用于解析POST表单:

FPM处理POST表单时,大概会通过php_stream_temp_create_ex创建用于存放解析结果的request_body buffer,第2个参数是内存阈值,一旦超过内存阈值就会写临时文件;

然后循环解析数据写入这个Buffer,因为上述case的POST body总大小是百K,所以就超过了内存阈值,写了临时文件。

这个SAPI_POST_BLOCK_SIZE内存阈值是16进制定义的,实际就是16384:

要想提高它,只能改PHP-FPM源码重新编译。

 step 11

最后,在高内存POD所在的node,进行一次slab dentry cache清理,观察POD内存是否下降:

POD内存从1.8G降到了346M,基本吻合了RSS实际占用,说明kmem部分被释放了。

step 12

虽然上述PHP接口频繁的创建临时文件,但是它请求结束也会删除掉,为什么slab cache能创建出数百万的dentry缓存对象呢?难道不应该删除后回收复用么?难道删除的文件表项也需要缓存起来,以便stat系统调用的时候可以立即返回文件不存在?还真不好说。

经过搜索(链接),发现内核的确会缓存删除文件的dentry:

负状态(negative):

与目录项关联的索引节点不复存在,那是因为相应的磁盘索引节点已被删除,或者因为目录项对象是通过解析一个不存在文件的路径名创建的。目录项对象的d_inode字段被置为NULL,但该对象仍然被保存在目录项高速缓存中,以便后续对同一文件目录名的查找操作能够快速完成。术语“负状态”容易使人误解,因为根本不涉及任何负值。

因此,PHP频繁的新建+删除文件,就会不停的分配新的dentry对象,旧的dentry会越来越多直到系统没有更多内存可用才会开始淘汰缓存。

总结

这个案例告诉我们,docker默认将kmem算作cgroup的内存占用是比较坑的,哪个cgroup创建出来的slab对象就会被算到谁的头上,多多少少有点不合理。

所以,也许禁止docker将kmem统计在memory usage内,是不是一个更好的做法呢?网上有诸多讨论,就不赘述了。

如果文章帮助您解决了工作难题,您可以帮我点击屏幕上的任意广告,或者赞助少量费用来支持我的持续创作,谢谢~