深入理解 CNI(容器網絡接口)

1、 CNI 簡介

容器網絡的配置是一個複雜的過程,爲了應對各式各樣的需求,容器網絡的解決方案也多種多樣,例如有 flannel,calico,kube-ovn,weave 等。同時,容器平臺 / 運行時也是多樣的,例如有 Kubernetes,Openshift,rkt 等。如果每種容器平臺都要跟每種網絡解決方案一一對接適配,這將是一項巨大且重複的工程。當然,聰明的程序員們肯定不會允許這樣的事情發生。想要解決這個問題,我們需要一個抽象的接口層,將容器網絡配置方案與容器平臺方案解耦。

CNI(Container Network Interface)就是這樣的一個接口層,它定義了一套接口標準,提供了規範文檔以及一些標準實現。採用 CNI 規範來設置容器網絡的容器平臺不需要關注網絡的設置的細節,只需要按 CNI 規範來調用 CNI 接口即可實現網絡的設置。

CNI 最初是由 CoreOS 爲 rkt 容器引擎創建的,隨着不斷髮展,已經成爲事實標準。目前絕大部分的容器平臺都採用 CNI 標準(rkt,Kubernetes ,OpenShift 等)。本篇內容基於 CNI 最新的發佈版本 v0.4.0。

值得注意的是,Docker 並沒有採用 CNI 標準,而是在 CNI 創建之初同步開發了 CNM(Container Networking Model)標準。但由於技術和非技術原因,CNM 模型並沒有得到廣泛的應用。

2、CNI 是怎麼工作的

CNI 的接口並不是指 HTTP,gRPC 接口,CNI 接口是指對可執行程序的調用(exec)。這些可執行程序稱之爲 CNI 插件,以 K8S 爲例,K8S 節點默認的 CNI 插件路徑爲 /opt/cni/bin ,在 K8S 節點上查看該目錄,可以看到可供使用的 CNI 插件:

$ ls /opt/cni/bin/
bandwidth  bridge  dhcp  firewall  flannel  host-device  host-local  ipvlan  loopback  macvlan  portmap  ptp  sbr  static  tuning  vlan

CNI 的工作過程大致如下圖所示:

圖片

CNI 通過 JSON 格式的配置文件來描述網絡配置,當需要設置容器網絡時,由容器運行時負責執行 CNI 插件,並通過 CNI 插件的標準輸入(stdin)來傳遞配置文件信息,通過標準輸出(stdout)接收插件的執行結果。圖中的 libcni 是 CNI 提供的一個 go package,封裝了一些符合 CNI 規範的標準操作,便於容器運行時和網絡插件對接 CNI 標準。

舉一個直觀的例子,假如我們要調用 bridge 插件將容器接入到主機網橋,則調用的命令看起來長這樣:

# CNI_COMMAND=ADD 顧名思義表示創建。
# XXX=XXX 其他參數定義見下文。
# < config.json 表示從標準輸入傳遞配置文件
CNI_COMMAND=ADD XXX=XXX ./bridge < config.json

插件入參

容器運行時通過設置環境變量以及從標準輸入傳入的配置文件來向插件傳遞參數。

環境變量

配置文件

文件示例:

{
  "cniVersion": "0.4.0", // 表示希望插件遵循的CNI標準的版本。
  "name": "dbnet",  // 表示網絡名稱。這個名稱並非指網絡接口名稱,是便於CNI管理的一個表示。應當在當前主機(或其他管理域)上全局唯一。
  "type": "bridge", // 插件類型
  "bridge": "cni0", // bridge插件的參數,指定網橋名稱。
  "ipam": { // IP Allocation Management,管理IP地址分配。
    "type": "host-local", // ipam插件的類型。
    // ipam 定義的參數
    "subnet": "10.1.0.0/16",
    "gateway": "10.1.0.1"
  }
}

公共定義部分

配置文件分爲公共部分和插件定義部分。公共部分在 CNI 項目中使用結構體 NetworkConfig 定義:

type NetworkConfig struct {
   Network *types.NetConf
   Bytes   []byte
}
...
// NetConf describes a network.
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:"-"`
}

更詳細的信息,可以參考官方文檔。

插件定義部分

上文提到,配置文件最終是傳遞給具體的 CNI 插件的,因此插件定義部分纔是配置文件的 “完全體”。公共部分定義只是爲了方便各插件將其嵌入到自身的配置文件定義結構體中,舉 bridge 插件爲例:

type NetConf struct {
  types.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"`
  Args struct {
    Cni BridgeArgs `json:"cni,omitempty"`
  } `json:"args,omitempty"`
  RuntimeConfig struct {
    Mac string `json:"mac,omitempty"`
  } `json:"runtimeConfig,omitempty"`
  mac string
}

各插件的配置文件文檔可參考官方文檔。

插件操作類型

CNI 插件的操作類型只有四種:ADD , DEL , CHECK 和 VERSION。插件調用者通過環境變量 CNI_COMMAND 來指定需要執行的操作。

ADD

ADD 操作負責將容器添加到網絡,或對現有的網絡設置做更改。具體地說,ADD 操作要麼:

例如通過 ADD 將容器網絡接口接入到主機的網橋中。

其中網絡接口名稱由 CNI_IFNAME 指定,網絡命名空間由 CNI_NETNS 指定。

DEL

DEL 操作負責從網絡中刪除容器,或取消對應的修改,可以理解爲是 ADD 的逆操作。具體地說,DEL 操作要麼:

例如通過 DEL 將容器網絡接口從主機網橋中刪除。

其中網絡接口名稱由 CNI_IFNAME 指定,網絡命名空間由 CNI_NETNS 指定。

CHECK

CHECK 操作是 v0.4.0 加入的類型,用於檢查網絡設置是否符合預期。容器運行時可以通過 CHECK 來檢查網絡設置是否出現錯誤,當 CHECK 返回錯誤時(返回了一個非 0 狀態碼),容器運行時可以選擇 Kill 掉容器,通過重新啓動來重新獲得一個正確的網絡配置。

VERSION

VERSION 操作用於查看插件支持的版本信息。

$ CNI_COMMAND=VERSION /opt/cni/bin/bridge
{"cniVersion":"0.4.0","supportedVersions":["0.1.0","0.2.0","0.3.0","0.3.1","0.4.0"]}

鏈式調用

單個 CNI 插件的職責是單一的,比如 bridge 插件負責網橋的相關配置, firewall 插件負責防火牆相關配置, portmap 插件負責端口映射相關配置。因此,當網絡設置比較複雜時,通常需要調用多個插件來完成。CNI 支持插件的鏈式調用,可以將多個插件組合起來,按順序調用。例如先調用 bridge 插件設置容器 IP,將容器網卡與主機網橋連通,再調用 portmap 插件做容器端口映射。容器運行時可以通過在配置文件設置 plugins 數組達到鏈式調用的目的:

{
  "cniVersion": "0.4.0",
  "name": "dbnet",
  "plugins": [
    {
      "type": "bridge",
      // type (plugin) specific
      "bridge": "cni0"
      },
      "ipam": {
        "type": "host-local",
        // ipam specific
        "subnet": "10.1.0.0/16",
        "gateway": "10.1.0.1"
      }
    },
    {
      "type": "tuning",
      "sysctl": {
        "net.core.somaxconn": "500"
      }
    }
  ]
}

細心的讀者會發現,plugins 這個字段並沒有出現在上文描述的配置文件結構體中。的確,CNI 使用了另一個結構體——NetworkConfigList 來保存鏈式調用的配置:

type NetworkConfigList struct {
   Name         string
   CNIVersion   string
   DisableCheck bool
   Plugins      []*NetworkConfig 
   Bytes        []byte
}

但 CNI 插件是不認識這個配置類型的。實際上,在調用 CNI 插件時,需要將 NetworkConfigList 轉換成對應插件的配置文件格式,再通過標準輸入(stdin)傳遞給 CNI 插件。例如在上面的示例中,實際上會先使用下面的配置文件調用 bridge 插件:

{
  "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"
  }
}

再使用下面的配置文件調用 tuning 插件:

{
  "cniVersion": "0.4.0",
  "name": "dbnet",
  "type": "tuning",
  "sysctl": {
    "net.core.somaxconn": "500"
  },
  "prevResult": { // 調用bridge插件的返回結果
     ...
  }
}

需要注意的是,當插件進行鏈式調用的時候,不僅需要對 NetworkConfigList 做格式轉換,而且需要將前一次插件的返回結果添加到配置文件中(通過 prevResult 字段),不得不說是一項繁瑣而重複的工作。不過幸好 libcni 已經爲我們封裝好了,容器運行時不需要關心如何轉換配置文件,如何填入上一次插件的返回結果,只需要調用 libcni 的相關方法即可。

3、示例

接下來將演示如何使用 CNI 插件來爲 Docker 容器設置網絡。

下載 CNI 插件

爲方便起見,我們直接下載可執行文件:

wget https://github.com/containernetworking/plugins/releases/download/v0.9.1/cni-plugins-linux-amd64-v0.9.1.tgz
mkdir -p  ~/cni/bin
tar zxvf cni-plugins-linux-amd64-v0.9.1.tgz -C ./cni/bin
chmod +x ~/cni/bin/*
ls ~/cni/bin/
bandwidth  bridge  dhcp  firewall  flannel  host-device  host-local  ipvlan  loopback  macvlan  portmap  ptp  sbr  static  tuning  vlan  vrfz

如果你是在 K8S 節點上實驗,通常節點上已經有 CNI 插件了,不需要再下載,但要注意將後續的 CNI_PATH 修改成 / opt/cni/bin。

示例 1——調用單個插件

在示例 1 中,我們會直接調用 CNI 插件,爲容器設置 eth0 接口,爲其分配 IP 地址,並接入主機網橋 mynet0。

跟 docker 默認使用的使用網絡模式一樣,只不過我們將 docker0 換成了 mynet0。

啓動容器

雖然 Docker 不使用 CNI 規範,但可以通過指定 --net=none 的方式讓 Docker 不設置容器網絡。以 nginx 鏡像爲例:

contid=$(docker run -d --net=none --name nginx nginx) # 容器ID
pid=$(docker inspect -f '{{ .State.Pid }}' $contid) # 容器進程ID
netnspath=/proc/$pid/ns/net # 命名空間路徑

啓動容器的同時,我們需要記錄一下容器 ID,命名空間路徑,方便後續傳遞給 CNI 插件。容器啓動後,可以看到除了 lo 網卡,容器沒有其他的網絡設置:

nsenter -t $pid -n ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever

nsenter 是 namespace enter 的簡寫,顧名思義,這是一個在某命名空間下執行命令的工具。-t 表示進程 ID, -n 表示進入對應進程的網絡命名空間。

添加容器網絡接口並連接主機網橋

接下來我們使用 bridge 插件爲容器創建網絡接口,並連接到主機網橋。創建 bridge.json 配置文件,內容如下:

{
    "cniVersion": "0.4.0",
    "name": "mynet",
    "type": "bridge",
    "bridge": "mynet0",
    "isDefaultGateway": true,
    "forceAddress": false,
    "ipMasq": true,
    "hairpinMode": true,
    "ipam": {
        "type": "host-local",
        "subnet": "10.10.0.0/16"
    }
}

調用 bridge 插件 ADD 操作:

CNI_COMMAND=ADD CNI_CONTAINERID=$contid CNI_NETNS=$netnspath CNI_IFNAME=eth0 CNI_PATH=~/cni/bin ~/cni/bin/bridge < bridge.json

調用成功的話,會輸出類似的返回值:

{
    "cniVersion": "0.4.0",
    "interfaces": [
        ....
    ],
    "ips": [
        {
            "version": "4",
            "interface": 2,
            "address": "10.10.0.2/16", //給容器分配的IP地址
            "gateway": "10.10.0.1" 
        }
    ],
    "routes": [
        .....
    ],
    "dns": {}
}

再次查看容器網絡設置:

nsenter -t $pid -n ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
5: eth0@if40: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
    link/ether c2:8f:ea:1b:7f:85 brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet 10.10.0.2/16 brd 10.10.255.255 scope global eth0
       valid_lft forever preferred_lft forever

可以看到容器中已經新增了 eth0 網絡接口,並在 ipam 插件設定的子網下爲其分配了 IP 地址。host-local 類型的 ipam 插件會將已分配的 IP 信息保存到文件,避免 IP 衝突,默認的保存路徑爲 / var/lib/cni/network/$NETWORK_NAME:

從主機訪問驗證

由於 mynet0 是我們添加的網橋,還未設置路由,因此驗證前我們需要先爲容器所在的網段添加路由:

ip route add 10.10.0.0/16 dev mynet0 src 10.10.0.1 # 添加路由
curl -I 10.10.0.2 # IP換成實際分配給容器的IP地址
HTTP/1.1 200 OK
....

刪除容器網絡接口

刪除的調用入參跟添加的入參是一樣的,除了 CNI_COMMAND 要替換成 DEL:

CNI_COMMAND=DEL CNI_CONTAINERID=$contid CNI_NETNS=$netnspath CNI_IFNAME=eth0 CNI_PATH=~/cni/bin ~/cni/bin/bridge < bridge.json

注意,上述的刪除命令並未清理主機的 mynet0 網橋。如果你希望刪除主機網橋,可以執行 ip link delete mynet0 type bridge 命令刪除。

示例 2——鏈式調用

在示例 2 中,我們將在示例 1 的基礎上,使用 portmap 插件爲容器添加端口映射。

使用 cnitool 工具

前面的介紹中,我們知道在鏈式調用過程中,調用方需要轉換配置文件,並需要將上一次插件的返回結果插入到本次插件的配置文件中。這是一項繁瑣的工作,而 libcni 已經將這些過程封裝好了,在示例 2 中,我們將使用基於 libcni 的命令行工具 cnitool 來簡化這些操作。

示例 2 將複用示例 1 中的容器,因此在開始示例 2 時,請確保已刪除示例 1 中的網絡接口。

通過源碼編譯或 go install 來安裝 cnitool:

go install github.com/containernetworking/cni/cnitool@latest

配置文件

libcni 會讀取. conflist 後綴的配置文件,我們在當前目錄創建 portmap.conflist:

{
  "cniVersion": "0.4.0",
  "name": "portmap",
  "plugins": [
    {
      "type": "bridge",
      "bridge": "mynet0",
      "isDefaultGateway": true, 
      "forceAddress": false, 
      "ipMasq": true, 
      "hairpinMode": true,
      "ipam": {
        "type": "host-local",
        "subnet": "10.10.0.0/16",
        "gateway": "10.10.0.1"
      }
    },
    {
      "type": "portmap",
      "runtimeConfig": {
        "portMappings": [
          {"hostPort": 8080, "containerPort": 80, "protocol": "tcp"}
        ]
      }
    }
  ]
}

從上述的配置文件定義了兩個 CNI 插件,bridge 和 portmap。根據上述的配置文件,cnitool 會先爲容器添加網絡接口並連接到主機 mynet0 網橋上(就跟示例 1 一樣),然後再調用 portmap 插件,將容器的 80 端口映射到主機的 8080 端口,就跟 docker run -p 8080:80 xxx 一樣。

設置容器網絡

使用 cnitool 我們還需要設置兩個環境變量:

NETCONFPATH: 指定配置文件(*.conflist)的所在路徑,默認路徑爲 /etc/cni/net.d

CNI_PATH :指定 CNI 插件的存放路徑。

使用 cnitool add 命令爲容器設置網絡:

CNI_PATH=~/cni/bin NETCONFPATH=.  cnitool add portmap $netnspath

設置成功後,訪問宿主機 8080 端口即可訪問到容器的 nginx 服務。

刪除網絡配置

使用 cnitool del 命令刪除容器網絡:

CNI_PATH=~/cni/bin NETCONFPATH=.  cnitool del portmap $netnspath

注意,上述的刪除命令並未清理主機的 mynet0 網橋。如果你希望刪除主機網橋,可以執行 ip link delete mynet0 type bridge 命令刪除。

4、總結 

至此,CNI 的工作原理我們已基本清楚。CNI 的工作原理大致可以歸納爲:

通過 JSON 配置文件定義網絡配置;

通過調用可執行程序(CNI 插件)來對容器網絡執行配置;

通過鏈式調用的方式來支持多插件的組合使用。

CNI 不僅定義了接口規範,同時也提供了一些內置的標準實現,以及 libcni 這樣的 “膠水層”,大大降低了容器運行時與網絡插件的接入門檻。

參考

作者:水立方

來源:用戶投稿

原文地址:https://juejin.cn/post/6986495816949039141

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/OG7zjt23iIDa-ZlR7tYGOg