Always keep a beginner's mind, don't forget t|

Blue Mountain

园龄:10年7个月粉丝:572关注:0

2024-04-19 10:14阅读: 58评论: 0推荐: 0

《容器实战高手课》 容器网络—— 小记随笔

容器网络:我修改了/proc/sys/net下的参数,为什么在容器中不起效?

在容器中运行的应用程序,如果需要用到 tcp/ip 协议栈的话,常常需要修改一些网络参数(内核中网络协议栈的参数)。很大一部分网络参数都在 /proc 文件系统下的/proc/sys/net/目录里。

修改这些参数主要有两种方法:一种方法是直接到 /proc 文件系统下的"/proc/sys/net/"目录里对参数做修改;还有一种方法是使用sysctl这个工具来修改。

# # The default value:
# cat /proc/sys/net/ipv4/tcp_congestion_control
cubic
# cat /proc/sys/net/ipv4/tcp_keepalive_time
7200
# cat /proc/sys/net/ipv4/tcp_keepalive_intvl
75
# cat /proc/sys/net/ipv4/tcp_keepalive_probes
9
# # To update the value:
# echo bbr > /proc/sys/net/ipv4/tcp_congestion_control
# echo 600 > /proc/sys/net/ipv4/tcp_keepalive_time
# echo 10 > /proc/sys/net/ipv4/tcp_keepalive_intvl
# echo 6 > /proc/sys/net/ipv4/tcp_keepalive_probes
#
# # Double check the value after update:
# cat /proc/sys/net/ipv4/tcp_congestion_control
bbr
# cat /proc/sys/net/ipv4/tcp_keepalive_time
600
# cat /proc/sys/net/ipv4/tcp_keepalive_intvl
10
# cat /proc/sys/net/ipv4/tcp_keepalive_probes
6

然后我们启动一个容器, 再来查看一下容器里这些参数的值。你可以先想想,容器里这些参数的值会是什么?我最初觉得容器里参数值应该会继承宿主机 Network Namesapce 里的值,实际上是不是这样呢?

tcp_congestion_control 的值是 bbr,和宿主机 Network Namespace 里的值是一样的,而其他三个 tcp keepalive 相关的值,都不是宿主机 Network Namespace 里设置的值,而是原来系统里的缺省值了

知识详解

如何理解 Network Namespace?

Namespace 有一个段简短的描述,在里面就列出了最主要的几部分资源,它们都是通过 Network Namespace 隔离的。

  • 第一种,网络设备,这里指的是 lo,eth0 等网络设备。你可以通过 ip link命令看到它们。

  • 第二种是 IPv4 和 IPv6 协议栈。从这里我们可以知道,IP 层以及上面的 TCP 和 UDP 协议栈也是每个 Namespace 独立工作的。

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

  • 第三种,IP 路由表,这个资源也是比较好理解的,你可以在不同的 Network Namespace 运行 ip route 命令,就能看到不同的路由表了。

  • 第四种是防火墙规则,其实这里说的就是 iptables 规则了,每个 Namespace 里都可以独立配置 iptables 规则。

  • 最后一种是网络的状态信息,这些信息你可以从 /proc/net 和 /sys/class/net 里得到,这里的状态基本上包括了前面 4 种资源的的状态信息。

Namespace 的操作

那我们怎么建立一个新的 Network Namespace 呢?我们可以通过系统调用 clone() 或者 unshare() 这两个函数来建立新的 Network Namespace。

  1. 第一种方法呢,是在新的进程创建的时候,伴随新进程建立,同时也建立出新的 Network Namespace。这个方法,其实就是通过 clone() 系统调用带上 CLONE_NEWNET flag 来实现的。
int new_netns(void *para)
{
printf("New Namespace Devices:\n");
system("ip link");
printf("\n\n");
sleep(100);
return 0;
}
int main(void)
{
pid_t pid;
printf("Host Namespace Devices:\n");
system("ip link");
printf("\n\n");
pid =
clone(new_netns, stack + STACK_SIZE, CLONE_NEWNET | SIGCHLD, NULL);
if (pid == -1)
errExit("clone");
if (waitpid(pid, NULL, 0) == -1)
errExit("waitpid");
return 0;
}

第二种方法呢,就是调用 unshare() 这个系统调用来直接改变当前进程的 Network Namespace,你可以看一下这段代码。

int main(void)
{
pid_t pid;
printf("Host Namespace Devices:\n");
system("ip link");
printf("\n\n");
if (unshare(CLONE_NEWNET) == -1)
errExit("unshare");
printf("New Namespace Devices:\n");
system("ip link");
printf("\n\n");
return 0;
}

其实呢,不仅是 Network Namespace,其它的 Namespace 也是通过 clone() 或者 unshare() 系统调用来建立的。

而创建容器的程序,比如runC也是用 unshare() 给新建的容器建立 Namespace 的。这里我简单地说一下 runC 是什么,我们用 Docker 或者 containerd 去启动容器,最后都会调用 runC 在 Linux 中把容器启动起来。

在 Network Namespace 创建好了之后呢,我们可以在宿主机上运行 lsns -t net 这个命令来查看系统里已有的 Network Namespace。当然,lsns也可以用来查看其它 Namespace。

用 lsns 查看已有的 Namespace 后,我们还可以用 nsenter 这个命令进入到某个 Network Namespace 里,具体去查看这个 Namespace 里的网络配置。

# ./clone-ns &
[1] 7732
# Host Namespace Devices:
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP mode DEFAULT group default qlen 1000
link/ether 74:db:d1:80:54:14 brd ff:ff:ff:ff:ff:ff
3: docker0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN mode DEFAULT group default
link/ether 02:42:0c:ff:2b:77 brd ff:ff:ff:ff:ff:ff
New Namespace Devices:
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
# lsns -t net
NS TYPE NPROCS PID USER NETNSID NSFS COMMAND
4026531992 net 283 1 root unassigned /usr/lib/systemd/systemd --switched-root --system --deserialize 16
4026532241 net 1 7734 root unassigned ./clone-ns
# nsenter -t 7734 -n ip addr
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

解决问题

static int __net_init tcp_sk_init(struct net *net)
{
net->ipv4.sysctl_tcp_keepalive_time = TCP_KEEPALIVE_TIME;
net->ipv4.sysctl_tcp_keepalive_probes = TCP_KEEPALIVE_PROBES;
net->ipv4.sysctl_tcp_keepalive_intvl = TCP_KEEPALIVE_INTVL;
/* Reno is always built in */
if (!net_eq(net, &init_net) &&
try_module_get(init_net.ipv4.tcp_congestion_control->owner))
net->ipv4.tcp_congestion_control = init_net.ipv4.tcp_congestion_control;
else
net->ipv4.tcp_congestion_control = &tcp_reno;
}

在函数tcp_sk_init() 里,tcp_keepalive 的三个参数都是重新初始化的,而 tcp_congestion_control 的值是从 Host Namespace 里复制过来的。

我们可以启动一个普通的容器,这里的“普通”呢,我指的不是"privileged"的那种容器,也就是在这个容器中,有很多操作都是不允许做的,比如 mount 一个文件系统。这个 privileged 容器概念,那么在启动完一个普通容器后,我们尝试一下在容器里去修改"/proc/sys/net/"下的参数。这时候你会看到,容器中"/proc/sys/"是只读 mount 的,那么在容器里是不能修改"/proc/sys/net/"下面的任何参数了。

那我们应该怎么来修改容器中 Network Namespace 的网络参数呢?

当然,如果你有宿主机上的 root 权限,最简单粗暴的方法就是用我们之前说的"nsenter"工具,用它修改容器里的网络参数的。不过这个方法在生产环境里显然是不会被允许的,因为我们不会允许用户拥有宿主机的登陆权限。

其次呢,一般来说在容器中的应用已经启动了之后,才会做这样的修改。也就是说,很多 tcp 链接已经建立好了,那么即使新改了参数,对已经建立好的链接也不会生效了。这就需要重启应用,我们都知道生产环境里通常要避免应用重启,那这样做显然也不合适。

通过刚刚的排除法,我们推理出了网络参数修改的“正确时机”:想修改 Network Namespace 里的网络参数,要选择容器刚刚启动,而容器中的应用程序还没启动之前进行。其实,runC 也在对 /proc/sys 目录做 read-only mount 之前,预留出了修改接口,就是用来修改容器里 "/proc/sys"下参数的,同样也是 sysctl 的参数

而 Docker 的–sysctl或者 Kubernetes 里的allowed-unsafe-sysctls特性也都利用了 runC 的 sysctl 参数修改接口,允许容器在启动时修改容器 Namespace 里的参数。比如,我们可以试一下 docker –sysctl,这时候我们会发现,在容器的 Network Namespace 里,/proc/sys/net/ipv4/tcp_keepalive_time 这个网络参数终于被修改了!

# docker run -d --name net_para --sysctl net.ipv4.tcp_keepalive_time=600 centos:8.1.1911 sleep 3600
7efed88a44d64400ff5a6d38fdcc73f2a74a7bdc3dbc7161060f2f7d0be170d1
# docker exec net_para cat /proc/sys/net/ipv4/tcp_keepalive_time
600

重点总结

img

容器网络配置(1):容器网络不通了要怎么调试?

基本概念

img

我们要让容器 Network Namespace 中的数据包最终发送到物理网卡上,需要完成哪些步骤呢?从图上看,我们大致可以知道应该包括这两步。

  1. 第一步,就是要让数据包从容器的 Network Namespace 发送到 Host Network Namespace 上。

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

不过对于容器从自己的 Network Namespace 连接到 Host Network Namespace 的方法,一般来说就只有两类设备接口:一类是veth,另外一类是 macvlan/ipvlan。

veth

那什么是 veth 呢?为了方便你更好地理解,我们先来模拟一下 Docker 为容器建立 eth0 网络接口的过程,动手操作一下,这样呢,你就可以很快明白什么是 veth 了。

对于这个模拟操作呢,我们主要用到的是ip netns 这个命令,通过它来对 Network Namespace 做操作。

首先,我们先启动一个不带网络配置的容器,和我们之前的命令比较,主要是多加上了"--network none"参数。我们可以看到,这样在启动的容器中,Network Namespace 里就只有 loopback 一个网络设备,而没有了 eth0 网络设备了

# docker run -d --name if-test --network none centos:8.1.1911 sleep 36000
cf3d3105b11512658a025f5b401a09c888ed3495205f31e0a0d78a2036729472
# docker exec -it if-test ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever

完成刚才的设置以后,我们就在这个容器的 Network Namespace 里建立 veth,你可以执行一下后面的这个脚本。

pid=$(ps -ef | grep "sleep 36000" | grep -v grep | awk '{print $2}')
echo $pid
ln -s /proc/$pid/ns/net /var/run/netns/$pid
# Create a pair of veth interfaces
ip link add name veth_host type veth peer name veth_container
# Put one of them in the new net ns
ip link set veth_container netns $pid
# In the container, setup veth_container
ip netns exec $pid ip link set veth_container name eth0
ip netns exec $pid ip addr add 172.17.1.2/16 dev eth0
ip netns exec $pid ip link set eth0 up
ip netns exec $pid ip route add default via 172.17.0.1
# In the host, set veth_host up
ip link set veth_host up

首先呢,我们先找到这个容器里运行的进程"sleep 36000"的 pid,通过 "/proc/$pid/ns/net"这个文件得到 Network Namespace 的 ID,这个 Network Namespace ID 既是这个进程的,也同时属于这个容器。

然后我们在"/var/run/netns/"的目录下建立一个符号链接,指向这个容器的 Network Namespace。完成这步操作之后,在后面的"ip netns"操作里,就可以用 pid 的值作为这个容器的 Network Namesapce 的标识了。

接下来呢,我们用 ip link 命令来建立一对 veth 的虚拟设备接口,分别是 veth_container 和 veth_host。从名字就可以看出来,veth_container 这个接口会被放在容器 Network Namespace 里,而 veth_host 会放在宿主机的 Host Network Namespace。

所以我们后面的命令也很好理解了,就是用 ip link set veth_container netns $pid 把 veth_container 这个接口放入到容器的 Network Namespace 中。

再然后我们要把 veth_container 重新命名为 eth0,因为这时候接口已经在容器的 Network Namesapce 里了,eth0 就不会和宿主机上的 eth0 冲突了。

最后对容器内的 eht0,我们还要做基本的网络 IP 和缺省路由配置。因为 veth_host 已经在宿主机的 Host Network Namespace 了,就不需要我们做什么了,这时我们只需要 up 一下这个接口就可以了。

那刚才这些操作完成以后,我们就建立了一对 veth 虚拟设备接口。我给你画了一张示意图,图里直观展示了这对接口在容器和宿主机上的位置。

img

现在,我们再来看看 veth 的定义了,其实它也很简单。veth 就是一个虚拟的网络设备,一般都是成对创建,而且这对设备是相互连接的。当每个设备在不同的 Network Namespaces 的时候,Namespace 之间就可以用这对 veth 设备来进行网络通讯了。

比如说,你可以执行下面的这段代码,试试在 veth_host 上加上一个 IP,172.17.1.1/16,然后从容器里就可以 ping 通这个 IP 了。这也证明了从容器到宿主机可以利用这对 veth 接口来通讯了。

# ip addr add 172.17.1.1/16 dev veth_host
# docker exec -it if-test ping 172.17.1.1
PING 172.17.1.1 (172.17.1.1) 56(84) bytes of data.
64 bytes from 172.17.1.1: icmp_seq=1 ttl=64 time=0.073 ms
64 bytes from 172.17.1.1: icmp_seq=2 ttl=64 time=0.092 ms
^C
--- 172.17.1.1 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 30ms
rtt min/avg/max/mdev = 0.073/0.082/0.092/0.013 ms

好了,这样我们完成了第一步,通过一对 veth 虚拟设备,可以让数据包从容器的 Network Namespace 发送到 Host Network Namespace 上。那下面我们再来看第二步, 数据包到了 Host Network Namespace 之后呢,怎么把它从宿主机上的 eth0 发送出去?

其实这一步呢,就是一个普通 Linux 节点上数据包转发的问题了。这里我们解决问题的方法有很多种,比如说用 nat 来做个转发,或者建立 Overlay 网络发送,也可以通过配置 proxy arp 加路由的方法来实现。

因为考虑到网络环境的配置,同时 Docker 缺省使用的是 bridge + nat 的转发方式, 那我们就在刚才讲的第一步基础上,再手动实现一下 bridge+nat 的转发方式。对于其他的配置方法,你可以看一下 Docker 或者 Kubernetes 相关的文档。

Docker 程序在节点上安装完之后,就会自动建立了一个 docker0 的 bridge interface。所以我们只需要把第一步中建立的 veth_host 这个设备,接入到 docker0 这个 bridge 上。

这里我要提醒你注意一下,如果之前你在 veth_host 上设置了 IP 的,就需先运行一下"ip addr delete 172.17.1.1/16 dev veth_host",把 IP 从 veth_host 上删除。

# ip addr delete 172.17.1.1/16 dev veth_host
ip link set veth_host master docker0

这个命令执行完之后,容器和宿主机的网络配置就会发生变化,这种配置是什么样呢?你可以参考一下面这张图的描述。

img

从这张示意图中,我们可以看出来,容器和 docker0 组成了一个子网,docker0 上的 IP 就是这个子网的网关 IP。

如果我们要让子网通过宿主机上 eth0 去访问外网的话,那么加上 iptables 的规则就可以了,也就是下面这条规则。

iptables -P FORWARD ACCEPT

好了,进行到这里,我们通过 bridge+nat 的配置,似乎已经完成了第二步——让数据从宿主机的 eth0 发送出去。那么我们这样配置,真的可以让容器里发送数据包到外网了吗?这需要我们做个测试,再重新尝试下这一讲开始的操作,从容器里 ping 外网的 IP,这时候,你会发现还是 ping 不通。其实呢,做到这一步,我们通过自己的逐步操作呢,重现了这一讲了最开始的问题。

解决问题

那最直接的方法呢,就是在容器中继续 ping 外网的 IP 39.106.233.176,然后在容器的 eth0 (veth_container),容器外的 veth_host,docker0,宿主机的 eth0 这一条数据包的路径上运行 tcpdump。

容器的 eth0

# ip netns exec $pid tcpdump -i eth0 host 39.106.233.176 -nn
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on eth0, link-type EN10MB (Ethernet), capture size 262144 bytes
00:47:29.934294 IP 172.17.1.2 > 39.106.233.176: ICMP echo request, id 71, seq 1, length 64
00:47:30.934766 IP 172.17.1.2 > 39.106.233.176: ICMP echo request, id 71, seq 2, length 64
00:47:31.958875 IP 172.17.1.2 > 39.106.233.176: ICMP echo request, id 71, seq 3, length 64

veth_host:

# tcpdump -i veth_host host 39.106.233.176 -nn
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on veth_host, link-type EN10MB (Ethernet), capture size 262144 bytes
00:48:01.654720 IP 172.17.1.2 > 39.106.233.176: ICMP echo request, id 71, seq 32, length 64
00:48:02.678752 IP 172.17.1.2 > 39.106.233.176: ICMP echo request, id 71, seq 33, length 64
00:48:03.702827 IP 172.17.1.2 > 39.106.233.176: ICMP echo request, id 71, seq 34, length 64

docker0:

# tcpdump -i docker0 host 39.106.233.176 -nn
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on docker0, link-type EN10MB (Ethernet), capture size 262144 bytes
00:48:20.086841 IP 172.17.1.2 > 39.106.233.176: ICMP echo request, id 71, seq 50, length 64
00:48:21.110765 IP 172.17.1.2 > 39.106.233.176: ICMP echo request, id 71, seq 51, length 64
00:48:22.134839 IP 172.17.1.2 > 39.106.233.176: ICMP echo request, id 71, seq 52, length 64

host eth0:

# tcpdump -i eth0 host 39.106.233.176 -nn
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on eth0, link-type EN10MB (Ethernet), capture size 262144 bytes
^C
0 packets captured
0 packets received by filter
0 packets dropped by kernel

通过上面的输出结果,我们发现 icmp 包到达了 docker0,但是没有到达宿主机上的 eth0。

因为我们已经配置了 iptables nat 的转发,这个也可以通过查看 iptables 的 nat 表确认一下,是没有问题的,具体的操作命令如下:

# iptables -L -t nat
Chain PREROUTING (policy ACCEPT)
target prot opt source destination
DOCKER all -- anywhere anywhere ADDRTYPE match dst-type LOCAL
Chain INPUT (policy ACCEPT)
target prot opt source destination
Chain POSTROUTING (policy ACCEPT)
target prot opt source destination
MASQUERADE all -- 172.17.0.0/16 anywhere
Chain OUTPUT (policy ACCEPT)
target prot opt source destination
DOCKER all -- anywhere !127.0.0.0/8 ADDRTYPE match dst-type LOCAL
Chain DOCKER (2 references)
target prot opt source destination
RETURN all -- anywhere anywhere

那么会是什么问题呢?因为这里需要做两个网络设备接口之间的数据包转发,也就是从 docker0 把数据包转发到 eth0 上,你可能想到了 Linux 协议栈里的一个常用参数 ip_forward。

我们可以看一下,它的值是 0,当我们把它改成 1 之后,那么我们就可以从容器中 ping 通外网 39.106.233.176 这个 IP 了!

# cat /proc/sys/net/ipv4/ip_forward
0
# echo 1 > /proc/sys/net/ipv4/ip_forward
# docker exec -it if-test ping 39.106.233.176
PING 39.106.233.176 (39.106.233.176) 56(84) bytes of data.
64 bytes from 39.106.233.176: icmp_seq=1 ttl=77 time=359 ms
64 bytes from 39.106.233.176: icmp_seq=2 ttl=77 time=346 ms
^C
--- 39.106.233.176 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1ms
rtt min/avg/max/mdev = 345.889/352.482/359.075/6.593 ms

容器网络配置(2):容器网络延时要比宿主机上的高吗?

这种容器向外发送数据包的路径,相比宿主机上直接向外发送数据包的路径,很明显要多了一次接口层的发送和接收。尽管 veth 是虚拟网络接口,在软件上还是会增加一些开销。如果我们的应用程序对网络性能有很高的要求,特别是之前运行在物理机器上,现在迁移到容器上的,如果网络配置采用 veth 方式,就会出现网络延时增加的现象。

分析问题

虽然 veth 是一个虚拟的网络接口,但是在接收数据包的操作上,这个虚拟接口和真实的网路接口并没有太大的区别。这里除了没有硬件中断的处理,其他操作都差不多,特别是软中断(softirq)的处理部分其实就和真实的网络接口是一样的。

其实 softirq 这个概念,我们之前在CPU 的模块中也提到过。在处理网络数据的时候,一些运行时间较长而且不能在硬中断中处理的工作,就会通过 softirq 来处理。一般在硬件中断处理结束之后,网络 softirq 的函数才会再去执行没有完成的包的处理工作。即使这里 softirq 的执行速度很快,还是会带来额外的开销。所以,根据 veth 这个虚拟网络设备的实现方式,我们可以看到它必然会带来额外的开销,这样就会增加数据包的网络延时。

解决问题

macvlan/ipvlan 与 veth 网络配置有什么不一样。容器的虚拟网络接口,直接连接在了宿主机的物理网络接口上了,形成了一个网络二层的连接。

img

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

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

容器网络配置(3):容器中的网络乱序包怎么这么高?

把他们的应用程序从物理机迁移到容器之后,从网络监控中发现,容器中数据包的重传的数量要比在物理机里高了不少。

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

那就是运行 netstat 命令来查看协议栈中的丢包和重传的情况。比如说,在运行上面的 iperf3 命令前后,我们都在容器的 Network Namespace 里运行一下 netstat 看看重传的情况。我们会发现,一共发生了 162 次(604-442)快速重传(fast retransmits),这个数值和 iperf3 中的 Retr 列里的数值是一样的。

-bash-4.2# nsenter -t 51598 -n netstat -s | grep retran
454 segments retransmited
442 fast retransmits
-bash-4.2# nsenter -t 51598 -n netstat -s | grep retran
616 segments retransmited
604 fast retransmits

问题分析

快速重传(fast retransmit)

我们都知道 TCP 协议里,发送端(sender)向接受端(receiver)发送一个数据包,接受端(receiver)都回应 ACK。如果超过一个协议栈规定的时间(RTO),发送端没有收到 ACK 包,那么发送端就会重传(Retransmit)数据包,就像下面的示意图一样。

img

不过呢,这样等待一个超时之后再重传数据,对于实际应用来说太慢了,所以 TCP 协议又定义了快速重传 (fast retransmit)的概念。它的基本定义是这样的:如果发送端收到 3 个重复的 ACK,那么发送端就可以立刻重新发送 ACK 对应的下一个数据包。

img

虽然 TCP 快速重传的标准定义是需要收到 3 个重复的 Ack,不过你会发现在 Linux 中常常收到一个 Dup Ack(重复的 Ack)后,就马上重传数据了。这是什么原因呢?这里先需要提到 SACK 这个概念,SACK 也就是选择性确认(Selective Acknowledgement)。其实跟普通的 ACK 相比呢,SACK 会把接收端收到的所有包的序列信息,都反馈给发送端。

img

在 Linux 内核中会有个判断(你可以看看下面的这个函数),大概意思是这样的:如果在接收端收到的数据和还没有收到的数据之间,两者数据量差得太大的话(超过了 reordering*mss_cache),也可以马上重传数据。

这里的数据量差是根据 bytes 来计算的,而不是按照包的数目来计算的,所以你会看到即使只收到一个 SACK,Linux 也可以重发数据包。

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

Veth 接口的数据包的发送

现在我们知道了网络包乱序会造成数据包的重传,接着我们再来看看容器的 veth 接口配置有没有可能会引起数据包的乱序。

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

对于多核的系统,这个发送数据的进程可以在多个 CPU 上切换运行。进程在不同的 CPU 上把数据放入队列并且 raise softirq 之后,因为每个 CPU 上处理 softirq 是个异步操作,所以两个 CPU network softirq handler 处理这个进程的数据包时,处理的先后顺序并不能保证。

所以,veth 对的这种发送数据方式增加了容器向外发送数据出现乱序的几率。

img

RSS 和 RPS

RSS(Receive Side Scaling)

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

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

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

img

RPS(Receive Packet Steering)

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

img

在硬件中断后,CPU2 收到了数据包,再一次对数据包计算一次四元组的 hash 值,得到这个数据包与 CPU1 的映射关系。接着会把这个数据包放到 CPU1 对应的 softnet_data 数据队列中,同时向 CPU1 发送一个 IPI 的中断信号。

这样一来,后面 CPU1 就会继续按照 Netowrk softirq 的方式来处理这个数据包了。

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

明白了 RPS 的概念之后,我们再回头来看 veth 对外发送数据时候,在 enqueue_to_backlog() 的时候选择 CPU 的问题。显然,如果对应的 veth 接口上打开了 RPS 的配置以后,那么对于同一个数据流,就可以始终选择同一个 CPU 了。

其实我们打开 RPS 的方法挺简单的,只要去 /sys 目录下,在网络接口设备接收队列中修改队列里的 rps_cpus 的值,这样就可以了。rps_cpus 是一个 16 进制的数,每个 bit 代表一个 CPU。

比如说,我们在一个 12CPU 的节点上,想让 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

本文作者:Blue Mountain

本文链接:https://www.cnblogs.com/BlueMountain-HaggenDazs/p/18143287

版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。

posted @   Blue Mountain  阅读(58)  评论(0编辑  收藏  举报
点击右上角即可分享
微信分享提示
评论
收藏
关注
推荐
深色
回顶
收起
  1. 1 404 not found REOL
404 not found - REOL
00:00 / 00:00
An audio error has occurred.