京东数科容器实践之contiv支持非docker容器运行时

在我们京东数科的kubernetes容器集群中,网络插件使用的是contiv,容器运行时使用的是默认的docker,一直以来它们配合的非常好。但是当我们把容器运行时更换为containerd或者cri-o时,pod却一直无法创建成功。查看日志发现,一直打印类似这样的错误:Err: invalid nw name space: /var/run/netns/cni-4128c748-11b7-58bf-9a98-6e740c4e7f13,于是定位到导致该错误的源码位于nsToPID函数中,如下:

 // mgmtfn/k8splugin/driver.go
 // nsToPID is a utility that extracts the PID from the netns
 func nsToPID(ns string) (int, error) {
     ok := strings.HasPrefix(ns, "/proc/")
     if !ok {
         return -1, fmt.Errorf("invalid nw name space: %v", ns)
     }
     elements := strings.Split(ns, "/")
     return strconv.Atoi(elements[2])
 }

该函数的实现比较简单,首先检查传入的ns字符串是否以/proc/开头,如果不是则直接返回错误。而出错的时候,ns的值类似/var/run/netns/cni-4128c748-11b7-58bf-9a98-6e740c4e7f13这种格式,显然不符合条件。我们也通过日志发现,当容器运行时是默认的docker时,ns的值是类似proc/12345/ns/net这种格式的,它可以通过格式检查,并返回正确的pid。那现在问题基本明确了,当容器运行时是docker时,ns的值是proc/[pid]/ns/net这种格式的;当容器运行时是containerd或者cri-o时,ns的值是/var/run/netns/[xxx]这种格式的,导致解析失败。所以我们就从最初的源头分析,到底是什么原因导致了两种格式的ns的存在,以及如何解决这个问题。

1. contiv的整体架构

contiv是kubernetes集群中提供容器跨主机通讯的开源网络插件,能够支持二层、三层、overlay、aci等多种模式。它的整体架构如下图:

其中,netctl是一个命令行管理工具。netmaster是整个contiv集群的管理控制结点。netplugin需要部署在集群的每个结点上,负责创建ovs流表、通告BGP路由等实际工作。contivk8s也需要部署在集群的每个结点上,它是kubelet与netplugin之间的适配器,负责将kubelet发送过来的cni(container network interface)请求转换为netplugin可以接受的数据格式。

2. kubernetes集群中使用非docker容器运行时

在kubernetes集群中,kubelet里面默认配置的容器运行时是docker,但是可以通过kubelet的参数container-runtime和container-runtime-endpoint来使用其他的容器运行时,例如containerd和cri-o。此时,container-runtime需要设置为remote,同时container-runtime-endpoint需要设置为具体的容器运行时监听的套接字地址,例如unix:///run/containerd/containerd.sock。在kubernetes集群中,之所以能够使用各种不同的容器运行时,是因为kubernetes中规定了容器运行时(container runtime interface)的标准,只要按照cri标准开发的容器运行时,都可以集成到kubernete集群中。

我们知道,kubelet在创建pod时,首先创建一个sandbox容器,然后才会创建用户定义的容器。sandbox容器的一个作用是初始化网络资源,例如设置IP地址、路由信息等,也就是说cni网络插件是在此步骤中参与进来的。前面说到,kubernetes中规定了容器运行时cri的标准,其中与sandbox容器相关的api有RunPodSandbox,StopPodSandbox等等,各个具体的容器运行时也都实现了这些api。当kubelet想要创建一个sandbox容器时,它会调用容器运行时提供的RunPodSandbox这个api。所以,去看看各个容器运行时的RunPodSandbox这个api是如何实现的,就能知道为什么会出现两种格式的ns。

3. 容器运行时docker

其实,docker的后台服务进程dockerd并没有直接实现cri标准中规定的那些api。因为docker出现的时间比较早,它自己本身就有一套完整的创建、启动、停止、删除容器的api。而cri标准是后来才出现的,并且这两套接口无法实现无缝结合,所以必须有一个中间的适配器adapter存在,这个适配器就是dockershim,它的作用就是将cri标准的接口转换为dockerd中可以识别的接口。最后值得注意的是dockershim是集成在kubelet里面的。

在dockershim中,也就是kubelet中,RunPodSandbox的实现位于源文件pkg/kubelet/dockershim/docker_sandbox.go中,具体分为以下几个步骤:
3.1 Pull the image for the sandbox
3.2 Create the sandbox container
在此步骤中,dockershim作为客户端,会向dockerd发送创建sandbox容器的请求,dockerd创建成功后返回新创建的sandbox容器的信息,其中包括容器ID,例如8e94b9fffe48793c43b35f906b2b5f5eec23b091c3934e2899d2f2f89b871e04
3.3 Start the sandbox container
3.4 Setup networking for the sandbox
在此步骤中,dockershim会通过cni接口调用网络插件,来设置sandbox容器的网络资源,例如IP地址、路由信息等等。但是容器运行时对网络插件的调用方式有些特殊,它并不是通过网络或者unix域套接字来调用的,而是创建出一个新的进程,然后在新的进程里执行具体的cni网络插件的二进制可执行文件(在contiv里面,这个二进制文件是contivk8s)。并且,传递参数的方式是在新的进程里面设置一些环境变量,当然这种调用方式是cni标准中规定的。这些环境变量主要包括CNI_COMMAND、CNI_ARGS、CNI_NETNS等。其中CNI_COMMAND表示命令类型,当它的值是ADD时表示创建sandbox容器,当它的值是DEL时表示删除sandbox容器。CNI_ARGS里面包含一些pod相关的信息,具体例如K8S_POD_NAMESPACE=default;K8S_POD_NAME=test-pod;K8S_POD_INFRA_CONTAINER_ID=8e94b9fffe48793c43b35f906b2b5f5eec23b091c3934e2899d2f2f89b871e04。CNI_NETNS则表示第2步中创建的sandbox容器的网络命名空间的路径,也就是本文开始处的nsToPID函数中的ns参数的值。经过分析源代码,发现环境变量CNI_NETNS的值是通过如下函数获得的:

 // pkg/kubelet/dockershim/docker_service.go
 func (ds *dockerService) GetNetNS(podSandboxID string) (string, error) {
     r, err := ds.client.InspectContainer(podSandboxID)
     if err != nil {
         return "", err
     }
     return fmt.Sprintf("/proc/%v/ns/net", r.State.Pid), nil
 }

在dockershim中调用GetNetNS函数时,需要传入第2步中返回的sandbox容器的容器ID。GetNetNS的实现也比较简单,就是给dockerd发送inspect请求,获得sandbox容器的详细信息,然后从详细信息中取出sandbox容器的pid,最终返回/proc/[pid]/ns/net。至此,dockershim的逻辑已经分析完了,也已经知道了/proc/[pid]/ns/net格式的ns是如何得来的。下面在分析容器运行时containerd之前,需要先了解下两个事情,一是创建sandbox容器时contiv做了些什么事情,二是linux内核中namespace的基础知识。

4. 创建sandbox容器时contiv做的具体工作

4.1 kubelet调用contivk8s
4.2 contivk8s将kubelet的cni请求转换为netplugin可以接受的api
4.3 netplugin向netmaster发送创建请求,netmaster分配一个未使用的IP地址
4.4 netplugin在本机上创建虚拟网卡对,例如vport1和vvport11,然后把虚拟网卡vvport1加入到ovs虚拟交换机中。
注意此时这两个虚拟网卡都在主机的网络命名空间内,即在主机上运行ifconfig命令,是可以看到这两个虚拟网卡的。
4.5 netplugin调用nsToPID函数,此时ns的值类似/proc/[pid]/ns/net,nsToPID函数返回sandbox容器的pid。
4.6 netplugin得到sandbox容器的pid之后,会基于pid做如下三个操作:一是将虚拟网卡对的另一端vport1移动到新的网络命名空间 netlink.LinkSetNsPid(link, pid),这里的link参数指的是vport1。经过此操作之后,主机的网络命名空间内无法再看到vport1这个虚拟网卡,而这个命令nsenter -t [pid] -n ifconfig则可以看到,因为此命令的意思是输出[pid]指定的网络命名空间内的网卡情况。二是执行命令nsenter -t [pid] -n ip link set dev vport1 name eth0,它的意思是将位于[pid]指定的网络命名空间内的虚拟网卡vport1重命名为eth0。三是执行命令nsenter -t [pid] -n ip address add 192.168.1.1/24 dev eth0,即设置ip地址。

5. linux内核中namespace的基础知识

namespace是linux内核提供的一种资源隔离方案,包括network、mount、user、pid、ipc、uts等多种资源类型的隔离。对于系统中的每个进程,都有/proc/[pid]/ns/这样的一个目录存在,里面包含了这个进程所属的各个namespace的信息,例如:

 ls -l /proc/12345/ns/

 lrwxrwxrwx 1 root root 0 Mar 12 16:53 ipc -> ipc:[4026533048]
 lrwxrwxrwx 1 root root 0 Mar 12 16:53 mnt -> mnt:[4026533279]
 lrwxrwxrwx 1 root root 0 Mar 12 16:53 net -> net:[4026533051]
 lrwxrwxrwx 1 root root 0 Mar 12 16:53 pid -> pid:[4026533283]
 lrwxrwxrwx 1 root root 0 Mar 12 16:53 user -> user:[4026531837]
 lrwxrwxrwx 1 root root 0 Mar 12 16:53 uts -> uts:[4026533282]

与namespace相关的api有三个,它们分别是clone、setns、unshare。在linux系统中,我们可以通过fork或者clone来创建新的进程,以fork方式创建出来的新的进程与其父进程在相同的命名空间内,而以clone方式创建新的进程时,可以通过flags参数来指定创建新的命名空间。例如,当flags中包含CLONE_NEWNET时,新的进程则会位于一个新的网络命名空间内,与父进程的网络命名空间是不同的。setns则可以将当前进程加入到一个已经存在的命名空间,它的函数原型是int setns(int fd, int nstype)。假设已经存在一个pid为12345的进程,我们想要把当前进程加入到pid为12345的进程所在的网络命名空间,则可以在当前进程中打开/proc/12345/ns/net这个文件,获得它的文件描述符fd,然后调用setns(fd, CLONE_NEWNET)来实现。unshare则会使当前进程退出当前的命名空间,然后加入到新创建的命名空间内,它的函数原型是int unshare(int flags)。例如我们想要把当前进程加入到一个新的网络命名空间内,则可以在当前进程内调用unshare(CLONE_NEWNET)来实现。最后命名空间还有一个特性,就是当一个命名空间中的所有进程都退出时,该命名空间将会被销毁。那么,当所有进程都退出某个命名空间时,我们依然想保留它怎么办?可以通过bind mount的方式。例如mount --bind /proc/12345/ns/net /file/tmp,这样就算属于这个网络命名空间的所有进程都退出了,只要/file/tmp这个文件还在,那么/proc/12345/ns/net这个网络命名空间就会一直存在。在其他的进程中,可以打开文件/file/tmp,获得文件描述符fd,最后调用setns(fd, CLONE_NEWNET)加入到这个网络命名空间。

6. 容器运行时containerd

在containerd中,RunPodSandbox的实现位于源文件vendor/github.com/containerd/cri/pkg/server/sandbox_run.go中,具体分为以下几个步骤:
6.1 Pull the image for the sandbox
6.2 Create a new network namespace
注意注意,此步骤是与容器运行时docker最大的区别。containerd首先创建出一个新的网络命名空间,而且这个网络命名空间内也没有任何进程存在,自然也就不会得到/proc/[pid]/ns/net这样格式的ns。从我们上面的分析可知,contiv的netplugin却是依赖于这个pid进行后续工作的。我们先看看containerd是如何创建出一个新的网络命名空间的。经过分析源代码发现,containerd会调用NewNS函数去创建一个新的网络命名空间,具体的代码如下,为了便于说明,已经做了修改。

 // vendor/github.com/containernetworking/plugins/pkg/ns/ns_linux.go
 func NewNS() string {
     // b是长度为16的随机字节数组
     nsName := fmt.Sprintf("cni-%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
     nsPath := path.Join("/var/run/netns", nsName)
     // 此时,nsPath类似这样/var/run/netns/cni-4128c748-11b7-58bf-9a98-6e740c4e7f13

     // 保留当前的网络命名空间
     origNS, _ = GetNS(getCurrentThreadNetNSPath())

     // 创建一个新的网络命名空间,并把当前进程加入进去
     unix.Unshare(unix.CLONE_NEWNET)

     // 将新的网络命名空间挂载到/var/run/netns/目录下的某个文件
     unix.Mount(getCurrentThreadNetNSPath(), nsPath, "none", unix.MS_BIND, "")

     // 将当前进程恢复到之前保留的网络命名空间
     origNS.Set()
     
     return nsPath
 }

最终,我们会得到一个文件/var/run/netns/[xxx],而这个文件会指向刚刚创建的新的网络命名空间。
6.3 Setup networking for the sandbox
此步骤与3.4章节中的流程相同,不再重复,唯一的区别就是环境变量CNI\_NETNS变为这样的格式/var/run/netns/[xxx]。这个值最终被传递到netplugin中nsToPID函数中,但是nsToPID函数却期望/proc/12345/ns/net这样的格式,所以解析失败,返回invalid nw name space的错误。
6.4 Create the sandbox container in the new network namespace

7. 问题修复

原因已经找到了,netplugin期望从传入的ns参数中获得代表新的网络命名空间的pid,但是在容器运行时containerd中构造出的ns是/var/run/netns/[xxx]这样的格式,无法获得pid。那我们的解决方式就是在这两种格式的ns之间做一个转换,将格式为/var/run/netns/[xxx]的ns转换为/proc/[pid]/ns/net这样的格式。要想转换,就需要一个pid的存在,那这个pid是哪个进程的pid呢?而且还要确保这个pid指向那个刚刚创建的网络命名空间。我们先把创建sandbox容器时涉及到的组件整理一下,如下图:

(1)kubelet调用cri标准的RunPodSandbox接口

(2)容器运行时containerd创建新的网络命名空间,将环境变量CNI_NETNS设置为这样的格式/var/run/netns/[xxx],然后调用cni网络插件接口创建新的进程并执行contivk8s

(3)contivk8s将cni请求转换为netplugin可以接受的api,其中一步是读取这样格式/var/run/netns/[xxx]的环境变量CNI_NETNS,然后将其赋值给ns参数

(4)netplugin设置ip地址、ovs流表等等

经过这些分析之后,我们可以在contivk8s这里搞点小事情。contivk8s进程启动后,读取环境变量CNI_NETNS的值,如果是类似/var/run/netns/[xxx]这种格式的,则打开该文件并获得文件描述符fd,然后调用setns(fd, CLONE_NEWNET)将自己加入到/var/run/netns/[xxx]指定的网络命名空间。接着获得自己进程的进程号pid,并格式化为/proc/[pid]/ns/net这样的格式,最后将其发送到netplugin。具体的代码片段如下:

 if !strings.HasPrefix(ppInfo.NwNameSpace, "/proc/") {
     nsHandle, err := netns.GetFromPath(ppInfo.NwNameSpace)
     if err != nil {
         return fmt.Errorf("error getting ns %s: %s", ppInfo.NwNameSpace, err)
     }
     err = netns.Set(nsHandle)
     if err != nil {
         return fmt.Errorf("error switching to ns %s: %s", ppInfo.NwNameSpace, err)
     }
     ppInfo.NwNameSpace = fmt.Sprintf("/proc/%d/ns/net", os.Getpid())
 }

8. 总结

contivk8s经过修改之后,不管容器运行时是dockerd还是containerd,最终netplugin里面接收到的ns参数始终是/proc/[pid]/ns/net这样的格式,从而可以进行正确的处理。我们已经将该功能合并到github的contiv项目中了,欢迎大家使用,如果有任何问题,可以通过邮件zhouzijiang@jd.com进行交流。

最后,我们京东数科一直致力于构建基于kubernetes的大规模容器集群,经受618和双11的流量考验,欢迎有意向的同学加入我们一起努力,可以发送简历到邮箱hejun3@jd.com,谢谢!

K8S中文社区微信公众号

评论 抢沙发

登录后评论

立即登录