mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-16 14:13:46 +08:00
mod
This commit is contained in:
218
极客时间专栏/深入剖析Kubernetes/Kubernetes容器网络/32 | 浅谈容器网络.md
Normal file
218
极客时间专栏/深入剖析Kubernetes/Kubernetes容器网络/32 | 浅谈容器网络.md
Normal 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<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
|
||||
inet 172.17.0.2 netmask 255.255.0.0 broadcast 0.0.0.0
|
||||
inet6 fe80::42:acff:fe11:2 prefixlen 64 scopeid 0x20<link>
|
||||
ether 02:42:ac:11:00:02 txqueuelen 0 (Ethernet)
|
||||
RX packets 364 bytes 8137175 (7.7 MiB)
|
||||
RX errors 0 dropped 0 overruns 0 frame 0
|
||||
TX packets 281 bytes 21161 (20.6 KiB)
|
||||
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0
|
||||
|
||||
lo: flags=73<UP,LOOPBACK,RUNNING> mtu 65536
|
||||
inet 127.0.0.1 netmask 255.0.0.0
|
||||
inet6 ::1 prefixlen 128 scopeid 0x10<host>
|
||||
loop txqueuelen 1000 (Local Loopback)
|
||||
RX packets 0 bytes 0 (0.0 B)
|
||||
RX errors 0 dropped 0 overruns 0 frame 0
|
||||
TX packets 0 bytes 0 (0.0 B)
|
||||
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0
|
||||
|
||||
$ route
|
||||
Kernel IP routing table
|
||||
Destination Gateway Genmask Flags Metric Ref Use Iface
|
||||
default 172.17.0.1 0.0.0.0 UG 0 0 0 eth0
|
||||
172.17.0.0 0.0.0.0 255.255.0.0 U 0 0 0 eth0
|
||||
|
||||
```
|
||||
|
||||
可以看到,这个容器里有一张叫作eth0的网卡,它正是一个Veth Pair设备在容器里的这一端。
|
||||
|
||||
通过route命令查看nginx-1容器的路由表,我们可以看到,这个eth0网卡是这个容器里的默认路由设备;所有对172.17.0.0/16网段的请求,也会被交给eth0来处理(第二条172.17.0.0路由规则)。
|
||||
|
||||
而这个Veth Pair设备的另一端,则在宿主机上。你可以通过查看宿主机的网络设备看到它,如下所示:
|
||||
|
||||
```
|
||||
# 在宿主机上
|
||||
$ ifconfig
|
||||
...
|
||||
docker0 Link encap:Ethernet HWaddr 02:42:d8:e4:df:c1
|
||||
inet addr:172.17.0.1 Bcast:0.0.0.0 Mask:255.255.0.0
|
||||
inet6 addr: fe80::42:d8ff:fee4:dfc1/64 Scope:Link
|
||||
UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1
|
||||
RX packets:309 errors:0 dropped:0 overruns:0 frame:0
|
||||
TX packets:372 errors:0 dropped:0 overruns:0 carrier:0
|
||||
collisions:0 txqueuelen:0
|
||||
RX bytes:18944 (18.9 KB) TX bytes:8137789 (8.1 MB)
|
||||
veth9c02e56 Link encap:Ethernet HWaddr 52:81:0b:24:3d:da
|
||||
inet6 addr: fe80::5081:bff:fe24:3dda/64 Scope:Link
|
||||
UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1
|
||||
RX packets:288 errors:0 dropped:0 overruns:0 frame:0
|
||||
TX packets:371 errors:0 dropped:0 overruns:0 carrier:0
|
||||
collisions:0 txqueuelen:0
|
||||
RX bytes:21608 (21.6 KB) TX bytes:8137719 (8.1 MB)
|
||||
|
||||
$ brctl show
|
||||
bridge name bridge id STP enabled interfaces
|
||||
docker0 8000.0242d8e4dfc1 no veth9c02e56
|
||||
|
||||
```
|
||||
|
||||
通过ifconfig命令的输出,你可以看到,nginx-1容器对应的Veth Pair设备,在宿主机上是一张虚拟网卡。它的名字叫作veth9c02e56。并且,通过brctl show的输出,你可以看到这张网卡被“插”在了docker0上。
|
||||
|
||||
这时候,如果我们再在这台宿主机上启动另一个Docker容器,比如nginx-2:
|
||||
|
||||
```
|
||||
$ docker run –d --name nginx-2 nginx
|
||||
$ brctl show
|
||||
bridge name bridge id STP enabled interfaces
|
||||
docker0 8000.0242d8e4dfc1 no veth9c02e56
|
||||
vethb4963f3
|
||||
|
||||
```
|
||||
|
||||
你就会发现一个新的、名叫vethb4963f3的虚拟网卡,也被“插”在了docker0网桥上。
|
||||
|
||||
这时候,如果你在nginx-1容器里ping一下nginx-2容器的IP地址(172.17.0.3),就会发现同一宿主机上的两个容器默认就是相互连通的。
|
||||
|
||||
这其中的原理其实非常简单,我来解释一下。
|
||||
|
||||
当你在nginx-1容器里访问nginx-2容器的IP地址(比如ping 172.17.0.3)的时候,这个目的IP地址会匹配到nginx-1容器里的第二条路由规则。可以看到,这条路由规则的网关(Gateway)是0.0.0.0,这就意味着这是一条直连规则,即:凡是匹配到这条规则的IP包,应该经过本机的eth0网卡,通过二层网络直接发往目的主机。
|
||||
|
||||
而要通过二层网络到达nginx-2容器,就需要有172.17.0.3这个IP地址对应的MAC地址。所以nginx-1容器的网络协议栈,就需要通过eth0网卡发送一个ARP广播,来通过IP地址查找对应的MAC地址。
|
||||
|
||||
>
|
||||
备注:ARP(Address Resolution Protocol),是通过三层的IP地址找到对应的二层MAC地址的协议。
|
||||
|
||||
|
||||
我们前面提到过,这个eth0网卡,是一个Veth Pair,它的一端在这个nginx-1容器的Network Namespace里,而另一端则位于宿主机上(Host Namespace),并且被“插”在了宿主机的docker0网桥上。
|
||||
|
||||
一旦一张虚拟网卡被“插”在网桥上,它就会变成该网桥的“从设备”。从设备会被“剥夺”调用网络协议栈处理数据包的资格,从而“降级”成为网桥上的一个端口。而这个端口唯一的作用,就是接收流入的数据包,然后把这些数据包的“生杀大权”(比如转发或者丢弃),全部交给对应的网桥。
|
||||
|
||||
所以,在收到这些ARP请求之后,docker0网桥就会扮演二层交换机的角色,把ARP广播转发到其他被“插”在docker0上的虚拟网卡上。这样,同样连接在docker0上的nginx-2容器的网络协议栈就会收到这个ARP请求,从而将172.17.0.3所对应的MAC地址回复给nginx-1容器。
|
||||
|
||||
有了这个目的MAC地址,nginx-1容器的eth0网卡就可以将数据包发出去。
|
||||
|
||||
而根据Veth Pair设备的原理,这个数据包会立刻出现在宿主机上的veth9c02e56虚拟网卡上。不过,此时这个veth9c02e56网卡的网络协议栈的资格已经被“剥夺”,所以这个数据包就直接流入到了docker0网桥里。
|
||||
|
||||
docker0处理转发的过程,则继续扮演二层交换机的角色。此时,docker0网桥根据数据包的目的MAC地址(也就是nginx-2容器的MAC地址),在它的CAM表(即交换机通过MAC地址学习维护的端口和MAC地址的对应表)里查到对应的端口(Port)为:vethb4963f3,然后把数据包发往这个端口。
|
||||
|
||||
而这个端口,正是nginx-2容器“插”在docker0网桥上的另一块虚拟网卡,当然,它也是一个Veth Pair设备。这样,数据包就进入到了nginx-2容器的Network Namespace里。
|
||||
|
||||
所以,nginx-2容器看到的情况是,它自己的eth0网卡上出现了流入的数据包。这样,nginx-2的网络协议栈就会对请求进行处理,最后将响应(Pong)返回到nginx-1。
|
||||
|
||||
以上,就是同一个宿主机上的不同容器通过docker0网桥进行通信的流程了。我把这个流程总结成了一幅示意图,如下所示:
|
||||
|
||||
<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模式,你觉得需要做哪些额外的准备工作呢?
|
||||
|
||||
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
291
极客时间专栏/深入剖析Kubernetes/Kubernetes容器网络/33 | 深入解析容器跨主机网络.md
Normal file
291
极客时间专栏/深入剖析Kubernetes/Kubernetes容器网络/33 | 深入解析容器跨主机网络.md
Normal 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/24,container-1的IP地址是100.96.1.2。Node 2的子网是100.96.2.0/24,container-2的IP地址是100.96.2.3。
|
||||
|
||||
而这些子网与宿主机的对应关系,正是保存在Etcd当中,如下所示:
|
||||
|
||||
```
|
||||
$ etcdctl ls /coreos.com/network/subnets
|
||||
/coreos.com/network/subnets/100.96.1.0-24
|
||||
/coreos.com/network/subnets/100.96.2.0-24
|
||||
/coreos.com/network/subnets/100.96.3.0-24
|
||||
|
||||
```
|
||||
|
||||
所以,flanneld进程在处理由flannel0传入的IP包时,就可以根据目的IP的地址(比如100.96.2.3),匹配到对应的子网(比如100.96.2.0/24),从Etcd中找到这个子网对应的宿主机的IP地址是10.168.0.3,如下所示:
|
||||
|
||||
```
|
||||
$ etcdctl get /coreos.com/network/subnets/100.96.2.0-24
|
||||
{"PublicIP":"10.168.0.3"}
|
||||
|
||||
```
|
||||
|
||||
而对于flanneld来说,只要Node 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包根据路由表进入TUN(flannel0)设备,从而回到用户态的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会在宿主机上设置一个特殊的网络设备作为“隧道”的两端。这个设备就叫作VTEP,即:VXLAN 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地址,这正是ARP(Address Resolution Protocol )表的功能。
|
||||
|
||||
而这里要用到的ARP记录,也是flanneld进程在Node 2节点启动时,自动添加在Node 1上的。我们可以通过ip命令看到它,如下所示:
|
||||
|
||||
```
|
||||
# 在Node 1上
|
||||
$ ip neigh show dev flannel.1
|
||||
10.1.16.0 lladdr 5e:f8:4f:00:e3:37 PERMANENT
|
||||
|
||||
```
|
||||
|
||||
这条记录的意思非常明确,即:IP地址10.1.16.0,对应的MAC地址是5e:f8:4f:00:e3:37。
|
||||
|
||||
>
|
||||
可以看到,最新版本的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内核里面,“网桥”设备进行转发的依据,来自于一个叫作FDB(Forwarding Database)的转发数据库。
|
||||
|
||||
不难想到,这个flannel.1“网桥”对应的FDB信息,也是flanneld进程负责维护的。它的内容可以通过bridge fdb命令查看到,如下所示:
|
||||
|
||||
```
|
||||
# 在Node 1上,使用“目的VTEP设备”的MAC地址进行查询
|
||||
$ bridge fdb show flannel.1 | grep 5e:f8:4f:00:e3:37
|
||||
5e:f8:4f:00:e3:37 dev flannel.1 dst 10.168.0.3 self permanent
|
||||
|
||||
```
|
||||
|
||||
可以看到,在上面这条FDB记录里,指定了这样一条规则,即:
|
||||
|
||||
发往我们前面提到的“目的VTEP设备”(MAC地址是5e:f8:4f:00:e3:37)的二层数据帧,应该通过flannel.1设备,发往IP地址为10.168.0.3的主机。显然,这台主机正是Node 2,UDP包要发往的目的地就找到了。
|
||||
|
||||
所以**接下来的流程,就是一个正常的、宿主机网络上的封包工作。**
|
||||
|
||||
我们知道,UDP包是一个四层数据包,所以Linux内核会在它前面加上一个IP头,即原理图中的Outer IP Header,组成一个IP包。并且,在这个IP头里,会填上前面通过FDB查询出来的目的主机的IP地址,即Node 2的IP地址10.168.0.3。
|
||||
|
||||
然后,Linux内核再在这个IP包前面加上二层数据帧头,即原理图中的Outer Ethernet Header,并把Node 2的MAC地址填进去。这个MAC地址本身,是Node 1的ARP表要学习的内容,无需Flannel维护。这时候,我们封装出来的“外部数据帧”的格式,如下所示:
|
||||
|
||||
<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地址)的连通性吗?为什么呢?
|
||||
|
||||
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。
|
||||
@@ -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、loopback(lo设备)、macvlan、ptp(Veth Pair设备),以及vlan。
|
||||
|
||||
我在前面提到过的Flannel、Weave等项目,都属于“网桥”类型的CNI插件。所以在具体的实现中,它们往往会调用bridge这个二进制文件。这个流程,我马上就会详细介绍到。
|
||||
|
||||
**第二类,叫作IPAM(IP Address Management)插件,它是负责分配IP地址的二进制文件**。比如,dhcp,这个文件会向DHCP服务器发起请求;host-local,则会使用预先配置的IP地址段来进行分配。
|
||||
|
||||
**第三类,是由CNI社区维护的内置CNI插件**。比如:flannel,就是专门为Flannel项目提供的CNI插件;tuning,是一个通过sysctl调整网络设备参数的二进制文件;portmap,是一个通过iptables配置端口映射的二进制文件;bandwidth,是一个使用Token Bucket Filter (TBF) 来进行限流的二进制文件。
|
||||
|
||||
从这些二进制文件中,我们可以看到,如果要实现一个给Kubernetes用的容器网络方案,其实需要做两部分工作,以Flannel项目为例:
|
||||
|
||||
**首先,实现这个网络方案本身**。这一部分需要编写的,其实就是flanneld进程里的主要逻辑。比如,创建和配置flannel.1设备、配置宿主机路由、配置ARP和FDB表里的信息等等。
|
||||
|
||||
**然后,实现该网络方案对应的CNI插件**。这一部分主要需要做的,就是配置Infra容器里面的网络栈,并把它连接在CNI网桥上。
|
||||
|
||||
由于Flannel项目对应的CNI插件已经被内置了,所以它无需再单独安装。而对于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(Container 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操作需要的参数包括:容器里网卡的名字eth0(CNI_IFNAME)、Pod的Network Namespace文件的路径(CNI_NETNS)、容器的ID(CNI_CONTAINERID)等。这些参数都属于上述环境变量里的内容。其中,Pod(Infra容器)的Network Namespace文件的路径,我在前面讲解容器基础的时候提到过,即:/proc/<容器进程的PID>/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:
|
||||
|
||||
```
|
||||
...
|
||||
"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 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)(目的地址转换)转发规则。这条规则的作用是,当宿主机上的进程访问“<宿主机的IP地址>:8080”时,iptables会把该请求直接转发到“<容器的IP地址>: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 做一个如此简单的假设呢?
|
||||
|
||||
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。
|
||||
286
极客时间专栏/深入剖析Kubernetes/Kubernetes容器网络/35 | 解读Kubernetes三层网络方案.md
Normal file
286
极客时间专栏/深入剖析Kubernetes/Kubernetes容器网络/35 | 解读Kubernetes三层网络方案.md
Normal 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.3(即:via 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也会在每台宿主机上,添加一个格式如下所示的路由规则:
|
||||
|
||||
```
|
||||
<目的容器IP地址段> via <网关的IP地址> 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,简称为AS):AS 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-0(tun0),这两种设备的功能是完全不一样的。
|
||||
|
||||
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 2(192.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项目才能够覆盖更多的场景,并为你提供更加可靠的组网方案和架构思路。
|
||||
|
||||
## 思考题
|
||||
|
||||
你能否能总结一下三层网络方案和“隧道模式”的异同,以及各自的优缺点?
|
||||
|
||||
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。
|
||||
@@ -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有什么实际的作用?
|
||||
|
||||
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。
|
||||
@@ -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 <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提供的是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 "default/hostnames: cluster IP" -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 "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/3;而如果第一条规则没有被选中,那么这时候就只剩下两条规则了,所以第二条规则的probability就必须设置为1/2;类似地,最后一条就必须设置为1。
|
||||
|
||||
你可以想一下,如果把这三条规则的probability字段的值都设置成1/3,最终每条规则被选中的概率会变成多少。
|
||||
|
||||
通过查看上述三条链的明细,我们就很容易理解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所指定的新的目的地址和端口。可以看到,这个目的地址和端口,正是被代理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
|
||||
...
|
||||
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规则,而是把对这些“规则”的处理放到了内核态,从而极大地降低了维护这些规则的代价。这也正印证了我在前面提到过的,“将重要操作放入内核态”是提高性能的重要手段。
|
||||
|
||||
>
|
||||
备注:这里你可以再回顾下第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记录就会变成:<pod的hostname>.<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
|
||||
- "3600"
|
||||
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模式下,都有哪几种?具体工作模式是怎样的?
|
||||
|
||||
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。
|
||||
@@ -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,你只需要访问:
|
||||
|
||||
```
|
||||
<任何一台宿主机的IP地址>:8080
|
||||
|
||||
```
|
||||
|
||||
就可以访问到某一个被代理的Pod的80端口了。
|
||||
|
||||
而在理解了我在上一篇文章中讲解的Service的工作原理之后,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规则。所以接下来的流程,就跟ClusterIP模式完全一样了。
|
||||
|
||||
需要注意的是,在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操作之前被打上去的。
|
||||
|
||||
可是,**为什么一定要对流出的包做SNAT<strong><strong>操作**</strong>呢?</strong>
|
||||
|
||||
这里的原理其实很简单,如下所示:
|
||||
|
||||
```
|
||||
client
|
||||
\ ^
|
||||
\ \
|
||||
v \
|
||||
node 1 <--- node 2
|
||||
| ^ SNAT
|
||||
| | --->
|
||||
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 "/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对应的规则,我在上一篇以及这一篇文章里已经全部介绍到了,它们包括:**
|
||||
|
||||
<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 "$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-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-dns(kube-dns只应该为同一个租户下的Service和Pod创建DNS Entry)。
|
||||
|
||||
当然,在Kubernetes中,kube-proxy和kube-dns其实也是普通的插件而已。你完全可以根据自己的需求,实现符合自己预期的Service。
|
||||
|
||||
## 思考题
|
||||
|
||||
为什么Kubernetes要求externalIPs必须是至少能够路由到一个Kubernetes的节点?
|
||||
|
||||
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。<br>
|
||||
|
||||
334
极客时间专栏/深入剖析Kubernetes/Kubernetes容器网络/39 | 谈谈Service与Ingress.md
Normal file
334
极客时间专栏/深入剖析Kubernetes/Kubernetes容器网络/39 | 谈谈Service与Ingress.md
Normal 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的Service(即:coffee-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 -> 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 <none> 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 (<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
|
||||
|
||||
```
|
||||
|
||||
可以看到,这个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字段。
|
||||
|
||||
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user