源码分析:K8s CNI macvlan 网络插件,是否做了 ARP 宣告?

先说结论

做了 ARP 宣告。因此,用了 macvlan L2 层网络插件的 K8s 网络,不需要担心 IPAM 分配策略不佳的问题:同一个 IP,短时间内,回收又分配出去。

为什么要分析?

首先,macvlan 是 K8s CNI 的一个网络插件,它主要是用来配置 underlay 大 L2 层网络。其实,真正的 L2 层网络,从 OSI 网络架构来看,是数据链路层,通信靠 MAC 地址。

我们知道,凡是依赖 L2 层网络的主机,在物理层用 MAC ,靠的是交换机那一侧转发表。然而,从容器网络来看,通信靠的 IP 地址。因此,容器环境中,存在自己的 ARP 表。ARP 存储的是 IP 和 MAC 的映射关系。

那么问题来了:

  • 如果 2 个 服务 A 和 B 通信,我们假设 A->B 的本质,是 a1 容器访问 b1 容器。
  • a1 上,关于 b1 的 ARP 关系为:[b1-ip : b1-mac]。
  • 现在,B 服务重新发布了,b1 容器变为了 b2 容器。假如:b1 销毁释放了 b1 的 IP,b2 创建,重新拿到了之前的 b1 的 IP。
  • 现象就是:A->B ,成了 a1->b2,且,a1 调用 b2 的 IP 还没变。
  • 问题:a1 原来存的 ARP 表为:[b1-ip : b1-mac] ,a1 调用 b2 还用原来 IP,只要 a1 ARP 表没更新,a1->b2 网络一定不通!!!

当然,这个问题,并不限于 macvlan 插件,只有在容器与宿主机在同一个 L2 层网络,都会面临这个问题。

那么,这个问题,应该怎么解?有 2 种方式:

  1. IPAM 分配策略做优化,IP 被回收后,放回 IP 池,下次不会立即分配,而是有序分配。目的是:保证 IP 分配策略周期 【大于】在 ARP 缓存时间周期。
  2. 容器网络在配置的时候,重新做 ARP 宣告。这样一来,上面的问题,即便 b2 拿到了原来 b1 的 IP,但重新宣告后,a1 容器的 ARP 表关系就从 [b1-ip : b1-mac] 变为了 [b1-ip : b2-mac]。这样一来,网络就通了。

其实这个问题,和 LVS VIP 切换之后,重新做 ARP 宣告,道理是一样的。

特别注意:

如果只从 IPAM 上,做 IP 池有序分配,其实也无法完全避免问题。因为,ARP 表中记录到期是存在一个策略的,这个策略可能导致 ARP 记录不更新:
Linux 为了性能考虑,缓存策略比较复杂,其中一个就是,如果收到来其他主机发阿里的来自 MAC 地址的网络通信包,那么 ARP 就会认为这个 MAC 地址还“健在”,进而,继续延后对这个 ARP 记录的更新,因此会导致其 ARP 这个记录一直无法更新,一直持有错误的 IP->MAC 的映射关系。

下面,我们会做以下几个事情:

  1. 模拟 Kubernetes CNI 环境(省的装 K8s了)
  2. 模拟容器网络,并用 cnitool 模拟 CNI 流程,为容器网络做配置
  3. macvlan 插件源码分析
  4. tcpdump 抓包的确有 ARP 宣告。

模拟 Kubernetes 的 CNI 环境

CNI 插件的测试过程,不需要一定安装一个 K8s 出来,走 K8s CNI 流程来测试。CNI 官方 repo 中,提供了 cnitool 工具来测试 CNI 的插件

1、创建 Linux 虚拟机(cnitool、macvlan、bridge 等网络插件,需要 Linux 环境)

2、安装 Golang 环境(方便安装 cnitool)

1
2
3
4
// 安装 Golang yum repo
rpm --import https://mirror.go-repo.io/centos/RPM-GPG-KEY-GO-REPO
curl -s https://mirror.go-repo.io/centos/go-repo.repo | tee /etc/yum.repos.d/go-repo.repo
yum install golang

3、配置 Golang 环境

1
2
3
4
// 配置 GOPATH
mkdir /root/golang
cd /root/golang
mkdir src pkg bin
1
2
3
4
5
6
7
vim ~/.bash_profile
// 在文件底部,添加如下内容:
export GOPATH=/root/golang
export CNI_PATH=/root/golang/bin/:/root/golang/src/github.com/containernetworking/plugins/bin
export PATH=$PATH:$CNI_PATH
// 使其生效
source ~/.bash_profile

4、下载 CNI 项目

1
2
3
4
5
6
mkdir -p /root/golang/src/github.com/containernetworking
cd /root/golang/src/github.com/containernetworking
// cni 项目(包含了 cnitool )
git clone https://github.com/containernetworking/cni
// cni plugins 项目
git clone https://github.com/containernetworking/plugins

5、安装 cnitool

1
go install github.com/containernetworking/cni/cnitool

基于 Golang 特性,安装之后,实际上是按照到了 /root/golang/bin 目录,而这个目录,我们在之前,已经添加到了 $PATH 中。

6、配置 CNI 配置文件(基于 macvlan)

1
2
3
4
// 创建 cni 目录
mkdir -p /etc/cni/net.d/
// 创建
touch /etc/cni/net.d/10-cnitest.conf

添加如下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"name": "cni0",
"cniVersion":"0.3.1",
"name": "cnitest",
"type": "macvlan",
"master": "ens33",
"ipam": {
"type": "host-local",
"subnet": "192.168.20.0/24",
"rangeStart": "192.168.20.200",
"rangeEnd": "192.168.20.250",
"gateway": "192.168.20.2",
"routes": [
{ "dst": "0.0.0.0/0" }
]
}
}

特别说明几个点:

  1. CNI 配置文件的文件名是 10-cnitest.conf ,CNI 配置内容中的 name 也是 cnitest ,这俩要对应。
  2. macvlan 是虚拟化网络。它的特性就是,需要一个父接口,我们的虚拟上网卡是 ens33(有些人可能是 eth0),所以,CNI 配置文件中的 master 指定的就是父接口,配置文件中的 type 得写为 macvlan。
  3. ipam 部分就是网络分配部分了。包括了:ip range 范围、网关、路由表。这个得和你的虚拟机适配。我的虚拟机网关是 192.168.20.2 所以我的 IPAM 也得这样配置。

模拟容器网络,并用 cnitool 模拟 CNI 流程,为容器网络做配置

1、创建空白虚拟网络

首先得创建一个空白的容器网络空间

1
ip netns add testing

2、执行 cnitool 配置这个空白网络空间

1
cnitool add cnitest /var/run/netns/testing

①:cnitool add cnitest 这个 cnitest 指的是刚才配置的 CNI 配置文件:/etc/cni/net.d/10-cnitest.conf 中定义的 cni 名称 cnitest,上面提过这一点。

②:/var/run/netns/testing 这个是我们之前创建的空白网络空间。

3、执行完上面的命令后,如果输出类似下面的内容,就是成功了

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
[root@localhost bin]# cnitool add cnitest /var/run/netns/testing
{
"cniVersion": "0.3.1",
"interfaces": [
{
"name": "eth0",
"mac": "c2:d6:84:f4:96:b5",
"sandbox": "/var/run/netns/testing"
}
],
"ips": [
{
"version": "4",
"interface": 0,
"address": "192.168.20.201/24",
"gateway": "192.168.20.2"
}
],
"routes": [
{
"dst": "0.0.0.0/0"
}
],
"dns": {}
}

4、验证之前创建的空白容器网络 testing 是否已填充好网络信息

1
2
3
4
5
6
// 进入容器网络
ip netns exec testing /bin/bash
// 查看网络信息
ifconfig
// 可能 lo 回环网卡没起,可以手动起一下
ip link set lo up

5、验证 IPAM 也分配了 IP

IPAM 我们用的 host-local,host-local 插件,默认会把分配的 IP 信息,放到下面目录

1
/var/lib/cni/networks/cnitest

注意:因为我们的 cni 名称是 cnitest,所以会放到 /var/lib/cni/networks/ 的子目录 cnitest 下。如果 cni 名称是 cni0,则会放到 /var/lib/cni/networks/cni0 下。

1
2
3
4
5
6
7
[root@localhost cnitest]# pwd
/var/lib/cni/networks/cnitest
[root@localhost cnitest]# ll
总用量 8
-rw-r--r--. 1 root root 34 79 15:30 192.168.20.201
-rw-r--r--. 1 root root 14 79 15:30 last_reserved_ip.0
-rwxr-x---. 1 root root 0 79 14:13 lock

6、清理 cnitool 配置的容器网络(可选)

1
cnitool del cnitest /var/run/netns/testing

分析 macvlan 的 ARP 宣告

1、CNI 流程简述

其实 CNI 只有 2 个调用流程

  1. 创建 Pod 的时候调用 ——> 一般对应 CNI 插件的 cmdAdd 方法
  2. 销毁 Pod 的时候调用 ——> 一般对应 CNI 插件的 cmdDel 方法

CNI 创建的流程中,其实也会经过 3 个流程:

  1. 通过具体的插件(比如 macvlan 网络插件),为空白的容器网络空间,配置网络。
  2. 配置的过程中,从 IPAM 插件,获取一个【网络信息】,这个网络信息包括:IP、路由表等信息。
  3. 从 IPAM 拿到网络信息后,继续为容器网络做相关配置。

注意:CNI 的流程是,先为 pause 容器创建网络空间并做一些简单配置,然后才调用 IPAM 获取更完整的网络配置信息,进一步做网络配置。取 IPAM 信息,并非 CNI 的第一个步骤。

2、 CNI 插件 macvlan 源码分析

直接上源码:

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
```c
// CNI macvlan 配置容器网络
func cmdAdd(args *skel.CmdArgs) error {

// 加载一下 CNI 的配置,这个配置包括:CNI 版本、CNI 名称、网络插件类型、IPAM 类型、DNS、以及当前网络插件需要的定制信息如:macvlan 主接口等。
// 这些信息,从 2 方面来,一部分是 macvlan 插件的调用方传来(这个调用方,一般就是 kubelet,或者 cnitool),一部分是一些参数,比如:要配置的网卡名称、CNI 二进制文件路径(cni-bin-path)等
n, cniVersion, err := loadConf(args.StdinData, args.Args)
if err != nil {
return err
}

// 这里判断当前是 3 层网络,还是 2 层网络。
// n.IPAM.Type 的值是 "host-local",因此,isLayer3 一定是 true
isLayer3 := n.IPAM.Type != ""

// 获取网络空间,并用此网络空间,创建 macvlan 子接口,实际上,也就是在容器网络空间,创建出一个 eth0 网卡。
netns, err := ns.GetNS(args.Netns)
if err != nil {
return fmt.Errorf("failed to open netns %q: %v", netns, err)
}
defer netns.Close()
macvlanInterface, err := createMacvlan(n, args.IfName, netns)
if err != nil {
return err
}

// 重要:如果 cni 插件执行出错,需要删除不完整的 macvlan 子接口网卡(也就是刚刚创建出来的 eth0)
defer func() {
if err != nil {
netns.Do(func(_ ns.NetNS) error {
return ip.DelLinkByName(args.IfName)
})
}
}()

// Assume L2 interface only
result := &current.Result{CNIVersion: cniVersion, Interfaces: []*current.Interface{macvlanInterface}}

// 如果是 L3 层网络(这里有点绕,其实指的就是我们说的 L2 层网络模式)
if isLayer3 {

// 调用 IPAM 插件,拿网络信息,包括:IP、路由表等等
r, err := ipam.ExecAdd(n.IPAM.Type, args.StdinData)
if err != nil {
return err
}

// 重要:如果 macvlan 执行发生错误,需要删除之前拿到的网络信息
// 因为 ipam 和 macvlan 是 2 个不同的二进制插件,如果 macvlan 执行出错后 ipam 不做删除,会导致
// ipam 已分配的 IP 无法回收。
defer func() {
if err != nil {
ipam.ExecDel(n.IPAM.Type, args.StdinData)
}
}()

// Convert whatever the IPAM result was into the current Result type
ipamResult, err := current.NewResultFromResult(r)
if err != nil {
return err
}

// 获取从 IPAM 分配到的网络信息
if len(ipamResult.IPs) == 0 {
return errors.New("IPAM plugin returned missing IP config")
}
result.IPs = ipamResult.IPs
result.Routes = ipamResult.Routes
for _, ipc := range result.IPs {
// All addresses apply to the container macvlan interface
ipc.Interface = current.Int(0)
}

// 重要:配置 eth0 网卡信息
err = netns.Do(func(_ ns.NetNS) error {
if err := ipam.ConfigureIface(args.IfName, result); err != nil {
return err
}

contVeth, err := net.InterfaceByName(args.IfName)
if err != nil {
return fmt.Errorf("failed to look up %q: %v", args.IfName, err)
}
// 重要: 发送 Arping !!!!
// 这一步非常重要,可以让其他网络,更新 IP 和 MAC 的映射关系。
for _, ipc := range result.IPs {
if ipc.Version == "4" {
_ = arping.GratuitousArpOverIface(ipc.Address.IP, *contVeth)
}
}
return nil
})
if err != nil {
return err
}
} else {

// 对于纯粹的2层网络来说,不对容器网卡 eth0 配置 IP,只是单纯的启用网卡就可以了(有 MAC)。
err = netns.Do(func(_ ns.NetNS) error {
macvlanInterfaceLink, err := netlink.LinkByName(args.IfName)
if err != nil {
return fmt.Errorf("failed to find interface name %q: %v", macvlanInterface.Name, err)
}

if err := netlink.LinkSetUp(macvlanInterfaceLink); err != nil {
return fmt.Errorf("failed to set %q UP: %v", args.IfName, err)
}

return nil
})
if err != nil {
return err
}
}

result.DNS = n.DNS

return types.PrintResult(result, cniVersion)
}

抓包验证使用了 macvlan CNI 插件,新创建容器网络是否有 ARP 报文

1、在虚拟机外(mac 主机)抓包

1
sudo tcpdump -i any -nn arp

2、在虚拟机内,使用 cnitool 为 testing 虚拟网络空间配置网络

1
2
3
4
5
// 为了防止之前的配置有影响,先移除 cnitool 配置的虚拟网络
cnitool del cnitest /var/run/netns/testing

// 使用 cnitool 为 testing 虚拟网络做配置
cnitool add cnitest /var/run/netns/testing

执行后结果:

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
[root@localhost ~]# cnitool add cnitest /var/run/netns/testing
{
"cniVersion": "0.3.1",
"interfaces": [
{
"name": "eth0",
"mac": "0a:df:56:f8:7b:1e",
"sandbox": "/var/run/netns/testing"
}
],
"ips": [
{
"version": "4",
"interface": 0,
"address": "192.168.20.212/24",
"gateway": "192.168.20.2"
}
],
"routes": [
{
"dst": "0.0.0.0/0"
}
],
"dns": {}
}

3、主机抓包结果:

结果符合 macvlan 源码行为,的确做了 ARP 宣告。

4、查看 mac 主机的 ARP 表

1
2
➜  ~ arp -a |grep 192.168.20.212
? (192.168.20.212) at a:df:56:f8:7b:1e on vmnet8 ifscope [ethernet]

ARP 表存在此记录,且 mac 地址符合 cni 结果。

Donate comment here