计算机之间需要网络连接才能进行相互通信,这句话对容器实例依然成立,因此网卡分为物理网卡和虚拟网卡。虽然说这两种类型的网卡都提供网络连接的能力,但是虚拟网卡并不直接等同于物理网卡,读者可以把虚拟网卡看成宿主机或者hypervisor(虚拟机监视器)提供的一种类型的虚拟设备,为虚拟机提供网络连接的能力。
网卡(network interfaces)在通信通信之前需要初始化,比如配置IP地址等,一张网卡可以分别配置一个IPV4地址和IPV6地址,当然也可以在一张网卡上配置多个IP地址。Linux操作系统也提供了网络接口(network interface)的概念,并且可以是物理接口(比如以太网卡Ethernet和端口)或者虚拟网络接口。比如我们在ubuntu操作系统上运行ifconfig,这个命令的输出结果就是这台机器上配置的所有物理网络接口以及配置信息。
# ifconfig
eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
inet 172.17.0.2 netmask 255.255.0.0 broadcast 172.17.255.255
ether 02:42:ac:11:00:02 txqueuelen 0 (Ethernet)
RX packets 177 bytes 206828 (206.8 KB)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 143 bytes 7966 (7.9 KB)
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
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
从ifconfig命令的输出信息可以看到笔者的这台机器有两个网络接口(network interfaces),lo接口也称作是loopback interface,回路接口。这是一种特殊的网络接口,专门用于相同机器上的不同进程之间通信。127.0.0.1是标准的回路接口ip地址,进程发往回路接口的数据包(packet)不会拉开当前机器,只有运行在相同机器上的进程才能接收发给回路地址的数据包。
输出信息中还包含另外一个网络接口eth0,通过名字也不难猜测出来是这台ubuntu机器上的以太网接口,其中inet字段为这个接口的ip地址,我们通过输出的信息还可以看到子网掩码,广播地址,mac地址等配置信息。由于容器运行时会为每个POD(注意是每个POD,不是容器实例)创建新的虚拟网络接口,因此如果我们在一台运行着数据众多容器实例的机器上运行ifconfig,输入结果会非常长。
除了传统意义上我们都熟悉的以太网接口和回路接口之外,Linux操作系统还提供Bridge接口,也叫“网桥接口”。允许系统管理员在宿主机上创建多个L2层的网络,换句话说就是网桥接口如同网桥一样,为同一台机器上的多个网络接口提供相互链接的能力。Bridge接口让POD可以通过各自虚拟的网络接口相互链接,并且通过宿主机来访问更大范围的网络服务。网桥接口的具体工作模式如下图所示:
如上图所示,veth设备一般被称作本地Ethernet通道,经常成对出现,从pod一端看到的就是eth0以太网接口(咱们上边ifconfg输出中的eth0就是veth设备),发送到veth设备一端的数据,会马上出现在设备的另外一端。我们可以通过命令行工具brctl或者ip来管理veth设备以及配置工作。Kubernetes的网络实现中大量使用了网桥接口,来通过veth设备将不同命名空间的POD进行连接,咱们会在后续的文章中详细介绍。
对于Linux操作系统来说,数据的收发全靠内核提供的网络协议栈能力,接下来咱们首先看看内核是如何处理连接的,因为服务路由,防火墙以及很多关键组件和功能都依赖于Linux底层的连接和数据包处理能力。
Linux操作系统从2.3版本引入了Netfiler组件,这是内核处理数据包的核心组件。简单来说Netfiler就提供给用户空间应用程序的一组回调函数,应用程序可以通过将自己开发的代码挂到这些回调函数上,来辅助内核处理收到的数据包。
如果我们开发了一个数据包处理程序,就可以将处理程序注册到Netfiler回调函数上,这样当内核收到符合要求的数据包,会调用我们注册的处理程序,咱们在处理程序中可以基于收到的数据包来决定数据改如何处理,比如drop掉数据包,或者对数据包进行修改,然后回传给内核继续后续处理。通过这种方式,开发人员就构建丰富的运行在用户空间的数据包处理或者统计报告应用程序。在Linux内核中,Netfiler和iptables就如同双胞胎,一起工作实现很多实用的功能。
具体来说,Netfiler中包含5个回调函数(hook,钩子函数),详细的信息如下所示:
- NF_IP_PRE_ROUTING hook,对应的iptables chain为PREROUTING,当数据从外部系统进入主机的时候触发
- NF_IP_LOCAL_IN hook,对应的iptables chain为INPUT,当数据包的目标IP地址和本机匹配的时候触发
- NF_IP_FORWARD,对应的iptables chain为NAT,当数据包的源和目标地址都和本机不匹配的时候触发
- NF_IP_LOCAL_OUT,对应的iptables chain为OUTPUT,当数据包为本机发给外部机器的时候触发
- NF_IP_POST_ROUTING,对应的iptables chain为POSTROUTING,当任意的数据包离开本机的时候触发
当数据包(packet)被内核收到,Netfilter会按固定的顺序逐个触发我们在上表每个钩子上注册的处理程序,基于笔者过往的经验,理解Netfiler的这5个hooks是理解Kubernetes中kube-proxy提供的服务能力的基础,因为kube-proxy依赖于iptables工作,而iptables会直接把chain和Netfiler的hooks函数关联在一起。
具体来说,Netfiler会在数据包处理的过程中,基于特定的条件触发hooks钩子函数,我们可以简单的将这个处理过程总结如下图:
从上图我们可以看到对任意数据包(packet)来说,并不是会调用所有的hooks钩子函数,比如从本机发给外部系统的数据包,会触发NF_IP_LOCAL_OUT和NF_IP_POST_ROUTING钩子函数。决定数据包具体会触发哪些Netfiler钩子函数取决于两个因素:1,数据包的源地址是否是本机;2,数据包的目的地址是否是本机。当进程调用本地的某个服务的时候,也就是进程发送的数据包,源地址和目的地址都是本机,会首先触发NF_IP_LOCAL_OUT钩子函数,接着触发NP_IP_POST_ROUTING函数,然后会重新进入到数据包处理流程,依次触发NF_IP_PRE_ROUTING和NF_IP_LOCAL_IN钩子函数。
由于本机发给本机这种情况会造成安全问题,因此我们可能会问是否能伪造一个数据包,源地址和目的地址都是127.0.0.1?其实当Linux操作系统内核收到这样的数据包的时候,默认行为是过滤掉这样的数据包,根本就不会进入到Netfiler的处理流程。由于127.0.0.1不是一个有效的互联网可达地址,因此Linux内核会很容易分辨出来这样的数据包,并采取行动。我们把这种source IP地址不正常的数据包称作Martian packet。即便是操作系统允许我们关闭过滤此类数据包的选项,笔者强烈建议大家在自己的生产环境不要做类似的尝试。以下是场景的四种Netfiler处理数据包的流程:
- 从本机发给本机:NF_IP_LOCAL_OUT,NF_IP_LOCAL_IN
- 从本地发给外部机器:NF_IP_LOCAL_OUT,NF_IP_POST_ROUTING
- 外部机器发给本机:NF_IP_PRE_ROUTING,NF_IP_LOCAL_IN
- 外部机器发给外部机器:NF_IP_PRE_ROUTING,NF_IP_FORWARD,NF_IP_POST_ROUTING
另外大家熟知的NAT(网络地址转换,Network Address Translation)只会影响在NF_IP_PRE_ROUTING和NF_IP_LOCAL_OUT钩子函数中的路由计算,这是iptables设计的核心,也就是说SNAT和DNAT只会作用域特定的hooks和chains。
对于工具开发人员来说,可以通过调用NF_REGISTER_NET_HOOK方法来注册数据包处理程序到Netfiler的钩子函数上,当适配的数据包被收到后,内核会调用注册的钩子函数按照特定的业务逻辑来处理。大家应该不难猜到iptables背后的工作原理就是按这种方式。不过对于大部分同学来说,应该这辈子都不会有机会编写生产ready的数据包处理代码。
Netfiler会基于钩子函数返回的结果来处理数据包,具体有一下几种类型的actions:
- Accept,继续数据包的处理
- Drop,丢弃数据包
- Queue,将数据包发给用户空间的处理程序
- Stolen,停止内核态后续数据包处理逻辑,将控制权转交给用户空间程序
- Repeat,重新处理数据包
注:笔者要特别强调的是,钩子函数处理的过程中,是可以改变数据包的状态,比如调整数据包的TTL字段,masquerade数据包等。
接下来我们讨论Netfiler的Conntrack模块,Conntrack组件主要用来跟踪Linux操作系统机器上外部连入以及内部连出的连接状态(connection state)。Conntrack会将受到的每个数据包和特定的连接关联起来,如果没有Conntrack提供的连接追踪能力,内核收到的数据流会显得非常混乱,Conntrack是操作系统的防火墙和NAT功能模块的基石。
具体来说,Conntrack模块可以让我们实现只允许存在连接(connection)的数据包进入到系统,其他的数据包(任意的数据包)都被丢弃。比如我们可以配置用户中心的服务只能访问(outbound)阿里的企业也钉钉用户信息获取接口,而不允许外部系统发起到这个服务的连接(inbound)。
对于iptables来说,NAT功能依赖于Conntrack来实现。我们都知道NAT有两种类型:SNAT(也称作source NAT,iptables会重写数据包的源地址),DNAT(也称作destination NAT,iptables会重新数据包的目的地址)。NAT的使用场景非常广泛,不光在Kubernetes的Service路由场景中,可能存在于家家户户的路由器配置中。举个例子,咱们家里上网的路由器就采用了SNAT和DNAT来将局域网中192.168地址SNAT成路由器的地址,这样才能访问外部的资源并返回。当收到返回的数据包后,有需要使用DNAT来讲数据发送给访问设备,比如家里卧室的台式机。
正是有了Conntrack提供的连接追踪功能,数据包就可以自动和所属的连接建立关系,可以让我们实现consitent路由决策,比如讲某个用户的连接固定的指向某个后台机器来处理。Conntrack通过我们熟悉的五元组(源IP地址,源端口号,目标IP地址,目标端口号和L4层的协议)来标识连接,其中端口号用来进行路由计算,端口号用来进行数据包和应用程序的映射(端口号是传输层确定数据接受进程的信息)。Conntrack的五元组中需要L4层协议的原因是因为应用程序支持TCP和UDP两种传输层的协议。Conntrack把每个五元组确定的连接称作flow(流),每个连接除了包含五元组等连接的元数据之外,还包括连接的状态。
从实现层面看,Conntrack的通过哈希表管理所有的连接,如下图所示:
如上图所示,哈希表的key是五元组,而key的空间可以配置。较大的key空间会占用更多的内存,但处理速度会更快。另外操作系统也支持最大连接数的配置(maximum number of flow),因此当Conntrack中所有的连接都被使用完之后,系统就无法再为新的用户请求服务,因为客户端无法建立连接。这是不是听起来像DOS攻击? 没错,通过大量短生命周期的连接迅速耗尽Conntrack的连接数空间是一种常用的DOS攻击手段。
注:为了内容的完整性,读者可以通过/proc/sys/net/nf_conntrack_max来修改Conntrack最大的连接数,以及/sys/module/nf_conntrack/parameters/hashsize来调整哈希表的大小,但是笔者强力不建议这么干,除非你知道自己在干什么。
咱们前边说过Conntrack除了包含五元组信息外,还包含连接的状态。具体来说Conntrack有四种状态,不过Conntrack工作在L3层(网络层),和我们数字的L4层(传输层)协议的状态是两码事,详细信息介绍如下:
- 状态NEW,连接上发送过或者收到过数据包,但是未看到响应数据包,比如只收到TCP SYN数据包
- 状态ESTABLISHED,连接上有双向的数据包,比如收到了TCP SYN数据包,也同时发送了SYN ACK数据包
- 状态RELATED,建立额外的连接,并通过元数据关联到原始的连接上。比如FTP应用,FTP客户端和服务器在22号端口上建立连接,然后会新开连接来实际传输数据
- 状态INVALID,数据包无效,比如收到TCP RST数据包,但是没有连接信息,也就是Conntrack找不到五元组和这个RST数据包匹配
虽然说Conntrack是内核的模块,但不见得在我们的系统上被激活,我们可以通过命令lsmod | grep nf_conntrack 来检查系统上的Conntrack模块是否被激活,如果没有的话,可以执行命令sudo modprobe nf_conntrack来加载Conntrack内核模块。
在Linux数据包处理机制(上)这篇文章的末尾,咱们来聊聊数据包的路由机制。读者首先要明确的是路由选择在协议栈的L3层(网络层),内核在处理数据包的时候,需要为数据包确定下一站是哪里,因为在大部分情况下,数据包的目的机器和当前接受机器不在同一个网络。
举个例子说明一下,假设我们希望从本机连接到IP地址为1.2.3.4的机器,并且1.2.3.4这个IP地址不在当前网络。对于处理数据包的机器来说,能够做的最大努力就是将数据包发送到离目的机器更近的机器上,而如何选择这个最近的机器需要路由表的支持。
笔者在自己本地的Ubuntu系统上通过route -n命令就可以查看路由表,输出如下:
# route -n
Kernel IP routing table
Destination Gateway Genmask Flags Metric Ref Use Iface
0.0.0.0 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
上边的输的是典型的路由配置,笔者的ubuntu机器包含两个路由,到本机的路由172.17.0.0以及默认路由0.0.0.0。大家如果学过计算机网络,应该知道我们可以有两种子网的表达方式:1,通过CIDR的方式(比如172.17.0.0/24);2,通过子网掩码的方式(比如255.255.0.0)。上边的输出采用的是子网掩码的方式。
因此如果回到我们的问题,本地机器需要连接到IP地址为1.2.3.4的远程机器上,该如何选择路由表?基于路由表的最小匹配原则,这个数据包会发送到机器172.17.0.1上,因为172.17.0.0并不匹配IP地址1.2.3.4,如果路由表中有两个路由项都匹配,那么就选择metric小的那一项(虽然笔者机器上这两项的值都是0,但是在生产机器上,网络管理员会维护这些路由信息,因此会更加丰富)。
注:很多CNI插架重度依赖于操作系统的路由表工作,咱们后边的内容遇到会详细介绍。
好了,这篇文章的内容就这么多了,咱们基本覆盖了Linux内核处理数据包的核心概念和流程。接下来(下)篇文章会继续讨论iptables,IPVS和eBPF的工作原理,为我们介绍容器网络和Kuberntes网络机制打下基础,敬请期待!