在前面几篇文章 《K8s 网络之从 0 实现一个 CNI 网络插件》 和 《K8s 网络之从 0 实现一个 CNI 网络插件》 以及 《基于 ebpf 和 vxlan 实现一个 k8s 网络插件》 还有 《基于 BGP 协议实现 Calico 的 IPIP 网络》 等文章中,我们把 K8s 的 CNI 网络模式详细地介绍了一遍,并且尝试手动实现了基于静态路由、ebpf、vxlan、ip in ip 的多种网络模式,今儿我们再来尝试实现另外两种比较常见的模式,分别是 IPVlan 和 MACVlan 模式。这两种也实现完之后,一般常见的模式就都差不多实现完了~
简介
本文我们会涉及到的关键术语有:IPVlan、MACVlan 等。
这两种模式算是两种比较简单的模式了,相比之前的 ebpf 和 tunnel 网络要好理解得多。他们俩相当于网卡的 “多重影分身之术”(哪撸头:“它舅!卡开步新no纠呲!”)。接下来我们简单介绍一下这两种模式。
IPVlan
所谓 IPVlan,这是一种把主机网卡虚拟化多个子网卡的技术。说简单一点,就是你的物理网卡(或虚拟网卡)会存在一个或多个 ip 地址,高版本的 Linux 允许你基于这个网卡,虚拟化出多个同 MAC 地址但是不同 ip 的子网卡。这些子网卡使用起来和使用这个主网卡效果基本一样,只是 ip 不一样而已。
一图以蔽之,大概如下:
另外 IPVlan 有两种模式,一种 L2 模式,一种 L3 模式。同一张父网卡同一时间只能使用一种模式。
L2 模式:这种模式很像是网桥,可以简单理解成把虚拟化出来的子网卡当成插在父网卡这个 “网桥” 上的网卡设备,然后对于同一个网段之间的网卡通信,父网卡的作用类似于 “网桥”。而对于要去到其他网段的流量,则会直接使用父网卡的路由信息去做转发。这种模式下创建出来的子网卡的网段应该要和父网卡属于同一网段。
L3 模式:这种模式比较像是一个路由器,它允许创建出来的子网卡不在同一个网段内。相当于两个不同网段的子网卡插在了一台 “路由器” 上。但是这种模式麻烦之处在于你需要手动去配置一些路由规则。比如当你把两个不同网段的子网卡分别放进两个 netns 中,两个 ns 中就需要分别配置对方的网段出口为当前 netns 中的 IPVlan 设备。另外如果想让外部的能够访问到 netns 中不网段的 IPVlan 设备,还需要在主机网络上配置路由表让访问 IPVlan 网段的流量走父网卡。
总结:上面简单介绍了一些 IPVlan 的 L2 和 L3 模式,其中 L2 模式相对简单,不过需要子网卡和父网卡在同一个网段中,L3 模式相对复杂,允许子网卡不在同一网段,但是需要手动去配置路由表。但是不管是 L2 还是 L3 模式,子网卡和父网卡的 MAC 地址都是一毛一样的。另外我们之后的代码实现主要是实现 L2 模式,这也是比较常用的一种模式。
MACVlan
至于 MACVlan,从表面上看其实它和 IPVlan 非常相似。IPVlan 虚拟化出来的子网卡的 MAC 地址都是一样的,而 MACVlan 也是从某张父网卡虚拟化出子网卡,也是子网卡可以有自己的 IP。但是和 IPVlan 不同的地方在于,MACVlan 虚拟出来的子网卡的 MAC 地址都是唯一的。
如图所示,它和上边 IPVlan 的区别在于每张网卡的 MAC 地址都是唯一的:
如果想给网卡创建 MACVlan 设备的话,首先需要给该网卡开启 “混杂模式”。所谓 “混杂模式” 是啥呢?按照我们课本里学的知识,“当一个网卡收到了数据包并查看数据包中目标 MAC 地址时,如果发现这个目标 MAC 地址不是本机的,则会直接丢掉”,这个一般是咱们上计算机课的时候可能会这么讲。其实现在的网卡可以开启一种叫 “混杂模式”,这种模式允许即使目标 MAC 不是本机也可以接受它。因为我们这的 MACVlan 设备的父设备,可能会需要将数据包转发给它的子 MACVlan 设备,但是他们的 MAC 地址是不一样的,所以这里就需要父网卡开启这种 “混杂模式” 以便接收目标 MAC 非本网卡的数据包。
另外 MACVlan 设备也支持很多种模式:
Private 模式:这种模式相当于每张子 MACVlan 设备都是互相隔离的,即使逻辑上他们好像都插在同一个父网卡上,但是当父网卡在转发他们的数据包时,也会将其丢掉。这种模式不知道有啥 b 用。
Bridge 模式:这种模式和上边的 IPVlan 的 L2 模式非常像,此时父网卡有点类似一个 “网桥”,可以用来转发多个同网段子网卡之间的数据包,并且相比网桥设备的好处在于每张子 MACVlan 设备的 MAC 地址对于父网卡都是已知的,不用学习即可转发。
VEPA 模式:这种模式也需要一个 “网桥” 或者 “交换机” 设备,但是和 Bridge 模式不一样在于,插在同一个父网卡上的多张子网卡之间,无法直接通过父网卡对数据包做转发,而是需要让父网卡再链接到一个网桥设备或者交换机设备,然后此设备还需要开启 “发卡模式”(所谓发卡就是 hairpin。一般来说交换设备在广播报文时会把报文从发送端口之外的其他端口转发出去,如果某个交换设备的某个端口开启了 hairpin 模式的话,那么该交换设备则允许从哪个口收的从哪个口转发回去),通过交换设备的发卡模式,把父网卡发上来的数据包再转发回给父网卡,然后父网卡再转发给目标的子 MACVlan 网卡。
Passthru 模式:这种模式是一台网卡只能绑定一个子 MACVlan 设备,并且该 MACVlan 设备的 MAC 地址和父网卡的 MAC 地址一样。
总结:MACVlan 和 IPVlan 类似,也是一种把主机网卡虚拟化出一张或多张子网卡的技术。其和 IPVlan 的差别就在于每张子网卡的 MAC 地址是不同的。另外 MACVlan 支持好多种模式,其中比较常用的就是这种 Bridge 模式,我们之后的代码实现也主要使用这种模式来实现网络间的通信。
实现 CNI
上边简单介绍了一些 IPVlan 和 MACVlan 这两种虚拟化技术,不知道你有没有感觉到,其实当你使用 IPVlan 的 L2 模式,或者 MACVlan 的 Bridge 模式时,这些子网卡都和父网卡拥有同样的网段,当你把这些子网卡塞到 pod 的 netns 中时,其实给人的感觉就像是强行把容器中的网卡给 “拍平” 到了和主机网卡同一级别上。所以我们可以想到,这两种模式下的网络,因为相当于他们在 “同一层” 的网络中,所以他们对 IP 地址和 MAC 地址的消耗是非常大的,但也正是因为他们在同一层中,所以他们之间数据包的转发效率相对来说是比较高的。接下来我们就来说一下实现这两种模式的思路。
思路
其实本文最开始我就说这两种模式算是实现起来比较简单的两种模式,因为只要理解了上边简介中比较重要的 IPVlan L2 模式和 MACVlan Bridge 模式之后,你就能发现他们相当于网卡在 “同一层” 中,所以没有那么多弯弯绕。因此我们只需要如何在 Linux 上去创建 IPVlan 设备和 MACVlan 设备之后,然后再把这些创建的步骤做成代码自动化,基本上就能实现出这个基于 IPVlan & MACVlan 的 CNI 插件了。
接下来我们简单来看下在 Linux 下如何创建这些设备。
首先来看 IPVlan:
# 创建俩 netns
ip netns add ns1
ip netns add ns2
# 基于 host 上的对外网卡(可能是虚拟网卡也可能是物理网卡) 创建 IPVlan 设备
# 并且是 L2 模式
ip link add ipvlan1 link enp0s1 type ipvlan mode l2
ip link add ipvlan2 link enp0s1 type ipvlan mode l2
# 把俩 ipvlan 设备分别塞到俩 netns 中
ip link set ipvlan1 netns ns1
ip link set ipvlan2 netns ns2
# 启动他俩并分别给个 ip, 该 ip 需要和父网卡同网段
ip netns exec ns1 ifconfig ipvlan1 192.168.64.66/24 up
ip netns exec ns2 ifconfig ipvlan2 192.168.64.77/24 up
当你在 Linux 主机上执行完上边的步骤之后,就能发现,此时同一台主机上的这俩 netns 中的已经可以互相 ping 通了。接下来你再另外一台 Linux 主机,执行上边同样的操作,注意修改 ipvlan 设备的 ip 地址后,在这一台主机的某个 netns 中去 ping 另外一台主机中的 netns 的 ip 地址,发现其实已经可以通了。
非常简单对吧,我们只需要在不同的节点上简单执行上面几条命令就可以让跨主机的 netns 互通了。
另外说一句,如果你想让 netns 中也能访问到外网比如什么百度啥的,可以尝试查看一下 host 上父网卡对应的默认网关是啥,然后把这个网关地址作为路由规则配置在每个 netns 中的路右边上,然后同样也要把 netns 中的 ip 地址作为路由规则配置在 host 的路由表中,这样外网和 netns 中的网络就打通了。
接下来我们来看 MACVlan:
# 开启父网卡的混杂模式以便能接收非本网卡 MAC 的数据包
ip link set enp0s1 promisc on
# 创建俩 netns
ip netns add ns1
ip netns add ns2
# 基于父网卡创建俩 MACVlan 设备, 并使用 bridge 模式
ip link add link enp0s1 name macvlan1 type macvlan mode bridge
ip link add link enp0s1 name macvlan2 type macvlan mode bridge
# 把俩网卡分别加入到俩 netns 中
ip link set macvlan1 netns ns1
ip link set macvlan2 netns ns2
# 给他俩 IP 并 up 起来
ip netns exec ns1 ifconfig macvlan1 192.168.64.111/24 up
ip netns exec ns2 ifconfig macvlan2 192.168.64.222/24 up
和 IPVlan 同样的,当你在两台节点上都执行了同样的操作(注意修改 ip 地址)之后,两台节点就能互相通信了。
另外当你创建完 IPVlan 设备之后,会发现每个 IPVlan 设备的 MAC 地址和你的父网卡 MAC 地址是一样的:
但是 MACVlan 设备则不然,每台设备的 MAC 地址都是独一无二的:
实验环境
我这里主要在 mac 上使用 multipass 这个虚拟机,有三台节点:
- cni-test-master:192.168.64.19
- cni-test-1:192.168.64.17
- cni-test-2:192.168.64.18
- go version:1.19
-
Linux Kernal: Ubuntu 5.15
这个内核版本可以随意一点,只要是是 4.2 以上就行,4.2 以上才支持 IPVlan 和 MACVlan
实现
有了上边手动执行命令的基础,同节点间和跨节点间的 ns 其实已经可以互通了,接下来我们只需要把这些转化为代码自动化实现就好。至于如何实现一个 CNI 插件,在之前的几篇文章中已经说的很清楚了,这里不再赘述,感兴趣的兄弟可以去之前的文章看一看我实现 CNI 插件的思路。
这里我们简单看下代码实现的大概逻辑:
func SetXVlanDevice(
mode xvlan_mode, // 这个 mode 表示是要创建 IPVlan 还是 MACVlan
args *skel.CmdArgs,
pluginConfig *cni.PluginConf,
) (string, string, error) {
// 初始化 ipam, 用来做 ip 地址的分配
ipamClient, err := initEveryClient(args, pluginConfig)
if err != nil {
return "", "", err
}
// 获取本机网卡信息, 得拿到一张可以使用的父网卡
currentNetwork, err := ipamClient.Get().HostNetwork()
if err != nil {
return "", "", err
}
// 创建一个 ipvlan 设备或 macvlan 设备
ifname := ""
if mode == MODE_IPVLAN {
ifname = "ipvlan"
} else {
ifname = "macvlan"
}
var device netlink.Link
if mode == MODE_IPVLAN {
// golang 语言中可以通过 netlink 包来创建不同的网络设备
device, err = nettools.CreateIPVlan(ifname, currentNetwork.Name)
if err != nil {
return "", "", err
}
} else {
// golang 语言中可以通过 netlink 包来创建不同的网络设备
device, err = nettools.CreateMacVlan(ifname, currentNetwork.Name)
if err != nil {
return "", "", err
}
}
// 获取到 netns, 这个 netns 就是 kubelet 传给 CNI 插件的 netns
netns, err := ns.GetNS(args.Netns)
if err != nil {
return "", "", err
}
// 把这个 ipvlan/macvlan 设备塞到 netns 中
err = nettools.SetDeviceToNS(device, netns)
if err != nil {
return "", "", err
}
// 从 ipam 获取一个未使用的 ip 地址
ip, err := ipamClient.Get().UnusedIP()
if err != nil {
return "", "", err
}
// 获取到子网, 这个地址后边要返回给 kubelet
subnet, err := ipamClient.Get().Subnet()
if err != nil {
return "", "", err
}
// 在 kubelet 传来的 pod 的 netns 中做一些操作
err = netns.Do(func(hostNs ns.NetNS) error {
_device, err := netlink.LinkByName(device.Attrs().Name)
if err != nil {
return err
}
// 获取网段, 因为给网络设备创建 ip 时需要网段号
mask, err := ipamClient.Get().MaskSegment()
if err != nil {
return err
}
// 创建一个 ip 地址, 格式是 a.b.c.d/<网段>
_ip := fmt.Sprintf("%s/%s", ip, mask)
// 设置 ip 给这个 ipvlan 设备, 也是通过 golang 的 netlink 包
err = nettools.SetIpForIPVlan(_device.Attrs().Name, _ip)
if err != nil {
return err
}
// 启动这个 ipvlan 设备
return nettools.SetUpIPVlan(_device.Attrs().Name)
})
return ip, subnet, err
}
然后当上边的操作都做完之后,会返回给外边一个 podIP 和网关地址,把这俩地址吐给 kubelet 就 ok 了:
func (ipvlan *IPVlanCNI) Bootstrap(
args *skel.CmdArgs,
pluginConfig *cni.PluginConf,
) (*types.Result, error) {
// 执行完上边那一堆操作之后,可以获取到一个 pod 地址和网关地址
podIP, gw, err := base.SetXVlanDevice(base.MODE_IPVLAN, args, pluginConfig)
if err != nil {
return nil, err
}
// 获取网关地址和 podIP 准备返回给外边的 kubelet
_gw := net.ParseIP(gw)
_, _podIP, _ := net.ParseCIDR(fmt.Sprintf("%s/32", podIP))
result := &types.Result{
CNIVersion: pluginConfig.CNIVersion,
IPs: []*types.IPConfig{
{
Address: *_podIP,
Gateway: _gw,
},
},
}
return result, nil
}
上边这些步骤比较清晰而且比较简单。当这些步骤执行完后,我们还需要按照惯例在每个节点上的 “/etc/cni/net.d” 目录下创建 CNI 需要的配置文件:
{
"cniVersion": "0.3.0",
"name": "testcni",
"type": "testcni",
"mode": "ipvlan",
"subnet": "192.168.64.0/24",
"ipam": {
"rangeStart": "192.168.64.50",
"rangeEnd": "192.168.64.60"
}
}
{
"cniVersion": "0.3.0",
"name": "testcni",
"type": "testcni",
"mode": "ipvlan",
"subnet": "192.168.64.0/24",
"ipam": {
"rangeStart": "192.168.64.70",
"rangeEnd": "192.168.64.80"
}
}
{
"cniVersion": "0.3.0",
"name": "testcni",
"type": "testcni",
"mode": "ipvlan",
"subnet": "192.168.64.0/24",
"ipam": {
"rangeStart": "192.168.64.90",
"rangeEnd": "192.168.64.100"
}
}
注意每台节点上的配置文件其他都一样,只有 IP 地址的范围是需要认为控制的。
走一发
当创建完每台节点上的配置文件之后,我们编译这个 golang 程序,然后将产生的 main 文件给 “mv /opt/cni/bin/testcni” 下。
然后我这里有一个用来测试的 busybox.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: busybox
spec:
selector:
matchLabels:
app: busybox
replicas: 5
template:
metadata:
labels:
app: busybox
spec:
containers:
- name: busybox
image: busybox
command:
- sleep
- "36000"
imagePullPolicy: IfNotPresent
之后我们在集群环境中执行 “k apply -f busybox.yaml”:
可以看到,一共创建了 5 个 pod,其中 node1 上俩,node2 上俩,node3 上有一个。随便进到其中的某一个之后,分别去 ping 其他人,全部 ok~
到这里,打完收工~
这次我们尝试基于 IPVlan 和 MACVlan 实现了一个支持同节点以及跨节点通信的 CNI 网络插件。到目前为止我们已经实现过 host-gw、ip in ip、vxlan、ebpf、ipvlan、macvlan 等几种模式的网络,基本上常见的几种 K8s 环境的网络模式都手撸完了。日后如果我再碰到什么其他的网络模式,我会再尝试手动实现的~
最后,如果大哥们觉得本文稍微有些帮助的话,还请给兄弟点个星星,谢谢: