Yan-Daojiang's Wiki Help

本机网络与容器网络

本机网络

在计算机网络的通信方式中,有一种特殊的情况:发送和接收都在本机进行,也就是本机网络通信。

在介绍这种情况之前,我们先考虑下面的几个问题:

  • 通过 127.0.0.1 进行网络 IO 是否需要经过网卡?

  • 本机网络 IO 和跨机网络 IO 收发流程上有没有不同?有什么不同?

  • 访问一个本机服务时,使用 127.0.0.1 和使用本机 IP 有没有区别?如果有区别,通过 127.0.0.1 是更快吗?

这几个问题在介绍完本机网络之后再来一一解答。

跨机发送/跨机接收

跨机发送

在跨机数据包的发送过程中,从 send系统调用开始,直到网卡把数据发送出去,下图展示了这一过程。

Send

首先用户数据被拷贝到内核态,然后经过协议栈处理之后进入 RingBuffer,随后网卡驱动将真正的数据发送出去。

网卡发送完成,会给 CPU 发送一个硬中断来通知 CPU。收到这个硬中断之后会释放 RingBuffer 中使用的内存。

跨机接收

下面再看下跨机接收的过程,同样可以用一张图表示下面的过程。

Receive

当网卡收到数据之后,会向 CPU 发送一个中断,以通知 CPU 有数据到达。当 CPU 收到中断请求后,会去调用网络驱动注册的中断处理函数,触发软中断。内核的 ksoftrqd 检测到有软中断请求到达,开始轮询收包。收到之后交给各级别的协议栈处理。协议栈处理完并把数据放到接收队列之后,唤醒用户进程(假设以阻塞的方式)。

本机发送

本机发送的整体流程和跨机发送是类似的,收据都要被拷贝到内核态,经过协议栈的处理,并通过网卡发送(虚拟),这里对有差异的两个地方进行介绍,分别是网络层路由和驱动程序。

网络层路由

在发送数据的过程中,有一个过程是协议栈处理,当发送数据进入协议栈,到达网络层,会进行路由选择,IP 头设置,进行 netfiler 的过滤,然后向下交给邻居子系统。

本机发送的特殊之处就在于路由选择。在 Linux 中有一个专门用于管理本地地址的路由表叫做 local 路由表。对于本机网络 I/O,通过 local 路由表就能找到路由项,通过该路由表可以看出,对应的设备都将使用 loopback 网卡。

# ip route list table local # ip route show # 默认主路由表 # ip route list table all
$ ip route list table local broadcast 127.0.0.0 dev lo proto kernel scope link src 127.0.0.1 local 127.0.0.0/8 dev lo proto kernel scope host src 127.0.0.1 local 127.0.0.1 dev lo proto kernel scope host src 127.0.0.1 broadcast 127.255.255.255 dev lo proto kernel scope link src 127.0.0.1 broadcast 172.17.0.0 dev docker0 proto kernel scope link src 172.17.0.1 local 172.17.0.1 dev docker0 proto kernel scope host src 172.17.0.1 broadcast 172.17.255.255 dev docker0 proto kernel scope link src 172.17.0.1 broadcast 172.20.64.0 dev eth0 proto kernel scope link src 172.20.69.0 local 172.20.69.0 dev eth0 proto kernel scope host src 172.20.69.0 broadcast 172.20.79.255 dev eth0 proto kernel scope link src 172.20.69.0

这里对网络层路由的原理进行简单的介绍。

网络层的入口函数是 ip_queue_xmit, 其中 ip_route_output_ports 函数调用能够确定数据包应该通过哪个路由发送。

int ip_queue_xmit(struct sk_buff *skb, struct flowi *fl) { .... rt = (struct rtable *)__sk_dst_check(sk, 0); if (rt == NULL) { __be32 daddr; /* Use correct destination address if we have options. */ daddr = inet->inet_daddr; if (inet_opt && inet_opt->opt.srr) daddr = inet_opt->opt.faddr; /* If this fails, retransmit mechanism of transport layer will * keep trying until route appears or the connection times * itself out. */ rt = ip_route_output_ports(sock_net(sk), fl4, sk, daddr, inet->inet_saddr, inet->inet_dport, inet->inet_sport, sk->sk_protocol, RT_CONN_FLAGS(sk), sk->sk_bound_dev_if); if (IS_ERR(rt)) goto no_route; sk_setup_caps(sk, &rt->dst); } skb_dst_set_noref(skb, &rt->dst); ...... return -EHOSTUNREACH; }

ip_route_output_ports 之后会依次调用 ip_route_output_flow, __ip_route_output_keyfib_lookup.

fib_lookup 会在指定的网络命名空间中先查 local 路由表再查 main 路由表,一旦找到就返回__ip_route_output_key

static inline int fib_lookup(struct net *net, const struct flowi4 *flp, struct fib_result *res) { struct fib_table *table; table = fib_get_table(net, RT_TABLE_LOCAL); if (!fib_table_lookup(table, flp, res, FIB_LOOKUP_NOREF)) return 0; table = fib_get_table(net, RT_TABLE_MAIN); if (!fib_table_lookup(table, flp, res, FIB_LOOKUP_NOREF)) return 0; return -ENETUNREACH; }
/* * Major route resolver routine. */ struct rtable *__ip_route_output_key(struct net *net, struct flowi4 *fl4) { ... if (fib_lookup(net, fl4, &res)) { ... } if (res.type == RTN_LOCAL) { if (!fl4->saddr) { if (res.fi->fib_prefsrc) fl4->saddr = res.fi->fib_prefsrc; else fl4->saddr = fl4->daddr; } dev_out = net->loopback_dev; // 如果类型是 RTN_LOCAL 那么就使用 RTN_LOCAL fl4->flowi4_oif = dev_out->ifindex; flags |= RTCF_LOCAL; goto make_route; } ... make_route: rth = __mkroute_output(&res, fl4, orig_oif, dev_out, flags); out: rcu_read_unlock(); return rth; }

接下来,网络层的处理仍然和跨机网络 I/O 一样,最后进入到邻居子系统。

发送的时候,如果 skb 大于 MTU 本机网络也同样会进行 IP 分片,但是 lo 虚拟网卡的 MTU 一般要大很多, 通过 ifconfig 就可以看到这一点。

$ ifconfig eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500 inet 172.20.69.0 netmask 255.255.240.0 broadcast 172.20.79.255 inet6 fe80::216:3eff:fe26:e03e prefixlen 64 scopeid 0x20<link> ether 00:16:3e:26:e0:3e txqueuelen 1000 (Ethernet) RX packets 7964689 bytes 2663205743 (2.6 GB) RX errors 0 dropped 0 overruns 0 frame 0 TX packets 5706086 bytes 4389686195 (4.3 GB) 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 inet6 ::1 prefixlen 128 scopeid 0x10<host> loop txqueuelen 1000 (Local Loopback) RX packets 1524730 bytes 178364879 (178.3 MB) RX errors 0 dropped 0 overruns 0 frame 0 TX packets 1524730 bytes 178364879 (178.3 MB) TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0

本机 IP 路由

前面在分析路由层原理的时候说过,内核在ip层的处理过程中会通过相应的函数(__ip_route_output_key )确定选择哪个设备。

单看路由表我们可能会疑惑,但其实内核在设置 local 路由表的时候把所有的路由表项都设置成了 RTN_LOCAL。因此,即使使用的本机 IP 而不是 127.0.0.1,内核在路由表项查找的时候判断类型是 RTN_LOCAL,仍然会使用 lo 虚拟网卡。这里我们可以通过抓包进行验证。这里同时开启了三个窗口,上面的窗口分别对 eth0 设备和 lo 设备进行抓包,可以看到在 eth0 设备这边没有反应,在 lo 设备这边可以看到抓包的结果。

Cap 1

lo 设备驱动程序

在跨机发送的过程中,对于有队列的物理设备来说,要进行复杂的排队处理,然后才会进入驱动程序发送,但是对于启动状态的回环设备就简单多了。其中没有队列问题,直接进入回环设备驱动回调函数 loopback_xmit ,将 skb 发送出去, 具体的过程如下所示。

Loopback

首先将 skb 剥离出来,然后把要发送的skb加入 input_pkt_queue 队列,并触发软中断。在本机网络的发送过程中,传输层下面的 skb 就不需要释放了,会直接给接受方传过去。

本机接收

在跨机发送的过程中,需要经过硬中断然后才能触发软中断,而在本机网络I/O的过程中,由于并不经过真正的物理网卡,因此硬中断就省去了,直接从软中断开始。

在发送阶段的结尾我们看到 skb 会被链到 input_pkt_queue, 而在接收端会将其取下来并链搭配 sd->process_queue上去,最后再将数据送到协议栈,之后的处理过程就跟跨机网络 I/O 一致了。

小结

  • 127.0.0.1 本机网络 IO 是否需要经过网卡?

    • 通过上面的介绍,可以确定,本机网络 I/O 不需要经过网卡,即使把网卡拔了,本机网络 I/O 还是可以正常进行。

  • 本机网络 IO 和跨机网络 IO 收发流程上有没有不同?有什么不同?

    • 本机网络 I/O 和跨机相比能够节省驱动上的一些开销,发送数据不需要进入到 RingBuffer 的驱动队列,直接把 skb 传递给协议栈。其他的过程:系统调用,协议栈等和跨机发送类似。

  • 访问一个本机服务时,使用 127.0.0.1 和使用本机IP有没有区别?如果有区别,通过127.0.0.1是更快吗?

    • 使用 IP 访问本机网络和使用 127.0.0.1 没有区别,都是走的虚拟 lo 设备。内核设置 IP 的时候,会把所有的本机 IP 都初始化到 local 路由表中,将类型写死了是 RTN_LOCAL 。路由项选择的时候发现是类型是 RTN_LOCAL就会选择 lo 设备。

容器网络与虚拟化

现在很多服务并不是直接部署在物理机上,而是部署到基于 Docker 的容器云上,因此本节对容器网络和虚拟化进行一些简单的介绍,在介绍之前,我们依然先提出几个问题,最后在总结部分对这些问题进行解答。

  • 容器中 eth0 和宿主机上的 eth0 是一个东西吗?

  • veth 设备是什么,它是如何工作的?

  • Linux 是如何实现虚拟网络环境的?

  • Linux 如何保证同宿主机上的多个虚拟网络环境中的路由表可以独立的工作?

  • 同一个宿主机上的多个容器是如何进行通信的?

  • Linux 上的容器如何与外部的机器进行通信?

veth 设备对

本节介绍 Docker 网络虚拟化中最基础的技术—— veth。在物理机组成的网络中,最基础、最简单的网络连接方式就是使用一根交叉网线将两台机器的网卡连接起来。网络虚拟化就是用软件来模拟这个简单的网络连接过程。实现的技术就是本节要介绍的 veth 设备——它模拟了物理世界两块连接在一起的网卡,两个网卡之间是可以相互通信的。Docker 里面看到的 eth0 设备其实就是 veth。veth 设备和 lo 设备类似,都是用软件虚拟出来的设备,不同的是 veth 设备总是成双成对的出现。

在 Linux 下可以使用 ip 命令创建一对 veth。我们可以通过下面的命令创建并查看创建的 veth 设备。

ip link add veth0 type veth peer name veth1 ip link show
Veth

和其他的网络设备一样,veth 设备也需要配置 IP 之后才能使用,这里我们对其分配 IP, 然后启动对应的设备。

# 配置 ip 地址 ip addr add 192.168.1.1/24 dev veth0 ip addr add 192.168.1.2/24 dev veth1 # 启动设备 ip link set veth0 up ip link set veth1 up # ifconfig
Ifconfig

之后设置一些配置之后可以验证两个设备之间是可以通信的,配置如下,主要是关闭反向过滤 rp_filter, 打开 accept_local,接收本机 IP 数据包。

echo 0 > /proc/sys/net/ipv4/conf/all/rp_filter echo 0 > /proc/sys/net/ipv4/conf/veth0/rp_filter echo 0 > /proc/sys/net/ipv4/conf/veth1/rp_filter echo 1 > /proc/sys/net/ipv4/conf/veth1/accept_local echo 1 > /proc/sys/net/ipv4/conf/veth0/accept_local

测试通信的时候可以在 veth0 设备上 ping veth1, 并通过 tcpdump 抓包进行验证。具体过程如下,通过 ping 可以看到两个设备可以通信,通过右边的终端我们可以看到由于是首次通信 veth0 首先发出 arp request, veth1 收到之后返回 arp reply,之后就是正常的 ping 下的 ip 包。

Ping 1

基于 veth 的网络通信过程实际上和基于 lo 设备的是类似的,不同的仅仅是使用的驱动设备不同。

network namespace

上一节中介绍了 veth,通过它可以创建出许多的虚拟设备,它们默认都是在宿主机网络中的。接下来虚拟化还有重要的一步就是隔离。A 容器不能用到 B 容器的设备,这样才保证不同的容器复用硬件资源的同时,不会影响到其他容器的正常运行。

在 Linux 中实现隔离技术的是命名空间(namespace), 通过命名空间可以隔离容器的 PID、文件系统挂载点、主机名等多种资源。实现网络隔离的是网络命名空间,它可以为不同的命名空间从逻辑上提供独立的网络协议栈,具体包括:网络设备、路由表、arp表、iptables 以及套接字等,使得不同的网络空间好像运行在独立的网络中一样。一个虚拟的网络环境如下图所示:

Env

Linux 中的每个进程(task_struct)都要关联到一个命名空间对象(nsproxy),命名空间对象中包含了网络命名空间,而网卡设备和socket等通过成员表明自己的归属。具体关系如下图所示:

Namespace

那么网络命名空间它的结构又是什么样的呢,这里我们也可以通过下面的结构在展示,在每个net内核对象中都包含了自己的路由表,iptable 以及内核参数配置等,同时每个网络命名空间中都一个lo设备。

Net

Linux 上有一个默认的网络命名空间,1号进程使用这个默认的命名空间,其他进程从1号进程中派生出来,如果没有特殊指定,都将共享这个默认的网络空间。

那么网络收发的过程中是如何使用命名网络空间的呢?通过上面的结构图,以发送为例,大致的原理就是:socket 上记录了归属的网络命名空间,需要查找路由表之前先找到该命名空间,再在命名空间中找到路由表,然后执行查找路由表。

虚拟网桥 Bridge

上面的 Linux 的 veth 是一对能够相互链接,能够互相通信的虚拟网卡,通过使用它能够让 Docker 容器与宿主机通信,或者在两个 Docker 容器中通信。然而在实际中,一台物理机上可能有几个甚至几十个容器,这样带来的问题就是大量容器之间的网络互联,仅仅通过 veth 互联的办法是无法直接通信的。

回想下,在物理机网络环境中,多台不同的物理机是如何连接并互相通信的呢?这个时候我们就需要一个新的设备网桥或者以太网交换机,现在通常用的都是交换机。同一网路内的多台物理机通过交换机连在一起,这样它们之间就可以互相通信了。

在虚拟化的网络环境中,和物理网络中的交换机一样,也需要这样一个设备,一种思路也就是使用软件的方式来实现这个设备。这个软件需要有多个虚拟的端口,能够把虚拟网卡连接在一起,通过类似交换机的转发功能,实现这些虚拟网卡之间的通信。在 Linux 下,这个软件的实现的交换机叫做 Bridge,是一个虚拟网桥。这样各个容器都通过 Veth 连接到 Bridge 上,Bridge 负责在不同的 "端口"之间转发数据包,这样各个 Docker 容器之间就可以互相通信了。

有了这一处理原理之后,再来看看数据包的完整处理过程。前面最开始讲过,数据包会被先送到 RingBuffer 中,然后依次经过硬中断、软中断处理。在软中断中再依次把数据包送到设备层、协议栈,最后唤醒应用程序。但是,如果是 Veth 设备,并且连接在虚拟 Bridge 上,那么又会有所不同。具体来说,如果它连接在 Bridge 上,设备层的处理会有所不同,连接在 Bridge 上的 Veth 在接收到数据包的时候,并不会进入到协议栈,而是会进入到 Bridge 处理。在 Bridge 上会找到合适的转发端口(这个端口上是另一个 Veth),通过这个 Veth 将数据包转发出去,整个工作流程如下图所示。

Bridge 1

这里可以小结一下,网络虚拟化实际上就是用软件来模拟实现真是的物理网络连接。Linux 内核中的 Bridge 模拟实现了物理网络中的交换机。和物理设备类似,可以将虚拟设备插入 Bridge,和物理设备存在的区别是,一对 Veth 插入到 Bridge 的那端其实就不是设备了,而是退化为一个网线插头。当 Bridge 接入了多对 Veth 后,就可以通过自身实现的网络包转发功能来让不同的 Veth 之间相互通信。

这里以一个 Docker 的使用场景为例:

Bridge 2

大致的步骤如下:

  1. Docker 1 往 Veth1上发送数据

  2. 由于 Veth1_p 是 Veth1 的对端,因此整个虚拟设备上能够收到数据包。

  3. veth上收到收据包之后发现之际是连接在 Bridge 上的,于是进入到 Bridge 处理。在 Bridge 设备上寻找需要转发的端口,此时找到 Veth2_p 是对应的端口,开始发送,Bridge 就完成了自己的转发工作。

  4. Veth2 作为 veth2_p 的对端,收到了数据包。

  5. Docker2 就可以从 Veth2 上收到数据包了。

外部网络通信

通过前面的内容我们知道通过 veth,namespace 和虚拟网桥在一台 Linux 上就能虚拟出多个网络环境,并且不同的网络环境、宿主机之间都可以进行通信,那么还有一个问题就是,虚拟网络环境如何同外部网络通信,例如一个docker 容器里的服务如何访问外部数据库。要解决这个问题就是这一节将要介绍的内容路由和NAT技术。

Linux 不仅可以发送数据包、接收数据包,同时也能够对数据包进行路由。所谓路由就是选择哪张网卡(包括虚拟网卡)将数据写进去,具体选择哪张网卡,规则写在路由表中,通过命令 route -n可以查看路由表。一般来说,我们认为转发是路由器的工作,实际上 Linux 也是可以进行转发的,只不过默认情况下,转发的功能是关闭的,也就是发现目的地址不是本机IP地址的时候,默认会将包丢弃,要实现转发需要做一些配置才能使 Linux 像路由器一样工作。

这里介绍下 Linux 转发涉及到的技术 iptables 以及 NAT:

Linux 内核网络协议栈为了迎合一些用户层的需求,内核开放了一些口子供用户层进行干预,其中 iptables 就是其中的一个干预内核行为的工具,它在内核埋下了五个Hook的入口,并提供了四张表,也就是通常说的 iptables 的四链五表。

Iptables

Linux 在接收数据的时 候,在 IP 层 进 入 ip_rcv 中处理。 再执行路由判 断, 发现是本 机的话就进入 ip_local_deliver 进行本机接收, 最后送往 TCP 协议层。在这个过程中埋了两个 Hook,一个是 PREROUTING,另一个是 INPUT。

发送数据的时候,找到出口设备的后,通过 __ip_local_out、ip_output 等函数将包送到设备层,中间分别过了 OUTPUT 和 POSTROUTING 规则。

转发的过程中,如果发现包不是本机,就会查找路由表通过合适的设备转发出去,分别需要经过PREROUTING、FORWARD 和 POSTROUTING 中的规则。

iptables 中间根据功能的不同又分成了四张表,其中涉及到转发规则的就是 NAT 表。

对于发送过程,假设容器中发了一个数据包,这个数据包从 veth 会被送到 bridge 网桥,通过配置相应的规则,网桥上的数据包可以转发到 eth0 网卡上。但是外部的机器并不认识这个网段的 IP,回想先我们局域网中的机器没有外网 IP 的时候是怎么做的的,其实应用的就是 NAT 转换技术。这里实现虚拟网络反问外网 IP 用的也是 NAT 技术,我们需要下面的这样一条 iptables 规则。

iptables -t nat -A POSTROUTING -s 192.168.0.0/24 ! -O br0 -j MASQUWRADE

这条命令用来配置了 iptables 的NAT规则。具体来说,它会将源IP地址为192.168.0.0/24的流量进行地址转换,从而允许这些流量通过其他网络接口(除了br0)。

对于数据的接收过程也是类似的,唯一一个有不同的地方就是需要明确的指定容器中的端口与宿主机上端口之间的对应关系。

小结

到此我们就介绍了容器中网络相关的知识,简单总结起来就是 veth 实现连接,Bridge 实现转发,网络命名空间实现隔离,路由表控制发送时的设备,iptables 实现 NAT 功能。

最后我们再来回顾下,在介绍容器网络开头提出的那几个问题:

  • 容器中的 eth0 和 宿主机上的 eth0 是不是一个东西?

    • 这里答案显然不是。每个容器中的设备都是独立的。Linux 物理机上的 eth0 是真正的网卡,有网线接口。容器中的 eth0 是一个虚拟设备,它是虚拟设备 veth 设备对中的一头,和回环设备 lo 类似,它以纯软件的方式工作。容器中的网卡也命名成 eth0 可能是出于让容器和物理机更像的目的。

  • veth 设备是什么,它是如何工作的?

    • veth 设备和 lo 回环设备类似,是纯软件的概念。每次创建 veth 都会创建出两个虚拟的网络接口,类似于一根虚拟的交叉网线连接两端的两个接口,在 veth 的一头发送数据,另一头就可以接收到。它是容器与容器,容器与宿主机通信的基础。

  • Linux 是如何实现虚拟网络环境的。

    • Linux 实现隔离的基础是 namespace。在默认的情况下,Linux 就存在一个网络命名空间,在内核中它叫做 init_net, 网络命名空间中有自己的路由表,iptable 以及其他的内核参数。Linux 支持创建新的网络命名空间。Linux 的每个进程记录了自己的命名空间归属。veth 设备、socket 也都有自己的网络空间归属,并且命名空间是可以修改的。这样每个命名空间中就有了自己的独立进程,虚拟网卡设备、socket、路由表、iptables 等,从而实现了网络的隔离。

  • Linux 如何保证同宿主机上的多个虚拟网络环境中的路由表可以独立的工作?

    • 不管有没有一个新的网络命名空间,Linux 网络收发包的流程都是一样的。只不过在涉及到特定的网络命名空间相关的逻辑的时候需要先查找到表示命名空间的net对象。例如对于路由来说,内核根据socket找到net对象,然后找到命名空间中的路由表,然后执行查找。如果没有创建新的路由表,使用的就是默认命名空间中的路由规则。

  • 同一个宿主机上的多个容器是如何进行通信的?

    • 在物理机的网络环境中吗,多台不同的物理机之间通过交换节连接在一起,进而实现通信。在Linux 下也是类似的:Bridge 用软件模拟了交换机,它也有插口的概念,多个虚拟设连接在 Bridge 上。Bridge 工作在内核网络栈的第二层,可以在不同的插口之间转发数据包。

  • Linux 上的容器如何与外部的机器进行通信?

    • 使用 veth、Bridge、网络命名空间搭建的虚拟网络环境只能在宿主机内部进行通信,因为其无法其私有 IP 无法被外网认识,需要采用路由表控制以及NAT转换使得虚拟网络通过宿主机的网卡和外部机器进行通信。

Last modified: 30 August 2024