CNI - Flannel - IP 管理篇

前言

前篇文章我們探討了 flannel 的安裝過程,包含了相關的 RBAC/PSP 安全性設定,放置相關設定檔案的 ConfigMap 以及最後運行整個運算邏輯的 DaemonSet

接下來本篇文章將來探討 flannel 是怎麼處理 IP分配的問題。

環境建置

為了搭建一個擁有三個節點的 kubernetes cluster,我認為直接使用 kubernetes-dind-cluster 是個滿不錯的選擇,可以快速搭建環境,又有多節點。

或是也可以土法煉鋼繼續使用 kubeadm 的方式創建多節點的 kubernetes cluster, 這部分並沒有特別規定,總之能搭建起來即可。

此外相關的版本資訊方面

  • kubernetes version:v1.15.4
  • flannel: 使用官方安裝 Yaml
  • kubeadm 安裝過程使用的參數 –pod-network-cidr=10.244.0.0/16

Kubeadm

所有使用 kubeadm 安裝過 flannel 都遇過需要設定 –pod-network-cidr 的情況,但是到底這個參數背後做了什麼,以及為什麼 flannel 會需要這個參數? 接下來就來研究一下

Workflow

直接先講結論,從結論講起再來講流程會比較清楚。

  1. kubernetes 會針對每個 node 去標示一個名為 PodCIDR 的值,代表該 Node 可以使用的網段是什麼,
  2. flannel 的 Pod 會去讀取該資訊,並且將該資訊寫道 /run/flannel/subnet.env 的這個檔案中
  3. flannel CNI 收到任何創建 Pod 的請求時,會去讀取 /run/flannel/subnet.env 的資訊,並且將其內容轉換最後呼叫 host-local 這隻 IPAM CNI,來取得可以用的 IP 並且設定到 POD 身上

相關檔案驗證

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$ kubectl describe nodes | grep PodCIDR
PodCIDR: 10.244.0.0/24
PodCIDR: 10.244.1.0/24
PodCIDR: 10.244.2.0/24

$ sudo cat /run/flannel/subnet.env
FLANNEL_NETWORK=10.244.0.0/16
FLANNEL_SUBNET=10.244.0.1/24
FLANNEL_MTU=1450
FLANNEL_IPMASQ=true

$ sudo ls /var/lib/cni/networks/cbr0
10.244.0.2 10.244.0.3 10.244.0.8 last_reserved_ip.0 lock

$ sudo cat /var/lib/cni/networks/cbr0/10.244.0.8
2d39d5afb81e56314a7fd6bdd57c9ccf6d02c32b556273cfb6b9bb8a248c851b

$ sudo docker ps --no-trunc | grep $(sudo cat /var/lib/cni/networks/cbr0/10.244.0.8)
2d39d5afb81e56314a7fd6bdd57c9ccf6d02c32b556273cfb6b9bb8a248c851b k8s.gcr.io/pause:3.1 "/pause" Up 4 hours k8s_POD_k8s-udpserver-6576555bcb-7h8jh_default_87196597-ccda-4643-ac5d-85343a3b6c90_0

先根據上面的指令解釋一下每個的含義,接下來再來研究其流程

  1. 透過 kubectl describer node 可以觀察到每個節點上都有一個 PodCIDR 的欄位,代表的是該節點可以使用的網段
  2. 由於我的節點是對應到的 PodCIDR10.244.0.0/24,接下來去觀察 /run/flannel/subnet.env,確認裡面的數值一致。
  3. 接下來由於我的系統上有跑過一些 Pod,這些 Pod 形成的過程中會呼叫 flannel CNI 來處理,而該 CNI 最後會再輾轉呼叫 host-loacl IPAM CNI 來處理,所以就會在這邊看到有 host-local 的產物
  4. 由於前篇介紹 IPAM 的文章有介紹過 host-local 的運作,該檔案的內容則是對應的 CONTAINER_ID,因此這邊得到的也是 CONTAINER_ID
  5. 最後則是透過 docker 指令去尋該 CONTAINER_ID,最後就看到對應到的不是真正運行的 Pod,而是先前介紹過的 Infrastructure Contaienr: Pause

接下來就是細談上述的流程

kubeadm

首先是 kubeadmcontroller-manager 兩者的關係,當我們透過 –pod-network-cide 去初始化 kubeadm 後,其創造出來的 controller-manager 就會自帶三個參數

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
root     20459  0.8  2.4 217504 100076 ?       Ssl  05:22   0:36 kube-controller-manager 
--authentication-kubeconfig=/etc/kubernetes/controller-manager.conf
--authorization-kubeconfig=/etc/kubernetes/controller-manager.conf
--bind-address=127.0.0.1
--client-ca-file=/etc/kubernetes/pki/ca.crt
--cluster-cidr=10.244.0.0/16
--node-cidr-mask-size=24
--allocate-node-cidrs=true
--cluster-signing-cert-file=/etc/kubernetes/pki/ca.crt
--cluster-signing-key-file=/etc/kubernetes/pki/ca.key
--controllers=*,bootstrapsigner,tokencleaner
--kubeconfig=/etc/kubernetes/controller-manager.conf
--leader-elect=true
--requestheader-client-ca-file=/etc/kubernetes/pki/front-proxy-ca.crt
--root-ca-file=/etc/kubernetes/pki/ca.crt
--service-account-private-key-file=/etc/kubernetes/pki/sa.key
--use-service-account-credentials=true

裡面的參數過多,直接挑出重點就是

  • –cluster-cidr=10.244.0.0/16
  • –allocate-node-cidrs=true
  • –node-cidr-mask-size=24

這邊就標明的整個 cluster network 會使用的網段,除了 cidr 大網段之外還透過 node-cide–mask 去標示寫網段,所以根據上述的範例,這個節點的數量不能超過255台節點,不然就沒有⻊夠的 可用網段去分配了。

此外很有趣的一點是,這邊的運作邏輯再 controller-managfer 內被稱為 nodeipam,也就是今天 kubernetes 自己跳下來幫忙做 IPAM 的工作,幫忙分配 IP/Subnet,只是單位是以 Node 為基準,不是以 Pod

根據 GitHub Controoler 可以看到當 Controller Manager 物件被創造的時候會根據上述的參數去產生一個名為 cidrset 的物件

1
2
3
4
5
6
....
set, err := cidrset.NewCIDRSet(clusterCIDR, nodeCIDRMaskSize)
if err != nil {
return nil, err
}
...

CIDRSet 的結構如下

1
2
3
4
5
6
7
8
9
10
type CidrSet struct {
sync.Mutex
clusterCIDR *net.IPNet
clusterIP net.IP
clusterMaskSize int
maxCIDRs int
nextCandidate int
used big.Int
subNetMaskSize int
}

基本上就是定義了 subnet 相關的所有變數,接下來裡面有一個函式叫做 allocateRange,顧名思義就是要出一塊可以用的網段


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
func (op *updateOp) allocateRange(ctx context.Context, sync *NodeSync, node *v1.Node) error {
if sync.mode != SyncFromCluster {
sync.kubeAPI.EmitNodeWarningEvent(node.Name, InvalidModeEvent,
"Cannot allocate CIDRs in mode %q", sync.mode)
return fmt.Errorf("controller cannot allocate CIDRS in mode %q", sync.mode)
}

cidrRange, err := sync.set.AllocateNext()
if err != nil {
return err
}
// If addAlias returns a hard error, cidrRange will be leaked as there
// is no durable record of the range. The missing space will be
// recovered on the next restart of the controller.
if err := sync.cloudAlias.AddAlias(ctx, node.Name, cidrRange); err != nil {
klog.Errorf("Could not add alias %v for node %q: %v", cidrRange, node.Name, err)
return err
}

if err := sync.kubeAPI.UpdateNodePodCIDR(ctx, node, cidrRange); err != nil {
klog.Errorf("Could not update node %q PodCIDR to %v: %v", node.Name, cidrRange, err)
return err
}

if err := sync.kubeAPI.UpdateNodeNetworkUnavailable(node.Name, false); err != nil {
klog.Errorf("Could not update node NetworkUnavailable status to false: %v", err)
return err
}

klog.V(2).Infof("Allocated PodCIDR %v for node %q", cidrRange, node.Name)

return nil
}

裡面最重要的就是呼叫 UpdateNodePodCIDR 這個函式來進行最後的更新

根據其原始碼

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func (a *adapter) UpdateNodePodCIDR(ctx context.Context, node *v1.Node, cidrRange *net.IPNet) error {
patch := map[string]interface{}{
"apiVersion": node.APIVersion,
"kind": node.Kind,
"metadata": map[string]interface{}{"name": node.Name},
"spec": map[string]interface{}{"podCIDR": cidrRange.String()},
}
bytes, err := json.Marshal(patch)
if err != nil {
return err
}

_, err = a.k8s.CoreV1().Nodes().Patch(node.Name, types.StrategicMergePatchType, bytes)
return err
}

可以看到最後會在 spec下面產生一個名稱為 podCIDR 的內容,且其數值就是分配後的網段(cidrRange.String())。

這部分可以透過 kubectl get nodes xxxx -o yaml 來驗證

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
$ kubectl get nodes k8s-dev -o yaml
apiVersion: v1
kind: Node
metadata:
annotations:
flannel.alpha.coreos.com/backend-data: '{"VtepMAC":"3e:94:52:9b:7e:d9"}'
flannel.alpha.coreos.com/backend-type: vxlan
flannel.alpha.coreos.com/kube-subnet-manager: "true"
flannel.alpha.coreos.com/public-ip: 10.0.2.15
kubeadm.alpha.kubernetes.io/cri-socket: /var/run/dockershim.sock
node.alpha.kubernetes.io/ttl: "0"
volumes.kubernetes.io/controller-managed-attach-detach: "true"
creationTimestamp: "2019-09-23T05:21:46Z"
labels:
beta.kubernetes.io/arch: amd64
beta.kubernetes.io/os: linux
kubernetes.io/arch: amd64
kubernetes.io/hostname: k8s-dev
kubernetes.io/os: linux
node-role.kubernetes.io/master: ""
name: k8s-dev
resourceVersion: "57899"
selfLink: /api/v1/nodes/k8s-dev
uid: cd8fadc0-e58c-4509-9056-3a06bdb8440f
spec:
podCIDR: 10.244.0.0/24
...

Pod Flannel

鏡頭一轉,我們來看當 flannel 部署的 Pod 運行起來後會做什麼事情。
前文有提過,預設的安裝設定檔案中會使得 flannel 使用 kubernetes API 來存取資訊,這同時也意味其 subnet manager 會使用 kubernetes API 來完成,這部分的程式碼都在

其中要特別注意的一個函式AcquireLease
可以看到裡面嘗試針對 node 底下的 sped.PodCIDR 去存取,並且透過 enet.ParseCIDR 的方式去解讀。

1
2
3
4
5
6
7
8
9
10
11
12
13
...
if n.Spec.PodCIDR == "" {
return nil, fmt.Errorf("node %q pod cidr not assigned", ksm.nodeName)
}
bd, err := attrs.BackendData.MarshalJSON()
if err != nil {
return nil, err
}
_, cidr, err := net.ParseCIDR(n.Spec.PodCIDR)
if err != nil {
return nil, err
}
...

接下來於主要的 main.go 這邊會在呼叫 WriteSubnetFile 把相關的結果寫到檔案內,最後大家就可以到 /run/flannel/subnet.env 去得到相關資訊。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
func WriteSubnetFile(path string, nw ip.IP4Net, ipMasq bool, bn backend.Network) error {
dir, name := filepath.Split(path)
os.MkdirAll(dir, 0755)

tempFile := filepath.Join(dir, "."+name)
f, err := os.Create(tempFile)
if err != nil {
return err
}

// Write out the first usable IP by incrementing
// sn.IP by one
sn := bn.Lease().Subnet
sn.IP += 1

fmt.Fprintf(f, "FLANNEL_NETWORK=%s\n", nw)
fmt.Fprintf(f, "FLANNEL_SUBNET=%s\n", sn)
fmt.Fprintf(f, "FLANNEL_MTU=%d\n", bn.MTU())
_, err = fmt.Fprintf(f, "FLANNEL_IPMASQ=%v\n", ipMasq)
f.Close()
if err != nil {
return err
}

// rename(2) the temporary file to the desired location so that it becomes
// atomically visible with the contents
return os.Rename(tempFile, path)
//TODO - is this safe? What if it's not on the same FS?
}

CNI Flannel

話題一轉,我們來看最後一個步驟,當 CRI 決定創建 POD 並且準備好相關環參數呼叫 CNI 後的運作。

這邊要額外提醒, flannel 的程式碼分兩的地方存放

  1. CoreOS - Pod
  2. ContainetNetworking - CNI

同時這也可以解釋為什麼一開始安裝好 kubernetes 後,系統內就有 flannel CNI 的執行檔案了,因為被放在官方的 repo 裡面。

我們先來看創建 POD 的時候 Flannel CNI 會做的事情

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
const (
defaultSubnetFile = "/run/flannel/subnet.env"
defaultDataDir = "/var/lib/cni/flannel"
)
...
func cmdAdd(args *skel.CmdArgs) error {
n, err := loadFlannelNetConf(args.StdinData)
if err != nil {
return err
}

fenv, err := loadFlannelSubnetEnv(n.SubnetFile)
if err != nil {
return err
}

if n.Delegate == nil {
n.Delegate = make(map[string]interface{})
} else {
if hasKey(n.Delegate, "type") && !isString(n.Delegate["type"]) {
return fmt.Errorf("'delegate' dictionary, if present, must have (string) 'type' field")
}
if hasKey(n.Delegate, "name") {
return fmt.Errorf("'delegate' dictionary must not have 'name' field, it'll be set by flannel")
}
if hasKey(n.Delegate, "ipam") {
return fmt.Errorf("'delegate' dictionary must not have 'ipam' field, it'll be set by flannel")
}
}

if n.RuntimeConfig != nil {
n.Delegate["runtimeConfig"] = n.RuntimeConfig
}

return doCmdAdd(args, n, fenv)
}

有個常見且習慣的名稱 cmdAdd,裡面可以看到呼叫了 loadFlannelSubnetEnv,其中若使用者沒有特別設定的話,預設的 SubnetFile 就是 defaultSubnetFile,如上面示,其值為 /run/flannel/subnet.env

接者該函式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
func loadFlannelSubnetEnv(fn string) (*subnetEnv, error) {
f, err := os.Open(fn)
if err != nil {
return nil, err
}
defer f.Close()

se := &subnetEnv{}

s := bufio.NewScanner(f)
for s.Scan() {
parts := strings.SplitN(s.Text(), "=", 2)
switch parts[0] {
case "FLANNEL_NETWORK":
_, se.nw, err = net.ParseCIDR(parts[1])
if err != nil {
return nil, err
}

case "FLANNEL_SUBNET":
_, se.sn, err = net.ParseCIDR(parts[1])
if err != nil {
return nil, err
}

case "FLANNEL_MTU":
mtu, err := strconv.ParseUint(parts[1], 10, 32)
if err != nil {
return nil, err
}
se.mtu = new(uint)
*se.mtu = uint(mtu)

case "FLANNEL_IPMASQ":
ipmasq := parts[1] == "true"
se.ipmasq = &ipmasq
}
}
if err := s.Err(); err != nil {
return nil, err
}

if m := se.missing(); m != "" {
return nil, fmt.Errorf("%v is missing %v", fn, m)
}

return se, nil
}

就會去讀取該檔案,並且整理成一個 subnetEnv 的物件格式,一切都處理完畢後,就會透過 CNI 內建的函式去呼叫其他的 CNI 來處理

可以再doCmdAdd 這個函式看到最後塞了一個 ipam 的字典資訊進去,然後裡面設定了 host-local 會用到的所有參數。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
func doCmdAdd(args *skel.CmdArgs, n *NetConf, fenv *subnetEnv) error {
n.Delegate["name"] = n.Name

if !hasKey(n.Delegate, "type") {
n.Delegate["type"] = "bridge"
}

if !hasKey(n.Delegate, "ipMasq") {
// if flannel is not doing ipmasq, we should
ipmasq := !*fenv.ipmasq
n.Delegate["ipMasq"] = ipmasq
}

if !hasKey(n.Delegate, "mtu") {
mtu := fenv.mtu
n.Delegate["mtu"] = mtu
}

if n.Delegate["type"].(string) == "bridge" {
if !hasKey(n.Delegate, "isGateway") {
n.Delegate["isGateway"] = true
}
}
if n.CNIVersion != "" {
n.Delegate["cniVersion"] = n.CNIVersion
}

n.Delegate["ipam"] = map[string]interface{}{
"type": "host-local",
"subnet": fenv.sn.String(),
"routes": []types.Route{
{
Dst: *fenv.nw,
},
},
}

return delegateAdd(args.ContainerID, n.DataDir, n.Delegate)
}

這個檔案其實也無形透露了, flannel 最後其實是產生一個使用 bridge 作為主體 CNIIPAM 使用 host-local 的設定檔案。
這也是我之前所說的這些由官方維護的基本功能解決方案,不論是基於提供網路功能的,或是 IPAM 相關的套件都會給受到其他的套件反覆使用而組合出更強大的功能。

一旦當 host-local 處理結束後,就會再 /var/run/cni/cbr0/networks 看到一系列由 host-local 所維護的正在使用 IP 清單。

Summary

flannel 本身並不處理 Linux Bridge 的設定以及 IPAM 相關的設定,反而是透過更上層的辦法去處理設定檔案的問題,確保每一台機器上面 host-local 看到的網段都不同,而 host-local 則專注於對每個網段都能夠不停的產生出唯一不被使用的 IP 地址。

這種分工合作的辦法也是現在軟體開發與整合的模式,隨者效能與功能愈來愈強大,很難有一個軟體可以涵括所有領域的功能,適度的合作與整合才有辦法打造出更好的解決方案。

本篇我們大概理解了 flannel 是如何處理 IP 分配的問題,透過 kubernetes nodeIPAM 的設計,以及 CNI Host-local IPAM 的處理來完成。

最後使用下圖來作為一個總結

參考