7.2 Docker网络基础

Docker技术依赖于近年来Linux内核虚拟化技术的发展,所以Docker对Linux内核有很强的依赖。这里将Docker使用到的与Linux网络有关的主要技术进行简单介绍,这些技术有网络命名空间(Network Namespace)、Veth设备对、网桥、ipatables和路由。

7.2.1 网络命名空间

为了支持网络协议栈的多个实例,Linux在网络栈中引入了网络命名空间,这些独立的协议栈被隔离到不同的命名空间中。处于不同命名空间中的网络栈是完全隔离的,彼此之间无法通信。通过对网络资源的隔离,就能在一个宿主机上虚拟多个不同的网络环境。Docker正是利用了网络的命名空间特性,实现了不同容器之间的网络隔离。

在Linux的网络命名空间中可以有自己独立的路由表及独立的iptables设置来提供包转发、NAT及IP包过滤等功能。

为了隔离出独立的协议栈,需要纳入命名空间的元素有进程、套接字、网络设备等。进程创建的套接字必须属于某个命名空间,套接字的操作也必须在命名空间中进行。同样,网络设备必须属于某个命名空间。因为网络设备属于公共资源,所以可以通过修改属性实现在命名空间之间移动。当然,是否允许移动与设备的特征有关。

让我们深入Linux操作系统内部,看看它是如何实现网络命名空间的,这也对理解后面的概念有帮助。

1. 网络命名空间的实现

Linux的网络协议栈是十分复杂的,为了支持独立的协议栈,相关的这些全局变量都必须被修改为协议栈私有。最好的办法就是让这些全局变量成为一个Net Namespace变量的成员,然后为协议栈的函数调用加入一个Namespace参数。这就是Linux实现网络命名空间的核心。

同时,为了保证对已经开发的应用程序及内核代码的兼容性,内核代码隐式地使用了命名空间中的变量。程序如果没有对命名空间有特殊需求,就不需要编写额外的代码,网络命名空间对应用程序而言是透明的。

在建立新的网络命名空间,并将某个进程关联到这个网络命名空间后,就出现了类似于如图7.1所示的内核数据结构,所有网站栈变量都被放入了网络命名空间的数据结构中。这个网络命名空间是其进程组私有的,和其他进程组不冲突。

图7.1 命名空间的内核数据结构

在新生成的私有命名空间中只有回环设备(名为“lo”且是停止状态),其他设备默认都不存在,如果我们需要,则要一一手工建立。Docker容器中的各类网络栈设备都是Docker Daemon在启动时自动创建和配置的。

所有网络设备(物理的或虚拟接口、桥等在内核里都叫作Net Device)都只能属于一个命名空间。当然,物理设备(连接实际硬件的设备)通常只能关联到root这个命名空间中。虚拟网络设备(虚拟以太网接口或者虚拟网口对)则可以被创建并关联到一个给定的命名空间中,而且可以在这些命名空间之间移动。

前面提到,由于网络命名空间代表的是一个独立的协议栈,所以它们之间是相互隔离的,彼此无法通信,在协议栈内部都看不到对方。那么有没有办法打破这种限制,让处于不同命名空间中的网络相互通信,甚至与外部的网络进行通信呢?答案是“有,应用Veth设备对即可”。Veth设备对的一个重要作用就是打通了相互看不到的协议栈之间的壁垒,它就像一条管子,一端连着这个网络命名空间的协议栈,一端连着另一个网络命名空间的协议栈。所以如果想在两个命名空间之间通信,就必须有一个Veth设备对。后面会介绍如何操作Veth设备对来打通不同命名空间之间的网络。

2. 对网络命名空间的操作

下面列举对网络命名空间的一些操作。我们可以使用Linux iproute2系列配置工具中的IP命令来操作网络命名空间。注意,这个命令需要由root用户运行。

创建一个命名空间:

在命名空间中运行命令:

也可以先通过bash命令进入内部的Shell界面,然后运行各种命令:

退出到外面的命名空间时,请输入“exit”。

3. 网络命名空间操作中的实用技巧

操作网络命名空间时的一些实用技巧如下。

我们可以在不同的网络命名空间之间转移设备,例如下面会提到的Veth设备对的转移。因为一个设备只能属于一个命名空间,所以转移后在这个命名空间中就看不到这个设备了。具体哪些设备能被转移到不同的命名空间中呢?在设备里面有一个重要的属性:NETIF_F_ETNS_LOCAL,如果这个属性为on,就不能被转移到其他命名空间中了。Veth设备属于可以转移的设备,而很多其他设备如lo设备、vxlan设备、ppp设备、bridge设备等都是不可以转移的。将无法转移的设备移动到别的命名空间时,会得到参数无效的错误提示:

如何知道这些设备是否可以转移呢?可以使用ethtool工具查看:

netns-local的值是on,说明不可以转移,否则可以转移。

7.2.2 Veth设备对

引入Veth设备对是为了在不同的网络命名空间之间通信,利用它可以直接将两个网络命名空间连接起来。由于要连接两个网络命名空间,所以Veth设备都是成对出现的,很像一对以太网卡,并且中间有一根直连的网线。既然是一对网卡,那么我们将其中一端称为另一端的peer。在Veth设备的一端发送数据时,它会将数据直接发送到另一端,并触发另一端的接收操作。

整个Veth的实现非常简单,有兴趣的读者可以参考源代码“drivers/net/veth.c”中的实现。如图7.2所示是Veth设备对示意图。

图7.2 Veth设备对示意图

1. 对Veth设备对的操作命令

接下来看看如何创建Veth设备对,如何将其连接到不同的命名空间中,并设置其地址,让它们通信。

创建Veth设备对:

创建后,可以查看Veth设备对的信息。使用ip link show命令查看所有网络接口:

可以看到有两个设备生成了,一个是veth0,它的peer是veth1。

现在这两个设备都在自己的命名空间中,那怎么能行呢?好了,如果将Veth看作有两个头的网线,那么我们将另一个头甩给另一个命名空间:

这时可在外面这个命名空间中看两个设备的情况:

只剩一个veth0设备了,已经看不到另一个设备了,另一个设备已被转移到另一个网络命名空间中了。

在netns1网络命名空间中可以看到veth1设备,这符合预期:

现在看到的结果是,两个不同的命名空间各自有一个Veth的“网线头”,各显示为一个Device(在Docker的实现里面,它除了将Veth放入容器内,还将它的名字改成了eth0,简直以假乱真,你以为它是一个本地网卡吗)。

现在可以通信了吗?不行,因为它们还没有任何地址,我们现在给它们分配IP地址:

再启动它们:

现在两个网络命名空间就可以相互通信了:

至此,我们就能够理解Veth设备对的原理和用法了。在Docker内部,Veth设备对也是连通容器与宿主机的主要网络设备,离开它是不行的。

2.Veth设备对如何查看对端

我们在操作Veth设备对时有一些实用技巧,如下所示。

一旦将Veth设备对的对端放入另一个命名空间中,在原命名空间中就看不到它了。那么我们怎么知道这个Veth设备的对端在哪里呢,也就是说它到底连接到哪个命名空间中了呢?可以使用ethtool工具来查看(当网络命名空间特别多时,这可不是一件很容易的事情)。

首先,在命名空间netns1中查询Veth设备对端接口在设备列表中的序列号:

得知另一端的接口设备的序列号是5,我们再到命名空间netns2中查看序列号5代表什么设备:

好,我们现在就找到序列号为5的设备了,它是veth0,它的另一端自然就是命名空间netns1中的veth1了,因为它们互为peer。

7.2.3 网桥

Linux可以支持多个不同的网络,它们之间能够相互通信,如何将这些网络连接起来并实现各网络中主机的相互通信呢?可以用网桥。网桥是一个二层的虚拟网络设备,把若干个网络接口“连接”起来,以使得网络接口之间的报文能够相互转发。网桥能够解析收发的报文,读取目标MAC地址的信息,将其与自己记录的MAC表结合,来决策报文的转发目标网络接口。为了实现这些功能,网桥会学习源MAC地址(二层网桥转发的依据就是MAC地址)。在转发报文时,网桥只需向特定的网口进行转发,来避免不必要的网络交互。如果它遇到一个自己从未学习到的地址,就无法知道这个报文应该向哪个网络接口转发,将报文广播给所有的网络接口(报文来源的网络接口除外)。

在实际的网络中,网络拓扑不可能永久不变。设备如果被移动到另一个端口上,却没有发送任何数据,网桥设备就无法感知这个变化,网桥还是向原来的端口转发数据包,在这种情况下数据会丢失。所以网桥还要对学习到的MAC地址表加上超时时间(默认为5min)。如果网桥收到了对应端口MAC地址回发的包,则重置超时时间,否则过了超时时间,就认为设备已经不在那个端口上了,它会重新广播发送。

在Linux的内部网络栈里实现的网桥设备,作用和上面的描述相同。Linux主机过去一般只有一个网卡,现在多网卡的机器越来越多,而且有很多虚拟设备存在,所以Linux网桥提供了在这些设备之间相互转发数据的二层设备。

Linux内核支持网口的桥接(目前只支持以太网接口)。但是与单纯的交换机不同,交换机只是一个二层设备,对于接收到的报文,要么转发,要么丢弃。运行着Linux内核的机器本身就是一台主机,有可能是网络报文的目的地,其收到的报文除了转发和丢弃,还可能被送到网络协议栈的上层(网络层),从而被自己(这台主机本身的协议栈)消化,所以我们既可以把网桥看作一个二层设备,也可以把它看作一个三层设备。

1.Linux网桥的实现

Linux内核是通过一个虚拟网桥设备(Net Device)来实现桥接的。这个虚拟设备可以绑定若干个以太网接口设备,从而将它们桥接起来。如图7.3所示,这种Net Device网桥和普通的设备不同,最明显的一个特性是它还可以有一个IP地址。

图7.3 网桥的位置

如图7.3所示,网桥设备br0绑定了eth0和eth1。对于网络协议栈的上层来说,只看得到br0就行。因为桥接是在数据链路层实现的,上层不需要关心桥接的细节,所以协议栈上层需要发送的报文被送到br0,网桥设备的处理代码判断报文应该被转发到eth0还是eth1,或者两者应该皆转发;反过来,从eth0或从eth1接收到的报文被提交给网桥的处理代码,在这里会判断报文应该被转发、丢弃还是被提交到协议栈上层。

而有时eth0、eth1也可能会作为报文的源地址或目的地址,直接参与报文的发送与接收,从而绕过网桥。

2. 网桥的常用操作命令

Docker自动完成了对网桥的创建和维护。为了进一步理解网桥,下面举几个常用的网桥操作例子,对网桥进行手工操作:

新增一个网桥设备:

之后可以为网桥增加网口,在Linux中,一个网口其实就是一个物理网卡。将物理网卡和网桥连接起来:

网桥的物理网卡作为一个网口,由于在链路层工作,就不再需要IP地址了,这样上面的IP地址自然失效:

给网桥配置一个IP地址:

这样网桥就有了一个IP地址,而连接到上面的网卡就是一个纯链路层设备了。

7.2.4 iptables和Netfilter

我们知道,Linux网络协议栈非常高效,同时比较复杂。如果我们希望在数据的处理。过程中对关心的数据进行一些操作,则该怎么做呢?Linux提供了一套机制来为用户实现自定义的数据包处理。

在Linux网络协议栈中有一组回调函数挂接点,通过这些挂接点挂接的钩子函数可以在Linux网络栈处理数据包的过程中对数据包进行一些操作,例如过滤、修改、丢弃等。该挂接点技术就叫作Netfilter和iptables。

Netfilter负责在内核中执行各种挂接的规则,运行在内核模式中;而iptables是在用户模式下运行的进程,负责协助和维护内核中Netfilter的各种规则表。二者相互配合来实现整个Linux网络协议栈中灵活的数据包处理机制。

Netfilter可以挂接的规则点有5个,如图7.4中的深色椭圆所示。

图7.4 Netfilter可以挂接的规则点

1. 规则表Table

这些挂接点能挂接的规则也分不同的类型(也就是规则表Table),我们可以在不同类型的Table中加入我们的规则。目前主要支持的Table类型有:RAW、MANGLE、NAT和FILTER。这4个Table(规则链)的优先级是RAW最高,FILTER最低。

在实际应用中,不同的挂接点所需的规则类型通常不同。例如,在Input的挂接点上明显不需要FILTER过滤规则,因为根据目标地址已经选择好本机的上层协议栈了,所以无须再挂接FILTER过滤规则。目前Linux系统支持的不同挂接点能挂接的规则类型如图7.5所示。

图7.5 不同的挂接点能挂接的规则类型

当Linux协议栈的数据处理运行到挂接点时,它会依次调用挂接点上所有的挂钩函数,直到数据包的处理结果是明确地接受或者拒绝。

2. 处理规则

每个规则的特性都分为以下几部分。

◎ 表类型(准备干什么事情)。

◎ 什么挂接点(什么时候起作用)。

◎ 匹配的参数是什么(针对什么样的数据包)。

◎ 匹配后有什么动作(匹配后具体的操作是什么)。

前面已经介绍了表类型和挂接点,接下来看看匹配的参数和匹配后的动作。

(1)匹配的参数。匹配的参数用于对数据包或者TCP数据连接的状态进行匹配。当有多个条件存在时,它们一起发挥作用,达到只针对某部分数据进行修改的目的。常见的匹配参数如下。

◎ 流入、流出的网络接口。

◎ 来源、目的地址。

◎ 协议类型。

◎ 来源、目的端口。

(2)匹配后的动作。一旦有数据匹配,就会执行相应的动作。动作类型既可以是标准的预定义的几个动作,也可以是自定义的模块注册动作,或者是一个新的规则链,以更好地组织一组动作。

3.iptables命令

iptables命令用于协助用户维护各种规则。我们在使用Kubernetes、Docker的过程中,通常都会去查看相关的Netfilter配置。这里只介绍如何查看规则表,详细的介绍请参照Linux的iptables帮助文档。查看系统中已有规则的方法如下。

◎ iptables-save:按照命令的方式打印iptables的内容。

◎ iptables-vnL:以另一种格式显示Netfilter表的内容。

7.2.5 路由

Linux系统包含一个完整的路由功能。当IP层在处理数据发送或者转发时,会使用路由表来决定发往哪里。在通常情况下,如果主机与目的主机直接相连,那么主机可以直接发送IP报文到目的主机,这个过程比较简单。例如,通过点对点的链接或网络共享,如果主机与目的主机没有直接相连,那么主机会将IP报文发送给默认的路由器,然后由路由器来决定往哪里发送IP报文。

路由功能由IP层维护的一张路由表来实现。当主机收到数据报文时,它用此表来决策接下来应该做什么操作。当从网络侧接收到数据报文时,IP层首先会检查报文的IP地址是否与主机自身的地址相同。如果数据报文中的IP地址是主机自身的地址,那么报文将被发送到传输层相应的协议中。如果报文中的IP地址不是主机自身的地址,并且主机配置了路由功能,那么报文将被转发,否则报文将被丢弃。

路由表中的数据一般是以条目形式存在的。一个典型的路由表条目通常包含以下主要的条目项。

(1)目的IP地址:此字段表示目标的IP地址。这个IP地址可以是某主机的地址,也可以是一个网络地址。如果这个条目包含的是一个主机地址,那么它的主机ID将被标记为非零;如果这个条目包含的是一个网络地址,那么它的主机ID将被标记为零。

(2)下一个路由器的IP地址:这里采用“下一个”的说法,是因为下一个路由器并不总是最终的目的路由器,它很可能是一个中间路由器。条目给出的下一个路由器的地址用来转发在相应接口接收到的IP数据报文。

(3)标志:这个字段提供了另一组重要信息,例如,目的IP地址是一个主机地址还是一个网络地址。此外,从标志中可以得知下一个路由器是一个真实路由器还是一个直接相连的接口。

(4)网络接口规范:为一些数据报文的网络接口规范,该规范将与报文一起被转发。

在通过路由表转发时,如果任何条目的第1个字段完全匹配目的IP地址(主机)或部分匹配条目的IP地址(网络),那么它将指示下一个路由器的IP地址。这是一个重要的信息,因为这些信息直接告诉主机(具备路由功能的)数据包应该被转发到哪个路由器。而条目中的所有其他字段将提供更多的辅助信息来为路由转发做决定。

如果没有找到一个完全匹配的IP,就接着搜索相匹配的网络ID。如果找到,那么该数据报文会被转发到指定的路由器上。可以看出,网络上的所有主机都通过这个路由表中的单个(这个)条目进行管理。

如果上述两个条件都不匹配,那么该数据报文将被转发到一个默认的路由器上。

如果上述步骤都失败,默认的路由器也不存在,那么该数据报文最终无法被转发。任何无法投递的数据报文都将产生一个ICMP主机不可达或ICMP网络不可达的错误,并将此错误返回给生成此数据报文的应用程序。

1. 路由表的创建

Linux的路由表至少包括两个表(当启用策略路由时,还会有其他表):一个是LOCAL,另一个是MAIN。在LOCAL表中会包含所有本地设备地址。LOCAL路由表是在配置网络设备地址时自动创建的。LOCAL表用于供Linux协议栈识别本地地址,以及进行本地各个不同网络接口之间的数据转发。

可以通过下面的命令查看LOCAL表的内容:

MAIN表用于各类网络IP地址的转发。它的建立既可以使用静态配置生成,也可以使用动态路由发现协议生成。动态路由发现协议一般使用组播功能来通过发送路由发现数据,动态地交换和获取网络的路由信息,并更新到路由表中。

Linux下支持路由发现协议的开源软件有许多,常用的有Quagga、Zebra等。7.8节会介绍如何使用Quagga动态容器路由发现的机制来实现Kubernetes的网络组网。

2. 路由表的查看

我们可以使用ip route list命令查看当前路由表:

在上面的例子代码中只有一个子网的路由,源地址是192.168.6.140(本机),目标地址在192.168.6.0/24网段的数据包都将通过eno16777736接口发送出去。

也可以通过netstat-rn命令查看路由表:

在显示的信息中,如果标志(Flag)是U(代表Up),则说明该路由是有效的;如果标志是G(代表Gateway),则说明这个网络接口连接的是网关;如果标志是H(代表Host),则说明目的地是主机而非网络域,等等。