53 _ Linux网络虚拟化(下):Docker所提供的容器通讯方案有哪些?

你好,我是周志明。今天我们接着上节课介绍的Linux网络知识,继续来学习它们在虚拟化网络方面的应用,从而为后续学习容器编排系统、理解各个容器是如何通过虚拟化网络来协同工作打好基础。

虚拟化网络设备

首先我们要知道,虚拟化网络并不需要完全遵照物理网络的样子来设计。不过,由于现在大量现成的代码,原来就是面向于物理存在的网络设备来编码实现的,另外也有出于方便理解和知识继承方面的考虑,因此虚拟化网络与物理网络中的设备还是具有相当高的相似性。

所以接下来,我就会从网络中那些与网卡、交换机、路由器等对应的虚拟设施,以及如何使用这些虚拟设施来组成网络入手,给你介绍容器间网络的通信基础设施。

好了,我们开始吧。

网卡:tun/tap、veth

首先是虚拟网卡设备。

目前主流的虚拟网卡方案有tun/tapveth两种,其中tun/tap出现得时间更早,它是一组通用的虚拟驱动程序包,里面包含了两个设备,分别是用于网络数据包处理的虚拟网卡驱动,以及用于内核空间与用户空间交互的字符设备(Character Devices,这里具体指/dev/net/tun)驱动。

大概在2000年左右,Solaris系统为了实现隧道协议(Tunneling Protocol)开发了这套驱动,从Linux Kernel 2.1版开始,tun/tap移植到了Linux内核中,当时它是作为源码中的可选模块,而在2.4版之后发布的内核,都会默认编译tun/tap的驱动。tun和tap是两个相对独立的虚拟网络设备,其中tap模拟了以太网设备,操作二层数据包(以太帧),tun则是模拟了网络层设备,操作三层数据包(IP报文)。

那么,使用tun/tap设备的目的,其实是为了把来自协议栈的数据包,先交给某个打开了/dev/net/tun字符设备的用户进程处理后,再把数据包重新发回到链路中。这里你可以通俗地理解为,这块虚拟化网卡驱动一端连接着网络协议栈,另一端连接着用户态程序,而普通的网卡驱动则是一端连接着网络协议栈,另一端连接着物理网卡。

如此一来,只要协议栈中的数据包能被用户态程序截获并加工处理,程序员就有足够的舞台空间去玩出各种花样,比如数据压缩、流量加密、透明代理等功能,都能够在此基础上实现。

这里我就以最典型的VPN应用程序为例,程序发送给tun设备的数据包,会经过如下所示的顺序流进VPN程序:

应用程序通过tun设备对外发送数据包后,tun设备如果发现另一端的字符设备已经被VPN程序打开(这就是一端连接着网络协议栈,另一端连接着用户态程序),就会把数据包通过字符设备发送给VPN程序,VPN收到数据包,会修改后再重新封装成新报文,比如数据包原本是发送给A地址的,VPN把整个包进行加密,然后作为报文体,封装到另一个发送给B地址的新数据包当中。

这种把一个数据包套进另一个数据包中的处理方式,就被形象地形容为“隧道”(Tunneling),隧道技术是在物理网络中构筑逻辑网络的经典做法。而其中提到的加密,实际上也有标准的协议可以遵循,比如IPSec协议。

不过,使用tun/tap设备来传输数据需要经过两次协议栈,所以会不可避免地产生一定的性能损耗,因而如果条件允许,容器对容器的直接通信并不会把tun/tap作为首选方案,而是一般基于veth来实现的

但tun/tap并没有像veth那样,有要求设备成对出现、数据要原样传输的限制,数据包到了用户态程序后,我们就有完全掌控的权力,要进行哪些修改、要发送到什么地方,都可以通过编写代码去实现,所以tun/tap方案比起veth方案有更广泛的适用范围

那么这里我提到的veth,就是另一种主流的虚拟网卡方案了,在Linux Kernel 2.6版本,Linux开始支持网络名空间隔离的同时,也提供了专门的虚拟以太网(Virtual Ethernet,习惯简写为veth),让两个隔离的网络名称空间之间可以互相通信。

其实,直接把veth比喻成虚拟网卡并不是很准确,如果要和物理设备类比,它应该相当于由交叉网线连接的一对物理网卡。

额外知识:直连线序、交叉线序- 交叉网线是指一头是T568A标准,另外一头是T568B标准的网线。直连网线则是两头采用同一种标准的网线。- 网卡对网卡这样的同类设备,需要使用交叉线序的网线来连接,网卡到交换机、路由器就采用直连线序的网线,不过现在的网卡大多带有线序翻转功能,直连线也可以网卡对网卡地连通了。

veth实际上也不是一个设备,而是一对设备,因而它也常被称作veth pair。我们要使用veth,就必须在两个独立的网络名称空间中进行才有意义,因为veth pair是一端连着协议栈,另一端彼此相连的,在veth设备的其中一端输入数据,这些数据就会从设备的另一端原样不动地流出,它在工作时的数据流动如下图所示:

由于两个容器之间采用veth通信,不需要反复多次经过网络协议栈,这就让veth比起tap/tun来说,具备了更好的性能,也让veth pair的实现变得十分简单,内核中只用几十行代码实现一个数据复制函数,就可以完成veth的主体功能。

不过veth其实也存在局限性。

虽然veth以模拟网卡直连的方式,很好地解决了两个容器之间的通信问题,然而对多个容器间通信,如果仍然单纯只用veth pair的话,事情就会变得非常麻烦,毕竟,让每个容器都为与它通信的其他容器建立一对专用的veth pair,根本就不实际,真正做起来成本会很高。

因此这时,就迫切需要有一台虚拟化的交换机,来解决多容器之间的通信问题了。

交换机:Linux Bridge

既然有了虚拟网卡,我们很自然就会联想到让网卡接入到交换机里,来实现多个容器间的相互连接。而Linux Bridge就是Linux系统下的虚拟化交换机,虽然它是以“网桥”(Bridge)而不是“交换机”(Switch)为名,但在使用过程中,你会发现Linux Bridge看起来像交换机,功能使用起来像交换机、程序实现起来也像交换机,所以它实际就是一台虚拟交换机。

Linux Bridge是在Linux Kernel 2.2版本开始提供的二层转发工具,由brctl命令创建和管理。Linux Bridge创建以后,就能够接入任何位于二层的网络设备,无论是真实的物理设备(比如eth0),还是虚拟的设备(比如veth或者tap),都能与Linux Bridge配合工作。当有二层数据包(以太帧)从网卡进入Linux Bridge,它就会根据数据包的类型和目标MAC地址,按照如下规则转发处理:

刚刚提到的这些名词,比如二层转发、泛洪、STP、MAC学习、地址转发表,等等,都是物理交换机中已经非常成熟的概念了,它们在Linux Bridge中都有对应的实现,所以我才说,Linux Bridge不仅用起来像交换机,实现起来也像交换机。

不过,它与普通的物理交换机也还是有一点差别的,普通交换机只会单纯地做二层转发,Linux Bridge却还支持把发给它自身的数据包,接入到主机的三层协议栈中

对于通过brctl命令显式接入网桥的设备,Linux Bridge与物理交换机的转发行为是完全一致的,它也不允许给接入的设备设置IP地址,因为网桥是根据MAC地址做二层转发的,就算设置了三层的IP地址也没有意义。

然而,Linux Bridge与普通交换机的区别是,除了显式接入的设备外,它自己也无可分割地连接着一台有着完整网络协议栈的Linux主机,因为Linux Bridge本身肯定是在某台Linux主机上创建的,我们可以看作是Linux Bridge有一个与自己名字相同的隐藏端口,隐式地连接了创建它的那台Linux主机。

因此,Linux Bridge允许给自己设置IP地址,这样就比普通交换机多出了一种特殊的转发情况:如果数据包的目的MAC地址为网桥本身,并且网桥设置了IP地址的话,那该数据包就会被认为是收到发往创建网桥那台主机的数据包,这个数据包将不会转发到任何设备,而是直接交给上层(三层)协议栈去处理。

这时,网桥就取代了物理网卡eth0设备来对接协议栈,进行三层协议的处理。

那么设置这条特殊转发规则的好处是什么呢?就是只要通过简单的NAT转换,就可以实现一个最原始的单IP容器网络。这种组网是最基本的容器间通信形式,下面我举个具体例子来帮助你理解。

假设现在有以下几个设备,它们的连接情况如图所示,具体配置是这样的:

这样一来,如果名称空间1中的应用程序想访问外网地址为122.246.6.183的服务器,由于容器没有自己的公网IP地址,程序发出的数据包必须经过处理之后,才能最终到达外网服务器。

我们来具体分析下这个处理步骤:

  1. 应用程序调用Socket API发送数据,此时生成的原始数据包为:- a. 源MAC:veth0的MAC- b. 目标MAC:网关的MAC(即网桥的MAC)- c. 源IP:veth0的IP,即192.168.31.1- d. 目标IP:外网的IP,即122.246.6.183

  2. 从veth0发送的数据,会在veth1中原样出来,网桥将会从veth1中接收到一个目标MAC为自己的数据包,并且网桥有配置IP地址,这样就触发了Linux Bridge的特殊转发规则。这个数据包也就不会转发给任何设备,而是转交给主机的协议栈处理。

注意,从这步以后就是三层路由了,已经不在网桥的工作范围之内,而是由Linux主机依靠Netfilter进行IP转发(IP Forward)去实现的。

  1. 数据包经过主机协议栈,Netfilter的钩子被激活,预置好的iptables NAT规则会修改数据包的源IP地址,把它改为物理网卡eth0的IP地址,并在映射表中记录设备端口和两个IP地址之间的对应关系,经过SNAT之后的数据包,最终会从eth0出去,此时报文头中的地址为:- a. 源MAC:eth0的MAC- b. 目标MAC:下一跳(Hop)的MAC- c. 源IP:eth0的IP,即14.123.254.86- d. 目标IP:外网的IP,即122.246.6.183

  2. 可见,经过主机协议栈后,数据包的源和目标IP地址均为公网的IP,这个数据包在外部网络中,可以根据IP正确路由到目标服务器手上。这样,当目标服务器处理完毕,对该请求发出响应后,返回数据包的目标地址也是公网IP。当返回的数据包经过链路上所有跳点,由eth0达到网桥时,报文头中的地址为:- a. 源MAC:eth0的MAC- b. 目标MAC:网桥的MAC- c. 源IP:外网的IP,即122.246.6.183- d. 目标IP:eth0的IP,即14.123.254.86

  3. 可见,这同样是一个以网桥MAC地址为目标的数据包,同样会触发特殊转发规则,然后交给协议栈处理。这时,Linux会根据映射表中的转换关系做DNAT转换,把目标IP地址从eth0替换回veth0的IP,最终veth0收到的响应数据包为:- a. 源MAC:网桥的MAC- b. 目标MAC:veth0的MAC- c. 源IP:外网的IP,即122.246.6.183- d. 目标IP:veth0的IP,即192.168.31.1

好了,这就是程序发出的数据包到达外网服务器之前的所有处理步骤。

在这个处理过程中,Linux主机独立承担了三层路由的职责,一定程度上扮演了路由器的角色。而且由于有Netfilter的存在,对网络层的路由转发,就不需要像Linux Bridge一样,专门提供brctl这样的命令去创建一个虚拟设备了。

通过Netfilter,很容易就能在Linux内核完成根据IP地址进行路由的功能。你也可以把Linux Bridge理解为是一个人工创建的虚拟交换机,而Linux内核是一个天然的虚拟路由器。

当然,除了我介绍的Linux Bridge这一种虚拟交换机的方案,还有OVS(Open vSwitch)等同样常见,而且更强大、更复杂的方案,这里我就不讨论了,感兴趣的话你可以去参考这个链接

网络:VXLAN

那么,有了虚拟化网络设备后,下一步就是要使用这些设备组成网络了。

我们知道,容器分布在不同的物理主机上,每一台物理主机都有物理网络相互联通,然而这种网络的物理拓扑结构是相对固定的,很难跟上云原生时代下,分布式系统的逻辑拓扑结构变动频率,比如服务的扩缩、断路、限流,等等,都可能要求网络跟随做出相应的变化。

也正因为如此,软件定义网络(Software Defined Network,SDN)的需求在云计算和分布式时代,就变得前所未有地迫切。SDN的核心思路是在物理的网络之上,再构造一层虚拟化的网络,把控制平面和数据平面分离开来,实现流量的灵活控制,为核心网络及应用的创新提供良好的平台。

SDN里,位于下层的物理网络被称为Underlay,它着重解决网络的连通性与可管理性;位于上层的逻辑网络被称为Overlay,它着重为应用提供与软件需求相符的传输服务和网络拓扑。

事实上,SDN已经发展了十几年的时间,比云原生、微服务这些概念出现得要早得多。网络设备商基于硬件设备开发出了EVI(Ethernet Virtualization Interconnect)、TRILL(Transparent Interconnection of Lots of Links)、SPB(Shortest Path Bridging)等大二层网络技术;软件厂商也提出了VXLAN(Virtual eXtensible LAN)、NVGRE(Network Virtualization Using Generic Routing Encapsulation)、STT(A Stateless Transport Tunneling Protocol for Network Virtualization)等一系列基于虚拟交换机实现的Overlay网络。

不过,由于跨主机的容器间通信用的大多是Overlay网络,所以接下来,我会以VXLAN为例,给你介绍Overlay网络的原理。

VXLAN你可能没怎么听说过,但VLAN相信只要从事计算机专业的人都会有所了解。VLAN的全称是“虚拟局域网”(Virtual Local Area Network),从名称来看,它也算是网络虚拟化技术的早期成果之一了。

由于二层网络本身的工作特性,决定了VLAN非常依赖于广播,无论是广播帧(如ARP请求、DHCP、RIP都会产生广播帧),还是泛洪路由,它的执行成本会随着接入二层网络设备数量的增长而等比例地增加,当设备太多,广播又频繁的时候,很容易就会形成广播风暴(Broadcast Radiation)。

因此,VLAN的首要职责就是划分广播域,把连接在同一个物理网络上的设备区分开来。

划分的具体方法是在以太帧的报文头中加入VLAN Tag,让所有广播只针对具有相同VLAN Tag的设备生效。这样既缩小了广播域,也附带提高了安全性和可管理性,因为两个VLAN之间不能直接通信。如果确实有通信的需要,就必须通过三层设备来进行,比如使用单臂路由(Router on a Stick)或者三层交换机。

可是,VLAN有两个明显的缺陷,第一个缺陷在于VLAN Tag的设计。定义VLAN的802.1Q规范是在1998年提出的,当时的网络工程师完全不可能预料到在未来云计算会如此地普及,因而就只给VLAN Tag预留了32 Bits的存储空间,其中还要分出16 Bits存储标签协议识别符(Tag Protocol Identifier)、3 Bits存储优先权代码点(Priority Code Point)、1 Bits存储标准格式指示(Canonical Format Indicator),剩下的12 Bits才能用来存储VLAN ID(Virtualization Network Identifier,VNI)。

所以换句话说,VLAN ID最多只能有 \(\\mathrm{2}^{12}\)=4096种取值。当云计算数据中心出现后,即使不考虑虚拟化的需求,单是需要分配IP的物理设备,都有可能数以万计、甚至数以十万计,这样的话,4096个VLAN肯定是不够用的。

后来,IEEE的工程师们又提出802.1AQ规范力图补救这个缺陷,大致思路是给以太帧连续打上两个VLAN Tag,每个Tag里仍然只有12 Bits的VLAN ID,但两个加起来就可以存储 \(\\mathrm{2}^{24}\)=16,777,216个不同的VLAN ID了,由于两个VLAN Tag并排放在报文头上,802.1AQ规范还有了个QinQ(802.1Q in 802.1Q)的昵称别名。

QinQ是2011年推出的规范,但是直到现在其实都没有特别普及,这是因为除了需要设备支持外,它还解决不了VLAN的第二个缺陷:跨数据中心传递

VLAN本身是为二层网络所设计的,但是在两个独立数据中心之间,信息只能跨三层传递。而由于云计算的灵活性,大型分布式系统完全有跨数据中心运作的可能性,所以此时如何让VLAN Tag在两个数据中心间传递,又成了不得不考虑的麻烦事。

由此,为了统一解决以上两个问题,IETF定义了VXLAN规范,这是三层虚拟化网络(Network Virtualization over Layer 3,NVO3)的标准技术规范之一,是一种典型的Overlay网络

VXLAN采用L2 over L4 (MAC in UDP)的报文封装模式,把原本在二层传输的以太帧,放到了四层UDP协议的报文体内,同时加入了自己定义的VXLAN Header。在VXLAN Header里直接就有24 Bits的VLAN ID,同样可以存储1677万个不同的取值。

如此一来,VXLAN就可以让二层网络在三层范围内进行扩展,不再受数据中心间传输的限制了。VXLAN的整个报文结构如下图所示:

(图片来源:Orchestrating EVPN VXLAN Services with Cisco NSO

VXLAN对网络基础设施的要求很低,不需要专门的硬件提供特别支持,只要三层可达的网络就能部署VXLAN。

VXLAN网络的每个边缘入口上,布置有一个VTEP(VXLAN Tunnel Endpoints)设备,它既可以是物理设备,也可以是虚拟化设备,主要负责VXLAN协议报文的封包和解包。互联网号码分配局(Internet Assigned Numbers Authority,IANA)也专门分配了4789作为VTEP设备的UDP端口(以前Linux VXLAN用的默认端口是8472,目前这两个端口在许多场景中仍有并存的情况)。

从Linux Kernel 3.7版本起,Linux系统就开始支持VXLAN。到了3.12版本,Linux对VXLAN的支持已经达到了完全完备的程度,能够处理单播和组播,能够运行于IPv4和IPv6之上,一台Linux主机经过简单配置之后,就可以把Linux Bridge作为VTEP设备来使用。

VXLAN带来了很高的灵活性、扩展性和可管理性,同一套物理网络中可以任意创建多个VXLAN网络,每个VXLAN中接入的设备,都像是在一个完全独立的二层局域网中一样,不会受到外部广播的干扰,也很难遭受外部的攻击,这就让VXLAN能够良好地匹配分布式系统的弹性需求。

不过,VXLAN也带来了额外的复杂度和性能开销,具体表现为以下两点:

副本网卡:MACVLAN

现在,理解了VLAN和VXLAN的原理后,我们就有足够的前置知识,去了解MACVLAN这最后一种网络设备虚拟化的方式了。

前面我提到,两个VLAN之间位于独立的广播域,是完全二层隔离的,要通信就只能通过三层设备。而最简单的三层通信就是靠单臂路由了。

接下来,我就以这里的示意图中给出的网络拓扑结构为例,来给你介绍下单臂路由是如何工作的。

假设位于VLAN-A中的主机A1,希望把数据包发送给VLAN-B中的主机B2,由于A、B两个VLAN之间二层链路不通,因此引入了单臂路由。单臂路由不属于任何VLAN,它与交换机之间的链路允许任何VLAN ID的数据包通过,这种接口被称为TRUNK

这样,A1要和B2通信,A1就把数据包先发送给路由(只需把路由设置为网关即可做到),然后路由根据数据包上的IP地址得知B2的位置,去掉VLAN-A的VLAN Tag,改用VLAN-B的VLAN Tag重新封装数据包后,发回给交换机,交换机收到后就可以顺利转发给B2了。

这个过程并没什么复杂的地方,但不知道你有没有注意到一个问题:路由器应该设置怎样的IP地址呢?

由于A1、B2各自处于独立的网段上,它们又各自要把同一个路由作为网关使用,这就要求路由器必须同时具备192.168.1.0/24和192.168.2.0/24的IP地址。当然,如果真的就只有VLAN-A、VLAN-B两个VLAN,那把路由器上的两个接口分别设置不同的IP地址,然后用两条网线分别连接到交换机上,也勉强算是一个解决办法。

但要知道,VLAN最多可以支持4096个VLAN,那如果要接四千多条网线就太离谱了。因此为了解决这个问题,802.1Q规范中专门定义了子接口(Sub-Interface)的概念,它的作用是允许在同一张物理网卡上,针对不同的VLAN绑定不同的IP地址。

所以,MACVLAN就借用了VLAN子接口的思路,并且在这个基础上更进一步,不仅允许对同一个网卡设置多个IP地址,还允许对同一张网卡上设置多个MAC地址,这也是MACVLAN名字的由来。

原本MAC地址是网卡接口的“身份证”,应该是严格的一对一关系,而MACVLAN打破了这层关系。方法就是在物理设备之上、网络栈之下生成多个虚拟的Device,每个Device都有一个MAC地址,新增Device的操作本质上相当于在系统内核中,注册了一个收发特定数据包的回调函数,每个回调函数都能对一个MAC地址的数据包进行响应,当物理设备收到数据包时,会先根据MAC地址进行一次判断,确定交给哪个Device来处理,如下图所示。

这样,我们以交换机一侧的视角来看,这个端口后面就像是另一台已经连接了多个设备的交换机一样。

用MACVLAN技术虚拟出来的副本网卡,在功能上和真实的网卡是完全对等的,此时真正的物理网卡实际上也确实承担着类似交换机的职责。

在收到数据包后,物理网卡会根据目标MAC地址,判断这个包应该转发给哪块副本网卡处理,由同一块物理网卡虚拟出来的副本网卡,天然处于同一个VLAN之中,因此可以直接二层通信,不需要将流量转发到外部网络。

那么,与Linux Bridge相比,这种以网卡模拟交换机的方法在目标上其实没有什么本质上的不同,但MACVLAN在内部实现上,则要比Linux Bridge轻量得多。

从数据流来看,副本网卡的通信只比物理网卡多了一次判断而已,就能获得很高的网络通信性能;从操作步骤来看,由于MAC地址是静态的,所以MACVLAN不需要像Linux Bridge那样,要考虑MAC地址学习、STP协议等复杂的算法,这也进一步突出了MACVLAN的性能优势。

而除了模拟交换机的Bridge模式外,MACVLAN还支持虚拟以太网端口聚合模式(Virtual Ethernet Port Aggregator,VEPA)、Private模式、Passthru模式、Source模式等另外几种工作模式,有兴趣的话你可以去参考下相关资料,我就不再逐一介绍了。

容器间通信

好了,前面我们通过对虚拟化网络基础知识的一番铺垫后,现在,我们就可以尝试使用这些知识去解构容器间的通信原理了,毕竟运用知识去解决问题,才是学习网络虚拟化的根本目的。

在这节课里,我们先以Docker为目标,谈一谈Docker所提供的容器通信方案。当下节课介绍过CNI下的Kubernetes网络插件生态后,你也许会觉得Docker的网络通信相对简单,对于某些分布式系统的需求来说,甚至是过于简陋了。不过,虽然容器间的网络方案多种多样,但通信主体都是固定的,不外乎没有物理设备的虚拟主体(容器、Pod、Service、Endpoints等等)、不需要跨网络的本地主机、以及通过网络连接的外部主机三种层次。

所有的容器网络通信问题,其实都可以归结为本地主机内部的多个容器之间、本地主机与内部容器之间,以及跨越不同主机的多个容器之间的通信问题,其中的许多原理都是相通的,所以我认为Docker网络的简单,在作为检验前面网络知识有没有理解到位时,倒不失为一种优势。

好,下面我们就具体来看看吧。

Docker的网络方案在操作层面上,是指能够直接通过docker run --network参数指定的网络,或者是先被docker network create创建后再被容器使用的网络。安装Docker的过程中,会自动在宿主机上创建一个名为docker0的网桥,以及三种不同的Docker网络,分别是bridge、host和none,你可以通过docker network ls命令查看到这三种网络,具体如下所示:

$ docker network ls
NETWORK ID          NAME                                    DRIVER              SCOPE
2a25170d4064        bridge                                  bridge              local
a6867d58bd14        host                                    host                local
aeb4f8df39b1        none                                    null                local

事实上,这三种网络,对应着Docker提供的三种开箱即用的网络方案,它们分别为:

而除了前面三种开箱即用的网络方案以外,Docker还支持由用户自行创建的网络,比如说:

小结

这节课我从模拟网卡、交换机这些网络设备开始,给你介绍了如何在Linux网络名称空间的支持下,模拟出一个物理上实际并不存在,但可以像物理网络一样,让程序可以进行通讯的虚拟化网路。

虚拟化网络是容器编排必不可少的功能,网络的功能和性能,对应用程序各个服务间通讯都有非常密切的关联,这一点你要重点关注。在实际生产中,容器编排系统就是由一批容器通过网络交互来共同对外提供服务的,其中的开发、除错、效率优化等工作,都离不开这些基础的网络知识。

一课一思

你在使用Docker时,有没有关注或者调整过它的容器通讯网络?在哪些需求场景下你做出过调整呢?

欢迎在留言区分享你的答案。如果觉得有收获,也欢迎你把今天的内容分享给其他的朋友。感谢你的阅读,我们下一讲再见。