天天看點

如何實作一個 Kubernetes 網絡插件

目前容器的網絡解決方案越來越多,每出現一種新的解決方案,都要為網絡方案和不同的容器運作時進行适配,這顯然是不合理的,而 CNI 就是為了解決這個問題。

春節假期在家維護「家庭級 Kubernetes 叢集」時,萌生了寫一個網絡插件的想法,于是基于 cni/plugin 倉庫已有的輪子,寫了 Village Net(

https://github.com/zwwhdls/village-net )。以這個網絡插件為例,本文着重介紹如何實作一個 CNI 插件。

CNI 工作原理

要了解如何實作一個 CNI 插件,需要先了解 CNI 的工作原理。CNI 是 Container Network Interface 的縮寫,是一個接口協定,用于配置容器的網絡。容器管理系統提供容器所在的 network namespace 之後,CNI 負責将 network interface 插入到該 network namespace 中,并配置相應的 ip 和路由。

CNI 其實是容器運作時系統和 CNI Plugin 的一個連接配接橋梁,CNI 将容器的運作時的資訊以及網絡配置資訊傳遞 Plugin,由各個 Plugin 實作後續工作,是以 CNI Plugin 才是容器網絡的具體實作。可以總結為下面這張圖:

如何實作一個 Kubernetes 網絡插件

CNI Plugin 是什麼

現在我們知道 CNI Plugin 是容器網絡的具體實作。在叢集裡,每個 Plugin 以二進制的形式存在,由 kubelet 通過 CNI 接口來調用每個插件執行。具體的流程如下:

如何實作一個 Kubernetes 網絡插件

CNI Plugin 可以分為三類:Main、IPAM 和 Meta。其中 Main 和 IPAM 插件相輔相成,完成了為容器建立網絡環境的基本工作。

IPAM 插件

IPAM (IP Address Management) 插件主要用來負責配置設定IP位址。官方提供的可使用插件包括下面幾種:

  • dhcp:主控端上運作的守護程序,代表容器發出 DHCP 請求
  • host-local:使用提前配置設定好的 IP 位址段來配置設定,并在記憶體中記錄 ip 的使用情況
  • static:用于為容器配置設定靜态的 IP 位址,主要是調試使用

Main 插件

Main 插件主要用來建立具體的網絡裝置的二進制檔案。官方提供的可使用插件包括下面幾種:

  • bridge: 在主控端上建立網橋然後通過 veth pair 的方式連接配接到容器
  • macvlan:虛拟出多個 macvtap,每個 macvtap 都有不同的 mac 位址
  • ipvlan:和 macvla n相似,也是通過一個主機接口虛拟出多個虛拟網絡接口,不同的是 ipvlan 虛拟出來的是共享 MAC 位址,ip 位址不同
  • loopback:lo 裝置(将回環接口設定成up)
  • ptp:veth pair 裝置
  • vlan:配置設定 vlan 裝置
  • host-device:移動宿主上已經存在的裝置到容器中

Meta 插件

由CNI社群維護的内部插件,目前主要包括:

  • flannel: 專門為 Flannel 項目提供的插件
  • tuning:通過 sysctl 調整網絡裝置參數的二進制檔案
  • portmap:通過 iptables 配置端口映射的二進制檔案
  • bandwidth:使用 Token Bucket Filter (TBF) 來進行限流的二進制檔案
  • firewall:通過 iptables 或者 firewalled 添加規則控制容器的進出流量

CNI Plugin 的實作

CNI Plugin 的倉庫在:

https://github.com/containernetworking/plugins

。在裡面可以看到每種類型 Plugin 的具體實作。每個 Plugin 都需要實作以下三個方法,再在 main 中注冊一下。

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

func cmdAdd(args *skel.CmdArgs) error {
    ...
}

func cmdDel(args *skel.CmdArgs) error {
    ...
}           

以 host-local 為例,注冊的方法如下,需要指明上面實作的三個方法、支援的版本、以及 Plugin 的名稱。

func main() {
    skel.PluginMain(cmdAdd, cmdCheck, cmdDel, version.All, bv.BuildString("host-local"))
}           

CNI 是什麼

了解了 Plugin 的工作原理之後,再來看下 CNI 的具體工作原理。CNI 的倉庫在:

https://github.com/containernetworking/cni

。本文分析的代碼以目前最新版本 v0.8.1 為基準。

社群提供了一個工具 cnitool,是模拟 CNI 接口被調用的工具,可以在一個已存在的 network namespace 中增加或删除網絡裝置。

先來看下 cnitool 的執行邏輯:

func main() {
    ...
    netconf, err := libcni.LoadConfList(netdir, os.Args[2])
    ...
    netns := os.Args[3]
    netns, err = filepath.Abs(netns)
    ...
    // Generate the containerid by hashing the netns path
    s := sha512.Sum512([]byte(netns))
    containerID := fmt.Sprintf("cnitool-%x", s[:10])
    cninet := libcni.NewCNIConfig(filepath.SplitList(os.Getenv(EnvCNIPath)), nil)

    rt := &libcni.RuntimeConf{
        ContainerID:    containerID,
        NetNS:          netns,
        IfName:         ifName,
        Args:           cniArgs,
        CapabilityArgs: capabilityArgs,
    }

    switch os.Args[1] {
    case CmdAdd:
        result, err := cninet.AddNetworkList(context.TODO(), netconf, rt)
        if result != nil {
            _ = result.Print()
        }
        exit(err)
    case CmdCheck:
        err := cninet.CheckNetworkList(context.TODO(), netconf, rt)
        exit(err)
    case CmdDel:
        exit(cninet.DelNetworkList(context.TODO(), netconf, rt))
    }
}           

從上面的代碼中可以看出,先是從 cni 配置檔案中解析出配置 netconf,然後擷取 netns、containerId 等資訊作為容器的運作時資訊傳給接口 cninet.AddNetworkList。

接下來看下接口 AddNetworkList 的實作:

// AddNetworkList executes a sequence of plugins with the ADD command
func (c *CNIConfig) AddNetworkList(ctx context.Context, list *NetworkConfigList, rt *RuntimeConf) (types.Result, error) {
    var err error
    var result types.Result
    for _, net := range list.Plugins {
        result, err = c.addNetwork(ctx, list.Name, list.CNIVersion, net, result, rt)
        if err != nil {
            return nil, err
        }
    }
    ...
    return result, nil
}           

顯然,該函數的作用就是按順序執行各個 Plugin 的 addNetwork 操作。再看下 addNetwork 函數:

func (c *CNIConfig) addNetwork(ctx context.Context, name, cniVersion string, net *NetworkConfig, prevResult types.Result, rt *RuntimeConf) (types.Result, error) {
    c.ensureExec()
    pluginPath, err := c.exec.FindInPath(net.Network.Type, c.Path)
    ...

    newConf, err := buildOneConfig(name, cniVersion, net, prevResult, rt)
    ...
    return invoke.ExecPluginWithResult(ctx, pluginPath, newConf.Bytes, c.args("ADD", rt), c.exec)
}           

對每個插件的 addNetwork 操作分為三個部分:

  • 首先,調用 FindInPath 函數,根據插件的類型來查找插件的絕對路徑;
  • 接着,調用 buildOneConfig 函數,從 NetworkList 中提取中目前插件的 NetworkConfig 結構,而其中的 preResult 是上一個插件的執行結果。
  • 最後,調用 invoke.ExecPluginWithResult 函數,真正執行插件的 Add 操作。其中 newConf.Bytes 存放 NetworkConfig 結構以及上一個插件的執行結果編碼形成的位元組流;而 c.args 函數用于建構一個 Args 類型的執行個體,主要存儲容器運作時資訊以及執行 CNI 的操作資訊。

事實上,invoke.ExecPluginWithResult 僅僅是一個包裝函數,裡面調用了一下 exec.ExecPlugin 就傳回了,這裡我們看一下 exec.ExecPlugin 的實作:

func (e *RawExec) ExecPlugin(ctx context.Context, pluginPath string, stdinData []byte, environ []string) ([]byte, error) {
    stdout := &bytes.Buffer{}
    stderr := &bytes.Buffer{}
    c := exec.CommandContext(ctx, pluginPath)
    c.Env = environ
    c.Stdin = bytes.NewBuffer(stdinData)
    c.Stdout = stdout
    c.Stderr = stderr

    // Retry the command on "text file busy" errors
    for i := 0; i <= 5; i++ {
        err := c.Run()
        ...
        // All other errors except than the busy text file
        return nil, e.pluginErr(err, stdout.Bytes(), stderr.Bytes())
    }
    ...
}           

看到這裡,我們也就看到了整個 CNI 的核心邏輯,出乎意料的簡單,僅僅是 exec 了插件的可執行檔案,發生錯誤的時候重試 5 次。

至此,整個 CNI 的執行流程已經非常清晰了,簡而言之,一個 CNI 插件就是一個可執行檔案,從配置檔案中擷取網絡的配置資訊,從容器運作時擷取容器的資訊,前者以标準輸入的形式,後者以環境變量的形式傳給各個插件,最終以配置檔案中定義的順序依次調用各個插件,并且将前一個插件的執行結果包含在配置資訊中傳給下一個插件。

盡管如此,我們目前熟悉的成熟的網絡插件的方案(如 calico),通常都不是依次調用 Plugin,而是隻調用 main 插件,在 main 插件中調用 ipam 插件,并當場擷取執行結果。

kubelet 如何使用 CNI

了解了 CNI 插件的具體工作原理之後,再來看看 kubelet 如何使用 CNI 插件。

kubelet 在建立 pod 的時候,會調用 CNI 插件為 pod 建立網絡環境。源碼如下,可以看到 kubelet 在 SetUpPod 函數(pkg/kubelet/dockershim/network/cni/cni.go)中調用了 plugin.addToNetwork 函數:

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 plugin.loNetwork != nil {
        if _, err = plugin.addToNetwork(cniTimeoutCtx, plugin.loNetwork, name, namespace, id, netnsPath, annotations, options); err != nil {
            return err
        }
    }

    _, err = plugin.addToNetwork(cniTimeoutCtx, plugin.getDefaultNetwork(), name, namespace, id, netnsPath, annotations, options)
    return err
}           

再來看看 addToNetwork 函數,該函數首先會去建構 pod 的運作時資訊,再讀取 CNI 插件的網絡配置資訊,即 /etc/cni/net.d 目錄下的配置檔案。組裝好 plugin 需要的參數後調用 cni 的接口 cniNet.AddNetworkList。源碼如下:

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) {
    rt, err := plugin.buildCNIRuntimeConf(podName, podNamespace, podSandboxID, podNetnsPath, annotations, options)
    ...

    pdesc := podDesc(podNamespace, podName, podSandboxID)
    netConf, cniNet := network.NetworkConfig, network.CNIConfig
    ...
    res, err := cniNet.AddNetworkList(ctx, netConf, rt)
    ...
    return res, nil
}           

模拟 CNI 的執行過程

在了解了整個 CNI 的執行流程後,我們模拟一下 CNI 的執行過程。我們用 cnitool 工具,main 插件選擇 bridge,ipam 插件選擇 host-local,來模拟容器網絡配置。

編譯 plugins

首先将 CNI Plugin 編譯成可執行檔案,可以執行運作官方倉庫中的 build_linux.sh 腳本:

$ mkdir -p $GOPATH/src/github.com/containernetworking/plugins
$ git clone https://github.com/containernetworking/plugins.git  $GOPATH/src/github.com/containernetworking/plugins
$ cd $GOPATH/src/github.com/containernetworking/plugins
$ ./build_linux.sh
$ ls
bandwidth  dhcp      flannel      host-local  loopback  portmap  sbr     tuning   village-ipam  vrf
bridge     firewall  host-device  ipvlan      macvlan   ptp      static  village  vlan           

建立網絡配置檔案

接着建立我們自己的網絡配置檔案,main 插件選擇 bridge,ipam 插件選擇 host-local,并指定可用 ip 段。

$ mkdir -p /etc/cni/net.d
$ cat >/etc/cni/net.d/10-hdlsnet.conf <<EOF
{
    "cniVersion": "0.2.0",
    "name": "hdls-net",
    "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" }
        ]
    }
}
EOF
$ cat >/etc/cni/net.d/99-loopback.conf <<EOF
{
    "cniVersion": "0.2.0",
    "name": "lo",
    "type": "loopback"
}
EOF           

建立 network namespace

$ ip netns add hdls           

執行 cnitool 的 add

最後将 CNI_PATH 指定為上面編譯好的插件可執行檔案的路徑,再運作官方倉庫的 cnitool 工具:

$ mkdir -p $GOPATH/src/github.com/containernetworking/cni
$ git clone https://github.com/containernetworking/cni.git  $GOPATH/src/github.com/containernetworking/cni
$ export CNI_PATH=$GOPATH/src/github.com/containernetworking/plugins/bin
$ go run cnitool.go  add hdls-net /var/run/netns/hdls
\{
    "cniVersion": "0.2.0",
    "ip4": {
        "ip": "10.22.0.2/16",
        "gateway": "10.22.0.1",
        "routes": [
            {
                "dst": "0.0.0.0/0"
            }
        ]
    },
    "dns": {}
}#           

結果表面為這個 network namespace hdls-net 配置設定的 ip 為 10.22.0.2,其實也就是說我們手動建立的容器 ip 為 10.22.0.2。

驗證

獲得了容器的 ip 後,檢驗是可以 ping 通的,用 nsenter 指令進入到容器的 namespace 中也可以發現該容器的預設網絡裝置 eth0 也建立出來了:

$ ping 10.22.0.2
PING 10.22.0.2 (10.22.0.2) 56(84) bytes of data.
64 bytes from 10.22.0.2: icmp_seq=1 ttl=64 time=0.039 ms
64 bytes from 10.22.0.2: icmp_seq=2 ttl=64 time=0.046 ms
64 bytes from 10.22.0.2: icmp_seq=3 ttl=64 time=0.042 ms
64 bytes from 10.22.0.2: icmp_seq=4 ttl=64 time=0.073 ms
^C
--- 10.22.0.2 ping statistics ---
4 packets transmitted, 4 received, 0% packet loss, time 3000ms
rtt min/avg/max/mdev = 0.039/0.050/0.073/0.013 ms
$ nsenter --net=/var/run/netns/hdls bash
[root@node-3 ~]# ip l
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
3: eth0@if5: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default
    link/ether be:6b:0c:93:3a:75 brd ff:ff:ff:ff:ff:ff link-netnsid 0

[root@node-3 ~]#           

最後我們再來檢查一下主控端的網絡裝置,發現和容器的 eth0 相對應的 veth 裝置對也建立出來了:

$ ip l
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: ens33: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000
    link/ether 00:0c:29:9a:04:8d brd ff:ff:ff:ff:ff:ff
3: docker0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN mode DEFAULT group default
    link/ether 02:42:22:86:98:d9 brd ff:ff:ff:ff:ff:ff
4: cni0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default qlen 1000
    link/ether 76:32:56:61:e4:f5 brd ff:ff:ff:ff:ff:ff
5: veth3e674876@if3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master cni0 state UP mode DEFAULT group default
    link/ether 62:b3:06:15:f9:39 brd ff:ff:ff:ff:ff:ff link-netnsid 0
               

Village Net

之是以選擇 Village Net 作為插件的名字,是希望通過 macvlan 實作一個基于二層的網絡插件。而對于一個二層網絡來說,内部通訊像極了一個小村莊,通訊基本靠吼(arp),當然還有村通網的含義,雖然簡陋,但足夠好用。

工作原理

選擇 macvlan 實作網絡插件的原因在于,對于一個「家庭級 Kubernetes 叢集」來說,節點的數目并不多,但是服務并不少,隻能通過端口映射(nodeport)對服務進行區分,而因為所有的機器本來就在同一個交換機上,IP 相對富裕,macvlan/ipvlan 都是簡單且好實作的方案。考慮到基于 mac 可以利用 dhcp 服務,甚至可以基于 mac 對 pod 的 ip 進行固定,是以便嘗試使用 macvlan 實作網絡插件。

但是 macvlan 在跨 net namespace 中存在不少問題,比如存在獨立 net namespace 時,流量會跨過 host 的協定棧,導緻了基于 iptables/ipvs 的 cluster ip 無法正常工作。

如何實作一個 Kubernetes 網絡插件

當然,也正是相同原因,隻是使用 macvlan 時,主控端和容器的網絡是不互通的,不過可以建立額外的 macvlan bridge 解決。

為了解決 cluster ip 無法正常工作的問題,便舍棄了隻是用 macvlan 的念頭,使用多網絡接口進行組網。

如何實作一個 Kubernetes 網絡插件

每個 Pod 都有兩個網絡接口,一個是基于 bridge 的 eth0,并作為預設網關,同時,在主控端上會添加相關路由以確定可以跨節點通信。第二個接口是 bridge 模式的 macvlan,并為這個裝置配置設定主控端網段的 ip。

工作流程

和前面提到的 CNI 的工作流程一緻,village net 也是分為 main 插件和 ipam 插件。

如何實作一個 Kubernetes 網絡插件

ipam 的主要任務是基于配置從兩個網段中個配置設定出一個可用 IP,main 插件是基于兩個網段的 IP 建立出 bridge、veth、macvlan 裝置,并進行配置。

最後

Village Net 的實作還是比較簡單,甚至還需要部分手動操作,比如 bridge 的路由部分。但是功能上基本達到預期,而且對 cni 的坑完整的梳理了一遍。cni 本身并不複雜,但是有很多細節是在一開始做的時候沒有考慮到的,甚至最後隻是通過了若幹 workaround 繞過。如果後面還有時間和精力放在網絡插件上,再考慮如何優化。<( ̄▽ ̄)/