基于 ebpf 和 vxlan 实现一个 k8s 网络插件

977 阅读1小时+

前言

在去年很早的时候,我更新了

《k8s 网络之深入理解 CNI》《K8s 网络之从 0 实现一个 CNI 网络插件》

其中详细地介绍了 k8s 的 cni 网络插件的工作原理以及自己动手实现了一个支持同节点同网段以及不同节点不同网段通信的 cni 插件并放到集群里跑了一把。在文章中也说过“后面有机会会尝试实现 vxlan 版”(只是立个 flag,本来以为没机会的),这次正好趁着辞职在家,就简单了解了一下 cilium,打算尝试参考 cilium 的原理来实现一个基于 ebpf 和 vxlan 的 cni 网络插件。


和上一版的区别

上一版实现 cni 插件中,主要是通过 veth-pair & bridge 实现的同节点之间通信,然后以 etcd 为依赖实现了一个 ipam 用来管理 ip 地址们,做到了简单的 host-gw 这种动态路由的方式,从而实现了不同节点之间的通信。

本次我们将在代码中添加一个新的 "ebpf & vxlan" 模式,做到同节点上依赖 ebpf 通信,不同节点不同网段依赖 ebpf & vxlan 来实现网络通信。另外上次的版本中 host-gw 的模式是直接写死在代码中的,这样很不灵活,因此本次更新将其改为了以注册插件的方式来注册不同的通信模式,方便以后再更新。


一些名词

由于想基于 vxlan 和 ebpf 来实现网络通信的难度要比上一版中的 host-gw 模式要难得多的多,所以本次涉及到的一些概念也会比较多。

主要可能涉及到 cilium、ebpf、vxlan、traffic control、c 语言与 clang 等。

由于上边提到的每个东西单拿出来都能出一本书,所以我们没有办法把每个东西都做特别详细的介绍或解释(当然了,主要是太深的东西我也不懂哈哈哈哈哈)。不过我会尽量把涉及到的知识点简单介绍一下,目的是给完全不知道的朋友做一下简单扫盲。(P.S. 里边很多东西还挺有意思的,感兴趣的朋友建议深入去了解一下~)

cilium

cilium 是 k8s 网络生态中的一个完全基于 ebpf 实现的网络插件。这玩意儿老生猛了,其不但支持最基本的 pod 之间通信,甚至还支持 L4/L7 层等负载均衡,甚至把 kube-proxy 给干掉了,同时其还自带 service mesh、DNS Proxy、网络追踪等各种乱七八糟你想得到想不到的功能它都有,着实是生猛!

另外由于其是基于 ebpf 的,所以在功能上能实现跳过一些协议栈以及一些过滤阶段等,因此性能上来讲会比其他一些通俗的网络插件要好。

不过他也不是完全没缺点,其主要缺点就在于对内核的要求非常之高,因为其会用到很多 linux 内核高版本才有的功能,同时安装也挺费劲的。它的官网上提供了数十种安装方式,我反正折腾了好久好久才部署好。另外就是 cilium 由于用了很多 ebpf 的方法,导致其非常复杂,不管是代码实现还是流量走向的观察都比较复杂,不像是 flannel,看看主机路由表啥的就能把流量走向看个大概的。

下面两张图分别是 cilium 的概览以及通信原理的描述,看着费劲没关系,后边咱们尝试自己实现,现在简单了解一下就行(图片来自于网络):

eBPF

大家可能经常会看到 bpf 和 ebpf 被放到一起说,这俩有啥关系呢?其实 bpf 就是 ebpf 的早期版本,也叫 classic bpf(cbpf)。它是很早很早以前被用来处理网络数据包过滤的一种技术,全称叫 “伯利克包过滤”。现在新版本的 Linux 内核中已经干掉了 cbpf,取而代之的是 ebpf,ebpf 相较于 cbpf 功能要更强大。目前的 ebpf 已经不仅仅能用在网络数据包过滤了,还能用在调试追踪等方面。

ebpf 的目的就是为了让开发者能在即使对 Linux 内核是黑盒的情况下,也能安全并且较为容易地对操作系统内核进行功能拓展。说白了,ebpf 就是你自己在用户态写个程序,然后用一些方法,能把你写的这个程序插入到内核的固定地方,然后内核执行到这个地方的时候就会触发你自己写的函数。

写过前端的同学肯定用过 webpack 这个东西,我个人感觉 ebpf 就和 webpack 有点像。就是 Linux 在内核中很多位置放了类似“点位”的东西,然后大家在用户态写个程序,程序中标注好要把这个程序插入到哪个“点位”上,然后 Linux 执行到这个点位的时候就会去执行用户写的方法。无论是内核的函数亦或是用户态自己写的程序,都可以有这种“点位”,以便让 ebpf 程序来插入进去。我的环境中的 Linux 版本是 5.15,其大概支持十万个(没错有个万字)内核函数的“点位”插入(bpftrace -l 可以查看当前内核支持插入“点位”的内核函数都有啥):

我这儿有张图,比较清晰地描述了 ebpf 涉及到的一些东西(图片来自于网络):

我们来简单解释一下图上面和 ebpf 相关的几个东西:

  1. eBPF 辅助函数: 可以由 c 代码调用。但是不同类型的 eBPF 程序只能调用属于自己类型的 helper 函数集。所谓的 “eBPF 的类型” 就是 ebpf 程序有很多不同的类型,比如“往内核函数里插个探针”是一种类,“拦截 socket”是一种类型等等,就是每种类型的 ebpf 程序只能在自己的一亩三分地里干自己的时候,能调用的帮助函数也仅仅局限于这个类型能使用的范围之内。而这个 “ebpf” 的类型要在写 ebpf 程序的时候通过 elf 文件 section 进行声明。

  2. eBPF 验证器:它用于确保 eBPF 程序的安全。验证器会将待执行的指令创建为一个有向无环图(DAG),确保程序中不包含不可达指令;接着再模拟指令的执行过程,确保不会执行无效指令。说白了就是内核不可能让上层的开发者随便开发啥程序,一定是那种不能威胁到内核安全的程序才能被塞到内核里。啥叫不安全的程序呢,比如死循环等。

  3. 存储模块:由 11 个 64 位寄存器、一个程序计数器和一个 512 字节的栈组成。R0 寄存器用于存储函数调用和 eBPF 程序的返回值,这意味着函数调用最多只能有一个返回值;R1-R5 寄存器用于函数调用的参数,因此函数调用的参数最多不能超过 5 个;而 R10 则是一个只读寄存器,用于从栈中读取数据。说白了就是你用 c 代码写的 ebpf 程序,被 clang 编译成 ebpf 的类型之后,其实不是通俗的可执行二进制格式,而是一种类似于 java 编译成 .class 之后的字节码,这些字节码里头会用到一些寄存器。这些字节码要被 JIT 编辑器加载。

  4. JIT 编译器:将 eBPF 字节码编译成本地机器指令,以便更高效地在内核中执行。

  5. BPF 映射(map):提供大块的存储。这些存储可被用户空间程序用来进行访问,进而控制 eBPF 程序的运行状态。这个 map 是用户态和内核空间在 ebpf 的场景下交流的唯一渠道。说白了就是内核专门提供了一种 KV 存储,你能在用户空间用一些系统调用往这些 map 里写一些 kv,也能在你的 ebpf 加载到内核空间后,通过一些系统调用从这些 map 里拿到你在用户空间写入的那些 kv 值,双向地可读可写。不过 ebpf 程序想要读取 map 中的数据还需要被 “pin” 一下。啥叫 “pin” 一下呢?后面写代码的时候我们再说。

总结一下 ebpf:简单理解就是可以自定义一些程序,然后把这些程序塞到内核的固定的“点位”,等内核执行到这些“点位”的时候,就会触发你自己写的这些 ebpf 程序。然后用户控件和 ebpf 程序之间的通信主要靠内核提供的一个 kv 数据库来通信。另外在 ebpf 程序真得被加载到内核之前还要经过非常严格的校验。

另外我们在本次实现的代码中会用到的 ebpf 相关的东西,等写到了我们再详细说。

eBPF 是个非常牛叉的东西,同时也是个挺难的玩意儿(个人觉得挺难的),本文中对 ebpf 程序的实践也仅仅只是“浅尝辄止”。真正想把 eBPF 学习透还是需要经过大量的学习与实践才行。不过虽然这玩意儿挺复杂的,但是一旦当你真正掌握了它,那它一定是你日后工作开发中的什么性能优化啊,链路追踪啊,问题排查啊之类的“意大利炮”~

vxlan

相比于上边 cilium 和 ebpf 那俩祖宗,vxlan 算是比较好理解的一个东西了。不过也只是广义上的“好理解”,真正想细致入微地理解这玩意儿也挺费劲的。

我这里给大家提供一个华为的“交换机技术手册”中对 vxlan 的解释,这个是真的贼专业的文档,感兴趣的建议大家读读看:support.huawei.com/enterprise/…

我这里简单解释一下 vxlan 是个啥。

vxlan 是一种隧道技术(tunnel),最初的应用场景是用来做多数据中心虚拟机热迁移的。举个例子,原来你有个“数据中心 A”,用户通过 “192.168.1.1” 访问该数据中心 A 里的某个服务。突然有一天老板要给该数据中 A 集体换新的物理机,换新机你得把服务停了呀,但是用户肯定不干呀,于是就要临时把原来数据中心 A 中那对乱七八糟的虚拟机给临时挪到 “数据中心 B”,言外之意就是临时先在 “数据中心 B” 启动一个和之前数据中心 A 一毛一样的服务,但是由于 “数据中心 A” 可能在北京,而 “数据中心 B” 可能在上海,所以如果什么也不做的话,你在北京用 “192.168.1.1” 肯定是访问不到 “数据中心 B” 中的服务了。这个时候 vxlan 就派上用场了。vxlan 能干啥呢?它可以给你原始的数据包外头包一层 UDP。也就是说你原来的数据包可能无法直接访问到 “数据中心 B”,但是给这个数据包外头包一层 UDP,只要你能保证北京和上海的俩数据中心是三层路由可达的(所谓三层路由可达这里可简单理解成就是咱们日常上网那种通信方式),那么这个 udp 包就能被平安从北京送到上海,然后到了上海的数据中心 B,再反向地把这层 udp 给卸下来,只把里头原始的访问 “192.168.1.1” 的那个数据包给暴露出来,这样就好像是用户还在访问和原来一样的服务了。

总结一下:vxlan 是一种隧道技术,它在你的原始数据包外头包一层 UDP,只要 UDP 数据包能到达的地方,就可以把原始数据包带过去,然后再暴露出来,暴露出来之后可以再由所到主机上的设备进行处理,比如进行路由转发啊,或者其他什么操作。

和 vxlan 类似的还有其他什么 vlan,这个是把网桥给虚拟化成多个广播域的技术,还有什么 ipvlan,macvlan 之类的,这些是网卡虚拟化技术,总之各种乱七八糟的通信技术还有很多,日后有机会再慢慢学吧~

TC(traffic control)

TC 的全称叫 “traffic control”,也就是流量控制,是 linux 中一种能控制数据包流速、走向的技术。和 tc 相关的几个点有 “queueing discipline (qdisc)”、"class"、"classifier"、"action"。

我们来简单介绍一下:

  1. queueing discipline (qdisc):排队规则,根据某种算法完成限速、整形等功能

  2. class:用户定义的流量类别

  3. classifier (也称为 filter):分类器,分类规则

  4. action:要对包执行什么动作

说白了就是:

你可以给某个网络设备创建一条 qdisc, 相当于一条总的 queue,你可以把这条 queue 想象成一条 “大管子”(qdisc)

所有经过这个网络设备的数据包都要进入到这条 “大管子” 中

然后还可以给某个网络设备创建一堆 class,相当于一堆 “小管子”

这些“小管子”是插在“大管子”上的

这些“小管儿”可以由用户定义一些“规则”(比如这个管儿中的包速率应该是多少 等)

这些“规则”就是一堆的 filter(classifier)

你可以想象成 “小管子” 和 “大管子” 的连接处,有一堆 “滤嘴”

这些 filter(滤嘴) 可以按照一定的规则(比如目标 ip, 源 ip 等)

根据这些规则把不同的包给漏到不同的 “小管儿” 中

在漏过去之后,“小管儿” 还可以选择一些 action(比如直接丢弃, 或者重定向 等)

具体是 tc 命令使用方法大家可以自行搜索,我这里不再过多地啰嗦。

Clang

clang 编译器大家肯定都知道,负责把 c/c艹 给编译成可执行的二进制文件。这里我想说的是,clang 还可以把一段 c 程序给编译成 ebpf 程序。也就是说,普通的 c 代码编译完的二进制程序不能作为 ebpf 程序被加入到内核,只有通过特定的写法写出来的 c 代码,并且通过 clang 编译器指定了相关参数的情况下,编译出来的东西才能被作为 ebpf 程序加载到内核。

这里就是简单提一下,后边写代码就能看到了。


数据包走向

这里我们放一张图(图片来自于网络):

为了后边大家能清楚地知道我们用 tc 是要干啥,以及 ebpf 要安装的位置,我们来解释一下这张图:

首先 DPDK 这玩意儿就不说了。

然后是 XDP。我不知道大家是否知道,当网卡收到数据包后,会把数据包处理成一个内核数据结构叫做 “sk_buffer” 的东西。不知道大家是否清楚,如果不了解的话,我这里简单说一下 “skb” 这个东西。

首先网卡经过硬中断和软中断,软中断从网卡的收包队列中捞数据,然后把数据塞到 skb 这个数据结构中,然后把 skb 送入链路层以及后边的协议栈层。每层都通过移动 skb 这个数据结构中的指针来找到对应层的头信息和 payload。最后把 skb 放到和用户态相关的 socket.sock 数据结构中,用户态再通过 read 或者 recv 之类的方法去拿这个数据。

大概 skb 就是这么个玩意儿,简单来讲它就是对面主机发过来的协议,里边有什么 mac 头, ip 头,udp 头,tcp 头,还有用户态数据等等,这个要经过一系列的内核处理最后被送到用户态从而被使用。

那 skb 和 XDP 有什么关系呢?其实没关系,因为当网卡收到数据包往内核送的时候,其实第一关走的不是协议栈,走的是这个 XDP。XDP 可以说是除了网卡之外,第一个会收到数据包的东西。它是由网卡驱动实现的程序,当数据包交给这个 XDP 驱动程序之后,其实数据包还不是 skb 的格式,就是一整个非常非常原始的数据包。然后 XDP 程序可以决定要怎么处理这坨数据。在 cilium 中其实也对 XDP 有应用,主要是用 XDP 做负载均衡的,其性能顶呱呱。你想啊,这 XDP 几乎就是当前主机除了网卡之外,能第一个拿到网络数据包的程序,那在它这儿直接把数据包做转发或者丢弃,那性能肯定比数据包都被处理成 skb 送到内核协议栈了要快很多。

所以简单来讲,XDP 是一个网卡驱动中的程序,它能拿到第一手的网络数据包,而且是最原始的那种数据包。

当 XDP 拿到数据包后,如果它不做任何处理直接把数据包给往内核送的话,那么接下来这个原生的数据包就会被处理成一个 skb 数据结构。

你可以简单地想象成一个原始的数据包,从网卡出来,走过了 XDP 后,就变成了 skb 这种数据结构(这里的意思不是说是 xdp 把数据包处理成 skb 的哦)。

然后 skb 这个数据结构,就会被内核代码送往下一站。那么下一站是哪儿呢?是内核协议栈么?答案是否定的

skb 从 XDP 出来后的下一站,其实就是咱们上文中说到的 tc,也就是那个 traffic control 那里。

traffic control 分为 ingress 方向和 egress 方向,很好理解,就是一个是数据包进来时的方向,一个是数据包要出去的方向。

所以这里 skb 从 XDP 出来后,会进到 tc 的 ingress 方向的“管子”,这根儿“管儿”里可以决定对 skb 的一些处理比如丢弃或者重定向或者限流等等。

如果 skb 能平安无事地从 tc 中出来,它才会被真的送往协议栈。不过在协议栈的三层网络层前后,还要经过大名鼎鼎的 netfilter 也就是 iptables 的处理规则过滤。关于 netfilter 和 iptables 相关可以参考这篇文章:

《浅入浅出 iptables 原理:在内核里骚一把 netfilter~》

所以简单总结一下数据包的流向,大概就是:

网卡 → 网卡驱动中的 XDP → tc → netfilter(协议栈)→ 用户态

这样就和上边的图对应起来了。了解了这些,也方便我们后边写代码。


cilium 的实现原理

在写代码之前,我们先简单了解一下 cilium 的实现原理,因为我们的 cni 是参考 cilium 实现的,所以了解了 cilium 的大概原理后写代码就能事半功倍了。

同节点网段通信

咱们先来看一张图,(该图片来源于网络):

图片上的其实就是 cilium 的同节点 pod 的通信原理。我来给大家简单解释一下。

首先 pod1 的数据从 veth 出来走到 host 上的 veth,之后有多种做法可以将数据包发给同主机上的 pod2 中的 veth。比如将每一对儿 veth pair 都插到同一个网桥下,或者主机配置双方的路由表等。

而这里 cilium 采用的方式是用 ebpf。

我们在上边说了,ebpf 提供了数据包的拦截功能。其实这个拦截功能这里要配合 tc 一起使用。在上边介绍 tc 的时候,我们说过 tc 可以比协议栈提前拿到 skb 数据结构对吧,所以 cilium 在这里,给 pod1 留在 host 上那半拉 veth 这个网络设备的 ingress 方向,打上了一个 tc,你可以想象成给 veth 前边加了根儿管子。当 skb 从 pod1 中出来时会先流到这根儿管儿里。

然后 tc 同时还可以配合着 ebpf 一起用。我们上边说过,内核中留了很多点位可以让你插入 ebpf 程序是吧,很妙的是,tc 的 ingress 方向和 egress 方向恰好就能插入 ebpf 程序。

所以 cilium 在数据包从 pod1 出来的时候,在 veth 的 tc 上加了个 ebpf 程序用来去拦截 skb 数据包。我们上面在介绍 ebpf 时也说过,ebpf 提供了很多 helper 函数对吧,其中就有一些 helper 函数,可以帮助将 skb 给重定向到其他网络设备。

说到这儿是不是能想到什么,是的,就和图片上一样,当数据包从 pod1 出来走到外边的 veth 从而走到 tc ingress 方向上的 ebpf 程序时,ebpf 程序通过其内核提供的 helper 函数,直接将该 skb 数据包给导入到了在 pod2 中的那半拉 veth。注意,都不是导入到 pod2 留在 host 上的那半拉 veth,是直接进了 pod2,也就是直接把 skb 给导入到了 pod2 所在 netns 中!

nb 吧!什么主机协议栈,什么 netfilter,什么路由子系统啥的,统统不走,没有中间商赚差价,一步到位!

反之也同样,当 pod2 中的数据包返回时,也通过 host 上的 veth 的 tc ingress 方向上的 ebpf 程序,给把流量直接干到 pod1 在 netns 中的那半拉 veth 上。

就很强!

不过这里需要注意的是,图片上也画出来了,能直接把数据包给重定向到在 netns 中的 veth 的功能,只有在 linux 5.10 以上的版本中才支持,这个函数叫 “bpf_redirect_peer”,后边我们写代码时候会看到。

如果在 5.10 以下的版本中呢,其实也没差多少,只是换了另外一个方法叫 “bpf_redirect”,虽然流量没法直接干到 netns 中,但是能直接干到对方 pod 留在 host 上的那半拉 veth 也不亏!

以上就是 cilium 中通节点中的 pod 通信原理。我们简单总结一下:当流量从 pod1 中出来先经过 pod1 留在主机上的那半个 veth,这个 veth 被打了个 tc,相当于流量进去到 veth 前要先进入一个水管,然后这个水管又被打上了 ebpf 程序,ebpf 程序可以拦截到这个 pod1 发出来的 skb 包,然后通过内核提供的 “bpf_redirect_peer” 函数(或 bpf_redirect)直接将 skb 重定向到 pod2 所在 netns 中的 veth(或 pod2 留在主机上的那半拉 veth)上。反之也如此。

不同节点不同网段通信

对于不同网段不同节点的 pod 之间通信,我们也来看一张图(图片来自于网络):

对于不同节点之间 pod 的通信,有了上边的铺垫,现在大家首先能想到的是,当流量从 pod1 出来走到其留在 host 上的那半拉 veth 之前,会先进去到 tc 这条水管中的 ebpf 程序里。这条 ebpf 程序中发现如果目标 ip 是本机其他 pod 的 ip,那就直接重定向流量到本机的目标 pod 的 netns 中的 veth 上。

那么如果在 ebpf 中发现目标 ip 不是本机 pod,而是其他节点上的 pod 的话,那该如何呢?

我们来看图片,其实当 ebpf 发现目标 ip 并不在本机上时,会将该 skb 给重定向到本机上一个 vxlan 设备上。

我们在上面是不是介绍过,vxlan 是一种 tunnel 技术,能够给原始数据报文外头包一层 udp,两台计算机之间通过路由可以让 udp 互相可达,那么包裹在 udp 中的原始数据包报文就能被平安送到目标主机上。

所以 cilium 这里对于不同网段的通信处理方法就是,当 veth 上的 tc 的 ebpf 程序发现目标 ip 是其他主机时,将 skb 重定向到这个 vxlan 设备上,由这个 vxlan 设备给包裹一层 UDP。

这个 UDP 的源地址,就是 pod1 所在的主机对外网卡的 ip 地址,目标地址就是 pod2 所在的主机对外网卡的 ip 地址。

接下来这个 udp 被 pod1 所在的主机给发走,此时的 udp 就是个非常普通的 udp 数据报文,你用户态的什么 telnet 或者 dns 之类的 udp 程序怎么被发送,这个 udp 就怎么被发送。

然后当这个 udp 报文被 pod2 所在的主机成功接收之后,vxlan 设备的默认处理端口是 8472,这个端口号不需要你自己配,也不需要修改,只要某台主机自己发现了某个数据包的目标 port 是 8472,那么就默认把该数据包交给监听着 8472 端口的 vxlan 处理程序去解封装。

所以等此时 pod2 上的 vxlan 设备再拿到 pod1 主机发来的数据包时,已经是一个解封装后的样子了,也就是一个 pod1 中发出来的原始的数据报文了。

接下来这个 vxlan 设备在其 ingress 方向上,绑定一个 ebpf 程序,该 ebpf 程序可以通过内核提供的 helper 帮助函数,将该 skb 给重定向到 pod2 所在的 netns 中的那半拉 veth 上。反之也如此。

如此如此,打完收工。

以上就是 cilium 通过 vxlan 来实现两个不同节点之间不同网段的 pod 通信的大概原理。

简单总结一下的话:数据包从 pod1 出来被 pod1 在主机上的 veth 上的 tc 上的 ebpf 给拦截到,然后 ebpf 程序发现目标是其他 node,就把数据包发送到本机的 vxlan 设备上,然后 vxlan 设备给该原始报文报上一层 udp,pod1 所在的主机可以把这个 udp 给发送到 pod2 所在的 host 上,并且目标端口是 vxlan 解封程序的默认端口。当数据包发送到 pod2 所在主机上时,8472 端口的 vxlan 处理程序把外头那层 udp 自动给解封,然后通过 vxlan 上的 ebpf 程序,将解封后的原始数据报文给送到 pod2 的 veth 上。


动手实现

到这里,我们就大概了解了 cilium 中实现网络通信的基本原理,同时也对 cilium 中用到的一些技术做了简单地描述。其实 cilium 的功能远不止如此,我们这里也仅仅只是介绍了其最常用也是最基本的两个功能,分别是同节点通信和不同节点通信,其实还有很多种通信场景,比如访问负载均衡,访问外网,访问普通的其他进程等等。我们没法全都实现一遍,因为太费精力了,我虽然目前跑路在家,又爽又有时间,但是钱也快花光了,所以还是得留点时间找下家金主爸爸。所以本次我们主要就实现 k8s 集群中最主要的两个功能,也就是同节点间 pod 通信以及不同网段的不同节点之间通信。

实验环境

虚拟机:multipass(mac 上启动 linux 虚拟机的软件,非常好用)

主要节点有三台虚拟机:

  1. cni-test-1: 192.168.64.17

  2. cni-test-2: 192.168.64.18

  3. cni-test-master: 192.168.64.19

Linux 环境: Ubuntu 22.04,内核版本:5.15 (建议 5.10 以上)

Kubernetes 版本:1.25.0

Golang 版本:1.19.1

Clang 版本:14.0.0

bpftool 版本(开发时不一定需要 bpftool,但是有它可以通过命令行去查看一些 ebpf 的信息):5.15.46

至于具体的环境就要大家自己去配置了。

其中 ebpf 环境安装可通过下面一行命令进行安装:

apt-get install -y make clang llvm libelf-dev libbpf-dev bpfcc-tools libbpfcc-dev linux-tools-$(uname -r) linux-headers-$(uname -r

另外 k8s 集群的环境可能会费劲一点,现在 k8s 默认的版本是 1.25,该版本中已经把 docker 干掉了,所以如果按照网上随便搜出来的那些早期版本的安装安装文档来一步步弄的话,大概率跑不起来。这里建议要么在配置时手动指定安装 1.24 以下的版本,要么安装 1.25 之后,把 kubelet 的启动配置改成用 containerd。


CNI 网络插件的开发方式

CNI 是如何被执行的

  1. kubelet 有个 rpc 的客户端(CRI Client),然后有个对应的 CRI Server 端叫 containerd(还有其他的实现)
  2. kubelet 在需要拉起一个 pod 时,会通过 grpc 的方式与 containerd 中的 server 端通信,告诉 containerd “我想拉起一个 pod”
  3. containerd 收到这个消息后会按照一定的流程去 “pull 镜像”,“创建 sandbox”,“创建 netns”,“启动容器”,“把容器加入到 sandbox” 等操作
  4. 但是 containerd 只是个“壳子”,真正去实现这些功能的地方在 runc(OCI)中(当然除了 runc 还有别的,比如 kata 等)
  5. 其中在 “把容器加入到 sandbox” 这步之前,会创建 netns 等网络环境,此时 containerd 会去主机的 “/etc/cni/net.d” 这个目录下读取 cni 的配置,该配置文件可以是用户自己手写也可以通过某些方式自动创建
  6. 读取完这个配置后,会在这个配置中拿到 “name”,然后拿着这个 name 去主机的 “/opt/kubernetes/cni/bin” 目录下找同名的二进制文件
  7. 找到了同名的二进制之后,containerd 将什么 “容器的 id”,“容器的 netns 命名空间”,“网卡名字”,“pod name” 等等信息以参数的形式传给这个二进制文件
  8. 同时将在 “/etc/cni/net.d” 下获取到的配置文件的信息以标准输出的形式传给这个二进制文件
  9. 这个二进制文件就是我们要实现的 cni 插件
  10. . 这个二进制可执行文件中要根据传进来的这些参数和配置,实现一套完整的通信机制

CNI 的开发框架

cni 是有一套开发模板的。基本上大部分官方实现的或者社区实现的 cni 插件都是这种模式:

上面这个是官方提供的 bridge 插件的源码,其中最最主要的是实现了 "main" 入口,"cmdAdd","cmdDel" 方法。

然后把 cmdAdd 和 cmdDel 通过 “skel.PluginMain” 方法给做个注册。

containerd 在调用 cni 这个二进制时会给他传 “ADD” 或者 “DEL” 这种参数,然后对应的参数就会去执行对应的 “cmdXXX” 方法。

比如当创建了一个 pod 时,就会调用这个二进制同时传一个 “ADD” 参数进来,然后这个 “cmdAdd” 方法中要实现所有的网络配置相关的事情。最后要生成一个 result。

这个 result 中包含了当前 pod 的 ip 地址,以及当前节点的网关等信息。这些信息要通过标准输出的方式做一个输出。containerd 会自动读这些信息。

这里我们不深入分析其源码了,在之前的文章中已经分析过了,感兴趣的同学可以回过头去看一看。


动手实现自己的 cni 之目录结构

在我之前的文章中,我们自己实现了一个基于 host-gw 方式的插件。这种模式相对来说比较简单,但是本次要实现的 vxlan & ebpf 就比较复杂了,所以对于上一个版本的代码,有不少修改和添加的地方,下面我来简单给大家介绍一下。

目录结构

首先我们先来看下目录结构:

介绍一下其中比较重要的几个:

client:这是一个通过 golang 自带的 “http” 包实现的轻量的 k8s 客户端。说白了就是里头是直接调用了 k8s 的 api-server 的 api 去集群拿一些东西。那为啥我不直接用 “client-go” 呢?其实因为我需要直接在 k8s 集群里拿的信息没多少,可能就一个 node 信息,但是我试过把 client-go 引入进来之后,这玩意儿编译完本身就有 16M。所以我就自己实现了个简单的客户端,只要够我用就行了

cni:提供了一个 cni 插件注册机制。由于在上一版的 host-gw 模式中,我是直接把代码都写死在 main.go 中了,后边想要拓展挺麻烦的,所以这次索性就改成了注册机制,这个 cni 目录下暴露了一个 Register 方法和一个 interface,只要在插件实现了这个 interface 就能把自己注册给这个 CNIManager。然后 main.go 中会根据配置文件中的配置信息自动执行不同类型的 cni 插件

这是自己实现的一个用来管理 ip 地址的客户端,主要基于 etcd 和上边那个 k8s 客户端来完成一些 ip 地址的分配,释放等等。该 ipam 的代码实现较多,但是绝大部分功能我都在其测试文件中有所体现,对这部分实现感兴趣的同学可以通过测试文件去了解其具体提供的能力以及实现方式:

nettools:该 pkg 提供了很多网络设备的管理功能,包括比如“创建 veth pair”,“创建网桥”,“创建 vxlan”,“创建路由条目” 等等。大部分功能都在测试文件有体现,大家可以通过测试文件去了解其包含的功能:

plugins:该目录下就是真正去实现 cni 插件代码的地方,可以看到我把上一次实现的 hostgw 也放在了这里。包括本次要实现的 vxlan 模式也在里头。通过目录也能看出来,hostgw 一个文件就搞定了,但是 vxlan 模式又细分了很多结构。至于其中每个目录是干啥,后边再说:

main.go:就是上边咱们说的那个开发 cni 的 “架子” 代码,里边主要就是注册了 “cmdAdd” 等方法:

其中每个方法都是按照固定的套路去写的,比如下边这个 cmdAdd:
其基本的套路就是:
a. 从 containerd 传进来的 args 中把 “/etc/cni/net.d” 下配置文件的信息给捞出来
b. 从配置文件中获取要使用的插件模式,这里支持 “host-gw” 和 “vxlan” 两个模式
c. 把要使用哪个模式告诉上边介绍过的那个 CNIManager
d. 调用 BootstarpCNI 方法,就会自动执行对应模式的 cni 插件
e. 最后把插件的执行结果给打印到标准输出


动手实现自己的 cni 之实现思路

在上边我们就把一个 cni 插件的实现整体框架介绍完了,如果感觉细节上不太清楚的同学可以去参考一下前几篇文章。

接下来简单解释一下我实现 vxlan 和 ebpf 模式的思路。

回顾一下 cilium

我们在上面简单介绍了 cilium:

  1. 同节点间,pod1 的流量出来走到主机上的 veth

  2. 这个 veth 前边被安装了个 tc,相当于一个水管

  3. 然后这个水管又被安装了一个 ebpf 程序

  4. 当流量走到这个水管的时候就被 ebpf 程序给拦截到了

  5. 然后 ebpf 发现如果目标是本机其他 pod 的 ip 就通过 epbf 提供的 helper 函数把流量转发给目标 pod

  6. 如果目标不是本机的 pod,ebpf 就将流量转发给 vxlan 设备

  7. vxlan 设备给数据包包一层 udp 的隧道

  8. 然后主机会把 udp 的包给送到目标主机的 8472 端口

  9. 目标主机接到 udp 的包后 8472 端口的 vxlan 程序解封装

  10. 然后目标主机上的 vxlan 设备再把数据包通过 ebpf 程序给重定向到 pod 内部

我的思路

我个人的整体思路就是参考的上边 cilium 的思路。不过我不会完全 fork cilium 的代码,为什么呢,一个是因为我们不需要 cilium 如此又多又复杂的功能,另外需要有些自己的思考。当然了,不抄 cilium 代码的最大原因......其实是我没看懂.....对不起,它真的太复杂了,我写代码三四年一共都没写过几行 c 代码,之前我一直在写 js,让我去看 cilium 那对乱七八糟的 c 代码,我都快看吐了也没看懂,实在是我太菜了,对不住了。感兴趣有本事的兄弟可以自己去看看其源码,如果你能明白的话,希望大佬也能给我讲一讲,谢谢~

不过当然了,既然我们要自己实现 ebpf 的功能,所以我们必然也要自己手写一部分 c 的代码。不过放心,不会太难,保证大家都看得懂,毕竟让大家看不懂的 c 代码我也看不懂我也写不出来。

好,言归正传,下面介绍一下我的思路。

同节点通信

首先需要给 pod 创建一对儿 veth pair,给 netns 中的那半拉打上 ip,这个 ip 地址通过上面介绍过的 ipam 从 etcd 中进行分配,子网掩码是 32 位,也就是 255.255.255.255。注意这里和之前的 host-gw 模式不同,之前的 host-gw 模式是 24 位,也就是 255.255.255.0。这里其实参考了 cilium 的方式,cilium 中的 pod ip 就都是 32 位掩码,包括其主机上的 pod 网关的掩码也是 32 位

然后在主机上创建另外一对儿 veth pair,一个叫 “veth_host”,一个叫 “veth_net”,这对儿 veth pair 只需要用到其中的 “veth_host” 这半就够了。然后给这个 “veth_host” 打上一个 32 位掩码的 ip。主要是用这半拉 veth 做一个 “假的” 网关。为啥说是 “假的” 呢?因为首先我们要让 pod 中的流量能从 pod 的 netns 中出来,这就需要其路由表中,至少有个 0.0.0.0 的默认路由,然后所有的流量都走 0.0.0.0 的配置,然后这个 0.0.0.0 的默认路由需要一个网关地址,才能把流量给送出 netns。而实际上当流量从 pod 出来之后,我们就直接让流量走 ebpf 的重定向了,根本不会走到这个所谓的网关,所以这里其实他是一个 “假的” 网关。其实这里你不创建这个假网关,而把 pod 留在 host 上的另一半的 veth 作为网关我个人觉得应该也是 ok 的,只不过你就要给每个 host 上的 veth 都分配一个 ip 了,这样就比较浪费 ip 地址

之后创建一个 vxlan 设备,这个 vxlan 设备也不用给 ip,注意这个 vxlan 在创建的时候一定是 “external” 模式的。至于这个 “external” 模式是啥意思,其实我也没太搞明白,我在网上查了一下,没找到比较容易理解的解释。这里如果有大佬知道的话也请告诉我一下,谢谢。

所以现在主机上的网络设备大概有这些:

其中 veth_net & veth_host 是创建在主机上的用作网关的一对儿 veth,其中 “veth_host” 是有一个 ip 的

ding_lxc_xxxx 是给 pod 创建的 veth pair,这里只能看到一半,因为另一半在 pod 的 netns 中,其中在 netns 中的那半拉 veth 也是有个 ip 地址 那个 ding_vxlan 就是我们创建的 vxlan 设备,后边要用作不同节点之间通信。

(P.S. 这里我解释一下,其实同一台节点上的 pod ip 和网关的 ip,都是类似 “a.b.c.x” 和 “a.b.c.y” 的格式,看上去虽然感觉像是同一个网段,但是实际上他们的掩码都是 32 位的,所以即使前边都是 a.b.c,他们其实还是处于不同网段。所以严格来讲,其实同主机上的 pod 通信也是不同网段之间通信,只是因为习惯,我还是把同节点上的叫做同网段通信,这里希望不要误导了大家 )

好,回到上边,继续说同节点上的 pod 通信思路。到这儿我们就已经给 pod 创建好了 veth 和 vxlan 设备。对于同节点上的 pod 通信来讲,我们暂时先不说 vxlan 的作用。

当这些乱七八糟的 veth 都创建出来之后,我们就要用 ebpf 了,但是在用 ebpf 之前,还有个重要的事儿。现在只是单纯的有了个 pod 的 netns 以及一对儿 veth pair,如果现在你进到 pod 或者这个 netns 中,是一定无法访问其他 pod 的,你的流量根本出不了 netns。因为当你的 ip 地址掩码是 32 位时候,linux 不会帮你创建默认的路由规则,也就是说你可以尝试自己创建个 netns,然后给其中的网卡打个 32 掩码的 ip,然后 route -n 看一下,你会发现啥也没有。

所以我们还需要手动添加路由规则,目的是让任何的流量都能够走到 netns 留在主机上的那半拉 veth 上。

但是此时如果你直接就给这个 netns 中添加默认路由规则的话,是一定添加不上的。 如下图,我们尝试自己在 host 上创建一对儿网关的 veth pair,然后给其中一个打上 32 位掩码的 ip,之后进入到 ns1 中,尝试添加默认路由,一定会报错 “不可达”

但是此时如果你直接就给这个 netns 中添加默认路由规则的话,是一定添加不上的。 如下图,我们尝试自己在 host 上创建一对儿网关的 veth pair,然后给其中一个打上 32 位掩码的 ip,之后进入到 ns1 中,尝试添加默认路由,一定会报错 “不可达”

上面只是一个演示,我们不会一点点手动去配置这些乱七八糟的东西,一会儿都在代码里自动化去做,这里只是希望大家能知道我们的代码里要干什么。

到此时,我们是不是就可以把流量给导入到外边了呢?实际还是不行的。如果现在你尝试在这个 netns 中去 ping 这个网关,是不会通的。

你可以尝试在这个 netns 中去抓包,你会发现网卡一直在发 arp 的请求,也就是说根本没有人能回复 netns 中这块儿网卡 10.0.0.1 这个 ip 对应的 mac 地址是啥,那二层不通,自然发不出去:

所以这里我们还需要一个操作,就是告诉 netns 中,10.0.0.1 也就是这个网关,它的 mac 地址是啥。怎么告诉呢?我可以通过 arp 命令去给它添加一条 neigh 规则,在图中可以看到,我们在主机上先看下 netns 留在 host 上的那半拉 veth 的 mac 是多少,然后在 netns 中通过 arp 命令把 mac 地址和 ip 地址添加到 neigh 表中:

此时再尝试去 ping,在 host 上抓包 veth,已经可以在 host 上抓到 icmp 的 request 了,说明流量已经成功从 pod 中出来了。但其实此时还是 ping 不通的,因为 veth1 没有和任何一个其他网络设备在同一个二层上,因此目标是 10.0.0.1 的数据包无法转发出去。不过没关系,后边我们就直接上 ebpf 了,ebpf 可以直接重定向

既然此时流量已经能正常 pod(netns)中出来了,那下一步就是要给这半拉的 veth 绑定 tc 和 ebpf 了。

首先我们先来准备一段简单的 ebpf 程序:

#include <linux/bpf.h>
#include <linux/pkt_cls.h>
#include <bpf/bpf_helpers.h>

#ifndef __section
# define __section(x)  __attribute__((section(x), used))
#endif

__section("classifier")
int cls_main(struct __sk_buff *skb) {
  // bpf_printk(xxx) 要通过 “cat  /sys/kernel/debug/tracing/trace_pipe” 查看
  bpf_printk("come in ebpf");
 
  return TC_ACT_OK;
}

char _license[] SEC("license") = "GPL";
# https://man7.org/linux/man-pages/man8/tc-bpf.8.html
  
# 编译 ebpf
clang -O2 -emit-llvm -c test.c -o - | llc -march=bpf -filetype=obj -o test.o
  
# 给 veth1 创建一个 clsact 类型的大队列
tc qdisc add dev veth1 clsact
# clsact 与 ingress qdisc 类似
# 能够以 direct-action 模式 attach eBPF 程序
# 其特点是不会执行任何排队
# clsact 是 ingress 的超集
# 因为它还支持在 egress 上以 direct-action 模式 attach eBPF 程序
  
# 把 test.o 以 da 的方式挂到 veth1 的 ingress 口上
tc filter add dev veth1 ingress bpf direct-action obj test.o
  
# 删除 clsact 这个大队列
tc qdisc del dev veth1 clsact
  
# 查看 test-veth1 ingress 方向的 ebpf
tc filter show dev veth1 ingress

首先简单解释一下上边的 ebpf 代码:

“__section("classifier")”:表示的就是 ebpf 程序的类型。classifier 之前在介绍 tc 时说到过,他是一个 filter,也就是说这段 ebpf 程序是一个 tc 的 filter 的类型的程序,相当于这段 ebpf 在被编译后,要被塞到 tc 的 “滤嘴” 处

“bpf_printk("come in ebpf")”:bpf_printk 是一个 helper 帮助函数,其实就是打印一个字符串,只不过通过这个 helper 函数打印的东西,会被默认写入到 “/sys/kernel/debug/tracing/trace_pipe” 这个缓冲区,可以通过 cat 查看

“return TC_ACT_OK”:这个是 tc 支持的 action 动作,也就是要对数据包做什么样的处理,OK 表示直接放过去,这里相当于什么也没干

“char _license[] SEC("license") = "GPL"”:最后这个签名一定要有,不然这玩意儿无法加载到内核中

然后下面通过 “clang -O2 -emit-llvm -c test.c -o - | llc -march=bpf -filetype=obj -o test.o” 这条命令去编译这段 c 代码,其中指定了要输出的 ebpf 程序,编译后会产生一个 test.o 文件

之后通过 “tc qdisc add dev veth1 clsact” 命令给 veth1 这个网卡设备绑定一个 clsact 类型的 queue,相当于给它装了个大水管;在通过 “tc filter add dev veth1 ingress bpf direct-action obj test.o” 命令给 veth1 的 ingress 方向上以 direct-action 的模式(对于该模式的理解可以参考这篇文章 “arthurchiao.art/blog/unders…”) 添加上这个 ebpf 程序;最后可以通过 “tc filter show dev veth1 ingress” 查看到这方向上添加的 ebpf 程序:

现在我们就给测试用的这个 veth1 添加了一个简单的 ebpf 程序,下面我们来试试看是否成功(其实一定是成功,如果不成功在执行 tc add 命令的时候就会报错了)。我们在 netns 中去 ping 网关,然后下面在 host 上查看 ebpf 的打印缓冲区文件,已经可以看到发送了三个包,然后缓冲区中就输出三个 “come in ebpf”,这说明 ebpf 程序是添加成功的:

如果想干掉这个 ebpf 程序,可以执行 “tc qdisc del dev veth1 clsact” 命令,删掉这个“水管”就行了,删完之后查看 ingress 方向已经啥也看不到了:

以上我们就通过了一个简单的例子,演示了如何手动给一块儿网卡添加 ebpf 程序以及如果把 netns 中的流量导出到 netns 外头。

剩下的我们要做的就是修改 ebpf 代码,让其能做到将流量导入到其他 netns 中。

那么怎么才能把流量导入到其他 pod 中呢?首先我们得之前,目标 ip 所对应的那对儿 veth pair 网卡是谁,这样才能通过 bpf 提供的帮助函数,将流量重定向到对方网卡上。那怎么才能知道目标 ip 的网卡是谁呢?想一想就可以发现,如果只是单纯的让发送方的 veth 上 ebpf 去猜,那肯定是猜不到的,因为发送方没有任何办法能知道对方的网卡到底是谁。所以此时应该能想到,一定需要有一种方式,能让 ebpf 程序中拿到本机中所有的 pod ip 对应的网卡信息。但是 ebpf 程序的校验是很严格的,只有一种方法能让 ebpf 程序访问到外部的数据,那就是最开始介绍 ebpf 时说过的 maps。

这个 maps 是个 KV 数据库,肉眼看的话,是以 16 进制的方式存储的。需要提前约定好 key 的长度以及 value 的长度还有 map 的类型。虽然说一般操作 maps 都是用的 c 语言,但是其实这些 maps 也就是一些系统调用,所以只要是能使用系统调用的语言那理论上都能操作 maps,就比如 golang。cilium 的官方提供了一个能在 golang 中操作 maps 的,叫 “cilium/ebpf”,这里我们使用这个库来做个简单的操作 map 的演示:

type TestKey struct {
  A uint32
}

type TestValue struct {
  B uint32
  C uint32
}

func createMap(
  name string,
  _type ebpf.MapType,
  keySize uint32,
  valueSize uint32,
  maxEntries uint32,
  flags uint32,
) (*ebpf.Map, error) {
  spec := ebpf.MapSpec{
    Name:       name,
    Type:       ebpf.Hash,
    KeySize:    keySize,
    ValueSize:  valueSize,
    MaxEntries: maxEntries,
    Flags:      flags,
  }
  m, err := ebpf.NewMap(&spec)
  if err != nil {
    return nil, err
  }
  return m, nil
}

// 该方法在同一节点上调用多次但是只会创建一个同名的 map
func CreateOnceMapWithPin(
  pinPath string,
  name string,
  _type ebpf.MapType,
  keySize uint32,
  valueSize uint32,
  maxEntries uint32,
  flags uint32,
) (*ebpf.Map, error) {
  if PathExists(pinPath) {
    return GetMapByPinned(pinPath), nil
  }
  m, err := createMap(
    name,
    _type,
    keySize,
    valueSize,
    maxEntries,
    flags,
  )
  if err != nil {
    return nil, err
  }
  err = m.Pin(pinPath)
  if err != nil {
    return nil, err
  }
  return m, nil
}

func main() {
  const (
    pinPath    = "/sys/fs/bpf/tc/globals/test_map"
    name       = "test_map"
    _type      = ebpf.Hash
    keySize    = uint32(unsafe.Sizeof(TestKey{}))
    valueSize  = uint32(unsafe.Sizeof(TestValue{}))
    maxEntries = 255
    flags      = 0
  )

  m, err := CreateOnceMapWithPin(
    pinPath,
    name,
    _type,
    keySize,
    valueSize,
    maxEntries,
    flags,
  )
  if err != nil {
    fmt.Println(err.Error())
    return
  }
  fmt.Println("获取到的 map 是: ", m)
  err = m.Put(TestKey{A: 6}, TestValue{
    B: 7,
    C: 8,
  })
  if err != nil {
    fmt.Println(err.Error())
    return
  }
}

现在有如上代码,想让如上代码运行成功需要安装 “github.com/cilium/ebpf” 包。

其实我不多解释大家看代码或者看官方的 example 也能看明白,简单来讲最主要的几个地方,一个是要告诉 ebpf 这个 map 的类型,这里用的是 ebpf.Hash 类型,然后 keySzie 和 valueSize 也要告诉 ebpf,这里通过 go 语言创建的 map,如果想在 c 语言的 ebpf 程序中使用的话,这里的 keySize 和 valueSize 一定要对应上。

另外还要说一下那个 “pinPath”,这个参数的作用,实际上就是告诉 ebpf,要把这个 map 给 “钉在哪儿”,只有 “钉” 给了某条路径,那对应的程序才能使用这个 map。

这里给 pin 到了 “/sys/fs/bpf/tc/globals/test_map”,也就是说,“所有设备绑定的 tc 都能够使用这个 test_map”。稍后我们会在 c 代码实现的 ebpf 程序中看到如果使用这些 “钉” 过来的 maps。然后这段 demo 最后一个调用了一个 Put 方法,也就是往这个 map 里存了一对儿 KV,现在我们来看下是否真的创建成功以及是否真的存进去了。

首先运行程序没有问题:

然后在终端中运行 “bpftool map list --bpffs -p” 可以查看所有的 maps,我这里的测试环境有很多 maps,只给大家截出刚刚创建的那个:

可以看到这里确实已经创建好了一个 map,那么 KV 是否存进去了呢?可以通过 “bpftool map dump id ${id}” 来查看:

可以看到,数据确实已经存进去了,肉眼可见是以十六进制的方式存储的,不过还是能很容易的认出来我们上面 demo 中的 key 是 6,value 是 7 和 8,没有问题,非常好。

所以有了上面关于 map 的基础,我们就可以想办法让 ebpf 去找目标 ip 对应的网卡设备了。怎么找呢?我这里的思路是每当 pod 要被创建的时候,首先不是需要给这个 pod 创建一对儿 veth pair 设备么,那么当我们给这对儿 veth pair 设备分配好 ip 之后,以 ip 作为 key,设备的 ifindex 以及 mac 地址作为 value,然后存到一个 maps 中,这样当其他 pod 想要访问本 pod 时,只要 ebpf 程序中拿到这个 map,同时在 skb 中拿到目标 ip,那么把这个 ip 作为 key,就能从 map 中把 value 中的 ifindex 以及 mac 地址捞出来。如图所示,我们要创建这样一个 key 和 value 的 maps:

到这里,大家应该已经能想到一个同主机 pod 通信的大概流程了,现在流量已经能从 pod 里出来了,ebpf 程序也已经能拦截到出来的 skb 了,我们也能够通过 map 这种 kv 数据库来查询目标 ip 对应的网卡设备了,同时 ebpf 程序还提供重定向的帮助函数,此时整体思路就很清晰了。

总结一下:我们要先在主机上创建一对儿 veth pair 作为网关设备,然后给 pod 创建一对儿 veth pair,并给 ns 中的 veth 打上 ip,同时还需要给 netns 中设置路由表以及 arp 表,以便让数据包能从 netns 中走到主机上的 veth 设备。然后当走到了主机上的 veth 时,要从 map 中根据目标 ip 去获取到目标 ip 对应的网卡设备,这个 map 中的信息是给 pod 的 veth 分配 ip 之后顺便存进去的。最后通过 ebpf 提供的 helper 函数中的重定向函数,将 skb 给重定向到对应网卡设备的 ifindex 上。

不同节点通信

在上面了解了同节点 pod 互相通信的实现思路之后,我们要理解用 vxlan 跨节点通信就容易多了。

首先还是得需要个 vxlan 设备,这个我们在上面已经看到了,已经创建好了。

然后我们顺着上面,流量从 pod 出来到主机上的 veth 这一步开始往后想,这一步第一件要做的事儿是判断是否目标 ip 在本主机上,如果在的话,就重定向到目标所在的网卡上。所以第二步其实就是判断,如果说目标 ip 不是本主机的话,那就要干点儿别的事儿了。

这里 ebpf 先去 map 中看一下是不是本主机的 pod,如果不是,此时其实有很多种情况,比如 “目标 ip 是本集群内其他节点上的 pod ip”,这个也是我们本次要实现的主要功能,其他还有 “目标 ip 是外网”,“目标 ip 是其他的普通进程”,“目标 ip 是负载均衡的地址” 等等。我们本次只实现第一点 “目标 ip 是本集群内其他节点上的 pod ip”,这也是最主要的功能。对于其他情况我们先暂时一律直接把 skb 给 pass 掉。

说到这里你应该能想到,我们上面用了一个 map,是来查询本主机的 ip 的,那我们为了查询目标 ip 是否是其他节点的 ip,我们就还需要一个新的 map 来存储本集群内,除了当前节点以外的其他所有节点上的 pod ip 以及该 pod ip 所在的节点网卡 ip:

我们通过上面这种数据结构来存储这个 map 的 KV,很简单,key 只需要存 pod ip,然后 value 用来存储 pod 所在的 node 对外网卡的 ip 就好。

所以有了这个 map 之后,我们就能自然地想到,当 ebpf 程序发现目标 ip 不在本机上时,可以直接去这个 map 中,根据目标 ip 查询 value,如果查到了这个 value,就说明目标 ip 在本集群内其他节点上。

然后我们就可以直接把这个 skb 通过 ebpf 的重定向帮助函数,给重定向到 vxlan 设备上,交由 vxlan 设备给包一层 udp

好,现在压力来到了 vxlan 设备这边,不知道你是否会想到一个问题,那就是 vxlan 是怎么知道目标 ip 所在的 node ip 的呢?

其实我最开始的时候以为,只要 skb 给重定向到 vxlan 设备,不用对 vxlan 设备做任何操作,他就能给外头包一层 UDP,只不过它不知道目标主机的 ip 是多少,所以可能包出来的 UDP 中的目标 ip 可能是个 0.0.0.0 之类的东西。我最开始时确实是这么以为的,但是经过实际测试,我发现当数据包被重定向到 vxlan 设备后,在 vxlan 设备的 tc 水管的 egress 方向中的 ebpf 程序中拿到的 skb,其实是原始数据报文,也就是说,在你没有对 vxlan 做任何配置的情况下,vxlan 设备不会主动给你的数据包包一层 UDP,所以这里其实还是需要咱们自己手动给 skb 原始报文外头包一层 UDP。这是个很麻烦的事儿,你需要精确地知道 4 层的 udp 报文,3 层的 ip 头,以及 2 层的 mac 头每个字段都有多长,这样才能做一个新的 skb 出来。

不过好在!!!ebpf 提供了一个类似这样的帮助函数,可以很方便的给原始的 skb 外头包一层 udp,这个函数叫 “bpf_skb_set_tunnel_key”:

从图片中可以看到,你只需要构建出一个 key,并把 key 中填充上目标主机的 ip,以及 tos 和 ttl,然后调用这个帮助函数,它就会自动帮你创建一个 udp 的隧道。很方便有木有~然后剩下的事情就交给主机了,主机会按照一个普普通通很平凡的 udp 数据包来把这个 udp 发送到目标主机上

当你完成了上面这步的时候,数据包就已经能以 UDP 的形式,达到目标主机的网卡上了。

由于在包裹外层 udp 的时候,默认会把 udp 中的目标 port 协程 8472,所以在目标主机上的协议栈解封装到 udp 层时发现目标 port 是 8472,就会自动降数据包中的 payload 给解出来,然后发送到 vxlan 设备上,所以此时如果你尝试在目标主机的 vxlan 设备上用 tcpdump 抓包的话,一定抓到的是暴露出来的原始数据报文,而不是 udp 报文。这里我们就不试了,感兴趣的同学可以自己试试看。

但是此时还有个问题,就是 vxlan 设备上虽然能收到原始的数据报文,但是它怎么知道要把这个数据报文再给谁呢?诶,还记不记得上边说过,我们用了一个 map 来存储每个本节点上的 pod ip 对应的网卡信息。没错现在我们就可以复用这个 map,首先从 vxlan 收到的 skb 中拿到目标 ip,然后再从 map 中根据 ip 找到其对应的网卡设备的 index

接下来的事情就好说了,你肯定能想到,目标 ip 的网卡信息都有了,那直接重定向过去呗!

哈哈,其实不太行。为啥呢?

不知道你有没有想到一个问题哈,咱们一直在说,vxlan 会拿到一个原始的数据包,那么这个原始的数据包长啥样呢?你可以简单想一想这个数据包从发送方的 pod 中出来时是啥样,那这儿就是啥样。

那出来时是啥样呢?我们回过头去想想最开始我们在容器的 netns 中,是不是创建了一条 arp 信息,这个 arp 信息对应的 mac 地址其实是 host 上的那半拉 veth 的 mac 地址。

所以假设你在 pod1(假设 ip 是 10.0.0.1)中去 ping 其他节点上的 pod2(假设 ip 是 10.0.1.1),那此时 pod1 的 netns 中的协议栈,会封装一个 源 ip === 10.0.0.1,目标 ip === 10.0.1.1,同时源 mac 地址是 pod1 在 netns 中的 veth 的 mac 地址,而目标 mac 地址此时从 arp 表中查到的,是 pod1 留在 host 上的那半拉 veth 的 mac 地址。

所以你想想看,这么个原始数据报文,原封不动地到了 pod2 所在的 node 上的 vxlan 设备上,哪怕说 vxlan 设备能够根据目标 ip 把 skb 重定向到 pod2 的 veth pair 上,那这个 skb 的源 mac 和目标 mac 其实都是 pod1 的 veth pair,根本匹配不上 pod2 的 veth pair 的 mac 地址,所以就算重定向过去,也会在二层链路层就被 pod2 的 veth 给干掉,于是 gg 了。

说到这儿,在回过头去看看上边咱们提到的那个用来获取本机 ip 以及网卡关系的 map 是如何创建的。没错里边的那个 MAC 和 NodeMAC 这俩属性,就是为了这个时候准备的:

现在我们重新理一下 vxlan 设备处理 skb 的思路。我们已经明白,vxlan 虽然能够拿到原始的数据包的报文,但是因为里边的 mac 地址无法和本机的 pod 的 mac 匹配上,所以会丢包,那么 vxlan 要做的事情就多了一件,那就是在其 “ingress”,也就是流量进来的方向上,要再查询一次本机 pod ip 与网卡关系的 map,根据目标 ip 拿到目标 ip 的网卡信息,用这两个 mac 地址,来替换掉原始 skb 报文中的源 mac 和目标 mac。这样重定向到 pod2 所在的 veth 时,才能正常被其接收,因为你目标 ip 是我,目标 mac 也是我,我就没有理由拒绝呀。

到这里为止,处于不同节点上的目标 pod 已经能够成功收到发送方发过来的数据包了。数据返回的时候,流程也是一样的,只不过数据包中的什么目标和源都反过来了而已,我们的 veth 设备和 vxlan 设备,要处理的事情还都是一样的。

总结一下:不同节点的 pod 通信,就是当数据包从 pod1 出来之后,先经过其 veth 上的 tc,然后 tc 上的 ebpf 程序会拦截数据包,先从一个 “本机 ip 与网卡设备” 的 map 中查目标是不是本机的,是的话走上边说的本机通信的流程,不是的话发送到 vxlan 设备上。然后由于 vxlan 设备不知道目标 ip 对应的 node ip 是多少,所以需要在 vxlan 设备上也打上 ebpf 程序,通过 ebpf 程序提供的帮助函数能很方便的给外头包上一层 UDP,其中 UDP 的目标 ip,要在另外一个 “非本节点以外的其他节点中的 pod ip 与 node ip” 的 map 中查找。然后 vxlan 包上一层 UDP 后,就能通过主机给送到目标主机,到了目标主机上,网卡先解封装,然后将 UDP 报文中的原始数据包暴露出来之后交给 vxlan 设备,vxlan 设备需要从 “本机 ip 与网卡设备” 的 map 中找到目标 ip 所在的那对儿 veth 网卡的 mac,用这俩 mac 替换掉原始 skb 中的 mac。回送的时候将这个流程反过来就行了。

好了,到这儿,我应该把我实现本机通信和跨节点通信这两种方式的思路都讲得差不多了。但是不知道你有没有注意到一个问题,就是在上面我提到,如果目标 ip 不是本主机的话,要在一个 “非本节点以外的其他节点中的 pod ip 与 node ip” 的 map 中查找目标 pod ip 所在的 node ip,说到这儿的时候不知道你有没有点疑惑,就是我自己这台主机咋能知道其他节点的 pod 和其他节点 ip 的对应关系呢?我要能知道不就好了么,甚至都不用这个 map 了,有一种鸡生蛋蛋生鸡的感觉。

这里确实是个问题,我来说一下我处理这个问题的思路。

为了能让 node1 感知到其他比如什么 node2 之类的 pod 和 node 的关系,理论上是一定需要有一种主动或者被动的方式,能让 node2 告诉 node1 “诶我是 node2,我上边有哪几个 pod ip” 的。比如说像 BGP,边界网关协议这种东西,可以自动让 node1 发现 node2 上的 pod ip 们,当然也有其他的协议也可以做到。不过我对 BGP 这种协议没有太多研究,就是知道个名儿而已,所以我用了另外的一种方法,就是想办法让 node1 去监听 node2 节点上 pod ip 的变化。

这个可以怎么做呢?我在最开始就说过,我在这个 cni 中,自己实现了一套 ipam 的 ip 地址管理机制,其中,每当某个节点创建了一个 pod,我都会通过这个 ipam 从 etcd 中的 ip 池中申请一条 ip 地址,当把这条 ip 地址更新给了本主机的 pod 之后,再把 etcd 中这个 ip 地址池子做一个更新。而 etcd 有个贼棒的功能,就是它允许你对某条 path 做监听。也就是说,当我 node2 更新了 etcd 上 node2 对应的 ip 地址池时,只要我提前让 node1 和 etcd 做个 node2 path 的监听,那么当这个 node2 的 ip 地址池的 path 发生改变后,etcd 会自动将改变后的 node2 上的 ip 情况通知给 node1,此时我就可以在 node1 上,用这个通知过来的内容,更新 node1 本机上的 “非本节点以外的其他节点中的 pod ip 与 node ip” 的 map 了。对于 node2 来讲,同样也需要对 node1 的 ip 地址池做个 watch。

大概就是这么个原理,所以在上边介绍目录的时候,我这里是有个 watcher 目录的:

总结一下如果让每个节点感知到其他不同节点上的 ip 变化:通过 etce 的 watch 功能,让每个节点去监听其他节点 ip 地址池的变化,每当其他节点被分配了 ip 到 pod 上,etcd 会主动通知其他节点 “诶谁谁发生了变化,现在它的 ip 们是多少多少”。大概就是这样。


动手实现自己的 cni 之开搞!

到这里我已经把我自己实现这个插件的大体思路都描述了一下,这个东西吧,说容易也容易,说不容易也确实不容易,把这套流程想明白了其实会发现,诶感觉还好,没那么复杂,但是要是真把这套活儿都手动实现一遍或者说清楚,其实也挺麻烦的。

接下来我就动手贴一些代码,来实现一下上边说的这些功能。因为代码还挺多的,我可能没办法把每个点都解释清楚,如果过程中哪里写得不太好还请见谅哈。

客户端实现

在主体代码中我会用到很多封装后的客户端,比如 etcd 的客户端,ipam 的客户端,k8s 的客户端等等,这些客户端代码其实也不少,但并不是这篇文章主要要介绍的,大家感兴趣的话可以自己去看一看我是如何实现的。可能实现的方式有些并不是最优的话,如果你发现也可以告诉我。基本上每个客户端的实现,我都有完整的测试,可以通过测试来了解每个客户端我都实现了哪些功能以及使用方法。这里我就不多说了。

ebpf maps 实现

map 的实现还是比较简单的,主要就实现了以下三种类型的 map:

package bpf_map

/********* 存本机网络设备的 ip - ifindex *********/
/********* pin path: NODE_LOCAL_MAP_DEFAULT_PATH *********/
type LOCAL_DEV_TYPE uint32

const (
  VXLAN_DEV LOCAL_DEV_TYPE = 1
  VETH_DEV  LOCAL_DEV_TYPE = 2
)

type LocalNodeMapKey struct {
  Type LOCAL_DEV_TYPE
}

type LocalNodeMapValue struct {
  IfIndex uint32
}

/********* 存本机每个 veth pair 的信息 *********/
/********* pin path: LXC_MAP_DEFAULT_PATH *********/
type EndpointMapKey struct {
  IP uint32
}

type EndpointMapInfo struct {
  IfIndex    uint32
  LxcIfIndex uint32 // 标记另一半的 ifindex
  // MAC        uint64
  // NodeMAC    uint64
  MAC     [8]byte
  NodeMAC [8]byte
}

/********* 存整个集群的 pod ip 以及对应的 node ip *********/
/********* pin path: POD_MAP_DEFAULT_PATH *********/
/********* 起一条常驻进程监听 etcd 以更新该 map *********/
type PodNodeMapKey struct {
  IP uint32
}

type PodNodeMapValue struct {
  IP uint32
}

其中 EndpointMap 就是上边说的用来存储本机 pod ip 以及网卡信息的 map,PodNodeMap 是用来存储通过监听 etcd 得来的其他节点的 node ip 与节点上 pod ip 对应关系的 map。还剩下那个 LocalNodeMap,这个作用很简单,其实就是用来存住一下本机上的网卡对应的 ifindex 的。为啥需要这玩意儿呢,你想,在 veth 的 ebpf 程序中,不是有可能需要把流量给重定向到 vxlan 设备嘛,总得知道 vxlan 设备的 id 才能定过去吧,所以这个 map 就是用来存这玩意儿的~

然后 maps 相关的代码实现,其实功能也不多,无非就是一些 map 的增删改查等,只不过做了一些封装,具体用法可以看一下测试文件:

watcher 实现

这里的 watcher 由于要一直在后台监听,所以使用了一个库叫 go-deamon,该库可以让 golang 程序以子进程的方式在后台运行

测试文件中已经把基本的使用方法写出来了。主要做的事情其实就是注册一个 handler,这个 handler 在每次 etcd 发生变化是会收到通知,能拿到发生了变化的 key 和 value,然后通过 go-deamon 这个库把这个 handler 扔到后台去常驻运行

测试文件中就是测试注册一个 handler 后,然后调用 etcd 客户端对某些路径做修改,看看是否会触发 handler,如果触发 handler,函数体内的 nums 会发生变化:

TC 实现

这里说的 tc 就是上边说过的 traffic control。我们上边用命令行简单做了实验。在代码中其实我也是直接通过执行命令的方式去做的。不要觉得 low,cilium 里也是直接调的命令行哈哈。

不过其实 tc 工具是有 golang 的库的,叫 go-tc。但是很惭愧我没有用明白,等以后玩明白了可以在改成更优雅的方式。

其实这里做的事情也不多,主要就是给某个网卡设备添加 “水管儿”,然后给 “水管儿” 添加 ebpf 程序等:

ebpf 实现

终于到了 ebpf 程序实现的步骤了。这里我们就要写一些 c 语言的代码了,不过不会很难,放心。

写之前我们来明确一下我们都需要多少 ebpf 程序。

首先还记得不,我们说 pod 留在 host 中的那半拉 veth 的 ingress,需要有个 ebpf,主要是用来拦截数据包,决定把数据包是发给同主机的 pod veth 还是发给主机上的 vxlan 的,所以这里是第一个 ebpf 程序 “veth_ingress”:

代码也不多,一屏足矣。

主要做的事情就是上来先校验一下 skb 的格式,看看是不是包含 mac 头和 ip 头,这俩要是都不包含的话,那就不知道这是个啥玩意儿了。

然后拿到目标的 ip 地址,用这个 ip 地址通过 “bpf_map_lookup_elem” 这个帮助函数去查询:

这里的 epKey 一定得是和之前 golang 中定义的那个长度要一样,包括 value 也要一样:

至于那个 ding_lxc,是这么个东西,这个东西就要用来指定要查哪个 map。其中有几个小地方图片中有注释,分别是结构体的名字必须和通过 bpftool map list 查出来的 map 中的 “pinned” 地址中的名字一样。另外就是如果后边用了 __srction_maps_btf 也就是 SEC(".maps"),一定在用 clang 编译时加个 -g 参数,这个参数是生成一些调试信息,只有生成了调试信息,这个 .maps 节才会被写入到生成 ELF 文件中:

然后查完 map 后,如果插到的话,就把目标 mac 和源 mac 都改成目标 ip 对应的网卡的 mac 地址,这点和 vxlan 是一样的,修改的理由也是一样的。

之后通过 “bpf_redirect_peer” 给发送到目标 pod 的 netns 中的 veth 上。注意这里虽然结果是要直接发送到 netns 中的 veth 上,但是再给参数的时候还得用留在 host 上的那个 veth 的 ifindex,因为在主机的命名空间中是找不到 netns 里的 dev 设备的 index 的。

如果目标不是本机的话,就需要将其发送到 vxlan 设备:

这里也很容易,同样的要在另外一个 map 中查找,看目标 ip 是否是本集群内,其他节点上的 pod ip,这个叫做 “ding_local” 的 map 中的数据,就是上边说的那个 golang 实现的 watcher 同步过来的。

然后如果没查到,就直接干掉,如果查到的话,说明是其他节点上的 pod,此时需要再从第三个 map,也就是 “本机网络设备与 index” 的 map 中,拿到 vxlan 设备的 ifindex,将流量重定向过去。

这就是第一个 ebpf 程序 “veth_ingress”。


接下来想想还有哪里需要 ebpf 程序呢?是不是就是在 vxlan 上。因为我们说过,vxlan 啥也没配置,所以它不会主动给原始数据报文包 UDP,这一步需要我们自己去做。所以第二个 ebpf 程序就应该放在 vxlan 设备的 egress 方向上:

代码很短,主要做的事情也很明确,就是在 “其他节点的 node ip 与 pod ip” 的 map 中,拿到目标 pod ip 所在的 node ip,然后通过 “bpf_skb_set_tunnel_key” 这个 ebpf 帮助函数,来给原始数据包外头包一层 UDP。包完直接返回 OK,表示让网络设备把它正常的一个 UDP 来处理。

当走完这一步时,这个 UDP 其实就已经能被主机的网卡给转发到目标 node 上了。


现在我们再来想想是不是还有地方需要 ebpf。是否还记得我们上边说目标主机收到了 UDP 包,通过 8472 端口的 vxlan 程序给解封后,会把原始的数据报文在交给 vxlan 设备。但是此时有个问题还记不记得,那就是此时的原始数据报文中的 mac 地址都是发送方的 pod 的 mac 地址,直接发给目标主机的 pod 的话,是会被丢掉的。所以这里还需要一个 ebpf 程序,用来将原始的数据包中的 mac 地址修改为目标 pod 的 mac 地址:

其中主要就是通过目标 ip,在本地的 “本机 pod id 和网卡 ip” 的 map 中进行查找,如果找到的话,说明确实是发给本机某个 pod 的,此时从 map 的 value 中把目标 pod 的两个 mac 地址捞出来,然后更新到 skb 中。

最后通过 bpf_redirect 发送给目标 pod 留在 host 上的那半拉 veth。

(P.S. 这里其实有个问题,cilium 官方的博客中说这里应该也是通过 bpf_redirect_peer 直接将 skb 从 vxlan 设备给重定向到 pod 在 netns 中的那一半 veth,但是我最开始尝试使用 bpf_redirect_peer 进行重定向的时候,观察到确实流量进入到了 pod 中的 veth,抓包也能在这个 veth 上抓到 request 请求,但是不知道为啥,死活就没有返回一个 reply,但是换成 bpf_redirect 就是 ok 的。如果有哪个大佬知道这是啥原因,还请告诉我一下,谢谢~)

好了,这就是第三个 ebpf 程序了。


到这儿为止,我们需要的 ebpf 程序已经都完事儿了。最后需要给他们编译一下,通过如下命令:

clang -g  -O2 -emit-llvm -c vxlan_egress.c -o - | llc -march=bpf -filetype=obj -o vxlan_egress.o
clang -g  -O2 -emit-llvm -c vxlan_ingress.c -o - | llc -march=bpf -filetype=obj -o vxlan_ingress.o
clang -g  -O2 -emit-llvm -c veth_ingress.c -o - | llc -march=bpf -filetype=obj -o veth_ingress.o

此时会编译出三个 ebpf 程序。

然后需要把这三个 ebpf 程序给拷贝到 “/opt/testcni/” 这个目录下。这个目录是我这个 cni 插件用来临时存储一些文件的目录,这个 cni 插件的二进制文件在代码中动态的给网络设备打 ebpf 程序也是要去这个目录中找的。其实就是我没实现代码动态编译,所以暂时只能先手动静态编译一下。

Vxlan 主体代码

在上边我们说所有的插件写在了 “/plugins” 目录下,只要实现了 cni 目录下的接口就行。这里把主体代码贴出来,每一步要干啥注释中写的比较清楚:

func (vx *VxlanCNI) Bootstrap(args *skel.CmdArgs, pluginConfig *cni.PluginConf) (*types.Result, error) {
  utils.WriteLog("进到了 vxlan 模式了")

  // 0. 先把各种能用的上的客户端初始化咯
  ipam, etcd, bpfmap, err := initEveryClient(args, pluginConfig)
  if err != nil {
    return nil, err
  }

  // 1. 开始监听 etcd 中 pod 和 subnet map 的变化, 注意该行为只能有一次
  err = startWatchNodeChange(ipam, etcd)
  if err != nil {
    return nil, err
  }

  // 2. 创建一对 veth pair 设备 veth_host 和 veth_net 作为默认网关
  gwPair, netPair, err := createHostVethPair(args, pluginConfig)
  if err != nil {
    return nil, err
  }

  // 启动这俩设备
  err = setUpHostVethPair(gwPair, netPair)
  if err != nil {
    return nil, err
  }

  // 3. 给这对儿网关 veth 设备中的 veth_host 加上 ip/32
  gw, err := setIpIntoHostPair(ipam, gwPair)
  if err != nil {
    return nil, err
  }

  // 4. 获取 ns
  netns, err := getNetns(args.Netns)
  if err != nil {
    return nil, err
  }

  var nsPair, hostPair *netlink.Veth
  var podIP string
  err = (*netns).Do(func(hostNs ns.NetNS) error {
    // 5. 创建一对儿 veth pair 作为 pod 的 veth
    nsPair, hostPair, err = createNsVethPair(args, pluginConfig)
    if err != nil {
      return err
    }
    // 6. 将 veth pair 设备加入到 kubelet 传来的 ns 下
    err = setHostVethIntoHost(ipam, hostPair, hostNs)
    if err != nil {
      return err
    }

    // 7. 给 ns 中的 veth 创建 ip/32, etcd 会自动通知其他 node
    podIP, err = setIpIntoNsPair(ipam, nsPair)
    if err != nil {
      return err
    }

    // 启动 ns pair
    err = setUpVeth(nsPair)
    if err != nil {
      return err
    }

    // 8. 给这个 ns 中创建默认的路由表以及 arp 表, 让其能把流量都走到 ns 外
    err = setFibTalbeIntoNs(gw, nsPair)
    if err != nil {
      return err
    }

    err = setArp(ipam, hostNs, hostPair, args.IfName)
    if err != nil {
      return err
    }

    // 启动 ns 留在 host 上那半拉 veth
    err = setUpHostPair(hostNs, hostPair)
    if err != nil {
      return err
    }

    // 9. 将 veth pair 的信息写入到 LXC_MAP_DEFAULT_PATH
    err = setVethPairInfoToLxcMap(bpfmap, hostNs, podIP, hostPair, nsPair)
    if err != nil {
      return err
    }
    // TODO(这步暂时不要好像也 ok): 10. 将 veth pair 的 ip 与 node ip 的映射写入到 NODE_LOCAL_MAP_DEFAULT_PATH
    return nil
  })

  if err != nil {
    return nil, err
  }

  // 11. 给 veth pair 中留在 host 上的那半拉的 tc 打上 ingress
  err = attachTcBPFIntoVeth(hostPair)
  if err != nil {
    return nil, err
  }

  // 12. 创建一块儿 vxlan 设备
  vxlan, err := createVxlan("ding_vxlan")
  if err != nil {
    return nil, err
  }

  // 13. 把 vxlan 加入到 NODE_LOCAL_MAP_DEFAULT_PATH
  err = setVxlanInfoToLocalMap(bpfmap, vxlan)
  if err != nil {
    return nil, err
  }

  // 14. 给这块儿 vxlan 设备的 tc 打上 ingress 和 egress
  err = attachTcBPFIntoVxlan(vxlan)
  if err != nil {
    return nil, err
  }

  // 最后交给外头去打印到标准输出
  _gw, _, _ := net.ParseCIDR(gw)
  _, _podIP, _ := net.ParseCIDR(podIP)
  result := &types.Result{
    CNIVersion: pluginConfig.CNIVersion,
    IPs: []*types.IPConfig{
      {
        Address: *_podIP,
        Gateway: _gw,
      },
    },
  }
  return result, nil
}

其实就是把上边说的思路用代码实现出来。其中包括:

  1. 初始化各种能用得上的客户端工具

  2. 监听 etcd 中的 pod 和 node ip 的变化,要实时地同步到本地的 map 中

  3. 创建一对儿 veth pair 作为网关设备并打上 ip,注意这里要单例,网关不能重复创建

  4. 创建一对儿 veth pair 作为 pod 的 veth 设备,其中一半在 netns 中,另一半在 host 上,给在 netns 中的那半拉打上 ip

  5. 给 netns 中创建路由表以及把 host 上那半拉 veth 的 mac 地址给搞到 arp 表中

  6. 把 veth pair 的 ip 和网卡对应的关系写入到 map 中

  7. 创建一个 vxlan 设备

  8. 把不同的 ebpf 程序给分别打到 pod 的 veth 上以及 vxlan 设备上

  9. 最后把该 pod 分配到的 ip 以及网关地址做成 result 结构给打印到标准输出中,之后的事情 kubelet 会自己处理


走一发!

到这里,整体的用来创建网络的 cni 插件的代码就差不多了,我在代码中提供了一个 makefile 文件,可以执行一下 “make build”,然后就会编译出该 cni 插件的可执行文件,以及三个 ebpf 程序,同时这仨 ebpf 程序会被自动拷贝到 “/opt/testcni” 目录下:

然后你就可以把这个编译后的 golang 的二进制可执行文件给拷贝到 containerd 默认读取的 “/opt/cni/bin/” 目录下了

而且还有一件重要的事儿别忘了,那就是一定要在 “/etc/cni/net.d/” 目录下创建一个配置文件,告诉 containerd 要使用哪个 cni 插件:

另外你需要在每个节点上都编译一次,并且都把编译后的 cni 插件拷贝到对应的目录,同时每个节点上的 “/etc/cni/net.d” 下都要有这个配置文件。


下面我们来看看效果。

我这里的环境有三个节点,分别是 “cni-tes-1” 和 “cni-test-2” 以及 “cni-test-master”,其中 cni-test-master 是主节点。

当我在 cni-test-1 和 cni-test-2 这两个节点上,执行了以上操作后,我的集群环境中的三个节点,配置了 cni 插件的 1 和 2 已经变成 “Ready” 状态了:

然后我这里有个简单的启动 3 个副本的 busybox 的 yaml 文件:

执行一下 “kubectl apply -f busybox.yaml”:

此时可以看到,已经有三个 pod 起来了。其中有两个在 “cni-test-2” 节点上,一个在 “cni-test-1” 节点上。

接下来进入到 cni-test-2 的某一个 pod 中

第一步尝试去 ping 一下 cni-test-1,也就是不同节点不用网段的 pod ip

OK 的!

第二步尝试去 ping 一下 cni-test-2,也就是同节点上的其他 pod 的 ip

OK 的!

好!打完收工~


到这儿,我们实现了一个基于 vxlan 和 ebpf 的,能够做到同节点之间以及不同节点之间的 pod 互相通信的 cni 插件。其实真正的 cilium 实现的功能还远不止如此,还有很多其他的东西要进行考虑。如果日后有机会的话,我们再尝试对其他方面做实现~

如果各位同学觉得这篇文章稍微有些帮助,还麻烦给哥们儿点个星星,谢谢: github.com/y805939188/…


欢迎长按下面的二维码关注云影原生生公众号

image.png