0.1. 容器通信 0.1.1. 例子 0.1.2. 容器间访问(同宿主机) 0.1.3. 宿主机访问容器 0.1.4. 容器访问宿主机 0.1.5. 容器间访问(跨主机) Linux容器能够看见的“网络栈”,是被隔离在它自己的Network Namespace当中的。 网络栈,包括: 网卡(Network Interface) 回环设备(Loopback Device) 路由表(Routing Table) iptables规则 对于一个进程来说,这些要素就构成了它发起和响应网络请求的基本环境。 作为一个容器,可以直接使用宿主机的网络栈,即不开启Network Namespace,如下: docker run -d -net=host --name nginx-host nginx # 这个容器启动后,直接监听宿主机的80端口 这样直接使用宿主机网络栈的方式: 好处:为容器提供良好的网络性能 缺点:引入共享网络资源的问题,比如端口冲突 所以在大多数情况下,都希望容器进程能使用自己的Network Namespace里的网络栈,即拥有自己的IP地址和端口。 0.1. 容器通信 被隔离的容器进程,该如何与其他Network Namespace里的容器进程进行交互? 将一个容器理解为一台主机,拥有独立的网络栈,那么主机之间通信最直接的方式就是通过网线,当有多台主机时,通过网线连接到交换机再进行通信。 在Linux中,能够起到虚拟交换机作用的网络设备,就是网桥(Bridge),工作在数据链路层(Data Link)的设备,主要功能根据MAC地址学习来将数据包转发到网桥的不同端口(Port)上。 Docker项目默认在宿主机上创建一个docker0网桥,凡是连接在docker0网桥上的容器,就可以通过它来进行通信。使用Veth Pair的虚拟设备把容器都连接到docker0网桥上。 Veth Pair设备的特点:它被创建后,总是以两张虚拟网卡(Veth Peer)的形式成对出现的,并且从其中一个“网卡”发出的数据包,可以直接出现在与它对应的另一张“网卡”上,哪怕这两个“网卡”在不同的Network Namespace中。所有Veth Pair常被用作连接不同Network Namespace的“网线”。 0.1.1. 例子 启动一个容器,并进入后查看它的网络设备,然后回到宿主机查看网络设备: docker run –d --name nginx-1 nginx # 在宿主机上 docker exec -it nginx-1 /bin/bash # 在容器里 root@2b3c181aecf1:/# ifconfig # 这张网卡是Veth Pair设备在容器里的一端 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 # 容器内默认路由设备,是eth0网卡 172.17.0.0 0.0.0.0 255.255.0.0 U 0 0 0 eth0 # 所有对172.17.0.0/16这个网段的请求,也会被交给eth0来处理 # 网关为0.0.0.0表示这是一条直连规则,凡是匹配到这个规则的IP包,应该经过本机的eth0网卡,通过二层网络直接发往目的主机 # 在宿主机上 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) # nginx-1容器对应的Veth Pair设备,在宿主机上是这个虚拟网卡 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 # 查看网桥,可以看到上面的虚拟网卡被连接到了docker0网桥上 bridge name bridge id STP enabled interfaces docker0 8000.0242d8e4dfc1 no veth9c02e56 新创建容器nginx-2的Veth Pair的一端在容器中,另一端在docker0网桥上,所以同一个宿主机上的两个容器默认就是互相连通的。 0.1.2. 容器间访问(同宿主机) 当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功能,查看数据包的传输过程,如下所示: # 在宿主机上执行 iptables -t raw -A OUTPUT -p icmp -j TRACE iptables -t raw -A PREROUTING -p icmp -j TRACE # 在宿主机的/var/log/syslog里看到数据包传输的日志 被限制在Network Namespace里的容器进程,实际上是通过Veth Pair设备和宿主机网桥的方式,实现了跟其他容器的数据交换。 0.1.3. 宿主机访问容器 访问宿主机上的容器的IP地址时,这个请求的数据包下根据路由规则到达Docker0网桥,然后被转发到对应的Veth Pair设备,最后出现在容器里。 0.1.4. 容器访问宿主机 宿主机之间网络需要互通。 当一个容器试图连接到另外一个宿主机(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规则是否有异常。 0.1.5. 容器间访问(跨主机) 在Docker默认的配置下,一台宿主机上的docker0网桥,和其它宿主机上的docker0网桥,没有任何关联。它们互相之间也没有办法连通、所以连接在网桥上的容器,没有办法进行通信。 如果通过网络创建一个整个集群“公用”的网桥,然后把集群里的所有容器都连接到整个网桥上,就可以互通了。如下图所示。 构建这种网络的核心在于:需要在已有的宿主机网络上,通过软件构建一个覆盖已有宿主机网络之上的、可以把所有容器连通在一起的虚拟网络。这种技术称为Overlay Network(覆盖网络)。 Overlay Network本身,可以由每台宿主机上的一个“特殊网桥”共同组成。比如,当node1上的容器1要访问node2上的容器3时,node1上的“特殊网桥”在收到数据包之后,能够通过某种方式,把数据包发送到正确的宿主机node2上。在node2上的“特殊网桥”在收到数据包后,也能够通过某种方式,把数据包转发给正确的容器,容器3。 甚至,每台宿主机上,都不要有一个“特殊网桥”,而仅仅通过某种方式配置宿主机的路由表,就能够把数据包转发到正确的宿主机上。
0.1. UDP模式 0.1.1. flannel0 0.1.2. 流程图 0.2. VXLAN模式 跨主机通信,Flannel项目,这是CoreOS公司主推的容器网络方案。Flannel项目本身只是一个框架,真正提供容器网络功能的是Flannel的后端实现,目前Flannel支持三种后端实现: VXLAN host-gw UDP:最早支持,最直接、最容易理解、但是性能最差,已经弃用 三种不同的后端实现,代表了三种容器跨主机网络的主流实现方法。 0.1. UDP模式 假设有两台宿主机,目标: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为例,如下所示: # 在 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包。 0.1.1. flannel0 当操作系统将一个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中,如下所示: $ 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地址,匹配对应的子网,从Etcd中找到这个子网对应的宿主机IP地址是10.168.0.3,如下所示: etcdctl get /coreos.com/network/subnets/100.96.2.0-24 {"PublicIP":"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上的类似,如下所示: # 在 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设备进入到c2的Network Namespace。 c2返回给C1的数据包,通过上述过程相反的路径回到c1。 上述流程要正确工作的一个重要前提,docker0网桥的地址范围必须是Flannel为宿主机分配的子网。以Node1为例,需要给它上面的Docker Daemon启动时配置如下的bip参数。 FLANNEL_SUBNET=100.96.1.1/24 dockerd --bip=$FLANNEL_SUBNET ... 0.1.2. 流程图 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模式成为主流容器网络方案的原因。 0.2. 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就会添加一条如下的路由规则: 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/4`网段的IP包,都需要经过flannel.1设备发出,随后被发往的网关地址是`10.1.16.0` 每个宿主机上的VETP设备之间需要构建一个虚拟的二层网络,即通过二层数据帧进行通信。即源VETP设备将原始IP包加上MAC地址封装成一个二层数据帧,发送到目的端VETP设备。 根据前面添加的路由记录,知道了目的VETP设备的IP地址,利用ARP表,根据三层IP地址查询对应的二层MAC地址。这里使用的ARP记录,也是flanneld进程在node2节点启动时,自动添加在node1上的。如下所示: # 在 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` 最新版的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信息,具体内容如下。 # 在 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 # 发往5e:f8:4f:00:e3:37MAC地址的二层数据帧,应该通过flannel.1设备,发送到IP地址为10.168.0.3的主机,这就是node2的IP地址 然后就是一个正常的宿主机网络上的封包工作。 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 设备来说,它发出的“内部数据帧”就仿佛是一直在这个虚拟的二层网络上流动。这,也正是覆盖网络的含义。
0.1. 例子 0.2. Network Namespace网络栈的配置 0.3. CNI插件工作原理 0.3.1. 设置环境变量 0.3.1.1. ADD操作 0.3.2. dockershim从CNI配置文件里加载默认插件的配置信息 0.4. 总结 容器跨主机网络的两种实现方式: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,这个参数在部署的时候指定: kubeadm init --pod-network-cidr=10.244.0.0/16 也可以在部署完成后,通过修改kube-controller-manager的配置文件来指定。 0.1. 例子 假设有两台宿主机,两个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上的路由表如下: # 在 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,所以匹配第二条路由规则,这条规则指向本机的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配置符合预期的网络栈。 一个Network Namespace的网络栈包括:网卡、回环设备、路由表、iptables。 0.2. Network Namespace网络栈的配置 首先需要部署和运行CNI插件。在部署kubernetes的时候,有一个步骤是安装kubernetes-cni包 ,它的目的就是在宿主机上安装CNI插件所需的基础可执行文件。安装完成后,可以在宿主机/opt/cni/bin/目录下看到它们,如下所示: # 这些CNI的基础可执行文件,按功能可以分为三类 ls -al /opt/cni/bin/ total 73088 # 第一类,Main插件,用来创建具体网络设备的二进制文件 # Flannel Weave等项目,都属于“网桥”类型的CNI插件,具体实现时会调用二进制文件bridge -rwxr-xr-x 1 root root 3890407 Aug 17 2017 bridge # 网桥设备 -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 3877986 Aug 17 2017 ptp # Veth Pair设备 -rwxr-xr-x 1 root root 3475750 Aug 17 2017 vlan # 第二类,IPAM(IP Address Management)插件,负责分配IP地址的二进制文件 -rwxr-xr-x 1 root root 9921982 Aug 17 2017 dhcp # 向DHCP服务器发起请求 -rwxr-xr-x 1 root root 2991965 Aug 17 2017 host-local # 使用预先配置的IP地址段进行分配 # 第三类,CNI社区维护的内置CNI插件 -rwxr-xr-x 1 root root 2814104 Aug 17 2017 flannel # 为Flannel项目提供的CNI插件 -rwxr-xr-x 1 root root 3470464 Aug 17 2017 portmap # 通过iptables配置端口映射的二进制文件 -rwxr-xr-x 1 root root 2605279 Aug 17 2017 sample # -rwxr-xr-x 1 root root 2808402 Aug 17 2017 tuning # 通过sysctl调整网络设备参数的二进制文件 # bandwidth 使用Token Bucket Filter(TBF)来进行限流的二进制文件 从以上内容可以看出,实现一个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配置文件内容如下: 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实现里完成。对于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插件会按照定义顺序被调用,从而依次完成“配置容器网络”和“配置端口映射”这两个操作。 0.3. CNI插件工作原理 当kubelet组件需要创建Pod的时候,第一个创建的一定是Infra容器。 dockershim调用Docker API创建并启动Infra容器 执行SetUpPod方法,为CNI插件准备参数 调用CNI插件(/opt/cni/bin/flannel)为Infra容器配置网络 调用CNI插件需要为它准备的参数分为2部分: 设置环境变量 dockershim从CNI配置文件里加载到的、默认插件的配置信息 0.3.1. 设置环境变量 由dockershim设置的一组CNI环境变量,其中最重要的环境变量是CNI_COMMAND,它的取值只有两种ADD/DEL。ADD和DEl操作是CNI插件唯一需要实现的两个方法。 ADD操作的含义:把容器添加到CNI网络里 DEL操作的含义:把容器从CNI网络里移除 对于网桥类型的CNI插件来说,这两个操作意味着把容器以Veth Pair的方式插到CNI网桥上或者从CNI网桥上拔出。 0.3.1.1. ADD操作 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协议的一个重要方法。 0.3.2. 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插件。 所以说,dockershim对Flannel CNI插件的调用,其实只是走个过程,Flannel CNI插件唯一需要做的,就是对dockershim传来的Network Configuration进行补充,如将Delegate的Type字段设置为bridge,将Delegate的IPAM字段设置为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 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网桥是否存在。如果没有的话,那就创建它。相当于在宿主机上执行如下操作: # 在宿主机上 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 vethb4963f # 启动 eth0 设备 ip link set eth0 u # 将 Veth Pair 设备的另一端(vethb4963f3设备)放到宿主机(Host Namespace)里 ip link set vethb4963f3 netns $HOST_N # 通过 Host Namespace,启动宿主机上的 vethb4963f3 设备 ip netns exec $HOST_NS ip link set vethb4963f3 up CNI bridge 插件就可以把vethb4963f3设备连接到CNI网桥上。这相当于在宿主机上执行如下命令: # 在宿主机上 ip link set vethb4963f3 master cni0 在将vethb4963f3设备连接在CNI网桥之后,CNI bridge插件还会为它设置Hairpin Mode(发夹模式),因为在默认情况下,网桥设备是不允许一个数据包从一个端口进来后再从这个端口发出去。开启发夹模式取消这个限制。这个特性主要用在容器需要通过NAT(端口映射)的方式,自己访问自己的场景。这样这个集群Pod才可以通过它自己的Service访问到自己。 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方法就宣告结束,接下来的流程就是容器跨主机通信的过程。 0.4. 总结 kubernetes CNI网络模型: 所有容器都可以直接使用IP地址与其他容器通信,无需使用NAT 所有宿主机都可以直接使用IP地址与所有容器通信,而无需使用NAT,反之亦然 容器自己“看到”的自己的IP地址,和别人(宿主机或容器)看到的地址是完全一样的 容器和容器通,容器和宿主机通,并且直接基于容器和宿主机的IP地址来进行通信。
0.1. Flannel的host-gw模式 0.2. Calico 0.2.1. Calico组成 0.2.2. Calico的工作方式如下 0.3. 模式 0.4. 多次路由跳转 0.4.1. 方案1 0.4.2. 方案2 0.5. 总结 除了网桥模式的CNI插件,还有纯三层(Pure Layer3)网络方案。如Flannel的host-gw模式和Calicao项目。 0.1. Flannel的host-gw模式 工作原理如下图所示: Node1的C1要访问Node2的C2,当设置Flannel使用hots-gw模式后,flanneld会在宿主机上创建如下规则: ip route ... 10.244.1.0/24 via 10.168.0.3 dev eth0 # 目标IP地址属于10.233.1.0/24网段的IP包,应该经过本机的eth0设备(dev eth0)发出,并且下一跳地址(next-hop)是10.168.0.3(即via 10.168.0.3) 下一跳地址是:如果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网络模型)。三层可达也能通过为几个子网设置三层转发来实现。 0.2. Calico Calico也会在每台宿主机上添加一个路由规则,如下所示: < 目的容器 IP 地址段 > via < 网关的 IP 地址 > dev eth0 # 网关的IP地址,正式目的容器所在宿主机的IP地址 这个三层网络方案得以正常工作的核心,是为每个容器的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自己的方案可比。 0.2.1. Calico组成 Calico的CNI插件,这是Calico与kubernetes对接的部分。 Felix,它是一个DaemonSet,负责在宿主机上插入路由规则(即,写入Linux内核的FIB转发信息库),以及维护Calico所需的网络设备等 BIRD,是BGP的客户端,专门负责在集群里分发路由规则信息。 Calico和Flannel的host-gw的异同: Calico不会在宿主机上创建任何网桥设备 对路由信息的维护方式不同 Calico在宿主机上设置的路由规则更多 都要求集群之间是二层连通 0.2.2. Calico的工作方式如下 绿色实线标出的路径,就是一个IP包从node1的C1到node2的C4的完整路径。Calico的CNI插件会为每个容器设置一个Veth Pair设备,然后把其中的一端放置在宿主机上(cali前缀)。 由于Calico没有使用CNI的网桥模式,所有Calico的CNI插件还需要在宿主机上为每个容器的Veth Pair设备配置一条路由规则,用于接受传入的IP包。如宿主机node2的C4对应的路由规则如下: 10.233.2.3 dev cali5863f3 scope link # 发往10.233.2.3的IP包,应该进入cali5863f3设备 有了Veth Pair设备之后,容器发送的IP包就会经过Veth Pair设备出现在宿主机上,然后,在宿主机网络栈就会根据路由规则的下一跳IP地址(最核心的这个路由规则,由Calico的Felix进程负责维护,这些路由规则信息,通过BGP Client也就是BIRD组件,使用BGP协议传输而来),把它们转发给正确的网关。 BGP协议传输的消息,类似如下格式: [BGP 消息] 我是宿主机 192.168.1.3 10.233.2.0/24 网段的容器都在我这里 这些容器的下一跳地址是我 Calico项目实际上将集群里的所有节点,都当作是边界路由器来处理,他们一起组成了一个全连通的网络,互相之间通过BGP协议交换路由规则,这些节点称为BGP Peer。 0.3. 模式 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上添加如下路由规则: 10.233.2.0/16 via 192.168.2.2 eth0 # 规则的下一跳地址是192.168.2.2 这个ip不在node1的子网中,没法通过二层网络把IP包发送到下一跳地址 使用IPIP模式后,可以解决这个问题,Felix进程会在Node1上添加的路由规则会有变化,如下所示: 10.233.2.0/24 via 192.168.2.2 tunl0 # 规则的下一跳地址没有变化,但是负责将IP包转发的设备变成了tunl0 # tunl0是一个IP隧道(IP tunnel设备) 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模式。 0.4. 多次路由跳转 如果Calico项目能让宿主机之间的路由设备(网关)也通过BGP协议学习到Calico网络里的路由规则,那么从容器发出的IP包,就可以通过这些设备路由到目的宿主机。 # 在node1中添加 如下路由规则 10.233.2.0/24 via 192.168.1.1 eth0 # 在Router(192.168.1.1)上添加如下路由规则 10.233.2.0/24 via 192.168.2.1 eth0 C1发出的IP包,通过两次下一跳,到达Router2。 在公有云环境下,宿主机之间的网关,是不允许用户进行干预和配置的。在大多数公有云环境下,宿主机(公有云提供的虚拟机)本身是二层连通的。 在私有云环境下,宿主机属于不同子网很常见,想办法将宿主机网关加入到BGP Mesh里从而避免使用IPIP。Calico提供了两种将宿主机网设置成BGP Peer的解决方案。 0.4.1. 方案1 所有宿主机都与宿主机网关建立BGP Peer关系。这样每个节点都需要主动与宿主机网关建立BGP连接,从而将路由信息同步到网关上。 这种方式,Calico要求宿主机网关必须支持Dynamic Neighbors的BGP配置,在常规的BGP配置中,运维人员必须明确给出所有BGP Peer的IP地址。kubernetes集群中宿主机动态增加节点,手动管理很麻烦,Dynamic Neighbors允许给路由配置一个网段,然后路由器会自动跟给网段里的主机建立BGP Peer关系。 0.4.2. 方案2 使一个或多个独立组件搜集整个集群里所有路由信息,然后通过BGP协议同步给网关。在大规模集群中,Calico使用Router Reflector节点的方式进行组网,这些节点兼任负责与宿主机网关进行沟通的独立组件的任务。 这种情况下,BGP Peer数量有限且固定,可以直接把这些独立组件配置成路由器的BGP Peer,无需Dynamic Neighbors支持。这些独立组件只需要WATCH Etcd里的宿主机和对应网段的变化信息,然后把这些信息通过BGP协议分发给网关即可。 0.5. 总结 在大规模集群中,三层网络方案在宿主机上的路由规则可能会非常多,导致错误排除困难,系统故障时,路由规则重叠冲突的概率变大,在公有云部署,使用Flannel host-gw模式,在私有云部署,Calico能覆盖更多场景,提供更可靠的组网方案和架构思路。
kubernetes的网络模型,只是关注容器之间网络的“连通”,却不关心容器之间网络的“隔离”。如何实现网络的隔离来满足多租户的需求。 NetworkPolicy 在kubernetes中,网络隔离能力的定义,依靠专门的API对象来描述,NetworkPolicy。它定义的规则其实是白名单。 一个完整的NetworkPolicy对象的示例如下: apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: test-network-policy namespace: default spec: podSelector: # 定义这个NetworkPolicy的限制范围 matchLabels: # 在当前Namespace中携带role=db标签的Pod role: db # podSelector: {} 表示当前Namespace下所有的Pod policyTypes: - Ingress # 影响流入请求 - Egress # 影响流出请求 ingress: - from: # 允许流入的白名单,并列的三种情况(满足一个即生效): # ipBlock,namespaceSelector,podSelector # 不用`-`分隔字段时,表示条件需要同时满足 - 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对象来指定。 一旦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。 网络插件对pod进行隔离 以三层网络插件(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 kubernetes网络插件使用上述NetworkPolicy定义,在宿主机上生成iptables规则,具体过程如下: 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 // 规则的名字 KUBE-NWPLCY-CHAIN // 含义:当IP包的源地址是srcIP,目的地址是dstIP,协议是protocol,目的端口是port的时候,就运行它通过(ACCEPT) } } } // 通过匹配条件决定下一步iptables规则 // 匹配这条规则所需要的四个参数都是从NetworkPolicy对象里读取出来 kubernetes网络插件对Pod进行隔离,其实是靠宿主机上生成的NetworkPolicy对应的iptables规则来实现的。在设置好上述“隔离”规则之后,网络插件还需要想办法,将所有对被隔离Pod的访问请求,都转发到上述KUBE-NWPLCY-CHAIN规则上去进行匹配,如果匹配不通过这个请求应该被“拒绝”。 设置两组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 // FORWARD链拦截一种特殊情况,它对应的是同一台宿主机上的容器之间经过CNI网桥进行通信的流入数据包 // --physdev-is-bridged表示这个FORWARD链匹配的是,通过本机上的网桥设备,发往目的地址的IP包 iptables -A FORWARD -d $podIP -j KUBE-POD-SPECIFIC-FW-CHAIN // FORWARD链拦截普通情况,容器跨主机通信,流入容器的数据包都是经过路由转发(FORWARD检查点)来的 // 这些规则最后都跳转到了KUBE-POD-SPECIFIC-FW-CHAIN规则上,这是NetworkPolicy设置的第二组规则 ... } } // iptables规则使用了内置链:FORWARD 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检查点 被转发到其他目的地 IP包不进入传输层,继续在网络层流动,从而进入转发路径(Forward Path) 转发路径中,Netfilter会设置FORWARD检查点 在FORWARD检查点完成后,IP包来到流出路径(Output Path) 转发的IP包目的地已经确定,不再经过路由,直接到达POSTROUTING检查点 POSTROUTING的作用,是上述两条路径,最终汇聚在一起的”最终检查点”。 在网桥参与的情况下,上述Netfilter设置检查点的流程,实际上也会出现在链路层(二层),并且会跟上面的网络层(三层)的流程有交互。链路层的检查点对应的操作界面叫作ebtables。数据包在Linux Netfilter子系统里完整的流动过测井如下图所示。 上述过程是途中绿色部分,即网络层的iptables链的工作流程。 第二组规则 KUBE-POD-SPECIFIC-FW-CHAIN,做出允许或拒绝的判断,这部分功能的实现,类似如下的iptables规则: iptables -A KUBE-POD-SPECIFIC-FW-CHAIN -j KUBE-NWPLCY-CHAIN # 把IP包转交给前面定义的KUBE-NEPLCY-CHAIN规则去进行匹配,匹配成功运行通过,匹配失败进入下一条规则 iptables -A KUBE-POD-SPECIFIC-FW-CHAIN -j REJECT --reject-with icmp-port-unreachable # REJECT规则,通过这条规则,不满足NetworkPolicy定义的请求会被拒绝掉,从而实现了该容器的“隔离” 总结 NetworkPolicy只是宿主机上一系列iptables规则,与传统IaaS的安全组类似。 kubernetes的网络模型以及大多数容器网络实现,即不会保证容器之间二层网络的互通,也不会实现容器之间二层网络的隔离,这与IaaS项目管理的虚拟机是完全不同的。kubernetes从底层的设计和实现上,更倾向于假设你已经有一套完整的物理基础设施,kubernetes负责在此基础上提供“弱多租户”的能力。
0.1. iptables模式 0.2. IPVS模式 0.3. DNS 0.4. 小结 0.5. Service 调试 0.5.1. Nodeport 0.5.2. LoadBalancer 0.5.3. ExternalName 0.5.4. externalIPs 0.6. 解决问题 0.7. 总结 kubernetes使用Service: Pod的IP地址不固定 一组Pod之间有负载均衡的需求 典型的Service如下: apiVersion: v1 kind: Service metadata: name: hostnames spec: selector: app: hostnames ports: - name: default protocol: TCP port: 80 #service的端口 targetPort: 9376 #代理的Pod的端口 具体的应用的Deployment如下: # 这个容器的作用是每次访问9376端口,返回它自己的hostname apiVersion: apps/v1 kind: Deployment metadata: name: hostnames spec: selector: matchLabels: app: hostnames replicas: 3 template: metadata: labels: app: hostnames spec: containers: - name: hostnames image: k8s.gcr.io/serve_hostname ports: - containerPort: 9376 protocol: TCP 被选中的Pod就是Service的Endpoints,使用kubectl get ep可以看到如下所示: $ kubectl get endpoints hostnames NAME ENDPOINTS hostnames 10.244.0.5:9376,10.244.0.6:9376,10.244.0.7:9376 # 只有处于Running,且readinessProbe检查通过的Pod才会出现在这个Service的Endpoints列表中 # 当某个Pod出现问题时,kubernetes会自动把它从Service里去除掉 通过该Service的VIP地址10.0.1.175,就能访问到它代理的Pod: $ kubectl get svc hostnames NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE hostnames ClusterIP 10.0.1.175 <none> 80/TCP 5s $ curl 10.0.1.175:80 hostnames-0uton $ curl 10.0.1.175:80 hostnames-yp2kp $ curl 10.0.1.175:80 hostnames-bvc05 这个VIP地址是kubernetes自动为Service分配的。通过三次连续不断地访问Service的VIP地址和代理端口80,为我们依次返回三个Pod的hostname,Service提供的是RoundRobin方式的负载均衡。这种方式称之为ClusterIP模式的Service。 0.1. iptables模式 Service是由kube-proxy组件,加上iptables来共同实现。 举个例子,对应创建的Service,一旦提交给kubernetes,那么kube-proxy就可以通过Service的Informer感知到这样一个Service对象的添加操作。作为对这个事件的响应,就会在宿主机上创建如下所示的iptables规则。 # iptables-save命令可以查看 -A KUBE-SERVICES -d 10.0.1.175/32 -p tcp -m comment --comment "default/hostnames: cluster IP" -m tcp --dport 80 -j KUBE-SVC-NWV5X2332I4OT4T3 # 这条规则的含义是:凡是目的地址是10.0.1.175、目的端口是80的IP包,都应该跳转到另外一个名叫KUBE-SVC-NWV5X2332I4OT4T3的iptables链进行处理 # 10.0.1.175真是这个Service的VIP,这条规则就是为Service设置了一个固定的入口地址 # 由于10.0.1.175只是一条iptables规则上的配置,并没有真正的网络设备,所以ping这个地址,是不会有任何响应的 KUBE-SVC-NWV5X2332I4OT4T3的规则是一个规则的集合,如下所示: -A KUBE-SVC-NWV5X2332I4OT4T3 -m comment --comment "default/hostnames:" -m statistic --mode random --probability 0.33332999982 -j KUBE-SEP-WNBA2IHDGP2BOBGZ -A KUBE-SVC-NWV5X2332I4OT4T3 -m comment --comment "default/hostnames:" -m statistic --mode random --probability 0.50000000000 -j KUBE-SEP-X3P2623AGDH6CDF3 -A KUBE-SVC-NWV5X2332I4OT4T3 -m comment --comment "default/hostnames:" -j KUBE-SEP-57KPRZ3JQVENLNBR # 这是一组随机模式(mode random)的iptables链 # 随机发送的目的地址,分别是KUBE-SEP-WNBA2IHDGP2BOBGZ、KUBE-SEP-X3P2623AGDH6CDF3、KUBE-SEP-57KPRZ3JQVENLNBR 这三条链指向的最终目的地,其实就是这个Service代理的三个pod。所以这一组规则,就是Service实现负载均衡的位置。 iptables规则匹配是从上到下逐条进行的,所以为了保证上述三条规则,每条被选中的概率一样,应该将他们的probability字段的值分别设置为1/3(0.333)、1/2和1。第一条选中的概率是三分之一,第一条没选择剩下两条的概率是二分之一,最后一条为1。 Service进行转发的具体原理如下所示: -A KUBE-SEP-57KPRZ3JQVENLNBR -s 10.244.3.6/32 -m comment --comment "default/hostnames:" -j MARK --set-xmark 0x00004000/0x00004000 -A KUBE-SEP-57KPRZ3JQVENLNBR -p tcp -m comment --comment "default/hostnames:" -m tcp -j DNAT --to-destination 10.244.3.6:9376 -A KUBE-SEP-WNBA2IHDGP2BOBGZ -s 10.244.1.7/32 -m comment --comment "default/hostnames:" -j MARK --set-xmark 0x00004000/0x00004000 -A KUBE-SEP-WNBA2IHDGP2BOBGZ -p tcp -m comment --comment "default/hostnames:" -m tcp -j DNAT --to-destination 10.244.1.7:9376 -A KUBE-SEP-X3P2623AGDH6CDF3 -s 10.244.2.3/32 -m comment --comment "default/hostnames:" -j MARK --set-xmark 0x00004000/0x00004000 -A KUBE-SEP-X3P2623AGDH6CDF3 -p tcp -m comment --comment "default/hostnames:" -m tcp -j DNAT --to-destination 10.244.2.3:9376 这是三条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是解决这个问题行之有效的方法。 0.2. IPVS模式 工作原理,与iptables模式类似,创建了Service之后,kube-proxy首先会在宿主机上创建一个虚拟网卡(kube-ipvs0),并为它分配Service VIP作为IP地址,如下所示: # ip addr ... 73:kube-ipvs0:<BROADCAST,NOARP> mtu 1500 qdisc noop state DOWN qlen 1000 link/ether 1a:ce:f5:5f:c1:4d brd ff:ff:ff:ff:ff:ff inet 10.0.1.175/32 scope global kube-ipvs0 valid_lft forever preferred_lft forever kube-proxy就会通过Linux的IPVS模式,为这个IP地址设置三个IPVS虚拟主机,并设置这个虚拟主机之间使用的轮询模式(rr)来作为负载均衡策略,通过ipvsadm查看这个设置,如下所示: # ipvsadm -ln IP Virtual Server version 1.2.1 (size=4096) Prot LocalAddress:Port Scheduler Flags -> RemoteAddress:Port Forward Weight ActiveConn InActConn TCP 10.102.128.4:80 rr -> 10.244.3.6:9376 Masq 1 0 0 -> 10.244.1.7:9376 Masq 1 0 0 -> 10.244.2.3:9376 Masq 1 0 0 这三个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集群规模带来的提升是非常巨大的。 0.3. DNS 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,如下所示。、 apiVersion: v1 kind: Service metadata: name: default-subdomain spec: selector: name: busybox clusterIP: None ports: - name: foo port: 1234 targetPort: 1234 --- apiVersion: v1 kind: Pod metadata: name: busybox1 labels: name: busybox spec: hostname: busybox-1 subdomain: default-subdomain containers: - image: busybox command: - sleep - "3600" name: busybox 通过busybox-1.default-subdomain.default.svc.cluster.local解析到这个pod的IP地址。 在kubernetes中,/etc/hosts文件是单独挂载的,所以kubelet能够对hostname进行修改并且pod重建后依然有效。与Docker的init层是一个原理。 0.4. 小结 Service机制和DNS插件都是为了解决同一个问题,如何找到某个容器。在平台级项目中称为服务发现,即当一个服务(Pod)的IP地址是不固定的且没办法提前获知时,该如何通过固定的方式访问到这个Pod。 ClusterIP模式的Service,提供的是一个Pod的稳定的IP地址,即VIP,并且pod和Service的关系通过Label确定。 Headless Service,提供的是一个Pod的稳定的DNS名字,并且这个名字可以通过Pod名字和Service名字拼接出来。 0.5. Service 调试 Service的访问信息在kubernetes集群之外是无效的。 Service的访问入口,就是每台宿主机上由kube-proxy生成的iptables规则,以及kube-dns生成的DNS记录。一旦离开这个集群,这些信息对用户来说,是没有作用的。 如何从kubernetes集群之外,访问到Kubernetes里创建的Service? Nodeport LoadBalancer ExternalName externalIPs 0.5.1. Nodeport apiVersion: v1 kind: Service metadata: name: my-nginx labels: run: my-nginx spec: type: NodePort # 声明类型 ports: - nodePort: 8080 # service的端口 targetPort: 80 protocol: TCP name: http - nodePort: 443 # Service的端口 protocol: TCP name: https selector: run: my-nginx 不显示声明nodePort字段,会随机分配30000-32767之间的端口,通过kube-apiserver的--service-node-port-range参数来修改它。 访问以上Service<任何一台宿主机的IP地址>:8080,就能够访问到某一个被代理的Pod的80端口。 NodePort模式的工作原理,是kube-proxy在每台宿主机上生成一条iptables规则,如下所示: -A KUBE-NODEPORTS -p tcp -m comment --comment "default/my-nginx: nodePort" -m tcp --dport 8080 -j KUBE-SVC-67RL4FN6JRUPOJYM # KUBE-SVC-67RL4FN6JRUPOJYM是一组随机模式的iptables规则 在NodePort模式下,kubernetes会在IP包离开宿主机发往目的Pod时,对这个IP包做一次SNAT操作,如下所示: -A KUBE-POSTROUTING -m comment --comment "kubernetes service traffic requiring SNAT" -m mark --mark 0x4000/0x4000 -j MASQUERADE # 这条规则设置在POSTROUTING检查点,为即将离开这台主机的IP包,进行一次SNAT操作, # 将这个IP包的源地址替换成了这个宿主机的CNI网桥地址,或者宿主机本身的IP地址(CNI网桥不存在时) SNAT操作只需要对Service转发出来的IP包进行(否则普通的IP包就被影响了)。iptables做这个判断的依据就是查看该IP包是否有一个0x4000的标志,这个标志是在IP包被执行DNAT操作之前被打上的。 原理如下图: client \ ^ \ \ v \ node 1 <--- node 2 | ^ SNAT | | ---> v | endpoint 当一个外部的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。操作流程如下: client ^ / \ / / \ / v X node 1 node 2 ^ | | | | v endpoint # 如果在一台宿主机上没有任何被代理的pod存在(如图中node2),那么使用node2的IP地址访问这个Service是无效的,请求会被DROP掉 0.5.2. LoadBalancer 适用于公有云上的Kubernetes集群的访问方式,指定一个LoadBalancer类型的Service,如下所示: --- kind: Service apiVersion: v1 metadata: name: example-service spec: ports: - port: 8765 targetPort: 9376 selector: app: example type: LoadBalancer 在公有云提供的kubernetes服务里,都是用了CloudProvider的转接层,来跟公有云本身的API进行对接。所有在LoadBalancer类型的Service被提交后,kubernetes就会调用CloudProvider在公有云上创建一个负载均衡服务,并且被代理的Pod的IP地址配置给负载均衡服务器做后端。 0.5.3. ExternalName kubernetes v1.7之后支持的新特性,ExternalName,如下: kind: Service apiVersion: v1 metadata: name: my-service spec: type: ExternalName externalName: my.database.example.com 指定一个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是一个效果。 0.5.4. externalIPs 同时,kubernetes的Service还可以为Service分配公有IP地址,如下: kind: Service apiVersion: v1 metadata: name: my-service spec: selector: app: MyApp ports: - name: http protocol: TCP port: 80 targetPort: 9376 externalIPs: - 80.11.12.10 指定externalIPs=80.11.12.10,此时通过访问80.11.12.10访问被代理的pod。在这里kubernetes要求externalIPs必须是至少能够路由到一个kubernetes节点的。 0.6. 解决问题 很多与Service相关的问题,都可以通过分享Service在宿主机上对应的iptables规则(或者IPVS配置)得到解决。 问题1 当Service无法通过DNS访问时,区分是Service本身的配置问题还是集群的DNS出现问题。通过检查kubernetes自己Master节点的Service DNS是否正常: # 在一个 Pod 里执行 $ nslookup kubernetes.default Server: 10.0.0.10 Address 1: 10.0.0.10 kube-dns.kube-system.svc.cluster.local Name: kubernetes.default Address 1: 10.0.0.1 kubernetes.default.svc.cluster.local 如果上述访问kubernetes.default返回的值都有问题,那么就需要检查kube-dns的运行状态和日志。否则就应该去检查Service定义是否有问题。 问题2 如果Service没办法通过ClusterIP访问到,首先应该检查这个Service是否有Endpoints: $ kubectl get endpoints hostnames NAME ENDPOINTS hostnames 10.244.0.5:9376,10.244.0.6:9376,10.244.0.7:9376 # 如果pod的readinessProbe没有通过,它是不会出现在Endpoints列表里。 如果Endpoints正常,就需要确认kube-proxy是否正确运行。通过kubeadm部署的集群中,kube-proxy的输出日志如下: I1027 22:14:53.995134 5063 server.go:200] Running in resource-only container "/kube-proxy" I1027 22:14:53.998163 5063 server.go:247] Using iptables Proxier. I1027 22:14:53.999055 5063 server.go:255] Tearing down userspace rules. Errors here are acceptable. I1027 22:14:54.038140 5063 proxier.go:352] Setting endpoints for "kube-system/kube-dns:dns-tcp" to [10.244.1.3:53] I1027 22:14:54.038164 5063 proxier.go:352] Setting endpoints for "kube-system/kube-dns:dns" to [10.244.1.3:53] I1027 22:14:54.038209 5063 proxier.go:352] Setting endpoints for "default/kubernetes:https" to [10.240.0.2:443] I1027 22:14:54.038238 5063 proxier.go:429] Not syncing iptables until Services and Endpoints have been received from master I1027 22:14:54.040048 5063 proxier.go:294] Adding new service "default/kubernetes:https" at 10.0.0.1:443/TCP I1027 22:14:54.040154 5063 proxier.go:294] Adding new service "kube-system/kube-dns:dns" at 10.0.0.10:53/UDP I1027 22:14:54.040223 5063 proxier.go:294] Adding new service "kube-system/kube-dns:dns-tcp" at 10.0.0.10:53/TCP 如果kube-proxy一起正常,就应该查看宿主机上iptables。一个iptables模式的Service对应的规则应该包括: KUBE-SERVICES或者KUBE-NODEPORTS规则对应的Service入口链,这个规则应该与VIP和Service端口一一对应 KUBE-SEP-(hash)规则对应的DNAT链,这些规则应该与Endpoints一一对应 KUBE-SVC-(hash)规则对应的负载均衡链,这些规则的数目应该与Endpoints数目一致 如果NodePort模式的话,还有POSTROUTING处的SNAT链 通过查看链的数量、转发目的地址、端口、过滤条件等信息,能发现异常的原因。 问题3 Pod无法通过Service访问到自己。这是因为kubelet的hairpin-mode没有被正确的设置,只需要将kubelet的hairpin-mode设置为hairpin-veth或者promiscuous-bridge即可。 hairpin-veth模式下,应该看到CNI网桥对应的各个VETH设备,都将Hairpin模式设置为1,如下所示; $ for d in /sys/devices/virtual/net/cni0/brif/veth*/hairpin_mode; do echo "$d= $(cat $d)"; done /sys/devices/virtual/net/cni0/brif/veth4bfbfe74/hairpin_mode = 1 /sys/devices/virtual/net/cni0/brif/vethfc2a18c5/hairpin_mode = 1 promiscuous-bride模式。应该看到CNI网桥的混杂模式(PROMISC)被开启,如下所示: $ ifconfig cni0 |grep PROMISC UP BROADCAST RUNNING PROMISC MULTICAST MTU:1460 Metric:1 0.7. 总结 所谓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。
1. Nginx Ingress Controller 1.1. 第一步,部署Nginx Ingress Controller 1.2. 第二部,创建Service来暴露Nginx Ingress Controller管理的Nginx服务 2. 具体例子 在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对象即可: apiVersion: extensions/v1beta1 kind: Ingress metadata: name: cafe-ingress spec: tls: - hosts: - cafe.example.com secretName: cafe-secret rules: # 这里是核心,称为IngressRule - host: cafe.example.com # 这里是Key,一个标准的域名格式(FQDN)的字符串,而不能是IP地址 http: # 这里是Value,是Ingress的入口 paths: # IngressRule字段的规则,依赖Path字段,每一个Path对应一个后端Service - path: /tea backend: serviceName: tea-svc servicePort: 80 - path: /coffee backend: serviceName: coffee-svc servicePort: 80 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。 1. Nginx Ingress Controller 1.1. 第一步,部署Nginx Ingress Controller $ kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/master/deploy/mandatory.yaml # mandatory.yaml 是Nginx官网维护的Ingress Controller的定义 kind: ConfigMap apiVersion: v1 metadata: name: nginx-configuration namespace: ingress-nginx labels: app.kubernetes.io/name: ingress-nginx app.kubernetes.io/part-of: ingress-nginx --- apiVersion: extensions/v1beta1 kind: Deployment metadata: name: nginx-ingress-controller namespace: ingress-nginx labels: app.kubernetes.io/name: ingress-nginx app.kubernetes.io/part-of: ingress-nginx spec: replicas: 1 selector: matchLabels: app.kubernetes.io/name: ingress-nginx app.kubernetes.io/part-of: ingress-nginx template: metadata: labels: app.kubernetes.io/name: ingress-nginx app.kubernetes.io/part-of: ingress-nginx annotations: ... spec: serviceAccountName: nginx-ingress-serviceaccount containers: - name: nginx-ingress-controller image: quay.io/kubernetes-ingress-controller/nginx-ingress-controller:0.20.0 args: - /nginx-ingress-controller - --configmap=$(POD_NAMESPACE)/nginx-configuration - --publish-service=$(POD_NAMESPACE)/ingress-nginx - --annotations-prefix=nginx.ingress.kubernetes.io securityContext: capabilities: drop: - ALL add: - NET_BIND_SERVICE # www-data -> 33 runAsUser: 33 env: - name: POD_NAME valueFrom: fieldRef: fieldPath: metadata.name - name: POD_NAMESPACE - name: http valueFrom: fieldRef: fieldPath: metadata.namespace ports: - name: http containerPort: 80 - name: https containerPort: 443 # 使用nginx-ingress-controller镜像的pod,启动命令是要使用该pod所在的Namespace作为参数,这些信息通过Downward API获得,即pod的env字段中定义的`env.valueFrom.filedRef.fieldPath` 这个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负载均衡器。 1.2. 第二部,创建Service来暴露Nginx Ingress Controller管理的Nginx服务 $ kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/master/deploy/provider/baremetal/service-nodeport.yaml # service-nodeport.yaml 是一个NodePort类型的Service apiVersion: v1 kind: Service metadata: name: ingress-nginx namespace: ingress-nginx labels: app.kubernetes.io/name: ingress-nginx app.kubernetes.io/part-of: ingress-nginx spec: type: NodePort # 这个Service的唯一工作,就是将所有携带ingress-nginx标签的pod的80和433端口暴露出去。 # 如果在公有云环境下,需要创建的就是LoadBalancer类型的Service。 ports: - name: http port: 80 targetPort: 80 protocol: TCP - name: https port: 443 targetPort: 443 protocol: TCP selector: app.kubernetes.io/name: ingress-nginx app.kubernetes.io/part-of: ingress-nginx # 这个service的访问入口,即:宿主机地址和NodePort端口 $ kubectl get svc -n ingress-nginx NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE ingress-nginx NodePort 10.105.72.96 <none> 80:30044/TCP,443:31453/TCP 3h # 为了方便使用,把上述入口设置为环境变量 $ IC_IP=10.168.0.2 # 任意一台宿主机的地址 $ IC_HTTPS_PORT=31453 # NodePort 端口 Ingress Controller和它所需要的Service部署完成后,就可以使用它了。 2. 具体例子 # 首先部署应用pod和对应的service $ kubectl create -f cafe.yaml # 创建Ingress所需的SSL证书(tls.crt)和密钥(tls.key),这些信息通过secret对象定义 $ kubectl create -f cafe-secret.yaml # 创建Ingress对象 $ kubectl create -f cafe-ingress.yaml # 查看Ingress对象的信息 $ kubectl get ingress NAME HOSTS ADDRESS PORTS AGE cafe-ingress cafe.example.com 80, 443 2h $ kubectl describe ingress cafe-ingress Name: cafe-ingress Namespace: default Address: Default backend: default-http-backend:80 (<none>) TLS: cafe-secret terminates cafe.example.com Rules: Host Path Backends ---- ---- -------- cafe.example.com /tea tea-svc:80 (<none>) /coffee coffee-svc:80 (<none>) Annotations: Events: Type Reason Age From Message ---- ------ ---- ---- ------- Normal CREATE 4m nginx-ingress-controller Ingress default/cafe-ingress # 在Rules字段定义更多的Path来为更多的域名提供负载均衡服务 # 通过访问Ingress的地址和端口,访问部署的应用 # 如https://cafe.example.com:443/coffee,应该是coffee这个Deployment负责响应 $ curl --resolve cafe.example.com:$IC_HTTPS_PORT:$IC_IP https://cafe.example.com:$IC_HTTPS_PORT/coffee --insecureServer address: 10.244.1.56:80 Server name: coffee-7dbb5795f6-vglbv #这是coffee的Deployment的名字 Date: 03/Nov/2018:03:55:32 +0000 URI: /coffee Request ID: e487e672673195c573147134167cf898 # https://cafe.example.com:443/tea,应该是tea这个Deployment负责响应 $ curl --resolve cafe.example.com:$IC_HTTPS_PORT:$IC_IP https://cafe.example.com:$IC_HTTPS_PORT/tea --insecure Server address: 10.244.1.58:80 Server name: tea-7d57856c44-lwbnp Date: 03/Nov/2018:03:55:52 +0000 URI: /tea Request ID: 32191f7ea07cb6bb44a1f43b8299415c # Nginx Ingress Controller创建的Nginx负载均衡器,成功地将请求转发给了对应的后端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配置等HTT P相关的操作时,都必须通过Ingress来进行。