天天看点

docker与k8s深层理解(2)

Job Controller 的工作原理

首先,Job Controller 控制的对象,直接就是 Pod。

其次,Job Controller 在控制循环中进行的调谐(Reconcile)操作,是根据实际在 Running 状态 Pod 的数目、已经成功退出的 Pod 的数目,以及 parallelism、completions 参数的值共 同计算出在这个周期里,应该创建或者删除的 Pod 数目,然后调用 Kubernetes API 来执行这 个操作。

以创建 Pod 为例。在上面计算 Pi 值的这个例子中,当 Job 一开始创建出来时,实际处于 Running 状态的 Pod 数目 =0,已经成功退出的 Pod 数目 =0,而用户定义的 completions, 也就是最终用户需要的 Pod 数目 =4。

所以,在这个时刻,需要创建的 Pod 数目 = 最终需要的 Pod 数目 - 实际在 Running 状态 Pod 数目 - 已经成功退出的 Pod 数目 = 4 - 0 - 0= 4。也就是说,Job Controller 需要创建 4 个 Pod 来纠正这个不一致状态。

三种常用的、使用 Job 对象的方法

第一种用法,也是最简单粗暴的用法:外部管理器 +Job 模板。

docker与k8s深层理解(2)

在控制这种 Job 时,我们只要注意如下两个方面即可:

1. 创建 Job 时,替换掉 $ITEM 这样的变量;

2. 所有来自于同一个模板的 Job,都有一个 jobgroup: jobexample 标签,也就是说这一组 Job 使用这样一个相同的标识。

很容易理解,在这种模式下使用 Job 对象,completions 和 parallelism 这两个字段都应该使 用默认值 1,而不应该由我们自行设置。而作业 Pod 的并行控制,应该完全交由外部工具来进 行管理(比如,KubeFlow)。

第二种用法:拥有固定任务数目的并行 Job。

这种模式下,我只关心最后是否有指定数目(spec.completions)个任务成功退出。至于执行 时的并行度是多少,我并不关心。

一旦你用 kubectl create 创建了这个 Job,它就会以并发度为 2 的方式,每两个 Pod 一 组,创建出 8 个 Pod。每个 Pod 都会去连接 BROKER_URL,从 RabbitMQ 里读取任务,然后 各自进行处理。

docker与k8s深层理解(2)
docker与k8s深层理解(2)

第三种用法,也是很常用的一个用法:指定并行度(parallelism),但不设置固定的 completions 的值。

你就必须自己想办法,来决定什么时候启动新 Pod,什么时候 Job 才算执行完成。在这 种情况下,任务的总数是未知的,所以你不仅需要一个工作队列来负责任务分发,还需要能够判断工作队列已经为空(即:所有的工作已经结束了)。

docker与k8s深层理解(2)

CronJob 与 Job 的关系,正如同 Deployment 与 Pod 的关系一样。CronJob 是一个专 门用来管理 Job 对象的控制器。只不过,它创建和删除 Job 的依据,是 schedule 字段定义 的、一个标准的Unix Cron格式的表达式。

可以通过 spec.concurrencyPolicy 字段来定义具体的处理策略。

比如: 1. concurrencyPolicy=Allow,这也是默认情况,这意味着这些 Job 可以同时存在;

2. concurrencyPolicy=Forbid,这意味着不会创建新的 Pod,该创建周期被跳过;

3. concurrencyPolicy=Replace,这意味着新产生的 Job 会替换旧的、没有执行完的 Job。

Kubernetes“声明式 API”的独特之处:

首先,所谓“声明式”,指的就是我只需要提交一个定义好的 API 对象来“声明”,我所期 望的状态是什么样子。

其次,“声明式 API”允许有多个 API 写端,以 PATCH 的方式对 API 对象进行修改,而无 需关心本地原始 YAML 文件的内容。

最后,也是最重要的,有了上述两个能力,Kubernetes 项目才可以基于对 API 对象的增、 删、改、查,在完全无需外界干预的情况下,完成对“实际状态”和“期望状态”的调谐 (Reconcile)过程。

所以说,声明式 API,才是 Kubernetes 项目编排能力“赖以生存”的核心所在

docker与k8s深层理解(2)

首先,Kubernetes 会匹配 API 对象的组

需要明确的是,对于 Kubernetes 里的核心 API 对象,比如:Pod、Node 等,是不需要 Group 的(即:它们 Group 是“”)。所以,对于这些 API 对象来说,Kubernetes 会直接在 /api 这个层级进行下一步的匹配过程。

而对于 CronJob 等非核心 API 对象来说,Kubernetes 就必须在 /apis 这个层级里查找它对应 的 Group,进而根据“batch”这个 Group 的名字,找到 /apis/batch。

这些 API Group 的分类是以对象功能为依据的,比如 Job 和 CronJob 就都属 于“batch” (离线业务)这个 Group。

然后,Kubernetes 会进一步匹配到 API 对象的版本号。

docker与k8s深层理解(2)

首先,当我们发起了创建 CronJob 的 POST 请求之后,我们编写的 YAML 的信息就被提交给 了 APIServer。

而 APIServer 的第一个功能,就是过滤这个请求,并完成一些前置性的工作,比如授权、超时 处理、审计等。

然后,请求会进入 MUX 和 Routes 流程。

如果你编写过 Web Server 的话就会知道,MUX 和 Routes 是 APIServer 完成 URL 和 Handler 绑定的场所。而 APIServer 的 Handler 要做的事 情,就是按照我刚刚介绍的匹配过程,找到对应的 CronJob 类型定义。

接着,APIServer 最重要的职责就来了:根据这个 CronJob 类型定义,使用用户提交的 YAML 文件里的字段,创建一个 CronJob 对象。

而在这个过程中,APIServer 会进行一个 Convert 工作,即:把用户提交的 YAML 文件,转换 成一个叫作 Super Version 的对象,它正是该 API 资源类型所有版本的字段全集。

这样用户提 交的不同版本的 YAML 文件,就都可以用这个 Super Version 对象来进行处理了。 接下来,APIServer 会先后进行 Admission() 和 Validation() 操作。

而 Validation,则负责验证这个对象里的各个字段是否合法。这个被验证过的 API 对象,都保 存在了 APIServer 里一个叫作 Registry 的数据结构中。

也就是说,只要一个 API 对象的定义能 在 Registry 里查到,它就是一个有效的 Kubernetes API 对象。 最后,APIServer 会把验证过的 API 对象转换成用户最初提交的版本,进行序列化操作,并调 用 Etcd 的 API 把它保存起来。

基于角色的权限控制之 RBAC

负责完成授权(Authorization)工作的机制,就是 RBAC:基于角 色的访问控制(Role-Based Access Control)

1. Role:角色,它其实是一组规则,定义了一组对 Kubernetes API 对象的操作权限。

2. Subject:被作用者,既可以是“人”,也可以是“机器”,也可以使你在 Kubernetes 里 定义的“用户”。

3. RoleBinding:定义了“被作用者”和“角色”的绑定关系

Role 本身就是一个 Kubernetes 的 API 对象,定义如下所示:

docker与k8s深层理解(2)

这个 Role 对象指定了它能产生作用的 Namepace 是:mynamespace

Namespace 是 Kubernetes 项目里的一个逻辑管理单位。不同 Namespace 的 API 对象,在 通过 kubectl 命令进行操作的时候,是互相隔离开的。

比如,kubectl get pods -n mynamespace。

当然,这仅限于逻辑上的“隔离”,Namespace 并不会提供任何实际的隔离或者多租户能力。 而在前面文章中用到的大多数例子里,我都没有指定 Namespace,那就是使用的是默认 Namespace:default。

RoleBinding 本身也是一个 Kubernetes 的 API 对象。它的定义如下所示:

docker与k8s深层理解(2)

这个 RoleBinding 对象里定义了一个 subjects 字段,即“被作用者”。它的类型是 User,即 Kubernetes 里的用户。这个用户的名字是 example-user。

在 Kubernetes 中,其实并没有一个叫作“User”的 API 对象。而且,我们在前面和部 署使用 Kubernetes 的流程里,既不需要 User,也没有创建过 User。

实际上,Kubernetes 里的“User”,也就是“用户”,只是一个授权系统里的逻辑概念。它需 要通过外部认证服务,比如 Keystone,来提供。或者,你也可以直接给 APIServer 指定一个用 户名、密码文件。那么 Kubernetes 的授权系统,就能够从这个文件里找到对应的“用户”了。 当然,在大多数私有的使用环境中,我们只要使用 Kubernetes 提供的内置“用户”,就足够 了。

roleRef 字段。正是通过这个字段,RoleBinding 对象就可以直接通 过名字,来引用我们前面定义的 Role 对象(example-role),从而定义了“被作用者 (Subject)”和“角色(Role)”之间的绑定关系。

Role 和 RoleBinding 对象都是 Namespaced 对象(Namespaced Object),它们对权限的限制规则仅在它们自己的 Namespace 内有效,roleRef 也只能引用当 前 Namespace 里的 Role 对象。

对于非 Namespaced(Non-namespaced)对象(比如:Node),或者,某一个 Role 想要作用于所有的 Namespace 的时候,我们又该如何去做授权

docker与k8s深层理解(2)
docker与k8s深层理解(2)

Kubernetes 还提供了四个预先定义好的 ClusterRole 来供用户直接使用:

1. cluster-amdin; 2. admin; 3. edit; 4. view。

docker与k8s深层理解(2)

PVC 描述的,是 Pod 想要使用的持久化存储的属性,比如存储的大小、读写权限等。 PV 描述的,则是一个具体的 Volume 的属性,比如 Volume 的类型、挂载目录、远程存储 服务器地址等。

而 StorageClass 的作用,则是充当 PV 的模板。并且,只有同属于一个 StorageClass 的 PV 和 PVC,才可以绑定在一起。

本文章提到的“Volume”,指的就 是一个远程存储服务挂载在宿主机上的持久化目录;而“PV”,指的是这个 Volume 在 Kubernetes 里的 API 对象。

在删除 PV 时需要按 如下流程执行操作:

1. 删除使用这个 PV 的 Pod; 2. 从宿主机移除本地磁盘(比如,umount 它); 3. 删除 PVC; 4. 删除 PV。

一个 Linux 容器能看见的“网络栈”,实际上是被隔离 在它自己的 Network Namespace 当中的

就包括了:网卡(Network Interface)、回环设备(Loopback Device)、路由表(Routing Table)和 iptables 规则。对于一个进程来说,这些要素,其实 就构成了它发起和响应网络请求的基本环境。

声明直接使用宿主机的网络栈(–net=host),即:不开 启 Network Namespace

这个被隔离的容器进程,该如何跟其他 Network Namespace 里的容器进程进行交互呢?

够起到虚拟交换机作用的网络设备,是网桥(Bridge)。它是一个工作在数据 链路层(Data Link)的设备,主要功能是根据 MAC 地址学习来将数据包转发到网桥的不同端 口(Port)上。

Docker 项目会默认在宿主机上创建一个名叫 docker0 的网桥,凡是连 接在 docker0 网桥上的容器,就可以通过它来进行通信。该如何把这些容器“连接”到 docker0 网桥上呢?

使用一种名叫Veth Pair的虚拟设备了。

Veth Pair 设备的特点是:它被创建出来后,总是以两张虚拟网卡(Veth Peer)的形式成对出 现的。并且,从其中一个“网卡”发出的数据包,可以直接出现在与它对应的另一张“网 卡”上,哪怕这两个“网卡”在不同的 Network Namespace 里。

docker与k8s深层理解(2)
docker与k8s深层理解(2)
docker与k8s深层理解(2)
docker与k8s深层理解(2)

通过 route 命令查看 nginx-1 容器的路由表,我们可以看到,这个 eth0 网卡是这个容器里的 默认路由设备;所有对 172.17.0.0/16 网段的请求,也会被交给 eth0 来处理(第二条 172.17.0.0 路由规则)。

而这个 Veth Pair 设备的另一端,则在宿主机上。你可以通过查看宿主机的网络设备看到它,如 下所示:

docker与k8s深层理解(2)
docker与k8s深层理解(2)

通过 ifconfig 命令的输出,你可以看到,nginx-1 容器对应的 Veth Pair 设备,在宿主机上是 一张虚拟网卡。它的名字叫作 veth9c02e56。并且,通过 brctl show 的输出,你可以看到这张 网卡被“插”在了 docker0 上。

这其中的原理

docker与k8s深层理解(2)

你就会发现一个新的、名叫 vethb4963f3 的虚拟网卡,也被“插”在了 docker0 网桥上。 这时候,如果你在 nginx-1 容器里 ping 一下 nginx-2 容器的 IP 地址(172.17.0.3),就会发 现同一宿主机上的两个容器默认就是相互连通的。

当你在 nginx-1 容器里访问 nginx-2 容器的 IP 地址(比如 ping 172.17.0.3)的时候,这个目 的 IP 地址会匹配到 nginx-1 容器里的第二条路由规则。可以看到,这条路由规则的网关 (Gateway)是 0.0.0.0,这就意味着这是一条直连规则,即:凡是匹配到这条规则的 IP 包,应 该经过本机的 eth0 网卡,通过二层网络直接发往目的主机。 而要通过二层网络到达 nginx-2 容器,就需要有 172.17.0.3 这个 IP 地址对应的 MAC 地址。 所以 nginx-1 容器的网络协议栈,就需要通过 eth0 网卡发送一个 ARP 广播,来通过 IP 地址查 找对应的 MAC 地址。

这个 eth0 网卡,是一个 Veth Pair,它的一端在这个 nginx-1 容器的 Network Namespace 里,而另一端则位于宿主机上(Host Namespace),并且被“插”在 了宿主机的 docker0 网桥上。

一旦一张虚拟网卡被“插”在网桥上,它就会变成该网桥的“从设备”。从设备会被“剥夺”调 用网络协议栈处理数据包的资格,从而“降级”成为网桥上的一个端口。而这个端口唯一的作 用,就是接收流入的数据包,然后把这些数据包的“生杀大权”(比如转发或者丢弃),全部交 给对应的网桥。

在收到这些 ARP 请求之后,docker0 网桥就会扮演二层交换机的角色,把 ARP 广播转 发到其他被“插”在 docker0 上的虚拟网卡上。这样,同样连接在 docker0 上的 nginx-2 容 器的网络协议栈就会收到这个 ARP 请求,从而将 172.17.0.3 所对应的 MAC 地址回复给 nginx-1 容器。

此时,对宿主机来说,docker0 网桥就是一个普通的网卡。

docker与k8s深层理解(2)

在实际的数据传递时,上述数据的传递过程在网络协议栈的不同层次,都有 Linux 内核 Netfilter 参与其中。

我们整个集群里的容器网络就会类似于下图所示的样子:

docker与k8s深层理解(2)

构建这种容器网络的核心在于:我们需要在已有的宿主机网络上,再通过软件构建一 个覆盖在已有宿主机网络之上的、可以把所有容器连通在一起的虚拟网络。所以,这种技术就被 称为:Overlay Network(覆盖网络)。

深入解析容器跨主机网络

Flannel 支持三种后端实现

1. VXLAN; 2. host-gw; 3. UDP。

UDP 模式,是 Flannel 项目最早支持的一种方式,却也是性能最差的一种方式。所以,这个模 式目前已经被弃用。不过,Flannel 之所以最先选择 UDP 模式,就是因为这种模式是最直接、也是 最容易理解的容器跨主网络实现。

会先从 UDP 模式开始

在这个例子中,我有两台宿主机。

宿主机 Node 1 上有一个容器 container-1,它的 IP 地址是 100.96.1.2,对应的 docker0 网桥 的地址是:100.96.1.1/24。

宿主机 Node 2 上有一个容器 container-2,它的 IP 地址是 100.96.2.3,对应的 docker0 网桥 的地址是:100.96.2.1/24。

这种情况下,container-1 容器里的进程发起的 IP 包,其源地址就是 100.96.1.2,目的地址就是 100.96.2.3。由于目的地址 100.96.2.3 并不在 Node 1 的 docker0 网桥的网段里,所以这个 IP 包 会被交给默认路由规则,通过容器的网关进入 docker0 网桥(如果是同一台宿主机上的容器间通 信,走的是直连规则),从而出现在宿主机上。

docker与k8s深层理解(2)

可以看到,由于我们的 IP 包的目的地址是 100.96.2.3,它匹配不到本机 docker0 网桥对应的 100.96.1.0/24 网段,只能匹配到第二条、也就是 100.96.0.0/16 对应的这条路由规则,从而进入 到一个叫作 flannel0 的设备中。

这个 flannel0 设备的类型就比较有意思了:它是一个 TUN 设备(Tunnel 设备)。

TUN 设备是一种工作在三层(Network Layer)的虚拟网络设备。TUN 设备的功能 非常简单,即:在操作系统内核和用户应用程序之间传递 IP 包。

以 flannel0 设备为例: 像上面提到的情况,当操作系统将一个 IP 包发送给 flannel0 设备之后,flannel0 就会把这个 IP 包,交给创建这个设备的应用程序,也就是 Flannel 进程。这是一个从内核态(Linux 操作系统) 向用户态(Flannel 进程)的流动方向。 反之,如果 Flannel 进程向 flannel0 设备发送了一个 IP 包,那么这个 IP 包就会出现在宿主机网络 栈中,然后根据宿主机的路由表进行下一步处理。这是一个从用户态向内核态的流动方向。 所以,当 IP 包从容器经过 docker0 出现在宿主机,然后又根据路由表进入 flannel0 设备后,宿主 机上的 flanneld 进程(Flannel 项目在每个宿主机上的主进程),就会收到这个 IP 包。然后, flanneld 看到了这个 IP 包的目的地址,是 100.96.2.3,就把它发送给了 Node 2 宿主机。

Flannel 项目里一个非常重要的概念:子网(Subnet)。

在由 Flannel 管理的容器网络里,一台宿主机上的所有容器,都属于该宿主机被分配的一 个“子网”。

在我们的例子中,Node 1 的子网是 100.96.1.0/24,container-1 的 IP 地址是 100.96.1.2。

Node 2 的子网是 100.96.2.0/24,container-2 的 IP 地址是 100.96.2.3。

docker与k8s深层理解(2)

flanneld 进程在处理由 flannel0 传入的 IP 包时,就可以根据目的 IP 的地址(比如 100.96.2.3),匹配到对应的子网(比如 100.96.2.0/24),从 Etcd 中找到这个子网对应的宿主机 的 IP 地址是 10.168.0.3

flanneld 进程在处理由 flannel0 传入的 IP 包时,就可以根据目的 IP 的地址(比如 100.96.2.3),匹配到对应的子网(比如 100.96.2.0/24),从 Etcd 中找到这个子网对应的宿主机 的 IP 地址是 10.168.0.3

docker与k8s深层理解(2)

对于 flanneld 来说,只要 Node 1 和 Node 2 是互通的,那么 flanneld 作为 Node 1 上的一个 普通进程,就一定可以通过上述 IP 地址(10.168.0.3)访问到 Node 2,这没有任何问题。

flanneld 在收到 container-1 发给 container-2 的 IP 包之后,就会把这个 IP 包直接封装 在一个 UDP 包里,然后发送给 Node 2。不难理解,这个 UDP 包的源地址,就是 flanneld 所在 的 Node 1 的地址,而目的地址,则是 container-2 所在的宿主机 Node 2 的地址。 当然,这个请求得以完成的原因是,每台宿主机上的 flanneld,都监听着一个 8285 端口,所以 flanneld 只要把 UDP 包发往 Node 2 的 8285 端口即可。

docker与k8s深层理解(2)

基于 Flannel UDP 模式的跨主通信的基本原理

Flannel UDP 模式提供的其实是一个三层的 Overlay 网络,即:它首先对发出端的 IP 包进行 UDP 封装,然后在接收端进行解封装拿到原始的 IP 包,进而把这个 IP 包转发给目标容 器。这就好比,Flannel 在不同宿主机上的两个容器之间打通了一条“隧道”,使得这两个容器可 以直接使用 IP 地址进行通信,而无需关心容器和宿主机的分布情况。

相比于两台宿主机之间的直接通信,基于 Flannel UDP 模式的容器通信多了一个额外的步 骤,即 flanneld 的处理过程。而这个过程,由于使用到了 flannel0 这个 TUN 设备,仅在发出 IP 包的过程中,就需要经过三次用户态与内核态之间的数据拷贝,如下所示:

docker与k8s深层理解(2)

我们可以看到:

第一次:用户态的容器进程发出的 IP 包经过 docker0 网桥进入内核态;

第二次:IP 包根据路由表进入 TUN(flannel0)设备,从而回到用户态的 flanneld 进程;

第三次:flanneld 进行 UDP 封包之后重新进入内核态,将 UDP 包通过宿主机的 eth0 发出去。

Flannel 进行 UDP 封装(Encapsulation)和解封装(Decapsulation) 的过程,也都是在用户态完成的。在 Linux 操作系统中,上述这些上下文切换和用户态操作的代价 其实是比较高的,这也正是造成 Flannel UDP 模式性能不好的主要原因。

在进行系统级编程的时候,有一个非常重要的优化原则,就是要减少用户态到内核态 的切换次数,并且把核心的处理逻辑都放在内核态进行。

VXLAN 模式,逐渐成为了主流的容器网络方案的原因

VXLAN,即 Virtual Extensible LAN(虚拟可扩展局域网),是 Linux 内核本身就支持的一种网络 虚似化技术。所以说,VXLAN 可以完全在内核态实现上述封装和解封装的工作,从而通过与前面 相似的“隧道”机制,构建出覆盖网络(Overlay Network)。

VXLAN 的覆盖网络的设计思想是:在现有的三层网络之上,“覆盖”一层虚拟的、由内核 VXLAN 模块负责维护的二层网络,使得连接在这个 VXLAN 二层网络上的“主机”(虚拟机或者容器都可 以)之间,可以像在同一个局域网(LAN)里那样自由通信。当然,实际上,这些“主机”可能分 布在不同的宿主机上,甚至是分布在不同的物理机房里。

而为了能够在二层网络上打通“隧道”,VXLAN 会在宿主机上设置一个特殊的网络设备作为“隧 道”的两端。这个设备就叫作 VTEP,即:VXLAN Tunnel End Point(虚拟隧道端点)。

而 VTEP 设备的作用,其实跟前面的 flanneld 进程非常相似。只不过,它进行封装和解封装的对 象,是二层数据帧(Ethernet frame);而且这个工作的执行流程,全部是在内核里完成的(因为VXLAN 本身就是 Linux 内核中的一个模块)。

docker与k8s深层理解(2)

现在,我们的 container-1 的 IP 地址是 10.1.15.2,要访问的 container-2 的 IP 地址是 10.1.16.3。 那么,与前面 UDP 模式的流程类似,当 container-1 发出请求之后,这个目的地址是 10.1.16.3 的 IP 包,会先出现在 docker0 网桥,然后被路由到本机 flannel.1 设备进行处理。也就是说,来 到了“隧道”的入口。为了方便叙述,我接下来会把这个 IP 包称为“原始 IP 包”。 为了能够将“原始 IP 包”封装并且发送到正确的宿主机,VXLAN 就需要找到这条“隧道”的出 口,即:目的宿主机的 VTEP 设备。 而这个设备的信息,正是每台宿主机上的 flanneld 进程负责维护的。

比如,当 Node 2 启动并加入 Flannel 网络之后,在 Node 1(以及所有其他节点)上,flanneld 就会添加一条如下所示的路由规则:

docker与k8s深层理解(2)

:凡是发往 10.1.16.0/24 网段的 IP 包,都需要经过 flannel.1 设备发出,并 且,它最后被发往的网关地址是:10.1.16.0。

,10.1.16.0 正是 Node 2 上的 VTEP 设 备(也就是 flannel.1 设备)的 IP 地址。

需要想办法组成一个虚拟的二层网络,即:通过二层数据帧进行通信。

“源 VTEP 设备”收到“原始 IP 包”后,就要想办法把“原始 IP 包”加上 一个目的 MAC 地址,封装成一个二层数据帧,然后发送给“目的 VTEP 设备”(当然,这么做还 是因为这个 IP 包的目的地址不是本机)。

此时,根据前面的路由记录,我们已经知道了“目的 VTEP 设备”的 IP 地址。而要根据三层 IP 地 址查询对应的二层 MAC 地址,这正是 ARP(Address Resolution Protocol )表的功能。

docker与k8s深层理解(2)

:IP 地址 10.1.16.0,对应的 MAC 地址是 5e:f8:4f:00:e3:37。

有了这个“目的 VTEP 设备”的 MAC 地址,Linux 内核就可以开始二层封包工作了。这个二层帧 的格式,如下所示:

docker与k8s深层理解(2)

,Linux 内核会把“目的 VTEP 设备”的 MAC 地址,填写在图中的 Inner Ethernet Header 字段,得到一个二层数据帧。

上述封包过程只是加一个二层头,不会改变“原始 IP 包”的内容。所以图中的 Inner IP Header 字段,依然是 container-2 的 IP 地址,即 10.1.16.3。

但是,上面提到的这些 VTEP 设备的 MAC 地址,对于宿主机网络来说并没有什么实际意义。所以 上面封装出来的这个数据帧,并不能在我们的宿主机二层网络里传输。为了方便叙述,我们把它称 为“内部数据帧”(Inner Ethernet Frame)。

所以接下来,Linux 内核还需要再把“内部数据帧”进一步封装成为宿主机网络里的一个普通的数 据帧,好让它“载着”“内部数据帧”,通过宿主机的 eth0 网卡进行传输。 我们把这次要封装出来的、宿主机对应的数据帧称为“外部数据帧”(Outer Ethernet Frame)。

为了实现这个“搭便车”的机制,Linux 内核会在“内部数据帧”前面,加上一个特殊的 VXLAN 头,用来表示这个“乘客”实际上是一个 VXLAN 要使用的数据帧。

而这个 VXLAN 头里有一个重要的标志叫作VNI,它是 VTEP 设备识别某个数据帧是不是应该归自 己处理的重要标识。而在 Flannel 中,VNI 的默认值是 1,这也是为何,宿主机上的 VTEP 设备都 叫作 flannel.1 的原因,这里的“1”,其实就是 VNI 的值。

Linux 内核会把这个数据帧封装进一个 UDP 包里发出去

这个 UDP 包该发给哪台宿主机呢? 在这种场景下,flannel.1 设备实际上要扮演一个“网桥”的角色,在二层网络进行 UDP 包的转 发。而在 Linux 内核里面,“网桥”设备进行转发的依据,来自于一个叫作 FDB(Forwarding Database)的转发数据库。

不难想到,这个 flannel.1“网桥”对应的 FDB 信息,也是 flanneld 进程负责维护的。它的内容可 以通过 bridge fdb 命令查看到,如下所示:

docker与k8s深层理解(2)

->>发往我们前面提到的“目的 VTEP 设备”(MAC 地址是 5e:f8:4f:00:e3:37)的二层数据帧,应该 通过 flannel.1 设备,发往 IP 地址为 10.168.0.3 的主机。显然,这台主机正是 Node 2,UDP 包 要发往的目的地就找到了。

接下来的流程,就是一个正常的、宿主机网络上的封包工作。

UDP 包是一个四层数据包,所以 Linux 内核会在它前面加上一个 IP 头,即原理图中的 Outer IP Header,组成一个 IP 包。并且,在这个 IP 头里,会填上前面通过 FDB 查询出来的目的 主机的 IP 地址,即 Node 2 的 IP 地址 10.168.0.3。

然后,Linux 内核再在这个 IP 包前面加上二层数据帧头,即原理图中的 Outer Ethernet Header, 并把 Node 2 的 MAC 地址填进去。这个 MAC 地址本身,是 Node 1 的 ARP 表要学习的内容, 无需 Flannel 维护。这时候,我们封装出来的“外部数据帧”的格式,如下所示:

docker与k8s深层理解(2)

接下来,Node 1 上的 flannel.1 设备就可以把这个数据帧从 Node 1 的 eth0 网卡发出去。显然, 这个帧会经过宿主机网络来到 Node 2 的 eth0 网卡。

这时候,Node 2 的内核网络栈会发现这个数据帧里有 VXLAN Header,并且 VNI=1。

所以 Linux 内核会对它进行拆包,拿到里面的内部数据帧,然后根据 VNI 的值,把它交给 Node 2 上的 flannel.1 设备。

而 flannel.1 设备则会进一步拆包,取出“原始 IP 包”。

最终,IP 包就进入到了 container-2 容器的 Network Namespace 里。 以上,就是 Flannel VXLAN 模式的具体工作原理了。

这两种模式其实都可 以称作“隧道”机制.

(UDP 模式创建的是 TUN 设备,VXLAN 模式创建的则是 VTEP 设备),docker0 与这个设备之间,通过 IP 转发(路由表)进行协作。

Kubernetes 是通过一个叫作 CNI 的接口,维护了一个单独的网桥来代替 docker0。这个网桥 的名字就叫作:CNI 网桥,它在宿主机上的设备名称默认是:cni0。

docker0 网桥被替换成了 CNI 网桥而已

docker与k8s深层理解(2)

假设 Infra-container-1 要访问 Infra-container-2(也就是 Pod-1 要访问 Pod-2), 这个 IP 包的源地址就是 10.244.0.2,目的 IP 地址是 10.244.1.3。而此时,Infra-container-1 里的 eth0 设备,同样是以 Veth Pair 的方式连接在 Node 1 的 cni0 网桥上。所以这个 IP 包就 会经过 cni0 网桥出现在宿主机上。

CNI 网桥只是接管所有 CNI 插件负责的、即 Kubernetes 创建的容器 (Pod)。而此时,如果你用 docker run 单独启动一个容器,那么 Docker 项目还是会把这个 容器连接到 docker0 网桥上。所以这个容器的 IP 地址,一定是属于 docker0 网桥的 172.17.0.0/16 网段。

有一个步骤是安装 kubernetes-cni 包,它的目的就是在宿主 机上安装CNI 插件所需的基础可执行文件。

docker与k8s深层理解(2)

这些 CNI 的基础可执行文件,按照功能可以分为三类:

第一类,叫作 Main 插件,它是用来创建具体网络设备的二进制文件。比如,bridge(网桥设 备)、ipvlan、loopback(lo 设备)、macvlan、ptp(Veth Pair 设备),以及 vlan。

第二类,叫作 IPAM(IP Address Management)插件,它是负责分配 IP 地址的二进制文 件。比如,dhcp,这个文件会向 DHCP 服务器发起请求;host-local,则会使用预先配置的 IP 地址段来进行分配

第三类,是由 CNI 社区维护的内置 CNI 插件。比如:flannel,就是专门为 Flannel 项目提供的 CNI 插件;tuning,是一个通过 sysctl 调整网络设备参数的二进制文件;portmap,是一个通 过 iptables 配置端口映射的二进制文件;bandwidth,是一个使用 Token Bucket Filter (TBF) 来进行限流的二进制文件。

首先,实现这个网络方案本身。这一部分需要编写的,其实就是 flanneld 进程里的主要逻辑。 比如,创建和配置 flannel.1 设备、配置宿主机路由、配置 ARP 和 FDB 表里的信息等等。 然后,实现该网络方案对应的 CNI 插件。这一部分主要需要做的,就是配置 Infra 容器里面的 网络栈,并把它连接在 CNI 网桥上。

CNI(container network interface)

CRI(Container Runtime Interface,容器运行时接口)

在 Kubernetes 中,处理容器网络相关的逻辑并不会在 kubelet 主干代码里执 行,而是会在具体的 CRI(Container Runtime Interface,容器运行时接口)实现里完成。对 于 Docker 项目来说,它的 CRI 实现叫作 dockershim,你可以在 kubelet 的代码里找到它。

docker与k8s深层理解(2)
docker与k8s深层理解(2)
docker与k8s深层理解(2)
docker与k8s深层理解(2)

Kubernetes 中 CNI 网络的实现原理

1. 所有容器都可以直接使用 IP 地址与其他容器通信,而无需使用 NAT。

2. 所有宿主机都可以直接使用 IP 地址与所有容器通信,而无需使用 NAT。反之亦然。

3. 容器自己“看到”的自己的 IP 地址,和别人(宿主机或者容器)看到的地址是完全一样 的。

Kubernetes 三层网络方案

host-gw 示意图

docker与k8s深层理解(2)

host-gw 模式的工作原理,其实就是将每个 Flannel 子网(Flannel Subnet,比 如:10.244.1.0/24)的“下一跳”,设置成了该子网对应的宿主机的 IP 地址

host-gw 的性能损失大约在 10% 左右,而其他所有基于 VXLAN“隧道”机制的网络方 案,性能损失都在 20%~30% 左右。

host-gw 模式能够正常工作的核心,就在于 IP 包在封 装成帧发送出去的时候,会使用路由表里的“下一跳”来设置目的 MAC 地址。这样,它就会经 过二层网络到达目的宿主机。

Flannel host-gw 模式必须要求集群宿主机之间是二层连通的。

BGP 的全称是 Border Gateway Protocol,即:边界网关协议。它是一个 Linux 内核原生就支 持的、专门用在大规模数据中心里维护不同的“自治系统”之间路由信息的、无中心的路由协 议

docker与k8s深层理解(2)

我们有两个自治系统(Autonomous System,简称为 AS):AS 1 和 AS 2。而 所谓的一个自治系统,指的是一个组织管辖下的所有 IP 网络和路由器的全体。你可以把它想象 成一个小公司里的所有主机和路由器。在正常情况下,自治系统之间不会有任何“来往”。

比如,AS 1 里面的主机 10.10.0.2,要访问 AS 2 里面的主机 172.17.0.3 的话。它发出的 IP 包,就会先到达自治系统 AS 1 上的路由器 Router 1。

而在此时,Router 1 的路由表里,有这样一条规则,即:目的地址是 172.17.0.2 包,应该经过 Router 1 的 C 接口,发往网关 Router 2(即:自治系统 AS 2 上的路由器)。 所以 IP 包就会到达 Router 2 上,然后经过 Router 2 的路由表,从 B 接口出来到达目的主机 172.17.0.3。

所谓 BGP,就是在大规模网络中实现节点路由信息共享的一种协议。

Calico 项目与 Flannel 的 host-gw 模式的另一个不同之处

docker与k8s深层理解(2)

为 Calico 打开 IPIP 模式。

docker与k8s深层理解(2)

尽管这条规则的下一跳地址仍然是 Node 2 的 IP 地址,但这一次,要负责将 IP 包 发出去的设备,变成了 tunl0。注意,是 T-U-N-L-0,而不是 Flannel UDP 模式使用的 T-UN-0(tun0),这两种设备的功能是完全不一样的。

两种将宿主机网关设置成 BGP Peer 的解决方案

第一种方案,就是所有宿主机都跟宿主机网关建立 BGP Peer 关系。 这种方案下,Node 1 和 Node 2 就需要主动跟宿主机网关 Router 1 和 Router 2 建立 BGP 连 接。从而将类似于 10.233.2.0/24 这样的路由信息同步到网关上去。 需要注意的是,这种方式下,Calico 要求宿主机网关必须支持一种叫作 Dynamic Neighbors 的 BGP 配置方式。这是因为,在常规的路由器 BGP 配置里,运维人员必须明确给出所有 BGP Peer 的 IP 地址。考虑到 Kubernetes 集群可能会有成百上千个宿主机,而且还会动态地添加和 删除节点,这时候再手动管理路由器的 BGP 配置就非常麻烦了。而 Dynamic Neighbors 则允 许你给路由器配置一个网段,然后路由器就会自动跟该网段里的主机建立起 BGP Peer 关系。 不过,相比之下,我更愿意推荐第二种方案。

这种方案,是使用一个或多个独立组件负责搜集整个集群里的所有路由信息,然后通过 BGP 协 议同步给网关。而我们前面提到,在大规模集群中,Calico 本身就推荐使用 Route Reflector 节点的方式进行组网。所以,这里负责跟宿主机网关进行沟通的独立组件,直接由 Route Reflector 兼任即可。 更重要的是,这种情况下网关的 BGP Peer 个数是有限并且固定的。所以我们就可以直接把这些 独立组件配置成路由器的 BGP Peer,而无需 Dynamic Neighbors 的支持。 当然,这些独立组件的工作原理也很简单:它们只需要 WATCH Etcd 里的宿主机和对应网段的 变化信息,然后把这些信息通过 BGP 协议分发给网关即可。

Kubernetes 里的 Pod 默认都是“允许所有”(Accept All)的,即:Pod 可以接收来自任何发送方的请求;或者,向任何接收方发送请求。而如果你 要对这个情况作出限制,就必须通过 NetworkPolicy 对象来指定。

NetworkPolicy 定义的规则,其实就是“白名单”

三种并列的情况,分别是:ipBlock、 namespaceSelector 和 podSelector。

安装 Flannel + Calico 的流程非常简单

​​https://docs.projectcalico.org/v3.2/getting-started/kubernetes/installation/flannel​​

docker与k8s深层理解(2)
docker与k8s深层理解(2)

一 组 Pod 实例之间总会有负载均衡的需求

IPVS 模块只负责上述的负载均衡和代理功能。而一个完整的 Service 流程 正常工作所需要的包过滤、SNAT 等操作,还是要靠 iptables 来实现。只不过,这些辅助性的 iptables 规则数量有限,也不会随着 Pod 数量的增加而增加。

继续阅读