原文:https://blog.csdn.net/qq_29648159/article/details/119614573
----------
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模型並沒有得到廣泛的應用。
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
的相關方法即可。
示例
接下來將演示如何使用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
:
ls /var/lib/cni/networks/mynet/
10.10.0.2 last_reserved_ip.0 lock
從主機訪問驗證
由於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
命令刪除。
總結
至此,CNI的工作原理我們已基本清楚。CNI的工作原理大致可以歸納爲:
- 通過JSON配置文件定義網絡配置;
- 通過調用可執行程序(CNI插件)來對容器網絡執行配置;
- 通過鏈式調用的方式來支持多插件的組合使用。
CNI不僅定義了接口規範,同時也提供了一些內置的標準實現,以及libcni
這樣的“膠水層”,大大降低了容器運行時與網絡插件的接入門檻。