Docker的网络实现
标准的Docker支持以下4类网络模式。
◎ host模式:使用--net=host指定。
◎ container模式:使用--net=container:NAME_or_ID指定。
◎ none模式:使用--net=none指定。
◎ bridge模式:使用--net=bridge指定,为默认设置。
在Kubernetes管理模式下通常只会使用bridge模式,所以本节只介绍在bridge模式下Docker是如何支持网络的。
在bridge模式下,Docker Daemon第1次启动时会创建一个虚拟的网桥,默认的名称是docker0,然后按照RPC1918的模型在私有网络空间中给这个网桥分配一个子网。针对由Docker创建的每一个容器,都会创建一个虚拟的以太网设备(Veth设备对),其中一端关联到网桥上,另一端使用Linux的网络命名空间技术,映射到容器内的eth0设备,然后从网桥的地址段内给eth0接口分配一个IP地址。
如下图所示就是Docker的默认桥接网络模型:
其中ip1是网桥的IP地址,Docker Daemon会再几个备选地址段里给它选一个地址,通常是以172开头的一个地址。这个地址和主机的IP地址是不重叠的。ip2是Docker在启动容器时,在这个地址段选择一个没有使用的IP地址分配给容器。相应的MAC地址也根据这个IP地址,在02:42:ac:11:00:00和02:42:ac:11:ff:ff的范围内生成,这样做可以确保不会有ARP冲突。
启动后,Docker还将Veth对的名称映射到eth0网络接口。ip3就是主机的网卡地址。
在一般情况下,ip1、ip2和ip3是不同的IP段,所以在默认不做任何特殊配置的情况下,在外部是看不到ip1和ip2的。
这样做的结果就是,在同一台机器内的容器之间可以相互通信,不同主机上的容器不能相互通信,实际上它们甚至有可能在相同的网络地址范围内(不同主机上的docker0的地址段可能是一样的)。
为了让它们跨节点互相通信,就必须在主机的地址上分配端口,然后通过这个端口路由或代理到容器上。这种做法显然意味着一定要在容器之间小心谨慎地协调好端口的分配,或者使用动态端口的分配技术。在不同应用之间协调好端口分配是十分困难的事情,特别是集群水平扩展时。而动态的端口分配也会带来高度复杂性,例如:每个应用程序都只能将端口看作一个符号(因为是动态分配的,所以无法提前设置)。而且API Server要在分配完后,将动态端口插入配置的合适位置,服务也必须能互相找到对方等。这些都是Docker的网络模型在跨主机访问时面临的问题。
1.查看Docker启动后的系统情况
我们已经知道,Docker网络在bridge模式下Docker Daemon启动时创建docker0网桥,并在网桥使用的网段为容器分配IP。让我们看看实际的操作。
在刚刚启动Docker Daemon并且还没有启动任何容器时,网络协议栈的配置情况如下:
可以看到,Docker创建了docker0网桥,并添加了iptables规则。docker0网桥和iptables规则都处于root命名空间中。通过解读这些规则,我们发现,在还没有启动任何容器时,如果启动了Docker Daemon,那么它已经做好了通信准备。对这些规则的说明如下:
(1)在NAT表中有4条记录,前两条匹配生效后,都会继续执行DOCKER链,如果DOCKER链为空,前两条只是做了一个框架,并没有实际效果(ps:由于不是刚刚安装的docker)。
(2)NAT表第3条的含义是,若本地发出的数据包不是发往docker0的,即是发往主机之外的设备的,则都需要进行动态地址修改(MASQUERADE),将源地址从容器的地址(172段)修改为宿主机网卡的IP地址,之后就可以发送给外面的网络了。
(3)在FILTER表中,第3条也是一个框架,因为后继的DOCKER链是空的。
(4)在FILTER表中,第5条是说,docker0发出的包,如果需要Forward到非docker0的本地IP地址的设备,则是允许的。这样,docker0设备的包就可以根据路由规则中转到宿主机的网卡设备,从而访问外面的网络。
(5)FILTER表中,第6条是说,docker0的包还可以被中转给docker0本身,即连接在docker0网桥上的不同容器之间的通信也是允许的。
(6)FILTER表中,3条是说,如果接收到的数据包属于以前已经建立好的连接,那么允许直接通过。这样接收到的数据包自然又走回docker0,并中转到相应的容器。
除了这些Netfilter的设置,Linux的ip_forward功能也被Docker Daemon打开了:
另外,我们可以看到刚刚启动Docker后的Route表,和启动前没有什么不同:
2.查看容器启动后的情况(容器无端口映射)
此处自己操作,自己查看,不做过多解释。
刚才查看了Docker服务启动后的网络情况。现在启动一个Registry容器(不使用任何端口镜像参数),看一下网络堆栈部分相关的变化:
docker run --name register -d registry
ip addr
iptables-save
ip route
(1)宿主机器上的Netfilter和路由表都没有变化,说明在不进行端口映射时,Docker的默认网络是没有特殊处理的。相关的NAT和FILTER这两个Netfilter链还是空的。
(2)宿主机上的Veth对已经建立,并连接到容器内。
我们再次进入刚刚启动的容器内,看看网络栈是什么情况。容器内部的IP地址和路由如下:
docker exec -it <容器id>
进入容器后执行:
ip route
可以看到,默认停止的回环设备lo已经被启动,外面宿主机连接进来的Veth设备也被命名成了eth0,并且已经配置了地址172.17.0.10。
路由信息表包含一条到docker0的子网路由和一条到docker0的默认路由。
3.查看容器启动后的情况(容器有端口映射)
下面用带端口映射的命令启动registry:
docker run --name register -d -p 1180:5000 registry
在启动后查看iptables的变化:
iptables-save
从新增的规则可以看出,Docker服务在NAT和FILTER两个表内添加的两个DOCKER子链都是给端口映射用的。在本例中我们需要把外面宿主机的1180端口映射到容器的5000端口。通过前面的分析我们知道,无论是宿主机接收到的还是宿主机本地协议栈发出的,目标地址是本地IP地址的包都会经过NAT表中的DOCKER子链。Docker为每一个端口映射都在这个链上增加了到实际容器目标地址和目标端口的转换。
经过这个DNAT的规则修改后的IP包,会重新经过路由模块的判断进行转发。由于目标地址和端口已经是容器的地址和端口,所以数据自然就被转发到docker0上,从而被转发到对应的容器内部。
当然在Forward时,也需要在DOCKER子链中添加一条规则,如果目标端口和地址是指定容器的数据,则允许通过。
在Docker按照端口映射的方式启动容器时,主要的不同就是上述iptables部分。而容器内部的路由和网络设备,都和不做端口映射时一样,没有任何变化。
Docker的网络局限
我们从Docker对Linux网络协议栈的操作可以看到,Docker一开始没有考虑到多主机互联的网络解决方案。
Docker一直以来的理念都是“简单为美”,几乎所有尝试Docker的人都被它“用法简单,功能强大”的特性所吸引,这也是Docker迅速走红的一个原因。
我们都知道,虚拟化技术中最为复杂的部分就是虚拟化网络技术,即使是单纯的物理网络部分,也是一个门槛很高的技能领域,通常只被少数网络工程师所掌握,所以我们可以理解结合了物理网络的虚拟网络技术有多难。在Docker之前,所有接触过OpenStack的人都对其网络问题讳莫如深,Docker明智地避开这个“雷区”,让其他专业人员去用现有的虚拟化网络技术解决 Docker主机的互联问题,以免让用户觉得Docker太难,从而放弃学习和使用Docker。
Docker成名以后,重新开始重视网络解决方案,收购了一家Docker网络解决方案公司—Socketplane,原因在于这家公司的产品广受好评,但有趣的是Socketplane的方案就是以Open vSwitch为核心的,其还为Open vSwitch提供了Docker镜像,以方便部署程序。之后,Docker开启了一个宏伟的虚拟化网络解决方案—Libnetwork,概念图如下:
这个概念图没有了IP,也没有了路由,已经颠覆了我们的网络常识,对于不怎么懂网络的大多数人来说,它的确很有诱惑力,未来是否会对虚拟化网络的模型产生深远冲击,我们还不得而知,但它仅仅是Docker官方当前的一次“尝试”。
针对目前Docker的网络实现,Docker使用的Libnetwork组件只是将Docker平台中的网络子系统模块化为一个独立库的简单尝试,离成熟和完善还有一段距离。
Kubernetes的网络实现
在实际的业务场景中,业务组件之间的关系十分复杂,特别是随着微服务理念逐步深入人心,应用部署的粒度更加细小和灵活。为了支持业务应用组件的通信,Kubernetes网络的设计主要致力于解决以下问题。
(1)容器到容器之间的直接通信。
(2)抽象的Pod到Pod之间的通信。
(3)Pod到Service之间的通信。
(4)集群外部与内部组件之间的通信。
其中第3条、第4条在之前的章节里都有所讲解,本节对更为基础的第1条与第2条进行深入分析和讲解。
1.容器到容器的通信
同一个Pod内的容器(Pod内的容器是不会跨宿主机的)共享同一个网络命名空间,共享同一个Linux协议栈。所以对于网络的各类操作,就和它们在同一台机器上一样,它们甚至可以用localhost地址访问彼此的端口。
这么做的结果是简单、安全和高效,也能减小将已经存在的程序从物理机或者虚拟机移植到容器下运行的难度。其实,在容器技术出来之前,大家早就积累了如何在一台机器上运行一组应用程序的经验,例如,如何让端口不冲突,以及如何让客户端发现它们等。
我们来看一下Kubernetes是如何利用Docker的网络模型的。
如上图中的阴影部分所示,在Node上运行着一个Pod实例。在我们的例子中,容器就是图中的容器1和容器2。容器1和容器2共享一个网络的命名空间,共享一个命名空间的结果就是它们好像在一台机器上运行,它们打开的端口不会有冲突,可以直接使用Linux的本地IPC进行通信(例如消息队列或者管道)。其实,这和传统的一组普通程序运行的环境是完全一样的,传统程序不需要针对网络做特别的修改就可以移植了,它们之间的互相访问只需要使用localhost就可以。例如,如果容器2运行的是MySQL,那么容器1使用localhost:3306就能直接访问这个运行在容器2上的MySQL了。
2.Pod之间的通信
我们看了同一个Pod内的容器之间的通信情况,再看看Pod之间的通信情况。
每一个Pod都有一个真实的全局IP地址,同一个Node内的不同Pod之间可以直接采用对方Pod的IP地址通信,而且不需要采用其他发现机制,例如DNS、Consul或者etcd。
Pod容器既有可能在同一个Node上运行,也有可能在不同的Node上运行,所以通信也分为两类:同一个Node内Pod之间的通信和不同Node上的Pod之间的通信。
同一个Node内Pod之间的通信
我们看一下同一个Node内两个Pod之间的关系,如下图所示:
可以看出,Pod1和Pod2都是通过Veth连接到同一个docker0网桥上的,它们的IP地址IP1、IP2都是从docker0的网段上动态获取的,它们和网桥本身的IP3是同一个网段的。
另外,在Pod1、Pod2的Linux协议栈上,默认路由都是docker0的地址,也就是说所有非本地地址的网络数据,都会被默认发送到docker0网桥上,由docker0网桥直接中转。
综上所述,由于它们都关联在同一个docker0网桥上,地址段相同,所以它们之间是能直接通信的。
不同Node上Pod之间的通信
Pod的地址是与docker0在同一个网段的,我们知道docker0网段与宿主机网卡是两个完全不同的IP网段,并且不同Node之间的通信只能通过宿主机的物理网卡进行,因此要想实现不同Node上Pod容器之间的通信,就必须想办法通过主机的这个IP地址进行寻址和通信。
另一方面,这些动态分配且藏在docker0之后的所谓“私有”IP地址也是可以找到的。Kubernetes会记录所有正在运行的Pod的IP分配信息,并将这些信息保存在etcd中(作为Service的Endpoint)。这些私有IP信息对于Pod到Pod的通信也是十分重要的,因为我们的网络模型要求Pod到Pod使用私有IP进行通信。所以首先要知道这些IP是什么。
之前提到,Kubernetes的网络对Pod的地址是平面的和直达的,所以这些Pod的IP规划也很重要,不能有冲突。只要没有冲突,我们就可以想办法在整个Kubernetes的集群中找到它。
综上所述,要想支持不同Node上Pod之间的通信,就要满足两个条件:
(1)在整个Kubernetes集群中对Pod的IP分配进行规划,不能有冲突;
(2)找到一种办法,将Pod的IP和所在Node的IP关联起来,通过这个关联让Pod可以互相访问。
根据条件1的要求,我们需要在部署Kubernetes时对docker0的IP地址进行规划,保证每个Node上的docker0地址都没有冲突。我们可以在规划后手工配置到每个Node上,或者做一个分配规则,由安装的程序自己去分配占用。例如,Kubernetes的网络增强开源软件Flannel就能够管理资源池的分配。
根据条件2的要求,Pod中的数据在发出时,需要有一个机制能够知道对方Pod的IP地址挂在哪个具体的Node上。也就是说先要找到Node对应宿主机的IP地址,将数据发送到这个宿主机的网卡,然后在宿主机上将相应的数据转发到具体的docker0上。一旦数据到达宿主机Node,则那个Node内部的docker0便知道如何将数据发送到Pod。如下图所示:
在上图中:IP1对应的是Pod1,IP2对应的是Pod2。Pod1在访问Pod2时,首先要将数据从源Node的eth0发送出去,找到并到达Node2的eth0。即先是从IP3到IP4的递送,之后才是从IP4到IP2的递送。
在谷歌的GCE环境中,Pod的IP管理(类似docker0)、分配及它们之间的路由打通都是由GCE完成的。Kubernetes作为主要在GCE上面运行的框架,它的设计是假设底层已经具备这些条件,所以它分配完地址并将地址记录下来就完成了它的工作。在实际的GCE环境中,GCE的网络组件会读取这些信息,实现具体的网络打通。
而在实际生产环境中,因为安全、费用、合规等种种原因,Kubernetes的客户不可能全部使用谷歌的GCE环境,所以在实际的私有云环境中,除了需要部署Kubernetes和Docker,还需要额外的网络配置,甚至通过一些软件来实现Kubernetes对网络的要求。做到这些后,Pod和Pod之间才能无差别地进行透明通信。
为了达到这个目的,开源界有不少应用增强了Kubernetes、Docker的网络,在后面的章节中会介绍几个常用的组件及其组网原理。
小结:
本章内容到此结束,谢谢大家的浏览,多多关注。