KubernetesのPodに複数のNICを刺す

通常、KubernetesのPodに複数のPodを構成する場合は [Multus-CNI](https://github.com/k8snetworkplumbingwg/multus-cni) の利用が一般的ですが、CRIにcontainerdを利用している場合には、単純に複数のNICを刺すためのもっと簡単な方法がありますよ、というエントリです。

このエントリは、[Z Lab Advent Calendar 2022](https://qiita.com/advent-calendar/2022/zlab) の七日目の記事です。

## TL;DR

`config.toml` における `max_conf` の設定を変更する。

```toml
[plugins."io.containerd.grpc.v1.cri".cni]
  max_conf_num = 2
```

## 環境

この記事は、以下のソフトウェアを利用してテストしています。

- Kubernetes: 1.24
- containerd: 1.6.8

## CRI が CNI をキックする

この設定項目を知ったのは、ふと、CRI が CNI を呼び出す際の仕様はどうなっているのか気になったからでした。

CNI の仕様的には、[CNI のバイナリに対して環境変数と標準入力に設定ファイルを渡す](https://www.cni.dev/docs/spec/#section-2-execution-protocol) だけなのだが、CRIはどのCNIをどういう優先度で決定して呼び出しているのだろうか？

CRIに設定したCNIの設定ファイルディレクトリ(デフォルト: `/etc/cni/net.d`) にある設定ファイルのうち、辞書順でソートして1番目の設定ファイルが利用されることは知っていたのだけれども、その仕様はどこに明記されているのやら。

- [Redefine Container Runtime Interface](https://github.com/kubernetes/design-proposals-archive/blob/main/node/container-runtime-interface-v1.md)
- [Container Runtime Interface (CRI) Networking Specifications](https://github.com/kubernetes/community/blob/master/contributors/devel/sig-node/kubelet-cri-networking.md)

とりあえず、ちょっと探しただけでは見つからなかったので、知っている人がいたら教えてください。

### containerd の実装

と言うことで探検隊は真実を知るために、`containerd` のジャングルの奥地へと向かったのでした。

```golang
// loadFromConfDir detects network config files from the
// configured cni config directory and load them. max is
// the maximum network config to load (max i<= 0 means no limit).
func loadFromConfDir(c *libcni, max int) error {
	files, err := cnilibrary.ConfFiles(c.pluginConfDir, []string{".conf", ".conflist", ".json"})
	switch {
	case err != nil:
		return fmt.Errorf("failed to read config file: %v: %w", err, ErrRead)
	case len(files) == 0:
		return fmt.Errorf("no network config found in %s: %w", c.pluginConfDir, ErrCNINotInitialized)
	}

	// files contains the network config files associated with cni network.
	// Use lexicographical way as a defined order for network config files.
	sort.Strings(files)
```

早速辿り着いたのは、`containerd` が内部で CNI をキックする時に利用しているライブラリは `go-cni` の `loadFromConfDir` と言う関数でした。

- https://github.com/containerd/go-cni/blob/main/opts.go#L209-L273

コメントを読むと分かる通り、設定ファイルディレクトリ内のファイルを辞書順でソートしています。

```golang
		networks = append(networks, &Network{
			cni:    c.cniConfig,
			config: confList,
			ifName: getIfName(c.prefix, i),
		})
		i++
		if i == max {
			break
		}
```

そして順番にネットワークを追加し、max値まで追加したら`break` してますね。

ここで思ったのは、「**待て、KubernetesのNICってデフォルト一枚じゃなかったっけ？**」でした。

その昔、CNIの記事を書いた際に、[戯れにKubernetesが管理するPodに2枚目のNICを刺した](https://qiita.com/yuanying/items/68b2a32b4d217e679955#kubernetes-が管理する-pod-に2枚目の-nic-を刺す) ことがあったのですが、`containerd` 公式にもその方法があったとは。

ソースコードを`containerd` に戻り、このmax値を制御している設定が、`config.toml` の `max_conf_num` と言うことがわかりました。と、言うことで冒頭の "TL;DR" に戻るわけですね！

## Pod に複数のNICを刺す

そんなわけでせっかくなので、各ノードに上記の設定を施し、実際にPodにネットワークが複数設定されるか試して見ましょう。今回は、以下のCNIの組み合わせを試してみます。

- Flannel + Cilium
- Flannel + Bridge

各CNIの設定は以下の通りです。

- Flannel
    - Version
        - flannelcni/flannel: v0.20.2
        - flannelcni/flannel-cni-plugin: v1.2.0
    - Tunnel: none (host-gw)
    - IPAM: Kubernetes
    - Pod CIDR: `10.242.0.0/16`
- Cilium
    - Version
        - Chart: 1.12.2
        - CLI: v0.12.10
    - Tunnel: none (host-gw)
    - IPAM: cluster-pool
    - Pod CIDR: `172.20.0.0/16`
- Bridge
    - name: br0
    - CIDR: `192.168.0.0/16`
    - 備考: ホストネットワーク直結

### Flannel + Cilium

まずは、以下のように三つのPodがFlannelによりIPアドレスが割り当てられているとします。

```bash
$ kubectl get pod -o=custom-columns=NAME:.metadata.name,IP:.status.podIP
NAME        IP
testapp-0   10.242.4.2
testapp-1   10.242.1.2
testapp-2   10.242.3.2
```

コンテナの中を見てみると、eth0 に上記のアドレスがついていることが確認できます。

```bash
$ kubectl exec -ti testapp-0 -- ip addr
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
2: eth0@if6: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
    link/ether 32:27:88:90:7f:90 brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet 10.242.4.2/24 brd 10.242.4.255 scope global eth0
       valid_lft forever preferred_lft forever
```

さて、そこに Cilium をインストールしてみましょう。

以下の `kustomization.yaml` を用意します。

```yaml
$ cat <<EOF > kustomization.yaml
helmCharts:
- name: cilium
  includeCRDs: true
  valuesInline:
    tunnel: disabled
    autoDirectNodeRoutes: true
    ipv4NativeRoutingCIDR: "172.20.0.0/16"

    cni:
      exclusive: false

    ipam:
      mode: cluster-pool
      operator:
        clusterPoolIPv4PodCIDRList:
        - "172.20.0.0/16"

    hubble:
      enabled: false
  releaseName: cilium
  version: 1.12.2
  repo: https://helm.cilium.io/

patchesStrategicMerge:
- |-
  apiVersion: apps/v1
  kind: DaemonSet
  metadata:
    name: cilium
  spec:
    template:
      spec:
        containers:
        - name: cilium-agent
          env:
          - name: CNI_CONF_NAME
            value: 20-cilium.conf
EOF
```

Cilium には古いCNIを `/etc/cni/net.d` から削除してしまう勝手機能があるのですが、`cni.exclusive=false` にしてそれを無効化してやります。

また、デフォルト Cilium がインストールする CNI の設定ファイルのファイル名は `05-cilium.conf` なのですが、事前にインストールされている Flannel の設定ファイル名が `10-flannel.conflist` のため、そのままCNIを適用すると Cilium が優先されてしまい、一番目のNICが Cilium になってしまいます。

今回は、一番目のNICにFlannelを、二番目のNICにCiliumを利用したかったため、`CNI_CONF_NAME` を変更して `20-cilium.conf` としています。

さて、早速 Cilium を kustomize build してインストールしてみましょう。

```bash
$ kustomize build --enable-helm --helm-command=helm . | k apply -f -
```

この状態で `testapp-0` と `testapp-1` を再起動します。

```bash
$ kubectl delete pod testapp-0 testapp-1
pod "testapp-0" deleted
pod "testapp-1" deleted

$ kubectl get pod -o=custom-columns=NAME:.metadata.name,IP:.status.podIP
NAME        IP
testapp-0   10.242.4.5
testapp-1   10.242.1.5
testapp-2   10.242.3.2
```

その後、PodのIPアドレスを確認してみると、、変わらずFlannelがIPアドレスを割り当てていますね。

Podの中に入ってアドレスを確認してみます。

```bash
$ kubectl exec -ti testapp-0 -- ip addr
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
    inet6 ::1/128 scope host
       valid_lft forever preferred_lft forever
2: eth0@if17: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
    link/ether ba:35:96:c8:e5:49 brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet 10.242.4.5/24 brd 10.242.4.255 scope global eth0
       valid_lft forever preferred_lft forever
15: eth1@if16: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
    link/ether 76:5e:de:13:57:a5 brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet 172.20.1.3/32 scope global eth1
       valid_lft forever preferred_lft forever
```

おお、`eth1` が追加で設定されているようです。

ルーティングは以下のようになっていました。

```bash
$ kubectl exec -ti testapp-0 -- ip route
default via 10.242.4.1 dev eth0
default via 172.20.1.247 dev eth1 mtu 1500
10.242.0.0/16 via 10.242.4.1 dev eth0
10.242.4.0/24 dev eth0 proto kernel scope link src 10.242.4.5
172.20.1.247 dev eth1 scope link
```

以下の二つのルーティングはFlannelの設定ファイル由来ですね。

- default via 10.242.4.1 dev eth0
- 10.242.0.0/16 via 10.242.4.1 dev eth0

Flannel の設定ファイルをノードで確認してみます。

```json
$ cat /etc/cni/net.d/10-flannel.conflist
{
  "name": "cbr0",
  "cniVersion": "0.3.1",
  "plugins": [
    {
      "type": "flannel",
      "delegate": {
        "hairpinMode": true,
        "isDefaultGateway": true,
        "isGateway": true
      }
    }
  ]
}
```

ここで、`isDefaultGateway` と `isGateway` が `false` になっていると上記のルーティングがなくなっていることが確認できるはずです。

FlannelのネットワークへのルーティングはFlannelが設定したNIC越しに、CiliumへのネットワークへのルーティングはCiliumが設定したNIC越しに利用する、ということがやりたければこのあたりの値を調整する必要がありそうです。

### Flannel + Bridge

TBD (自宅のネットワークを調整したらあとで書きたい)

## ユースケース

と、まあ、簡単にNICを2枚刺すことはできたのですが、ユースケースは限られそうです。

というのも、NICを2枚刺したいケースというのは、Kubevirt を利用して Kubernetes上のVMにKubernetesのPod Network外のNICを刺したい場合、とか、Longhornでストレージのネットワークを分けたい場合、とかが考えられるのですが、どちらもネイティブに対応しているのは [Multus-CNI](https://github.com/k8snetworkplumbingwg/multus-cni) だったりします。

苦肉の策で思いついたユースケースが CNI の Migration に使えそう、というものだったのですが、これはまた"移行期間中にPod間の接続でNATを許し"さえすれば、このように二つのCNIを共存させる必要はなさそうでした。

CNI の Migration については [Kubernetes Advent Calender 2022](https://qiita.com/advent-calendar/2022/kubernetes) の21日目に書く予定です。

## まとめ

KubernetesのPodに複数のNICを構成するのは、Multusを使わずとも簡単にできることがわかったが、ユースケースが見当たらない。

以上。