当Kubernetes遇见Macvlan—实现CNI路由插件

上次我们使得Pod和Host网络互通,现在我们实现一个CNI插件,通过链式执行自动完成上面的事情。

最佳实践
当Kubernetes遇见Macvlan——实现CNI路由插件.png

实验背景

上次做了一个《基于Macvlan的Kubernetes网络方案》,Macvlan插件执行以后,Pod和Host网络还没有互通。于是我们创建了一个Bridge模式的Macvlan子设备,手写从Bridge到Pod的路由规则,使得Pod和Host网络互通。现在我们实现一个CNI插件,通过链式执行自动完成上面的事情。

创建项目

CNI插件使用golang开发,我们随便创建一个文件夹,在其中go mod init <module>一下就可以了。下面是我的项目结构。

.
├── go.mod
├── go.sum 
├── main.go
├── Makefile
└── net.d
    └── 00-default.conflist

go.mod来看,我们的项目只依赖两个外部模块,一个是CNI插件框架,一个用来操作Linux网络设备。

...
require (
    // CNI插件框架
	github.com/containernetworking/cni v1.0.1
    // 操作Linux网络设备
	github.com/vishvananda/netlink v1.1.0
)
...

00-default.conflist是我们的CNI插件配置,最后会放到/etc/cni/net.d下面。

{
    "cniVersion": "0.4.0",
    "name": "default",
    "plugins": [
        {
            "type": "macvlan",
            "master": "eno1",
            "ipam": {
                "type": "host-local",
                "ranges": [
                    [
                        {
                            "subnet": "192.168.0.0/16",
                            "rangeStart": "192.168.1.2",
                            "rangeEnd": "192.168.1.254",
                            "gateway": "192.168.0.1"
                        }
                    ]
                ],
                "routes": [
                    {"dst": "0.0.0.0/0"}
                ]
            }
        },
        {
            "type": "route",
            "master": "eno1",
            "bridge": "eno1.host"
        }
    ]
}

代码实现

这里是我们用到的的所有go module。

import (
	"encoding/json"
	"net"

	"github.com/containernetworking/cni/pkg/skel"
	"github.com/containernetworking/cni/pkg/types"
	"github.com/containernetworking/cni/pkg/types/040"
	"github.com/containernetworking/cni/pkg/version"
	"github.com/vishvananda/netlink"
)

首先我们需要声明一个结构体,用来解析我们自定义插件的配置,以及接收macvlan插件的执行结果。

type PluginConfig struct {
    // 组合代替继承
	types.NetConf

	Master      string `json:"master"`
	Bridge      string `json:"bridge"`
	MasterIndex int    `json:"-"`
	BridgeIndex int    `json:"-"`
}

每次调用CNI插件的ADD和DEL方法时,会通过stdin传入插件配置和上一步的执行结果,我们实现一个parseConfig方法解析传入的内容。

func parseConfig(stdin []byte) (*PluginConfig, error) {
	var config PluginConfig

    // 解析当前插件的配置
    if err := json.Unmarshal(stdin, &config); err != nil {
		return nil, err
	}

    // 解析上一步执行结果
	if err := version.ParsePrevResult(&config.NetConf); err != nil {
		return nil, err
	}

	return &config, nil
}

然后实现一个initBridge方法,在Host网络空间创建Bridge模式的Macvlan子设备。

func initBridge(config *PluginConfig) error {
    // 找到macvlan主设备的索引
	masterLink, err := netlink.LinkByName(config.Master)
	if err != nil {
		return err
	}
	config.MasterIndex = masterLink.Attrs().Index

    // 检查当前是否存在bridge
	bridgeLink, err := netlink.LinkByName(config.Bridge)
    if err == nil {
		config.BridgeIndex = bridgeLink.Attrs().Index
		return nil
	}
    if _, ok := err.(netlink.LinkNotFoundError); !ok {
        return err
    }
	
    // 创建bridge模式的macvlan子设备
	if err := netlink.LinkAdd(&netlink.Macvlan{
		LinkAttrs: netlink.LinkAttrs{
			Name:        config.Bridge,
			ParentIndex: config.MasterIndex,
		},
		Mode: netlink.MACVLAN_MODE_BRIDGE,
	}); err != nil {
		return err
	}

    // 找到创建的bridge并启动它
	bridgeLink, err = netlink.LinkByName(config.Bridge)
	if err != nil {
		return err
	}
	if err := netlink.LinkSetUp(bridgeLink); err != nil {
		return err
	}
	config.BridgeIndex = bridgeLink.Attrs().Index
	
    return nil
}

实现CNI插件的ADD方法,根据Pod IP在Host上创建路由。

func cmdAdd(args *skel.CmdArgs) error {
    // 解析插件配置和上一步的结果
	config, err := parseConfig(args.StdinData)
	if err != nil {
		return err
	}

    // 获取上一步(macvlan插件)的执行结果
	prevResult, err := types040.GetResult(config.PrevResult)
	if err != nil {
		return err
	}

    // 检查或初始化macvlan bridge
    if err := initBridge(config); err != nil {
		return err
	}

    // 获得Pod IP
	podIP := prevResult.IPs[0].Address
	podIP.Mask = net.CIDRMask(32, 32)

    // 创建路由
	route := &netlink.Route{
		Dst:       &podIP,
		Scope:     netlink.SCOPE_LINK,
		LinkIndex: config.BridgeIndex,
	}
	if err := netlink.RouteAdd(route); err != nil {
		return err
	}

    // 透传macvlan插件的执行结果
    // 这里必须主动打印一下,否则执行会报错
	return types.PrintResult(prevResult, config.CNIVersion)
}

实现CNI插件的DEL方法,Pod被回收时删除路由。

func cmdDel(args *skel.CmdArgs) error {
    // 解析插件配置和上一步的结果
	config, err := parseConfig(args.StdinData)
	if err != nil {
		return err
	}

    // 获取ADD操作的的执行结果
	prevResult, err := types040.GetResult(config.PrevResult)
	if err != nil {
		return err
	}

    // 获得Pod IP
	podIP := prevResult.IPs[0].Address
	podIP.Mask = net.CIDRMask(32, 32)

    // 删除路由
	route := &netlink.Route{
		Dst:       &podIP,
		Scope:     netlink.SCOPE_LINK,
		LinkIndex: config.BridgeIndex,
	}
	return netlink.RouteDel(route)
}

CNI插件的CHECK方法不用实现,声明一下就可以。

func cmdCheck(args *skel.CmdArgs) error {
	return nil
}

下面是CNI插件的执行入口,在CNI框架的支持下,一行代码足矣。

func main() {
	skel.PluginMain(cmdAdd, cmdCheck, cmdDel, version.All, "CNI route plugin 0.0.1")
}

功能测试

没有将插件应用到Kubernetes时,我们用cnitool这个工具对插件进行测试,可以现场装一个。

go install github.com/containernetworking/cni/cnitool

构建route插件,放到/opt/cni/bin这个目录。

go build -o /opt/cni/bin/route main.go

使用实验配置测试ADD功能。

$ NETCONFPATH=$PWD/net.d CNI_PATH=/opt/cni/bin cnitool add default /var/run/netns/testing

CNI插件链被成功执行,产生了一个JSON输出。这是CNI插件链的执行结果,会被缓存到CNI工作目录。

{
    "cniVersion": "0.4.0",
    "interfaces": [
        {
            "name": "eth0",
            "mac": "ea:a8:70:cd:18:62",
            "sandbox": "/var/run/netns/testing"
        }
    ],
    "ips": [
        {
            "version": "4",
            "interface": 0,
            "address": "192.168.1.2/16",
            "gateway": "192.168.0.1"
        }
    ],
    "routes": [
        {
            "dst": "0.0.0.0/0"
        }
    ],
    "dns": {}
}

我们在/var/lib/cni/networks/default下面可以看到IPAM分配的Pod IP。

$ ls /var/lib/cni/networks/default
192.168.1.2  last_reserved_ip.0  lock

# 上一个被分配的IP
$ cat /var/lib/cni/networks/default/last_reserved_ip.0 
192.168.1.2

/var/lib/cni/results下可以看到缓存的CNI执行结果,执行DEL操作时缓存内容会被传入。

$ ls /var/lib/cni/results
default-cnitool-77383ca0a0715733ca6f-eth0

$ cat /var/lib/cni/results/default-cnitool-77383ca0a0715733ca6f-eth0 | jq
{
  "kind": "cniCacheV1",
  "containerId": "cnitool-77383ca0a0715733ca6f",
  "config": ...(base64后的原始配置)
  "ifName": "eth0",
  "networkName": "default",
  "result": {
    ...(刚才那一大坨JSON执行结果)
  }
}

现在我们测试DEL功能。

$ NETCONFPATH=$PWD/net.d CNI_PATH=/opt/cni/bin cnitool del default /var/run/netns/testing

虽然什么输出都没有,但是执行是成功的。这时你再去/var/lib/cni下面查看ADD的执行结果,很多文件已经被删除了。

集成测试

上一步我们已经将route插件放到了/opt/cni/bin目录,这里只要覆盖一下Kubernetes的CNI配置。

$ cp $PWD/net.d/00-default.conflist /etc/cni/net.d

删除从前创建的Macvlan Bridge。

ip link del eno1.host

然后我们创建一个Nginx Deployment,观察Pod IP。

$ kubectl create deployment nginx --image nginx:stable-alpine 
deployment.apps/nginx created

$ kubectl get pods -o wide
NAME                     READY   STATUS    RESTARTS   AGE   IP             NODE   
nginx-7bd849c599-vkppk   1/1     Running   0          28s   192.168.1.3   lyr620

测试Pod到Host的网络连通性。

$ ping -c 3 192.168.1.3
PING 192.168.1.3 (192.168.1.3) 56(84) bytes of data.
64 bytes from 192.168.1.3: icmp_seq=1 ttl=64 time=0.172 ms
64 bytes from 192.168.1.3: icmp_seq=2 ttl=64 time=0.057 ms
64 bytes from 192.168.1.3: icmp_seq=3 ttl=64 time=0.089 ms

--- 192.168.1.3 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2036ms
rtt min/avg/max/mdev = 0.057/0.106/0.172/0.048 ms


$ kubectl exec nginx-7bd849c599-vkppk -it -- ping -c 3 192.168.0.5
PING 192.168.0.5 (192.168.0.5): 56 data bytes
64 bytes from 192.168.0.5: seq=0 ttl=64 time=0.149 ms
64 bytes from 192.168.0.5: seq=1 ttl=64 time=0.118 ms
64 bytes from 192.168.0.5: seq=2 ttl=64 time=0.132 ms

--- 192.168.0.5 ping statistics ---
3 packets transmitted, 3 packets received, 0% packet loss
round-trip min/avg/max = 0.118/0.133/0.149 m

查看Host上创建的Macvlan子设备。

$ ip link list
...
47: eno1.host@eno1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default 
    link/ether be:c3:ad:8b:1f:53 brd ff:ff:ff:ff:ff:ff

查看Host上为Pod创建的路由。

$ ip route list
...
192.168.1.3 dev eno1.host scope link

参考资料

Get a tailored plan

Contact us

Join the community

Scan with WeChat
to join the community

Try Guance

Start online and pay only for what you use.

Get started

Choose a Guance plan

Code hosting