Kubernetes 的网络模型是什么?

Kubernetes 网络模型设计的基础原则之一:每个 Pod 都拥有一个独立的 IP 地址,而且假定所有 Pod 都在一个可以直接连通的,扁平的网络空间中。

所以两个不同 Node 的 Pod 也能直接通过 IP 进行访问,这样不需要额外考虑如何建立 Pod 之间的连接,也不需要考虑将容器端口映射到主机端口等问题。

实际上 Kubernetes 中,IP 是以 Pod 为单位进行分配的。一个 Pod 内部的所有容器共享一个网络堆栈(一个网络命名空间,包括它们的 IP 地址,网络设备,配置等信息,这些都是共享的)

按照这个网络原则抽象出来的一个 Pod 一个 IP 的设计模型被称为 IP-per-Pod 模型

Pod 内部的应用程序看到自己的 IP 地址和端口,无论在内部和外部都是保持一致的。

同一个 Pod 内的不同容器将会共享一个网络命名空间,这意味着同一个 Pod 内的容器可以通过 localhost 来连接对方的端口。这种关系和同一个 VM 内的进程之间关系是一样的,而且 Pod 内不同容器之间的端口是共享的,没有所谓的私有端口的概念。

Docker 的网络基础

Docker 对 Linux 内核的特性有很强的依赖。接下来会对 Network Namespace 网络命名空间,Veth 设备对, Iptables/Netfilter , 网桥,路由

Network Namespace 网络命名空间

为了支持网络协议的多个实例,Linux 再网络栈中引入了网络命名空间,这些独立的协议栈被隔离到不同的命名空间中。不同命名空间的网络栈是完全隔离的,彼此之间无法通信。而 Docker 利用网络的命名空间特性,来实现容器之间网络的隔离。

在 Linux 的网络命名空间内可以有自己独立的路由表及 Iptables/Netfilter 设置来提供包转发,NAT 及 IP 包过滤等功能。

网络命名空间的实现

Linux 的网络协议栈为了支持i独立的协议栈,相关的全局变量都必须修改为协议栈私有。最好的办法就是让这些全局变量成为一个 Net Namespace 变量的成员,然后为协议栈的函数调用加入一个 Namespace 参数。这就是 Linux 实现网络命名空间的核心。

内核代码隐式地使用命名空间内的变量。网络命名空间对应用程序而言式透明的。

在建立了新的网络命名空间,并将某个进程关联到这个网络命名空间后,所有网站栈变量都放入了网络命名空间的数据结构中。这个网络命名空间是同属它的进程组私有的,和其他进程组不冲突。

Docker 容器中的各类网络栈设备都是 Docker Daemon 在启动时自动创建和配置的。

Veth 设备对

作用就是打通互相看不到的协议栈之间的壁垒,它就像一个管子,一段连着这个网络命名空间的协议栈,一端连着另一个网络命名空间的协议栈。

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

网桥

Linux 支持多个不同端口,网桥的作用就是让这些端口连接起来并实现类似交换机那样的多对多通信。网桥是一个二层网络设备,可以解析收发的报文,读取目标 MAC 地址的信息,和自己记录的Mac 表结合,来决策报文的转发端口。

我们既可以把网桥看作一个二层设备,也可以看作一个三层设备。

在实际网络中,如果设备移动到另外一个端口上,而它没有发送任何数据,那么网桥设备就无法感知这个变化,结果导致网桥还是向原来的端口转发数据包,那么数据就会丢失。为了处理这种情况,网桥会对 Mac 地址表加上一个超时时间(默认为五分钟),如果网桥收到了对应端口 Mac 地址回的包,就刷新超时时间,如果超过五分钟没收到就认为那个设备已经不在那个端口上了,它就会重新广播发送。

容器跨主机网络

VXLAN, host-gw, UDP 是三种容器跨主网络的主流实现方法。

其中 UDP 模式是性能最差的一种,也是目前被弃用的,不过这种模式最直接,也容易被理解。

下例示例中,以Flannel 项目作为例子来讲解容器是怎么跨主机网络来进行通讯的。

当100.96.1.2 Node 中 container-1 容器里的进程向目标地址:100.96.2.3 发起一个 IP 包会经历那些步骤。

1.判断目标地址 100.96.2.3 在不在 Node1 的 docker0 网桥的网段。因为不在,所以这个IP包会被交给默认路由规则,通过容器的网关进入 docker0 网桥(如果是同一台宿主机上的容器间通信,走的是直连规则),从而出现在宿主机上。

简单来说,会先上判断目标地址是否能通过网桥访问,如果不能,则将 IP 包交给宿主机

2.IP 包到达宿主机上后,会根据宿主机上的路由规则,从而进入到 flannel0 设备。

flannel0 设备

这个设备是一个 TUN 设备(Tunnel 设备)

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

3.IP 包进入到 flannel0 设备后,flannel0 会把这个 IP 包交给 Flannel 进程,这时 flanneld 进程(Flannel 项目在每个宿主机上的主进程)会根据 IP 包的目标地址,将包发送给 Node2 宿主机。

Flannel 子网(Subnet)

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

而这些子网与宿主机对应关系,保存在 Etcd 中。所以f lanneld 进程在处理由 flannel0 传入的 IP 包时,就可以根据目的 IP 的地址匹配到对应的子网。

所以对于 flanneld 来说,只要宿主机 Node1 和 Node2 互通,那么容器也可以互通。当然 docker0 网桥的地址范围必须是 Flannel 为宿主机分配的子网。

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

缺点

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

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

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

VXLAN (Virtual Extensible LAN 虚拟可扩展局域网)

Linux 内核本身就支持的一种网络虚拟化技术。VXLAN 的覆盖网络的设计思想:在现有的三层网络之上,“覆盖” 一层虚拟的,由内核 VXLAN 模块负责维护的二层网络,使得连接在这个 VXLAN 二层网络上的 “主机”之间,可以像在同一个局域网(LAN)里那样自由通信。

当然,实际上可能有所不同,可能这些“主机”分布在不同的宿主机上,甚至分布在不同的物理机房里。

而为了能够在二层网络上打通“隧道”,VXLAN 会在宿主机上设置一个特殊的网络设备作为“隧道”的两端。这个设备就叫作 VTEP,即:VXLAN Tunnel End Point(虚拟隧道端点)。而 VTEP 设备的作用,其实跟前面的 flanneld 进程非常相似。只不过,它进行封装和解封装的对象,是二层数据帧(Ethernet frame);而且这个工作的执行流程,全部是在内核里完成的(因为 VXLAN 本身就是 Linux 内核中的一个模块)

CNI 网络插件

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

在Kubernetes 环境中,它的工作方式和VXLAN 一样,只是 docker0 网桥被替换成了CNI 网桥而已。Kubernetes 为 Flannel 分配的子网范围是 10.244.0.0/16 ,在做集群初始化的时候,经常可以看到:

1
kubeadm init --pod-network-cidr=10.244.0.0/16

CNI 网桥只是接管所有 CNI 插件负责的,即 Kubernetes 创建的 Pod,如果使用docker run 命令启动容器,是不归 CNI 网桥所纳管的。那么Docker 还是会把这个容器连接到 docker0 网桥上。

这是因为:一方面 Kubernetes 项目并没有使用 Docker 的网络模型(CNM),所以它并不希望,也不具备配置 docker0 网桥的能力。另一方与 Kubernetes 配置Pod 有关,也就是 Infra 容器的 Network Namespace 密切相关。

CNI 的设计思想就是 Kubernetes 启动 infra 容器之后就可以直接调用CNI 网络插件,为这个 Infra 容器的 Network Namespace 配置符合预期的网络栈。

CNI 插件工作原理

当 kubelet 组件需要创建 Pod 的时候,它第一个创建的一定是 Infra 容器。所以在这一步,dockershim 就会先调用 Docker API 创建并启动 Infra 容器,紧接着执行一个叫作 SetUpPod 的方法。这个方法的作用就是:为 CNI 插件准备参数,然后调用 CNI 插件为 Infra 容器配置网络。

CNI 执行三种插件:

1.Main 插件,用于创建具体网络设备的二进制文件。比如 bridge(网桥设备)、ipvlan、loopback(lo 设备)、macvlan、ptp(Veth Pair 设备),以及 vlan。

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

3.CNI 社区维护的内置 CNI 插件。比如:flannel,就是专门为 Flannel 项目提供的 CNI 插件

Flannel 的 host-gw 模式

当Flannel 使用该模式后,flanneld 会在宿主机上创建一条规则:

1
2
3
$ ip route
...
10.244.1.0/24 via 10.168.0.3 dev eth0

上述信息包含:

目的IP地址属于 10.244.1.0/24 网段的IP包,这是经由本机 eth0 设备发出去,并且它的下一跳地址为:10.168.0.3

下一跳指的是主机经过路由设备X的中转,那么设备X的IP地址就应该配置为主机A的下一跳地址。

host-gw 模式的工作原理,其实就是将每个 Flannel 子网的“下一跳”,设置成该子网对应的宿主机的IP地址。这意味着,宿主机被当作“网关”。

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

Calico

相同的,Calico 也会在每台宿主机上,添加一个类似的路由规则。不过不同于 Flannel 通过 Etcd 和宿主机上的 flanneId 来维护路由信息,它使用 BGP 来自动在整个集群中分发路由信息。

BGP Border Gateway Protocol 边界网关协议

它是一个Linux 内核原生就支持,专门用在大规模数据中心里维护不同的“自治系统”之间路由信息的,无中心的路由协议。它跟普通路由器不同之处在于:它的路由表里拥有其他自治系统里的主机路由信息。

可以这么理解,每个边界网关上在后台运行一个逻辑:将自己的路由表信息通过 TCP 传递给其他的边界网关;对收到的数据进行分析筛选后,将需要的信息添加到自己的路由表里。

Kubernetes 中 Service 是如何工作的?

Service 是由 kube-proxy 组件加上 iptables 来共同实现的。当一个 Service 被提交给 Kubernetes 那么 kube-proxy 可以通过 Service 的 Informer 知道有一条新的 Service 对象添加,它会在宿主机上创建一条 iptables 规则。

这条规则内容相当于为这个 Service 设置了一个固定的入口地址,但是这个只是 iptables 规则上的配置,并不是真正的网络设备。

当流量进入规则后,会发现这不是一条规则,而是一组随机模式的iptables链。而规则中的链指向的是 Service 代理的 Pod。所以这一组规则其实就是 Service 实现负载均衡的位置。

DNAT 规则的作用:就是在 PREROUTING 检查点之前,也就是在路由之前,将流入 IP 包的目的地址和端口,改成–to-destination 所指定的新的目的地址和端口。可以看到,这个目的地址和端口,正是被代理 Pod 的 IP 地址和端口。

访问 Service VIP 的 IP 包经过上述 iptables 处理之后,就已经变成了访问具体某一个后端 Pod 的 IP 包了。不难理解,这些 Endpoints 对应的 iptables 规则,正是 kube-proxy 通过监听 Pod 的变化事件,在宿主机上生成并维护的。

Kubernetes Ingress

由于每个 Service 都要有一个负载均衡服务,所以这个做法实际上既浪费成本又高。作为用户,我其实更希望看到 Kubernetes 为我内置一个全局的负载均衡器。然后,通过我访问的 URL,把请求转发给不同的后端 Service。

这种全局的、为了代理不同后端 Service 而设置的负载均衡服务,就是 Kubernetes 里的 Ingress 服务。

一个 Nginx Ingress Controller 为你提供的服务,其实是一个可以根据 Ingress 对象和被代理后端 Service 的变化,来自动进行更新的 Nginx 负载均衡器。

学习资料

《深入剖析Kubernetes》