深入理解CNI(容器網路介面)

語言: CN / TW / HK

01

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模型並沒有得到廣泛的應用。

02

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

外掛入參

容器執行時通過設定環境變數以及從標準輸入傳入的配置檔案來向外掛傳遞引數。

環境變數

  • CNI_COMMAND :定義期望的操作,可以是ADD,DEL,CHECK或VERSION。

  • CNI_CONTAINERID : 容器ID,由容器執行時管理的容器唯一識別符號。

  • CNI_NETNS:容器網路名稱空間的路徑。(形如 /run/netns/[nsname] )。

  • CNI_IFNAME :需要被建立的網路介面名稱,例如eth0。

  • CNI_ARGS :執行時呼叫時傳入的額外引數,格式為分號分隔的key-value對,例如 FOO=BAR;ABC=123

  • CNI_PATH : CNI外掛可執行檔案的路徑,例如/opt/cni/bin。

配置檔案

檔案示例:

{
"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:"-"`
}
  • cniVersion 表示希望外掛遵循的CNI標準的版本。

  • name 表示網路名稱。這個名稱並非指網路介面名稱,是便於CNI管理的一個表示。應當在當前主機(或其他管理域)上全域性唯一。

  • type 表示外掛的名稱,也就是外掛對應的可執行檔案的名稱。

  • bridge 該引數屬於bridge外掛的引數,指定主機網橋的名稱。

  • ipam 表示IP地址分配外掛的配置,ipam.type 則表示ipam的外掛型別。

更詳細的資訊,可以參考官方文件。

外掛定義部分

上文提到,配置檔案最終是傳遞給具體的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 操作要麼:

  • 為容器所在的網路名稱空間刪除一個網路介面,或者

  • 撤銷 ADD 操作的修改

例如通過 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 的相關方法即可。

03

示例

接下來將演示如何使用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命令刪除。

04

總結  

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

通過JSON配置檔案定義網路配置;

通過呼叫可執行程式(CNI外掛)來對容器網路執行配置;

通過鏈式呼叫的方式來支援多外掛的組合使用。

CNI不僅定義了介面規範,同時也提供了一些內建的標準實現,以及libcni這樣的“膠水層”,大大降低了容器執行時與網路外掛的接入門檻。

參考

  • CNI v0.4.0規範文件

  • CNI master分支規範文件

  • CNI內建外掛文件

  • cnitool 文件

  • 為什麼Kubernetes不使用CNM模型

  • Introduction to CNI

  • CNI deep dive

作者:水立方

來源:使用者投稿

原文地址: http://mtw.so/5AqWR2