容器网络

很大一部分网络参数都在 /proc 文件系统下的 /proc/sys/net/ 目录里,修改这些参数主要有两种方法:

  1. 直接到 /proc 文件系统下的 "/proc/sys/net/" 目录里对参数做修改

  2. 使用 sysctl 工具来修改

同样的容器内也有这些网络参数,但是没有 privileged 权限的普通容器内是无法修改这些内核参数的,因为容器内的 /proc/sys 是read-only mount。

通常容器已经启动的话,很多 TCP 连接已经建立,即使修改参数也不会生效,因此正确的修改时机是在容器启动的时候runC 的 sysctl 参数修改接口,允许容器在启动时修改容器 Namespace 里的参数,因为无论是 Docker 或 Containerd 都是调用的 runC 在Linux 中把容器启动起来。

  • Docker:使用 --sysctl 参数,如 docker run -d --name net_para --sysctl net.ipv4.tcp_keepalive_time=600 centos:8.1.1911 sleep 3600

  • Kubernetes:使用 allowed-unsafe-sysctls 特性

在容器内能看到的网络参数都是 network namespace 下,而不是宿主机级别的。runC 只允许修改 namesapce 下的参数,不会修改宿主机级别的参数。

Network Namespace

Network Namespace 负责管理 Linux 节点上网络环境的隔离,主要包括:

  1. 网络设备,这里指的是 lo,eth0 等网络设备,通过 ip link 命令看到它们。

  2. IPv4/IPv6 协议栈,也就是说 IP 层以及上面的 TCP 和 UDP 协议栈也是每个 Namespace 独立工作的。所以 IP、TCP、UDP 的很多协议,它们的相关参数也是每个 Namespace 独立的,这些参数大多数都在 /proc/sys/net/ 目录下面,同时也包括了 TCP 和 UDP 的 port 资源。

  3. IP 路由表,在不同的 Network Namespace 运行 ip route 命令,就能看到不同的路由表。

  4. 防火墙规则,也就是 iptables 规则,每个 Namespace 里都可以独立配置 iptables 规则。

  5. 网络状态信息,这些信息可以从 /proc/net/sys/class/net 里得到,这里的状态基本上包括了前面 4 种资源的状态信息。

**通过系统调用 clone() 或者 unshare() 这两个函数可以建立新的 Network Namespace。**具体的操作如下:

  1. 在新的进程创建的时候,伴随新进程建立,同时也建立出新的 Network Namespace。通过 clone() 系统调用带上 CLONE_NEWNET flag 来实现的,如 clone(new_netns, stack + STACK_SIZE, CLONE_NEWNET | SIGCHLD, NULL)Clone() 建立出来一个新的进程,这个新的进程所在的 Network Namespace 也是新的。

  2. 调用 unshare() 系统调用来直接改变当前进程的 Network Namespace,如 unshare(CLONE_NEWNET)

容器网络接口

如下图所示:

  • 容器有自己的 Network Namespace,eth0 是这个 Network Namespace 里的网络接口。

  • 宿主机也有自己的 eth0,这是真正的物理网卡,可以和外面通讯。

要让容器 Network Namespace 中的数据包最终发送到物理网卡上,大致应该包括这两步:

  1. 让数据包从容器的 Network Namespace 发送到 Host Network Namespace 上。

  2. 数据包发到了 Host Network Namespace 之后,还要解决数据包怎么从宿主机上的 eth0 发送出去的问题。

数据包从容器到宿主机

Docker 网络文档或者 Kubernetes 网络文档中介绍了很多种容器网络配置的方式。对于容器从自己的 Network Namespace 连接到 Host Network Namespace 的方法,一般来说就只有两类设备接口:

  1. veth:是一个虚拟的网络设备,一般都是成对创建,而且这对设备是相互连接的。当每个设备在不同的 Network Namespaces 的时候,Namespace 之间就可以用这对 veth 设备来进行网络通讯。这是使用最多的方式,Docker 启动容器缺省的网络接口,主要通过 ip netns 命令对 network namespace 操作。

  2. macvlan/ipvlan:无论是 macvlan 还是 ipvlan,都是在一个物理的网络接口上再配置几个虚拟的网络接口。在这些虚拟的网络接口上,都可以配置独立的 IP,并且这些 IP 可以属于不同的 Namespace。容器的虚拟网络接口,直接连接在了宿主机的物理网络接口上了,形成了一个网络二层的连接。

    1. macvlan:每个虚拟网络接口都有自己独立的 mac 地址

    2. ipvlan :虚拟网络接口是和物理网络接口共享同一个 mac 地址

数据包从宿主机 eth0 发送出去

这就是一个普通 Linux 节点上数据包转发的问题。这里解决问题的方法有很多种:

  1. 用 nat 来做个转发

  2. 建立 Overlay 网络发送

  3. 通过配置 proxy arp 加路由的方法来实现

考虑到网络环境的配置,Docker 缺省使用的是 bridge + nat 的转发方式, 对于其他的配置方法,可以查阅 Docker 或者 Kubernetes 相关的文档。

Docker 程序在节点上安装完之后,就会自动建立了一个 docker0 的 bridge interface。所以 veth_host 这个设备会接入到 docker0 这个 bridge 上,这样容器和 docker0 组成了一个子网,docker0 上的 IP 就是这个子网的网关 IP。要让子网通过宿主机上 eth0 去访问外网,加上 iptables 的规则(iptables -P FORWARD ACCEPT)同时配置两个网络设备接口 docker0 和 eth0 之间的数据包转发(echo 1 > /proc/sys/net/ipv4/ip_forward)就可以了。

容器网络接口对网络的延时的影响

从 veth 的这种网络接口配置上看,一个数据包要从容器里发送到宿主机外,需要先从容器里的 eth0 (veth_container) 把包发送到宿主机上 veth_host,然后再在宿主机上通过 nat 或者路由的方式,经过宿主机上的 eth0 向外发送。

这种容器向外发送数据包的路径,相比宿主机上直接向外发送数据包的路径,很明显要多了一次接口层的发送和接收。尽管 veth 是虚拟网络接口,在软件上还是会增加一些开销。

虽然 veth 是一个虚拟的网络接口,但是在接收数据包的操作上,这个虚拟接口和真实的网络接口并没有太大的区别。除了没有硬件中断的处理,其他操作都差不多,特别是软中断(softirq)的处理部分其实就和真实的网络接口是一样的,可以通过阅读 Linux 内核里的 veth 的驱动代码(drivers/net/veth.c)确认。

在处理网络数据时,一些运行时间较长且不能在硬中断中处理的工作,就会通过 softirq 来处理。一般在硬件中断处理结束之后,网络 softirq 的函数才会再去执行没有完成的包的处理工作。即使这里 softirq 的执行速度很快,还是会带来额外的开销。

由于 veth 接口是成对工作,在对外发送数据的时候,peer veth 接口都会 raise softirq 来完成一次收包操作,这样就会带来数据包处理的额外开销。

如果要减小容器网络延时,就可以给容器配置 ipvlan/macvlan 的网络接口来替代 veth 网络接口。Ipvlan/macvlan 直接在物理网络接口上虚拟出接口,在发送对外数据包的时候可以直接通过物理接口完成,没有节点内部类似 veth 的那种 softirq 的开销。容器使用 ipvlan/maclan 的网络接口,它的网络延时可以非常接近物理网络接口的延时。

**所以,根据 veth 这个虚拟网络设备的实现方式,可以看到它必然会带来额外的开销,这样就会增加数据包的网络延时。**对于网络延时敏感的应用程序,可以考虑使用 ipvlan/macvlan 的容器网络配置方式来替换缺省的 veth 网络配置。

对于延时敏感的应用程序,我们可以考虑使用 ipvlan/macvlan 网络接口的容器。不过,由于 ipvlan/macvlan 网络接口直接挂载在物理网络接口上,对于需要使用 iptables 规则的容器,比如 Kubernetes 里使用 service 的容器,就不能工作了。这就需要你结合实际应用的需求做个判断,再选择合适的方案。

容器中网络乱序包

网络中发生数据包的重传,有可能是数据包在网络中丢失,也有可能是数据包乱序导致的。

最直接的方法就是用 tcpdump 去抓包,不过对于大流量的网络,瞬间就会有几个 GB 的数据。这样会带来额外的系统开销,特别是在生产环境中这个方法也不太好用。

所以更简单的方法是运行 netstat 命令来查看协议栈中的丢包和重传的情况。

TCP 快速重传

运行 netstat 命令后,查看“retransmit”,能看到有快速重传(fast retransmit)的次数。

nsenter -t 51598 -n netstat -s | grep retran
    454 segments retransmited
    442 fast retransmits

TCP 协议里,发送端(sender)向接受端(receiver)发送一个数据包,接受端(receiver)都回应 ACK。如果超过一个协议栈规定的时间(RTO),发送端没有收到 ACK 包,那么发送端就会重传(Retransmit)数据包。这样等待一个超时之后再重传数据,对于实际应用来说太慢了,所以 TCP 协议定义了快速重传 (fast retransmit)的概念。它的基本定义是这样的:如果发送端收到 3 个重复的 ACK,那么发送端就可以立刻重新发送 ACK 对应的下一个数据包。

如下图所示,接受端没有收到 Seq 2 这个包,但是收到了 Seq 3–5 的数据包,那么接收端在回应 Ack 的时候,Ack 的数值只能是 2。这是因为按顺序来说收到 Seq 1 的包之后,后面 Seq 2 一直没有到,所以接收端就只能一直发送 Ack 2。那么当发送端收到 3 个重复的 Ack 2 后,就可以马上重新发送 Seq 2 这个数据包了,而不用再等到重传超时之后了。

虽然 TCP 快速重传的标准定义是需要收到 3 个重复的 Ack,但在 Linux 中常常收到一个 Dup Ack(重复的 Ack)后,就马上重传数据了。因为 SACK 也就是选择性确认(Selective Acknowledgement),与普通的 ACK 相比呢,SACK 会把接收端收到的所有包的序列信息,都反馈给发送端。对于发送端来说,在收到 SACK 之后就已经知道接收端收到了哪些数据,没有收到哪些数据。

在 Linux 内核中会有个判断,如果在接收端收到的数据和还没有收到的数据之间,两者数据量差得太大的话(超过了 reordering*mss_cache),就可以马上重传数据。这里的数据量差是根据 bytes 来计算的,而不是按照包的数目来计算的,所以即使只收到一个 SACK,Linux 也可以重发数据包。

运行 netstat 命令后,查看"reordering",就可以看到大量的 SACK 发现的乱序包。

nsenter -t 51598 -n netstat -s  | grep reordering
    Detected reordering 501067 times using SACK

在云平台的网络环境里,网络包乱序 +SACK 之后,产生的数据包重传的量要远远高于网络丢包引起的重传。

veth 接口的数据包发送

网络包乱序会造成数据包的重传,容器的 veth 接口配置可能会引起数据包的乱序:

  1. 通过 veth 接口从容器向外发送数据包,会触发 peer veth 设备去接收数据包,这个接收的过程就是一个网络的 softirq 的处理过程。

  2. 在触发 softirq 之前,veth 接口会模拟硬件接收数据的过程,通过 enqueue_to_backlog() 函数把数据包放到某个 CPU 对应的数据包队列里(softnet_data)。

  3. 在缺省的状况下(也就是没有 RPS 的情况下),enqueue_to_backlog() 把数据包放到了“当前运行的 CPU”(get_cpu())对应的数据队列中。如果是从容器里通过 veth 对外发送数据包,那么这个“当前运行的 CPU”就是容器中发送数据的进程所在的 CPU。

对于多核的系统,这个发送数据的进程可以在多个 CPU 上切换运行。进程在不同的 CPU 上把数据放入队列并且 raise softirq 之后,因为每个 CPU 上处理 softirq 是个异步操作,所以两个 CPU network softirq handler 处理这个进程的数据包时,处理的先后顺序并不能保证。所以,veth 对的这种发送数据方式增加了容器向外发送数据出现乱序的几率。

RSS 和 RPS

veth 接口模拟硬件接收数据的过程,在调用 enqueue_to_backlog() 的时候,传入的 CPU 并不是当前运行的 CPU,而是通过 get_rps_cpu() 得到的 CPU,这个 RSS 不是内存 RSS,而是和网卡硬件相关的一个概念。

RSS 是 Receive Side Scaling 的缩写。

现在的网卡性能越来越强劲,从原来一条 RX 队列扩展到了 N 条 RX 队列,而网卡的硬件中断也从一个硬件中断,变成了每条 RX 队列都会有一个硬件中断。

每个硬件中断可以由一个 CPU 来处理,对于多核的系统,多个 CPU 可以并行的接收网络包,这样就大大地提高了系统的网络数据的处理能力。

在网卡硬件中,可以根据数据包的 4 元组或者 5 元组信息来保证同一个数据流,比如一个 TCP 流的数据始终在一个 RX 队列中,这样也能保证同一流不会出现乱序的情况。

RSS 的实现在网卡硬件和驱动里面,而 RPS(Receive Packet Steering)就是在软件层面实现类似的功能。它主要实现的代码框架就在 netif_rx_internal() 代码里。

如下图所示:

  1. 在硬件中断后,CPU2 收到了数据包,再一次对数据包计算一次四元组的 hash 值,得到这个数据包与 CPU1 的映射关系。

  2. 接着会把这个数据包放到 CPU1 对应的 softnet_data 数据队列中,同时向 CPU1 发送一个 IPI 的中断信号。这样一来,后面 CPU1 就会继续按照 Netowrk softirq 的方式来处理这个数据包了。

RSS(工作在网卡的硬件层)RPS(工作在 Linux 内核的软件层) 的目的都是把数据包分散到更多的 CPU 上进行处理,使得系统有更强的网络包处理能力。在把数据包分散到各个 CPU 时,保证了同一个数据流在一个 CPU 上,这样就可以减少包的乱序。

如果对应的 veth 接口上打开了 RPS 的配置,那么对于同一个数据流,就可以始终选择同一个 CPU 了。

/sys 目录下,在网络接口设备接收队列中修改队列里的 rps_cpus (是一个 16 进制的数,每个 bit 代表一个 CPU) 的值,就可以开启 RPS。

比如说,在一个 12 个 CPU 的节点上,想让 host 上的 veth 接口在所有的 12 个 CPU 上,都可以通过 RPS 重新分配数据包。那么就可以执行下面这段命令:

cat /sys/devices/virtual/net/veth57703b6/queues/rx-0/rps_cpus
000

echo fff > /sys/devices/virtual/net/veth57703b6/queues/rx-0/rps_cpus
cat /sys/devices/virtual/net/veth57703b6/queues/rx-0/rps_cpus
fff

RPS 的配置会带来额外的系统开销,在某些网络环境中会引起 softirq CPU 使用率的增大。那接口要不要打开 RPS 呢?这个问题需要根据实际情况来做个权衡。在实际应用的时候,对于物理网络接口,如果已经有了RSS的情况,一般就不需要再打开RPS了。

同时还要注意,TCP 的乱序包,并不一定都会产生数据包的重传。想要减少网络数据包的重传,还可以考虑协议栈中其他参数的设置,比如 /proc/sys/net/ipv4/tcp_reordering

Last updated