【Kubernets系列-10】容器网络基础

容器网络基础

单机容器网络

在前面讲解容器基础时,我们知道容器内的资源是被“隔离”的,这其中包括了“网络栈”等。实际上是都是被隔离在它自己的 Network Namespace 当中的。

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

所以使用上作为一个容器,它可以声明直接使用宿主机的网络栈(–net=host),即:不开启 Network Namespace,比如:


$ docker run –d –net=host --name nginx-host nginx

在这种情况下,这个容器启动后,直接监听的就是宿主机的 80 端口。

像这样直接使用宿主机网络栈的方式,虽然可以为容器提供良好的网络性能,但也会不可避免地引入共享网络资源的问题,比如端口冲突。所以,在大多数情况下,我们都希望容器进程能使用自己 Network Namespace 里的网络栈,即:拥有属于自己的 IP 地址和端口

这时候,我们需要解决一个问题就是:这个被隔离的容器进程,该如何跟其他 Network Namespace 里的容器进程进行交互呢

为了理解这个问题,我们可以把每一个容器看做一台主机,它们都有一套独立的“网络栈”

如果你想要实现两台主机之间的通信,最直接的办法,就是把它们用一根网线连接起来;而如果你想要实现多台主机之间的通信,那就需要用网线,把它们连接在一台交换机上。

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

所以为了复原同样功能,Docker 项目会默认在宿主机上创建一个名叫 docker0 的网桥,凡是连接在 docker0 网桥上的容器,就可以通过它来进行通信。

不过为了让我们的容器 连接到个docker0 网桥上,我们还需要使用一种名叫 Veth Pair 的虚拟设备了。

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

这就使得 Veth Pair 常常被用作连接不同 Network Namespace“网线”

比如,现在我们启动了一个叫作 nginx-1 的容器:


$ docker run –d --name nginx-1 nginx

然后进入到这个容器中查看一下它的网络设备:


# 在宿主机上
$ docker exec -it nginx-1 /bin/bash
# 在容器里
root@2b3c181aecf1:/# ifconfig
eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 172.17.0.2  netmask 255.255.0.0  broadcast 0.0.0.0
        inet6 fe80::42:acff:fe11:2  prefixlen 64  scopeid 0x20<link>
        ether 02:42:ac:11:00:02  txqueuelen 0  (Ethernet)
        RX packets 364  bytes 8137175 (7.7 MiB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 281  bytes 21161 (20.6 KiB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0
        
lo: flags=73<UP,LOOPBACK,RUNNING>  mtu 65536
        inet 127.0.0.1  netmask 255.0.0.0
        inet6 ::1  prefixlen 128  scopeid 0x10<host>
        loop  txqueuelen 1000  (Local Loopback)
        RX packets 0  bytes 0 (0.0 B)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 0  bytes 0 (0.0 B)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0
        
$ route
Kernel IP routing table
Destination     Gateway         Genmask         Flags Metric Ref    Use Iface
default         172.17.0.1      0.0.0.0         UG    0      0        0 eth0
172.17.0.0      0.0.0.0         255.255.0.0     U     0      0        0 eth0

可以看到,这个容器里有一张叫作 eth0 的网卡,它正是一个 Veth Pair 设备在容器里的这一端。

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

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


# 在宿主机上
$ ifconfig
...
docker0   Link encap:Ethernet  HWaddr 02:42:d8:e4:df:c1  
          inet addr:172.17.0.1  Bcast:0.0.0.0  Mask:255.255.0.0
          inet6 addr: fe80::42:d8ff:fee4:dfc1/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:309 errors:0 dropped:0 overruns:0 frame:0
          TX packets:372 errors:0 dropped:0 overruns:0 carrier:0
 collisions:0 txqueuelen:0 
          RX bytes:18944 (18.9 KB)  TX bytes:8137789 (8.1 MB)
veth9c02e56 Link encap:Ethernet  HWaddr 52:81:0b:24:3d:da  
          inet6 addr: fe80::5081:bff:fe24:3dda/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:288 errors:0 dropped:0 overruns:0 frame:0
          TX packets:371 errors:0 dropped:0 overruns:0 carrier:0
 collisions:0 txqueuelen:0 
          RX bytes:21608 (21.6 KB)  TX bytes:8137719 (8.1 MB)
          
$ brctl show
bridge name bridge id  STP enabled interfaces
docker0  8000.0242d8e4dfc1 no  veth9c02e56

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

这时候,如果我们再在这台宿主机上启动另一个 Docker 容器,比如 nginx-2:


$ docker run –d --name nginx-2 nginx
$ brctl show
bridge name bridge id  STP enabled interfaces
docker0  8000.0242d8e4dfc1 no  veth9c02e56
       vethb4963f3

你就会发现一个新的、名叫 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 地址。

备注:ARP(Address Resolution Protocol),是通过三层的 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 容器。

有了这个目的 MAC 地址,nginx-1 容器的 eth0 网卡就可以将数据包发出去。

而根据 Veth Pair 设备的原理,这个数据包会立刻出现在宿主机上的 veth9c02e56 虚拟网卡上。不过,此时这个 veth9c02e56 网卡的网络协议栈的资格已经被“剥夺”,所以这个数据包就直接流入到了 docker0 网桥里。

docker0 处理转发的过程,则继续扮演二层交换机的角色。此时,docker0 网桥根据数据包的目的 MAC 地址(也就是 nginx-2 容器的 MAC 地址),在它的 CAM 表(即交换机通过 MAC 地址学习维护的端口和 MAC 地址的对应表)里查到对应的端口(Port)为:vethb4963f3,然后把数据包发往这个端口。

而这个端口,正是 nginx-2 容器“插”在 docker0 网桥上的另一块虚拟网卡,当然,它也是一个 Veth Pair 设备。这样,数据包就进入到了 nginx-2 容器的 Network Namespace 里。

所以,nginx-2 容器看到的情况是,它自己的 eth0 网卡上出现了流入的数据包。这样,nginx-2 的网络协议栈就会对请求进行处理,最后将响应(Pong)返回到 nginx-1

以上,就是同一个宿主机上的不同容器通过 docker0 网桥进行通信的流程了。我把这个流程总结成了一幅示意图,如下所示:

在这里插入图片描述

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

熟悉了 docker0 网桥的工作方式,你就可以理解,在默认情况下,被限制在 Network Namespace 里的容器进程,实际上是通过 Veth Pair 设备 + 宿主机网桥的方式,实现了跟同其他容器的数据交换。

与之类似地,当你在一台宿主机上,访问该宿主机上的容器的 IP 地址时,这个请求的数据包,也是先根据路由规则到达 docker0 网桥,然后被转发到对应的 Veth Pair 设备,最后出现在容器里。这个过程的示意图,如下所示:
在这里插入图片描述
同样地,当一个容器试图连接到另外一个宿主机时,比如:ping 10.168.0.3,它发出的请求数据包,首先经过 docker0 网桥出现在宿主机上。然后根据宿主机的路由表里的直连路由规则(10.168.0.0/24 via eth0)),对 10.168.0.3 的访问请求就会交给宿主机的 eth0 处理。

所以接下来,这个数据包就会经宿主机的 eth0 网卡转发到宿主机网络上,最终到达 10.168.0.3 对应的宿主机上。当然,这个过程的实现要求这两台宿主机本身是连通的。这个过程的示意图,如下所示:
在这里插入图片描述

跨主通信

如果在另外一台宿主机(比如:10.168.0.3)上,也有一个 Docker 容器。那么,我们的 nginx-1 容器又该如何访问它呢?

这个问题,其实就是容器的“跨主通信”问题。

在 Docker 的默认配置下,一台宿主机上的 docker0 网桥,和其他宿主机上的 docker0 网桥,没有任何关联,它们互相之间也没办法连通。所以,连接在这些网桥上的容器,自然也没办法进行通信了。

不过,万变不离其宗。

如果我们通过软件的方式,创建一个整个集群“公用”的网桥,然后把集群里的所有容器都连接到这个网桥上,不就可以相互通信了吗?

说得没错。

这样一来,我们整个集群里的容器网络就会类似于下图所示的样子:
在这里插入图片描述
可以看到,构建这种容器网络的核心在于:我们需要在已有的宿主机网络上,再通过软件构建一个覆盖在已有宿主机网络之上的、可以把所有容器连通在一起的虚拟网络。所以,这种技术就被称为:Overlay Network(覆盖网络)。

而这个 Overlay Network 本身,可以由每台宿主机上的一个“特殊网桥”共同组成。

比如,当 Node 1 上的 Container 1 要访问 Node 2 上的 Container 3 的时候,Node 1 上的“特殊网桥”在收到数据包之后,能够通过某种方式,把数据包发送到正确的宿主机,比如 Node 2 上。而 Node 2 上的“特殊网桥”在收到数据包后,也能够通过某种方式,把数据包转发给正确的容器,比如 Container 3

容器跨主通信

为了解决这个容器“跨主通信”的问题,社区里才出现了那么多的容器网络方案。要理解容器“跨主通信”的原理,就一定要先从 Flannel 这个项目说起。

Flannel 项目是 CoreOS 公司主推的容器网络方案。事实上,Flannel 项目本身只是一个框架,真正为我们提供容器网络功能的,是 Flannel 的后端实现。目前,Flannel 支持三种后端实现,分别是:

1,VXLAN
2,host-gw
3,UDP

这三种不同的后端实现,正代表了三种容器跨主网络的主流实现方法。

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

UDP

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

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

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

我们现在的任务,就是让 container-1 访问 container-2

这种情况下,container-1 容器里的进程发起的 IP 包,其源地址就是 100.96.1.2,目的地址就是 100.96.2.3

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

这时候,这个 IP 包的下一个目的地,就取决于宿主机上的路由规则了。此时,Flannel 已经在宿主机上创建出了一系列的路由规则,以 Node 1 为例,如下所示:


# 在Node 1上
$ ip route
default via 10.168.0.1 dev eth0
100.96.0.0/16 dev flannel0  proto kernel  scope link  src 100.96.1.0
100.96.1.0/24 dev docker0  proto kernel  scope link  src 100.96.1.1
10.168.0.0/24 dev eth0  proto kernel  scope link  src 10.168.0.2

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

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

在 Linux 中,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 宿主机。

这里 为什么 flanneld知道这个 IP 地址对应的容器,是运行在 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

而这些子网与宿主机的对应关系,正是保存在 Etcd 当中,如下所示:


$ etcdctl ls /coreos.com/network/subnets
/coreos.com/network/subnets/100.96.1.0-24
/coreos.com/network/subnets/100.96.2.0-24
/coreos.com/network/subnets/100.96.3.0-24

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


$ etcdctl get /coreos.com/network/subnets/100.96.2.0-24
{"PublicIP":"10.168.0.3"}

而对于 flanneld 来说,只要 Node 1Node 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 28285 端口即可。

通过这样一个普通的、宿主机之间的 UDP 通信,一个 UDP 包就从 Node 1 到达了 Node 2。而 Node 2 上监听 8285 端口的进程也是 flanneld,所以这时候,flanneld 就可以从这个 UDP 包里解析出封装在里面的、container-1 发来的原 IP 包。

而接下来 flanneld 的工作就非常简单了:flanneld 会直接把这个 IP 包发送给它所管理的 TUN 设备,即 flannel0 设备。

根据我前面讲解的 TUN 设备的原理,这正是一个从用户态向内核态的流动方向(Flannel 进程向 TUN 设备发送数据包),所以 Linux 内核网络栈就会负责处理这个 IP 包,具体的处理方法,就是通过本机的路由表来寻找这个 IP 包的下一步流向。

Node 2 上的路由表,跟 Node 1 非常类似,如下所示:


# 在Node 2上
$ ip route
default via 10.168.0.1 dev eth0
100.96.0.0/16 dev flannel0  proto kernel  scope link  src 100.96.2.0
100.96.2.0/24 dev docker0  proto kernel  scope link  src 100.96.2.1
10.168.0.0/24 dev eth0  proto kernel  scope link  src 10.168.0.3

由于这个 IP 包的目的地址是 100.96.2.3,它跟第三条、也就是 100.96.2.0/24 网段对应的路由规则匹配更加精确。所以,Linux 内核就会按照这条路由规则,把这个 IP 包转发给 docker0 网桥。

接下来的流程,就如同单机容器网络那样,docker0 网桥会扮演二层交换机的角色,将数据包发送给正确的端口,进而通过 Veth Pair 设备进入到 container-2Network Namespace 里。

container-2 返回给 container-1 的数据包,则会经过与上述过程完全相反的路径回到 container-1 中。

需要注意的是,上述流程要正确工作还有一个重要的前提,那就是 docker0 网桥的地址范围必须是 Flannel 为宿主机分配的子网。这个很容易实现,以 Node 1 为例,你只需要给它上面的 Docker Daemon 启动时配置如下所示的 bip 参数即可:


$ FLANNEL_SUBNET=100.96.1.1/24
$ dockerd --bip=$FLANNEL_SUBNET ...

以上,就是基于 Flannel UDP 模式的跨主通信的基本原理了。我把它总结成了一幅原理图,如下所示。

在这里插入图片描述

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

我前面曾经提到,上述 UDP 模式有严重的性能问题,所以已经被废弃了

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

在这里插入图片描述
我们可以看到:

1,用户态的容器进程发出的 IP 包经过 docker0 网桥进入内核态;

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

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

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

所以说,我们在进行系统级编程的时候,有一个非常重要的优化原则,就是要减少用户态到内核态的切换次数,并且把核心的处理逻辑都放在内核态进行。这也是为什么,Flannel 后来支持的VXLAN 模式,逐渐成为了主流的容器网络方案的原因。

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 内核中的一个模块)。

上述基于 VTEP 设备进行“隧道”通信的流程,我也为你总结成了一幅图,如下所示:
在这里插入图片描述

可以看到,图中每台宿主机上名叫 flannel.1 的设备,就是 VXLAN 所需的 VTEP 设备,它既有 IP 地址,也有 MAC 地址。

现在,我们的 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 就会添加一条如下所示的路由规则:


$ route -n
Kernel IP routing table
Destination     Gateway         Genmask         Flags Metric Ref    Use Iface
...
10.1.16.0       10.1.16.0       255.255.255.0   UG    0      0        0 flannel.1

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

从图 3 的 Flannel VXLAN 模式的流程图中我们可以看到,10.1.16.0 正是 Node 2 上的 VTEP 设备(也就是 flannel.1 设备)的 IP 地址。

为了方便叙述,接下来我会把 Node 1Node 2 上的 flannel.1 设备分别称为“源 VTEP 设备”和“目的 VTEP 设备”。

而这些 VTEP 设备之间,就需要想办法组成一个虚拟的二层网络,即:通过二层数据帧进行通信

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

这里需要解决的问题就是:“目的 VTEP 设备”的 MAC 地址是什么?

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

而这里要用到的 ARP 记录,也是 flanneld 进程在 Node 2 节点启动时,自动添加在 Node 1 上的。我们可以通过 ip 命令看到它,如下所示:


# 在Node 1上
$ ip neigh show dev flannel.1
10.1.16.0 lladdr 5e:f8:4f:00:e3:37 PERMANENT

这条记录的意思非常明确,即:IP 地址 10.1.16.0,对应的 MAC 地址是 5e:f8:4f:00:e3:37

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

在这里插入图片描述

可以看到,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 要使用的数据帧。

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

所以,跟 UDP 模式类似,在宿主机看来,它会以为自己的 flannel.1 设备只是在向另外一台宿主机的 flannel.1 设备,发起了一次普通的 UDP 链接。它哪里会知道,这个 UDP 包里面,其实是一个完整的二层数据帧。

不过,不要忘了,一个 flannel.1 设备只知道另一端的 flannel.1 设备的 MAC 地址,却不知道对应的宿主机地址是什么,也就是说,这个 UDP 包该发给哪台宿主机呢?

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

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


# 在Node 1上,使用“目的VTEP设备”的MAC地址进行查询
$ bridge fdb show flannel.1 | grep 5e:f8:4f:00:e3:37
5e:f8:4f:00:e3:37 dev flannel.1 dst 10.168.0.3 self permanent

可以看到,在上面这条 FDB 记录里,指定了这样一条规则,即:

发往我们前面提到的“目的 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 维护。这时候,我们封装出来的“外部数据帧”的格式,如下所示:

在这里插入图片描述
这样,封包工作就宣告完成了。

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

这时候,Node 2 的内核网络栈会发现这个数据帧里有 VXLAN Header,并且 VNI=1。所以 Linux 内核会对它进行拆包,拿到里面的内部数据帧,然后根据 VNI 的值,把它交给 Node 2 上的 flannel.1 设备。

flannel.1 设备则会进一步拆包,取出“原始 IP 包”。接下来就回到了我在上一篇文章中分享的单机容器网络的处理流程。最终,IP 包就进入到了 container-2 容器的 Network Namespace 里。

这就是 Flannel VXLAN 模式的具体工作原理了。

Kubernetes跨主通信

上面介绍的容器跨主机网络的实现 UDP, VXLAN 都有工性,

就是用户的容器都连接在 docker0 网桥上。而网络插件则在宿主机上创建了一个特殊的设备UDP 模式创建的是 TUN 设备VXLAN 模式创建的则是 VTEP 设备),docker0 与这个设备之间,通过 IP 转发(路由表)进行协作。

然后,网络插件真正要做的事情,则是通过某种方法,把不同宿主机上的特殊设备连通,从而达到容器跨主机通信的目的。

CNI

实际上,上面这个流程,也正是 Kubernetes 对容器网络的主要处理方法。只不过,Kubernetes 是通过一个叫作 CNI 的接口,维护了一个单独的网桥来代替 docker0。这个网桥的名字就叫作:CNI 网桥,它在宿主机上的设备名称默认是:cni0

在 Kubernetes 环境里,它的工作方式跟以 Flannel 的 VXLAN模式 没有任何不同。只不过,docker0 网桥被替换成了 CNI 网桥而已,如下所示:

在这里插入图片描述

在这里,Kubernetes 为 Flannel 分配的子网范围是 10.244.0.0/16。这个参数可以在部署的时候指定,比如:


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

也可以在部署完成后,通过修改 kube-controller-manager 的配置文件来指定。

这时候,假设 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 1cni0 网桥上。所以这个 IP 包就会经过 cni0 网桥出现在宿主机上。

此时,Node 1 上的路由表,如下所示:


# 在Node 1上
$ route -n
Kernel IP routing table
Destination     Gateway         Genmask         Flags Metric Ref    Use Iface
...
10.244.0.0      0.0.0.0         255.255.255.0   U     0      0        0 cni0
10.244.1.0      10.244.1.0      255.255.255.0   UG    0      0        0 flannel.1
172.17.0.0      0.0.0.0         255.255.0.0     U     0      0        0 docker0

因为我们的 IP 包的目的 IP 地址是 10.244.1.3,所以它只能匹配到第二条规则,也就是 10.244.1.0 对应的这条路由规则。

可以看到,这条规则指定了本机的 flannel.1 设备进行处理。并且,flannel.1 在处理完后,要将 IP 包转发到的网关(Gateway),正是“隧道”另一端的 VTEP 设备,也就是 Node 2flannel.1 设备。所以,接下来的流程,介绍过的 Flannel VXLAN 模式完全一样了。

Kubernetes 之所以要设置这样一个与 docker0 网桥功能几乎一样的 CNI 网桥,主要原因包括两个方面:

1,Kubernetes 项目并没有使用 Docker 的网络模型(CNM),所以它并不希望、也不具备配置 docker0 网桥的能力;

2,这还与 Kubernetes 如何配置 Pod,也就是 Infra 容器的 Network Namespace 密切相关。

之前介绍过,Kubernetes 创建一个 Pod 的第一步,就是创建并启动一个 Infra 容器,用来“hold”住这个 Pod 的 Network Namespace。

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

实现原理

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

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

在安装完成后,你可以在宿主机的 /opt/cni/bin 目录下看到它们,如下所示:


$ ls -al /opt/cni/bin/
total 73088
-rwxr-xr-x 1 root root  3890407 Aug 17  2017 bridge
-rwxr-xr-x 1 root root  9921982 Aug 17  2017 dhcp
-rwxr-xr-x 1 root root  2814104 Aug 17  2017 flannel
-rwxr-xr-x 1 root root  2991965 Aug 17  2017 host-local
-rwxr-xr-x 1 root root  3475802 Aug 17  2017 ipvlan
-rwxr-xr-x 1 root root  3026388 Aug 17  2017 loopback
-rwxr-xr-x 1 root root  3520724 Aug 17  2017 macvlan
-rwxr-xr-x 1 root root  3470464 Aug 17  2017 portmap
-rwxr-xr-x 1 root root  3877986 Aug 17  2017 ptp
-rwxr-xr-x 1 root root  2605279 Aug 17  2017 sample
-rwxr-xr-x 1 root root  2808402 Aug 17  2017 tuning
-rwxr-xr-x 1 root root  3475750 Aug 17  2017 vlan

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

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

我在前面提到过的 Flannel、Weave 等项目,都属于“网桥”类型的 CNI 插件。所以在具体的实现中,它们往往会调用 bridge 这个二进制文件。

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

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

从这些二进制文件中,我们可以看到,如果要实现一个给 Kubernetes 用的容器网络方案,其实需要做两部分工作,以 Flannel 项目为例:

首先,实现这个网络方案本身。这一部分需要编写的,其实就是 flanneld 进程里的主要逻辑。比如,创建和配置 flannel.1 设备、配置宿主机路由、配置 ARP 和 FDB 表里的信息等等。

然后,实现该网络方案对应的 CNI 插件。这一部分主要需要做的,就是配置 Infra 容器里面的网络栈,并把它连接在 CNI 网桥上

由于 Flannel 项目对应的 CNI 插件已经被内置了,所以它无需再单独安装。而对于 WeaveCalico 等其他项目来说,我们就必须在安装插件的时候,把对应的 CNI 插件的可执行文件放在 /opt/cni/bin/ 目录下。

实际上,对于 WeaveCalico 这样的网络方案来说,它们的 DaemonSet 只需要挂载宿主机的 /opt/cni/bin/,就可以实现插件可执行文件的安装了。

接下来,你就需要在宿主机上安装 flanneld(网络方案本身)。而在这个过程中,flanneld 启动后会在每台宿主机上生成它对应的 CNI 配置文件(它其实是一个 ConfigMap),从而告诉 Kubernetes,这个集群要使用 Flannel 作为容器网络方案。

这个 CNI 配置文件的内容如下所示:


$ cat /etc/cni/net.d/10-flannel.conflist 
{
  "name": "cbr0",
  "plugins": [
    {
      "type": "flannel",
      "delegate": {
        "hairpinMode": true,
        "isDefaultGateway": true
      }
    },
    {
      "type": "portmap",
      "capabilities": {
        "portMappings": true
      }
    }
  ]
}

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

所以,接下来 dockershim 会加载上述的 CNI 配置文件。

需要注意,Kubernetes 目前不支持多个 CNI 插件混用。如果你在 CNI 配置目录(/etc/cni/net.d)里放置了多个 CNI 配置文件的话,dockershim 只会加载按字母顺序排序的第一个插件。

但另一方面,CNI 允许你在一个 CNI 配置文件里,通过 plugins 字段,定义多个插件进行协作。

比如,在我们上面这个例子里,Flannel 项目就指定了 flannelportmap 这两个插件。

这时候,dockershim 会把这个 CNI 配置文件加载起来,并且把列表里的第一个插件、也就是 flannel 插件,设置为默认插件。而在后面的执行过程中,flannelportmap 插件会按照定义顺序被调用,从而依次完成“配置容器网络”“配置端口映射”这两步操作。

CNI 插件实现原理

接下来,我们仔细看下 CNI 插件的工作原理。

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

这里要调用的 CNI 插件,就是 /opt/cni/bin/flannel;而调用它所需要的参数,分为两部分

1,CNI 环境变量
2,插件的配置信息

CNI 环境变量

第一部分,是由 dockershim 设置的一组 CNI 环境变量。

其中,最重要的环境变量参数叫作:CNI_COMMAND。它的取值只有两种:ADDDEL。这个 ADDDEL 操作,就是 CNI 插件唯一需要实现的两个方法。

而对于网桥类型的 CNI 插件来说,这两个操作意味着把容器以 Veth Pair 的方式“插”到 CNI 网桥上,或者从网桥上“拔”掉。

CNIADD 操作需要的参数包括:容器里网卡的名字 eth0(CNI_IFNAME)、Pod 的 Network Namespace 文件的路径(CNI_NETNS)、容器的 ID(CNI_CONTAINERID)等。这些参数都属于上述环境变量里的内容。其中,Pod(Infra 容器)的 Network Namespace 文件的路径,我在前面讲解容器基础的时候提到过,即:/proc/< 容器进程的 PID>/ns/net

除此之外,在 CNI 环境变量里,还有一个叫作 CNI_ARGS 的参数。通过这个参数,CRI 实现(比如 dockershim)就可以以 Key-Value 的格式,传递自定义信息给网络插件。这是用户将来自定义 CNI 协议的一个重要方法。

插件的配置信息

第二部分,则是 dockershim 从 CNI 配置文件里加载到的、默认插件的配置信息。

这个配置信息在 CNI 中被叫作 Network Configuration,它的完整定义你可以参考这个文档。dockershim 会把 Network Configuration 以 JSON 数据的格式,通过标准输入(stdin)的方式传递给 Flannel CNI 插件。

而有了这两部分参数,Flannel CNI 插件实现 ADD 操作的过程就非常简单了。

不过,需要注意的是,Flannel 的 CNI 配置文件( /etc/cni/net.d/10-flannel.conflist)里有这么一个字段,叫作 delegate:


...
     "delegate": {
        "hairpinMode": true,
        "isDefaultGateway": true
      }

Delegate 字段的意思是,这个 CNI 插件并不会自己做事儿,而是会调用 Delegate 指定的某种 CNI 内置插件来完成。对于 Flannel 来说,它调用的 Delegate 插件,就是前面介绍到的 CNI bridge 插件。

所以说,dockershimFlannel CNI 插件的调用,其实就是走了个过场。Flannel CNI 插件唯一需要做的,就是对 dockershim 传来的 Network Configuration 进行补充。比如,将 DelegateType 字段设置为 bridge,将 DelegateIPAM 字段设置为 host-local 等。

经过 Flannel CNI 插件补充后的、完整的 Delegate 字段如下所示:


{
    "hairpinMode":true,
    "ipMasq":false,
    "ipam":{
        "routes":[
            {
                "dst":"10.244.0.0/16"
            }
        ],
        "subnet":"10.244.1.0/24",
        "type":"host-local"
    },
    "isDefaultGateway":true,
    "isGateway":true,
    "mtu":1410,
    "name":"cbr0",
    "type":"bridge"
}

其中,ipam 字段里的信息,比如 10.244.1.0/24,读取自 Flannel 在宿主机上生成的 Flannel 配置文件,即:宿主机上的 /run/flannel/subnet.env 文件。

接下来,Flannel CNI 插件就会调用 CNI bridge 插件,也就是执行:/opt/cni/bin/bridge 二进制文件。

这一次,调用 CNI bridge 插件需要的两部分参数的第一部分、也就是 CNI 环境变量,并没有变化。所以,它里面的 CNI_COMMAND 参数的值还是“ADD”

而第二部分 Network Configration,正是上面补充好的 Delegate 字段。Flannel CNI 插件会把 Delegate 字段的内容以标准输入(stdin)的方式传递给 CNI bridge 插件。

有了这两部分参数,接下来 CNI bridge 插件就可以“代表”Flannel,进行“将容器加入到 CNI 网络里”这一步操作了。

ADD 操作
CNI bridge

首先,CNI bridge 插件会在宿主机上检查 CNI 网桥是否存在。如果没有的话,那就创建它。这相当于在宿主机上执行:


# 在宿主机上
$ ip link add cni0 type bridge
$ ip link set cni0 up

接下来,CNI bridge 插件会通过 Infra 容器的 Network Namespace 文件,进入到这个 Network Namespace 里面,然后创建一对 Veth Pair 设备。

紧接着,它会把这个 Veth Pair 的其中一端,“移动”到宿主机上。这相当于在容器里执行如下所示的命令:


#在容器里

# 创建一对Veth Pair设备。其中一个叫作eth0,另一个叫作vethb4963f3
$ ip link add eth0 type veth peer name vethb4963f3

# 启动eth0设备
$ ip link set eth0 up 

# 将Veth Pair设备的另一端(也就是vethb4963f3设备)放到宿主机(也就是Host Namespace)里
$ ip link set vethb4963f3 netns $HOST_NS

# 通过Host Namespace,启动宿主机上的vethb4963f3设备
$ ip netns exec $HOST_NS ip link set vethb4963f3 up 

这样,vethb4963f3 就出现在了宿主机上,而且这个 Veth Pair 设备的另一端,就是容器里面的 eth0

当然,你可能已经想到,上述创建 Veth Pair 设备的操作,其实也可以先在宿主机上执行,然后再把该设备的一端放到容器的 Network Namespace 里,这个原理是一样的。

不过,CNI 插件之所以要“反着”来,是因为 CNI 里对 Namespace 操作函数的设计就是如此,如下所示:


err := containerNS.Do(func(hostNS ns.NetNS) error {
  ...
  return nil
})

这个设计其实很容易理解。在编程时,容器的 Namespace 是可以直接通过 Namespace 文件拿到的;而 Host Namespace,则是一个隐含在上下文的参数。所以,像上面这样,先通过容器 Namespace 进入容器里面,然后再反向操作 Host Namespace,对于编程来说要更加方便。

接下来,CNI bridge 插件就可以把 vethb4963f3 设备连接在 CNI 网桥上。这相当于在宿主机上执行:


# 在宿主机上
$ ip link set vethb4963f3 master cni0

在将 vethb4963f3 设备连接在 CNI 网桥之后,CNI bridge 插件还会为它设置 Hairpin Mode(发夹模式)。这是因为,在默认情况下,网桥设备是不允许一个数据包从一个端口进来后,再从这个端口发出去的。但是,它允许你为这个端口开启 Hairpin Mode,从而取消这个限制。

这个特性,主要用在容器需要通过NAT(即:端口映射)的方式,“自己访问自己”的场景下。

举个例子,比如我们执行 docker run -p 8080:80,就是在宿主机上通过 iptables 设置了一条DNAT(目的地址转换)转发规则。这条规则的作用是,当宿主机上的进程访问“< 宿主机的 IP 地址 >:8080”时,iptables 会把该请求直接转发到“< 容器的 IP 地址 >:80”上。也就是说,这个请求最终会经过 docker0 网桥进入容器里面。

但如果你是在容器里面访问宿主机的 8080 端口,那么这个容器里发出的 IP 包会经过 vethb4963f3 设备(端口)和 docker0 网桥,来到宿主机上。此时,根据上述 DNAT 规则,这个 IP 包又需要回到 docker0 网桥,并且还是通过 vethb4963f3 端口进入到容器里。所以,这种情况下,我们就需要开启 vethb4963f3 端口的 Hairpin Mode 了。

所以说,Flannel 插件要在 CNI 配置文件里声明 hairpinMode=true。这样,将来这个集群里的 Pod 才可以通过它自己的 Service 访问到自己。

CNI ipam

接下来,CNI bridge 插件会调用 CNI ipam 插件,从 ipam.subnet 字段规定的网段里为容器分配一个可用的 IP 地址。然后,CNI bridge 插件就会把这个 IP 地址添加在容器的 eth0 网卡上,同时为容器设置默认路由。这相当于在容器里执行:


# 在容器里
$ ip addr add 10.244.0.2/24 dev eth0
$ ip route add default via 10.244.0.1 dev eth0

最后,CNI bridge 插件会为 CNI 网桥添加 IP 地址。这相当于在宿主机上执行:


# 在宿主机上
$ ip addr add 10.244.0.1/24 dev cni0

在执行完上述操作之后,CNI 插件会把容器的 IP 地址等信息返回给 dockershim,然后被 kubelet 添加到 Pod 的 Status 字段。

至此,CNI 插件的 ADD 方法就宣告结束了。接下来的流程,就跟容器跨主机通信的过程完全一致了。

纯三层网络方案

待补充

多租户实现(soft multi-tenancy)

NetworkPolicy

不难发现,Kubernetes 的网络模型,以及前面这些网络方案的实现,都只关注容器之间网络的“连通”,却并不关心容器之间网络的“隔离”。这跟传统的 IaaS 层的网络方案,区别非常明显。

Kubernetes 里,网络隔离能力的定义,是依靠一种专门的 API 对象来描述的,即:NetworkPolicy

一个完整的 NetworkPolicy 对象的示例,如下所示:


apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: test-network-policy
  namespace: default
spec:
  podSelector:
    matchLabels:
      role: db
  policyTypes:
  - Ingress
  - Egress
  ingress:
  - from:
    - ipBlock:
        cidr: 172.17.0.0/16
        except:
        - 172.17.1.0/24
    - namespaceSelector:
        matchLabels:
          project: myproject
    - podSelector:
        matchLabels:
          role: frontend
    ports:
    - protocol: TCP
      port: 6379
  egress:
  - to:
    - ipBlock:
        cidr: 10.0.0.0/24
    ports:
    - protocol: TCP
      port: 5978

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

而在上面这个例子里,你首先会看到 podSelector 字段。它的作用,就是定义这个 NetworkPolicy 的限制范围,比如:当前 Namespace 里携带了 role=db 标签的 Pod。

而如果你把 podSelector 字段留空:


spec:
 podSelector: {}

那么这个 NetworkPolicy 就会作用于当前 Namespace 下的所有 Pod。

而一旦 Pod 被 NetworkPolicy 选中,那么这个 Pod 就会进入“拒绝所有”(Deny All)的状态,即:这个 Pod 既不允许被外界访问,也不允许对外界发起访问。

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

例如,在我们上面这个例子里,我在 policyTypes 字段,定义了这个 NetworkPolicy 的类型是 ingressegress,即:它既会影响流入(ingress)请求,也会影响流出(egress)请求。

然后,在 ingress 字段里,我定义了 from 和 ports,即:允许流入的“白名单”和端口。其中,这个允许流入的“白名单”里,我指定了三种并列的情况,分别是:ipBlocknamespaceSelectorpodSelector

而在 egress 字段里,我则定义了 to 和 ports,即:允许流出的“白名单”和端口。这里允许流出的“白名单”的定义方法与 ingress 类似。只不过,这一次 ipblock 字段指定的,是目的地址的网段。

综上所述,这个 NetworkPolicy 对象,指定的隔离规则如下所示:

1,该隔离规则只对 default Namespace 下的,携带了 role=db 标签的 Pod 有效。限制的请求类型包括 ingress(流入)和 egress(流出)。

2,Kubernetes 会拒绝任何访问被隔离 Pod 的请求,除非这个请求来自于以下“白名单”里的对象,并且访问的是被隔离 Pod 的 6379 端口。这些“白名单”对象包括:

a. default Namespace 里的,携带了 role=fronted 标签的 Pod;
b. 携带了 project=myproject 标签的 Namespace 里的任何 Pod;
c. 任何源地址属于 172.17.0.0/16 网段,且不属于 172.17.1.0/24 网段的请求。

3,Kubernetes 会拒绝被隔离 Pod 对外发起任何请求,除非请求的目的地址属于 10.0.0.0/24 网段,并且访问的是该网段地址的 5978 端口。

需要注意的是,定义一个 NetworkPolicy 对象的过程,容易犯错的是“白名单”部分(from 和 to 字段)。

举个例子:


  ...
  ingress:
  - from:
    - namespaceSelector:
        matchLabels:
          user: alice
    - podSelector:
        matchLabels:
          role: client
  ...

像上面这样定义的 namespaceSelector 和 podSelector,是“或”(OR)的关系。所以说,这个 from 字段定义了两种情况,无论是 Namespace 满足条件,还是 Pod 满足条件,这个 NetworkPolicy 都会生效。

而下面这个例子,虽然看起来类似,但是它定义的规则却完全不同:


...
  ingress:
  - from:
    - namespaceSelector:
        matchLabels:
          user: alice
      podSelector:
        matchLabels:
          role: client
  ...

注意看,这样定义的 namespaceSelectorpodSelector,其实是“与”(AND)的关系。所以说,这个 from 字段只定义了一种情况,只有 Namespace 和 Pod 同时满足条件,这个 NetworkPolicy 才会生效。

此外,如果要使上面定义的 NetworkPolicyKubernetes 集群里真正产生作用,你的 CNI 网络插件就必须是支持 KubernetesNetworkPolicy 的。

在具体实现上,凡是支持 NetworkPolicyCNI 网络插件,都维护着一个 NetworkPolicy Controller,通过控制循环的方式对 NetworkPolicy 对象的增删改查做出响应,然后在宿主机上完成 iptables 规则的配置工作。

在 Kubernetes 生态里,目前已经实现了 NetworkPolicy 的网络插件包括 CalicoWeavekube-router 等多个项目,但是并不包括 Flannel 项目。

实现原理

接下来,我们已三层网络插件为例(比如 Calico 和 kube-router),来为你分析一下这部分的原理。

为了方便讲解,我们定义一个比较简单的 NetworkPolicy 对象,如下所示:


apiVersion: extensions/v1beta1
kind: NetworkPolicy
metadata:
  name: test-network-policy
  namespace: default
spec:
  podSelector:
    matchLabels:
      role: db
  ingress:
   - from:
     - namespaceSelector:
         matchLabels:
           project: myproject
     - podSelector:
         matchLabels:
           role: frontend
     ports:
       - protocol: tcp
         port: 6379

可以看到,我们指定的ingress白名单”,是任何 Namespace 里,携带 project=myproject 标签的 Namespace 里的 Pod;以及 default Namespace 里,携带了 role=frontend 标签的 Pod。允许被访问的端口是:6379。

而被隔离的对象,是所有携带了 role=db 标签的 Pod。

那么这个时候,Kubernetes 的网络插件就会使用这个 NetworkPolicy 的定义,在宿主机上生成 iptables 规则。这个过程,我可以通过如下所示的一段 Go 语言风格的伪代码来为你描述:


for dstIP := range 所有被networkpolicy.spec.podSelector选中的Pod的IP地址
  for srcIP := range 所有被ingress.from.podSelector选中的Pod的IP地址
    for port, protocol := range ingress.ports {
      iptables -A KUBE-NWPLCY-CHAIN -s $srcIP -d $dstIP -p $protocol -m $protocol --dport $port -j ACCEPT 
    }
  }
} 

可以看到,这是一条最基本的、通过匹配条件决定下一步动作的 iptables 规则。

这条规则的名字是 KUBE-NWPLCY-CHAIN,含义是:当 IP 包的源地址是 srcIP、目的地址是 dstIP、协议是 protocol、目的端口是 port 的时候,就允许它通过(ACCEPT)。

而正如这段伪代码所示,匹配这条规则所需的这四个参数,都是从 NetworkPolicy 对象里读取出来的。

可以看到,Kubernetes 网络插件对 Pod 进行隔离,其实是靠在宿主机上生成 NetworkPolicy 对应的 iptable 规则来实现的。

此外,在设置好上述“隔离”规则之后,网络插件还需要想办法,将所有对被隔离 Pod 的访问请求,都转发到上述 KUBE-NWPLCY-CHAIN 规则上去进行匹配。并且,如果匹配不通过,这个请求应该被“拒绝”

在 CNI 网络插件中,上述需求可以通过设置两组 iptables 规则来实现。

第一组规则,负责“拦截”对被隔离 Pod 的访问请求。生成这一组规则的伪代码,如下所示:


for pod := range 该Node上的所有Pod {
    if pod是networkpolicy.spec.podSelector选中的 {
        iptables -A FORWARD -d $podIP -m physdev --physdev-is-bridged -j KUBE-POD-SPECIFIC-FW-CHAIN
        iptables -A FORWARD -d $podIP -j KUBE-POD-SPECIFIC-FW-CHAIN
        ...
    }
}

可以看到,这里的的 iptables 规则使用到了内置链:FORWARD

iptables

这里我们普及一下 iptables 的知识了。

实际上,iptables 只是一个操作 Linux 内核 Netfilter 子系统的“界面”。顾名思义,Netfilter 子系统的作用,就是 Linux 内核里挡在“网卡”“用户态进程”之间的一道“防火墙”。它们的关系,可以用如下的示意图来表示:

在这里插入图片描述

可以看到,这幅示意图中,IP 包“一进一出”的两条路径上,有几个关键的“检查点”,它们正是 Netfilter 设置“防火墙”的地方。在 iptables 中,这些“检查点”被称为:链(Chain)。这是因为这些“检查点”对应的 iptables 规则,是按照定义顺序依次进行匹配的。这些“检查点”的具体工作原理,可以用如下所示的示意图进行描述:

在这里插入图片描述
可以看到,当一个 IP 包通过网卡进入主机之后,它就进入了 Netfilter 定义的流入路径(Input Path)里。

在这个路径中,IP 包要经过路由表路由来决定下一步的去向。而在这次路由之前,Netfilter 设置了一个名叫 PREROUTING 的“检查点”。在 Linux 内核的实现里,所谓“检查点”实际上就是内核网络协议栈代码里的 Hook(比如,在执行路由判断的代码之前,内核会先调用 PREROUTING 的 Hook)。

而在经过路由之后,IP 包的去向就分为了两种:

第一种,继续在本机处理;
第二种,被转发到其他目的地。

第一种去向。这时候,IP 包将继续向上层协议栈流动。在它进入传输层之前,Netfilter 会设置一个名叫 INPUT 的“检查点”。到这里,IP 包流入路径(Input Path)结束。

接下来,这个 IP 包通过传输层进入用户空间,交给用户进程处理。而处理完成后,用户进程会通过本机发出返回的 IP 包。这时候,这个 IP 包就进入了流出路径(Output Path)。

此时,IP 包首先还是会经过主机的路由表进行路由。路由结束后,Netfilter 就会设置一个名叫 OUTPUT 的“检查点”。然后,在 OUTPUT 之后,再设置一个名叫 POSTROUTING“检查点”。

你可能会觉得奇怪,为什么在流出路径结束后,Netfilter 会连着设置两个“检查点”呢?这个问题在后面会说明

第二种去向; 在这种情况下,这个 IP 包不会进入传输层,而是会继续在网络层流动,从而进入到转发路径(Forward Path)。在转发路径中,Netfilter 会设置一个名叫 FORWARD 的“检查点”。

而在 FORWARD“检查点”完成后,IP 包就会来到流出路径。而转发的 IP 包由于目的地已经确定,它就不会再经过路由,也自然不会经过 OUTPUT,而是会直接来到 POSTROUTING“检查点”。

所以说,POSTROUTING 的作用,其实就是上述两条路径,最终汇聚在一起的“最终检查点”。

这些链路层的“检查点”对应的操作界面叫作 ebtables。所以,准确地说,数据包在 Linux Netfilter 子系统里完整的流动过程,其实应该如下所示(这是一幅来自Netfilter 官方的原理图):

在这里插入图片描述
可以看到,我前面为你讲述的,正是上图中绿色部分,也就是网络层的 iptables 链的工作流程。

另外,你应该还能看到,每一个白色的“检查点”上,还有一个绿色的“标签”,比如:raw、nat、filter 等等。在 iptables 里,这些标签叫作:。比如,同样是 OUTPUT 这个“检查点”filter Outputnat Outputiptables 里的语法和参数,就完全不一样,实现的功能也完全不同。

在理解了 iptables 的工作原理之后,我们再回到 NetworkPolicy 上来。这时候,前面由网络插件设置的、负责“拦截”进入 Pod 的请求的两条 iptables 规则,就很容易读懂了:


iptables -A FORWARD -d $podIP -m physdev --physdev-is-bridged -j KUBE-POD-SPECIFIC-FW-CHAIN
iptables -A FORWARD -d $podIP -j KUBE-POD-SPECIFIC-FW-CHAIN
...

其中,第一条 FORWARD 链“拦截”的是一种特殊情况:它对应的是同一台宿主机上容器之间经过 CNI 网桥进行通信的流入数据包。其中,–physdev-is-bridged 的意思就是,这个 FORWARD 链匹配的是,通过本机上的网桥设备,发往目的地址是 podIP 的 IP 包。

而第二条 FORWARD 链“拦截”的则是最普遍的情况,即:容器跨主通信。这时候,流入容器的数据包都是经过路由转发(FORWARD 检查点)来的。

不难看到,这些规则最后都跳转(即:-j)到了名叫 KUBE-POD-SPECIFIC-FW-CHAIN 的规则上。它正是网络插件为 NetworkPolicy 设置的第二组规则。

而这个 KUBE-POD-SPECIFIC-FW-CHAIN 的作用,就是做出“允许”或者“拒绝”的判断。这部分功能的实现,可以简单描述为下面这样的 iptables 规则:


iptables -A KUBE-POD-SPECIFIC-FW-CHAIN -j KUBE-NWPLCY-CHAIN
iptables -A KUBE-POD-SPECIFIC-FW-CHAIN -j REJECT --reject-with icmp-port-unreachable

可以看到,首先在第一条规则里,我们会把 IP 包转交给前面定义的 KUBE-NWPLCY-CHAIN 规则去进行匹配。按照我们之前的讲述,如果匹配成功,那么 IP 包就会被“允许通过”

而如果匹配失败,IP 包就会来到第二条规则上。可以看到,它是一条 REJECT 规则。通过这条规则,不满足 NetworkPolicy 定义的请求就会被拒绝掉,从而实现了对该容器的“隔离”

以上,就是 CNI 网络插件实现 NetworkPolicy 的基本方法了。当然,对于不同的插件来说,上述实现过程可能有不同的手段,但根本原理是不变的。

小结

可以看到,NetworkPolicy 实际上只是宿主机上的一系列 iptables 规则。这跟传统 IaaS 里面的安全组(Security Group)其实是非常类似的。

Kubernetes 的网络模型以及大多数容器网络实现,其实既不会保证容器之间二层网络的互通,也不会实现容器之间的二层网络隔离。这跟 IaaS 项目管理虚拟机的方式,是完全不同的。

所以说,Kubernetes 从底层的设计和实现上,更倾向于假设你已经有了一套完整的物理基础设施。然后,Kubernetes 负责在此基础上提供一种“弱多租户”(soft multi-tenancy)的能力。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

df007df

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值