Kata Containers 网络模型(一)
Containerd 是如何为容器准备网络的
最近在着手改进 Kata containers 网络模块,所以我用了两周时间梳理了 Kata containers 网络是如何配置的,这里以最经典的“bridge + veth + tcfiltering”模型为例,这也是默认情况的网络配置,其他的网络在流程上大同小异。这篇文章里会涉及上层 runtime(containerd)、cni 插件、下层 runtime(kata-shim)以及 agent 之间的配合细节。在这篇文章中也会有相当一部分的源码,我会尽量在讲清原理之后再深入代码,避免陷入细节无法自拔。
- Kata Containers 网络模型(一)针对 containerd 如何配置网络这一主题详细展开,使用大量篇幅陈述如何初始化 CNI 插件,这部分内容有助于理解 CNI 插件的配置文件与网络的关系。然后详细描述了 containerd 在创建 sandbox 的时候是如何创建 netns 以及如何调用 CNI 插件为 pod 配置网络。
- Kata Containers 网络模型(二)详细展开了 CNI 插件的基本使用方法,以 bridge 插件为例剖析了代码实现,在最后展示了网络配置效果。
- Kata Containers 网络模型(三)将会聚焦 Kata containers 如何为安全容器连接网络,这里我们以 Go(2.0)版本的 runtime 作为例子,Rust(3.0)版本在原理上大同小异。还有部分网络更新的逻辑在 agent 组件中,不可避免的还是要看一些 Rust 源码。
Table of Contents
总览
架构
Kata containers 作为一个建立在轻量虚拟机上的容器技术,除了多了一层与虚拟机的交互外,其他的流程与 docker 之类的容器并无差异,所以这篇文章介绍的内容中有相当一部分是通用的。
在深入之前,先大体看一下“bridge + veth + tcfiltering”模式下网络架构,请记住这张图,更多的信息会被添加到这张图中。
Kata containers 是基于虚拟机的轻量化容器,所以需要通过一个 tap 设备接入网络,在 tap0_kata 左边都是与普通 docker 容器的网络一致,其右边则是虚拟机特有的。之后这张图会被进一步展开,并逐步探寻网络是如何被搭建的。
Netns 和网络设备
在这个图里,有 host 和 pod 两个 netns(网络命名空间),网络命名空间可以被简单的理解为一个独特的网络栈,每个网络栈 [1] 包括:网卡(Network Interface)、回环设备(Loopback Device)、路由表(Routing Table)和 iptables 规则。换句话说,不同的 netns 有不同的网络行为。
Host netns 顾名思义是指主机的网络空间,pod netns 是指当前启动的 pod 的网络空间。可以看到在 host netns 中有 cni0 和 vethc1d06c00 两个设备,前者是一个网桥,后者是一个 veth 设备。
Veth 设备在整个容器体系中起到了举足轻重的作用,它是成对出现的,比如在这个图上 "vethc1d06c00 (eth0)" 和 "eth0 (veth)"。Veth 设备的行为类似于 Linux 管道,数据从一端进入,就会从另一端输出,所以一般会将两个 veth 放在不同的 netns 下,实现不同的 netns 之间的通讯。
网桥设备(bridge)是一个软件模拟的交换机,工作在 L2 层。如果 veth 连接到网桥的一个端口上,那么该 veth 的数据会直接转发给网桥(原来是转发给协议栈的)。如上图所示,vethc1d06c00 连接到了 cni0 的一个端口,如果 "eth0 (veth)" 发送了一个数据包,那么数据链路是 "eth0 (veth) -> vethc1d06c00 (eth0) -> cni0"。
Tap/tun 设备是一个非常常见的设备,在构建 VPN 应用、虚拟机网络等方面发挥巨大的作用。物理网卡一端连接着物理网线,一端连接着协议栈,外部的数据包经过物理网卡后会进入协议栈处理(协议栈会决定是转发、丢弃还是交给上层应用)。而 Tap/tun 设备则是一端连接用户进程,一端连接协议栈,与物理网卡的区别在于数据的来源是网线还是用户程序。Tun 设备工作在 L3,只能接收 IP 数据包,而 tap 设备工作在 L2,只能接收链路层数据包。
有关 netns 和网络设备的内容已经超越了本文介绍的内容,不会再更详细的介绍了,如果感兴趣的话请参考杜军老师的《Kubernetes 网络权威指南:基础、原理与实践》第一章,动手操作一遍相信你很快就能明白我在说什么。
组件介绍
在开头讲到了,网络的建立离不开 4 个相关的组件,在这一章节中会简要的介绍下各个组件的指责和作用。
Containerd 是上层 runtime 的一种实现方式,类似的还有 RedHat 开源的 rkt。它实现了 CRI(Container Runtime Interface)接口 [2],负责管理镜像、网络、存储等,调用下层 runtime 创建容器。
CNI 插件是一组被上层 runtime 调用的可执行二进制文件,可用于创建网络设备、分配 IP 等,CNI 插件需要实现 cni(Container Network Interface)接口。
Containerd-shim-kata-v2(简称 kata-shim)是 Kata containers 对应的下层 runtime,kata-shim 负责启动 Kata containers。不同的容器需要调用不同的 shim 实现,如果你想启动 docker 容器,则需要调用 containerd-shim-runc-v2。(注:由于我们主要聚焦于下层 runtime,所以单独提 runtime 时一般指的是下层 runtime。)
Agent 是一个运行在虚拟机的 daemon,用于接收 runtime 的 rpc 请求,对虚拟机内部配置进行设置,比如启动容器、更新网络等。
基于网桥的网络
前面逼逼叨叨了这么多,都是为这一部分能够顺利的展开。在下面这张图里展示了一个 pod 设置网络的全过程,尽量隐藏和屏蔽一些不必要的细节。可以看到在一个 pod 被创建的时候,containerd 先创建一个 netns,然后调用 CNI 插件设置网络,实际上就是配置网络设备、路由信息等,然后会调用下层 runtime 实际创建一个 sandbox,这个 sandbox 对于 Kata containers 来说就是一个虚拟机,最后 runtime 会调用 agent 更新虚拟机的网络。
随后我们会基于这个流程慢慢扩充,同时会有一大部分的源码解析,我尽量剔除一些对理解如何设置网络没有帮助的部分。
Containerd
Containerd 作为上层 runtime,向上接收来自 kubelet 的指令,向下调用 cni 插件配置网络和下层 runtime 启动容器。在启动工作容器前,containerd 会启动一个名叫 sandbox 的东西,作用是保持住资源,比如网络资源(netns)。对于 docker 容器来说,sandbox 是指一个 pause container [1],对于 Kata containers 来说,sandbox 是启动一个虚拟机,比如 QEMU 或者 Dragonball。
与启动 sandbox 相关的代码都集中在 containerd/containerd 的 pkg/cri/server 文件夹中。Containerd 负责
- 初始化 CNI 插件。
- 创建 sandbox。
接下来会分别讨论上述的两个步骤。
两个 CNI 接口
我们知道 CNI 接口是被定义在 containernetworking/cni 中的(简称 "containernetworking/CNI"),它的定义想必大家应该见到很多次了。
但是 containerd 并没有直接使用上述标准 CNI 接口,而是在 containerd/go-cni 中自己封装了一个 CNI 接口(简称 "go-cni/CNI"),当然这个封装最后也会调用标准 CNI 接口实现网络创建。
更具体一点,单独列出这些接口的具体实现:
- libcni: 位于 containerd/go-cni 包中的 go-cni/CNI 接口的实现。
- CNIConfig: 位于 containernetworking/cni 包中的 containernetworking/CNI 接口的实现。
一般来说,containerd 调用 libcni,libcni 调用 CNIConfig,下图展示了他们之间的关系。
CNI 插件配置数据结构
这三个数据结构中最基本的是 NetConf,它保存着一个配置中最基本的信息,一些重要字段的作用已经展示在注释中了。这里向特别说明的是 PrevResult 字段,CNI 插件对一个容器设置网络的时候是串行执行的,也就是按照 "cniplugin0 -> cniplugin1 -> ... -> cnipluginN" 的方式,因此 CNI 插件会根据上一个插件的结果决定后续的行为,比如如果上一个插件设置失败,那么后续就可以直接失败了。
NetworkConfig 对应的配置文件一般是以 ".conf" 为后缀,从结构上来看就是在 NetConf 的基础上新增了一个 Bytes 字段,因为 NetConf 只包含了最基本的配置,而不同的 CNI 插件可能还有自己特殊的配置,所以该字段保存了 ".conf" 的字节数据,后续可以通过 JSON 反序列化获取具体的数据。
这里我们以网桥插件为例,给出一个具体的配置文件示例,如上所示。可以看到我们使用了 bridge 插件(刚才提到的 main 插件的一种),创建了一个名为 cni0 的网桥,使用 host-local 插件作为 IP 分配器,在 172.19.0.0/24 子网下分配 IP 地址。
从名字中也不难猜出,NetworkConfigList 就是多个 NetworkConfig 合并后成为了一个数组,存放的位置是 NetworkConfigList::Plugins。该数据结构对应的配置文件一般是以 ".conflist" 为后缀。值得一提的是,NetworkConfig 会被最终转换为长度为 1 的 NetworkConfigList。
初始化 CNI 插件
初始化 CNI 插件在 containerd 中的实现比较复杂,我将这个流程整理成了一个时序图,如下图所示。
从上面的图我们可以知道
- CNI 插件的初始化都是在 containerd 的 NewCRIService() 方法中完成的。
- 在 initPlatform() 方法中只初步初始化了 libcni 实例。
- 在 initPlatform() 方法结束后,有单独一块逻辑用于解析配置文件。
初始化 CNI 插件的逻辑存在于 containerd 仓库中的 pkg/cri/server/server_linux.go 的 initPlatform() 函数,它做的事情包括:
- 遍历 containerd 配置中的 runtime 模块,检查每个 runtime 是否需要单独配置 CNI 插件。
- 根据不同的 CNI 配置初始化不同的 libcni 实例。
其中
- "Handler name" 是指下层 runtime 的名字,比如 Kata containers 的 handler name 是 "kata"。
- "defaultNetworkPlugin" 的值是 "default",在 handler name 为空时一般使用该默认值对应的 CNI 插件。
- "c.config.NetworkPluginConfDir" 是指 CNI 插件配置文件目录, 一般的值是 "/etc/cni/net.d/"。
- "c.config.NetworkPluginBinDir" 是指 CNI 插件可执行二进制目录,一般的值是 "/opt/cni/bin"。
Containerd 会首先找到 CNI 插件的配置目录,默认情况下只有一条记录 pluginDirs["default"] = "/etc/cni/net.d/",假设在 runtime 的配置中为 Kata containers 设置了一个特殊的 CNI 插件,那么 pluginDirs["kata"] = "/path/to/other/cni/config/dir"。
以上就是 containerd 初始化 CNI 插件的全部流程了。剩下的初始化工作都是由 cni.New() 方法接手(时序图第 3 步),在深入看这部分初始化逻辑之前,先快速看看 libcni 的定义:
不难发现这个结构体囊括了几乎有关 CNI 插件的一切信息。
config 结构体
字段 | 类型 | 描述 |
---|---|---|
pluginDirs | []string{} | CNI 插件可执行二进制目录,默认值为 []string{"/opt/cni/bin"}。 |
pluginConfDir | string | CNI 插件配置文件目录,默认值为 "/etc/cni/net.d/"。 |
pluginMaxConfNum | int | 最大配置数,假设在一个配置目录下有 100 个配置,当该值被设置为 10 时,只会加载前 10 个配置。 |
libcni 结构体
字段 | 类型 | 描述 |
---|---|---|
config | config | 包含了 config 结构体。 |
cniConfig | cnilibrary.CNI | containernetworking/CNI 的一个具体实现。 |
networks | []*Network | 一个 CNI 插件配置对应一个 Network,关于这个问题我们稍后详细展开。 |
再跳回 initPlatform() 的 CNI 插件初始化逻辑,cni.New() 方法就做了两件事:
- 调用 defaultCNIConfig() 以默认配置创建了一个 libcni 实例。
- 在默认配置基础上,为 libcni 应用从 containerd 传来的多个选项。跳回到上面 initPlatform() 的代码中,我们可以看到传入了 4 个选项,如下表所示
选项 | 作用 |
---|---|
cni.WithMinNetworkCount | 设置 libcni.config.networkCount 的值。 |
cni.WithPluginConfDir | 设置 libcni.config.pluginConfDir 的值。 |
cni.WithPluginMaxConfNum | 设置 libcni.config.pluginMaxConfNum 的值。 |
cni.WithPluginDir | 设置 libcni.config.pluginDirs 的值,重新创建一个包含最新 pluginDirs 的 CNIConfig 实例。 |
至此时序图的前 6 步都已经完成了,我们得到的是一个半初始化的 libcni,但是仅仅指向了 CNI 插件配置目录,并没有真正地解析配置。这部分工作是在 NewCRIService() 方法中完成的,该方法最终会调用 loadFromConfDir() 方法完成解析配置工作。
loadFromConfDir() 方法接收两个参数:
字段 | 类型 | 描述 |
---|---|---|
c | *libcni | libcni 实例。 |
max | int | 最多的允许的配置文件数(libcni.config.pluginMaxConfNum)。 |
这个方法做的事情包括:
- 将目录下的全部 conf 文件、conflist 文件和 json 文件按照字母表顺序排序,比如一个目录下有 10-mynet.conf 和 11-mynet.conf,那么最后排序的结果是 {"10-mynet.conf", "11-mynet.conf"}。
- 检查是否文件数量超过了 max,如果超过了则仅截取前 max 个配置文件。
- 解析配置文件为 NetworkConfigList 结构体,如果后缀为 conf 文件,直接解析文件会获得一个 NetworkConfig 结构体,随后执行一次转换使其变为 NetworkConfigList 结构体。
- 将 NetworkConfigList 结构体转换为 Network 结构体,对于 libcni 实例来说一个 Network 结构体就对应一个 CNI 插件配置。
终于!CNI 插件的初始化工作已经全部完成了!胜利就在眼前。
创建 sandbox
理解 CNI 初始化的目的在于熟悉 libcni、配置和网络之间的关系。相比 CNI 插件初始化没有复杂的封装和调用链,更容易理解。话不多说先上图,这次比较复杂的逻辑都集中在 containernetworking 包下面。
Containerd 的 RunPodSandbox() 是启动 sandbox 的主要逻辑,在这个函数中有各种复杂的配置,实际上就做了两件与网络相关的事:
- 为 pod 创建了一个新的 netns。
- 调用 setupPodNetwork() 方法让 CNI 插件配置网络。
如时序图第 4 步所示,在 setupPodNetwork() 方法中调用了 go-cni/CNI 接口的 Setup() 方法(参见《两个 CNI 接口》),接下来就靠 libcni 来设置网络了。
libcni 在 Setup() 方法中首先遍历目前已有的 Networks(参见《初始化 CNI 插件》),调用 Network 结构体的 Attach() 方法,紧接着 containernetworking/CNI 接口的 AddNetworkList() 方法被调用,我们终于看到 go-cni/CNI 接口是如何调用 containernetworking/CNI 接口了!
CNIConfig 是 containernetworking/CNI 接口的一个具体实现,它负责调用真正的 CNI 插件。
时序图的第 7 步到第 10 步都是在 addNetwork() 方法内部,做了以下 3 件事:
- 根据插件名(如 "bridge")从 CNI 插件可执行文件目录中搜索该插件。
- 多个 CNI 插件是被链式调用的,当前 CNI 插件被执行时需要知道上一个 CNI 插件的执行结果(Prev Result),buildOneConfig() 方法正是生成一个包含上一个插件执行结果的新配置。
- 调用 CNI 插件:将控制信息写入环境变量,将配置以标准输入的方式传递给下一个插件,请参考《Kata Containers 网络模型(二)》。