Kubernetes 网络插件 CNI 初探

仅仅是初步学习一下 CNI 的运作流程

Ref


CNI 作用

推荐这个视频 Youtube: Kubernetes Networking: How to Write a CNI Plugin From Scratch - Eran Yanay, Twistlock ,作者用 bash 写了一个 cni https://github.com/eranyanay/cni-from-scratch

假设 Pod 中只有一个容器,我对于 Kubernetes 启动一个 Pod 的流程的理解如下:

  1. 首先,会先启动一个 pause 容器,Pod 的各种命名空间都是由它来创建的。
  2. 调用 CNI 插件为这个网络命名空间进行相关配置,例如 ip、路由,并确保之后各个网络命名空间之间可以互相通信。
  3. 启动容器,将其将入到 pause 容器的网络命名空间中。

所以我觉得,CNI 的本质作用是对一个网络命名空间做一些配置,并在后续管理容器流量,确保集群中各个网络命名空间之间可以互相通信。

同一宿主机上不同网络命名空间通信最简单是采用 bridge 模式,也就是使用 veth pair 将所有的网络命名空间都与宿主机网络命名空间中的一个网桥连接,这个网络起到了一个交换机的作用。关于如何模拟这种行为的见我之前写过的一篇博文 veth + iptables 模拟 Docker 网络 Bridge 模式

CNI 的官方 Github 仓库给出了 CNI 的规范 https://github.com/containernetworking/cni/blob/master/SPEC.md,主要内容大致分为两部分:

  1. CNI 网络插件与调用者交互的方式和输入输出格式
  2. CNI 网络插件需要实现的功能

在仓库 https://github.com/containernetworking/plugins 中给出了一些样例插件,例如 bridge https://github.com/containernetworking/plugins/blob/master/plugins/main/bridge。网上还有一些供人学习的 CNI 样例插件,比如说这个用 Bash 实现的 https://github.com/eranyanay/cni-from-scratch


使用 CNI

这里做一个实验,实验不需要搭建 Kubernetes 集群,流程如下:

  1. 首先,创建两个网络命名空间 ns-2,ns-3
  2. 使用 CNI 网络插件 bridge 对 ns-2,ns-3 进行配置
  3. 使用 containerd 启动一个 nginx 容器并加入至 ns-2 中
  4. 测试在宿主机以及 ns-3 中是否可以访问 ns-2 中的 nginx
1
2
3
4
5
6
7
8
┌─────────────────────────────────────────────┐
│ ns-2 ns-3 │
│ ┌───────────────┐ ┌───────────────┐ │
│ │ ┌─────────┐ │ │ │ │
│ │ │ Nginx │ │ │ │ │
│ │ └─────────┘ │ │ │ │
│ └───────────────┘ └───────────────┘ │
└─────────────────────────────────────────────┘

环境:ubuntu 16.04,root用户

创建命名空间

1
2
ip netns add ns-2
ip netns add ns-3

检查是否创建成功

1
2
3
ip netns list
# 或
ls /var/run/netns/

使用 CNI 进行配置

编译 https://github.com/containernetworking/cni 中的插件过程在链接仓库的 README.md 中有详细说明,这里不再赘述。这里 CNI 插件最终存放在 /opt/cni/bin 目录下

这里使用 https://github.com/containerd/go-cni 对 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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
package main

import (
"context"
"flag"
"fmt"
"log"
"os"
"os/signal"
"strconv"

gocni "github.com/containerd/go-cni"
)

var idF = flag.Int("id", 2, "网络命名空间的编号")

func init() {
flag.Parse()
}

func main() {
id := *idF
netns := fmt.Sprintf("/var/run/netns/ns-%d", id)

l, err := gocni.New(
gocni.WithMinNetworkCount(2),
gocni.WithPluginDir([]string{"/opt/cni/bin"}),
gocni.WithInterfacePrefix("eth"))
if err != nil {
log.Fatalf("failed to initialize cni library: %v", err)
}

if err := l.Load(gocni.WithLoNetwork, gocni.WithConfListFile("/etc/cni/net.d/10-c.conf")); err != nil {
log.Fatalf("failed to load cni configuration: %v", err)
}

ctx := context.Background()
defer func() {
if err := l.Remove(ctx, strconv.Itoa(id), netns); err != nil {
log.Fatalf("failed to teardown network: %v", err)
}
}()

result, err := l.Setup(ctx, strconv.Itoa(id), netns)
if err != nil {
log.Fatalf("failed to setup network for namespace: %v", err)
}

for key, iff := range result.Interfaces {
if len(iff.IPConfigs) > 0 {
IP := iff.IPConfigs[0].IP.String()
fmt.Printf("IP of the interface %s:%s\n", key, IP)
}
}

ch := make(chan os.Signal, 1)
signal.Notify(ch, os.Interrupt)
<-ch
}

配置文件 /etc/cni/net.d/10-c.conf 如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
"cniVersion": "0.4.0",
"name": "hdls-net",
"plugins": [
{
"type": "bridge",
"bridge": "cni0",
"isGateway": true,
"ipMasq": true,
"ipam": {
"type": "host-local",
"subnet": "10.22.0.0/16",
"routes": [
{ "dst": "0.0.0.0/0" }
]
}
},
{
"type": "firewall",
"backend": "iptables"
}
]
}

打开两个终端分别执行如下命令对网络命名空间进行配置

1
2
./program -id 2
./program -id 3

会看到相关的 ip 输出,我这里输出的分别是是10.22.0.6 和 10.22.0.7

上述配置文件中添加了 “filewall” 的原因是 https://serverfault.com/questions/162366/iptables-bridge-and-forward-chain ,可以执行如下命令插件新增的 iptable filter 规则:

1
iptables -t filter -L -n

部分输出如下

1
2
3
4
5
6
7
8
9
10
11
Chain FORWARD (policy ACCEPT)
target prot opt source destination
CNI-FORWARD all -- 0.0.0.0/0 0.0.0.0/0 /* CNI firewall plugin rules */

Chain CNI-FORWARD (1 references)
target prot opt source destination
CNI-ADMIN all -- 0.0.0.0/0 0.0.0.0/0 /* CNI firewall plugin rules */
ACCEPT all -- 0.0.0.0/0 10.22.0.6 ctstate RELATED,ESTABLISHED
ACCEPT all -- 10.22.0.6 0.0.0.0/0
ACCEPT all -- 0.0.0.0/0 10.22.0.7 ctstate RELATED,ESTABLISHED
ACCEPT all -- 10.22.0.7 0.0.0.0/0

创建容器

containerd 是一个容器运行时,它提供了相关的 SDK 供第三方程序与其交互。containerd 的二进制程序可以直接从 github 上下载它编译好的文件,或者直接自己编译,具体过程不再赘述。

启动容器的代码如下,注意,运行此代码时 containerd 需要启动,且这里已经写死了容器使用的网络命名空间是 “/var/run/netns/ns-2” 以及使用的容器镜像是 “docker.io/library/nginx:alpine”

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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
package main

import (
"context"
"fmt"
"os"
"os/signal"
"syscall"

"github.com/containerd/containerd"
"github.com/containerd/containerd/cio"
"github.com/containerd/containerd/log"
"github.com/containerd/containerd/namespaces"
"github.com/containerd/containerd/oci"
"github.com/opencontainers/runtime-spec/specs-go"
)

func main() {
if err := createAndRunContainer("/var/run/netns/ns-2", "app1", "docker.io/library/nginx:alpine"); err != nil {
log.G(context.TODO()).Errorf("%+v\n", err)
}
}

func createAndRunContainer(netNSPath string, containerID string, image string) (err error) {
var (
client *containerd.Client
appImage containerd.Image
app1 containerd.Container
task containerd.Task
exitStatusC <-chan containerd.ExitStatus
)

if client, err = containerd.New("/run/containerd/containerd.sock"); err != nil {
return err
}
defer client.Close()

ctx := namespaces.WithNamespace(context.Background(), "example")
if appImage, err = client.Pull(ctx, image, containerd.WithPullUnpack); err != nil {
return err
}

app1, err = client.NewContainer(ctx, containerID,
containerd.WithImage(appImage),
containerd.WithNewSnapshot(fmt.Sprintf("%s-snapshot", containerID), appImage),
containerd.WithNewSpec(
oci.WithImageConfig(appImage),
oci.WithLinuxNamespace(specs.LinuxNamespace{
Type: specs.NetworkNamespace,
Path: netNSPath,
}),
),
)
if err != nil {
return err
}
defer app1.Delete(ctx, containerd.WithSnapshotCleanup)

if task, err = app1.NewTask(ctx, cio.NewCreator(cio.WithStdio)); err != nil {
return err
}
defer task.Delete(ctx)

if exitStatusC, err = task.Wait(ctx); err != nil {
return err
}

go func() {
ch := make(chan os.Signal, 1)
signal.Notify(ch, os.Interrupt, os.Kill)
<-ch
_ = task.Kill(ctx, syscall.SIGTERM)
}()

if err = task.Start(ctx); err != nil {
return err
}

_, _, err = (<-exitStatusC).Result()
return
}

网络连通性测试

从 ns-3 中访问 nginx

1
ip netns exec ns-3 curl 10.22.0.6

从宿主机中访问 nginx

1
curl 10.22.0.6

CNI 都干了啥

这里跳过了处理输入和输出的步骤,仅仅是大致模拟一遍 bridge 模式的网络配置流程。需要使用到 ip、brctl 工具。仅模拟配置 ns-2 的过程(没有配置 DNS)

首先,如果 cni0 不存在的话先要创建个 cni0 网桥,并将网桥配置为网关

1
2
3
4
brctl addbr cni0
ip link set cni0 up
ip addr add 10.22.0.1/16 dev cni0
route add -net 10.22.0.0/16 dev cni0

随后,创建 veth pair,一边放到 ns-2 网络命名空间中,另一端连在 cni0 网桥上

1
2
3
4
ip link add eth0 type veth peer name veth_ns-2
ip link set eth0 netns ns-2
ifconfig veth_ns-2 up
brctl addif cni0 veth_ns-2

然后进入到 ns-2 中配置 ip 和路由,这里忽略了调用 ipam 生成 ip 的过程

1
2
ip netns exec ns-2 ifconfig eth0 10.22.0.6/16 up
ip netns exec ns-2 ip route add default via 10.22.0.1 dev eth0

随后对 iptables nat 的 masquerade 做配置,确保它能与外部网络通信

1
iptables -t nat -A POSTROUTING -s 10.22.0.6/16 -j MASQUERADE

由于还添加了 firewalls 插件,它会允许同一节点上不同网络命名空间之前的通信

1
2
iptables -A FORWARD -s 10.22.0.6/16 -j ACCEPT
iptables -A FORWARD -d 10.22.0.6/16 -j ACCEPT

对于 ns-3 同理,配置如下,不再详细介绍了

1
2
3
4
5
6
7
8
9
ip link add eth0 type veth peer name veth_ns-3
ip link set eth0 netns ns-3
ifconfig veth_ns-3 up
brctl addif cni0 veth_ns-3
ip netns exec ns-3 ifconfig eth0 10.22.0.7/16 up
ip netns exec ns-3 ip route add default via 10.22.0.1 dev eth0
iptables -t nat -A POSTROUTING -s 10.22.0.7/16 -j MASQUERADE
iptables -A FORWARD -s 10.22.0.7/16 -j ACCEPT
iptables -A FORWARD -d 10.22.0.7/16 -j ACCEPT

跨节点还需要做额外的路由配置,例如

1
ip route add <other node ns cidr> via <host ip> dev <host if>