Linux网络虚拟化

Linux目前提供的八种名称空间里,网络名称空间无疑是隔离内容最多的一种,它为名称空间内的所有进程提供了全套的网络设施,包括独立的设备界面、路由表、ARP表,IP地址表、iptables/ebtables规则、协议栈,等等。虚拟化容器是以Linux名称空间的隔离性为基础来实现的,那解决隔离的容器之间、容器与宿主机之间、乃至跨物理网络的不同容器间通信问题的责任,很自然也落在了Linux网络虚拟化技术的肩上。本节里,我们暂时放下容器编排、云原生、微服务等这些上层概念,走入Linux网络的底层世界,去学习一些与设备、协议、通信相关的基础网络知识。

本节的阅读对象设定为以实现业务功能为主、平常并不直接接触网络设备的普通开发人员,对于平台基础设施的开发者或者运维人员,可能会显得有点过于啰嗦或过于基础了,如果你已经提前掌握了这些知识,完全可以快速阅读,或者直接跳过部分内容。

网络通信模型

如果抛开虚拟化,只谈网络的话,那首先应该了解的知识笔者认为是Linux系统的网络通信模型,即信息是如何从程序中发出,通过网络传输,再被另一个程序接收到的。整体上看,Linux系统的通信过程无论按理论上的OSI七层模型,还是以实际上的TCP/IP四层模型来解构,都明显地呈现出“逐层调用,逐层封装”的特点,这种逐层处理的方式与栈结构,譬如程序执行时的方法栈很类似,因此它通常被称为“Linux网络协议栈”,简称“网络栈”,有时也称“协议栈”。图12-1体现了Linux网络通信过程与OSI或者TCP/IP模型的对应关系,也展示了网络栈中的数据流动的路径。

图12-1 Linux系统下的网络通信模型

图中传输模型的左侧,笔者特别标示出了网络栈在用户与内核空间的部分,可见几乎整个网络栈(应用层以下)都位于系统内核空间之中,之所以采用这种设计,主要是从数据安全隔离的角度出发来考虑的。由内核去处理网络报文的收发,无疑会有更高的执行开销,譬如数据在内核态和用户态之间来回拷贝的额外成本,因此会损失一些性能,但是能够保证应用程序无法窃听到或者去伪造另一个应用程序的通信内容。针对特别关注收发性能的应用场景,也有直接在用户空间中实现全套协议栈的旁路方案,譬如开源的Netmap以及Intel的DPDK,都能做到零拷贝收发网络数据包。

图中传输模型的箭头展示的是数据流动的方向,它体现了信息从程序中发出以后,到被另一个程序接收到之前,将经历如下几个阶段:

  • Socket:应用层的程序是通过Socket编程接口来和内核空间的网络协议栈通信的。Linux Socket是从BSD Socket发展而来的,现在Socket已经不局限于某个操作系统的专属功能,成为各大主流操作系统共同支持的通用网络编程接口,是网络应用程序实际上的交互基础。应用程序通过读写收、发缓冲区(Receive/Send Buffer)来与Socket进行交互,在UNIX和Linux系统中,出于“一切皆是文件”的设计哲学,对Socket操作被实现为对文件系统(socketfs)的读写访问操作,通过文件描述符(File Descriptor)来进行。
  • TCP/UDP:传输层协议族里最重要的协议无疑是传输控制协议(Transmission Control Protocol,TCP)和用户数据报协议(User Datagram Protocol,UDP)两种,它们也是在Linux内核中被直接支持的协议。此外还有流控制传输协议(Stream Control Transmission Protocol,SCTP)、数据报拥塞控制协议(Datagram Congestion Control Protocol,DCCP)等等。
    不同的协议处理流程大致是一样的,只是封装的报文以及头、尾部信息会有所不同,这里以TCP协议为例,内核发现Socket的发送缓冲区中有新的数据被拷贝进来后,会把数据封装为TCP Segment报文,常见网络协议的报文基本上都是由报文头(Header)和报文体(Body,也叫荷载“Payload”)两部分组成。系统内核将缓冲区中用户要发送出去的数据作为报文体,然后把传输层中的必要控制信息,譬如代表哪个程序发、由哪个程序收的源、目标端口号,用于保证可靠通信(重发与控制顺序)的序列号、用于校验信息是否在传输中出现损失的校验和(Check Sum)等信息封装入报文头中。
  • IP:网络层协议最主要就是网际协议(Internet Protocol,IP),其他还有因特网组管理协议(Internet Group Management Protocol,IGMP)、大量的路由协议(EGP、NHRP、OSPF、IGRP、……)等等。
    以IP协议为例,它会将来自上一层(本例中的TCP报文)的数据包作为报文体,再次加入自己的报文头,譬如指明数据应该发到哪里的路由地址、数据包的长度、协议的版本号,等等,封装成IP数据包后发往下一层。关于TCP和IP协议报文的内容,笔者曾在“负载均衡”中详细讲解过,有需要的读者可以参考。
  • Device:网络设备(Device)是网络访问层中面向系统一侧的接口,这里所说的设备与物理硬件设备并不是同一个概念,Device只是一种向操作系统端开放的接口,其背后既可能代表着真实的物理硬件,也可能是某段具有特定功能的程序代码,譬如即使不存在物理网卡,也依然可以存在回环设备(Loopback Device)。许多网络抓包工具,如tcpdumpWirshark便是在此处工作的,前面介绍微服务流量控制时曾提到过的网络流量整形,通常也是在这里完成的。Device主要的作用是抽象出统一的界面,让程序代码去选择或影响收发包出入口,譬如决定数据应该从哪块网卡设备发送出去;还有就是准备好网卡驱动工作所需的数据,譬如来自上一层的IP数据包、下一跳(Next Hop)的MAC地址(这个地址是通过ARP Request得到的)等等。
  • Driver:网卡驱动程序(Driver)是网络访问层中面向硬件一侧的接口,网卡驱动程序会通过DMA将主存中的待发送的数据包复制到驱动内部的缓冲区之中。数据被复制的同时,也会将上层提供的IP数据包、下一跳MAC地址这些信息,加上网卡的MAC地址、VLAN Tag等信息一并封装成为以太帧(Ethernet Frame),并自动计算校验和。对于需要确认重发的信息,如果没有收到接收者的确认(ACK)响应,那重发的处理也是在这里自动完成的。

上面这些阶段是信息从程序中对外发出时经过协议栈的过程,接收过程则是从相反方向进行的逆操作。程序发送数据做的是层层封包,加入协议头,传给下一层;接受数据则是层层解包,提取协议体,传给上一层,你可以类比来理解数据包接收过程,笔者就不再专门列举一遍数据接收步骤了。

干预网络通信

网络协议栈的处理是一套相对固定和封闭的流程,整套处理过程中,除了在网络设备这层能看到一点点程序以设备的形式介入处理的空间外,其他过程似乎就没有什么可供程序插手的余地了。然而事实并非如此,从Linux Kernel 2.4版开始,内核开放了一套通用的、可供代码干预数据在协议栈中流转的过滤器框架。这套名为Netfilter的框架是Linux防火墙和网络的主要维护者Rusty Russell提出并主导设计的,它围绕网络层(IP协议)的周围,埋下了五个钩子(Hooks),每当有数据包流到网络层,经过这些钩子时,就会自动触发由内核模块注册在这里的回调函数,程序代码就能够通过回调来干预Linux的网络通信。笔者先将这五个钩子的名字与含义列出:

  • PREROUTING:来自设备的数据包进入协议栈后立即触发此钩子。PREROUTING钩子在进入IP路由之前触发,这意味着只要接收到的数据包,无论是否真的发往本机,都会触发此钩子。一般用于目标网络地址转换(Destination NAT,DNAT)。
  • INPUT:报文经过IP路由后,如果确定是发往本机的,将会触发此钩子,一般用于加工发往本地进程的数据包。
  • FORWARD:报文经过IP路由后,如果确定是发往本机的,将会触发此钩子,一般用于处理转发到其他机器的数据包。
  • OUTPUT:从本机程序发出的数据包,在经过IP路由前,将会触发此钩子,一般用于加工本地进程的输出数据包。
  • POSTROUTING:从本机网卡出去的数据包,无论是本机的程序所发出的,还是由本机转发给其他机器的,都会触发此钩子,一般用于源网络地址转换(Source NAT,SNAT)。

图12-2 应用收、发数据包所经过的Netfilter钩子

Netfilter允许在同一个钩子处注册多个回调函数,因此向钩子注册回调函数时必须提供明确的优先级,以便触发时能按照优先级从高到低进行激活。由于回调函数会存在多个,看起来就像挂在同一个钩子上的一串链条,因此钩子触发的回调函数集合就被称为“回调链”(Chained Callbacks),这个名字导致了后续基于Netfilter设计的Xtables系工具,如稍后介绍的iptables均有使用到“链”(Chain)的概念。虽然现在看来Netfilter只是一些简单的事件回调机制而已,然而这样一套简单的设计,却成为了整座Linux网络大厦的核心基石,Linux系统提供的许多网络能力,如数据包过滤、封包处理(设置标志位、修改TTL等)、地址伪装、网络地址转换、透明代理、访问控制、基于协议类型的连接跟踪,带宽限速,等等,都是在Netfilter基础之上实现的。

以Netfilter为基础的应用有很多,其中使用最广泛的毫无疑问要数Xtables系列工具,譬如iptables、ebtables、arptables、ip6tables等等。这里面至少iptables应该是用过Linux系统的开发人员都或多或少会使用过,它常被称为是Linux系统“自带的防火墙”,然而iptables实际能做的事情已远远超出防火墙的范畴,严谨地讲,比较贴切的定位应是能够代替Netfilter多数常规功能的IP包过滤工具。iptables的设计意图是因为Netfilter的钩子回调虽然很强大,但毕竟要通过程序编码才够能使用,并不适合系统管理员用来日常运维,而它的价值便是以配置去实现原本用Netfilter编码才能做到的事情。iptables先把用户常用的管理意图总结成具体的行为预先准备好,然后在满足条件时自动激活行为。以下列出了部分iptables预置的行为:

  • DROP:直接将数据包丢弃。
  • REJECT:给客户端返回Connection Refused或Destination Unreachable报文。
  • QUEUE:将数据包放入用户空间的队列,供用户空间的程序处理。
  • RETURN:跳出当前链,该链里后续的规则不再执行。
  • ACCEPT:同意数据包通过,继续执行后续的规则。
  • JUMP:跳转到其他用户自定义的链继续执行。
  • REDIRECT:在本机做端口映射。
  • MASQUERADE:地址伪装,自动用修改源或目标的IP地址来做NAT
  • LOG:在/var/log/messages文件中记录日志信息。
  • ……

这些行为本来能够被挂载到Netfilter钩子的回调链上,但iptables又进行了一层额外抽象,不是把行为与链直接挂钩,而是根据这些底层操作根据其目的,先总结为更高层次的规则。举个例子,假设你挂载规则目的是为了实现网络地址转换(NAT),那就应该对符合某种特征的流量(譬如来源于某个网段、从某张网卡发送出去)、在某个钩子上(譬如做SNAT通常在POSTROUTING,做DNAT通常在PREROUTING)进行MASQUERADE行为,这样具有相同目的的规则,就应该放到一起才便于管理,由此便形成“规则表”的概念。iptables内置了五张不可扩展的规则表(其中security表并不常用,很多资料只计算了前四张表),如下所列:

  1. raw表:用于去除数据包上的连接追踪机制(Connection Tracking)。
  2. mangle表:用于修改数据包的报文头信息,如服务类型(Type Of Service,ToS)、生存周期(Time to Live,TTL)以及为数据包设置Mark标记,典型的应用是链路的服务质量管理(Quality Of Service,QoS)。
  3. nat表:用于修改数据包的源或者目的地址等信息,典型的应用是网络地址转换(Network Address Translation)。
  4. filter表:用于对数据包进行过滤,控制到达某条链上的数据包是继续放行、直接丢弃或拒绝(ACCEPT、DROP、REJECT),典型的应用是防火墙。
  5. security表:用于在数据包上应用SELinux,这张表并不常用。

以上五张规则表是具有优先级的:raw→mangle→nat→filter→security,也即是上面列举它们的顺序。在iptables中新增规则时,需要按照规则的意图指定要存入到哪张表中,如果没有指定,默认将会存入filter表。此外,每张表能够使用到的链也有所不同,具体表与链的对应关系如表12-1所示。

表12-1 表与链的对应关系

PREROUTING POSTROUTING FORWARD INPUT OUTPUT
raw × × ×
mangle
nat(Source) × × ×
nat(Destination) × × ×
filter × ×
security × ×

从名字上就能看出预置的五条链直接源自于Netfilter的钩子,它们与五张规则表的对应关系是固定的,用户不能增加自定义的表,或者修改已有表与链的关系,但可以增加自定义的链,新增的自定义链与Netfilter的钩子没有天然的对应关系,换而言之就是不会被自动触发,只有显式使用JUMP行为,从默认的五条链中跳转过去才能被执行。

iptables不仅仅是Linux系统自带的一个网络工具,它在容器间通信中扮演相当重要的角色,譬如Kubernetes用来管理Sevice的Endpoints的核心组件kube-proxy,就依赖iptables来完成ClusterIP到Pod的通信(也可以采用IPVS,IPVS同样是基于Netfilter的),这种通信的本质就是一种NAT访问。对于Linux用户,以上都是相当基础的网络常识,但如果你平常较少在Linux系统下工作,就可能需要一些用iptables充当防火墙过滤数据、充当作路由器转发数据、充当作网关做NAT转换的实际例子来帮助理解,由于这些操作在网上很容易就能找到,笔者便不专门去举例说明了。

行文至此,本章用了两个小节的篇幅去介绍Linux下网络通信的协议栈模型,以及程序如何干涉在协议栈中流动的信息,它们与虚拟化并没有什么直接关系,是整个Linux网络通信的必要基础。从下一节开始,我们就要开始专注于与网络虚拟化密切相关的内容了。

虚拟化网络设备

虚拟化网络并不需要完全遵照物理网络的样子来设计,不过,由于已有大量现成的代码原本就是面向于物理存在的网络设备来编码实现的,也有出于方便理解和知识继承的方面的考虑,虚拟化网络与物理网络中的设备还是有相当高的相似性。所以,笔者准备从网络中那些与网卡、交换机、路由器等对应的虚拟设施,以及如何使用这些虚拟设施来组成网络入手,来介绍容器间网络的通信基础设施。

网卡:tun/tap、veth

目前主流的虚拟网卡方案有tun/tapveth两种,在时间上tun/tap出现得更早,它是一组通用的虚拟驱动程序包,里面包含了两个设备,分别是用于网络数据包处理的虚拟网卡驱动,以及用于内核空间与用户空间交互的字符设备(Character Devices,这里具体指/dev/net/tun)驱动。大概在2000年左右,Solaris系统为了实现隧道协议(Tunneling Protocol)开发了这套驱动,从Linux Kernel 2.1版开始移植到Linux内核中,当时是源码中的可选模块,2.4版之后发布的内核都会默认编译tun/tap的驱动。

tun和tap是两个相对独立的虚拟网络设备,其中tap模拟了以太网设备,操作二层数据包(以太帧),tun则模拟了网络层设备,操作三层数据包(IP报文)。使用tun/tap设备的目的是实现把来自协议栈的数据包先交由某个打开了/dev/net/tun字符设备的用户进程处理后,再把数据包重新发回到链路中。你可以通俗地将它理解为这块虚拟化网卡驱动一端连接着网络协议栈,另一端连接着用户态程序,而普通的网卡驱动则是一端连接则网络协议栈,另一端连接着物理网卡。只要协议栈中的数据包能被用户态程序截获并加工处理,程序员就有足够的舞台空间去玩出各种花样,譬如数据压缩、流量加密、透明代理等功能都能够以此为基础来实现,以最典型的VPN应用程序为例,程序发送给tun设备的数据包,会经过如图12-3所示的顺序流进VPN程序:

图12-3 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设备的其中一端输入数据,这些数据就会从设备的另外一端原样不变地流出,它工作时数据流动如图12-4所示:

图12-4 veth pair工作示意图

由于两个容器之间采用veth通信不需要反复多次经过网络协议栈,这让veth比起tap/tun具有更好的性能,也让veth pair的实现变的十分简单,内核中只用了几十行代码实现了一个数据复制函数就完成了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地址,按如下规则转发处理:

  • 如果数据包是广播帧,转发给所有接入网桥的设备。
  • 如果数据包是单播帧:
    • 且MAC地址在地址转发表中不存在,则洪泛(Flooding)给所有接入网桥的设备,并将响应设备的接口与MAC地址学习(MAC Learning)到自己的MAC地址转发表中。
    • 且MAC地址在地址转发表中已存在,则直接转发到地址表中指定的设备。
  • 如果数据包是此前转发过的,又重新发回到此Bridge,说明冗余链路产生了环路。由于以太帧不像IP报文那样有TTL来约束,因此一旦出现环路,如果没有额外措施来处理的话就会永不停歇地转发下去。对于这种数据包就需要交换机实现生成树协议(Spanning Tree Protocol,STP)来交换拓扑信息,生成唯一拓扑链路以切断环路。

上面提到的这些名词,譬如二层转发、泛洪、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容器网络。这种组网是最基本的容器间通信形式,笔者举个具体例子来帮助你理解。假设现有如下设备,它们的连接情况如图所示,具体配置为:

  • 网桥br0:分配IP地址192.168.31.1;
  • 容器:三个网络名称空间(容器),分别编号为1、2、3,均使用veth pair接入网桥,且有如下配置:
    • 在容器一端的网卡名为veth0,在网桥一端网卡名为veth1、veth2、veth3;
    • 三个容器中的veth0网卡分配IP地址:192.168.1.10、192.168.1.11、192.168.1.12;
    • 三个容器中的veth0网卡设置网关为网桥,即192.168.31.1;
    • 网桥中的veth1、veth2、veth3无IP地址;
  • 物理网卡eth0:分配的IP地址14.123.254.86;
  • 外部网络:外部网络中有一台服务器,地址为122.246.6.183

图12-5 Linux Bridge构建单IP容器网络

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

  1. 应用程序调用Socket API发送数据,此时生成的原始数据包为:

    • 源MAC:veth0的MAC
    • 目标MAC:网关的MAC(即网桥的MAC)
    • 源IP:veth0的IP,即192.168.31.1
    • 目标IP:外网的IP,即122.246.6.183
  2. 从veth0发送的数据,会在veth1中原样出来,网桥将会从veth1中接收到一个目标MAC为自己的数据包,并且网桥有配置IP地址,由此触发了Linux Bridge的特殊转发规则。这个数据包便不会转发给任何设备,而是转交给主机的协议栈处理。
    注意,从这步以后就是三层路由了,已不在网桥的工作范围之内,是由Linux主机依靠Netfilter进行IP转发(IP Forward)去实现的。

  3. 数据包经过主机协议栈,Netfilter的钩子被激活,预置好的iptables NAT规则会修改数据包的源IP地址,将其改为物理网卡eth0的IP地址,并在映射表中记录设备端口及两个IP地址之间的对应关系,经过SNAT之后的数据包,最终会从eth0出去,此时报文头中的地址为:

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

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

    • 源MAC:网桥的MAC
    • 目标MAC:veth0的MAC
    • 源IP:外网的IP,即122.246.6.183
    • 目标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,它着重为应用提供与软件需求相符的传输服务和网络拓扑。

软件定义网络已经发展了十余年时间,远比云原生、微服务这些概念出现得更早。网络设备商基于硬件设备开发出了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),从名称来看它也算是网络虚拟化技术的早期成果之一了。由于二层网络本身的工作特性决定了它非常依赖于广播,无论是广播帧(如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最多只能有212=4096种取值。当云计算数据中心出现后,即使不考虑虚拟化的需求,单是需要分配IP的物理设备都有可能数以万计甚至数以十万计,这样4096个VLAN肯定是不够用的。后来IEEE的工程师们又提出802.1AQ规范力图补救这个缺陷,大致思路是给以太帧连续打上两个VLAN Tag,每个Tag里仍然只有12 Bits的VLAN ID,但两个加起来就可以存储224=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的整个报文结构如图12-6所示:

. 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也带来了额外的复杂度和性能开销,具体表现在:

  • 传输效率的下降,如果你仔细数过前面VXLAN报文结构中UDP、IP、以太帧报文头的字节数,会发现经过VXLAN封装后的报文,新增加的报文头部分就有整整占了50 Bytes(VXLAN报文头占8 Bytes,UDP报文头占8 Bytes,IP报文头占20 Bytes,以太帧的MAC头占14 Bytes),而原本只需要14 Bytes而已,而且现在这14 Bytes的消耗也还在,被封到了最里面的以太帧中。以太网的MTU是1500 Bytes,如果是传输大量数据,额外损耗50 Bytes并不算很高的成本,但如果传输的数据本来就只有几个Bytes的话,那传输消耗在报文头上的成本就很高昂了。

  • 传输性能的下降,每个VXLAN报文的封包和解包操作都属于额外的处理过程,尤其是用软件来实现的VTEP,额外的运算资源消耗有时候会成为不可忽略的性能影响因素。

副本网卡:MACVLAN

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

前文中提到过,两个VLAN之间是完全二层隔离的,不存在重合的广播域,因此要通信就只能通过三层设备,最简单的三层通信就是靠单臂路由了。笔者以图12-7所示的网络拓扑结构来举个具体例子,介绍单臂路由是如何工作的:

图12-7 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来处理,如图12-8所示。以交换机一侧的视角来看,这个端口后面仿佛是另一台已经连接了多个设备的交换机一样。

图12-8 MACVLAN原理

用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等等)、不需要跨网络的本地主机、以及通过网络连接的外部主机三种层次,所有的容器网络通信问题,都可以归结为本地主机内部的多个容器之间、本地主机与内部容器之间和跨越不同主机的多个容器之间的通信问题,其中的许多原理都是相通的,所以Docekr网络的简单,在作为检验前面网络知识有没有理解到位时倒不失为一种优势。

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提供的三种开箱即用的网络方案,它们分别为:

  • 桥接模式,使用--network=bridge指定,这种也是未指定网络参数时的默认网络。桥接模式下,Docekr会为新容器分配独立的网络名称空间,创建好veth pair,一端接入容器,另一端接入到docker0网桥上。Docker为每个容器自动分配好IP地址,默认配置下地址范围是172.17.0.0/24,docker0的地址默认是172.17.0.1,并且设置所有容器的网关均为docker0,这样所有接入同一个网桥内的容器直接依靠二层网络来通信,在此范围之外的容器、主机就必须通过网关来访问,具体过程笔者在介绍Linux Bridge时已经举例详细讲解过。
  • 主机模式,使用--network=host指定。主机模式下,Docker不会为新容器创建独立的网络名称空间,这样容器一切的网络设施,如网卡、网络栈等都直接使用宿主机上的真实设施,容器也就不会拥有自己独立的IP地址。此模式下与外界通信无须进行NAT转换,没有性能损耗,但缺点也十分明显,没有隔离就无法避免网络资源的冲突,譬如端口号就不允许重复。
  • 空置模式,使用--network=none指定,空置模式下,Docekr会给新容器创建独立的网络名称空间,但是不会创建任何虚拟的网络设备,此时容器能看到的只有一个回环设备(Loopback Device)而已。提供这种方式是为了方便用户去做自定义的网络配置,如自己增加网络设备、自己管理IP地址,等等。

除了三种开箱即用的网络外,Docker还支持以下由用户自行创建的网络:

  • 容器模式,创建容器后使用--network=container:容器名称指定。容器模式下,新创建的容器将会加入指定的容器的网络名称空间,共享一切的网络资源,但其他资源,如文件、PID等默认仍然是隔离的。两个容器间可以直接使用回环地址(localhost)通信,端口号等网络资源不能有冲突。
  • MACVLAN模式:使用docker network create -d macvlan创建,此网络允许为容器指定一个副本网卡,容器通过副本网卡的MAC地址来使用宿主机上的物理设备,在追求通信性能的场合,这种网络是最好的选择。Docker的MACVLAN只支持Bridge通信模式,因此在功能表现上与桥接模式相类似。
  • Overlay模式:使用docker network create -d overlay创建,Docker说的Overlay网络实际上就是特指VXLAN,这种网络模式主要用于Docker Swarm服务之间进行通信。然而由于Docker Swarm败于Kubernetes,并未成为主流,所有这种网络模式实际很少使用。
Kudos to Star
总字数: 13,936 字  最后更新: 3/25/2021, 4:58:18 PM