This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,218 @@
<audio id="audio" title="32 | 浅谈容器网络" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5b/01/5b32d0260bdddec861973990dd60ce01.mp3"></audio>
你好,我是张磊。今天我和你分享的主题是:浅谈容器网络。
在前面讲解容器基础时我曾经提到过一个Linux容器能看见的“网络栈”实际上是被隔离在它自己的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上。
当然至于为什么这些主机之间需要MAC地址才能进行通信这就是网络分层模型的基础知识了。不熟悉这块内容的读者可以通过[这篇文章](https://www.lifewire.com/layers-of-the-osi-model-illustrated-818017)来学习一下。
而为了实现上述目的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&lt;UP,BROADCAST,RUNNING,MULTICAST&gt; 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&lt;link&gt;
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&lt;UP,LOOPBACK,RUNNING&gt; mtu 65536
inet 127.0.0.1 netmask 255.0.0.0
inet6 ::1 prefixlen 128 scopeid 0x10&lt;host&gt;
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地址。
>
备注ARPAddress 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地址的对应表里查到对应的端口Portvethb4963f3然后把数据包发往这个端口。
而这个端口正是nginx-2容器“插”在docker0网桥上的另一块虚拟网卡当然它也是一个Veth Pair设备。这样数据包就进入到了nginx-2容器的Network Namespace里。
所以nginx-2容器看到的情况是它自己的eth0网卡上出现了流入的数据包。这样nginx-2的网络协议栈就会对请求进行处理最后将响应Pong返回到nginx-1。
以上就是同一个宿主机上的不同容器通过docker0网桥进行通信的流程了。我把这个流程总结成了一幅示意图如下所示
<img src="https://static001.geekbang.org/resource/image/e0/66/e0d28e0371f93af619e91a86eda99a66.png" alt=""><br>
需要注意的是在实际的数据传递时上述数据的传递过程在网络协议栈的不同层次都有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里看到数据包传输的日志了。这一部分内容你可以在课后结合[iptables的相关知识](https://en.wikipedia.org/wiki/Iptables)进行实践,从而验证我和你分享的数据包传递流程。
熟悉了docker0网桥的工作方式你就可以理解在默认情况下**被限制在Network Namespace里的容器进程实际上是通过Veth Pair设备+宿主机网桥的方式,实现了跟同其他容器的数据交换。**
与之类似地当你在一台宿主机上访问该宿主机上的容器的IP地址时这个请求的数据包也是先根据路由规则到达docker0网桥然后被转发到对应的Veth Pair设备最后出现在容器里。这个过程的示意图如下所示
<img src="https://static001.geekbang.org/resource/image/9f/01/9fb381d1e49318bb6a67bda3f9db6901.png" alt=""><br>
同样地当一个容器试图连接到另外一个宿主机时比如ping 10.168.0.3它发出的请求数据包首先经过docker0网桥出现在宿主机上。然后根据宿主机的路由表里的直连路由规则10.168.0.0/24 via eth0)对10.168.0.3的访问请求就会交给宿主机的eth0处理。
所以接下来这个数据包就会经宿主机的eth0网卡转发到宿主机网络上最终到达10.168.0.3对应的宿主机上。当然,这个过程的实现要求这两台宿主机本身是连通的。这个过程的示意图,如下所示:
<img src="https://static001.geekbang.org/resource/image/90/95/90bd630c0723ea8a1fb7ccd738ad1f95.png" alt=""><br>
所以说,**当你遇到容器连不通“外网”的时候你都应该先试试docker0网桥能不能ping通然后查看一下跟docker0和Veth Pair设备相关的iptables规则是不是有异常往往就能够找到问题的答案了。**
不过在最后一个“Docker容器连接其他宿主机”的例子里你可能已经联想到了这样一个问题如果在另外一台宿主机比如10.168.0.3也有一个Docker容器。那么我们的nginx-1容器又该如何访问它呢
这个问题,其实就是容器的“跨主通信”问题。
在Docker的默认配置下一台宿主机上的docker0网桥和其他宿主机上的docker0网桥没有任何关联它们互相之间也没办法连通。所以连接在这些网桥上的容器自然也没办法进行通信了。
不过,万变不离其宗。
如果我们通过软件的方式,创建一个整个集群“公用”的网桥,然后把集群里的所有容器都连接到这个网桥上,不就可以相互通信了吗?
说得没错。
这样一来,我们整个集群里的容器网络就会类似于下图所示的样子:
<img src="https://static001.geekbang.org/resource/image/b4/3d/b4387a992352109398a66d1dbe6e413d.png" alt=""><br>
可以看到构建这种容器网络的核心在于我们需要在已有的宿主机网络上再通过软件构建一个覆盖在已有宿主机网络之上的、可以把所有容器连通在一起的虚拟网络。所以这种技术就被称为Overlay Network覆盖网络
而这个Overlay Network本身可以由每台宿主机上的一个“特殊网桥”共同组成。比如当Node 1上的Container 1要访问Node 2上的Container 3的时候Node 1上的“特殊网桥”在收到数据包之后能够通过某种方式把数据包发送到正确的宿主机比如Node 2上。而Node 2上的“特殊网桥”在收到数据包后也能够通过某种方式把数据包转发给正确的容器比如Container 3。
甚至,每台宿主机上,都不需要有一个这种特殊的网桥,而仅仅通过某种方式配置宿主机的路由表,就能够把数据包转发到正确的宿主机上。这些内容,我在后面的文章中会为你一一讲述。
## 总结
在今天这篇文章中我主要为你介绍了在本地环境下单机容器网络的实现原理和docker0网桥的作用。
这里的关键在于容器要想跟外界进行通信它发出的IP包就必须从它的Network Namespace里出来来到宿主机上。
而解决这个问题的方法就是为容器创建一个一端在容器里充当默认网卡、另一端在宿主机上的Veth Pair设备。
上述单机容器网络的知识,是后面我们讲解多机容器网络的重要基础,请务必认真消化理解。
## 思考题
尽管容器的Host Network模式有一些缺点但是它性能好、配置简单并且易于调试所以很多团队会直接使用Host Network。那么如果要在生产环境中使用容器的Host Network模式你觉得需要做哪些额外的准备工作呢
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,291 @@
<audio id="audio" title="33 | 深入解析容器跨主机网络" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b2/ee/b29f9c8c2ea603185d861d12d7412bee.mp3"></audio>
你好,我是张磊。今天我和你分享的主题是:深入解析容器跨主机网络。
在上一篇文章中我为你详细讲解了在单机环境下Linux容器网络的实现原理网桥模式。并且提到了在Docker的默认配置下不同宿主机上的容器通过IP地址进行互相访问是根本做不到的。
而正是为了解决这个容器“跨主通信”的问题,社区里才出现了那么多的容器网络方案。而且,相信你一直以来都有这样的疑问:这些网络方案的工作原理到底是什么?
要理解容器“跨主通信”的原理就一定要先从Flannel这个项目说起。
Flannel项目是CoreOS公司主推的容器网络方案。事实上Flannel项目本身只是一个框架真正为我们提供容器网络功能的是Flannel的后端实现。目前Flannel支持三种后端实现分别是
<li>
VXLAN
</li>
<li>
host-gw
</li>
<li>
UDP。
</li>
这三种不同的后端实现正代表了三种容器跨主网络的主流实现方法。其中host-gw模式我会在下一篇文章中再做详细介绍。
而UDP模式是Flannel项目最早支持的一种方式却也是性能最差的一种方式。所以这个模式目前已经被弃用。不过Flannel之所以最先选择UDP模式就是因为这种模式是最直接、也是最容易理解的容器跨主网络实现。
所以在今天这篇文章中我会先从UDP模式开始来为你讲解容器“跨主网络”的实现原理。
在这个例子中,我有两台宿主机。
- 宿主机Node 1上有一个容器container-1它的IP地址是100.96.1.2对应的docker0网桥的地址是100.96.1.1/24。
- 宿主机Node 2上有一个容器container-2它的IP地址是100.96.2.3对应的docker0网桥的地址是100.96.2.1/24。
我们现在的任务就是让container-1访问container-2。
这种情况下container-1容器里的进程发起的IP包其源地址就是100.96.1.2目的地址就是100.96.2.3。由于目的地址100.96.2.3并不在Node 1的docker0网桥的网段里所以这个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/24container-1的IP地址是100.96.1.2。Node 2的子网是100.96.2.0/24container-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
{&quot;PublicIP&quot;:&quot;10.168.0.3&quot;}
```
而对于flanneld来说只要Node 1和Node 2是互通的那么flanneld作为Node 1上的一个普通进程就一定可以通过上述IP地址10.168.0.3访问到Node 2这没有任何问题。
所以说flanneld在收到container-1发给container-2的IP包之后就会把这个IP包直接封装在一个UDP包里然后发送给Node 2。不难理解这个UDP包的源地址就是flanneld所在的Node 1的地址而目的地址则是container-2所在的宿主机Node 2的地址。
当然这个请求得以完成的原因是每台宿主机上的flanneld都监听着一个8285端口所以flanneld只要把UDP包发往Node 2的8285端口即可。
通过这样一个普通的、宿主机之间的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网桥。
接下来的流程,就如同我在上一篇文章[《浅谈容器网络》](https://time.geekbang.org/column/article/64948)中和你分享的那样docker0网桥会扮演二层交换机的角色将数据包发送给正确的端口进而通过Veth Pair设备进入到container-2的Network 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模式的跨主通信的基本原理了。我把它总结成了一幅原理图如下所示。
<img src="https://static001.geekbang.org/resource/image/83/6c/8332564c0547bf46d1fbba2a1e0e166c.jpg" alt="" title="图1 基于Flannel UDP模式的跨主通信的基本原理">
可以看到Flannel UDP模式提供的其实是一个三层的Overlay网络它首先对发出端的IP包进行UDP封装然后在接收端进行解封装拿到原始的IP包进而把这个IP包转发给目标容器。这就好比Flannel在不同宿主机上的两个容器之间打通了一条“隧道”使得这两个容器可以直接使用IP地址进行通信而无需关心容器和宿主机的分布情况。
我前面曾经提到上述UDP模式有严重的性能问题所以已经被废弃了。通过我上面的讲述你有没有发现性能问题出现在了哪里呢
实际上相比于两台宿主机之间的直接通信基于Flannel UDP模式的容器通信多了一个额外的步骤即flanneld的处理过程。而这个过程由于使用到了flannel0这个TUN设备仅在发出IP包的过程中就需要经过三次用户态与内核态之间的数据拷贝如下所示
<img src="https://static001.geekbang.org/resource/image/84/8d/84caa6dc3f9dcdf8b88b56bd2e22138d.png" alt="" title="图2 TUN设备示意图">
我们可以看到:
第一次用户态的容器进程发出的IP包经过docker0网桥进入内核态
第二次IP包根据路由表进入TUNflannel0设备从而回到用户态的flanneld进程
第三次flanneld进行UDP封包之后重新进入内核态将UDP包通过宿主机的eth0发出去。
此外我们还可以看到Flannel进行UDP封装Encapsulation和解封装Decapsulation的过程也都是在用户态完成的。在Linux操作系统中上述这些上下文切换和用户态操作的代价其实是比较高的这也正是造成Flannel UDP模式性能不好的主要原因。
所以说,**我们在进行系统级编程的时候,有一个非常重要的优化原则,就是要减少用户态到内核态的切换次数,并且把核心的处理逻辑都放在内核态进行**。这也是为什么Flannel后来支持的VXLAN模式逐渐成为了主流的容器网络方案的原因。
VXLAN即Virtual Extensible LAN虚拟可扩展局域网是Linux内核本身就支持的一种网络虚似化技术。所以说VXLAN可以完全在内核态实现上述封装和解封装的工作从而通过与前面相似的“隧道”机制构建出覆盖网络Overlay Network
VXLAN的覆盖网络的设计思想是在现有的三层网络之上“覆盖”一层虚拟的、由内核VXLAN模块负责维护的二层网络使得连接在这个VXLAN二层网络上的“主机”虚拟机或者容器都可以之间可以像在同一个局域网LAN里那样自由通信。当然实际上这些“主机”可能分布在不同的宿主机上甚至是分布在不同的物理机房里。
而为了能够在二层网络上打通“隧道”VXLAN会在宿主机上设置一个特殊的网络设备作为“隧道”的两端。这个设备就叫作VTEPVXLAN Tunnel End Point虚拟隧道端点
而VTEP设备的作用其实跟前面的flanneld进程非常相似。只不过它进行封装和解封装的对象是二层数据帧Ethernet frame而且这个工作的执行流程全部是在内核里完成的因为VXLAN本身就是Linux内核中的一个模块
上述基于VTEP设备进行“隧道”通信的流程我也为你总结成了一幅图如下所示
<img src="https://static001.geekbang.org/resource/image/03/f5/03185fab251a833fef7ed6665d5049f5.jpg" alt="" title="图3 基于Flannel VXLAN模式的跨主通信的基本原理
">
可以看到图中每台宿主机上名叫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 1和Node 2上的flannel.1设备分别称为“源VTEP设备”和“目的VTEP设备”。
而这些VTEP设备之间就需要想办法组成一个虚拟的二层网络通过二层数据帧进行通信。
所以在我们的例子中“源VTEP设备”收到“原始IP包”后就要想办法把“原始IP包”加上一个目的MAC地址封装成一个二层数据帧然后发送给“目的VTEP设备”当然这么做还是因为这个IP包的目的地址不是本机
这里需要解决的问题就是:**“目的VTEP设备”的MAC地址是什么**
此时根据前面的路由记录我们已经知道了“目的VTEP设备”的IP地址。而要根据三层IP地址查询对应的二层MAC地址这正是ARPAddress 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。
>
可以看到最新版本的Flannel并不依赖L3 MISS事件和ARP学习而会在每台节点启动时把它的VTEP设备对应的ARP记录直接下放到其他每台宿主机上。
有了这个“目的VTEP设备”的MAC地址**Linux内核就可以开始二层封包工作了**。这个二层帧的格式,如下所示:
<img src="https://static001.geekbang.org/resource/image/f2/55/f208fba66d2b58405864882342b23255.jpg" alt="" title="图4 Flannel VXLAN模式的内部帧">
可以看到Linux内核会把“目的VTEP设备”的MAC地址填写在图中的Inner Ethernet Header字段得到一个二层数据帧。
需要注意的是上述封包过程只是加一个二层头不会改变“原始IP包”的内容。所以图中的Inner IP Header字段依然是container-2的IP地址即10.1.16.3。
但是上面提到的这些VTEP设备的MAC地址对于宿主机网络来说并没有什么实际意义。所以上面封装出来的这个数据帧并不能在我们的宿主机二层网络里传输。为了方便叙述我们把它称为“内部数据帧”Inner Ethernet Frame
所以接下来Linux内核还需要再把“内部数据帧”进一步封装成为宿主机网络里的一个普通的数据帧好让它“载着”“内部数据帧”通过宿主机的eth0网卡进行传输。
我们把这次要封装出来的、宿主机对应的数据帧称为“外部数据帧”Outer Ethernet Frame
为了实现这个“搭便车”的机制Linux内核会在“内部数据帧”前面加上一个特殊的VXLAN头用来表示这个“乘客”实际上是一个VXLAN要使用的数据帧。
而这个VXLAN头里有一个重要的标志叫作**VNI**它是VTEP设备识别某个数据帧是不是应该归自己处理的重要标识。而在Flannel中VNI的默认值是1这也是为何宿主机上的VTEP设备都叫作flannel.1的原因这里的“1”其实就是VNI的值。
**然后Linux内核会把这个数据帧封装进一个UDP包里发出去。**
所以跟UDP模式类似在宿主机看来它会以为自己的flannel.1设备只是在向另外一台宿主机的flannel.1设备发起了一次普通的UDP链接。它哪里会知道这个UDP包里面其实是一个完整的二层数据帧。这是不是跟特洛伊木马的故事非常像呢
不过不要忘了一个flannel.1设备只知道另一端的flannel.1设备的MAC地址却不知道对应的宿主机地址是什么。
也就是说这个UDP包该发给哪台宿主机呢
在这种场景下flannel.1设备实际上要扮演一个“网桥”的角色在二层网络进行UDP包的转发。而在Linux内核里面“网桥”设备进行转发的依据来自于一个叫作FDBForwarding 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 2UDP包要发往的目的地就找到了。
所以**接下来的流程,就是一个正常的、宿主机网络上的封包工作。**
我们知道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维护。这时候我们封装出来的“外部数据帧”的格式如下所示
<img src="https://static001.geekbang.org/resource/image/8c/85/8cede8f74a57617494027ba137383f85.jpg" alt="" title="图5 Flannel VXLAN模式的外部帧">
这样,封包工作就宣告完成了。
接下来Node 1上的flannel.1设备就可以把这个数据帧从Node 1的eth0网卡发出去。显然这个帧会经过宿主机网络来到Node 2的eth0网卡。
这时候Node 2的内核网络栈会发现这个数据帧里有VXLAN Header并且VNI=1。所以Linux内核会对它进行拆包拿到里面的内部数据帧然后根据VNI的值把它交给Node 2上的flannel.1设备。
而flannel.1设备则会进一步拆包取出“原始IP包”。接下来就回到了我在上一篇文章中分享的单机容器网络的处理流程。最终IP包就进入到了container-2容器的Network Namespace里。
以上就是Flannel VXLAN模式的具体工作原理了。
## 总结
在本篇文章中我为你详细讲解了Flannel UDP和VXLAN模式的工作原理。这两种模式其实都可以称作“隧道”机制也是很多其他容器网络插件的基础。比如Weave的两种模式以及Docker的Overlay模式。
此外从上面的讲解中我们可以看到VXLAN模式组建的覆盖网络其实就是一个由不同宿主机上的VTEP设备也就是flannel.1设备组成的虚拟二层网络。对于VTEP设备来说它发出的“内部数据帧”就仿佛是一直在这个虚拟的二层网络上流动。这也正是覆盖网络的含义。
>
<p>备注如果你想要在我们前面部署的集群中实践Flannel的话可以在Master节点上执行如下命令来替换网络插件。<br>
第一步,执行`$ rm -rf /etc/cni/net.d/*`<br>
第二步,执行`$ kubectl delete -f "https://cloud.weave.works/k8s/net?k8s-version=1.11"`<br>
第三步,在`/etc/kubernetes/manifests/kube-controller-manager.yaml`里,为容器启动命令添加如下两个参数:<br>
`--allocate-node-cidrs=true`<br>
`--cluster-cidr=10.244.0.0/16`<br>
第四步, 重启所有kubelet<br>
第五步, 执行`$ kubectl create -f https://raw.githubusercontent.com/coreos/flannel/bc79dd1505b0c8681ece4de4c0d86c5cd2643275/Documentation/kube-flannel.yml`</p>
## 思考题
可以看到Flannel通过上述的“隧道”机制实现了容器之间三层网络IP地址的连通性。但是根据这个机制的工作原理你认为Flannel能负责保证二层网络MAC地址的连通性吗为什么呢
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,344 @@
<audio id="audio" title="34 | Kubernetes网络模型与CNI网络插件" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d3/33/d38ef6da2fbe39a55edc25ca41814233.mp3"></audio>
你好我是张磊。今天我和你分享的主题是Kubernetes网络模型与CNI网络插件。
在上一篇文章中我以Flannel项目为例为你详细讲解了容器跨主机网络的两种实现方法UDP和VXLAN。
不难看到这些例子有一个共性那就是用户的容器都连接在docker0网桥上。而网络插件则在宿主机上创建了一个特殊的设备UDP模式创建的是TUN设备VXLAN模式创建的则是VTEP设备docker0与这个设备之间通过IP转发路由表进行协作。
然后,网络插件真正要做的事情,则是通过某种方法,把不同宿主机上的特殊设备连通,从而达到容器跨主机通信的目的。
实际上上面这个流程也正是Kubernetes对容器网络的主要处理方法。只不过Kubernetes是通过一个叫作CNI的接口维护了一个单独的网桥来代替docker0。这个网桥的名字就叫作CNI网桥它在宿主机上的设备名称默认是cni0。
以Flannel的VXLAN模式为例在Kubernetes环境里它的工作方式跟我们在上一篇文章中讲解的没有任何不同。只不过docker0网桥被替换成了CNI网桥而已如下所示
<img src="https://static001.geekbang.org/resource/image/9f/8c/9f11d8716f6d895ff6d1c813d460488c.jpg" alt="">
在这里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 1的cni0网桥上。所以这个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 2的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这里你可以再回顾一下专栏第13篇文章[《为什么我们需要Pod](https://time.geekbang.org/column/article/40092)中的相关内容)。
所以CNI的设计思想就是**Kubernetes在启动Infra容器之后就可以直接调用CNI网络插件为这个Infra容器的Network Namespace配置符合预期的网络栈。**
>
备注在前面第32篇文章[《浅谈容器网络》](https://time.geekbang.org/column/article/64948)中我讲解单机容器网络时已经和你分享过一个Network Namespace的网络栈包括网卡Network Interface、回环设备Loopback Device、路由表Routing Table和iptables规则。
那么,这个网络栈的配置工作又是如何完成的呢?
为了回答这个问题我们就需要从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网桥设备、ipvlan、loopbacklo设备、macvlan、ptpVeth Pair设备以及vlan。
我在前面提到过的Flannel、Weave等项目都属于“网桥”类型的CNI插件。所以在具体的实现中它们往往会调用bridge这个二进制文件。这个流程我马上就会详细介绍到。
**第二类叫作IPAMIP 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插件已经被内置了所以它无需再单独安装。而对于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
{
&quot;name&quot;: &quot;cbr0&quot;,
&quot;plugins&quot;: [
{
&quot;type&quot;: &quot;flannel&quot;,
&quot;delegate&quot;: {
&quot;hairpinMode&quot;: true,
&quot;isDefaultGateway&quot;: true
}
},
{
&quot;type&quot;: &quot;portmap&quot;,
&quot;capabilities&quot;: {
&quot;portMappings&quot;: true
}
}
]
}
```
需要注意的是在Kubernetes中处理容器网络相关的逻辑并不会在kubelet主干代码里执行而是会在具体的CRIContainer Runtime Interface容器运行时接口实现里完成。对于Docker项目来说它的CRI实现叫作dockershim你可以在kubelet的代码里找到它。
所以接下来dockershim会加载上述的CNI配置文件。
需要注意Kubernetes目前不支持多个CNI插件混用。如果你在CNI配置目录/etc/cni/net.d里放置了多个CNI配置文件的话dockershim只会加载按字母顺序排序的第一个插件。
但另一方面CNI允许你在一个CNI配置文件里通过plugins字段定义多个插件进行协作。
比如在我们上面这个例子里Flannel项目就指定了flannel和portmap这两个插件。
**这时候dockershim会把这个CNI配置文件加载起来并且把列表里的第一个插件、也就是flannel插件设置为默认插件**。而在后面的执行过程中flannel和portmap插件会按照定义顺序被调用从而依次完成“配置容器网络”和“配置端口映射”这两步操作。
接下来我就来为你讲解一下这样一个CNI插件的工作原理。
当kubelet组件需要创建Pod的时候它第一个创建的一定是Infra容器。所以在这一步dockershim就会先调用Docker API创建并启动Infra容器紧接着执行一个叫作SetUpPod的方法。这个方法的作用就是为CNI插件准备参数然后调用CNI插件为Infra容器配置网络。
这里要调用的CNI插件就是/opt/cni/bin/flannel而调用它所需要的参数分为两部分。
**第一部分是由dockershim设置的一组CNI环境变量。**
其中最重要的环境变量参数叫作CNI_COMMAND。它的取值只有两种ADD和DEL。
**这个ADD和DEL操作就是CNI插件唯一需要实现的两个方法。**
其中ADD操作的含义是把容器添加到CNI网络里DEL操作的含义则是把容器从CNI网络里移除掉。
而对于网桥类型的CNI插件来说这两个操作意味着把容器以Veth Pair的方式“插”到CNI网桥上或者从网桥上“拔”掉。
接下来我以ADD操作为重点进行讲解。
CNI的ADD操作需要的参数包括容器里网卡的名字eth0CNI_IFNAME、Pod的Network Namespace文件的路径CNI_NETNS、容器的IDCNI_CONTAINERID等。这些参数都属于上述环境变量里的内容。其中PodInfra容器的Network Namespace文件的路径我在前面讲解容器基础的时候提到过/proc/&lt;容器进程的PID&gt;/ns/net。
>
备注这里你也可以再回顾下专栏第8篇文章[《白话容器基础重新认识Docker容器》](https://time.geekbang.org/column/article/18119)中的相关内容。
除此之外,在 CNI 环境变量里还有一个叫作CNI_ARGS的参数。通过这个参数CRI实现比如dockershim就可以以Key-Value的格式传递自定义信息给网络插件。这是用户将来自定义CNI协议的一个重要方法。
**第二部分则是dockershim从CNI配置文件里加载到的、默认插件的配置信息。**
这个配置信息在CNI中被叫作Network Configuration它的完整定义你可以参考[这个文档](https://github.com/containernetworking/cni/blob/master/SPEC.md#network-configuration)。dockershim会把Network Configuration以JSON数据的格式通过标准输入stdin的方式传递给Flannel CNI插件。
而有了这两部分参数Flannel CNI插件实现ADD操作的过程就非常简单了。
不过需要注意的是Flannel的CNI配置文件 /etc/cni/net.d/10-flannel.conflist里有这么一个字段叫作delegate
```
...
&quot;delegate&quot;: {
&quot;hairpinMode&quot;: true,
&quot;isDefaultGateway&quot;: 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字段如下所示
```
{
&quot;hairpinMode&quot;:true,
&quot;ipMasq&quot;:false,
&quot;ipam&quot;:{
&quot;routes&quot;:[
{
&quot;dst&quot;:&quot;10.244.0.0/16&quot;
}
],
&quot;subnet&quot;:&quot;10.244.1.0/24&quot;,
&quot;type&quot;:&quot;host-local&quot;
},
&quot;isDefaultGateway&quot;:true,
&quot;isGateway&quot;:true,
&quot;mtu&quot;:1410,
&quot;name&quot;:&quot;cbr0&quot;,
&quot;type&quot;:&quot;bridge&quot;
}
```
其中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插件。
>
此外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 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](https://en.wikipedia.org/wiki/Network_address_translation)(即:端口映射)的方式,“自己访问自己”的场景下。
举个例子比如我们执行docker run -p 8080:80就是在宿主机上通过iptables设置了一条[DNAT](http://linux-ip.net/html/nat-dnat.html)(目的地址转换)转发规则。这条规则的作用是,当宿主机上的进程访问“&lt;宿主机的IP地址&gt;:8080”时iptables会把该请求直接转发到“&lt;容器的IP地址&gt;:80”上。也就是说这个请求最终会经过docker0网桥进入容器里面。
但如果你是在容器里面访问宿主机的8080端口那么这个容器里发出的IP包会经过vethb4963f3设备端口和docker0网桥来到宿主机上。此时根据上述DNAT规则这个IP包又需要回到docker0网桥并且还是通过vethb4963f3端口进入到容器里。所以这种情况下我们就需要开启vethb4963f3端口的Hairpin Mode了。
所以说Flannel插件要在CNI配置文件里声明hairpinMode=true。这样将来这个集群里的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方法就宣告结束了。接下来的流程就跟我们上一篇文章中容器跨主机通信的过程完全一致了。
需要注意的是对于非网桥类型的CNI插件上述“将容器添加到CNI网络”的操作流程以及网络方案本身的工作原理就都不太一样了。我将会在后续文章中继续为你分析这部分内容。
## 总结
在本篇文章中我为你详细讲解了Kubernetes中CNI网络的实现原理。根据这个原理你其实就很容易理解所谓的“Kubernetes网络模型”了
<li>
所有容器都可以直接使用IP地址与其他容器通信而无需使用NAT。
</li>
<li>
所有宿主机都可以直接使用IP地址与所有容器通信而无需使用NAT。反之亦然。
</li>
<li>
容器自己“看到”的自己的IP地址和别人宿主机或者容器看到的地址是完全一样的。
</li>
可以看到,这个网络模型,其实可以用一个字总结,那就是“通”。
容器与容器之间要“通”容器与宿主机之间也要“通”。并且Kubernetes要求这个“通”还必须是直接基于容器和宿主机的IP地址来进行的。
当然,考虑到不同用户之间的隔离性,在很多场合下,我们还要求容器之间的网络“不通”。这个问题,我会在后面的文章中会为你解决。
## 思考题
请你思考一下为什么Kubernetes项目不自己实现容器网络而是要通过 CNI 做一个如此简单的假设呢?
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,286 @@
<audio id="audio" title="35 | 解读Kubernetes三层网络方案" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9c/4e/9c2355af02dec4fdf8b506b527783f4e.mp3"></audio>
你好我是张磊。今天我和你分享的主题是解读Kubernetes三层网络方案。
在上一篇文章中我以网桥类型的Flannel插件为例为你讲解了Kubernetes里容器网络和CNI插件的主要工作原理。不过除了这种模式之外还有一种纯三层Pure Layer 3网络方案非常值得你注意。其中的典型例子莫过于Flannel的host-gw模式和Calico项目了。
我们先来看一下Flannel的host-gw模式。
它的工作原理非常简单我用一张图就可以和你说清楚。为了方便叙述接下来我会称这张图为“host-gw示意图”。
<img src="https://static001.geekbang.org/resource/image/3d/25/3d8b08411eeb49be2658eb4352206d25.png" alt="" title="图1 Flannel host-gw示意图">
假设现在Node 1上的Infra-container-1要访问Node 2上的Infra-container-2。
当你设置Flannel使用host-gw模式之后flanneld会在宿主机上创建这样一条规则以Node 1为例
```
$ ip route
...
10.244.1.0/24 via 10.168.0.3 dev eth0
```
这条路由规则的含义是目的IP地址属于10.244.1.0/24网段的IP包应该经过本机的eth0设备发出去dev eth0并且它下一跳地址next-hop是10.168.0.3via 10.168.0.3)。
所谓下一跳地址就是如果IP包从主机A发到主机B需要经过路由设备X的中转。那么X的IP地址就应该配置为主机A的下一跳地址。
而从host-gw示意图中我们可以看到这个下一跳地址对应的正是我们的目的宿主机Node 2。
一旦配置了下一跳地址那么接下来当IP包从网络层进入链路层封装成帧的时候eth0设备就会使用下一跳地址对应的MAC地址作为该数据帧的目的MAC地址。显然这个MAC地址正是Node 2的MAC地址。
这样这个数据帧就会从Node 1通过宿主机的二层网络顺利到达Node 2上。
而Node 2的内核网络栈从二层数据帧里拿到IP包后会“看到”这个IP包的目的IP地址是10.244.1.3即Infra-container-2的IP地址。这时候根据Node 2上的路由表该目的地址会匹配到第二条路由规则也就是10.244.1.0对应的路由规则从而进入cni0网桥进而进入到Infra-container-2当中。
可以看到,**host-gw模式的工作原理其实就是将每个Flannel子网Flannel Subnet比如10.244.1.0/24的“下一跳”设置成了该子网对应的宿主机的IP地址。**
也就是说这台“主机”Host会充当这条容器通信路径里的“网关”Gateway。这也正是“host-gw”的含义。
当然Flannel子网和主机的信息都是保存在Etcd当中的。flanneld只需要WACTH这些数据的变化然后实时更新路由表即可。
>
注意在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网络模型。当然“三层可达”也可以通过为几个子网设置三层转发来实现。
而在容器生态中要说到像Flannel host-gw这样的三层网络方案我们就不得不提到这个领域里的“龙头老大”Calico项目了。
实际上Calico项目提供的网络解决方案与Flannel的host-gw模式几乎是完全一样的。也就是说Calico也会在每台宿主机上添加一个格式如下所示的路由规则
```
&lt;目的容器IP地址段&gt; via &lt;网关的IP地址&gt; dev eth0
```
其中网关的IP地址正是目的容器所在宿主机的IP地址。
而正如前所述这个三层网络方案得以正常工作的核心是为每个容器的IP地址找到它所对应的、“下一跳”的**网关**。
不过,**不同于Flannel通过Etcd和宿主机上的flanneld来维护路由信息的做法Calico项目使用了一个“重型武器”来自动地在整个集群中分发路由信息。**
这个“重型武器”就是BGP。
**BGP的全称是Border Gateway Protocol边界网关协议**。它是一个Linux内核原生就支持的、专门用在大规模数据中心里维护不同的“自治系统”之间路由信息的、无中心的路由协议。
这个概念可能听起来有点儿“吓人”,但实际上,我可以用一个非常简单的例子来为你讲清楚。
<img src="https://static001.geekbang.org/resource/image/2e/9b/2e4b3bee1d924f4ae25e2c1fd115379b.jpg" alt="" title="图2 自治系统">
在这个图中我们有两个自治系统Autonomous System简称为ASAS 1和AS 2。而所谓的一个自治系统指的是一个组织管辖下的所有IP网络和路由器的全体。你可以把它想象成一个小公司里的所有主机和路由器。在正常情况下自治系统之间不会有任何“来往”。
但是如果这样两个自治系统里的主机要通过IP地址直接进行通信我们就必须使用路由器把这两个自治系统连接起来。
比如AS 1里面的主机10.10.0.2要访问AS 2里面的主机172.17.0.3的话。它发出的IP包就会先到达自治系统AS 1上的路由器 Router 1。
而在此时Router 1的路由表里有这样一条规则目的地址是172.17.0.2包应该经过Router 1的C接口发往网关Router 2自治系统AS 2上的路由器
所以IP包就会到达Router 2上然后经过Router 2的路由表从B接口出来到达目的主机172.17.0.3。
但是反过来如果主机172.17.0.3要访问10.10.0.2那么这个IP包在到达Router 2之后就不知道该去哪儿了。因为在Router 2的路由表里并没有关于AS 1自治系统的任何路由规则。
所以这时候网络管理员就应该给Router 2也添加一条路由规则比如目标地址是10.10.0.2的IP包应该经过Router 2的C接口发往网关Router 1。
像上面这样负责把自治系统连接在一起的路由器,我们就把它形象地称为:**边界网关**。它跟普通路由器的不同之处在于,它的路由表里拥有其他自治系统里的主机路由信息。
上面的这部分原理,相信你理解起来应该很容易。毕竟,路由器这个设备本身的主要作用,就是连通不同的网络。
但是,你可以想象一下,假设我们现在的网络拓扑结构非常复杂,每个自治系统都有成千上万个主机、无数个路由器,甚至是由多个公司、多个网络提供商、多个自治系统组成的复合自治系统呢?
这时候,如果还要依靠人工来对边界网关的路由表进行配置和维护,那是绝对不现实的。
而这种情况下BGP大显身手的时刻就到了。
在使用了BGP之后你可以认为在每个边界网关上都会运行着一个小程序它们会将各自的路由表信息通过TCP传输给其他的边界网关。而其他边界网关上的这个小程序则会对收到的这些数据进行分析然后将需要的信息添加到自己的路由表里。
这样图2中Router 2的路由表里就会自动出现10.10.0.2和10.10.0.3对应的路由规则了。
所以说,**所谓BGP就是在大规模网络中实现节点路由信息共享的一种协议。**
而BGP的这个能力正好可以取代Flannel维护主机上路由表的功能。而且BGP这种原生就是为大规模网络环境而实现的协议其可靠性和可扩展性远非Flannel自己的方案可比。
>
需要注意的是BGP协议实际上是最复杂的一种路由协议。我在这里的讲述和所举的例子仅是为了能够帮助你建立对BGP的感性认识并不代表BGP真正的实现方式。
接下来我们还是回到Calico项目上来。
在了解了BGP之后Calico项目的架构就非常容易理解了。它由三个部分组成
<li>
Calico的CNI插件。这是Calico与Kubernetes对接的部分。我已经在上一篇文章中和你详细分享了CNI插件的工作原理这里就不再赘述了。
</li>
<li>
Felix。它是一个DaemonSet负责在宿主机上插入路由规则写入Linux内核的FIB转发信息库以及维护Calico所需的网络设备等工作。
</li>
<li>
BIRD。它就是BGP的客户端专门负责在集群里分发路由规则信息。
</li>
**除了对路由信息的维护方式之外Calico项目与Flannel的host-gw模式的另一个不同之处就是它不会在宿主机上创建任何网桥设备**。这时候Calico的工作方式可以用一幅示意图来描述如下所示在接下来的讲述中我会统一用“BGP示意图”来指代它
<img src="https://static001.geekbang.org/resource/image/8d/1b/8db6dee96c4242738ae2878e58cecd1b.jpg" alt="" title="图3 Calico工作原理">
其中的绿色实线标出的路径就是一个IP包从Node 1上的Container 1到达Node 2上的Container 4的完整路径。
可以看到Calico的CNI插件会为每个容器设置一个Veth Pair设备然后把其中的一端放置在宿主机上它的名字以cali前缀开头
此外由于Calico没有使用CNI的网桥模式Calico的CNI插件还需要在宿主机上为每个容器的Veth Pair设备配置一条路由规则用于接收传入的IP包。比如宿主机Node 2上的Container 4对应的路由规则如下所示
```
10.233.2.3 dev cali5863f3 scope link
```
发往10.233.2.3的IP包应该进入cali5863f3设备。
>
基于上述原因Calico项目在宿主机上设置的路由规则肯定要比Flannel项目多得多。不过Flannel host-gw模式使用CNI网桥的主要原因其实是为了跟VXLAN模式保持一致。否则的话Flannel就需要维护两套CNI插件了。
有了这样的Veth Pair设备之后容器发出的IP包就会经过Veth Pair设备出现在宿主机上。然后宿主机网络栈就会根据路由规则的下一跳IP地址把它们转发给正确的网关。接下来的流程就跟Flannel host-gw模式完全一致了。
其中这里最核心的“下一跳”路由规则就是由Calico的Felix进程负责维护的。这些路由规则信息则是通过BGP Client也就是BIRD组件使用BGP协议传输而来的。
而这些通过BGP协议传输的消息你可以简单地理解为如下格式
```
[BGP消息]
我是宿主机192.168.1.3
10.233.2.0/24网段的容器都在我这里
这些容器的下一跳地址是我
```
不难发现Calico项目实际上将集群里的所有节点都当作是边界路由器来处理它们一起组成了一个全连通的网络互相之间通过BGP协议交换路由规则。这些节点我们称为BGP Peer。
需要注意的是,**Calico维护的网络在默认配置下是一个被称为“Node-to-Node Mesh”的模式**。这时候每台宿主机上的BGP Client都需要跟其他所有节点的BGP Client进行通信以便交换路由信息。但是随着节点数量N的增加这些连接的数量就会以N²的规模快速增长从而给集群本身的网络带来巨大的压力。
所以Node-to-Node Mesh模式一般推荐用在少于100个节点的集群里。而在更大规模的集群中你需要用到的是一个叫作Route Reflector的模式。
在这种模式下Calico会指定一个或者几个专门的节点来负责跟所有节点建立BGP连接从而学习到全局的路由规则。而其他节点只需要跟这几个专门的节点交换路由信息就可以获得整个集群的路由规则信息了。
这些专门的节点就是所谓的Route Reflector节点它们实际上扮演了“中间代理”的角色从而把BGP连接的规模控制在N的数量级上。
此外我在前面提到过Flannel host-gw模式最主要的限制就是要求集群宿主机之间是二层连通的。而这个限制对于Calico来说也同样存在。
举个例子假如我们有两台处于不同子网的宿主机Node 1和Node 2对应的IP地址分别是192.168.1.2和192.168.2.2。需要注意的是这两台机器通过路由器实现了三层转发所以这两个IP地址之间是可以相互通信的。
而我们现在的需求还是Container 1要访问Container 4。
按照我们前面的讲述Calico会尝试在Node 1上添加如下所示的一条路由规则
```
10.233.2.0/16 via 192.168.2.2 eth0
```
但是,这时候问题就来了。
上面这条规则里的下一跳地址是192.168.2.2可是它对应的Node 2跟Node 1却根本不在一个子网里没办法通过二层网络把IP包发送到下一跳地址。
**在这种情况下你就需要为Calico打开IPIP模式。**
我把这个模式下容器通信的原理总结成了一张图片如下所示接下来我会称之为IPIP示意图
<img src="https://static001.geekbang.org/resource/image/4d/c9/4dd9ad6415caf68da81562d9542049c9.jpg" alt="" title="图4 Calico IPIP模式工作原理">
在Calico的IPIP模式下Felix进程在Node 1上添加的路由规则会稍微不同如下所示
```
10.233.2.0/24 via 192.168.2.2 tunl0
```
可以看到尽管这条规则的下一跳地址仍然是Node 2的IP地址但这一次要负责将IP包发出去的设备变成了tunl0。注意是T-U-N-L-0而不是Flannel UDP模式使用的T-U-N-0tun0这两种设备的功能是完全不一样的。
Calico使用的这个tunl0设备是一个IP隧道IP tunnel设备。
在上面的例子中IP包进入IP隧道设备之后就会被Linux内核的IPIP驱动接管。IPIP驱动会将这个IP包直接封装在一个宿主机网络的IP包中如下所示
<img src="https://static001.geekbang.org/resource/image/fc/90/fc2b4173782b7a993f4a43a2cb966f90.jpg" alt="">
其中经过封装后的新的IP包的目的地址图5中的Outer IP Header部分正是原IP包的下一跳地址即Node 2的IP地址192.168.2.2。
而原IP包本身则会被直接封装成新IP包的Payload。
这样原先从容器到Node 2的IP包就被伪装成了一个从Node 1到Node 2的IP包。
由于宿主机之间已经使用路由器配置了三层转发也就是设置了宿主机之间的“下一跳”。所以这个IP包在离开Node 1之后就可以经过路由器最终“跳”到Node 2上。
这时Node 2的网络内核栈会使用IPIP驱动进行解包从而拿到原始的IP包。然后原始IP包就会经过路由规则和Veth Pair设备到达目的容器内部。
以上就是Calico项目主要的工作原理了。
不难看到当Calico使用IPIP模式的时候集群的网络性能会因为额外的封包和解包工作而下降。在实际测试中Calico IPIP模式与Flannel VXLAN模式的性能大致相当。所以在实际使用时如非硬性需求我建议你将所有宿主机节点放在一个子网里避免使用IPIP。
不过通过上面对Calico工作原理的讲述你应该能发现这样一个事实
如果Calico项目能够让宿主机之间的路由设备也就是网关也通过BGP协议“学习”到Calico网络里的路由规则那么从容器发出的IP包不就可以通过这些设备路由到目的宿主机了么
比如只要在上面“IPIP示意图”中的Node 1上添加如下所示的一条路由规则
```
10.233.2.0/24 via 192.168.1.1 eth0
```
然后在Router 1上192.168.1.1),添加如下所示的一条路由规则:
```
10.233.2.0/24 via 192.168.2.1 eth0
```
那么Container 1发出的IP包就可以通过两次“下一跳”到达Router 2192.168.2.1了。以此类推我们可以继续在Router 2上添加“下一条”路由最终把IP包转发到Node 2上。
遗憾的是上述流程虽然简单明了但是在Kubernetes被广泛使用的公有云场景里却完全不可行。
这里的原因在于:公有云环境下,宿主机之间的网关,肯定不会允许用户进行干预和设置。
>
当然,在大多数公有云环境下,宿主机(公有云提供的虚拟机)本身往往就是二层连通的,所以这个需求也不强烈。
不过在私有部署的环境下宿主机属于不同子网VLAN反而是更加常见的部署状态。这时候想办法将宿主机网关也加入到BGP Mesh里从而避免使用IPIP就成了一个非常迫切的需求。
而在Calico项目中它已经为你提供了两种将宿主机网关设置成BGP Peer的解决方案。
**第一种方案**就是所有宿主机都跟宿主机网关建立BGP Peer关系。
这种方案下Node 1和Node 2就需要主动跟宿主机网关Router 1和Router 2建立BGP连接。从而将类似于10.233.2.0/24这样的路由信息同步到网关上去。
需要注意的是这种方式下Calico要求宿主机网关必须支持一种叫作Dynamic Neighbors的BGP配置方式。这是因为在常规的路由器BGP配置里运维人员必须明确给出所有BGP Peer的IP地址。考虑到Kubernetes集群可能会有成百上千个宿主机而且还会动态地添加和删除节点这时候再手动管理路由器的BGP配置就非常麻烦了。而Dynamic Neighbors则允许你给路由器配置一个网段然后路由器就会自动跟该网段里的主机建立起BGP Peer关系。
不过,相比之下,我更愿意推荐**第二种方案**。
这种方案是使用一个或多个独立组件负责搜集整个集群里的所有路由信息然后通过BGP协议同步给网关。而我们前面提到在大规模集群中Calico本身就推荐使用Route Reflector节点的方式进行组网。所以这里负责跟宿主机网关进行沟通的独立组件直接由Route Reflector兼任即可。
更重要的是这种情况下网关的BGP Peer个数是有限并且固定的。所以我们就可以直接把这些独立组件配置成路由器的BGP Peer而无需Dynamic Neighbors的支持。
当然这些独立组件的工作原理也很简单它们只需要WATCH Etcd里的宿主机和对应网段的变化信息然后把这些信息通过BGP协议分发给网关即可。
## 总结
在本篇文章中我为你详细讲述了Fannel host-gw模式和Calico这两种纯三层网络方案的工作原理。
需要注意的是,在大规模集群里,三层网络方案在宿主机上的路由规则可能会非常多,这会导致错误排查变得困难。此外,在系统故障的时候,路由规则出现重叠冲突的概率也会变大。
基于上述原因如果是在公有云上由于宿主机网络本身比较“直白”我一般会推荐更加简单的Flannel host-gw模式。
但不难看到在私有部署环境里Calico项目才能够覆盖更多的场景并为你提供更加可靠的组网方案和架构思路。
## 思考题
你能否能总结一下三层网络方案和“隧道模式”的异同,以及各自的优缺点?
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,319 @@
<audio id="audio" title="36 | 为什么说Kubernetes只有soft multi-tenancy" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/46/58/46705030342c8f1e84f2ae5ac84f7858.mp3"></audio>
你好我是张磊。今天我和你分享的主题是为什么说Kubernetes只有soft multi-tenancy
在前面的文章中我为你详细讲解了Kubernetes生态里主流容器网络方案的工作原理。
不难发现Kubernetes的网络模型以及前面这些网络方案的实现都只关注容器之间网络的“连通”却并不关心容器之间网络的“隔离”。这跟传统的IaaS层的网络方案区别非常明显。
你肯定会问了Kubernetes的网络方案对“隔离”到底是如何考虑的呢难道Kubernetes就不管网络“多租户”的需求吗
接下来,在今天这篇文章中,我就来回答你的这些问题。
在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
```
我在和你分享前面的内容时已经说过这里你可以再回顾下第34篇文章[](https://time.geekbang.org/column/article/67351)[Kubernetes 网络模型与 CNI 网络插件](https://time.geekbang.org/column/article/67351)[](https://time.geekbang.org/column/article/67351)中的相关内容),**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的类型是ingress和egress它既会影响流入ingress请求也会影响流出egress请求。
然后在ingress字段里我定义了from和ports允许流入的“白名单”和端口。其中这个允许流入的“白名单”里我指定了**三种并列的情况**分别是ipBlock、namespaceSelector和podSelector。
而在egress字段里我则定义了to和ports允许流出的“白名单”和端口。这里允许流出的“白名单”的定义方法与ingress类似。只不过这一次ipblock字段指定的是目的地址的网段。
综上所述这个NetworkPolicy对象指定的隔离规则如下所示
1. 该隔离规则只对default Namespace下的携带了role=db标签的Pod有效。限制的请求类型包括ingress流入和egress流出
<li>Kubernetes会拒绝任何访问被隔离Pod的请求除非这个请求来自于以下“白名单”里的对象并且访问的是被隔离Pod的6379端口。这些“白名单”对象包括<br>
a. default Namespace里的携带了role=fronted标签的Pod<br>
b. 携带了project=myproject 标签的 Namespace 里的任何 Pod<br>
c. 任何源地址属于172.17.0.0/16网段且不属于172.17.1.0/24网段的请求。</li>
1. 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
...
```
注意看这样定义的namespaceSelector和podSelector其实是“与”AND的关系。所以说这个from字段只定义了一种情况只有Namespace和Pod同时满足条件这个NetworkPolicy才会生效。
**这两种定义方式的区别,请你一定要分清楚。**
此外如果要使上面定义的NetworkPolicy在Kubernetes集群里真正产生作用你的CNI网络插件就必须是支持Kubernetes的NetworkPolicy的。
在具体实现上凡是支持NetworkPolicy的CNI网络插件都维护着一个NetworkPolicy Controller通过控制循环的方式对NetworkPolicy对象的增删改查做出响应然后在宿主机上完成iptables规则的配置工作。
在Kubernetes生态里目前已经实现了NetworkPolicy的网络插件包括Calico、Weave和kube-router等多个项目但是并不包括Flannel项目。
所以说如果想要在使用Flannel的同时还使用NetworkPolicy的话你就需要再额外安装一个网络插件比如Calico项目来负责执行NetworkPolicy。
>
安装Flannel + Calico的流程非常简单你直接参考这个文档[一键安装](https://docs.projectcalico.org/v3.2/getting-started/kubernetes/installation/flannel)即可。
那么这些网络插件又是如何根据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
```
可以看到我们指定的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只是一个操作Linux内核Netfilter子系统的“界面”。顾名思义Netfilter子系统的作用就是Linux内核里挡在“网卡”和“用户态进程”之间的一道“防火墙”。它们的关系可以用如下的示意图来表示
<img src="https://static001.geekbang.org/resource/image/4a/c2/4a012412dd694cb815ac9ee11ce511c2.png" alt=""><br>
可以看到这幅示意图中IP包“一进一出”的两条路径上有几个关键的“检查点”它们正是Netfilter设置“防火墙”的地方。**在iptables中这些“检查点”被称为Chain**。这是因为这些“检查点”对应的iptables规则是按照定义顺序依次进行匹配的。这些“检查点”的具体工作原理可以用如下所示的示意图进行描述
<img src="https://static001.geekbang.org/resource/image/f7/8e/f722f0f8b16338b02aa02904729dbc8e.jpg" alt="">
可以看到当一个IP包通过网卡进入主机之后它就进入了Netfilter定义的流入路径Input Path里。
在这个路径中IP包要经过路由表路由来决定下一步的去向。而在这次路由之前Netfilter设置了一个名叫PREROUTING的“检查点”。在Linux内核的实现里所谓“检查点”实际上就是内核网络协议栈代码里的Hook比如在执行路由判断的代码之前内核会先调用PREROUTING的Hook
而在经过路由之后IP包的去向就分为了两种
- 第一种,继续在本机处理;
- 第二种,被转发到其他目的地。
**我们先说一下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的作用其实就是上述两条路径最终汇聚在一起的“最终检查点”。
需要注意的是在有网桥参与的情况下上述Netfilter设置“检查点”的流程实际上也会出现在链路层二层并且会跟我在上面讲述的网络层三层的流程有交互。
这些链路层的“检查点”对应的操作界面叫作ebtables。所以准确地说数据包在Linux Netfilter子系统里完整的流动过程其实应该如下所示这是一幅来自[Netfilter官方的原理图](https://en.wikipedia.org/wiki/Iptables#/media/File:Netfilter-packet-flow.svg),建议你点击图片以查看大图):
<img src="https://static001.geekbang.org/resource/image/e9/32/e96b58808bf16039e9975e947a6c7532.jpg" alt="">
可以看到我前面为你讲述的正是上图中绿色部分也就是网络层的iptables链的工作流程。
另外你应该还能看到每一个白色的“检查点”上还有一个绿色的“标签”比如raw、nat、filter等等。
在iptables里这些标签叫作表。比如同样是OUTPUT这个“检查点”filter Output和nat Output在iptables里的语法和参数就完全不一样实现的功能也完全不同。
所以说iptables表的作用就是在某个具体的“检查点”比如Output按顺序执行几个不同的检查动作比如先执行nat再执行filter
在理解了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包。
当然如果是像Calico这样的非网桥模式的CNI插件就不存在这个情况了。
>
kube-router其实是一个简化版的Calico它也使用BGP来维护路由信息但是使用CNI bridge插件负责跟Kubernetes进行交互。
而**第二条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的基本方法了。当然对于不同的插件来说上述实现过程可能有不同的手段但根本原理是不变的。
## 总结
在本篇文章中我主要和你分享了Kubernetes对Pod进行“隔离”的手段NetworkPolicy。
可以看到NetworkPolicy实际上只是宿主机上的一系列iptables规则。这跟传统IaaS里面的安全组Security Group其实是非常类似的。
而基于上述讲述,你就会发现这样一个事实:
Kubernetes的网络模型以及大多数容器网络实现其实既不会保证容器之间二层网络的互通也不会实现容器之间的二层网络隔离。这跟IaaS项目管理虚拟机的方式是完全不同的。
所以说Kubernetes从底层的设计和实现上更倾向于假设你已经有了一套完整的物理基础设施。然后Kubernetes负责在此基础上提供一种“弱多租户”soft multi-tenancy的能力。
并且基于上述思路Kubernetes将来也不大可能把Namespace变成一个具有实质意义的隔离机制或者把它映射成为“子网”或者“租户”。毕竟你可以看到NetworkPolicy对象的描述能力要比基于Namespace的划分丰富得多。
这也是为什么到目前为止Kubernetes项目在云计算生态里的定位其实是基础设施与PaaS之间的中间层。这是非常符合“容器”这个本质上就是进程的抽象粒度的。
当然随着Kubernetes社区以及CNCF生态的不断发展Kubernetes项目也已经开始逐步下探“吃”掉了基础设施领域的很多“蛋糕”。这也正是容器生态继续发展的一个必然方向。
## 思考题
请你编写这样一个NetworkPolicy它使得指定的Namespace比如my-namespace里的所有Pod都不能接收任何Ingress请求。然后请你说说这样的NetworkPolicy有什么实际的作用
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,260 @@
<audio id="audio" title="37 | 找到容器不容易Service、DNS与服务发现" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d4/45/d4b42d64264d45ef71b9559d2d0fa145.mp3"></audio>
你好我是张磊。今天我和你分享的主题是找到容器不容易之Service、DNS与服务发现。
在前面的文章中我们已经多次使用到了Service这个Kubernetes里重要的服务对象。而Kubernetes之所以需要Service一方面是因为Pod的IP不是固定的另一方面则是因为一组Pod实例之间总会有负载均衡的需求。
一个最典型的Service定义如下所示
```
apiVersion: v1
kind: Service
metadata:
name: hostnames
spec:
selector:
app: hostnames
ports:
- name: default
protocol: TCP
port: 80
targetPort: 9376
```
这个Service的例子相信你不会陌生。其中我使用了selector字段来声明这个Service只代理携带了app=hostnames标签的Pod。并且这个Service的80端口代理的是Pod的9376端口。
然后我们的应用的Deployment如下所示
```
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
```
这个应用的作用就是每次访问9376端口时返回它自己的hostname。
而被selector选中的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 &lt;none&gt; 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提供的是Round Robin方式的负载均衡。对于这种方式我们称为ClusterIP模式的Service。
你可能一直比较好奇Kubernetes里的Service究竟是如何工作的呢
实际上,**Service是由kube-proxy组件加上iptables来共同实现的。**
举个例子对于我们前面创建的名叫hostnames的Service来说一旦它被提交给Kubernetes那么kube-proxy就可以通过Service的Informer感知到这样一个Service对象的添加。而作为对这个事件的响应它就会在宿主机上创建这样一条iptables规则你可以通过iptables-save看到它如下所示
```
-A KUBE-SERVICES -d 10.0.1.175/32 -p tcp -m comment --comment &quot;default/hostnames: cluster IP&quot; -m tcp --dport 80 -j KUBE-SVC-NWV5X2332I4OT4T3
```
可以看到这条iptables规则的含义是凡是目的地址是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 &quot;default/hostnames:&quot; -m statistic --mode random --probability 0.33332999982 -j KUBE-SEP-WNBA2IHDGP2BOBGZ
-A KUBE-SVC-NWV5X2332I4OT4T3 -m comment --comment &quot;default/hostnames:&quot; -m statistic --mode random --probability 0.50000000000 -j KUBE-SEP-X3P2623AGDH6CDF3
-A KUBE-SVC-NWV5X2332I4OT4T3 -m comment --comment &quot;default/hostnames:&quot; -j KUBE-SEP-57KPRZ3JQVENLNBR
```
可以看到这一组规则实际上是一组随机模式mode random的iptables链。
而随机转发的目的地分别是KUBE-SEP-WNBA2IHDGP2BOBGZ、KUBE-SEP-X3P2623AGDH6CDF3和KUBE-SEP-57KPRZ3JQVENLNBR。
而这三条链指向的最终目的地其实就是这个Service代理的三个Pod。所以这一组规则就是Service实现负载均衡的位置。
需要注意的是iptables规则的匹配是从上到下逐条进行的所以为了保证上述三条规则每条被选中的概率都相同我们应该将它们的probability字段的值分别设置为1/30.333…、1/2和1。
这么设置的原理很简单第一条规则被选中的概率就是1/3而如果第一条规则没有被选中那么这时候就只剩下两条规则了所以第二条规则的probability就必须设置为1/2类似地最后一条就必须设置为1。
你可以想一下如果把这三条规则的probability字段的值都设置成1/3最终每条规则被选中的概率会变成多少。
通过查看上述三条链的明细我们就很容易理解Service进行转发的具体原理了如下所示
```
-A KUBE-SEP-57KPRZ3JQVENLNBR -s 10.244.3.6/32 -m comment --comment &quot;default/hostnames:&quot; -j MARK --set-xmark 0x00004000/0x00004000
-A KUBE-SEP-57KPRZ3JQVENLNBR -p tcp -m comment --comment &quot;default/hostnames:&quot; -m tcp -j DNAT --to-destination 10.244.3.6:9376
-A KUBE-SEP-WNBA2IHDGP2BOBGZ -s 10.244.1.7/32 -m comment --comment &quot;default/hostnames:&quot; -j MARK --set-xmark 0x00004000/0x00004000
-A KUBE-SEP-WNBA2IHDGP2BOBGZ -p tcp -m comment --comment &quot;default/hostnames:&quot; -m tcp -j DNAT --to-destination 10.244.1.7:9376
-A KUBE-SEP-X3P2623AGDH6CDF3 -s 10.244.2.3/32 -m comment --comment &quot;default/hostnames:&quot; -j MARK --set-xmark 0x00004000/0x00004000
-A KUBE-SEP-X3P2623AGDH6CDF3 -p tcp -m comment --comment &quot;default/hostnames:&quot; -m tcp -j DNAT --to-destination 10.244.2.3:9376
```
可以看到这三条链其实是三条DNAT规则。但在DNAT规则之前iptables对流入的IP包还设置了一个“标志”set-xmark。这个“标志”的作用我会在下一篇文章再为你讲解。
而DNAT规则的作用就是在PREROUTING检查点之前也就是在路由之前将流入IP包的目的地址和端口改成to-destination所指定的新的目的地址和端口。可以看到这个目的地址和端口正是被代理Pod的IP地址和端口。
这样访问Service VIP的IP包经过上述iptables处理之后就已经变成了访问具体某一个后端Pod的IP包了。不难理解这些Endpoints对应的iptables规则正是kube-proxy通过监听Pod的变化事件在宿主机上生成并维护的。
以上就是Service最基本的工作原理。
此外你可能已经听说过Kubernetes的kube-proxy还支持一种叫作IPVS的模式。这又是怎么一回事儿呢
其实通过上面的讲解你可以看到kube-proxy通过iptables处理Service的过程其实需要在宿主机上设置相当多的iptables规则。而且kube-proxy还需要在控制循环里不断地刷新这些规则来确保它们始终是正确的。
不难想到当你的宿主机上有大量Pod的时候成百上千条iptables规则不断地被刷新会大量占用该宿主机的CPU资源甚至会让宿主机“卡”在这个过程中。所以说**一直以来基于iptables的Service实现都是制约Kubernetes项目承载更多量级的Pod的主要障碍。**
而IPVS模式的Service就是解决这个问题的一个行之有效的方法。
IPVS模式的工作原理其实跟iptables模式类似。当我们创建了前面的Service之后kube-proxy首先会在宿主机上创建一个虚拟网卡叫作kube-ipvs0并为它分配Service VIP作为IP地址如下所示
```
# ip addr
...
73kube-ipvs0&lt;BROADCAST,NOARP&gt; 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
-&gt; RemoteAddress:Port Forward Weight ActiveConn InActConn
TCP 10.102.128.4:80 rr
-&gt; 10.244.3.6:9376 Masq 1 0 0
-&gt; 10.244.1.7:9376 Masq 1 0 0
-&gt; 10.244.2.3:9376 Masq 1 0 0
```
可以看到这三个IPVS虚拟主机的IP地址和端口对应的正是三个被代理的Pod。
这时候任何发往10.102.128.4:80的请求就都会被IPVS模块转发到某一个后端Pod上了。
而相比于iptablesIPVS在内核中的实现其实也是基于Netfilter的NAT模式所以在转发这一层上理论上IPVS并没有显著的性能提升。但是IPVS并不需要在宿主机上为每个Pod设置iptables规则而是把对这些“规则”的处理放到了内核态从而极大地降低了维护这些规则的代价。这也正印证了我在前面提到过的“将重要操作放入内核态”是提高性能的重要手段。
>
备注这里你可以再回顾下第33篇文章[《深入解析容器跨主机网络》](https://time.geekbang.org/column/article/65287)中的相关内容。
不过需要注意的是IPVS模块只负责上述的负载均衡和代理功能。而一个完整的Service流程正常工作所需要的包过滤、SNAT等操作还是要靠iptables来实现。只不过这些辅助性的iptables规则数量有限也不会随着Pod数量的增加而增加。
所以在大规模集群里我非常建议你为kube-proxy设置proxy-mode=ipvs来开启这个功能。它为Kubernetes集群规模带来的提升还是非常巨大的。
**此外我在前面的文章中还介绍过Service与DNS的关系。**
在Kubernetes中Service和Pod都会被分配对应的DNS A记录从域名解析IP的记录
对于ClusterIP模式的Service来说比如我们上面的例子它的A记录的格式是<my-svc>.<my-namespace>.svc.cluster.local。当你访问这条A记录的时候它解析到的就是该Service的VIP地址。</my-namespace></my-svc>
而对于指定了clusterIP=None的Headless Service来说它的A记录的格式也是<my-svc>.<my-namespace>.svc.cluster.local。但是当你访问这条A记录的时候它返回的是所有被代理的Pod的IP地址的集合。当然如果你的客户端没办法解析这个集合的话它可能会只会拿到第一个Pod的IP地址。</my-namespace></my-svc>
此外对于ClusterIP模式的Service来说它代理的Pod被自动分配的A记录的格式是<pod-ip>.<my-namespace>.pod.cluster.local。这条记录指向Pod的IP地址。</my-namespace></pod-ip>
而对Headless Service来说它代理的Pod被自动分配的A记录的格式是<my-pod-name>.<my-service-name>.<my-namespace>.svc.cluster.local。这条记录也指向Pod的IP地址。</my-namespace></my-service-name></my-pod-name>
但如果你为Pod指定了Headless Service并且Pod本身声明了hostname和subdomain字段那么这时候Pod的A记录就会变成&lt;pod的hostname&gt;.<subdomain>.<my-namespace>.svc.cluster.local比如</my-namespace></subdomain>
```
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
- &quot;3600&quot;
name: busybox
```
在上面这个Service和Pod被创建之后你就可以通过busybox-1.default-subdomain.default.svc.cluster.local解析到这个Pod的IP地址了。
需要注意的是在Kubernetes里/etc/hosts文件是单独挂载的这也是为什么kubelet能够对hostname进行修改并且Pod重建后依然有效的原因。这跟Docker的Init层是一个原理。
## 总结
在这篇文章里我为你详细讲解了Service的工作原理。实际上Service机制以及Kubernetes里的DNS插件都是在帮助你解决同样一个问题如何找到我的某一个容器
这个问题在平台级项目中往往就被称作服务发现当我的一个服务Pod的IP地址是不固定的且没办法提前获知时我该如何通过一个固定的方式访问到这个Pod呢
而我在这里讲解的、ClusterIP模式的Service为你提供的就是一个Pod的稳定的IP地址即VIP。并且这里Pod和Service的关系是可以通过Label确定的。
而Headless Service为你提供的则是一个Pod的稳定的DNS名字并且这个名字是可以通过Pod名字和Service名字拼接出来的。
在实际的场景里,你应该根据自己的具体需求进行合理选择。
## 思考题
请问Kubernetes的Service的负载均衡策略在iptables和ipvs模式下都有哪几种具体工作模式是怎样的
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,272 @@
<audio id="audio" title="38 | 从外界连通Service与Service调试“三板斧”" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/df/5d/dfb5d3a01e072a3d77db8dbf23faad5d.mp3"></audio>
你好我是张磊。今天我和你分享的主题是从外界连通Service与Service调试“三板斧”。
在上一篇文章中我为你介绍了Service机制的工作原理。通过这些讲解你应该能够明白这样一个事实Service的访问信息在Kubernetes集群之外其实是无效的。
这其实也容易理解所谓Service的访问入口其实就是每台宿主机上由kube-proxy生成的iptables规则以及kube-dns生成的DNS记录。而一旦离开了这个集群这些信息对用户来说也就自然没有作用了。
所以在使用Kubernetes的Service时一个必须要面对和解决的问题就是**如何从外部Kubernetes集群之外访问到Kubernetes里创建的Service**
这里最常用的一种方式就是NodePort。我来为你举个例子。
```
apiVersion: v1
kind: Service
metadata:
name: my-nginx
labels:
run: my-nginx
spec:
type: NodePort
ports:
- nodePort: 8080
targetPort: 80
protocol: TCP
name: http
- nodePort: 443
protocol: TCP
name: https
selector:
run: my-nginx
```
在这个Service的定义里我们声明它的类型是type=NodePort。然后我在ports字段里声明了Service的8080端口代理Pod的80端口Service的443端口代理Pod的443端口。
当然如果你不显式地声明nodePort字段Kubernetes就会为你分配随机的可用端口来设置代理。这个端口的范围默认是30000-32767你可以通过kube-apiserver的service-node-port-range参数来修改它。
那么这时候要访问这个Service你只需要访问
```
&lt;任何一台宿主机的IP地址&gt;:8080
```
就可以访问到某一个被代理的Pod的80端口了。
而在理解了我在上一篇文章中讲解的Service的工作原理之后NodePort模式也就非常容易理解了。显然kube-proxy要做的就是在每台宿主机上生成这样一条iptables规则
```
-A KUBE-NODEPORTS -p tcp -m comment --comment &quot;default/my-nginx: nodePort&quot; -m tcp --dport 8080 -j KUBE-SVC-67RL4FN6JRUPOJYM
```
而我在上一篇文章中已经讲到KUBE-SVC-67RL4FN6JRUPOJYM其实就是一组随机模式的iptables规则。所以接下来的流程就跟ClusterIP模式完全一样了。
需要注意的是在NodePort方式下Kubernetes会在IP包离开宿主机发往目的Pod时对这个IP包做一次SNAT操作如下所示
```
-A KUBE-POSTROUTING -m comment --comment &quot;kubernetes service traffic requiring SNAT&quot; -m mark --mark 0x4000/0x4000 -j MASQUERADE
```
可以看到这条规则设置在POSTROUTING检查点也就是说它给即将离开这台主机的IP包进行了一次SNAT操作将这个IP包的源地址替换成了这台宿主机上的CNI网桥地址或者宿主机本身的IP地址如果CNI网桥不存在的话
当然这个SNAT操作只需要对Service转发出来的IP包进行否则普通的IP包就被影响了。而iptables做这个判断的依据就是查看该IP包是否有一个“0x4000”的“标志”。你应该还记得这个标志正是在IP包被执行DNAT操作之前被打上去的。
可是,**为什么一定要对流出的包做SNAT<strong><strong>操作**</strong>呢?</strong>
这里的原理其实很简单,如下所示:
```
client
\ ^
\ \
v \
node 1 &lt;--- node 2
| ^ SNAT
| | ---&gt;
v |
endpoint
```
当一个外部的client通过node 2的地址访问一个Service的时候node 2上的负载均衡规则就可能把这个IP包转发给一个在node 1上的Pod。这里没有任何问题。
而当node 1上的这个Pod处理完请求之后它就会按照这个IP包的源地址发出回复。
可是如果没有做SNAT操作的话这时候被转发来的IP包的源地址就是client的IP地址。**所以此时Pod就会直接将回复发<strong><strong>给**</strong>client。</strong>对于client来说它的请求明明发给了node 2收到的回复却来自node 1这个client很可能会报错。
所以在上图中当IP包离开node 2之后它的源IP地址就会被SNAT改成node 2的CNI网桥地址或者node 2自己的地址。这样Pod在处理完成之后就会先回复给node 2而不是client然后再由node 2发送给client。
当然这也就意味着这个Pod只知道该IP包来自于node 2而不是外部的client。对于Pod需要明确知道所有请求来源的场景来说这是不可以的。
所以这时候你就可以将Service的spec.externalTrafficPolicy字段设置为local这就保证了所有Pod通过Service收到请求之后一定可以看到真正的、外部client的源地址。
而这个机制的实现原理也非常简单:**这时候一台宿主机上的iptables规则会设置为只将IP包转发给运行在这台宿主机上的Pod**。所以这时候Pod就可以直接使用源地址将回复包发出不需要事先进行SNAT了。这个流程如下所示
```
client
^ / \
/ / \
/ v X
node 1 node 2
^ |
| |
| v
endpoint
```
当然这也就意味着如果在一台宿主机上没有任何一个被代理的Pod存在比如上图中的node 2那么你使用node 2的IP地址访问这个Service就是无效的。此时你的请求会直接被DROP掉。
从外部访问Service的第二种方式适用于公有云上的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地址配置给负载均衡服务做后端。
而第三种方式是Kubernetes在1.7之后支持的一个新特性叫作ExternalName。举个例子
```
kind: Service
apiVersion: v1
metadata:
name: my-service
spec:
type: ExternalName
externalName: my.database.example.com
```
在上述Service的YAML文件中我指定了一个externalName=my.database.example.com的字段。而且你应该会注意到这个YAML文件里不需要指定selector。
这时候当你通过Service的DNS名字访问它的时候比如访问my-service.default.svc.cluster.local。那么Kubernetes为你返回的就是`my.database.example.com`。所以说ExternalName类型的Service其实是在kube-dns里为你添加了一条CNAME记录。这时访问my-service.default.svc.cluster.local就和访问my.database.example.com这个域名是一个效果了。
此外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
```
在上述Service中我为它指定的externalIPs=80.11.12.10那么此时你就可以通过访问80.11.12.10:80访问到被代理的Pod了。不过在这里Kubernetes要求externalIPs必须是至少能够路由到一个Kubernetes的节点。你可以想一想这是为什么。
实际上在理解了Kubernetes Service机制的工作原理之后很多与Service相关的问题其实都可以通过分析Service在宿主机上对应的iptables规则或者IPVS配置得到解决。
比如当你的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 定义是不是有问题。
而如果你的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的readniessProbe没通过它也不会出现在Endpoints列表里。
而如果Endpoints正常那么你就需要确认kube-proxy是否在正确运行。在我们通过kubeadm部署的集群里你应该看到kube-proxy输出的日志如下所示
```
I1027 22:14:53.995134 5063 server.go:200] Running in resource-only container &quot;/kube-proxy&quot;
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 &quot;kube-system/kube-dns:dns-tcp&quot; to [10.244.1.3:53]
I1027 22:14:54.038164 5063 proxier.go:352] Setting endpoints for &quot;kube-system/kube-dns:dns&quot; to [10.244.1.3:53]
I1027 22:14:54.038209 5063 proxier.go:352] Setting endpoints for &quot;default/kubernetes:https&quot; 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 &quot;default/kubernetes:https&quot; at 10.0.0.1:443/TCP
I1027 22:14:54.040154 5063 proxier.go:294] Adding new service &quot;kube-system/kube-dns:dns&quot; at 10.0.0.10:53/UDP
I1027 22:14:54.040223 5063 proxier.go:294] Adding new service &quot;kube-system/kube-dns:dns-tcp&quot; at 10.0.0.10:53/TCP
```
如果kube-proxy一切正常你就应该仔细查看宿主机上的iptables了。而**一个iptables模式的Service对应的规则我在上一篇以及这一篇文章里已经全部介绍到了它们包括**
<li>
KUBE-SERVICES或者KUBE-NODEPORTS规则对应的Service的入口链这个规则应该与VIP和Service端口一一对应
</li>
<li>
KUBE-SEP-(hash)规则对应的DNAT链这些规则应该与Endpoints一一对应
</li>
<li>
KUBE-SVC-(hash)规则对应的负载均衡链,这些规则的数目应该与 Endpoints 数目一致;
</li>
<li>
如果是NodePort模式的话还有POSTROUTING处的SNAT链。
</li>
通过查看这些链的数量、转发目的地址、端口、过滤条件等信息,你就能很容易发现一些异常的蛛丝马迹。
当然,**还有一种典型问题就是Pod没办法通过Service访问到自己**。这往往就是因为kubelet的hairpin-mode没有被正确设置。关于Hairpin的原理我在前面已经介绍过这里就不再赘述了。你只需要确保将kubelet的hairpin-mode设置为hairpin-veth或者promiscuous-bridge即可。
>
这里你可以再回顾下第34篇文章[《Kubernetes网络模型与CNI网络插件》](https://time.geekbang.org/column/article/67351)中的相关内容。
其中在hairpin-veth模式下你应该能看到CNI 网桥对应的各个VETH设备都将Hairpin模式设置为了1如下所示
```
$ for d in /sys/devices/virtual/net/cni0/brif/veth*/hairpin_mode; do echo &quot;$d = $(cat $d)&quot;; done
/sys/devices/virtual/net/cni0/brif/veth4bfbfe74/hairpin_mode = 1
/sys/devices/virtual/net/cni0/brif/vethfc2a18c5/hairpin_mode = 1
```
而如果是promiscuous-bridge模式的话你应该看到CNI网桥的混杂模式PROMISC被开启如下所示
```
$ ifconfig cni0 |grep PROMISC
UP BROADCAST RUNNING PROMISC MULTICAST MTU:1460 Metric:1
```
## 总结
在本篇文章中我为你详细讲解了从外部访问Service的三种方式NodePort、LoadBalancer 和 External Name和具体的工作原理。然后我还为你讲述了当Service出现故障的时候如何根据它的工作原理按照一定的思路去定位问题的可行之道。
通过上述讲解不难看出所谓Service其实就是Kubernetes为Pod分配的、固定的、基于iptables或者IPVS的访问入口。而这些访问入口代理的Pod信息则来自于Etcd由kube-proxy通过控制循环来维护。
并且你可以看到Kubernetes里面的Service和DNS机制也都不具备强多租户能力。比如在多租户情况下每个租户应该拥有一套独立的Service规则Service只应该看到和代理同一个租户下的Pod。再比如DNS在多租户情况下每个租户应该拥有自己的kube-dnskube-dns只应该为同一个租户下的Service和Pod创建DNS Entry
当然在Kubernetes中kube-proxy和kube-dns其实也是普通的插件而已。你完全可以根据自己的需求实现符合自己预期的Service。
## 思考题
为什么Kubernetes要求externalIPs必须是至少能够路由到一个Kubernetes的节点
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。<br>

View File

@@ -0,0 +1,334 @@
<audio id="audio" title="39 | 谈谈Service与Ingress" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7b/97/7b6585028a2841253d4a7dab2ce1ee97.mp3"></audio>
你好我是张磊。今天我和你分享的主题是谈谈Service与Ingress。
在上一篇文章中我为你详细讲解了将Service暴露给外界的三种方法。其中有一个叫作LoadBalancer类型的Service它会为你在Cloud Provider比如Google Cloud或者OpenStack里创建一个与该Service对应的负载均衡服务。
但是,相信你也应该能感受到,由于每个 Service 都要有一个负载均衡服务所以这个做法实际上既浪费成本又高。作为用户我其实更希望看到Kubernetes为我内置一个全局的负载均衡器。然后通过我访问的URL把请求转发给不同的后端Service。
**这种全局的、为了代理不同后端Service而设置的负载均衡服务就是Kubernetes里的Ingress服务。**
所以Ingress的功能其实很容易理解**所谓Ingress就是Service的“Service”。**
举个例子,假如我现在有这样一个站点:`https://cafe.example.com`。其中,`https://cafe.example.com/coffee`,对应的是“咖啡点餐系统”。而,`https://cafe.example.com/tea`对应的则是“茶水点餐系统”。这两个系统分别由名叫coffee和tea这样两个Deployment来提供服务。
那么现在我如何能使用Kubernetes的Ingress来创建一个统一的负载均衡器从而实现当用户访问不同的域名时能够访问到不同的Deployment呢
上述功能在Kubernetes里就需要通过Ingress对象来描述如下所示
```
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: cafe-ingress
spec:
tls:
- hosts:
- cafe.example.com
secretName: cafe-secret
rules:
- host: cafe.example.com
http:
paths:
- path: /tea
backend:
serviceName: tea-svc
servicePort: 80
- path: /coffee
backend:
serviceName: coffee-svc
servicePort: 80
```
在上面这个名叫cafe-ingress.yaml文件中最值得我们关注的是rules字段。在Kubernetes里这个字段叫作**IngressRule**。
IngressRule的Key就叫做host。它必须是一个标准的域名格式Fully Qualified Domain Name的字符串而不能是IP地址。
>
备注Fully Qualified Domain Name的具体格式可以参考[RFC 3986](https://tools.ietf.org/html/rfc3986)标准。
而host字段定义的值就是这个Ingress的入口。这也就意味着当用户访问cafe.example.com的时候实际上访问到的是这个Ingress对象。这样Kubernetes就能使用IngressRule来对你的请求进行下一步转发。
而接下来IngressRule规则的定义则依赖于path字段。你可以简单地理解为这里的每一个path都对应一个后端Service。所以在我们的例子里我定义了两个path它们分别对应coffee和tea这两个Deployment的Servicecoffee-svc和tea-svc
**通过上面的讲解不难看到所谓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。
接下来我就以最常用的Nginx Ingress Controller为例在我们前面用kubeadm部署的Bare-metal环境中和你实践一下Ingress机制的使用过程。
部署Nginx Ingress Controller的方法非常简单如下所示
```
$ kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/master/deploy/mandatory.yaml
```
其中,在[mandatory.yaml](https://raw.githubusercontent.com/kubernetes/ingress-nginx/master/deploy/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 -&gt; 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
```
可以看到在上述YAML文件中我们定义了一个使用nginx-ingress-controller镜像的Pod。需要注意的是这个Pod的启动命令需要使用该Pod所在的Namespace作为参数。而这个信息当然是通过Downward API拿到的Pod的env字段里的定义env.valueFrom.fieldRef.fieldPath
而这个Pod本身就是一个监听Ingress对象以及它所代理的后端Service变化的控制器。
当一个新的Ingress对象由用户创建后nginx-ingress-controller就会根据Ingress对象里定义的内容生成一份对应的Nginx配置文件/etc/nginx/nginx.conf并使用这个配置文件启动一个 Nginx 服务。
而一旦Ingress对象被更新nginx-ingress-controller就会更新这个配置文件。需要注意的是如果这里只是被代理的 Service 对象被更新nginx-ingress-controller所管理的 Nginx 服务是不需要重新加载reload的。这当然是因为nginx-ingress-controller通过[Nginx Lua](https://github.com/openresty/lua-nginx-module)方案实现了Nginx Upstream的动态配置。
此外nginx-ingress-controller还允许你通过Kubernetes的ConfigMap对象来对上述 Nginx 配置文件进行定制。这个ConfigMap的名字需要以参数的方式传递给nginx-ingress-controller。而你在这个 ConfigMap 里添加的字段,将会被合并到最后生成的 Nginx 配置文件当中。
**可以看到一个Nginx Ingress Controller为你提供的服务其实是一个可以根据Ingress对象和被代理后端 Service 的变化来自动进行更新的Nginx负载均衡器。**
当然为了让用户能够用到这个Nginx我们就需要创建一个Service来把Nginx Ingress Controller管理的 Nginx 服务暴露出去,如下所示:
```
$ kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/master/deploy/provider/baremetal/service-nodeport.yaml
```
由于我们使用的是Bare-metal环境所以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
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的唯一工作就是将所有携带ingress-nginx标签的Pod的80和433端口暴露出去。
>
而如果你是公有云上的环境你需要创建的就是LoadBalancer类型的Service了。
**上述操作完成后你一定要记录下这个Service的访问入口宿主机的地址和NodePort的端口**,如下所示:
```
$ kubectl get svc -n ingress-nginx
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
ingress-nginx NodePort 10.105.72.96 &lt;none&gt; 80:30044/TCP,443:31453/TCP 3h
```
为了后面方便使用,我会把上述访问入口设置为环境变量:
```
$ IC_IP=10.168.0.2 # 任意一台宿主机的地址
$ IC_HTTPS_PORT=31453 # NodePort端口
```
在Ingress Controller和它所需要的Service部署完成后我们就可以使用它了。
>
备注这个“咖啡厅”Ingress的所有示例文件都在[这里](https://github.com/resouer/kubernetes-ingress/tree/master/examples/complete-example)。
首先我们要在集群里部署我们的应用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 (&lt;none&gt;)
TLS:
cafe-secret terminates cafe.example.com
Rules:
Host Path Backends
---- ---- --------
cafe.example.com
/tea tea-svc:80 (&lt;none&gt;)
/coffee coffee-svc:80 (&lt;none&gt;)
Annotations:
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal CREATE 4m nginx-ingress-controller Ingress default/cafe-ingress
```
可以看到这个Ingress对象最核心的部分正是Rules字段。其中我们定义的Host是`cafe.example.com`它有两条转发规则Path分别转发给tea-svc和coffee-svc。
>
当然在Ingress的YAML文件里你还可以定义多个Host比如`restaurant.example.com``movie.example.com`等等,来为更多的域名提供负载均衡服务。
接下来我们就可以通过访问这个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
Date: 03/Nov/2018:03:55:32 +0000
URI: /coffee
Request ID: e487e672673195c573147134167cf898
```
我们可以看到访问这个URL 得到的返回信息是Server name: coffee-7dbb5795f6-vglbv。这正是 coffee 这个 Deployment 的名字。
而当我访问`https://cafe.example.com:433/tea`的时候则应该是tea这个Deployment负责响应我的请求Server name: tea-7d57856c44-lwbnp如下所示
```
$ 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。
以上就是Kubernetes里Ingress的设计思想和使用方法了。
不过,你可能会有一个疑问,**如果我的请求没有匹配到任何一条IngressRule那么会发生什么呢**
首先既然Nginx Ingress Controller是用Nginx实现的那么它当然会为你返回一个 Nginx 的404页面。
不过Ingress Controller也允许你通过Pod启动命令里的default-backend-service参数设置一条默认规则比如default-backend-service=nginx-default-backend。
这样任何匹配失败的请求就都会被转发到这个名叫nginx-default-backend的Service。所以你就可以通过部署一个专门的Pod来为用户返回自定义的404页面了。
## 总结
在这篇文章里我为你详细讲解了Ingress这个概念在Kubernetes里到底是怎么一回事儿。正如我在文章里所描述的Ingress实际上就是Kubernetes对“反向代理”的抽象。
目前Ingress只能工作在七层而Service只能工作在四层。所以当你想要在Kubernetes里为应用进行TLS配置等HTTP相关的操作时都必须通过Ingress来进行。
当然正如同很多负载均衡项目可以同时提供七层和四层代理一样将来Ingress的进化中也会加入四层代理的能力。这样一个比较完善的“反向代理”机制就比较成熟了。
而Kubernetes提出Ingress概念的原因其实也非常容易理解有了Ingress这个抽象用户就可以根据自己的需求来自由选择Ingress Controller。比如如果你的应用对代理服务的中断非常敏感那么你就应该考虑选择类似于Traefik这样支持“热加载”的Ingress Controller实现。
更重要的是一旦你对社区里现有的Ingress方案感到不满意或者你已经有了自己的负载均衡方案时你只需要做很少的编程工作就可以实现一个自己的Ingress Controller。
在实际的生产环境中Ingress带来的灵活度和自由度对于使用容器的用户来说其实是非常有意义的。要知道当年在Cloud Foundry项目里不知道有多少人为了给Gorouter组件配置一个TLS而伤透了脑筋。
## 思考题
如果我的需求是,当访问`www.mysite.com``forums.mysite.com`分别访问到不同的Service比如site-svc和forums-svc。那么这个Ingress该如何定义呢请你描述出YAML文件中的rules字段。
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。