CNI学习笔记
Kubernetes没有提供默认可用的容器网络,第三方提供的容器网络,必须满足以下条件:
- 容器之间可以相互通信,且不需要NAT
- 宿主机和容器可以相互通信,且不需要NAT
- 容器看到自己的IP,和其它节点/容器看到的它的IP,是一样的
即集群包含的每一个容器都拥有一个与集群中其它的容器、节点可直接路由的独立IP地址。但是Kubernetes并没有具体实现这样一个网络模型,而是设计了一个开放的容器网络标准CNI。
K8S容器网络,具有两种实现风格:
- Overlay Network,即通用的虚拟化网络模型,不依赖于宿主机底层网络架构,可以适应任何的应用场景,方便快速体验。但是性能较差,因为在原有网络的基础上叠加了一层Overlay网络,封包解包或者NAT对网络性能都是有一定损耗的
- Underlay Network,即基于宿主机物理网络环境的模型,容器与现有网络可以直接互通,不需要经过封包解包或是NAT,其性能最好。但是其普适性较差,且受宿主机网络架构的制约,比如MAC地址可能不够用
K8S的网络插件主要支持两类:
- CNI插件:基于v0.4.0版本,本文的主题
- Kubenet插件:没什么用,通过网桥和host-local CNI插件简单的实现cbr0
Kubelet使用单个默认网络插件,全集群使用一个默认网络。在Kubelet启动时,它会探测插件列表,并且记住它们,在Pod生命周期的适当阶段(仅对于Docker,CRI会自行管理CNI插件)调用选择的插件。两个相关的命令行选项:
--cni-bin-dir Kubelet启动时从该目录中探测插件
--network-plugin 对于CNI插件,取值
cni
除了实现NetworkPlugin接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
type NetworkPlugin interface { // Init initializes the plugin. This will be called exactly once // before any other methods are called. Init(host Host, hairpinMode kubeletconfig.HairpinMode, nonMasqueradeCIDR string, mtu int) error // Called on various events like: // NET_PLUGIN_EVENT_POD_CIDR_CHANGE Event(name string, details map[string]interface{}) // Name returns the plugin's name. This will be used when searching // for a plugin by name, e.g. Name() string // Returns a set of NET_PLUGIN_CAPABILITY_* Capabilities() utilsets.Int // SetUpPod is the method called after the infra container of // the pod has been created but before the other containers of the // pod are launched. SetUpPod(namespace string, name string, podSandboxID kubecontainer.ContainerID, annotations, options map[string]string) error // TearDownPod is the method called before a pod's infra container will be deleted TearDownPod(namespace string, name string, podSandboxID kubecontainer.ContainerID) error // GetPodNetworkStatus is the method called to obtain the ipv4 or ipv6 addresses of the container GetPodNetworkStatus(namespace string, name string, podSandboxID kubecontainer.ContainerID) (*PodNetworkStatus, error) // Status returns error if the network plugin is in error state Status() error } |
以创建/清理容器网络,网络插件还需要支持kube-proxy。对于iptables模式的kube-proxy来说,对iptables的依赖是很显然的,因此网络插件应当进行适当的配置,让容器流量对宿主机的iptabels可见。例如,当容器连接到Linux bridge时,必须设置/net/bridge/bridge-nf-call-iptables为1以确保iptables proxy正常工作。使用其它技术时,你同样需要考虑为kube-proxy设置正确的路由。
在K8S中,如果设置--network-plugin=cni则选用CNI作为网络插件。这时Kubelet会读取--cni-conf-dir=/etc/cni/net.d中的CNI配置,来创建Pod网络。配置文件中引用的任何插件都必须存在于--cni-bin-dir=/opt/cni/bin目录下。
如果--cni-bin-dir下有多个插件配置,则Kubelet使用按字典序排名最靠前的那个配置文件。
从CNI 0.2.0开始,除了通过配置文件指定的CNI插件,Kubelet还会使用一个标准的CNI插件lo,它不需要配置。
容器网络接口(Container Network Interface,CNI)是CNCF项目之一,它关注容器的网络连接,负责在容器启动时创建网络资源,在容器删除时清理为其创建的网络资源。由于网络和运行环境关系很密切,因此很必要进行插件化,这是创建CNI项目的缘由。
CNI项目包含两大部分:
- CNI规范文档:
- libcni,Go语言的CNI接口,供运行时调用,转调具体CNI插件
- skel,Go语言的CNI插件骨架
- https://github.com/containernetworking/cni
- 一系列参考实现和样例插件:
- 接口插件:ptp、bridge、macvlan……
- Chained插件:portmap、bandwidth、tuning
- https://github.com/containernetworking/plugins
具有以下特点:
- 供应商中立,不仅仅是为K8S设计。还可以被Mesos、CloudFoundry、podman、CRI-O使用
- 为网络操作定义了基本的执行流、配置格式
- 简单、向后兼容
JSON格式,对于任何操作,此配置都从标准输入喂给插件。可以包含标准的配置项、插件特有的配置项,示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
{ "name": "mynet", "type": "bridge", "bridge": "mynet0", "isDefaultGateway": true, "forceAddress": false, "ipMasq": true, "hairpinMode": true, "ipam": { "type": "host-local", "subnet": "10.10.0.0/16" } } |
- CNI插件是可执行文件,支持ADD / DEL / CHECK / VERSION几个命令
- 当期望进行网络配置操作时,由容器运行时调用并产生CNI插件进程
- JSON配置、容器相关的数据,通过stdin传递给CNI插件
- CNI插件通过stdout报告处理结果
在未来,CNI可能会支持:
- 动态更新现有网络配置
- 网络带宽和防火墙策略的动态策略支持
CNI实现 | 说明 |
calico | 参考:基于Calico的CNI |
galaxy | 参考:Galaxy学习笔记 |
flannel | 参考:Flannel学习笔记 |
规范的版本和CNI库的版本、插件样例/参考实现的版本无关。
为了支持平滑升级,CNI插件的作者应当兼容多版本的CNI规范。特别是,已经发布出去的插件,需要保持对老版本的兼容。插件应该在应答 VERSION命令时,返回类似下面的结构:
1 2 3 4 |
{ "cniVersion": "0.3.0", "supportedVersions": [ "0.1.0", "0.2.0", "0.3.0" ] } |
对于ADD命令,插件必须尊重网络配置JSON中指定的cniVersion:
- 如果网络配置没有提供cniVersion字段,假设使用v0.2.0版本,返回结果应该是v0.2.0格式的
- 如果插件不支持配置指定的版本,应该返回错误
初始版本。
ADD命令正常结果格式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
{ "cniVersion": "0.1.0", "ip4": { "ip": <ipv4-and-subnet-in-CIDR>, "gateway": <ipv4-of-the-gateway>, (optional) "routes": <list-of-ipv4-routes> (optional) }, "ip6": { "ip": <ipv6-and-subnet-in-CIDR>, "gateway": <ipv6-of-the-gateway>, (optional) "routes": <list-of-ipv6-routes> (optional) }, "dns": { "nameservers": <list-of-nameservers> (optional) "domain": <name-of-local-domain> (optional) "search": <list-of-additional-search-domains> (optional) "options": <list-of-options> (optional) } } |
出错时的结果格式:
1 2 3 4 5 6 |
{ "cniVersion": "0.1.0", "code": <numeric-error-code>, "msg": <short-error-message>, "details": <long-error-message> (optional) } |
该版本主要增加了VERSION命令。该命令的返回结果格式参考上文。
该版本中args被作为一个保留字段。这样可以提供任意的结构化信息,而不是仅仅靠CNI_ARGS传递 k1=v1;k2=v2形式的字符串。
此版本提供了更加丰富的关于容器网络配置的信息,包括网络接口的细节信息,并且支持多个IP地址。
ADD命令正常结果格式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
{ "cniVersion": "0.3.0", "interfaces": [ (this key omitted by IPAM plugins) { "name": "<name>", "mac": "<MAC address>", (required if L2 addresses are meaningful) "sandbox": "<netns path or hypervisor identifier>" (required for container/hypervisor interfaces, empty/omitted for host interfaces) } ], "ip": [ { "version": "<4-or-6>", "address": "<ip-and-prefix-in-CIDR>", "gateway": "<ip-address-of-the-gateway>", (optional) "interface": <numeric index into 'interfaces' list> }, ... ], "routes": [ (optional) { "dst": "<ip-and-prefix-in-cidr>", "gw": "<ip-of-next-hop>" (optional) }, ... ] "dns": { "nameservers": <list-of-nameservers> (optional) "domain": <name-of-local-domain> (optional) "search": <list-of-additional-search-domains> (optional) "options": <list-of-options> (optional) } } |
可以看到,和容器有关的网络接口的详细信息包含在结果中,每个接口都可以包含多个ip信息。
0.3.0版本存在一个BUG,Result结构中的ip字段应当已经被重命名为ips,以保证和IPAM的Result结构一致。v0.3.1解决此问题,并且所有first-party CNI插件(bridge,host-local等)都已经在0.3.1修改为,使用ips字段。
所有v.0.3.0的CNI插件需要注意此差异。
该版本主要增加了CHECK命令。
CNI规范来自rkt网络提案,它规定了Linux下应用容器网络的插件化解决方案。在此规范中,术语:
- 容器(container)可以认为是Linux网络命名空间的同义词,其关联的单元取决于容器运行时,对于K8S来说是Pod,对于Docker来说是一个裸容器
- 网络(network)是一组可被唯一寻址的实体,它们能够相互通信。这些实体可以是上一条提到的容器、一个主机、某些网络设备(例如路由器)。容器可以被添加到1-N个网络,或者从中删除
CNI规范规定的是运行时和插件之间的接口。
- 在调用任何插件之前,容器运行时必须先为容器创建网络命名空间
- 运行时必须确定,容器属于哪些网络,对于这些网络,分别应当调用什么插件
- 配置文件格式为JSON,包含name、type等必须字段。每次插件调用,参数可以不一样,为此目的,可选的 args字段包含变化的信息
- 为了将容器添加到每个网络,运行时必须逐个的为每个网络调用插件
- 在容器销毁时,运行时必须以相反顺序调用插件,将容器从网络断开
- 运行时可以并行的为多个容器调用插件,但是对于单个容器,必须串行调用
- 容器必须有唯一性的标识ContainerID,如果需要存储状态,插件必须以网络名、CNI_CONTAINERID、CNI_IFNAME(容器内接口名)作为联合主键
- 对于任一个上述联合主键,ADD不得被运行时调用两次。这隐含表示:一个容器不得被加入同一网络两次,除非它具有两个网络接口
插件必须实现为可执行文件,rkt/K8S等容器管理系统将通过命令行来调用它们。运行时配置(RuntimeConf)一般通过环境变量传递,网络配置(NetworkConfig)则是通过标准输入喂入。
CNI插件负责把一个网络接口(例如VETH对的一端)插入到容器的网络命名空间,并在宿主机上执行任何必要的操作(例如将VETH对的另外一端接到网桥)。CNI插件还必须给接口分配IP地址,并且通过调用适当的IPAM插件,创建和IPAM一致的路由规则。
该接口负责将容器添加到网络中。
参数:
- Container ID,容器唯一标识符文本,由运行时分配,不得为空。以数字/字母开头,可以包含 _.-字符
- Network namespace path,网络命名空间的路径,形式 /proc/PID/ns/net,或者指向该文件的绑定挂载/链接
- Network configuration,描述网络的JSON配置
- Extra arguments,基于容器级别的定制化配置参数
- Name of the interface inside the container,在容器网络命名空间中创建并分配的网络接口的名字,符合Linux接口命名空间,不得超过16字符,不含 / :或空白字符
结果:
- Interfaces list,取决于插件,返回的信息可能包括沙盒(容器或Hypervisor)接口名、宿主机接口名、接口的硬件地址、沙盒的详细信息
- IP configuration assigned to each interface,分配到沙盒/宿主机的IPv4/IPv6地址、网关、路由
- DNS information,包含DNS信息(服务器、domain、search domains、options)的字典
该接口负责将容器从网络中移除。
参数:和ADD相同。
注意点:
- 所有参数必须和ADD时一致
- DEL必须清理掉容器持有的、在目标网络中的所有资源
- 如果存在已知的,针对容器的先前ADD操作,则运行时必须在插件(or all plugins in a chain)JSON中添加prevResult字段,该字段的内容是上一个ADD操作的结果,JSON形式
- 如果CNI_NETNS或/和prevResult没有提供,则插件应当尽可能清理更多的资源(例如释放IPAM分配的地址)并返回提示操作成功的响应
- 如果运行时缓存了针对容器的上一个ADD的结果,则它应该在成功DEL后清理掉此缓存
如果某些资源丢失,此接口一般也不返回错误。例如:
- 即使容器网络命名空间已不存在,IPAM插件也应该释放IP地址并且返回成功,除非网络命名空间对于IPAM管理是关键的
- bridge插件应当将DEL委托给IPAM插件,并清理自身的资源,即使网络命名空间/容器接口已不存在
该接口报告CNI插件相关的版本信息。没有参数,结果示例:
1 2 3 4 5 6 |
{ // 当前CNI版本 "cniVersion": "0.4.0", // 支持的CNI版本 "supportedVersions": [ "0.1.0", "0.2.0", "0.3.0", "0.3.1", "0.4.0" ] } |
检查容器的网络是否如预期一样。
参数:和ADD一样, 但是Network configuration包含prevResult字段,存放上一个ADD调用的结果。
结果:返回错误或空。
注意点:
- 插件必须检查prevResult,确定接口、地址符合预期
- 插件必须允许后续的chained plugin看到修改后的网络资源,例如路由
- 如果prevResult中列出的资源(接口、路由、地址)不存在,或者状态异常,应该返回错误
- 同样的,没有在prevResult中跟踪的网络资源,例如防火墙规则、流量塑形控制规则、IP预留、外部依赖,不存在或状态异常时,也应该返回错误
- 如果发现容器不可达,应单返回错误
- 插件需要处理在ADD后紧跟着的CHECK调用,这意味着需要考虑某些异步资源的合理创建延迟
- 插件需要调用所有被委托插件(例如IPAM)的CHECK,并将错误收集并返回
- 运行时不会对尚未ADD的容器调用CHECK,也不会对在ADD后进行DEL的容器进行CHECK
- 如果在配置列表中,disableCheck设置为true,则运行时不会调用CHECK
- 如果chain中一个插件返回错误,运行时可能选择停止继续迭代后续插件的CHECK
- 在ADD调用之后,直到DEL调用之前,运行时都可以调用CHECK
- 运行时可以假设CHECK失败的容器处于不可恢复的错误配置状态中
如果ADD操作成功,插件应该以0退出,并且在标准输出上打印JSON。JSON中ips、dns部分必须和IPAM插件输出的一致,interfaces数组则需要插件填写,因为IPAM插件对接口无感知:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
{ // 必须和Network Configuration中的一致 "cniVersion": "0.4.0", // IPAM插件忽略此键值 // 插件创建的接口列表 // 如果变量CNI_IFNAME存在,插件必须使用此名字来创建sandbox/hypervisor接口,如果不能,返回错误 "interfaces": [ { "name": "<name>", // 如果L2地址有价值,则必须 "mac": "<MAC address>", // 对于容器接口,必须是网络命名空间路径 // 对于虚拟机,必须是虚拟化沙盒的ID,网络接口在其中创建 // 对于宿主机接口,为空或忽略 "sandbox": "<netns path or hypervisor identifier>" } ], // IP配置信息 "ips": [ { "version": "<4-or-6>", "address": "<ip-and-prefix-in-CIDR>", "gateway": "<ip-address-of-the-gateway>", // 可选 "interface": <numeric-index-into-interfaces-list> }, ... ], // 路由配置信息 "routes": [ // 可选 { "dst": "<ip-and-prefix-in-cidr>", "gw": "<ip-of-next-hop>" // 可选 }, ... ], // DNS信息 "dns": { // 可选 "nameservers": <list-of-nameservers> "domain": <name-of-local-domain> "search": <list-of-additional-search-domains> "options": <list-of-options> } } |
如果ADD操作失败,则应该以非0退出,并打印:
1 2 3 4 5 6 |
{ "cniVersion": "0.4.0", "code": <numeric-error-code>, "msg": <short-error-message>, "details": <long-error-message> (optional) } |
网络配置为JSON格式,可以存放在磁盘上,或者由运行时从其它源动态生成。
插件可以定义自己的字段,知名字段包括:
字段 | 说明 | ||||
cniVersion | 字符串。当前配置遵循的CNI规范版本 | ||||
name | 字符串。网络名,在主机范围内必须唯一 | ||||
type | 字符串。CNI插件的可执行文件的名字 | ||||
args |
字典,可选。由运行时提供的额外参数。例如可以传递一组标签 此字段可以传入任何值 |
||||
ipMasq | 布尔,可选。如果插件支持,则在宿主机上为此网络设置IP遮掩(动态SNAT)。如果宿主机需要充当容器IP的网关,则为true | ||||
ipam |
字典,可选。IPAM相关的配置: type:IPAM插件的可执行文件名 |
||||
dns |
字典,可选。DNS相关配置: nameservers:此网络可以感知的,优先级排序的DNS服务器列表 |
||||
runtimeConfig |
知名非标准字段。运行时动态填充的信息应该存放在此 通过列出capabilities,插件可以请求运行时填充必要的动态信息 |
||||
capabilities |
知名非标准字段。和runtimeConfig配合使用,提示运行时插件具有哪些特性,运行时因而能够提供这些特性所需的动态参数 例如,一个端口映射插件,可以这样配置:
这样,运行时传递给插件的、填充后的网络配置可能如下:
|
插件可以定义额外的字段,如果配置文件中传入它不能理解的字段,可能会报错。例外是args字段,可以配置任何值。
Arg | 说明 | ||
labels | 传递一系列键值对给插件
|
||
ips |
用于请求插件分配静态IP地址
|
Capability | 目的/runtimeConfig | ||
portMappings |
传递宿主机端口、容器网络命名空间端口的映射关系
|
||
ipRanges |
动态配置分配的IP地址的范围。对于那些负责管理IP地址池(但是不管理单个IP)的运行时,可以传递这些信息给插件
|
||
bandwidth |
用于动态配置网络接口的带宽限制。单位bits/sec
|
||
dns |
由运行时动态提供DNS信息
|
||
ips |
由运行时动态的给容器网络接口分配IP地址。如果容器运行时具有IP分配能力,可以传递
|
||
mac |
容器运行时可以将MAC传递给那些需要mac作为输入的CNI插件
|
||
aliases |
提供映射到容器IP地址的别名,便于位于同一个容器网络中的实体可以用此名字访问容器
|
bridge:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
{ "cniVersion": "0.4.0", "name": "dbnet", "type": "bridge", // 特有字段 "bridge": "cni0", "ipam": { "type": "host-local", // 特有字段 "subnet": "10.1.0.0/16", "gateway": "10.1.0.1" }, "dns": { "nameservers": [ "10.1.0.1" ] } } |
macvlan:
1 2 3 4 5 6 7 8 9 10 11 12 |
{ "cniVersion": "0.4.0", "name": "wan", "type": "macvlan", "ipam": { "type": "dhcp", "routes": [ { "dst": "10.0.0.0/8", "gw": "10.0.0.1" } ] }, "dns": { "nameservers": [ "10.0.0.1" ] } } |
使用网络配置列表,可以针对单个容器、按照特定顺序依次调用多个CNI插件,并将前一个插件的结果传递给后一个插件。
注意:网络配置列表的目的并不是为了将容器加入到多个网络,而是为了实现端口映射、流量塑形等额外能力。运行时调用时不会为每个插件指定一个网络接口名称。
可用字段:
字段 | 说明 |
cniVersion | 字符串。当前配置遵循的CNI规范版本 |
name | 字符串。网络名,在主机范围内必须唯一 |
disableCheck | 布尔。如果为true则运行时不会调用CHECK |
plugins |
标准的CNI插件的配置列表 |
运行时调用单个插件时:
- 会将name, cniVersion换成列表中的name, cniVersion
- 会将上一个插件的结果写入到prevResult字段
- 在DEL时,调用插件的顺序和ADD相反
- 在相同环境下调用所有插件
- 如果出错,则终止后续插件调用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
{ "name": "k8s-pod-network", "cniVersion": "0.3.0", "plugins": [ { "type": "calico", "log_level": "info", "etcd_endpoints": "https://10.0.5.1:2379,https://10.0.2.1:2379,https://10.0.3.1:2379", "etcd_key_file": "/etc/cni/net.d/calico-tls/etcd-key", "etcd_cert_file": "/etc/cni/net.d/calico-tls/etcd-cert", "etcd_ca_cert_file": "/etc/cni/net.d/calico-tls/etcd-ca", "mtu": 1440, "ipam": { "type": "calico-ipam" }, "policy": { "type": "k8s" }, "kubernetes": { "kubeconfig": "/etc/cni/net.d/calico-kubeconfig" } }, { "type": "portmap", "snat": true, "capabilities": {"portMappings": true} } ] } |
CNI插件可能需要为网络接口分配IP地址,并为网络接口安装相关的路由规则。为了支持不同的IP管理需求(DHCP、host-local),让IP分配和CNI插件基本功能解耦,规范定义了新的插件类型:IPAM Plugin。调用IPAM Plugin是CNI插件的职责,它应该在适当的时机发起调用,为网络接口获得IP地址。
IPAM插件需要决定:
- 网络接口IP/子网
- 网关
- 路由
并将这些信息返回给“主”CNI插件来应用。IPAM插件的信息来源可能是DHCP协议、本地文件系统中的数据、网络配置ipam段中的配置信息。
类似于CNI插件,IPAM插件也是通过运行可执行文件来调用的、参数也是通过stdin传递。 IPAM插件必须接收传递给CNI插件的全部环境变量。
如果调用成功,应当以0退出,并跟着如下格式的JSON:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
{ "cniVersion": "0.4.0", "ips": [ { "version": "<4-or-6>", "address": "<ip-and-prefix-in-CIDR>", // e.g. 192.168.1.3/24 "gateway": "<ip-address-of-the-gateway>" // optional }, ... ], "routes": [ // optional { "dst": "<ip-and-prefix-in-cidr>", "gw": "<ip-of-next-hop>" // optional }, ... ] "dns": { // optional "nameservers": <list-of-nameservers> // optional "domain": <name-of-local-domain> // optional "search": <list-of-search-domains> // optional "options": <list-of-options> // optional } } |
上述返回结果类似于CNI的返回结果,但是不包含interfaces键,IPAM不需要关心网络接口的信息,将IP配置给网络接口是“主”插件的职责。
错误码 | 说明 |
1 | 不兼容的CNI版本 |
2 | 网络配置中包含不支持的字段,错误消息中应该指明不支持的键值 |
3 | 未知或不存在的容器,隐含运行时不需要进行任何容器网络清理 |
4 | 无效的必须环境变量,例如CNI_COMMAND、CNI_CONTAINERID |
5 | I/O错误,例如无法从stdin读取网络配置 |
6 | 无法解析网络配置 |
7 | 无效网络配置 |
11 | 出现临时性的错误,提示运行时后续重试 |
对于Kubelet来说,CNI的网络配置通常是静态文件,那么针对容器/Pod的运行时参数,都需要通过环境变量传递。
即使使用了插件列表,Kubelet也仅会执行一次CNI调用,这些环境变量对应RuntimeConfig的字段。每个插件看到的都是一样的值。
例如CNI_IFNAME,它总是eth0,是Kubelet给的一个提示,容器的主网络接口的名字是什么。至于CNI插件是否使用eth0,会不会创建eth1,并不受限制。
环境变量 | 说明 |
CNI_PATH | 从什么目录寻找CNI插件,默认 /opt/cni/bin |
CNI_CONTAINERID | 容器唯一标识 |
CNI_NETNS | 所在网络命名空间 |
CNI_IFNAME |
网络接口名称,通常是eth0 |
CNI_COMMAND | 调用的CNI接口,例如ADD、DEL、CHECK |
CNI_ARGS |
CNI参数,格式为 key1=val1;key2=val2 对于Kubelet来说,它肯定会传递以下键: K8S_POD_NAMESPACE 当前Pod所属命名空间 |
NETCONFPATH | CNI网络配置所在目录,默认 /etc/cni/net.d |
要开发自己的CNI插件,应当先去阅读CNI规范、样例/参考实现。
这是一个小工具,可以执行CNI配置,在已存在的网络命名空间中添加、删除网络接口。
cnitool会搜索 $NETCONFPATH下面所有的 *.conf或 *.json文件,加载它们,然后寻找网络名称和传递给cnitool匹配的网络配置。
1 2 |
go get github.com/containernetworking/cni go install github.com/containernetworking/cni/cnitool |
cnitool是参考实现的一部分,因此你可以直接构建参考实现,以获得cnitool:
1 2 3 |
git clone https://github.com/containernetworking/plugins.git cd plugins ./build_linux.sh |
首先需要创建网络命名空间:
1 2 3 |
sudo ip netns add testing # 自动创建/var/run/netns/testing,也就是/run/netns/testing # 此文件指向的inode即代表网络命名空间 |
并创建CNI网络配置:
1 2 3 4 5 6 7 8 9 10 11 |
{ "cniVersion": "0.4.0", "name": "myptp", "type": "ptp", "ipMasq": true, "ipam": { "type": "host-local", "subnet": "172.16.29.0/24", "routes": [ { "dst": "0.0.0.0/0" } ] } } |
然后调用cnitool:
1 |
sudo CNI_PATH=./bin cnitool add myptp /var/run/netns/testing |
检查配置是否符合预期:
1 |
sudo CNI_PATH=./bin cnitool check myptp /var/run/netns/testing |
检查命名空间中的网络接口:
1 2 |
sudo ip -n testing addr sudo ip netns exec testing ping -c 172.16.29.1 |
清理:
1 2 |
sudo CNI_PATH=./bin cnitool del myptp /var/run/netns/testing sudo ip netns del testing |
CNI项目提供了一个代码库,其中包含了若干参考实现。本文后面的内容会分析其中一些CNI插件的源代码。
构建、安装了参考实现,或者你自己编写的CNI插件后,可以利用参考实现项目的scripts/目录中的priv-net-run.sh、docker-run.sh来执行插件,以进行快速的验证。
1 2 |
cd $GOPATH/src/github.com/containernetworking/plugins ./build_linux.sh |
你需要为被测试的CNI插件创建网络配置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
{ "cniVersion": "0.2.0", "name": "mynet", "type": "bridge", "bridge": "cni0", "isGateway": true, "ipMasq": true, "ipam": { "type": "host-local", "subnet": "10.22.0.0/16", "routes": [ { "dst": "0.0.0.0/0" } ] } } |
1 2 3 4 5 |
{ "cniVersion": "0.2.0", "name": "lo", "type": "loopback" } |
调用命令:
1 2 3 4 5 6 7 8 9 |
CNI_PATH=$GOPATH/src/github.com/containernetworking/plugins/bin cd $GOPATH/src/github.com/containernetworking/cni/scripts sudo CNI_PATH=$CNI_PATH ./priv-net-run.sh ifconfig # eth0 Link encap:Ethernet HWaddr f2:c2:6f:54:b8:2b # inet addr:10.22.0.2 Bcast:0.0.0.0 Mask:255.255.0.0 # lo Link encap:Local Loopback # inet addr:127.0.0.1 Mask:255.0.0.0 |
你可以在私有网络命名空间下看到和上面两个CNI配置对应的网络接口。
创建并配置网络命名空间,然后在其中运行Docker容器:
1 2 3 |
CNI_PATH=$GOPATH/src/github.com/containernetworking/plugins/bin cd $GOPATH/src/github.com/containernetworking/cni/scripts sudo CNI_PATH=$CNI_PATH ./docker-run.sh --rm busybox:latest ifconfig |
libcni.CNI,这是CNI提供的一套接口,供容器运行时调用,实现CNI相关的操控。接口规格如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
type CNI interface { // 将容器添加到一组网络,也就是调用一组插件 AddNetworkList(ctx context.Context, net *NetworkConfigList, rt *RuntimeConf) (types.Result, error) // 检查一组网络 CheckNetworkList(ctx context.Context, net *NetworkConfigList, rt *RuntimeConf) error // 将容器从一组网络中删除 DelNetworkList(ctx context.Context, net *NetworkConfigList, rt *RuntimeConf) error // 获取一组网络的ADD调用结果 GetNetworkListCachedResult(net *NetworkConfigList, rt *RuntimeConf) (types.Result, error) // 添加、检查、删除、获取单个网络 AddNetwork(ctx context.Context, net *NetworkConfig, rt *RuntimeConf) (types.Result, error) CheckNetwork(ctx context.Context, net *NetworkConfig, rt *RuntimeConf) error DelNetwork(ctx context.Context, net *NetworkConfig, rt *RuntimeConf) error GetNetworkCachedResult(net *NetworkConfig, rt *RuntimeConf) (types.Result, error) // 进行配置校验 ValidateNetworkList(ctx context.Context, net *NetworkConfigList) ([]string, error) ValidateNetwork(ctx context.Context, net *NetworkConfig) ([]string, error) } |
NetworkConfigList、NetworkConfig就对应了规范中的网络配置列表、网络配置,JSON部分直接存放在字节数组:
1 2 3 4 5 6 7 8 9 10 11 12 |
type NetworkConfig struct { Network *types.NetConf Bytes []byte } type NetworkConfigList struct { Name string CNIVersion string DisableCheck bool Plugins []*NetworkConfig Bytes []byte } |
网络配置,转换为的结构:
1 2 3 4 5 6 7 8 9 10 11 12 |
type NetConf struct { CNIVersion string `json:"cniVersion,omitempty"` Name string `json:"name,omitempty"` Type string `json:"type,omitempty"` Capabilities map[string]bool `json:"capabilities,omitempty"` IPAM IPAM `json:"ipam,omitempty"` DNS DNS `json:"dns"` RawPrevResult map[string]interface{} `json:"prevResult,omitempty"` PrevResult Result `json:"-"` } |
RuntimeConf则包含了一次CNI调用中,除了网络配置之外的参数信息。通常由容器运行时根据上下文来构建:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
type RuntimeConf struct { // 当前针对的容器ID ContainerID string // 当前操控的网络命名空间 NetNS string // 当前网络接口名,注意,尽管会通过插件列表调用多个插件,但是这个名字是唯一的,一般是eth0 IfName string // 参数 Args [][2]string // 运行时传递给插件的Capability相关的数据 CapabilityArgs map[string]interface{} // 缓存目录 CacheDir string } |
上一个CNI插件的调用结果,存放在此结构中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
type current.Result struct { CNIVersion string `json:"cniVersion,omitempty"` Interfaces []*Interface `json:"interfaces,omitempty"` IPs []*IPConfig `json:"ips,omitempty"` Routes []*types.Route `json:"routes,omitempty"` DNS types.DNS `json:"dns,omitempty"` } // 实现了下面的接口 // Result is an interface that provides the result of plugin execution type types.Result interface { // 不需要转换,该结果即可支持的最高CNI版本 Version() string // 转换此Result到指定的CNI版本 GetAsVersion(version string) (Result, error) // 以JSON格式打印此Result到标准输出 Print() error // 以JSON格式打印此Result PrintTo(writer io.Writer) error // 返回JSON格式的Result String() string } |
dockershim是kubelet中的一个模块,Kubelet通过CRI gRPC调用进程内的dockershim,后者则将CRI请求适配为对Docker守护进程的请求。可以认为dockershim是一个容器运行时。
在创建Pod时,NetworkPlugin.SetUpPod方法会被调用,来为Pod安装网络:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 |
func (plugin *cniNetworkPlugin) SetUpPod(namespace string, name string, id kubecontainer.ContainerID, annotations, options map[string]string) error { if err := plugin.checkInitialized(); err != nil { return err } // 获得网络命名空间 netnsPath, err := plugin.host.GetNetNS(id.ID) if err != nil { return fmt.Errorf("CNI failed to retrieve network namespace path: %v", err) } cniTimeoutCtx, cancelFunc := context.WithTimeout(context.Background(), network.CNITimeoutSec*time.Second) defer cancelFunc() // Windows doesn't have loNetwork. It comes only with Linux if plugin.loNetwork != nil { // 对于Linux,先加入lo网络 if _, err = plugin.addToNetwork(cniTimeoutCtx, plugin.loNetwork, name, namespace, id, netnsPath, annotations, options); err != nil { return err } } // 将Pod加入到默认网络 *cniNetwork _, err = plugin.addToNetwork(cniTimeoutCtx, plugin.getDefaultNetwork(), name, namespace, id, netnsPath, annotations, options) return err } // 表示一个网络 type cniNetwork struct { name string // 网络配置列表 NetworkConfig *libcni.NetworkConfigList // 提供CNI的接口 CNIConfig libcni.CNI Capabilities []string } func (plugin *cniNetworkPlugin) addToNetwork(ctx context.Context, network *cniNetwork, podName string, podNamespace string, podSandboxID kubecontainer.ContainerID, podNetnsPath string, annotations, options map[string]string) (cnitypes.Result, error) { // 生成RuntimeConfig rt, err := plugin.buildCNIRuntimeConf(podName, podNamespace, podSandboxID, podNetnsPath, annotations, options) if err != nil { klog.Errorf("Error adding network when building cni runtime conf: %v", err) return nil, err } pdesc := podDesc(podNamespace, podName, podSandboxID) netConf, cniNet := network.NetworkConfig, network.CNIConfig // 调用CNI接口 res, err := cniNet.AddNetworkList(ctx, netConf, rt) if err != nil { klog.Errorf("Error adding %s to network %s/%s: %v", pdesc, netConf.Plugins[0].Network.Type, netConf.Name, err) return nil, err } klog.V(4).Infof("Added %s to network %s: %v", pdesc, netConf.Name, res) return res, nil } |
此包提供了一些(通过命令行)调用CNI插件的函数。
下面这个函数,根据名字查找CNI插件,并且传递网络配置、来自环境变量的CNI参数,调用插件的ADD命令:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
func DelegateAdd(delegatePlugin string, netconf []byte) (types.Result, error) { if os.Getenv("CNI_COMMAND") != "ADD" { return nil, fmt.Errorf("CNI_COMMAND is not ADD") } paths := filepath.SplitList(os.Getenv("CNI_PATH")) pluginPath, err := FindInPath(delegatePlugin, paths) if err != nil { return nil, err } return ExecPluginWithResult(pluginPath, netconf, ArgsFromEnv()) } |
ExecPluginWithResult / ExecPluginWithoutResult函数发起CNI调用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// 关注结果 func ExecPluginWithResult(pluginPath string, netconf []byte, args CNIArgs) (types.Result, error) { return defaultPluginExec.WithResult(pluginPath, netconf, args) } // 不关心结果 func ExecPluginWithoutResult(pluginPath string, netconf []byte, args CNIArgs) error { return defaultPluginExec.WithoutResult(pluginPath, netconf, args) } // 示例 return invoke.ExecPluginWithoutResult(pluginPath, netconfBytes, &invoke.Args{ Command: "DEL", ContainerID: args.ContainerID, NetNS: args.Netns, PluginArgsStr: args.Args, IfName: ifName, Path: args.Path, }) |
参考实现的测试用例基于Ginkgo,本节阅读loopback插件的测试用例,依此了解如何调用、测试CNI插件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
package main_test import ( "github.com/onsi/gomega/gexec" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" "testing" ) var pathToLoPlugin string func TestLoopback(t *testing.T) { RegisterFailHandler(Fail) // 入口点,启动测试套件 RunSpecs(t, "plugins/main/loopback") } var _ = BeforeSuite(func() { var err error // 使用go build来执行构建,返回临时目录中的二进制文件的路径 pathToLoPlugin, err = gexec.Build("github.com/containernetworking/plugins/plugins/main/loopback") Expect(err).NotTo(HaveOccurred()) }) var _ = AfterSuite(func() { // 清理套件启动后构建的二进制文件 gexec.CleanupBuildArtifacts() }) |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 |
package main_test import ( "fmt" "net" "os/exec" "strings" "github.com/containernetworking/plugins/pkg/ns" "github.com/containernetworking/plugins/pkg/testutils" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" "github.com/onsi/gomega/gbytes" "github.com/onsi/gomega/gexec" ) // 一组用例 var _ = Describe("Loopback", func() { var ( networkNS ns.NetNS command *exec.Cmd environ []string ) // 准备工作 BeforeEach(func() { // 插件命令 command = exec.Command(pathToLoPlugin) var err error // 通过unshare系统调用创建新NS,但是不会切换到此NS networkNS, err = testutils.NewNS() Expect(err).NotTo(HaveOccurred()) // 准备调用CNI插件需要的环境变量 environ = []string{ fmt.Sprintf("CNI_CONTAINERID=%s", "dummy"), // CNI插件需要在哪个命名空间中运作 fmt.Sprintf("CNI_NETNS=%s", networkNS.Path()), fmt.Sprintf("CNI_IFNAME=%s", "this is ignored"), fmt.Sprintf("CNI_ARGS=%s", "none"), fmt.Sprintf("CNI_PATH=%s", "/some/test/path"), } // 准备插件输入参数 command.Stdin = strings.NewReader(`{ "name": "loopback-test", "cniVersion": "0.1.0" }`) }) // 清理工作,关闭命名空间描述符、卸载命名空间 AfterEach(func() { Expect(networkNS.Close()).To(Succeed()) Expect(testutils.UnmountNS(networkNS)).To(Succeed()) }) Context("when given a network namespace", func() { It("sets the lo device to UP", func() { // 调用ADD接口 command.Env = append(environ, fmt.Sprintf("CNI_COMMAND=%s", "ADD")) session, err := gexec.Start(command, GinkgoWriter, GinkgoWriter) Expect(err).NotTo(HaveOccurred()) // 对命令的标准输出进行断言 Eventually(session).Should(gbytes.Say(`{.*}`)) Eventually(session).Should(gexec.Exit(0)) var lo *net.Interface err = networkNS.Do(func(ns.NetNS) error { // 如果CNI插件运行成功,则在此NS中会出现一个lo网卡 var err error lo, err = net.InterfaceByName("lo") return err }) Expect(err).NotTo(HaveOccurred()) Expect(lo.Flags & net.FlagUp).To(Equal(net.FlagUp)) }) It("sets the lo device to DOWN", func() { // 调用DEL接口 command.Env = append(environ, fmt.Sprintf("CNI_COMMAND=%s", "DEL")) session, err := gexec.Start(command, GinkgoWriter, GinkgoWriter) Expect(err).NotTo(HaveOccurred()) Eventually(session).Should(gbytes.Say(``)) Eventually(session).Should(gexec.Exit(0)) var lo *net.Interface err = networkNS.Do(func(ns.NetNS) error { // 如果CNI插件运行成功,则在此NS中不存在lo网卡 var err error lo, err = net.InterfaceByName("lo") return err }) Expect(err).NotTo(HaveOccurred()) Expect(lo.Flags & net.FlagUp).NotTo(Equal(net.FlagUp)) }) }) }) |
这个包提供了CNI插件开发的骨架代码,它实现了参数解析和校验,你可以将其作为库使用,简化CNI开发。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 |
package skel import ( "bytes" "encoding/json" "fmt" "io" "io/ioutil" "log" "os" "strings" "github.com/containernetworking/cni/pkg/types" "github.com/containernetworking/cni/pkg/version" ) // 来自环境变量、标准输入的CNI参数 type CmdArgs struct { ContainerID string Netns string IfName string Args string Path string StdinData []byte } // 解析并得到CmdArgs、执行CNI调用 type dispatcher struct { Getenv func(string) string Stdin io.Reader Stdout io.Writer Stderr io.Writer ConfVersionDecoder version.ConfigDecoder VersionReconciler version.Reconciler } type reqForCmdEntry map[string]bool // internal only error to indicate lack of required environment variables type missingEnvError struct { msg string } func (e missingEnvError) Error() string { return e.msg } // 从环境变量解析参数 func (t *dispatcher) getCmdArgsFromEnv() (string, *CmdArgs, error) { var cmd, contID, netns, ifName, args, path string vars := []struct { name string val *string reqForCmd reqForCmdEntry }{ { "CNI_COMMAND", // 接口名 &cmd, reqForCmdEntry{ "ADD": true, "CHECK": true, "DEL": true, }, }, { "CNI_CONTAINERID", // 容器ID &contID, reqForCmdEntry{ "ADD": true, "CHECK": true, "DEL": true, }, }, { "CNI_NETNS", // 网络NS文件路径 &netns, reqForCmdEntry{ "ADD": true, "CHECK": true, "DEL": false, }, }, { "CNI_IFNAME", // 网络接口名 &ifName, reqForCmdEntry{ "ADD": true, "CHECK": true, "DEL": true, }, }, { "CNI_ARGS", // CNI参数 &args, reqForCmdEntry{ "ADD": false, "CHECK": false, "DEL": false, }, }, { "CNI_PATH", // CNI二进制文件路径 &path, reqForCmdEntry{ "ADD": true, "CHECK": true, "DEL": true, }, }, } argsMissing := make([]string, 0) for _, v := range vars { *v.val = t.Getenv(v.name) if *v.val == "" { if v.reqForCmd[cmd] || v.name == "CNI_COMMAND" { argsMissing = append(argsMissing, v.name) } } } if len(argsMissing) > 0 { joined := strings.Join(argsMissing, ",") return "", nil, missingEnvError{fmt.Sprintf("required env variables [%s] missing", joined)} } if cmd == "VERSION" { t.Stdin = bytes.NewReader(nil) } stdinData, err := ioutil.ReadAll(t.Stdin) if err != nil { return "", nil, fmt.Errorf("error reading from stdin: %v", err) } cmdArgs := &CmdArgs{ ContainerID: contID, Netns: netns, IfName: ifName, Args: args, Path: path, StdinData: stdinData, } return cmd, cmdArgs, nil } func createTypedError(f string, args ...interface{}) *types.Error { return &types.Error{ Code: 100, Msg: fmt.Sprintf(f, args...), } } // 检查CNI并执行调用 func (t *dispatcher) checkVersionAndCall(cmdArgs *CmdArgs, pluginVersionInfo version.PluginInfo, toCall func(*CmdArgs) error) error { configVersion, err := t.ConfVersionDecoder.Decode(cmdArgs.StdinData) if err != nil { return err } verErr := t.VersionReconciler.Check(configVersion, pluginVersionInfo) if verErr != nil { return &types.Error{ Code: types.ErrIncompatibleCNIVersion, Msg: "incompatible CNI versions", Details: verErr.Details(), } } return toCall(cmdArgs) } // 校验配置 func validateConfig(jsonBytes []byte) error { var conf struct { Name string `json:"name"` } if err := json.Unmarshal(jsonBytes, &conf); err != nil { return fmt.Errorf("error reading network config: %s", err) } if conf.Name == "" { return fmt.Errorf("missing network name") } return nil } // CNI调用的核心逻辑 func (t *dispatcher) pluginMain(cmdAdd, cmdCheck, cmdDel func(_ *CmdArgs) error, versionInfo version.PluginInfo, about string) *types.Error { // 解析命令和参数 cmd, cmdArgs, err := t.getCmdArgsFromEnv() if err != nil { // 如果没有指定命令,则打印About后退出 if _, ok := err.(missingEnvError); ok && t.Getenv("CNI_COMMAND") == "" && about != "" { fmt.Fprintln(t.Stderr, about) return nil } return createTypedError(err.Error()) } // 进行配置(标准输入)校验 if cmd != "VERSION" { err = validateConfig(cmdArgs.StdinData) if err != nil { return createTypedError(err.Error()) } } switch cmd { case "ADD": // ADD接口,直接转调函数 err = t.checkVersionAndCall(cmdArgs, versionInfo, cmdAdd) case "CHECK": // CHECK接口 // 检查版本 configVersion, err := t.ConfVersionDecoder.Decode(cmdArgs.StdinData) if err != nil { return createTypedError(err.Error()) } if gtet, err := version.GreaterThanOrEqualTo(configVersion, "0.4.0"); err != nil { return createTypedError(err.Error()) } else if !gtet { return &types.Error{ Code: types.ErrIncompatibleCNIVersion, Msg: "config version does not allow CHECK", } } for _, pluginVersion := range versionInfo.SupportedVersions() { gtet, err := version.GreaterThanOrEqualTo(pluginVersion, configVersion) if err != nil { return createTypedError(err.Error()) } else if gtet { // 转调函数 if err := t.checkVersionAndCall(cmdArgs, versionInfo, cmdCheck); err != nil { return createTypedError(err.Error()) } return nil } } return &types.Error{ Code: types.ErrIncompatibleCNIVersion, Msg: "plugin version does not allow CHECK", } case "DEL": // DEL接口,直接转调函数 err = t.checkVersionAndCall(cmdArgs, versionInfo, cmdDel) case "VERSION": // 打印版本信息 err = versionInfo.Encode(t.Stdout) default: return createTypedError("unknown CNI_COMMAND: %v", cmd) } if err != nil { if e, ok := err.(*types.Error); ok { // don't wrap Error in Error return e } return createTypedError(err.Error()) } return nil } // PluginMainWithError是一个CNI的核心main逻辑骨架,调用后产生的错误需要你自行处理 // 实际上就是创建dispatcher,并调用它的pluginMain方法 func PluginMainWithError(cmdAdd, cmdCheck, cmdDel func(_ *CmdArgs) error, versionInfo version.PluginInfo, about string) *types.Error { return (&dispatcher{ Getenv: os.Getenv, Stdin: os.Stdin, Stdout: os.Stdout, Stderr: os.Stderr, }).pluginMain(cmdAdd, cmdCheck, cmdDel, versionInfo, about) } // 此函数是一个CNI的核心main逻辑骨架,支持自动化的错误处理 // // 调用者(CNI插件)需要指明它支持哪些CNI规范版本 // // 如果没有指定CNI_COMMAND,则调用者提供的about打印到stderr // about的推荐格式: CNI plugin <foo> v<version> // // cmdAdd, cmdCheck或cmdDel调用出错,则PluginMain打印错误JSON到stdout,并os.Exit(1) // // 如果需要定制错误处理,调用PluginMainWithError() func PluginMain(cmdAdd, cmdCheck, cmdDel func(_ *CmdArgs) error, versionInfo version.PluginInfo, about string) { if e := PluginMainWithError(cmdAdd, cmdCheck, cmdDel, versionInfo, about); e != nil { if err := e.Print(); err != nil { log.Print("Error writing error JSON to stdout: ", err) } os.Exit(1) } } |
有了skel,我们实现自己的CNI插件时,只需要编写几个函数就可以了。
这个插件很简单,就是在命名空间中添加一个lo接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 |
package main import ( "encoding/json" "errors" "fmt" "net" "github.com/vishvananda/netlink" "github.com/containernetworking/cni/pkg/skel" "github.com/containernetworking/cni/pkg/types" "github.com/containernetworking/cni/pkg/types/current" "github.com/containernetworking/cni/pkg/version" "github.com/containernetworking/plugins/pkg/ns" bv "github.com/containernetworking/plugins/pkg/utils/buildversion" ) // 从标准输入的JSON解析CNI配置 func parseNetConf(bytes []byte) (*types.NetConf, error) { conf := &types.NetConf{} if err := json.Unmarshal(bytes, conf); err != nil { return nil, fmt.Errorf("failed to parse network config: %v", err) } // 可能存在前序被调用插件 if conf.RawPrevResult != nil { // 前序调用结果是存放在网络配置中的 if err := version.ParsePrevResult(conf); err != nil { return nil, fmt.Errorf("failed to parse prevResult: %v", err) } // 尝试解析前序结果,失败则出错 if _, err := current.NewResultFromResult(conf.PrevResult); err != nil { return nil, fmt.Errorf("failed to convert result to current version: %v", err) } } return conf, nil } func cmdAdd(args *skel.CmdArgs) error { // 解析出配置 conf, err := parseNetConf(args.StdinData) if err != nil { return err } var v4Addr, v6Addr *net.IPNet // 强制使用lo作为网络接口名称,忽略配置 args.IfName = "lo" // 在路径args.Netns代表的网络命名空间下执行 err = ns.WithNetNSPath(args.Netns, func(_ ns.NetNS) error { // 获取Link对象,lo接口不需要添加,默认就存在,只是可能没有up link, err := netlink.LinkByName(args.IfName) if err != nil { return err // not tested } // 设置网络接口为UP状态 err = netlink.LinkSetUp(link) if err != nil { return err // not tested } // 获得网络接口的IPv4地址接口 v4Addrs, err := netlink.AddrList(link, netlink.FAMILY_V4) if err != nil { return err // not tested } // 如果有IPv4的地址,则确认是loopback地址 if len(v4Addrs) != 0 { v4Addr = v4Addrs[0].IPNet // sanity check that this is a loopback address for _, addr := range v4Addrs { if !addr.IP.IsLoopback() { return fmt.Errorf("loopback interface found with non-loopback address %q", addr.IP) } } } // 获得网络接口的IPv6地址接口 // 如果有IPv6的地址,则确认是loopback地址 v6Addrs, err := netlink.AddrList(link, netlink.FAMILY_V6) if err != nil { return err // not tested } if len(v6Addrs) != 0 { v6Addr = v6Addrs[0].IPNet // sanity check that this is a loopback address for _, addr := range v6Addrs { if !addr.IP.IsLoopback() { return fmt.Errorf("loopback interface found with non-loopback address %q", addr.IP) } } } return nil }) if err != nil { return err // not tested } var result types.Result // 如果有来自上一个CNI插件的结果,则透明的传递下去 if conf.PrevResult != nil { // If loopback has previous result which passes from previous CNI plugin, // loopback should pass it transparently result = conf.PrevResult } else { // 否则,构建一个结果,作为PrevResult loopbackInterface := ¤t.Interface{ Name: args.IfName, // lo不关心L2地址 Mac: "00:00:00:00:00:00", Sandbox: args.Netns, } // 将本次添加的IP地址纳入结果 r := ¤t.Result{ CNIVersion: conf.CNIVersion, Interfaces: []*current.Interface{ loopbackInterface }, } if v4Addr != nil { r.IPs = append(r.IPs, ¤t.IPConfig{ Version: "4", Interface: current.Int(0), Address: *v4Addr, }) } if v6Addr != nil { r.IPs = append(r.IPs, ¤t.IPConfig{ Version: "6", Interface: current.Int(0), Address: *v6Addr, }) } result = r } return types.PrintResult(result, conf.CNIVersion) } // 只是把lo关闭,不能删除 func cmdDel(args *skel.CmdArgs) error { if args.Netns == "" { return nil } args.IfName = "lo" // ignore config, this only works for loopback err := ns.WithNetNSPath(args.Netns, func(ns.NetNS) error { link, err := netlink.LinkByName(args.IfName) if err != nil { return err // not tested } err = netlink.LinkSetDown(link) if err != nil { return err // not tested } return nil }) if err != nil { return err // not tested } return nil } // 检查lo接口是否启动 func cmdCheck(args *skel.CmdArgs) error { args.IfName = "lo" // ignore config, this only works for loopback return ns.WithNetNSPath(args.Netns, func(_ ns.NetNS) error { link, err := netlink.LinkByName(args.IfName) if err != nil { return err } // link属性标记,位域 if link.Attrs().Flags&net.FlagUp != net.FlagUp { return errors.New("loopback interface is down") } return nil }) } func main() { // 调用skel skel.PluginMain(cmdAdd, cmdCheck, cmdDel, version.All, bv.BuildString("loopback")) } |
使用该插件,同一主机上的所有容器被连接到一个网桥上,网桥位于宿主机的网络命名空间中。一个veth对,一端接着网桥,另一端接着容器。IP地址仅仅在veth对连接容器的那一端分配。
网桥自身也可以分配IP地址,这样它可以作为所有容器的网关。如果不分配,那么网桥工作在纯L2模式,如果容器有对外访问需求(也就是不仅仅需要和本机其它容器通信),则网桥需要桥接到宿主机的某个网络接口。
使用此插件时,需要指定网桥的名字。示例配置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
{ "cniVersion": "0.3.1", "name": "mynet", "type": "bridge", // 使用或创建的网桥的名字,默认cni0 "bridge": "mynet0", // 如果为true则为网桥分配IP地址,默认false "isGateway": true, // isGateway为true的前提下,如果设置为true,则设置网桥IP为命名空间的默认路由 "isDefaultGateway": true, // 是否需要为网桥设置新的IP地址(如果地址变化),默认false "forceAddress": false, // 在宿主机上,为来自此容器,目标是主机之外的流量设置IP遮掩(SNAT),默认值由内核选择 "ipMasq": true, // 为网桥上的接口设置hairpin模式,默认false // 默认情况下,网桥不会将从端口A发来的封包,再发回A去,这会导致容器无法访问自己 "hairpinMode": true, // 使用的IPAM插件 "ipam": { "type": "host-local", "subnet": "10.10.0.0/16" } // 将网桥设置为混杂模式,默认false // 混杂模式下,网络接口监听任何封包,不管它的目的地(MAC)是不是自己 promiscMode: false // 分配VLAN tag,默认不分配 // 如果指定,则在veth对的宿主机端设置VLAN tag,并且启用网桥的vlan_filtering特性 vlan: 0 } |
纯L2模式配置:
1 2 3 4 5 6 7 |
{ "cniVersion": "0.3.1", "name": "mynet", "type": "bridge", "bridge": "mynet0", "ipam": {} } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 |
package main import ( "encoding/json" "errors" "fmt" "io/ioutil" "net" "runtime" "syscall" "time" "github.com/j-keck/arping" "github.com/vishvananda/netlink" "github.com/containernetworking/cni/pkg/skel" "github.com/containernetworking/cni/pkg/types" "github.com/containernetworking/cni/pkg/types/current" "github.com/containernetworking/cni/pkg/version" "github.com/containernetworking/plugins/pkg/ip" "github.com/containernetworking/plugins/pkg/ipam" "github.com/containernetworking/plugins/pkg/ns" "github.com/containernetworking/plugins/pkg/utils" bv "github.com/containernetworking/plugins/pkg/utils/buildversion" "github.com/containernetworking/plugins/pkg/utils/sysctl" ) // For testcases to force an error after IPAM has been performed var debugPostIPAMError error const defaultBrName = "cni0" type NetConf struct { types.NetConf // 在标准NetConf之上新增的字段 BrName string `json:"bridge"` IsGW bool `json:"isGateway"` IsDefaultGW bool `json:"isDefaultGateway"` ForceAddress bool `json:"forceAddress"` IPMasq bool `json:"ipMasq"` MTU int `json:"mtu"` HairpinMode bool `json:"hairpinMode"` PromiscMode bool `json:"promiscMode"` Vlan int `json:"vlan"` } // 网关信息 type gwInfo struct { // IP地址和子网掩码 gws []net.IPNet // IPv4还是IPv6 family int // 在网关列表中,到此网关为止,是否发现默认路由 defaultRouteFound bool } func init() { // 确保main函数在主线程(线程组Leader)上运行 // 命名空间操作(unshare、setns)为单个线程设计,我们需要确保main函数 // 不会跳转到其它OS线程上 runtime.LockOSThread() } func loadNetConf(bytes []byte) (*NetConf, string, error) { n := &NetConf{ BrName: defaultBrName, } if err := json.Unmarshal(bytes, n); err != nil { return nil, "", fmt.Errorf("failed to load netconf: %v", err) } if n.Vlan < 0 || n.Vlan > 4094 { return nil, "", fmt.Errorf("invalid VLAN ID %d (must be between 0 and 4094)", n.Vlan) } return n, n.CNIVersion, nil } // 处理IPAM插件的结果,为每个地址族: // 1. 计算、编译网关地址列表 // 2. 如果需要,添加一个默认路由 func calcGateways(result *current.Result, n *NetConf) (*gwInfo, *gwInfo, error) { gwsV4 := &gwInfo{} gwsV6 := &gwInfo{} // 便利IPAM返回的每一个IPConfig for _, ipc := range result.IPs { // Determine if this config is IPv4 or IPv6 var gws *gwInfo // 代表一个IP网络信息(网络号+掩码) defaultNet := &net.IPNet{} switch { case ipc.Address.IP.To4() != nil: gws = gwsV4 // 地址族信息 gws.family = netlink.FAMILY_V4 defaultNet.IP = net.IPv4zero case len(ipc.Address.IP) == net.IPv6len: gws = gwsV6 gws.family = netlink.FAMILY_V6 defaultNet.IP = net.IPv6zero default: return nil, nil, fmt.Errorf("Unknown IP object: %v", ipc) } // 计算掩码 defaultNet.Mask = net.IPMask(defaultNet.IP) // 这里仅仅是为了获得*int,以便后续设置网络接口索引 ipc.Interface = current.Int(2) // 如果IPAM没有提供网关,则计算出网关地址 if ipc.Gateway == nil && n.IsGW { // 使用网络的第1个地址作为网关 ipc.Gateway = calcGatewayIP(&ipc.Address) } // 如有必要(此网桥作为默认路由),为当前地址族添加默认路由(使用当前网关地址) if n.IsDefaultGW && !gws.defaultRouteFound { for _, route := range result.Routes { if route.GW != nil && defaultNet.String() == route.Dst.String() { gws.defaultRouteFound = true break } } if !gws.defaultRouteFound { // 添加一条默认路由,可能使用计算出的网关地址 result.Routes = append( result.Routes, &types.Route{Dst: *defaultNet, GW: ipc.Gateway}, ) gws.defaultRouteFound = true } } // 如果当前网络作为网关,则加入到网关列表 if n.IsGW { gw := net.IPNet{ IP: ipc.Gateway, Mask: ipc.Address.Mask, } gws.gws = append(gws.gws, gw) } } return gwsV4, gwsV6, nil } // 确保某个(IPAM分配的)IP地址被设置到网桥上 func ensureAddr(br netlink.Link, family int, ipn *net.IPNet, forceAddress bool) error { // 获得网络接口现有地址列表 addrs, err := netlink.AddrList(br, family) if err != nil && err != syscall.ENOENT { return fmt.Errorf("could not get list of IP addresses: %v", err) } // 需要被设置的IP地址 ipnStr := ipn.String() for _, a := range addrs { // 已经被设置了,不做操作 if a.IPNet.String() == ipnStr { return nil } // 如果对应的子网不重叠,多个IPv6地址可以存在于网桥上 // 对于IPv4,或者子网重叠的Ipv6,如果forceAddress为true,重新配置IP地址 if family == netlink.FAMILY_V4 || a.IPNet.Contains(ipn.IP) || ipn.Contains(a.IPNet.IP) { if forceAddress { // 删除现有IP地址 if err = deleteAddr(br, a.IPNet); err != nil { return err } } else { return fmt.Errorf("%q already has an IP address different from %v", br.Attrs().Name, ipnStr) } } } // 为网桥添加新IP地址 addr := &netlink.Addr{IPNet: ipn, Label: ""} if err := netlink.AddrAdd(br, addr); err != nil && err != syscall.EEXIST { return fmt.Errorf("could not add IP address to %q: %v", br.Attrs().Name, err) } // 将网桥的MAC地址设置给自己 // otherwise, the bridge will take the // lowest-numbered mac on the bridge, and will change as ifs churn if err := netlink.LinkSetHardwareAddr(br, br.Attrs().HardwareAddr); err != nil { return fmt.Errorf("could not set bridge's mac: %v", err) } return nil } // 将IP地址从网络接口上删除 func deleteAddr(br netlink.Link, ipn *net.IPNet) error { addr := &netlink.Addr{IPNet: ipn, Label: ""} if err := netlink.AddrDel(br, addr); err != nil { return fmt.Errorf("could not remove IP address from %q: %v", br.Attrs().Name, err) } return nil } // 根据名称获取网桥 func bridgeByName(name string) (*netlink.Bridge, error) { l, err := netlink.LinkByName(name) if err != nil { return nil, fmt.Errorf("could not lookup %q: %v", name, err) } // 通过CAST判断网络接口是不是网桥 br, ok := l.(*netlink.Bridge) if !ok { return nil, fmt.Errorf("%q already exists but is not a bridge", name) } return br, nil } // 确保网桥存在、并且已经配置好 func ensureBridge(brName string, mtu int, promiscMode, vlanFiltering bool) (*netlink.Bridge, error) { // 网桥对象 br := &netlink.Bridge{ LinkAttrs: netlink.LinkAttrs{ Name: brName, MTU: mtu, // 让内核使用默认的发送队列长度(txqueuelen); leaving it unset // 如果不设置 ,则为0,也就是使用0长的发送队列长度,这会导致FIFO流量塑形器(traffic shapers) // 不能正常工作,因为它使用发送队列长度作为默认的封包限制 TxQLen: -1, }, } if vlanFiltering { // 启用VLAN过滤,使用一个网桥即可管理所有VLAN,而不是为每个VLAN创建独立网桥,3.8开始支持 br.VlanFiltering = &vlanFiltering } // 添加网桥 err := netlink.LinkAdd(br) if err != nil && err != syscall.EEXIST { return nil, fmt.Errorf("could not add %q: %v", brName, err) } // 设置混杂模式 if promiscMode { if err := netlink.SetPromiscOn(br); err != nil { return nil, fmt.Errorf("could not set promiscuous mode on %q: %v", brName, err) } } // 重新读取网桥配置 br, err = bridgeByName(brName) if err != nil { return nil, err } // 不接受IPv6路由通告,自己管理关于此接口的路由 _, _ = sysctl.Sysctl(fmt.Sprintf("net/ipv6/conf/%s/accept_ra", brName), "0") // 启动网桥 if err := netlink.LinkSetUp(br); err != nil { return nil, err } return br, nil } // 确保VLAN接口就绪 func ensureVlanInterface(br *netlink.Bridge, vlanId int) (netlink.Link, error) { name := fmt.Sprintf("%s.%d", br.Name, vlanId) // 检查VLAN接口是否已经存在 brGatewayVeth, err := netlink.LinkByName(name) if err != nil { if err.Error() != "Link not found" { // 根据字符串判断靠谱么? return nil, fmt.Errorf("failed to find interface %q: %v", name, err) } hostNS, err := ns.GetCurrentNS() if err != nil { return nil, fmt.Errorf("faild to find host namespace: %v", err) } // 使用veth将网桥和VLAN接口连接起来(和容器网络接口一样) // 创建veth对,一端连在网桥,另外一端连接在hostNS的VLAN网络接口 _, brGatewayIface, err := setupVeth(hostNS, br, name, br.MTU, false, vlanId) if err != nil { return nil, fmt.Errorf("faild to create vlan gateway %q: %v", name, err) } // 重新查找,确保OK brGatewayVeth, err = netlink.LinkByName(brGatewayIface.Name) if err != nil { return nil, fmt.Errorf("failed to lookup %q: %v", brGatewayIface.Name, err) } } return brGatewayVeth, nil } // 创建veth对 func setupVeth(netns ns.NetNS, br *netlink.Bridge, ifName string, mtu int, hairpinMode bool, vlanID int) (*current.Interface, *current.Interface, error) { contIface := ¤t.Interface{} hostIface := ¤t.Interface{} // 容器网络命名空间 err := netns.Do(func(hostNS ns.NetNS) error { // 创建veth对,在容器网络命名空间中调用,会自动将对端移动到hostNS hostVeth, containerVeth, err := ip.SetupVeth(ifName, mtu, hostNS) if err != nil { return err } // 从veth收集容器端网络接口的名字、MAC地址 contIface.Name = containerVeth.Name contIface.Mac = containerVeth.HardwareAddr.String() contIface.Sandbox = netns.Path() hostIface.Name = hostVeth.Name return nil }) if err != nil { return nil, nil, err } // 由于移动到了根网络命名空间,hostVeth的index已经变了,需要重新获取网络接口对象 hostVeth, err := netlink.LinkByName(hostIface.Name) if err != nil { return nil, nil, fmt.Errorf("failed to lookup %q: %v", hostIface.Name, err) } // 设置宿主端的veth的L2地址。如果这个地址不正确,则无法进行L2通信,这个veth是容器和外部的桥梁 hostIface.Mac = hostVeth.Attrs().HardwareAddr.String() // 将hostVeth对接到网桥上 if err := netlink.LinkSetMaster(hostVeth, br); err != nil { return nil, nil, fmt.Errorf("failed to connect %q to bridge %v: %v", hostVeth.Attrs().Name, br.Attrs().Name, err) } // 为宿主端的veth设置Hairpin模式 if err = netlink.LinkSetHairpin(hostVeth, hairpinMode); err != nil { return nil, nil, fmt.Errorf("failed to setup hairpin mode for %v: %v", hostVeth.Attrs().Name, err) } // 如果指定了VLAN,则在网桥上(实际上是veth的宿主机端,桥的一个端口)添加VLAN filter entry if vlanID != 0 { // bridge vlan add dev DEV vid VID [ pvid ] [ untagged ] [ self ] [ master ] err = netlink.BridgeVlanAdd(hostVeth, uint16(vlanID), true, true, false, true) if err != nil { return nil, nil, fmt.Errorf("failed to setup vlan tag on interface %q: %v", hostIface.Name, err) } } return hostIface, contIface, nil } // 网关IP,简单的+1 func calcGatewayIP(ipn *net.IPNet) net.IP { nid := ipn.IP.Mask(ipn.Mask) return ip.NextIP(nid) } // 创建网桥 func setupBridge(n *NetConf) (*netlink.Bridge, *current.Interface, error) { vlanFiltering := false if n.Vlan != 0 { vlanFiltering = true } // 如果需要,创建并配置网桥 br, err := ensureBridge(n.BrName, n.MTU, n.PromiscMode, vlanFiltering) if err != nil { return nil, nil, fmt.Errorf("failed to create bridge %q: %v", n.BrName, err) } return br, ¤t.Interface{ Name: br.Attrs().Name, Mac: br.Attrs().HardwareAddr.String(), }, nil } // 禁用IP6的地址冲突检测(Duplicate Address Detection,DAD) // 禁用的原因是Hairpin模式会导致网络接口看到自己的DAD封包 func disableIPV6DAD(ifName string) error { // ehanced_dad sends a nonce with the DAD packets, so that we can safely // ignore ourselves enh, err := ioutil.ReadFile(fmt.Sprintf("/proc/sys/net/ipv6/conf/%s/enhanced_dad", ifName)) if err == nil && string(enh) == "1\n" { return nil } f := fmt.Sprintf("/proc/sys/net/ipv6/conf/%s/accept_dad", ifName) return ioutil.WriteFile(f, []byte("0"), 0644) } // 启用IP转发 func enableIPForward(family int) error { if family == netlink.FAMILY_V4 { // 启用IPv4转发 return ip.EnableIP4Forward() } // 启用IPv6转发 return ip.EnableIP6Forward() } func cmdAdd(args *skel.CmdArgs) error { var success bool = false // 解析配置 n, cniVersion, err := loadNetConf(args.StdinData) if err != nil { return err } if n.IsDefaultGW { n.IsGW = true } // 不支持同时设置Hairpin和Promisc模式 if n.HairpinMode && n.PromiscMode { return fmt.Errorf("cannot set hairpin mode and promiscous mode at the same time.") } // 如有必要,创建网桥,并进行配置 br, brInterface, err := setupBridge(n) if err != nil { return err } // 当前处理的容器网络命名空间 netns, err := ns.GetNS(args.Netns) if err != nil { return fmt.Errorf("failed to open netns %q: %v", args.Netns, err) } defer netns.Close() // 为容器网络命名空间创建VETH对 // 创建VETH对,一端插入容器,成为containerInterface // 另外一端插到网桥上,即hostInterface hostInterface, containerInterface, err := setupVeth(netns, br, args.IfName, n.MTU, n.HairpinMode, n.Vlan) if err != nil { return err } // 返回接口列表:网桥、宿主机端veth、容器命名空间内eth result := ¤t.Result{ CNIVersion: cniVersion, Interfaces: []*current.Interface{ brInterface, hostInterface, containerInterface, }, } isLayer3 := n.IPAM.Type != "" // 如果是L3模式,向IPAM申请IP地址 if isLayer3 { // 调用IPAM插件,传递自己的配置(标准输入)给它 r, err := ipam.ExecAdd(n.IPAM.Type, args.StdinData) if err != nil { return err } // 如果调用失败,释放掉可能已经分配的IP地址 defer func() { if !success { ipam.ExecDel(n.IPAM.Type, args.StdinData) } }() // 转换Result为当前CNI版本 ipamResult, err := current.NewResultFromResult(r) if err != nil { return err } // 获取IPAM分配的地址、路由 result.IPs = ipamResult.IPs result.Routes = ipamResult.Routes if len(result.IPs) == 0 { return errors.New("IPAM plugin returned missing IP config") } // 为每个地址族收集路由信息 gwsV4, gwsV6, err := calcGateways(result, n) if err != nil { return err } // 在容器网络命名空间配置IP地址和路由 if err := netns.Do(func(_ ns.NetNS) error { // 为了应对网桥开启hairpin的情况,禁用DAD // Hairpin会导致回响(echo)neighbor solicitation packets, 进而导致DAD错误 for _, ipc := range result.IPs { if ipc.Version == "6" && (n.HairpinMode || n.PromiscMode) { // 禁用IPv6 Duplicate Address Detection if err := disableIPV6DAD(args.IfName); err != nil { return err } break } } // 使用IPAM的调用结果来分配IP地址给容器接口,并且添加路由 if err := ipam.ConfigureIface(args.IfName, result); err != nil { return err } return nil }); err != nil { return err } // 检查网桥Port状态 retries := []int{0, 50, 500, 1000, 1000} for idx, sleep := range retries { time.Sleep(time.Duration(sleep) * time.Millisecond) // 宿主机端(连接在网桥上)的VETH端 hostVeth, err := netlink.LinkByName(hostInterface.Name) if err != nil { return err } // 应当处于Up状态(可以发封包了) if hostVeth.Attrs().OperState == netlink.OperUp { break } if idx == len(retries)-1 { return fmt.Errorf("bridge port in error state: %s", hostVeth.Attrs().OperState) } } // 发送无故ARP(也叫免费ARP)请求(gratuitous arp) —— 请求容器接口自身IP地址对应 // 的MAC,价值是: // 1. 验证IP是否冲突,如果此ARP收到应答,则提示网络中有人用了和自己相同的IP // 2. 如果当前接口改变了MAC地址,可以通知网络中其它主机及时更新ARP缓存 if err := netns.Do(func(_ ns.NetNS) error { contVeth, err := net.InterfaceByName(args.IfName) if err != nil { return err } for _, ipc := range result.IPs { if ipc.Version == "4" { // 发送免费ARP _ = arping.GratuitousArpOverIface(ipc.Address.IP, *contVeth) } } return nil }); err != nil { return err } // (此网桥作为容器的一个)网关模式下,为网桥配置IP地址,并启用IP转发 if n.IsGW { var firstV4Addr net.IP var vlanInterface *current.Interface // 遍历IPAM分配的网关信息 for _, gws := range []*gwInfo{gwsV4, gwsV6} { for _, gw := range gws.gws { if gw.IP.To4() != nil && firstV4Addr == nil { firstV4Addr = gw.IP } if n.Vlan != 0 { // 如有必要,创建名为brname.vlanid的、网桥的VLAN子接口 vlanIface, err := ensureVlanInterface(br, n.Vlan) if err != nil { return fmt.Errorf("failed to create vlan interface: %v", err) } if vlanInterface == nil { vlanInterface = ¤t.Interface{Name: vlanIface.Attrs().Name, Mac: vlanIface.Attrs().HardwareAddr.String()} // VLAN子接口添加到结果中 result.Interfaces = append(result.Interfaces, vlanInterface) } // 将网关IP地址分配给网桥的VLAN子接口 err = ensureAddr(vlanIface, gws.family, &gw, n.ForceAddress) if err != nil { return fmt.Errorf("failed to set vlan interface for bridge with addr: %v", err) } } else { // 将IP分配给网桥 err = ensureAddr(br, gws.family, &gw, n.ForceAddress) if err != nil { return fmt.Errorf("failed to set bridge addr: %v", err) } } } if gws.gws != nil { // 启用宿主机的IP转发功能 if err = enableIPForward(gws.family); err != nil { return fmt.Errorf("failed to enable forwarding: %v", err) } } } } if n.IPMasq { // 设置iptables规则,以实现MASQ // 生成一个自定义链 chain := utils.FormatChainName(n.Name, args.ContainerID) comment := utils.FormatComment(n.Name, args.ContainerID) for _, ipc := range result.IPs { // 安装MASQ到chain,此china将位于NAT表作为POSTROUTING的子链 if err = ip.SetupIPMasq(&ipc.Address, chain, comment); err != nil { return err } } } } // 在第一个VETH添加之后,或者设置IP地址之后,网桥的MAC可能变化 // 因此重新获取网桥 br, err = bridgeByName(n.BrName) if err != nil { return err } brInterface.Mac = br.Attrs().HardwareAddr.String() result.DNS = n.DNS // Return an error requested by testcases, if any if debugPostIPAMError != nil { return debugPostIPAMError } success = true return types.PrintResult(result, cniVersion) } func cmdDel(args *skel.CmdArgs) error { n, _, err := loadNetConf(args.StdinData) if err != nil { return err } isLayer3 := n.IPAM.Type != "" if isLayer3 { // 调用IPAM插件,回收IP地址 if err := ipam.ExecDel(n.IPAM.Type, args.StdinData); err != nil { return err } } if args.Netns == "" { return nil } // 网络命名空间存在,尝试清理其中的网络接口,由于可能调用多次 // 因此需要实现幂等操作,如果网络接口已经被移除过,不要返回错误 var ipnets []*net.IPNet err = ns.WithNetNSPath(args.Netns, func(_ ns.NetNS) error { var err error // 删除容器网络接口,导致VETH对被删除 ipnets, err = ip.DelLinkByNameAddr(args.IfName) if err != nil && err == ip.ErrLinkNotFound { return nil } return err }) if err != nil { return err } // 清理MASQ规则 if isLayer3 && n.IPMasq { chain := utils.FormatChainName(n.Name, args.ContainerID) comment := utils.FormatComment(n.Name, args.ContainerID) for _, ipn := range ipnets { if err := ip.TeardownIPMasq(ipn, chain, comment); err != nil { return err } } } return err } // 这个插件就是基于skel做的,如果自己从头开发CNI插件,这是很好的参考 func main() { skel.PluginMain(cmdAdd, cmdCheck, cmdDel, version.All, bv.BuildString("bridge")) } type cniBridgeIf struct { Name string ifIndex int peerIndex int masterIndex int found bool } // CHECK接口 func cmdCheck(args *skel.CmdArgs) error { // 检查网络配置 n, _, err := loadNetConf(args.StdinData) if err != nil { return err } netns, err := ns.GetNS(args.Netns) if err != nil { return fmt.Errorf("failed to open netns %q: %v", args.Netns, err) } defer netns.Close() // 调用IPAM插件进行检查 err = ipam.ExecCheck(n.IPAM.Type, args.StdinData) if err != nil { return err } // 解析并转换上一次ADD调用的结果 if n.NetConf.RawPrevResult == nil { return fmt.Errorf("Required prevResult missing") } if err := version.ParsePrevResult(&n.NetConf); err != nil { return err } result, err := current.NewResultFromResult(n.PrevResult) if err != nil { return err } var errLink error var contCNI, vethCNI cniBridgeIf var brMap, contMap current.Interface // 遍历上次调用结果中的网络接口列表,找到容器网络接口、网桥接口 for _, intf := range result.Interfaces { if n.BrName == intf.Name { brMap = *intf continue } else if args.IfName == intf.Name { if args.Netns == intf.Sandbox { contMap = *intf continue } } } // 检查网桥 brCNI, err := validateCniBrInterface(brMap, n) if err != nil { return err } // 检查容器的沙盒是否就是参数中的网络命名空间 if args.Netns != contMap.Sandbox { return fmt.Errorf("Sandbox in prevResult %s doesn't match configured netns: %s", contMap.Sandbox, args.Netns) } // 检查容器网络接口 if err := netns.Do(func(_ ns.NetNS) error { contCNI, errLink = validateCniContainerInterface(contMap) if errLink != nil { return errLink } return nil }); err != nil { return err } // 检查容器veth的对端 for _, intf := range result.Interfaces { // Skip this result if name is the same as cni bridge // It's either the cni bridge we dealt with above, or something with the // same name in a different namespace. We just skip since it's not ours if brMap.Name == intf.Name { continue } // same here for container name if contMap.Name == intf.Name { continue } vethCNI, errLink = validateCniVethInterface(intf, brCNI, contCNI) if errLink != nil { return errLink } if vethCNI.found { // veth with container interface as peer and bridge as master found break } } if !brCNI.found { return fmt.Errorf("CNI created bridge %s in host namespace was not found", n.BrName) } if !contCNI.found { return fmt.Errorf("CNI created interface in container %s not found", args.IfName) } if !vethCNI.found { return fmt.Errorf("CNI veth created for bridge %s was not found", n.BrName) } // 检查IP地址、路由、DNS,是否和容器中的状态匹配 if err := netns.Do(func(_ ns.NetNS) error { // 检查容器IP是否符合期望 err = ip.ValidateExpectedInterfaceIPs(args.IfName, result.IPs) if err != nil { return err } // 检查路由是否匹配 err = ip.ValidateExpectedRoute(result.Routes) if err != nil { return err } return nil }); err != nil { return err } return nil } // 下面的若干函数都是由CHECK接口调用,实现了各方面的检查 // 检查网络接口 func validateInterface(intf current.Interface, expectInSb bool) (cniBridgeIf, netlink.Link, error) { ifFound := cniBridgeIf{found: false} if intf.Name == "" { return ifFound, nil, fmt.Errorf("Interface name missing ") } // 确保接口存在 link, err := netlink.LinkByName(intf.Name) if err != nil { return ifFound, nil, fmt.Errorf("Interface name %s not found", intf.Name) } // 确保接口在/不在网络沙盒中 if expectInSb { if intf.Sandbox == "" { return ifFound, nil, fmt.Errorf("Interface %s is expected to be in a sandbox", intf.Name) } } else { if intf.Sandbox != "" { return ifFound, nil, fmt.Errorf("Interface %s should not be in sandbox", intf.Name) } } return ifFound, link, err } // 检查网桥 func validateCniBrInterface(intf current.Interface, n *NetConf) (cniBridgeIf, error) { // 检查网桥接口本身 brFound, link, err := validateInterface(intf, false) if err != nil { return brFound, err } // 断言其是一个网桥 _, isBridge := link.(*netlink.Bridge) if !isBridge { return brFound, fmt.Errorf("Interface %s does not have link type of bridge", intf.Name) } // 如果网桥有MAC,断言它和结果中的MAC一致 if intf.Mac != "" { if intf.Mac != link.Attrs().HardwareAddr.String() { return brFound, fmt.Errorf("Bridge interface %s Mac doesn't match: %s", intf.Name, intf.Mac) } } // 混杂模式断言 linkPromisc := link.Attrs().Promisc != 0 if linkPromisc != n.PromiscMode { return brFound, fmt.Errorf("Bridge interface %s configured Promisc Mode %v doesn't match current state: %v ", intf.Name, n.PromiscMode, linkPromisc) } brFound.found = true brFound.Name = link.Attrs().Name brFound.ifIndex = link.Attrs().Index brFound.masterIndex = link.Attrs().MasterIndex return brFound, nil } // 检查容器中的VETH接口 func validateCniVethInterface(intf *current.Interface, brIf cniBridgeIf, contIf cniBridgeIf) (cniBridgeIf, error) { // 同样的,首先进行接口通用检查 vethFound, link, err := validateInterface(*intf, false) if err != nil { return vethFound, err } // 断言其是一个VETH _, isVeth := link.(*netlink.Veth) if !isVeth { // just skip it, it's not what CNI created return vethFound, nil } // 断言对端的序号符合预期 _, vethFound.peerIndex, err = ip.GetVethPeerIfindex(link.Attrs().Name) if err != nil { return vethFound, fmt.Errorf("Unable to obtain veth peer index for veth %s", link.Attrs().Name) } vethFound.ifIndex = link.Attrs().Index vethFound.masterIndex = link.Attrs().MasterIndex if vethFound.ifIndex != contIf.peerIndex { return vethFound, nil } if contIf.ifIndex != vethFound.peerIndex { return vethFound, nil } if vethFound.masterIndex != brIf.ifIndex { return vethFound, nil } // 断言MAC地址符合预期 if intf.Mac != "" { if intf.Mac != link.Attrs().HardwareAddr.String() { return vethFound, fmt.Errorf("Interface %s Mac doesn't match: %s not found", intf.Name, intf.Mac) } } vethFound.found = true vethFound.Name = link.Attrs().Name return vethFound, nil } func validateCniContainerInterface(intf current.Interface) (cniBridgeIf, error) { vethFound, link, err := validateInterface(intf, true) if err != nil { return vethFound, err } _, isVeth := link.(*netlink.Veth) if !isVeth { return vethFound, fmt.Errorf("Error: Container interface %s not of type veth", link.Attrs().Name) } _, vethFound.peerIndex, err = ip.GetVethPeerIfindex(link.Attrs().Name) if err != nil { return vethFound, fmt.Errorf("Unable to obtain veth peer index for veth %s", link.Attrs().Name) } vethFound.ifIndex = link.Attrs().Index if intf.Mac != "" { if intf.Mac != link.Attrs().HardwareAddr.String() { return vethFound, fmt.Errorf("Interface %s Mac %s doesn't match container Mac: %s", intf.Name, intf.Mac, link.Attrs().HardwareAddr) } } vethFound.found = true vethFound.Name = link.Attrs().Name return vethFound, nil } |
创建VETH对的过程如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 |
// 从容器命名空间中调用此函数 // 自动将一端移动到“宿主机”命名空间(实际上是任何NS,不一定是真正的宿主机NS) func SetupVeth(contVethName string, mtu int, hostNS ns.NetNS) (hostVeth net.Interface, containerVeth net.Interface, error) { return SetupVethWithName(contVethName, "", mtu, hostNS) } func SetupVethWithName(contVethName, hostVethName string, mtu int, hostNS ns.NetNS) (net.Interface, net.Interface, error) { // 宿主端 容器端 hostVethName, contVeth, err := makeVeth(contVethName, hostVethName, mtu) if err != nil { return net.Interface{}, net.Interface{}, err } // 启动容器端 if err = netlink.LinkSetUp(contVeth); err != nil { return net.Interface{}, net.Interface{}, fmt.Errorf("failed to set %q up: %v", contVethName, err) } hostVeth, err := netlink.LinkByName(hostVethName) if err != nil { return net.Interface{}, net.Interface{}, fmt.Errorf("failed to lookup %q: %v", hostVethName, err) } // 移动对端到宿主机命名空间 if err = netlink.LinkSetNsFd(hostVeth, int(hostNS.Fd())); err != nil { return net.Interface{}, net.Interface{}, fmt.Errorf("failed to move veth to host netns: %v", err) } // 在宿主机命名空间启动对端 err = hostNS.Do(func(_ ns.NetNS) error { hostVeth, err = netlink.LinkByName(hostVethName) if err != nil { return fmt.Errorf("failed to lookup %q in %q: %v", hostVethName, hostNS.Path(), err) } if err = netlink.LinkSetUp(hostVeth); err != nil { return fmt.Errorf("failed to set %q up: %v", hostVethName, err) } // we want to own the routes for this interface _, _ = sysctl.Sysctl(fmt.Sprintf("net/ipv6/conf/%s/accept_ra", hostVethName), "0") return nil }) if err != nil { return net.Interface{}, net.Interface{}, err } return ifaceFromNetlinkLink(hostVeth), ifaceFromNetlinkLink(contVeth), nil } func makeVeth(name, vethPeerName string, mtu int) (peerName string, veth netlink.Link, err error) { for i := 0; i < 10; i++ { if vethPeerName != "" { peerName = vethPeerName } else { // 给对端随机分配名称 peerName, err = RandomVethName() if err != nil { return } } veth, err = makeVethPair(name, peerName, mtu) switch { case err == nil: return case os.IsExist(err): if peerExists(peerName) && vethPeerName == "" { continue } err = fmt.Errorf("container veth name provided (%v) already exists", name) return default: err = fmt.Errorf("failed to make veth pair: %v", err) return } } // should really never be hit err = fmt.Errorf("failed to find a unique veth name") return } func makeVethPair(name, peer string, mtu int) (netlink.Link, error) { // 创建VETH结构 veth := &netlink.Veth{ LinkAttrs: netlink.LinkAttrs{ Name: name, Flags: net.FlagUp, MTU: mtu, }, // 对端(需要移动到宿主机的)名称,随机,例如veth99350820 PeerName: peer, } // 添加连接 if err := netlink.LinkAdd(veth); err != nil { return nil, err } // 创建连接后需要重新获取 // 因为索引、MAC等信息可能更新 veth2, err := netlink.LinkByName(name) if err != nil { netlink.LinkDel(veth) // 出错则删除设备并清理 return nil, err } return veth2, nil } |
使用配置:
1 2 3 4 5 6 7 8 |
{ "cniVersion": "0.3.1", "name": "testConfig", "type": "bridge", "bridge": "bridge0", "vlan": 100, "ipam": {} } |
获得应答:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
{ "cniVersion": "0.3.1", "interfaces": [ { "name": "bridge0", "mac": "ce:d7:70:c7:97:08" }, { "name": "veth99350820", "mac": "ce:d7:70:c7:97:08" }, { "name": "eth0", "mac": "ce:87:b0:d4:94:c3", "sandbox": "/var/run/netns/cnitest-cdf173b0-fa2a-66ab-e5d6-bba23a0708f5" } ], "dns": {} } |
产生的网络拓扑如下:
Leave a Reply