容器网络
最后更新于
这有帮助吗?
容器网络发端于 Docker 的网络。
Docker 使用比较简单的网络模型,即内部的网桥加内部的保留IP。这种设计的好处在于容器的网络和外部世界是解耦的,无需占用宿主机的 IP 或者宿主机的资源,完全是虚拟的。
它的设计初衷是:当需要访问外部世界时,会采用 SNAT 这种方法来借用 Node 的 IP 去访问外面的服务。比如,容器需要对外提供服务的时候,所用的是 DNAT 技术,也就是在 Node 上开一个端口,然后通过 iptable 或者别的某些机制,把流导入到容器的进程上以达到目的。
内部保留IP范围:
10.0.0.0/8(A类):10.0.0.0-10.255.255.255
172.16.0.0/12(B类):172.16.0.0-172.31.255.255
192.168.0.0/16(C类):192.168.0.0-192.168.255.255
该模型的问题在于,外部网络无法区分哪些是容器的网络与流量、哪些是宿主机的网络与流量。比如,如果要做一个高可用的时候,上图中的 172.16.1.1 和 172.16.1.2 是拥有同样功能的两个容器,此时需要将两者绑成一个 Group 对外提供服务,而这个时候发现从外部看来两者没有相同之处,它们的 IP 都是借用宿主机的端口,因此很难将两者归拢到一起。
从协议层次和网络拓扑两个维度来看容器网络的解决方案。
和 TCP 协议栈的概念是相同的,需要一层层地传递。
发包的时候先有应用数据,然后发到了 TCP 或者 UDP 的四层协议,继续向下传送,加上 IP 头,再加上 MAC 头就可以送出去了。
收包的时候则按照相反的顺序,首先剥离 MAC 的头,再剥离 IP 的头,最后通过协议号在端口找到需要接收的进程。
一个容器的包所要解决的问题分为两步:
第一步,如何从容器的空间跳到宿主机的空间;
第二步,如何从宿主机空间到达远端。
容器网络的解决方案可以通过接入、流控、通道这三个层面来考虑。
第一个是接入,即容器和宿主机之间是使用哪一种机制做连接:比如 Veth + bridge、Veth + pair 这样的经典方式,也有利用高版本内核的新机制(如 mac/IPvlan 等)其他方式,来把包送入到宿主机空间;
第二个是流控,即要不要支持 Network Policy,如果支持的话用何种方式去实现。需要注意的是,实现方式一定要在数据路径必经的一个节点上。如果数据路径不通过该 Hook 点,那就不会起作用;
第三个是通道,即两个主机之间通过什么方式完成包的传输。有很多种方式,比如以路由的方式,具体分为 BGP 路由或者直接路由。还有各种各样的隧道技术等。
最终实现的目的就是一个容器内的包通过容器,经过接入层传到宿主机,再穿越宿主机的流控模块(如果有)到达通道送到对端。
Linux容器能够看见的“网络栈”,是被隔离在它自己的Network Namespace当中的。
网络栈,包括:
网卡(Network Interface)
回环设备(Loopback Device)
路由表(Routing Table)
iptables规则
对于一个进程来说,这些要素就构成了它发起和响应网络请求的基本环境。
作为一个容器,可以直接使用宿主机的网络栈,即不开启Network Namespace,如下:
这样直接使用宿主机网络栈的方式:
好处:为容器提供良好的网络性能
缺点:引入共享网络资源的问题,比如端口冲突
所以在大多数情况下,都希望容器进程能使用自己的Network Namespace里的网络栈,即拥有自己的IP地址和端口。
被隔离的容器进程,该如何与其他Network Namespace里的容器进程进行交互?
将一个容器理解为一台主机,拥有独立的网络栈,那么主机之间通信最直接的方式就是通过网线,当有多台主机时,通过网线连接到交换机再进行通信。
在Linux中,能够起到虚拟交换机作用的网络设备,就是网桥(Bridge),工作在数据链路层的设备,主要功能根据MAC地址学习来将数据包转发到网桥的不同端口上。
Docker项目默认在宿主机上创建一个docker0网桥,凡是连接在docker0网桥上的容器,就可以通过它来进行通信。使用Veth Pair
的虚拟设备把容器都连接到docker0网桥上。
Veth Pair
设备的特点:它被创建后,总是以两张虚拟网卡(Veth Peer
)的形式成对出现的,并且从其中一个“网卡”发出的数据包,可以直接出现在与它对应的另一张“网卡”上,哪怕这两个“网卡”在不同的Network Namespace中。所有Veth Pair
常被用作连接不同Network Namespace的“网线”。
启动一个容器,进入容器后查看它的网络设备,然后回到宿主机查看网络设备:
新创建容器nginx-1的Veth Pair的一端在容器中,另一端在docker0网桥上,所以同一个宿主机上的两个容器默认就是互相连通的。
当container-1访问container-2的IP地址(172.17.0.3)时,目标IP地址会匹配container-1里面的第二条路由规则
通过二层网络到达container-2容器,就需要有172.17.0.3这个IP地址对应的MAC地址,所以container-1容器的网络协议栈需要通过eth0网卡发送一个ARP广播,来通过IP地址查找对应的MAC地址
ARP(Address Resoultion Protocol),是通过三层的IP地址找到对应的二层MAC地址的协议
容器内的eth0网卡是Veth Pair,它的一端在容器的Network Namespace中,另一端在宿主机上(Host Namespace),并且被插在宿主机docker0网桥上
虚拟网卡被插在网桥上就会变成网桥的从设备(被剥夺调用网络协议栈处理数据包的资格),从而降级为网桥上的一个端口,这个端口的唯一作用就是接受流入的数据包,然后把数据包的转发或丢弃全部交给对应的网桥
在收到container-1容器中发出的ARP请求后,docker0网桥就会扮演二层交换机的角色,把ARP广播转发到其他插在docker0网桥上的虚拟网卡。container-2容器内的网络协议栈就会收到这个ARP请求,从而将172.17.0.3所对应的MAC地址回复给container-1容器
container-1容器获取MAC地址后,就能把数据包从容器内eth0网卡发送出去。根据Veth Pair设备的原理,这个数据包会立刻出现在宿主机的虚拟网卡veth9c02e56上,因为虚拟网卡的网络协议栈资格被剥夺,数据就直接流入docker0网桥里
docker0处理转发的过程,继续扮演二层交换机的角色,网桥根据数据包目的MAC地址,在它的CAM表(交换机通过MAC地址学习维护的端口和MAC地址的对应表)里查到对应的端口为container-2的虚拟网卡,然后把数据发送到这个端口
这个虚拟网卡也是一个Veth Pair设备,所有数据直接进入到container-2容器的Network Namespace中
container-2容器看到的情况是,它自己的eth0网卡上出现了流入的数据包,这样container-2的网络协议栈就会对请求进行处理,最后将响应返回给container-1
需要注意的是,在实际的数据传递时,数据的传递过程在网络协议的不同层次,都有Linux内核Netfilter参与其中。可以使用iptables的TRACE功能,查看数据包的传输过程,如下所示:
被限制在Network Namespace里的容器进程,实际上是通过Veth Pair设备和宿主机网桥的方式,实现了跟其他容器的数据交换。
访问宿主机上的容器的IP地址时,这个请求的数据包根据路由规则到达Docker0网桥,然后被转发到对应的Veth Pair设备,最后出现在容器里。
宿主机之间网络需要互通。
当一个容器试图连接到另外一个宿主机(10.168.0.3)时,发出的请求数据包,首先经过docker0网桥出现在宿主机上,然后根据路由表里的直连路由规则(10.168.0.0/24 via eth0),对10.168.0.3的访问请求就会交给宿主机的eth0处理。这个数据包经过宿主机的eth0网卡转发到宿主机网络上,最终到达10.168.0.3对应的宿主机上。
当出现容器不能访问外网的时候,先试一下能不能ping通docker0网桥,然后查看一下docker0和Veth Pair设备相关的iptables规则是否有异常。
在Docker默认的配置下,一台宿主机上的docker0网桥,和其它宿主机上的docker0网桥,没有任何关联。它们互相之间也没有办法连通、所以连接在网桥上的容器,没有办法进行通信。如果通过网络创建一个整个集群“公用”的网桥,然后把集群里的所有容器都连接到整个网桥上,就可以互通了。如下图所示。
构建这种网络的核心在于:需要在已有的宿主机网络上,通过软件构建一个覆盖在已有宿主机网络之上的、可以把所有容器连通在一起的虚拟网络。这种技术称为Overlay Network(覆盖网络)。
Overlay Network本身,可以由每台宿主机上的一个“特殊网桥”共同组成。比如,当node1上的容器1要访问node2上的容器3时,node1上的“特殊网桥”在收到数据包之后,能够通过某种方式,把数据包发送到正确的宿主机node2上。在node2上的“特殊网桥”在收到数据包后,也能够通过某种方式,把数据包转发给正确的容器3。甚至,每台宿主机上,都不要有一个“特殊网桥”,而仅仅通过某种方式配置宿主机的路由表,就能够把数据包转发到正确的宿主机上。
容器跨宿主机通信中,Flannel项目是CoreOS公司主推的容器网络方案。Flannel项目本身只是一个框架,真正提供容器网络功能的是Flannel的后端实现,目前Flannel支持三种后端实现:
UDP模式:最早支持,最直接、最容易理解、但是性能最差,已经弃用
VXLAN
host-gw
三种不同的后端实现,代表了三种容器跨主机网络的主流实现方法。
假设有两台宿主机,目标是:c1访问c2。
宿主机
容器
IP
docker0网桥地址
node1
c1
100.96.1.2
100.96.1.1/24
node2
c2
100.96.2.3
100.96.2.1/24
这种情况下,c1容器里的进程发起的IP包,其源地址是
100.96.1.2
,目的地址是100.96.2.3
,由于目的地址100.96.2.3
不在node1的docker0网桥的网段里,所以这个IP包会被交给默认路由规则,通过容器的网关进入docker0网桥(如果是同一台宿主机上的容器间通信,走的是直连规则),从而出现在宿主机上。此时,这个IP包的下一个目的地,就取决于宿主机上的路由规则。
此时,Flannel已经在宿主机上创建出了一系列的路由规则,以node1为例,如下所示:
可以看到,由于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包。
当操作系统将一个IP包发送给flannel0设备之后,flannel0就会把这个IP包交给创建这个设备的应用程序(Flannel进程),这是一个从内核态(Linux操作系统)向用户态(Flannel进程)的流动方向。
如果Flannel进程向flannel0设备发送一个IP包,那么这个IP包就会出现在宿主机网络栈中,然后根据宿主机的路由规则进行下一步处理。这是一个从用户态向内核态的流动方向。
所以当IP包从容器经过docker0出现在宿主机,然后又根据路由表进入flannel0设备后,宿主机上的flanneld进程(Flannel项目在宿主机上的主进程),就会收到这个IP包,然后,flanneld看到这个IP包的目的地址是100.96.2.3
,就把它发送给了node2宿主机。
在Flannel管理的容器网络里,一台宿主机上的所有容器,都属于该宿主机被分配的一个子网,以上面的例子来说,node1的子网是
100.96.1.0/24
,c1的IP地址是100.96.1.2
,node2的子网是100.96.2.0/24
,c2的IP地址是100.96.2.3
。
这些子网与宿主机的对应关系保存在Etcd中,如下所示:
flanneld进程在处理从flannel0传入的IP包时,就可以根据目的IP地址,匹配对应的子网,从Etcd中找到这个子网对应的宿主机IP地址是10.168.0.3
,如下所示:
对flanneld来说,只要node1和node2互通,那么flanneld作为node1上的普通进程就可以通过IP地址与node2通信。
flanneld收到c1发给c2的IP包后,就会把这个IP包直接封装在一个UDP包(这个包的源地址是node1,目的地址是node2),发送给node2。具体的说,是node1上的flanneld进程把UDP包发送到node2的8285端口(node2上flanneld监听的端口)。通过一个普通的宿主机之间的UDP通信,一个UDP包就从node1到达了node2。
node2上的flanneld进程接收到这个IP包之后将它发送给TUN设备(flannel0),数据从用户态向内核态转换,Linux内核网络栈负责处理这个IP包,即根据本机的路由表来寻找这个IP包的下一步流向。
node2上的路由表,也node1上的类似,如下所示:
这个IP包的目的地址是100.96.2.3
,这与第三条(100.96.2.0/24
)网段对应的路由规则匹配。Linux内核就会按照这条路由规则,把这个IP包转发给docker0网桥。然后docker0网桥扮演二层交换机的角色,将数据包发送给正确的端口,进而通过Veth Pair设备进入到c2的Network Namespace。c2返回给C1的数据包,通过上述过程相反的路径回到c1。
上述流程要正确工作的一个重要前提,docker0网桥的地址范围必须是Flannel为宿主机分配的子网。以Node1为例,需要给它上面的Docker Daemon启动时配置如下的bip参数。
Flannel UDP模式的跨主机通信的基本过程如下图所示:
Flannel UDP提供的是一个三层的Overlay Network,首先对发出端的IP包进行UDP封装,然后在接受端进行解封装拿到原始的IP包,进而把这个IP包转发给目标容器。就像Flannel在不同宿主机上的两个容器之间打通了一条隧道,使得两个容器能够直接使用IP地址进行通信,而无需关心容器和宿主机的分布情况。
UDP模式的性能问题在于,相比于宿主机直接通信,这种模式多了flanneld的处理过程。这个过程使用TUN,仅仅在发送IP包的过程中,就需要经过三次用户态与内核态之间的数据拷贝,如下图:
用户态的容器进程发出IP包经过docker0网桥进入内核态
IP包根据路由表进入TUN设备,从而回到用户态flanneld进程
flanneld进行UDP封包后重新进入内核态,将UDP包通过宿主机的eth0发送出去
UDP封装(Encapsulation)和解封装(Decapsulation)的过程是在用户态进行的。在Linux操作系统中,上下文的切换和用户态的操作代价比较高,这是UDP模式性能不好的主要原因。
在系统级编程时,非常重要的一个优化原则,减少用户态和内核态的切换次数,并且把核心的处理逻辑放在内核态执行。这也是VXLAN模式成为主流容器网络方案的原因。
Virtual Extensible LAN(虚拟可扩展局域网),是Linux内核本身就支持的一种网络虚拟化技术。所以VXLAN可以完全在内核态实现上述封装和解封装的工作,从而通过与上述相似的隧道机制,构建出覆盖网络(overlay network)。
VXLAN的设计思想:在现有三层网络之上,覆盖一层虚拟的、由内核VXLAN模块负责维护的二层网络,使得连接在这个VXLAN二层网络上的主机(虚拟机、容器)之间,可以像在同一个局域网那样自由通信。这些主机可以分布在不用的宿主机甚至不同的物理机房。
为了能够在二层网络上打通隧道,VXLAN会在宿主机上设置一个特殊的网络设备(VTEP,VXLAN Tunnel End Point隧道虚拟端点)作为隧道的两端。
VTEP设备的作用,与flanneld进程很相似。只不过它进行封装和解封装的对象,是二层数据帧(Ethernet frame),而且整个工作流程在内核里完成。因为VXLAN本身就是在Linux内核中的一个模块。
基于VTEP设备进行隧道通信的流程如下图:
在每台主机上有一个叫flannel.1的设备,这就是VXLAN所需要的VETP设备,它既有IP地址也有MAC地址。
假设C1的IP地址是10.1.15.2
,要访问C2的IP地址是10.1.16.3
,与UDP模式的流程类似:
当c1发出请求后,这个目的地址是10.1.16.3
的IP包,会先出现在docker0网桥
然后被路由到本机flannel.1设备进行处理,也就是来到了隧道入口
为了能够将这个IP数据包封装并且发送到正确的宿主机,VXLAN需要找到这条隧道的出口,即目的宿主机的VETP设备,这些设备信息由每台宿主机的flanneld进程负责维护。
当node2启动并加入到Flannel网络之后,node1以及其他所有节点上的flanneld就会添加一条如下的路由规则:
每个宿主机上的VETP设备之间需要构建一个虚拟的二层网络,即通过二层数据帧进行通信。即源VETP设备将原始IP包加上MAC地址封装成一个二层数据帧,发送到目的端VETP设备。
根据前面添加的路由记录,知道了目的VETP设备的IP地址,利用ARP表,根据三层IP地址查询对应的二层MAC地址。这里使用的ARP记录,也是flanneld进程在node2节点启动时,自动添加在node1上的。如下所示:
最新版的Flannel不依赖L3 MISS实现和ARP学习,而会在每台节点启动时,把它的VETP设备对应的ARP记录直接放在其他每台宿主机上。
有了MAC地址,Linux内核就可以开始二层封包工作,二层帧的格式如下:
上面封装的二层数据帧中的MAC地址是VETP的地址,对于宿主机网络来说没有实际意义,因此在Linux内核中需要把这个二层数据帧进一步封装成宿主机网络里的普通数据帧,这样就能通过宿主机eth0网卡进行传输。为了实现这个封装,Linux内核会在封装好的二层数据帧前加上一个特殊的VXLAN头,表示这是一个VXLAN要使用的数据帧,然后Linux内核会把这个数据帧封装进一个UDP包里发出去。
VXLAN头里面有一个重要的标志VNI,这个是VTEP设备识别某个数据帧是不是应该归自己处理的重要标识。在Flannel中,VNI默认为1,这与宿主机上的VETP设备的名称flannel.1 匹配。
与UDP模式类似,在宿主机看来,只会认为是自己的flannel.1在向另一台宿主机的flannel.1发送一个普通的UDP数据包。但是在这个UDP包中只包含了flannel.1的MAC地址,而不知道应该发给哪一台宿主机,所有flannel.1设备实际上要扮演网桥的角色,在二层网络进行UDP包转发。
在Linux内核里,网桥设备进行转发的依据,来自FDB(Forwarding Database)转发数据库。flanneld进程也需要负责维护这个flannel.1网桥对应的FDB信息,具体内容如下。
然后就是一个正常的宿主机网络上的封包工作。
UDP包是一个四层数据包,所有Linux内核要在它的头部加上IP头(Outer IP Header),组成一个IP包。并且在IP头中填写通过FDB查询到的目的主机的IP地址。
Linux在这个IP包前面加上二层数据帧(Outer Ethernet Header),并把node2的MAC地址(node1的ARP表要学习的内容,无需Flannel维护)填写进去,封装后的数据帧图下图所示。
封包完成后,node1上的flannel.1设备就可以把这个数据帧从node1的eth0网卡发出去,这个帧经过宿主机网络来到node2的eth0网卡。
node2的内核网络栈会发现这个数据帧里面的VXLAN头,VNI=1,内核进行拆包,根据数据帧的VNI值,把它交给node2的flannel.1设备。
flannel.1设备继续拆包,取出原始IP包,下面的步骤就是单机容器网络的处理流程。
最终IP包进入c2容器的Network Namespace。
VXLAN 模式组建的覆盖网络,其实就是一个由不同宿主机上的 VTEP 设备,也就是 flannel.1 设备组成的虚拟二层网络。对于 VTEP 设备来说,它发出的“内部数据帧”就仿佛是一直在这个虚拟的二层网络上流动,这也正是覆盖网络的含义。
要实现的目标是容器和容器通,容器和宿主机通,并且直接基于容器和宿主机的IP地址来进行通信:
所有容器都可以直接使用IP地址与其他容器通信,无需使用NAT
所有宿主机都可以直接使用IP地址与所有容器通信,而无需使用NAT,反之亦然
容器自己“看到”的自己的IP地址,和别人(宿主机或容器)看到的地址是完全一样的
从上面已知,容器跨主机网络的两种实现方式:UDP和VXLAN,有以下共同点:
用户的容器都是连接在docker0网桥上
网络插件在宿主机上创建一个特殊的设备,docker0与这个设备之间通过IP转发(路由表)进行协作
UDP模式创建的是TUN设备
VXLAN模式创建的是VETP设备
网络插件真正完成的是通过某种方法,把不同宿主机上的特殊设备连通,从而达到容器跨主机通信的目的
上述过程,也是kubernetes对容器网络的主要处理方式,kubernetes通过CNI接口,维护了一个单独的网桥(CNI网桥,cni0)来代替docker0。
以Flannel的VXLAN模式为例,在kubernetes环境下的工作方式如下图,只是把docker0换成cni0:
kubernetes为Flannel分配的子网范围是10.244.0.0/16
,这个参数在部署的时候指定:
也可以在部署完成后,通过修改kube-controller-manager
的配置文件来指定。
假设有两台宿主机,两个pod,pod1需要访问pod2
宿主机
pod
IP地址
node1
pod1
10.244.0.2
node2
pod2
10.244.1.3
pod1的eth0网卡也是通过Veth Pair的方式连接在node1的cni0网桥上,所有pod1中的IP包会经过cni0网桥出现在宿主机上。
node1上的路由表如下:
IP包的目的IP地址是10.244.1.3
,所以匹配第二条路由规则,这条规则指向本机的flannel.1设备进行处理
flannel.1处理完成后,要将IP包转发到网关,正是“隧道”另一端的VETP设备,即node2的flannel.1设备
接下来的处理流程和flannel VXLAN模式完全一样
CNI网桥只是接管所有CNI插件负责的部分,即kubernetes创建的pod。
如果此时使用docker run单独启动一个容器,那么docker项目会把这个容器连接到docker0网桥上,所以这个容器的IP地址一定是属于docker0网桥的172.17.0.0/16网段。
kubernetes之所以要设置这样一个与docker0网桥几乎一样的CNI网桥,主要原因包括两个方面:
kubernetes项目并没有使用docker的网络模型(CNM),所以不希望,也不具备配置docker0网桥的能力
这与kubernetes如何配置Pod,即Infra容器的Network Namespace密切相关
因为kubernetes创建一个Pod的第一步是创建并启动一个Infra容器,用来hold住这个pod的Network Namespace。所以CNI的设计思想是:kubernetes在启动Infra容器之后,就可以直接调用CNI网络组件,为这个Infra容器的Network Namespace配置符合预期的网络栈(网卡、回环设备、路由表、iptables)。
首先需要部署和运行CNI插件。
在部署kubernetes的时候,有一个步骤是安装kubernetes-cni包 ,它的目的就是在宿主机上安装CNI插件所需的基础可执行文件和配置文件。安装完成后:
在宿主机/etc/cni/net.d/
目录下看到CNI的配置文件;
在宿主机/opt/cni/bin/
目录下看到CNI二进制插件,如下所示。
从以上内容可以看出,实现一个kubernetes的容器网络方案,需要两部分工作,以Flannel为例:
实现网络方案本身,这部分需要编写flanneld进程里的主要逻辑,如创建和配置flannel.1设备,配置宿主机路由、配置ARP和FDB表里的信息
实现该网络方案对应的CNI插件,这部分主要是配置Infra容器里面的网络栈,并把它连接在CNI网桥上
Flannel项目对应的CNI插件已经内置在kubernetes项目中。其他项目如Weave、Calico等,需要安装插件,把对应的CNI插件的可执行文件放在
/opt/cni/bin/
目录下。对于Weave、Calico这样的网络方案来说,他们的DaemonSet只需要挂载宿主机的/opt/cni/bin/
,就可以实现插件可执行文件的安装。
在宿主机上安装flanneld(网络方案本身),flanneld启动后会在每一台宿主机上生成它对应的CNI配置文件(是一个ConfigMap),从而告诉Kubernetes,这个集群要使用Flannel作为容器网络方案。CNI配置文件内容如下:
在kubernetes中,处理容器网络相关的逻辑并不会在kubelet主干代码里执行,而是会在具体的CRI实现里完成。对于Docker项目来说,它的CRI实现是dockershim,在kubelet的代码中可以找到。所以dockershim会加载上述CNI配置文件。
目前,kubernetes不支持多个CNI插件混用,如果在CNI配置目录
/etc/cni/net.d
里面放置了多个CNI配置文件的话,dockershim只会加载按照字母顺序排序的第一个插件。不过CNI运行在一个CNI配置文件中,通过plugins字段,定义多个插件进行协作。上面的例子中,plugins字段指定了flannel和portmap两个插件。
dockershim会把CNI配置文件加载起来,并且把列表里的第一个插件(flannel)设置为默认插件,在后面的执行过程中,flannel和portmap插件会按照定义顺序被调用,从而依次完成“配置容器网络”和“配置端口映射”这两个操作。
在集群里面创建一个 Pod 的时候,首先会通过 apiserver 将 Pod 的配置写入。
apiserver 的管控组件(比如 Scheduler)会调度到某个具体的节点上去。
Kubelet 监听到这个 Pod 的创建之后,会在本地进行一些创建的操作。
当执行到创建网络这一步骤时,首先读取CNI配置目录中的配置文件,配置文件里面会声明所使用的是哪一个插件,然后去执行具体的 CNI 插件的二进制文件,再由 CNI 插件进入 Pod 的网络空间去配置 Pod 的网络。
配置完成之后,Kubelet 也就完成了整个 Pod 的创建过程,这个 Pod 就在线了。
当kubelet组件需要创建Pod的时候,第一个创建的一定是Infra容器。
dockershim调用Docker API创建并启动Infra容器
执行SetUpPod方法,为CNI插件准备参数
调用CNI插件(/opt/cni/bin/flannel
)为Infra容器配置网络
调用CNI插件需要为它准备的参数分为2部分:
设置环境变量
dockershim从CNI配置文件里加载到的、默认插件的配置信息
由dockershim设置的一组CNI环境变量,其中最重要的环境变量是CNI_COMMAND
,它的取值只有两种ADD/DEL。ADD和DEL操作是CNI插件唯一需要实现的两个方法。
ADD操作的含义:把容器添加到CNI网络里
DEL操作的含义:把容器从CNI网络里移除
对于网桥类型的CNI插件来说,这两个操作意味着把容器以Veth Pair的方式插到CNI网桥上或者从CNI网桥上拔出。
CNI的ADD操作需要的参数包括:
容器里网卡的名字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协议的一个重要方法。
配置信息在CNI中被叫作Network Configuration,dockershim会把Network Configuration以JSON格式,通过标准输入(stdin)的方式传递给Flannel CNI插件。
有了这两部分参数,Flannel CNI插件实现ADD操作的过程就很简单,需要在Flannel的CNI配置文件(/etc/cni/net.d/10-flannel.conflist
)里有一个delegate字段:
Delegate字段的意思是,CNI插件并不会自己做事,而是调用Delegate指定的某种CNI内置插件来完成。对于Flannel来说,它调用的Delegate插件,就是CNI bridge插件。
所以说,dockershim对Flannel CNI插件的调用,其实只是走个过程,Flannel CNI插件唯一需要做的,就是对dockershim传来的Network Configuration进行补充,如将Delegate的Type字段设置为bridge,将Delegate的IPAM字段设置为host-local等。
经过Flannel CNI插件的补充后,完整的Delegate字段如下:
其中,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 Configuration正是上面补充好的Delegate字段。Flannel CNI插件会把Delegate字段的内容以标准输入的方式传递给CNI bridge插件。Flannel CNI插件还会把Delegate字段以JSON文件的方式,保存在/var/lib/cni/flannel
目录下,这是给删除容器调用DEL操作时使用。
有了两部分参数,CNI bridge插件就可以代表Flannel,将容器加入到CNI网络里,这一步与容器Network Namespace密切相关。
首先,CNI bridge插件会在宿主机上检查CNI网桥是否存在。如果没有的话,那就创建它。相当于在宿主机上执行如下操作:
接下来,CNI bridge插件通过Infra容器的Network Namespace文件,进入到这个Network Namespace里面,然后创建一对Veth Pair设备。
然后,把这个Veth Pair的其中一端,移动到宿主机上,相当于在容器里执行如下命令:
CNI bridge 插件就可以把vethb4963f3设备连接到CNI网桥上。这相当于在宿主机上执行如下命令:
在将vethb4963f3设备连接在CNI网桥之后,CNI bridge插件还会为它设置Hairpin Mode(发夹模式),因为在默认情况下,网桥设备是不允许一个数据包从一个端口进来后再从这个端口发出去。开启发夹模式取消这个限制。这个特性主要用在容器需要通过NAT(端口映射)的方式,自己访问自己的场景。这样这个集群Pod才可以通过它自己的Service访问到自己。
CNI bridge插件会调用CNI ipam插件,从ipam.subnet
字段规定的网段里为容器分配一个可用的IP地址。然后,CNI bridge插件就会把这个IP地址添加到容器的eth0网卡上,同时为容器设置默认路由,这相当于执行如下命令:
最后,CNI bridge插件会为CNI网桥添加IP地址,相当于在宿主机上执行:
执行完上述操作后,CNI插件会把容器的IP地址等信息返回给dockershim,然后被kubelet添加到Pod的status字段。至此,CNI插件的ADD方法就宣告结束,接下来的流程就是容器跨主机通信的过程。
除了网桥模式的CNI插件,还有纯三层(Pure Layer3)的网络方案。如Flannel的host-gw模式和Calicao项目。
在大规模集群中,三层网络方案在宿主机上的路由规则可能会非常多,导致错误排除困难,系统故障时,路由规则重叠冲突的概率变大,在公有云部署,使用Flannel host-gw模式,在私有云部署,Calico能覆盖更多场景,提供更可靠的组网方案和架构思路。
工作原理如下图所示:
Node1的C1要访问Node2的C2,当设置Flannel使用hots-gw模式后,flanneld会在宿主机上创建如下规则:
下一跳地址是:如果IP包从主机A发送到主机B,需要经过路由设备X的中转,那么X的IP地址就应该配置为主机A的下一跳地址。
host-gw模式下,下一跳地址就是目的宿主机node2的地址。配置完成后,当IP包从网络层进入链路层封装成帧的时候,eth0设备就会使用下一跳地址对应的MAC地址(node2的MAC地址),作为该数据帧的目的地址。这样数据帧就能从node1通过宿主机的二层网络顺利到达node2上。Node2的内核网络栈从二层数据帧里拿到IP包后,看到IP包的目的IP地址是C2的IP,根据Node2的路由表,该目的地址会匹配第二条路由规则,从而进入cni0网桥,最后进入到C2中。
host-gw模式的工作原理:将每个Flannel子网(如10.244.1.0/24)的下一跳设置成该子网对应的宿主机的IP地址,宿主机会被充当这条容器通信路径里的网关。
Flannel子网和主机的信息都是保存在Etcd中,flanneld只需要WATCH这些数据的编号,然后实时更新路由表即可。
kubernetes v1.7之后,类似Flannel,Calico的CNI网络插件都是可以直接通过kubernetes的APIServer来访问Etcd的,无需额外部署Etcd。
这种模式下,容器通信的过程就免除了额外的封包和解包带来的性能损耗。实际测试:
host-gw的性能损耗在10%
VXLAN隧道机制的性能损耗在20~30%
host-gw模式能够正常工作的核心,就在于IP包在封装成帧发送出去的时候,会使用路由表的下一跳来设置目的MAC地址,这样,它就会经过二层网络到达目的宿主机。Flannel host-gw要求集群宿主机之间是二层连通的。
宿主机之间二层不连通的情况广泛存在。如宿主机分布在不同的子网(VLAN)里。但是在一个kubernetes集群里,宿主机之间必须可以通过IP地址进行通信,也就是至少三层可达。否则的话,集群将不满足宿主机之间IP互通的假设(kubernetes网络模型)。三层可达也能通过为几个子网设置三层转发来实现。
Calico也会在每台宿主机上添加一个路由规则,如下所示:
这个三层网络方案得以正常工作的核心:为每个容器的IP地址,找到它所对应的下一跳的网关。
不同于Flannel通过Etcd和宿主机上的flanneld来维护路由信息,Calico使用BGP(Border Gateway Protocol,边界网关协议)来自动地在整个集群中分发路由信息。
BGP是Linux内核原生支持的、专门用在大规模数据中心里维护不同的“自治系统”之间路由信息的、无中心的路由协议。
图中有两个自治系统(一个组织管辖下的所有IP网络和路由器的全体):AS1、AS2,正常情况下,自治系统之间不会有任何来往。如果自治系统中的主机,要通过IP地址直接进行通信,就必须使用路由器把这两个自治系统连接起来。
AS1的主机(10.10.0.2
)要访问AS2的主机(172.17.0.3
),发出的IP包就会先到达自治系统AS1的路由器Router1
Router1的路由表里有一条规则:目的地址的(172.17.0.2
)的包,应该经过Router1的C接口,发往网关Route2
IP包到到达Router2上,经过路由表从接口B出来达到目的主机(172.17.0.3
)
当主机(172.17.0.3
)要访问主机(10.10.0.2
),那么这个IP包,在到达Router2之后,就不知道该去哪里了,因为在Router2的路由表里,并没有关于AS1自治系统的任何路由规则
此时应该给Router2添加一条路由规则(如:目的地址10.10.0.2
的IP包,应该经过Router2的C接口,发送网关Router1)
像这样,负责把自治系统连接在一起的路由器,称为边界网关,与普通路由器的不同之处在于,路由表里拥有其他自治系统的主机路由信息。
当网络拓扑结构非常复杂,每个自治系统都有成千上万个主机、无数个路由器,甚至是多个分公司,多个网络提供商、多个自治系统的复合自治系统,依靠人工来对边界网关的路由表进行配置和维护,那是不现实的。此时就需要使用BGP,BGP会在每个边界网关上运行一个小程序,他们会将各自的路由表信息、通过TCP传输给其他的边界网关,其他边界网关上的这个小程序,就会对接收到的这些数据进行分析,然后将需要的信息添加到自己的路由表中。
BGP是在大规模网络中实现节点路由信息共享的一种协议。BGP的这个能力正好取代Flannel维护主机路由表的功能,而且,BGP这种原生就是为大规模网络环境而实现的协议,其可靠性和可扩展性,远非Flannel自己的方案可比。
Calico主要由如下几个部分组成:
Calico的CNI插件,这是Calico与kubernetes对接的部分
Felix,它是一个DaemonSet,负责在宿主机上插入路由规则(即,写入Linux内核的FIB转发信息库),以及维护Calico所需的网络设备等
BIRD,是BGP的客户端,专门负责在集群里分发路由规则信息
Calico和Flannel的host-gw的异同:
Calico不会在宿主机上创建任何网桥设备
对路由信息的维护方式不同
Calico在宿主机上设置的路由规则更多
都要求集群之间是二层连通
绿色实线标出的路径,就是一个IP包从node1的C1到node2的C4的完整路径。Calico的CNI插件会为每个容器设置一个Veth Pair设备,然后把其中的一端放置在宿主机上(cali前缀)。
由于Calico没有使用CNI的网桥模式,所有Calico的CNI插件还需要在宿主机上为每个容器的Veth Pair设备配置一条路由规则,用于接收传入的IP包。如宿主机node2的C4对应的路由规则如下:
有了Veth Pair设备之后,容器发送的IP包就会经过Veth Pair设备出现在宿主机上,然后,在宿主机网络栈就会根据路由规则的下一跳IP地址(最核心的这个路由规则,由Calico的Felix进程负责维护,这些路由规则信息,通过BGP Client也就是BIRD组件,使用BGP协议传输而来),把它们转发给正确的网关。
BGP协议传输的消息,类似如下格式:
Calico项目实际上将集群里的所有节点,都当作是边界路由器来处理,他们一起组成了一个全连通的网络,互相之间通过BGP协议交换路由规则,这些节点称为BGP Peer。
Node-to-Node Mesh的模式
Calico维护的网络在默认配置下,是一个被称为“Node-to-Node Mesh”的模式(通常推荐节点小于100)。每台宿主机上的BGP Client都需要跟其他所有节点的BGP Client进行通信以便交换路由信息,随着节点数量的增加,这些连接的数量会以N^2的规模增长,从而给集群本身的网络带来巨大的压力。
Route Reflector模式
规模比较大时,使用Route Reflector模式,在这中模式下,Calico会指定一个或者几个专门的节点,来负责跟所有节点建立BGP连接从而学习到全局的路由规则,其他节点只需要与这几个专门的节点(Route Reflector节点,扮演了中间代理的角色)交换路由信息,就能够获得整个集群的路由规则信息。这个模式可以把BGP连接的规模控制在N的数量级上。
IPIP模式
当两个节点在不同的子网下,节点中的容器需要通信时,如C1(192.168.1.2
)与C4(192.168.2.2
)进行通信,Calico会尝试在Node1上添加如下路由规则:
使用IPIP模式后,可以解决这个问题,Felix进程会在Node1上添加的路由规则会有变化,如下所示:
IP包进入IP隧道设备后,就会被Linux内核的IPIP驱动接管,IPIP驱动会将这个IP包直接封装在一个宿主机的IP包中,如下图:
经过封装后的新的IP包的目的地址(Outer IP Header部分),正是原IP包的下一跳地址(node2的ip 192.168.2.2
),原IP包本身,则会被直接封装成新IP包的Payload。
原先从容器到Node2的IP包,就被伪装成一个从Node1到Node2的IP包。
宿主机之间已经使用路由器配置了三层转发(即设置了宿主机之间的下一跳),所有IP包离开Node1之后,就可以经过路由器,最终跳到Node2上。
Node2的网络内核栈会使用IPIP驱动进行解包,从而拿到原始的IP包。
原始IP包经过路由规则和Veth Pair设备到达目的容器内部。
当使用IPIP模式时,集群的网络性能会因为额外的封包和解包工作而下降。性能大概和Flannel的VXLAN模式差不多。在实际使用时,尽量在一个子网中,避免使用IPIP模式。
如果Calico项目能让宿主机之间的路由设备(网关)也通过BGP协议学习到Calico网络里的路由规则,那么从容器发出的IP包,就可以通过这些设备路由到目的宿主机。
C1发出的IP包,通过两次下一跳,到达Router2。
在公有云环境下,宿主机之间的网关,是不允许用户进行干预和配置的。在大多数公有云环境下,宿主机(公有云提供的虚拟机)本身是二层连通的。
在私有云环境下,宿主机属于不同子网很常见,想办法将宿主机网关加入到BGP Mesh里从而避免使用IPIP。Calico提供了两种将宿主机网设置成BGP Peer的解决方案。
方案1:所有宿主机都与宿主机网关建立BGP Peer关系。这样每个节点都需要主动与宿主机网关建立BGP连接,从而将路由信息同步到网关上。这种方式,Calico要求宿主机网关必须支持Dynamic Neighbors的BGP配置,在常规的BGP配置中,运维人员必须明确给出所有BGP Peer的IP地址。kubernetes集群中宿主机动态增加节点,手动管理很麻烦,Dynamic Neighbors允许给路由配置一个网段,然后路由器会自动跟给网段里的主机建立BGP Peer关系。
方案2:使一个或多个独立组件搜集整个集群里所有路由信息,然后通过BGP协议同步给网关。在大规模集群中,Calico使用Router Reflector节点的方式进行组网,这些节点兼任负责与宿主机网关进行沟通的独立组件的任务。 这种情况下,BGP Peer数量有限且固定,可以直接把这些独立组件配置成路由器的BGP Peer,无需Dynamic Neighbors支持。这些独立组件只需要WATCH Etcd里的宿主机和对应网段的变化信息,然后把这些信息通过BGP协议分发给网关即可。
通常来说,CNI 插件可以分为三种:Overlay、路由及 Underlay。
Overlay模式的典型特征是容器独立于主机的 IP 段,这个 IP 段进行跨主机网络通信时是通过在主机之间创建隧道的方式,将整个容器网段的包全都封装成底层的物理网络中主机之间的包。该方式的好处在于它不依赖于底层网络;
路由模式中主机和容器也分属不同的网段,它与 Overlay 模式的主要区别在于它的跨主机通信是通过路由打通,无需在不同主机之间做一个隧道封包。但路由打通就需要部分依赖于底层网络,比如说要求底层网络有二层可达的一个能力;
Underlay模式中容器和宿主机位于同一层网络,两者拥有相同的地位。容器之间网络的打通主要依靠于底层网络。因此该模式是强依赖于底层能力的。
不同环境中所支持的底层能力是不同的。
虚拟化环境(例如 OpenStack)中的网络限制较多,比如不允许机器之间直接通过二层协议访问,必须要带有 IP 地址这种三层的才能去做转发,限制某一个机器只能使用某些 IP 等。在这种被做了强限制的底层网络中,只能去选择 Overlay 的插件,常见的有 Flannel-vxlan, Calico-ipip, Weave 等;
物理机环境中底层网络的限制较少,比如在同一个交换机下直接做一个二层的通信。对于这种集群环境,可以选择 Underlay 或者路由模式的插件。Underlay 意味着直接在一个物理机上插多个网卡或者是在一些网卡上做硬件虚拟化;路由模式就是依赖于 Linux 的路由协议做一个打通。这样就避免了像 vxlan 的封包方式导致的性能降低。这种环境下可选的插件包括 clico-bgp, flannel-hostgw, sriov 等;
公有云环境也是虚拟化,底层限制较多。但每个公有云都会考虑适配容器,提升容器的性能,因此每家公有云可能都提供了一些 API 去配置一些额外的网卡或者路由这种能力。在公有云上,要尽量选择公有云厂商提供的 CNI 插件以达到兼容性和性能上的最优。比如 Aliyun 就提供了一个高性能的 Terway 插件。
环境限制考虑完之后,我们心中应该都有一些选择了,知道哪些能用、哪些不能用。在这个基础上,再去考虑功能上的需求。
Kubernetes 支持 NetworkPolicy,通过 NetworkPolicy 的一些规则去支持“Pod 之间是否可以访问”这类策略。但不是每个 CNI 插件都支持 NetworkPolicy 的声明,如果有这个需求,可以选择支持 NetworkPolicy 的一些插件,比如 Calico, Weave 等。
应用最初都是在虚拟机或者物理机上,容器化之后,应用无法一下就完成迁移,因此就需要传统的虚拟机或者物理机能跟容器的 IP 地址互通。为了实现这种互通,就需要两者之间有一些打通的方式或者直接位于同一层。此时可以选择 Underlay 的网络,比如 sriov 这种就是 Pod 和以前的虚拟机或者物理机在同一层。也可以使用 calico-bgp,此时它们虽然不在同一网段,但可以通过它去跟原有的路由器做一些 BGP 路由的一个发布,这样也可以打通虚拟机与容器。
Kubernetes 的服务发现与负载均衡就是Service,但并不是所有的 CNI 插件都能实现这两种能力。比如很多 Underlay 模式的插件,在 Pod 中的网卡是直接用的 Underlay 的硬件,或者通过硬件虚拟化插到容器中的,这个时候它的流量无法走到宿主机所在的命名空间,因此也无法应用 kube-proxy 在宿主机配置的规则。
这种情况下,插件就无法访问到 Kubernetes 的服务发现。因此如果需要服务发现与负载均衡,在选择 Underlay 的插件时就需要注意它们是否支持这两种能力。
经过功能需求的过滤之后,能选的插件就很少了。经过环境限制和功能需求的过滤之后,如果还剩下 3、4 种插件,可以再来考虑性能需求。
从 Pod 的创建速度和 Pod 的网络性能来衡量不同插件的性能。
当创建一组 Pod 时,比如业务高峰来了,需要紧急扩容,这时比如扩容 1000 个 Pod,就需要 CNI 插件创建并配置 1000 个网络资源。
Overlay 和路由模式在这种情况下的创建速度是很快的,因为它是在机器里面又做了虚拟化,所以只需要调用内核接口就可以完成这些操作。
Underlay 模式,由于需要创建一些底层的网络资源,所以整个 Pod 的创建速度相对会慢一些。因此对于经常需要紧急扩容或者创建大批量的 Pod 这些场景,应该尽量选择 Overlay 或者路由模式的网络插件。
主要表现在两个 Pod 之间的网络转发、网络带宽、PPS 延迟等这些性能指标上。
Overlay 模式的性能较差,因为它在节点上又做了一层虚拟化,还需要去封包,封包又会带来一些包头的损失、CPU 的消耗等,如果对网络性能的要求比较高,比如说机器学习、大数据这些场景就不适合使用 Overlay 模式。
这种情形下我们通常选择 Underlay 或者路由模式的 CNI 插件。
通过这三步的挑选之后都能找到适合自己的网络插件。
CNI 插件的实现通常包含两个部分:
(给Pod插网线)二进制的 CNI 插件去配置 Pod 网卡和 IP 地址。这一步配置完成之后相当于给 Pod 上插上了一条网线,Pod已经有自己的IP和网卡了;
(给Pod连网络)Daemon 进程去管理 Pod 之间的网络打通。这一步相当于说将 Pod 真正连上网络,让 Pod 之间能够互相通信。
通常用一个 "veth" 这种虚拟网卡,一端放到 Pod 的网络空间,一端放到主机的网络空间,这样就实现了 Pod 与主机这两个命名空间的打通。
这个 IP 地址有一个要求,这个 IP 地址在集群里需要是唯一的。一般来说在创建整个集群的时候会指定 Pod 的一个大网段,按照每个节点去分配一个 Node 网段。
比如说上图右侧:
创建的是一个 172.16 的网段,再按照每个节点去分配一个 /24 的段,这样就能保障每个节点上的地址是互不冲突的。
然后每个 Pod 再从一个具体的节点上的网段中再去顺序分配具体的 IP 地址,比如 Pod1 分配到了 172.16.0.1,Pod2 分配到了 172.16.0.2,这样就实现了在节点里面 IP 地址分配的不冲突,并且不同的 Node 又分属不同的网段,因此不会冲突。
将分配到的 IP 地址配置给 Pod 的虚拟网卡;
在 Pod 的网卡上配置集群网段的路由,令访问的流量都走到对应的 Pod 网卡上去,并且也会配置默认路由的网段到这个网卡上,也就是说走公网的流量也会走到这个网卡上进行路由;
在宿主机上配置到 Pod 的 IP 地址的路由,指向到宿主机对端 veth1 这个虚拟网卡上。
这样实现的是从 Pod 能够到宿主机上进行路由出去的,同时也实现了在宿主机上访问到 Pod 的 IP 地址也能路由到对应的 Pod 的网卡所对应的对端上去。
给 Pod 插上网线,就是给它配了 IP 地址以及路由表。然后要让每一个 Pod 的 IP 地址在集群里面都能被访问到。一般在 CNI Daemon 进程中去做这些网络打通的事情。通常来说是这样一个步骤:
CNI 在每个节点上运行的 Daemon 进程会学习到集群所有 Pod 的 IP 地址及其所在节点信息。学习的方式通常是通过监听 Kubernetes APIServer,拿到现有 Pod 的 IP 地址以及节点,并且新的节点和新的 Pod 的创建的时候也能通知到每个 Daemon;拿到 Pod 以及 Node 的相关信息之后,再去配置网络进行打通。
Daemon 会创建到整个集群所有节点的通道。这里的通道是个抽象概念,具体实现一般是通过 Overlay 隧道、路由表、或者是机房里的 BGP 路由完成的;
将所有 Pod 的 IP 地址跟上一步创建的通道关联起来。关联也是个抽象概念,具体的实现通常是通过 Linux 路由、fdb 转发表或者OVS 流表等完成的。
Linux 路由可以设定某一个 IP 地址路由到哪个节点上去。
FDB(forwarding database) 转发表,就是把某个 Pod 的 IP 转发到某一个节点的隧道端点上去(Overlay 网络)。
OVS 流表是由 Open vSwitch 实现的,它可以把 Pod 的 IP 转发到对应的节点上。
kubernetes的网络模型,只是关注容器之间网络的“连通”,却不关心容器之间网络的“隔离”。如何实现网络的隔离来满足多租户的需求。
kubernetes的网络模型以及大多数容器网络实现,即不会保证容器之间二层网络的互通,也不会实现容器之间二层网络的隔离,这与IaaS项目管理的虚拟机是完全不同的。kubernetes从底层的设计和实现上,更倾向于假设已有一套完整的物理基础设施,kubernetes负责在此基础上提供“弱多租户”的能力。
在kubernetes中,网络隔离能力的定义,依靠专门的API对象来描述,NetworkPolicy(宿主机上一系列iptables规则,与传统IaaS的安全组类似),它定义的规则其实是白名单。
一个完整的NetworkPolicy对象的示例如下:
kubernetes里的Pod默认都是“允许所有(Accept All)”,即:
Pod可以接收来自任何发送方的请求
向任何接收方发送请求
如果要对这个情况做出限制,就必须通过NetworkPolicy对象来指定。
一旦pod被NetworkPolicy选中,那么就会进入“拒绝所有”(Deny All)的状态,即:
这个pod不允许被外界访问
也不允许对外界发起访问
上述NetworkPolicy独享指定的隔离规则如下:
该隔离规则只对default namespace下携带role=db标签的pod有效,限制的请求类型包括ingress和egress
kubernetes会拒绝任何访问被隔离Pod的请求,除非这个请求来自白名单里的对象,并且访问的是被隔离Pod的6379端口
kubernetes会拒绝被隔离Pod对外发起任何请求,除非请求的目的地址属于10.0.0.0/24
网段,并且访问的是该网段地址的5978端口
白名单对象包括:
default namespace中携带role=fronted标签的pod
任何namespace中携带project=myproject标签的pod
任何源地址属于172.17.0.0/16
网段,且不属于172.17.1.0/24
网段的请求
要使用上述定义的NetworkPolicy在kubernetes集群中真正产生作用,需要CNI网络插件支持kubernetes的NetworkPolicy。
凡是支持NetworkPolicy的CNI网络插件,都维护着一个NetworkPolicy Controller,通过控制循环的方式对NetworkPolicy对象的增删改查做出响应,然后在宿主机上完成iptables规则的配置工作。
目前实现NetworkPolicy的网络插件包括Calico、Weave和kube-router等。在使用Flannel的同时要使用NetworkPolicy的话,就需要在额外安装一个网络插件,如Calico来负责执行NetworkPolicy。
以三层网络插件(Calico和kube-router)为例,分析一下实现原理。
创建一个NetworkPolicy对象,如下:
kubernetes网络插件使用上述NetworkPolicy定义,在宿主机上生成iptables规则,具体过程如下:
kubernetes网络插件对Pod进行隔离,其实是靠宿主机上生成的NetworkPolicy对应的iptables规则来实现的。在设置好上述“隔离”规则之后,网络插件还需要想办法,将所有对被隔离Pod的访问请求,都转发到上述KUBE-NWPLCY-CHAIN规则上去进行匹配,如果匹配不通过这个请求应该被“拒绝”。
负责拦截对被隔离Pod的访问请求,生成这一组规则的伪代码如下:
iptables是网络层检查点,在Linux内核Netfilter子系统的“操作界面”,Netfilter子系统的作用,就是Linux内核里挡在“网卡”和“用户态进程”之间的一道“防火墙”,他们之间的关系如下所示:
IP包一进一出的两条路径上,有几个关键的”检查点“,它们正是Netfilter设置”防火墙“的地方。在iptables中,这些检查点被称为链(Chain)。这些检查点对应的iptables规则是按照定义顺序依次进行匹配的,具体工作原理如下图所示:
当一个IP包通过网卡进入主机之后,它就进入了Netfilter定义的流入路径(Input Path)里。在这个路径中,IP包要经过路由表来决定下一步的去向
在这次路由之前,Netfilter设置了PREROUTING的检查点
经过路由之后,IP包的去向分为两种:
继续在本机处理
IP包将继续向上层协议栈流动,在它进入传输层之前,Netfilter会设置INPUT检查点,至此,IP包流入路径(Input Path)结束
IP包通过传输层进入用户空间,交给用户进程处理
处理完成后,用户进程通过本机发出返回的IP包,此时,IP包就进入流出路径(Output Path)
IP包首先经过主机的路由表进行路由
路由结束后,Netfilter设置OUTPUT检查点
在OUTPUT之后再设置POSTROUTING检查点
被转发到其他目的地
IP包不进入传输层,继续在网络层流动,从而进入转发路径(Forward Path)
转发路径中,Netfilter会设置FORWARD检查点
在FORWARD检查点完成后,IP包来到流出路径(Output Path)
转发的IP包目的地已经确定,不再经过路由,直接到达POSTROUTING检查点
在Linux内核实现里,所谓”检查点“实际上就是内核网络协议代码里的Hook(比如,在执行路由判断的代码之前,内核会先调用PREROUTING的Hook)。POSTROUTING的作用,是上述两条路径,最终汇聚在一起的”最终检查点”。
在网桥参与的情况下,上述Netfilter设置检查点的流程,实际上也会出现在链路层(二层),并且会跟上面的网络层(三层)的流程有交互。链路层的检查点对应的操作界面叫作ebtables。
数据包在Linux Netfilter子系统里完整的流动过测井如下图所示:
上述过程是途中绿色部分,即网络层的iptables链的工作流程。
KUBE-POD-SPECIFIC-FW-CHAIN,做出允许或拒绝的判断,这部分功能的实现,类似如下的iptables规则:
kubernetes使用Service的原因:
Pod的IP地址不固定
一组Pod之间有负载均衡的需求
Service机制和DNS插件都是为了解决同一个问题,如何找到某个容器。在平台级项目中称为服务发现,即当一个服务(Pod)的IP地址是不固定的且没办法提前获知时,该如何通过固定的方式访问到这个Pod。
ClusterIP模式的Service,提供的是一个Pod的稳定的IP地址,即VIP,并且pod和Service的关系通过Label确定。
Headless Service,提供的是一个Pod的稳定的DNS名字,并且这个名字可以通过Pod名字和Service名字拼接出来。
被选中的Pod就是Service的Endpoints,使用kubectl get ep
可以看到如下所示:
通过该Service的VIP地址10.0.1.175
,就能访问到它代理的Pod:
这个VIP地址是kubernetes自动为Service分配的。通过三次连续不断地访问Service的VIP地址和代理端口80,依次返回三个Pod的hostname,Service提供的是RoundRobin方式的负载均衡。这种方式称之为ClusterIP模式的Service。
如下图所示,Kubernetes 服务发现以及 Service 是这样整体的一个架构。
Kubernetes 分为 master 节点和 worker 节点:
master 里面主要是 Kubernetes 管控的内容;
worker 节点里面是实际跑用户应用的一个地方。
在 Kubernetes master 节点里面有 APIServer,是统一管理 Kubernetes 所有对象的地方,所有的组件都会注册到 APIServer 上面去监听这个对象的变化,比如 pod 生命周期发生变化,这些事件。
这里面最关键的有三个组件:
Cloud Controller Manager,负责去配置 LoadBalancer 的一个负载均衡器给外部去访问;
Coredns,就是通过 Coredns 去观测 APIServer 里面的 service 后端 pod 的一个变化,去配置 service 的 DNS 解析,实现通过 service 的名字直接访问到 service 的虚拟 IP,或者是 Headless 类型的 Service 中的 IP 列表的解析;
每个节点上的kube-proxy,通过监听 service 以及 pod 变化,然后去配置集群里面的 nodeport 或者是虚拟 IP 地址。
从集群内部的一个 Client Pod3 去访问 Service:
Client Pod3 首先通过 Coredns 解析出 ServiceIP,Coredns 会返回给它 ServiceName 所对应的 service IP ,
Client Pod3 拿 Service IP 去做请求,它的请求到宿主机的网络之后,就会被 kube-proxy 所配置的 iptables 或者 IPVS 拦截并处理,
然后负载均衡到实际的后端 pod 上,这样就实现了一个负载均衡以及服务发现。
对于外部的流量,通过公网访问:
通过外部的负载均衡器 Cloud Controller Manager 监听 service 的变化,然后配置负载均衡器,将请求转发到节点上的一个 NodePort,
NodePort 经过 kube-proxy 配置的iptables规则,把 NodePort 的流量转换成 ClusterIP,
然后转换成后端 pod 的 IP 地址,并做负载均衡以及服务发现。
这就是整个 Kubernetes 服务发现以及 Kubernetes Service 整体的结构。
Service是由kube-proxy组件,加上iptables来共同实现。
待创建的Service,一旦提交给kubernetes,那么kube-proxy就可以通过Service的Informer感知到这样一个Service对象的添加操作。作为对这个事件的响应,就会在宿主机上创建如下所示的iptables规则。
这三条链指向的最终目的地,其实就是这个Service代理的三个pod。所以这一组规则,就是Service实现负载均衡的位置。
iptables规则匹配是从上到下逐条进行的,所以为了保证上述三条规则,每条被选中的概率一样,应该将他们的probability字段的值分别设置为1/3(0.333)、1/2和1。第一条选中的概率是三分之一,第一条没选择剩下两条的概率是二分之一,最后一条为1。
Service进行转发的具体原理如下所示:
这是三条DNAT规则,在DNAT规则之前,iptables对流入的IP包还设置了一个标志(--set-xmark
)。DNAT规则的作用就是在PREROUTING检查点之前,即路由之前,将流入IP包的目的地址和端口,改成--to-destination
所指定的新的目的地址和端口。
这样访问Service VIP的IP包经过上述iptables处理之后,就已经成了访问具体某一个后端Pod的IP包了。这些Endpoints对应的iptables规则,正是kube-proxy通过监听Pod的变化时间,在宿主机上生成并维护的。
kube-proxy通过iptables处理Service的过程,需要在宿主机上设置相当多的iptables规则,而且,kube-proxy还需要在控制循环里不断地刷新这些规则来始终保持正确。当宿主机上有大量pod的时候,成百上千条iptables规则在不断地刷新,会大量占用该宿主机的CPU资源,甚至会让宿主机“卡”在这个过程中。一直以来,基于iptables的Service实现,都是制约kubernetes项目承载更多量级的Pod的主要障碍。
IPVS模式的Service是解决这个问题行之有效的方法。
工作原理,与iptables模式类似,创建了Service之后,kube-proxy首先会在宿主机上创建一个虚拟网卡(kube-ipvs0),并为它分配Service VIP作为IP地址,如下所示:
kube-proxy就会通过Linux的IPVS模式,为这个IP地址设置三个IPVS虚拟主机,并设置这个虚拟主机之间使用的轮询模式(rr)来作为负载均衡策略,通过ipvsadm查看这个设置,如下所示:
这三个IPVS虚拟主机的IP地址和端口,对应的正是三个被代理的Pod。这样任何发往10.102.128.4:80
的请求,就都会被IPVS模块转发到某一个后端Pod上了。
相比于iptables,IPVS在内核中的实现其实也是基于Netfilter的NAT模式,所以在转发这一层上,理论上IPVS并没有显著的性能提升。但是,IPVS并不需要在宿主机上为每个Pod设置iptables规则,而是把这些“规则”的处理放在内核态,从而极大地降低了维护这些规则的代价。
将重要操作放在内核态是提高性能的重要手段。
IPVS模块只负责上述的负载均衡和代理功能。而一个完整的Service流程正常工作所需要的包过滤,SNAT等操作,还是依靠iptables来实现,不过这些附属的iptables数量有限,也不会随着pod数量的增加而增加。
在大规模集群里,建议kube-proxy设置
--proxy-mode=ipvs
来开启这个功能,它为kubernetes集群规模带来的提升是非常巨大的。
Service与DNS也有关系,在kubernetes中,Service和Pod都会被分配对应的DNS A记录(从域名解析IP的记录)。
对于ClusterIP模式的Service来说,它的A记录的格式是:..svc.cluster.local
。当你访问这个A记录的时候,它解析到的就是该Service的VIP地址。它代理的Pod被自动分配的A记录格式是:..pod.cluster.local
,这条记录指向Pod的IP地址。
对于执行clusterIP=None的Headless Service来说,它的A记录的格式也是:..svc.cluster.local
,但是访问这个A记录的时候,它返回的是所代理的Pod的IP地址集合。(如果客户端无法解析这个集合,那可能只会拿到第一个Pod的IP地址)。它代理的Pod被自动分配的A记录的格式是:..svc.cluster.local
。这条记录指向Pod的IP地址。
如果为pod指定了Headless Service,并且Pod本身声明了
hostname
和subdomain
字段,那么Pod的A记录就会变成:<pod的hostname>...svc.cluster.local
。
通过busybox-1.default-subdomain.default.svc.cluster.local
解析到这个pod的IP地址。
在kubernetes中,/etc/hosts
文件是单独挂载的,所以kubelet能够对hostname进行修改并且pod重建后依然有效,与Docker的init层是一个原理。
Service的访问信息在kubernetes集群之外是无效的。
Service的访问入口,就是每台宿主机上由kube-proxy生成的iptables规则,以及kube-dns生成的DNS记录。一旦离开这个集群,这些信息对用户来说,是没有作用的。
Kubernetes中提供了丰富多样的Service,从集群之外,访问到Kubernetes里创建的Service,则通过2~5这四种类型实现:
ClusterIP:Node内部使用,将Service承载在一个内部ClusterIP上,集群内部的一个虚拟 IP,这个 IP 会绑定到一组提供服务的 Pod 上,是默认的服务方式。缺点是只能在 Node 内部也就是集群内部使用。
Nodeport:供集群外部调用,将Service承载在Node的静态端口上,会自动创建一个ClusterIP机制,端口号和Service一一对应,那么集群外的用户就可以通过 <NodeIP>:<NodePort>
的方式调用到 Service。
LoadBalancer:给云厂商的扩展接口。将Service通过外部云厂商的负载均衡接口承载,会自动创建 NodePort 和 ClusterIP 这里两种机制,云厂商可以选择直接将 LB 挂到这两种机制上,或者两种都不用,直接把 Pod 的 RIP 挂到云厂商的 ELB 的后端也是可以的。
ExternalName:摈弃内部机制,依赖外部设施,将 Service 完全映射到外部的一个域名,整个负载均衡的工作都是外部实现的,Service与CName挂钩,内部不会创建任何机制。
externalIPs:外部访问的另一种方式,需要保证externalIPs是集群中的某个节点的IP地址,直接把Service的端口暴露在该节点的宿主机网络上,通过端口对外提供服务。
不显示声明nodePort字段,会随机分配30000-32767
之间的端口,通过kube-apiserver的--service-node-port-range
参数来修改它。
访问以上Service<任何一台宿主机的IP地址>:8080
,就能够访问到某一个被代理的Pod的80端口。
NodePort模式的工作原理,是kube-proxy在每台宿主机上生成一条iptables规则,如下所示:
在NodePort模式下,kubernetes会在IP包离开宿主机发往目的Pod时,对这个IP包做一次SNAT操作,如下所示:
SNAT操作只需要对Service转发出来的IP包进行(否则普通的IP包就被影响了)。iptables做这个判断的依据就是查看该IP包是否有一个0x4000
的标志,这个标志是在IP包被执行DNAT操作之前被打上的。
原理如下图:
当一个外部的client通过node2的地址访问一个Service的时候,node2上的负载均衡规则就可能把这个IP包转发给一个node1上的pod,当node1上的这个pod处理完请求之后,它就会按照这个IP包的源地址发出回复。
如果没有SNAT操作,这个时候被转发的IP包源地址就是client的IP地址,pod就会直接回复client,对于client来说,它的请求明明是发给node2,收到的回复却来自node1,此时client可能会报错。
所以当IP包离开node2之后,它的源IP地址就会被SNAT改成node2的CNI网桥或者node2自己的地址。这样pod处理完成后就会先回复给node2(而不是直接给client),然后node2发送给client。
这样的话pod只知道这个IP包来自node2,而不是外部client,对于pod需要知道所有请求来源的场景来说,这是不行的。需要将Service的spec.externalTrafficPolicy
字段设置为local
,保证所有pod通过Service收到请求之后,一定可以看到真正的、外部client的源地址。
这个机制的实现原理:一台宿主机上的iptables规则会设置为只将IP包转发给运行在这台宿主机上的Pod。这样pod就可以直接使用源地址将回复包发出,不需要事先进行SNAT。操作流程如下:
适用于公有云上的Kubernetes集群的访问方式,指定一个LoadBalancer类型的Service,如下所示:
在公有云提供的kubernetes服务里,都是用了CloudProvider的转接层,来跟公有云本身的API进行对接。所有在LoadBalancer类型的Service被提交后,kubernetes就会调用CloudProvider在公有云上创建一个负载均衡服务,并且被代理的Pod的IP地址配置给负载均衡服务器做后端。
kubernetes v1.7之后支持的新特性,ExternalName,如下:
指定一个externalName=my.database.example.com
字段,并且不需要指定selector。通过Service的DNS名字(如my-service.service.default.svc.cluster.local
)访问的时候,kubernetes返回的是my.database.example.com
,所有externalName类型的Service,其实是在kube-dns里添加一条CNAME记录,此时访问my-service.service.default.svc.cluster.local
和访问my.database.example.com
是一个效果。
同时,kubernetes的Service还可以为Service分配公有IP地址,如下:
指定externalIPs=80.11.12.10
,此时通过访问80.11.12.10
访问被代理的pod。在这里kubernetes要求externalIPs必须是至少能够路由到一个kubernetes节点的。
很多与Service相关的问题,都可以通过分享Service在宿主机上对应的iptables规则(或者IPVS配置)得到解决。
当Service无法通过DNS访问时,区分是Service本身的配置问题还是集群的DNS出现问题。通过检查kubernetes自己Master节点的Service DNS是否正常:
如果上述访问kubernetes.default
返回的值都有问题,那么就需要检查kube-dns的运行状态和日志。否则就应该去检查Service定义是否有问题。
如果Service没办法通过ClusterIP访问到,首先应该检查这个Service是否有Endpoints:
如果Endpoints正常,就需要确认kube-proxy是否正确运行。通过kubeadm部署的集群中,kube-proxy的输出日志如下:
如果kube-proxy一切正常,就应该查看宿主机上iptables。一个iptables模式的Service对应的规则应该包括:
KUBE-SERVICES或者KUBE-NODEPORTS规则对应的Service入口链,这个规则应该与VIP和Service端口一一对应
KUBE-SEP-(hash)规则对应的DNAT链,这些规则应该与Endpoints一一对应
KUBE-SVC-(hash)规则对应的负载均衡链,这些规则的数目应该与Endpoints数目一致
如果NodePort模式的话,还有POSTROUTING处的SNAT链
通过查看链的数量、转发目的地址、端口、过滤条件等信息,能发现异常的原因。
Pod无法通过Service访问到自己。这是因为kubelet的hairpin-mode没有被正确的设置,只需要将kubelet的hairpin-mode设置为hairpin-veth或者promiscuous-bridge即可。
hairpin-veth模式下,应该看到CNI网桥对应的各个VETH设备,都将Hairpin模式设置为1,如下所示;
promiscuous-bride模式。应该看到CNI网桥的混杂模式(PROMISC)被开启,如下所示:
所谓Service就是kubernetes为Pod分配的、固定的、基于iptables(或者IPVS)的访问入口,这些访问入口代理的Pod信息,来自Etcd并由kube-proxy通过控制循环来维护。
kubernetes里的Service和DNS机制,都不具备强多租户能力。在多租户情况下:
每个租户应该拥有一套独立的Service规则(Service只应该看到和代理同一个租户下的Pod)
每个租户应该拥有自己的kube-dns(kube-dns只应该为同一个租户下的Service和Pod创建DNS Entry)
在kubernetes中,kube-proxy和kube-dns都只是普通的插件,可以根据自己的需求,实现符合自己预期的Service。
在Service对外暴露的是三种方法中,LoadBalancer类型的Service,会在Cloud Provider(如GCP)里面创建一个该Service对应的负载均衡服务。
但是每个Service都要一个负载均衡服务,这个做法实际上很浪费而且成本高。如果在kubernetes中内置一个全局的负载均衡器,然后通过访问的URL,把请求转发给不同的后端Service。这种全局的、为了代理不同后端Service而设置的负载均衡服务,就是kubernetes中的Ingress服务。Ingress其实就是Service的“Service”。
假设有一个网站,
https://cage.example.com
,其中https://cafe.example.com/coffee
对应的是咖啡点餐系统,而https://cafe.exapmle.com/tea
对应的是茶水点餐系统。这两个系统,分别由名叫coffee和tea的Deployment来提供服务。
使用kubernetes的Ingress来创建一个统一的负载均衡器,实现当用户访问不同的域名时,能够访问到不同的Deployment,只要定义如下的Ingress对象即可:
Fully Qualified Domian Name 的具体格式:FQDN。
当用户访问cafe.example.com
的时候,实际上访问到的是这个Ingress对象。这样,kubernetes就能使用IngressRule来对请求进行下一步转发。Ingress对象,其实就是kubernetes项目对“反向代理”的一种抽象。一个Ingress对象的主要内容,实际上是一个“反向代理”服务(如Nginx)的配置文件的描述。这个代理服务对应的转发规则,就是IngressRule。
所以在每个IngressRule里,都需要有:
host
字段:作为这条IngressRule的入口
一系列path
字段:声明具体的转发策略(这与Nginx、HAproxy的配置文件的写法是一致的)
有了Ingress这样统一的抽象,kubernetes用户就无需关系Ingress的具体细节,在实际的使用中,只需要选择一个具体的Ingress Controller,把它部署在kubernetes集群里即可。Ingress Controller根据定义的Ingress对象,提供对应的代理能力。
业界常用的反向代理项目,Nginx、HAproxy、Envoy、Traefik等,都已经为kubernetes专门维护了对应Ingress Controller。
这个pod本身,就是一个监听Ingress对象以及它所代理的后端Service变化的控制器。当一个新的Ingress对象由用户创建后,nginx-ingress-controller就会根据Ingress对象里定义的内容,生成一份对应的Nginx配置文件(/etc/nginx/nginx.conf
),并使用这个配置文件启动一个Nginx服务。
一旦Ingress对象被更新,nginx-ingress-controller就会更新这个配置文件,需要注意的是,如果这里只是被代理的Service对象被更新,nginx-ingress-controller所管理的Nginx服务是不需要重新加载的。因为nginx-ingress-controller通过Nginx Lua方案实现了Nginx Upstream的动态配置。
nginx-ingress-controller运行通过ConfigMap对象来对上述Nginx的配置文件进行定制。这个ConfigMap的名字需要以参数的形式传递个nginx-ingress-controller。在这个ConfigMap里添加的字段,将会被合并到最后生成的Nginx配置文件当中。
一个Nginx Ingress Controller提供的服务,其实是一个可以根据Ingress对象和被代理的后端Service的变化来自动更新的Nginx负载均衡器。
Ingress Controller和它所需要的Service部署完成后,就可以使用它了。
如果请求没有匹配到IngressRule,会返回Nginx的404页面,因为这个Nginx Ingress Controller是Nginx实现的。
Ingress Controller运行通过Pod启动命令的
--default-backend-service
参数,设置一条默认的规则,如--default-backend-service=nginx-default-backend
。这样任何匹配失败的请求,都会被转发到这个nginx-default-backend
的Service。可以专门部署一个专用的pod,来为用户返回自定义的404页面。
目前,Ingress只能工作在七层,Service只能工作在四层,所有想要在kubernetes里为应用进行TLS配置等HTTP相关的操作时,都必须通过Ingress来进行。