「内核模块auth_rpcgss」netns引用计数泄露导致容器弹性网卡残留

我们不久前定位了一个Linux内核bug,这个bug会影响所有在特权容器中启用了use-gss-proxy的Linux环境,表现为容器的网络命名空间(net namespace)无法彻底释放,导致容器终止后关联的虚拟网卡未能自动清除,运行时间长的机器上会观察到内存泄露。目前upstream还没有对这个bug的修复,我们内部已经做好了patch待测。

这个问题的定位过程很有借鉴价值,特此与大家分享。

「内核模块auth_rpcgss」netns引用计数泄露导致容器弹性网卡残留

【问题现象】

在k8s环境里,容器终止之后概率性地发生弹性网卡残留现象,而且只有privileged容器才有问题,不加privileged就没问题:

「内核模块auth_rpcgss」netns引用计数泄露导致容器弹性网卡残留

这个问题在客户的环境里可以稳定复现,但是在容器团队的测试环境无法复现,给排查问题的过程增加了不少障碍。

【定位过程】

1. 为什么虚拟网卡未被自动删除?

思路是这样的:因为测试发现残留的弹性网卡是可以通过"ip link del ..."命令手工删除的,内核中删除弹性网卡的函数是veth_dellink(),我们可以利用ftrace跟踪veth_dellink()调用,对比正常情况和发生残留的情况,试图搞清楚发生残留的时候有什么异常。ftrace脚本如下:

#!/bin/bash
SYS_TRACE=/sys/kernel/debug/tracing
[ -e $SYS_TRACE/events/kprobes/enable ] && echo 0 > $SYS_TRACE/events/kprobes/enable
echo > $SYS_TRACE/kprobe_events
echo > $SYS_TRACE/trace
echo nostacktrace > $SYS_TRACE/trace_options
if [ $# -eq 1 -a $1=="stop" ]; then
 echo "stopped"
 exit
fi
echo "p veth_dellink net_device=%di" >> $SYS_TRACE/kprobe_events
echo stacktrace > $SYS_TRACE/trace_options
for evt in `ls $SYS_TRACE/events/kprobes/*/enable`; do
 echo 1 > $evt
done
cat $SYS_TRACE/trace_pipe

以上ftrace脚本观察到正常场景下,tke-eni-cni进程有主动删除网卡的动作,而发生残留的场景下则没有。主动删除网卡的call trace是这样的:

「内核模块auth_rpcgss」netns引用计数泄露导致容器弹性网卡残留

tke-eni-cni进程不主动删除网卡的原因是net namespace已经被删除了,"lsns -t net"已经看不到了,在/var/run/docker/netns下也没了。而且net namespace被删除不应该导致虚拟网卡残留,恰恰相反,理论上net namespace销毁的过程中会自动删除关联的弹性网卡,可以通过以下的简单测试来验证,在客户的系统上验证也是没问题的:

「内核模块auth_rpcgss」netns引用计数泄露导致容器弹性网卡残留

阅读源代码,看到netns的内核数据结构是struct net,其中的count字段表示引用计数,只有当netns的引用计数归零之后才能执行销毁动作:

struct net {
 refcount_t passive;
 atomic_t count;
...

用crash工具查看内核,可以看到struct net的引用计数确实没有归零,难怪没有触发销毁动作:

crash> struct net.count ffffa043bb9d9600
 count = {
 counter = 2
 }

至此我们得出的判断是:导致弹性网卡残留的原因是netns泄露。

2. 是谁导致了netns引用计数泄露?

由于弹性网卡残留现象只出现在privileged容器,那么加不加privileged有什么区别呢?

加了privileged权限可以在容器里启动systemd服务。

对比发现,privileged容器里多了很多后台服务,怀疑是其中某个服务导致了netns引用计数泄露。我们一个一个依次排除,最终找到了直接导致netns泄露的后台服务是:gssproxy。

可是,容器终止后,在gssproxy后台进程也消失的情况下,netns引用计数仍然不能归零,这就很难解释了,因为用户态进程退出之后应该会释放它占用的所有资源,不应该影响内核,说明问题没那么简单,很可能内核有bug。

【复现问题】

之前容器团队在测试环境里复现不出问题,是因为信息量不够,我们定位到这一步,得到的信息已经可以复现问题了,步骤如下。

然后创建镜像,并运行它,注意以下第一条命令是执行特权容器,第二条是非特权容器

先创建以下Dockerfile用于制作镜像:

FROM centos:7
ENV container docker
RUN echo 'root:root' | chpasswd
ADD gssproxy-0.4.1-7.el7.x86_64.rpm /gssproxy-0.4.1-7.el7.x86_64.rpm
ADD nfs-utils-1.3.0-0.21.el7.x86_64.rpm /nfs-utils-1.3.0-0.21.el7.x86_64.rpm 
RUN yum localinstall -y gssproxy-0.4.1-7.el7.x86_64.rpm
RUN yum localinstall -y nfs-utils-1.3.0-0.21.el7.x86_64.rpm
RUN (cd /lib/systemd/system/sysinit.target.wants/; for i in *; do [ $i == \
systemd-tmpfiles-setup.service ] || rm -f $i; done); \
rm -f /etc/systemd/system/*.wants/*;\
rm -f /lib/systemd/system/local-fs.target.wants/*; \
rm -f /lib/systemd/system/sockets.target.wants/*udev*; \
rm -f /lib/systemd/system/sockets.target.wants/*initctl*; \
rm -f /lib/systemd/system/basic.target.wants/*;\
rm -f /lib/systemd/system/anaconda.target.wants/*;
VOLUME [ "/sys/fs/cgroup" ]
RUN systemctl enable gssproxy
CMD ["/usr/sbin/init"]
然后创建镜像,并运行它,注意以下第一条命令是执行特权容器,第二条是非特权容器
# docker run --privileged -it local/c7-systemd
# docker run -it local/c7-systemd

同时利用crash工具实时观察net namespace:在

crash> net_namespace_list

net_namespace_list = $2 = {

next = 0xffffffff907ebe18,

prev = 0xffffa043bb9dac18

}

在运行容器之前,所有的nets如下:

crash> list 0xffffffff907ebe18

ffffffff907ebe18

ffffa043bb220018

ffffa043bb221618

ffffa043bb222c18

ffffa043bb224218

ffffa043bb225818

ffffa043bb9d8018

ffffa043bb9d9618

ffffffff907ed400

在运行容器之后,多了一个netns:

crash> list 0xffffffff907ebe18

ffffffff907ebe18

ffffa043bb220018

ffffa043bb221618

ffffa043bb222c18

ffffa043bb224218

ffffa043bb225818

ffffa043bb9d8018

ffffa043bb9d9618

ffffa043bb9dac18 <<<新增的netns

ffffffff907ed400

然后我们杀掉这个容器:

[root@tlinux-test ~]# docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
12e632ca0ac7 local/c7-systemd "/usr/sbin/init" 2 hours ago Up 2 hours zealous_darwin
[root@tlinux-test ~]# docker kill 12e632ca0ac7
12e632ca0ac7

crash再看,netns并没有释放(如下),这样就成功地复现了问题。

crash> list 0xffffffff907ebe18
ffffffff907ebe18
ffffa043bb220018
ffffa043bb221618
ffffa043bb222c18
ffffa043bb224218
ffffa043bb225818
ffffa043bb9d8018
ffffa043bb9d9618
ffffa043bb9dac18 <<< 没有释放
ffffffff907ed400
crash> struct net.count ffffa043bb9dac00
 count = {
 counter = 2 <<< 引用计数没有归零
 }

【追查问题根源】

内核里修改引用计数的函数是get_net和put_net,一个递增,一个递减,通过追踪这两个函数就可以找到导致netns泄露的代码,但是由于它们都是inline函数,所以无法用ftrace或systemtap等工具进行动态追踪,只能自制debug kernel来调试。我们在get_net和put_net中添加了如下代码:

+ printk("put_net: %px count: %d PID: %i(%s)\n", net, net->count, current->pid, current->comm);+ dump_stack();

捕捉到可疑的调用栈如下,auth_rpcgss内核模块中,write_gssp()产生了两次get_net引用,但是容器终止的过程中没有相应的put_net:

「内核模块auth_rpcgss」netns引用计数泄露导致容器弹性网卡残留

通过strace跟踪gssproxy进程的启动过程,可以看到导致调用write_gssp()的操作是往/proc/net/rpc/use-gss-proxy里写入1:

20818 open("/proc/net/rpc/use-gss-proxy", O_RDWR) = 9
20818 write(9, "1", 1) = 1

到这里我们终于知道为什么一个用户态进程会导致内核问题了:

因为gssproxy进程向/proc/net/rpc/use-gss-proxy写入1之后,触发了内核模块auth_rpcgss的一些列动作,真正有问题的是这个内核模块,而不是gssproxy进程本身。

【临时规避方法】

客户使用的gssproxy版本是:gssproxy-0.4.1-7.el7.x86_64。用最新版本gssproxy-0.7.0-21.el7.x86_64测试,发现问题消失。对比这两个版本的配置文件,发现老版本0.4.1-7的配置文件包含如下内容,而新版本0.7.0-21则没有:

# cat /etc/gssproxy/gssproxy.conf

...

[service/nfs-server]

mechs = krb5

socket = /run/gssproxy.sock

cred_store = keytab:/etc/krb5.keytab

trusted = yes

kernel_nfsd = yes

euid = 0

...

按照手册,kernel_nfsd的含义如下,它会影响对/proc/net/rpc/use-gss-proxy的操作:

kernel_nfsd (boolean)
Boolean flag that allows the Linux kernel to check if gssproxy is running (via /proc/net/rpc/use-gss-proxy).
Default: kernel_nfsd = false

在老版本gssproxy-0.4.1-7上测试把kernel_nfsd从yes改为no,问题也随之消失。

所以临时规避的方法有两个:

1、在特权容器中,从gssproxy的配置文件/etc/gssproxy/gssproxy.conf中关掉kernel_nfsd即可,即kernel_nfsd=no。

2、在特权容器中,把gssproxy版本升级到0.7.0-21。

【解决方法】

分析源代码,这个问题的根本原因是内核模块auth_rpcgss通过gssp_rpc_create()创建了一个rpc client,因为用到了网络命名空间,所以要递增引用计数。代码路径如下:

gssproxy进程向/proc/net/rpc/use-gss-proxy写入1
 => write_gssp
 => set_gssp_clnt
 => gssp_rpc_create
 => rpc_create

当rpc client关闭的时候,引用计数会相应递减,负责的函数是rpcsec_gss_exit_net(),这是一个回调函数,它什么时候被调用呢?它的调用路径如下:

put_net
 => cleanup_net
 => ops_exit_list
 => rpcsec_gss_exit_net
 => gss_svc_shutdown_net

当put_net引用计数降到0的时候,会触发cleanup_net(),cleanup_net()随后会调用包括rpcsec_gss_exit_net()在内的一系列pernet_operations exit方法。问题就出在这:负责递减引用计数的函数rpcsec_gss_exit_net()必须在引用计数归零之后才能被调用,而rpcsec_gss_exit_net()不调用就无法递减引用计数,逻辑上发生了死锁。

修复这个bug的思路是打破上面这个死锁,让rpcsec_gss_exit_net()在恰当的条件下得以执行。我的patch是把它放进nsfs_evict()中,当netns被卸载的时候,nsfs_evict()会被调用,在这个时刻调用rpcsec_gss_exit_net()比较合理。提交给TLinux3 Public的补丁如下:

https://git.code.oa.com/tlinux/tkernel3-public/commit/55f576e2dd113047424fb90883dabc647aa7b143

相关推荐