This commit is contained in:
louzefeng
2024-07-09 18:38:56 +00:00
parent 8bafaef34d
commit bf99793fd0
6071 changed files with 1017944 additions and 0 deletions

View File

@@ -0,0 +1,323 @@
<audio id="audio" title="15 | 容器网络:我修改了/proc/sys/net下的参数为什么在容器中不起效" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ee/55/ee4cff36d3af749890cb4672ae0fc455.mp3"></audio>
你好,我是程远。
从这一讲开始我们进入到了容器网络这个模块。容器网络最明显的一个特征就是它有自己的Network Namespace了。你还记得在我们这个课程的[第一讲](https://time.geekbang.org/column/article/308108)里我们就提到过Network Namespace负责管理网络环境的隔离。
今天呢我们更深入地讨论一下和Network Namespace相关的一个问题——容器中的网络参数。
和之前的思路一样,我们先来看一个问题。然后在解决问题的过程中,更深入地理解容器的网络参数配置。
## 问题再现
在容器中运行的应用程序如果需要用到tcp/ip协议栈的话常常需要修改一些网络参数内核中网络协议栈的参数
很大一部分网络参数都在/proc文件系统下的[/proc/sys/net/](https://www.kernel.org/doc/Documentation/sysctl/net.txt)目录里。
**修改这些参数主要有两种方法:一种方法是直接到/proc文件系统下的"/proc/sys/net/"目录里对参数做修改;还有一种方法是使用[sysctl](https://man7.org/linux/man-pages/man8/sysctl.8.html)这个工具来修改。**
在启动容器之前呢,根据我们的需要我们在宿主机上已经修改过了几个参数,也就是说这些参数的值已经不是内核里原来的缺省值了.
比如我们改了下面的几个参数:
```
# # 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 &gt; /proc/sys/net/ipv4/tcp_congestion_control
# echo 600 &gt; /proc/sys/net/ipv4/tcp_keepalive_time
# echo 10 &gt; /proc/sys/net/ipv4/tcp_keepalive_intvl
# echo 6 &gt; /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里的值实际上是不是这样呢
我们还是先按下面的脚本,启动容器,然后运行 `docker exec` 命令一起看一下:
```
# docker run -d --name net_para centos:8.1.1911 sleep 3600
deec6082bac7b336fa28d0f87d20e1af21a784e4ef11addfc2b9146a9fa77e95
# docker exec -it net_para bash
[root@deec6082bac7 /]# cat /proc/sys/net/ipv4/tcp_congestion_control
bbr
[root@deec6082bac7 /]# cat /proc/sys/net/ipv4/tcp_keepalive_time
7200
[root@deec6082bac7 /]# cat /proc/sys/net/ipv4/tcp_keepalive_intvl
75
[root@deec6082bac7 /]# cat /proc/sys/net/ipv4/tcp_keepalive_probes
9
```
从这个结果我们看到tcp_congestion_control的值是bbr和宿主机Network Namespace里的值是一样的而其他三个tcp keepalive相关的值都不是宿主机Network Namespace里设置的值而是原来系统里的缺省值了。
那为什么会这样呢在分析这个问题之前我们需要先来看看Network Namespace这个概念。
## 知识详解
### 如何理解Network Namespace
对于Network Namespace我们从字面上去理解的话可以知道它是在一台Linux节点上对网络的隔离不过它具体到底隔离了哪部分的网络资源呢
我们还是先来看看操作手册,在[Linux Programmers Manual](https://man7.org/linux/man-pages/man7/network_namespaces.7.html)里对Network Namespace有一个段简短的描述在里面就列出了最主要的几部分资源它们都是通过Network Namespace隔离的。
我把这些资源给你做了一个梳理:
第一种网络设备这里指的是loeth0等网络设备。你可以通过 `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。**
下面我们会讲两个例子,带你体会一下这两个方法具体怎么用。
第一种方法呢是在新的进程创建的时候伴随新进程建立同时也建立出新的Network Namespace。这个方法其实就是通过clone()系统调用带上CLONE_NEWNET flag来实现的。
Clone建立出来一个新的进程这个新的进程所在的Network Namespace也是新的。然后我们执行 `ip link` 命令查看Namespace里的网络设备就可以确认一个新的Network Namespace已经建立好了。
具体操作你可以看一下[这段代码](https://github.com/chengyli/training/blob/master/net/namespace/clone-ns.c)。
```
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你可以看一下[这段代码](https://github.com/chengyli/training/blob/master/net/namespace/unshare-ns.c)。
```
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](https://github.com/opencontainers/runc)也是用unshare()给新建的容器建立Namespace的。
这里我简单地说一下runC是什么我们用Docker或者containerd去启动容器最后都会调用runC在Linux中把容器启动起来。
除了在代码中用系统调用来建立Network Namespace我们也可以用命令行工具来建立Network Namespace。比如用 `ip netns` 命令,在下一讲学习容器网络配置的时候呢,我们会用到 `ip netns`,这里你先有个印象就行。
在Network Namespace 创建好了之后呢,我们可以在宿主机上运行 `lsns -t net` 这个命令来查看系统里已有的Network Namespace。当然`lsns`也可以用来查看其它Namespace。
`lsns` 查看已有的Namespace后我们还可以用 `nsenter` 这个命令进入到某个Network Namespace里具体去查看这个Namespace里的网络配置。
比如下面的这个例子用我们之前的clone()的例子里的代码编译出clone-ns这个程序运行后再使用 `lsns` 查看新建的Network Namespace并且用`nsenter`进入到这个Namespace查看里面的lo device。
具体操作你可以参考下面的代码:
```
# ./clone-ns &amp;
[1] 7732
# Host Namespace Devices:
1: lo: &lt;LOOPBACK,UP,LOWER_UP&gt; 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: &lt;BROADCAST,MULTICAST,UP,LOWER_UP&gt; 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: &lt;NO-CARRIER,BROADCAST,MULTICAST,UP&gt; 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: &lt;LOOPBACK&gt; 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: &lt;LOOPBACK&gt; 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
```
## 解决问题
那理解了Network Namespace之后我们再来看看这一讲最开始的问题我们应该怎么来设置容器里的网络相关参数呢
首先你要避免走入误区。从我们一开始的例子里也可以看到容器里Network Namespace的网络参数并不是完全从宿主机Host Namespace里继承的也不是完全在新的Network Namespace建立的时候重新初始化的。
其实呢,这一点我们只要看一下内核代码中对协议栈的初始化函数,很快就可以知道为什么会有这样的情况。
在我们的例子里tcp_congestion_control的值是从Host Namespace里继承的而tcp_keepalive相关的几个值会被重新初始化了。
在函数[tcp_sk_init](https://github.com/torvalds/linux/blob/v5.4/net/ipv4/tcp_ipv4.c#L2631)()里tcp_keepalive的三个参数都是重新初始化的而tcp_congestion_control 的值是从Host Namespace里复制过来的。
```
static int __net_init tcp_sk_init(struct net *net)
{
net-&gt;ipv4.sysctl_tcp_keepalive_time = TCP_KEEPALIVE_TIME;
net-&gt;ipv4.sysctl_tcp_keepalive_probes = TCP_KEEPALIVE_PROBES;
net-&gt;ipv4.sysctl_tcp_keepalive_intvl = TCP_KEEPALIVE_INTVL;
/* Reno is always built in */
if (!net_eq(net, &amp;init_net) &amp;&amp;
try_module_get(init_net.ipv4.tcp_congestion_control-&gt;owner))
net-&gt;ipv4.tcp_congestion_control = init_net.ipv4.tcp_congestion_control;
else
net-&gt;ipv4.tcp_congestion_control = &amp;tcp_reno;
}
```
那么我们现在知道Network Namespace的网络参数是怎么初始化的了你可能会问了我在容器里也可以修改这些参数吗
我们可以启动一个普通的容器,这里的“普通”呢,我指的不是"privileged"的那种容器也就是在这个容器中有很多操作都是不允许做的比如mount一个文件系统。这个privileged容器概念我们会在后面容器安全这一讲里详细展开这里你有个印象。
那么在启动完一个普通容器后,我们尝试一下在容器里去修改"/proc/sys/net/"下的参数。
这时候你会看到,容器中"/proc/sys/"是只读mount的那么在容器里是不能修改"/proc/sys/net/"下面的任何参数了。
```
# docker run -d --name net_para centos:8.1.1911 sleep 3600
977bf3f07da90422e9c1e89e56edf7a59fab5edff26317eeb253700c2fa657f7
# docker exec -it net_para bash
[root@977bf3f07da9 /]# echo 600 &gt; /proc/sys/net/ipv4/tcp_keepalive_time
bash: /proc/sys/net/ipv4/tcp_keepalive_time: Read-only file system
[root@977bf3f07da9 /]# cat /proc/mounts | grep "proc/sys"
proc /proc/sys proc ro,relatime 0 0
```
为什么“/proc/sys/” 在容器里是只读mount呢 这是因为runC当初出于安全的考虑把容器中所有/proc和/sys相关的目录缺省都做了read-only mount的处理。详细的说明你可以去看看这两个commits:
<li>
[Mount /proc and /sys read-only, except in privileged containers](https://github.com/opencontainers/runc/commit/5a6b042e5395660ac8a6e3cc33227ca66df7c835)
</li>
<li>
[Make /proc writable, but not /proc/sys and /proc/sysrq-trigger](https://github.com/opencontainers/runc/commit/73c607b7ad5cea5c913f96dff17bca668534ad18)
</li>
那我们应该怎么来修改容器中Network Namespace的网络参数呢
当然如果你有宿主机上的root权限最简单粗暴的方法就是用我们之前说的"nsenter"工具,用它修改容器里的网络参数的。不过这个方法在生产环境里显然是不会被允许的,因为我们不会允许用户拥有宿主机的登陆权限。
其次呢一般来说在容器中的应用已经启动了之后才会做这样的修改。也就是说很多tcp链接已经建立好了那么即使新改了参数对已经建立好的链接也不会生效了。这就需要重启应用我们都知道生产环境里通常要避免应用重启那这样做显然也不合适。
通过刚刚的排除法我们推理出了网络参数修改的“正确时机”想修改Network Namespace里的网络参数要选择容器刚刚启动而容器中的应用程序还没启动之前进行。
其实runC也在对/proc/sys目录做read-only mount之前预留出了修改接口就是用来修改容器里 "/proc/sys"下参数的同样也是sysctl的参数。
而Docker的[sysctl](https://docs.docker.com/engine/reference/commandline/run/#configure-namespaced-kernel-parameters-sysctls-at-runtime)或者Kubernetes里的[allowed-unsafe-sysctls](https://kubernetes.io/docs/tasks/administer-cluster/sysctl-cluster/)特性也都利用了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
```
## 重点总结
好了,今天的课我们讲完了,那么下面我来给你做个总结。
今天我们讨论问题是容器中网络参数的问题因为是问题发生在容器里又是网络的参数那么自然就和Network Namespace有关所以我们首先要理解Network Namespace。
Network Namespace可以隔离网络设备ip协议栈ip路由表防火墙规则以及可以显示独立的网络状态信息。
我们可以通过clone()或者unshare()系统调用来建立新的Network Namespace。
此外,还有一些工具"ip""netns""unshare""lsns"和"nsenter"也可以用来操作Network Namespace。
这些工具的适用条件,我用表格的形式整理如下,你可以做个参考。
<img src="https://static001.geekbang.org/resource/image/6d/cd/6da09e062c0644492af26823343c6ecd.jpeg" alt=""><br>
接着我们分析了如何修改普通容器非privileged的网络参数。
由于安全的原因,普通容器的/proc/sys是read-only mount的所以在容器启动以后我们无法在容器内部修改/proc/sys/net下网络相关的参数。
这时可行的方法是**通过runC sysctl相关的接口在容器启动的时候对容器内的网络参数做配置。**
这样一来想要修改网络参数就可以这么做如果是使用Docker我们可以加上"—sysctl"这个参数而如果使用Kubernetes的话就需要用到"allowed unsaft sysctl"这个特性了。
## 思考题
这一讲中,我们提到了可以使用"nsenter"这个工具,从宿主机上修改容器里的/proc/sys/net/下的网络参数,你可以试试看具体怎么修改。
欢迎你在留言区分享你的收获和疑问。如果这篇文章对你有帮助,也欢迎转发给你的同事和朋友,一起交流探讨。

View File

@@ -0,0 +1,317 @@
<audio id="audio" title="16 | 容器网络配置1容器网络不通了要怎么调试?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1f/b0/1f203869198452ac1f50c85cbb1476b0.mp3"></audio>
你好,我是程远。
在上一讲我们讲了Network Namespace隔离了网络设备IP协议栈和路由表以及防火墙规则那容器Network Namespace里的参数怎么去配置我们现在已经很清楚了。
其实对于网络配置的问题,我们还有一个最需要关心的内容,那就是容器和外面的容器或者节点是怎么通讯的,这就涉及到了容器网络接口配置的问题了。
所以这一讲呢我们就来聊一聊容器Network Namespace里如何配置网络接口还有当容器网络不通的时候我们应该怎么去做一个简单调试。
## 问题再现
在前面的课程里,我们一直是用 `docker run` 这个命令来启动容器的。容器启动了之后,我们也可以看到,在容器里面有一个"eth0"的网络接口接口上也配置了一个IP地址。
不过呢如果我们想从容器里访问外面的一个IP地址比如说39.106.233.176这个是极客时间网址对应的IP结果就发现是不能ping通的。
这时我们可能会想到,到底是不是容器内出了问题,在容器里无法访问,会不会宿主机也一样不行呢?
所以我们需要验证一下首先我们退出容器然后在宿主机的Network Namespace下再运行 `ping 39.106.233.176`,结果就会发现在宿主机上,却是可以连通这个地址的。
```
# docker run -d --name if-test centos:8.1.1911 sleep 36000
244d44f94dc2931626194c6fd3f99cec7b7c4bf61aafc6c702551e2c5ca2a371
# docker exec -it if-test bash
[root@244d44f94dc2 /]# ip addr
1: lo: &lt;LOOPBACK,UP,LOWER_UP&gt; 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
808: eth0@if809: &lt;BROADCAST,MULTICAST,UP,LOWER_UP&gt; mtu 1500 qdisc noqueue state UP group default
link/ether 02:42:ac:11:00:02 brd ff:ff:ff:ff:ff:ff link-netnsid 0
inet 172.17.0.2/16 brd 172.17.255.255 scope global eth0
valid_lft forever preferred_lft forever
[root@244d44f94dc2 /]# ping 39.106.233.176 ### 容器中无法ping通
PING 39.106.233.176 (39.106.233.176) 56(84) bytes of data.
^C
--- 39.106.233.176 ping statistics ---
9 packets transmitted, 0 received, 100% packet loss, time 185ms
[root@244d44f94dc2 /]# exit ###退出容器
exit
# ping 39.106.233.176 ### 宿主机上可以ping通
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=78 time=296 ms
64 bytes from 39.106.233.176: icmp_seq=2 ttl=78 time=306 ms
64 bytes from 39.106.233.176: icmp_seq=3 ttl=78 time=303 ms
^C
--- 39.106.233.176 ping statistics ---
4 packets transmitted, 3 received, 25% packet loss, time 7ms
rtt min/avg/max/mdev = 296.059/301.449/305.580/4.037 ms
```
那么碰到这种容器内网络不通的问题我们应该怎么分析调试呢我们还是需要先来理解一下容器Network Namespace里的网络接口是怎么配置的。
## 基本概念
在讲解容器的网络接口配置之前,我们需要先建立一个整体的认识,搞清楚容器网络接口在系统架构中处于哪个位置。
你可以看一下我给你画的这张图图里展示的是容器有自己的Network Namespaceeth0 是这个Network Namespace里的网络接口。而宿主机上也有自己的eth0宿主机上的eth0对应着真正的物理网卡可以和外面通讯。
<img src="https://static001.geekbang.org/resource/image/68/98/6848619c9d4db810560fe1a712fb2d98.jpeg" alt="">
那你可以先想想我们要让容器Network Namespace中的数据包最终发送到物理网卡上需要完成哪些步骤呢从图上看我们大致可以知道应该包括这两步。
**第一步就是要让数据包从容器的Network Namespace发送到Host Network Namespace上。**
**第二步数据包发到了Host Network Namespace之后还要解决数据包怎么从宿主机上的eth0发送出去的问题。**
整体的思路已经理清楚了接下来我们做具体分析。我们先来看第一步怎么让数据包从容器的Network Namespace发送到Host Network Namespace上面。
你可以查看一下[Docker 网络的文档](https://docs.docker.com/network/)或者[Kubernetes网络的文档](https://kubernetes.io/docs/concepts/cluster-administration/networking/),这些文档里面介绍了很多种容器网络配置的方式。
不过对于容器从自己的Network Namespace连接到Host Network Namespace的方法一般来说就只有两类设备接口一类是[veth](https://man7.org/linux/man-pages/man4/veth.4.html)另外一类是macvlan/ipvlan。
在这些方法中我们使用最多的就是veth的方式用Docker启动的容器缺省的网络接口用的也是这个veth。既然它这么常见所以我们就用veth作为例子来详细讲解。至于另外一类macvlan/ipvlan的方式我们在下一讲里会讲到。
那什么是veth呢为了方便你更好地理解我们先来模拟一下Docker为容器建立eth0网络接口的过程动手操作一下这样呢你就可以很快明白什么是veth了。
对于这个模拟操作呢,我们主要用到的是[ip netns](https://man7.org/linux/man-pages/man8/ip-netns.8.html) 这个命令通过它来对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: &lt;LOOPBACK,UP,LOWER_UP&gt; 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
```
我在这里解释一下这个veth的建立过程是什么样的。
首先呢,我们先找到这个容器里运行的进程"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 src="https://static001.geekbang.org/resource/image/56/89/569287c365c99d3778858b7bc42b5989.jpeg" alt="">
现在我们再来看看veth的定义了其实它也很简单。veth就是一个虚拟的网络设备一般都是成对创建而且这对设备是相互连接的。当每个设备在不同的Network Namespaces的时候Namespace之间就可以用这对veth设备来进行网络通讯了。
比如说你可以执行下面的这段代码试试在veth_host上加上一个IP172.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 src="https://static001.geekbang.org/resource/image/a0/69/a006f0707d02d38917983523c9356869.jpeg" alt="">
从这张示意图中我们可以看出来容器和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_hostdocker0宿主机的eth0这一条数据包的路径上运行tcpdump。
这样就可以查到到底在哪个设备接口上没有收到ping的icmp包。我把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 &gt; 39.106.233.176: ICMP echo request, id 71, seq 1, length 64
00:47:30.934766 IP 172.17.1.2 &gt; 39.106.233.176: ICMP echo request, id 71, seq 2, length 64
00:47:31.958875 IP 172.17.1.2 &gt; 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 &gt; 39.106.233.176: ICMP echo request, id 71, seq 32, length 64
00:48:02.678752 IP 172.17.1.2 &gt; 39.106.233.176: ICMP echo request, id 71, seq 33, length 64
00:48:03.702827 IP 172.17.1.2 &gt; 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 &gt; 39.106.233.176: ICMP echo request, id 71, seq 50, length 64
00:48:21.110765 IP 172.17.1.2 &gt; 39.106.233.176: ICMP echo request, id 71, seq 51, length 64
00:48:22.134839 IP 172.17.1.2 &gt; 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 &gt; /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
```
## 重点小结
这一讲,我们主要解决的问题是如何给容器配置网络接口,让容器可以和外面通讯;同时我们还学习了当容器网络不通的时候,我们应该怎么来做一个简单调试。
解决容器与外界通讯的问题呢一共需要完成两步。第一步是怎么让数据包从容器的Network Namespace发送到Host Network Namespace上第二步数据包到了Host Network Namespace之后还需要让它可以从宿主机的eth0发送出去。
我们想让数据从容器Netowrk Namespace发送到Host Network Namespace可以用配置一对veth虚拟网络设备的方法实现。而让数据包从宿主机的eth0发送出去就用可bridge+nat的方式完成。
这里我讲的是最基本的一种配置,但它也是很常用的一个网络配置。针对其他不同需要,容器网络还有很多种。那你学习完这一讲,了解了基本的概念和操作之后呢,还可以查看更多的网上资料,学习不同的网络配置。
遇到容器中网络不通的情况我们先要理解自己的容器以及容器在宿主机上的配置通过对主要设备上做tcpdump可以找到具体在哪一步数据包停止了转发。
然后我们结合内核网络配置参数,路由表信息,防火墙规则,一般都可以定位出根本原因,最终解决这种网络完全不通的问题。
但是如果是网络偶尔丢包的问题,这个就需要用到其他的一些工具来做分析了,这个我们会在之后的章节做讲解。
## 思考题
我们这一讲的例子呢实现了从容器访问外面的IP。那么如果要实现节点外的程序来访问容器的IP我们应该怎么配置网络呢
欢迎你在留言区分享你的思考和问题。如果这篇文章对你有启发,也欢迎分享给你的朋友,一起学习进步。

View File

@@ -0,0 +1,257 @@
<audio id="audio" title="17容器网络配置2容器网络延时要比宿主机上的高吗?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6d/5d/6d71a36ce9dc9a996ec380777b5f2e5d.mp3"></audio>
你好,我是程远。
在上一讲里我们学习了在容器中的网络接口配置重点讲解的是veth的接口配置方式这也是绝大部分容器用的缺省的网络配置方式。
不过呢从veth的这种网络接口配置上看一个数据包要从容器里发送到宿主机外需要先从容器里的eth0 (veth_container) 把包发送到宿主机上veth_host然后再在宿主机上通过nat或者路由的方式经过宿主机上的eth0向外发送。
<img src="https://static001.geekbang.org/resource/image/2f/be/2fba246fb4aaa6315661a11996fa04be.jpg" alt="">
这种容器向外发送数据包的路径相比宿主机上直接向外发送数据包的路径很明显要多了一次接口层的发送和接收。尽管veth是虚拟网络接口在软件上还是会增加一些开销。
如果我们的应用程序对网络性能有很高的要求特别是之前运行在物理机器上现在迁移到容器上的如果网络配置采用veth方式就会出现网络延时增加的现象。
那今天我们就来聊一聊,容器网络接口对于容器中应用程序网络延时有怎样的影响,还有这个问题应该怎么解决。
## 问题重现
对于这种veth接口配置导致网络延时增加的现象我们可以通过运行[netperf](https://hewlettpackard.github.io/netperf/)Netperf是一个衡量网络性能的工具它可以提供单向吞吐量和端到端延迟的测试来模拟一下。
这里我们需要两台虚拟机或者物理机,这两台机器需要同处于一个二层的网络中。
具体的配置示意图如下:
<img src="https://static001.geekbang.org/resource/image/24/20/24bc885e34e6477cd69324f67e65f120.jpeg" alt="">
首先我们需要在第一台机器上启动一个veth接口的容器容器的启动和宿主机上的配置你可以参考一下这里的[脚本](https://github.com/chengyli/training/blob/master/net/latency/start_container.sh)。在第二台机器上我们只要启动一个netserver就可以了。
然后呢我们分别在容器里和宿主机上运行与netserver交互的netperf再比较一下它们延时的差异。
我们可以运行netperf的TCP_RR测试用例TCP_RR是netperf里专门用来测试网络延时的缺省每次运行10秒钟。运行以后我们还要计算平均每秒钟TCP request/response的次数这个次数越高就说明延时越小。
接下来我们先在第一台机器的宿主机上直接运行netperf的TCP_RR测试用例3轮得到的值分别是2504.922410.14和2422.81计算一下可以得到三轮Transactions平均值是2446/s。
```
# ./netperf -H 192.168.0.194 -t TCP_RR
MIGRATED TCP REQUEST/RESPONSE TEST from 0.0.0.0 (0.0.0.0) port 0 AF_INET to 192.168.0.194 () port 0 AF_INET : first burst 0
Local /Remote
Socket Size Request Resp. Elapsed Trans.
Send Recv Size Size Time Rate
bytes Bytes bytes bytes secs. per sec
16384 131072 1 1 10.00 2504.92
16384 131072
# ./netperf -H 192.168.0.194 -t TCP_RR
MIGRATED TCP REQUEST/RESPONSE TEST from 0.0.0.0 (0.0.0.0) port 0 AF_INET to 192.168.0.194 () port 0 AF_INET : first burst 0
Local /Remote
Socket Size Request Resp. Elapsed Trans.
Send Recv Size Size Time Rate
bytes Bytes bytes bytes secs. per sec
16384 131072 1 1 10.00 2410.14
16384 131072
# ./netperf -H 192.168.0.194 -t TCP_RR
MIGRATED TCP REQUEST/RESPONSE TEST from 0.0.0.0 (0.0.0.0) port 0 AF_INET to 192.168.0.194 () port 0 AF_INET : first burst 0
Local /Remote
Socket Size Request Resp. Elapsed Trans.
Send Recv Size Size Time Rate
bytes Bytes bytes bytes secs. per sec
16384 131072 1 1 10.00 2422.81
16384 131072
```
同样我们再在容器中运行一下netperf的TCP_RR也一样运行三轮计算一下这三次的平均值得到的值是2141。
那么我们拿这次容器环境中的平均值和宿主机上得到的值2446做比较会发现Transactions下降了大概12.5%也就是网络的延时超过了10%。
```
[root@4150e2a842b5 /]# ./netperf -H 192.168.0.194 -t TCP_RR
MIGRATED TCP REQUEST/RESPONSE TEST from 0.0.0.0 (0.0.0.0) port 0 AF_INET to 192.168.0.194 () port 0 AF_INET : first burst 0
Local /Remote
Socket Size Request Resp. Elapsed Trans.
Send Recv Size Size Time Rate
bytes Bytes bytes bytes secs. per sec
16384 131072 1 1 10.00 2104.68
16384 131072
[root@4150e2a842b5 /]# ./netperf -H 192.168.0.194 -t TCP_RR
MIGRATED TCP REQUEST/RESPONSE TEST from 0.0.0.0 (0.0.0.0) port 0 AF_INET to 192.168.0.194 () port 0 AF_INET : first burst 0
Local /Remote
Socket Size Request Resp. Elapsed Trans.
Send Recv Size Size Time Rate
bytes Bytes bytes bytes secs. per sec
16384 131072 1 1 10.00 2146.34
16384 131072
[root@4150e2a842b5 /]# ./netperf -H 192.168.0.194 -t TCP_RR
MIGRATED TCP REQUEST/RESPONSE TEST from 0.0.0.0 (0.0.0.0) port 0 AF_INET to 192.168.0.194 () port 0 AF_INET : first burst 0
Local /Remote
Socket Size Request Resp. Elapsed Trans.
Send Recv Size Size Time Rate
bytes Bytes bytes bytes secs. per sec
16384 131072 1 1 10.00 2173.79
16384 131072
```
## 分析问题
刚才我们已经得到了测试的数值我们发现veth方式的确带来了很高的网络延时。那现在我们先来分析一下为什么veth会带来这么大的网络延时然后再看看有什么方法可以降低容器里的网络延时。
我们先回顾一下容器里veth接口的配置还是拿我们上一讲里容器veth的图作为例子。
<img src="https://static001.geekbang.org/resource/image/56/89/569287c365c99d3778858b7bc42b5989.jpeg" alt="">
上一讲中我提到过veth的虚拟网络接口一般都是成对出现就像上面图里的veth_container和veth_host一样。
在每次网络传输的过程中数据包都需要通过veth_container这个接口向外发送而且必须保证veth_host先接收到这个数据包。
虽然veth是一个虚拟的网络接口但是在接收数据包的操作上这个虚拟接口和真实的网路接口并没有太大的区别。这里除了没有硬件中断的处理其他操作都差不多特别是软中断softirq的处理部分其实就和真实的网络接口是一样的。
我们可以通过阅读Linux内核里的veth的驱动代码[drivers/net/veth.c](https://github.com/torvalds/linux/blob/v5.4/drivers/net/veth.c))确认一下。
veth发送数据的函数是veth_xmit()它里面的主要操作就是找到veth peer设备然后触发peer设备去接收数据包。
比如veth_container这个接口调用了veth_xmit()来发送数据包最后就是触发了它的peer设备veth_host去调用netif_rx()来接收数据包。主要的代码我列在下面了:
```
static netdev_tx_t veth_xmit(struct sk_buff *skb, struct net_device *dev)
{
/* 拿到veth peer设备的net_device */
rcv = rcu_dereference(priv-&gt;peer);
/* 将数据送到veth peer设备 */
if (likely(veth_forward_skb(rcv, skb, rq, rcv_xdp) == NET_RX_SUCCESS)) {
}
static int veth_forward_skb(struct net_device *dev, struct sk_buff *skb,
struct veth_rq *rq, bool xdp)
{
/* 这里最后调用了 netif_rx() */
return __dev_forward_skb(dev, skb) ?: xdp ?
veth_xdp_rx(rq, skb) :
netif_rx(skb);
}
```
而netif_rx()是一个网络设备驱动里面标准的接收数据包的函数netif_rx()里面会为这个数据包raise一个softirq。
```
__raise_softirq_irqoff(NET_RX_SOFTIRQ);
```
其实softirq这个概念我们之前在[CPU的模块](https://time.geekbang.org/column/article/311054)中也提到过。在处理网络数据的时候一些运行时间较长而且不能在硬中断中处理的工作就会通过softirq来处理。
一般在硬件中断处理结束之后网络softirq的函数才会再去执行没有完成的包的处理工作。即使这里softirq的执行速度很快还是会带来额外的开销。
**所以根据veth这个虚拟网络设备的实现方式我们可以看到它必然会带来额外的开销这样就会增加数据包的网络延时。**
## 解决问题
那么我们有什么方法可以减少容器的网络延时呢你可能会想到我们可不可以不使用veth这个方式配置网络接口而是换成别的方式呢
的确是这样其实除了veth之外容器还可以选择其他的网络配置方式。在Docker的文档中提到了macvlan的配置方式和macvlan很类似的方式还有ipvlan。
那我们先来简单看一下macvlan和ipvlan的异同点。
我们先来看这两个方式的相同之处无论是macvlan还是ipvlan它们都是在一个物理的网络接口上再配置几个虚拟的网络接口。在这些虚拟的网络接口上都可以配置独立的IP并且这些IP可以属于不同的Namespace。
然后我再说说它们的不同点。**对于macvlan每个虚拟网络接口都有自己独立的mac地址而ipvlan的虚拟网络接口是和物理网络接口共享同一个mac地址。**而且它们都有自己的L2/L3的配置方式不过我们主要是拿macvlan/ipvlan来和veth做比较这里可以先忽略macvlan/ipvlan这些详细的特性。
我们就以ipvlan为例运行下面的这个脚本为容器手动配置上ipvlan的网络接口。
```
docker run --init --name lat-test-1 --network none -d registry/latency-test:v1 sleep 36000
pid1=$(docker inspect lat-test-1 | grep -i Pid | head -n 1 | awk '{print $2}' | awk -F "," '{print $1}')
echo $pid1
ln -s /proc/$pid1/ns/net /var/run/netns/$pid1
ip link add link eth0 ipvt1 type ipvlan mode l2
ip link set dev ipvt1 netns $pid1
ip netns exec $pid1 ip link set ipvt1 name eth0
ip netns exec $pid1 ip addr add 172.17.3.2/16 dev eth0
ip netns exec $pid1 ip link set eth0 up
```
在这个脚本里,我们先启动一个容器,这里我们用"—network none"的方式来启动,也就是在容器中没有配置任何的网络接口。
接着我们在宿主机eth0的接口上增加一个ipvlan虚拟网络接口ipvt1再把它加入到容器的Network Namespace里面重命名为容器内的eth0并且配置上IP。这样我们就配置好了第一个用ipvlan网络接口的容器。
我们可以用同样的方式配置第二个容器这样两个容器可以相互ping一下IP看看网络是否配置成功了。脚本你可以在[这里](https://github.com/chengyli/training/blob/main/net/latency/create_ipvlan.sh)得到。
两个容器配置好之后就像下面图中描述的一样了。从这张图里你很容易就能看出macvlan/ipvlan与veth网络配置有什么不一样。容器的虚拟网络接口直接连接在了宿主机的物理网络接口上了形成了一个网络二层的连接。
<img src="https://static001.geekbang.org/resource/image/1e/a0/1e78d66a93e7a0aab1315056c4b937a0.jpeg" alt="">
如果从容器里向宿主机外发送数据看上去通过的接口要比veth少了那么实际情况是不是这样呢我们先来看一下ipvlan接口发送数据的代码。
从下面的ipvlan接口的发送代码中我们可以看到如果是往宿主机外发送数据发送函数会直接找到ipvlan虚拟接口对应的物理网络接口。
比如在我们的例子中这个物理接口就是宿主机上的eth0然后直接调用dev_queue_xmit(),通过物理接口把数据直接发送出去。
```
static int ipvlan_xmit_mode_l2(struct sk_buff *skb, struct net_device *dev)
{
if (!ipvlan_is_vepa(ipvlan-&gt;port) &amp;&amp;
ether_addr_equal(eth-&gt;h_dest, eth-&gt;h_source)) {
} else if (is_multicast_ether_addr(eth-&gt;h_dest)) {
}
/*
* 对于普通的对外发送数据上面的if 和 else if中的条件都不成立
* 所以会执行到这一步拿到ipvlan对应的物理网路接口设备
* 然后直接从这个设备发送数据。
*/
skb-&gt;dev = ipvlan-&gt;phy_dev;
return dev_queue_xmit(skb);
}
```
和veth接口相比我们用ipvlan发送对外数据就要简单得多因为这种方式没有内部额外的softirq处理开销。
现在我们还可以看一下在实际生产环境中一个应用程序跑在使用veth接口的容器中跟这个应用程序跑在使用ipvlan接口的容器中两者的网络延时差异是怎样的。
下面这张图是网络延时的监控图图里蓝色的线表示程序运行在veth容器中黄色线表示程序运行在ipvlan的容器里绿色的线代表程序直接运行在物理机上。
从这张延时Latency图里我们可以看到在veth容器里程序的网络延时要明显高一些而程序在ipvlan容器里的网络延时已经比较接近物理机上的网络延时了。
<img src="https://static001.geekbang.org/resource/image/5d/18/5de6462780275acca261d63661b9e018.png" alt="">
所以对于网络延时敏感的应用程序我们可以考虑使用ipvlan/macvlan的容器网络配置方式来替换缺省的veth网络配置。
## 重点小结
好了,今天的内容讲完了,我们来做个总结。今天我们主要讨论了容器网络接口对容器中应用程序网络延时的影响。
容器通常缺省使用veth虚拟网络接口不过veth接口会有比较大的网络延时。我们可以使用netperf这个工具来比较网络延时相比物理机上的网络延时使用veth接口容器的网络延时会增加超过10%。
我们通过对veth实现的代码做分析可以看到由于veth接口是成对工作**在对外发送数据的时候peer veth接口都会raise softirq来完成一次收包操作这样就会带来数据包处理的额外开销。**
如果要减小容器网络延时就可以给容器配置ipvlan/macvlan的网络接口来替代veth网络接口。Ipvlan/macvlan直接在物理网络接口上虚拟出接口在发送对外数据包的时候可以直接通过物理接口完成没有节点内部类似veth的那种softirq的开销。**容器使用ipvlan/maclan的网络接口它的网络延时可以非常接近物理网络接口的延时。**
对于延时敏感的应用程序我们可以考虑使用ipvlan/macvlan网络接口的容器。不过由于ipvlan/macvlan网络接口直接挂载在物理网络接口上对于需要使用iptables规则的容器比如Kubernetes里使用service的容器就不能工作了。这就需要你结合实际应用的需求做个判断再选择合适的方案。
## 思考题
在这节课的最后我提到“由于ipvlan/macvlan网络接口直接挂载在物理网络接口上对于需要使用iptables规则的容器比如Kubernetes里使用service的容器就不能工作了”请你思考一下这个判断背后的具体原因。
欢迎你在留言区写下你的思考和疑问。如果这篇文章让你有所收获,也欢迎分享给你的朋友,一起交流进步。

View File

@@ -0,0 +1,244 @@
<audio id="audio" title="18 | 容器网络配置3容器中的网络乱序包怎么这么高" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/cc/48/cc523ff9c4edf75c7758cd0ae92c8448.mp3"></audio>
你好,我是程远。这一讲,我们来聊一下容器中发包乱序的问题。
这个问题也同样来自于工作实践,我们的用户把他们的应用程序从物理机迁移到容器之后,从网络监控中发现,容器中数据包的重传的数量要比在物理机里高了不少。
在网络的前面几讲里我们已经知道了容器网络缺省的接口是vethveth接口都是成对使用的。容器通过veth接口向外发送数据首先需要从veth的一个接口发送给跟它成对的另一个接口。
那么这种接口会不会引起更多的网络重传呢?如果会引起重传,原因是什么,我们又要如何解决呢?接下来我们就带着这三个问题开始今天的学习。
## 问题重现
我们可以在容器里运行一下 `iperf3` 命令向容器外部发送一下数据从iperf3的输出"Retr"列里,我们可以看到有多少重传的数据包。
比如下面的例子里我们可以看到有162个重传的数据包。
```
# iperf3 -c 192.168.147.51
Connecting to host 192.168.147.51, port 5201
[ 5] local 192.168.225.12 port 51700 connected to 192.168.147.51 port 5201
[ ID] Interval Transfer Bitrate Retr Cwnd
[ 5] 0.00-1.00 sec 1001 MBytes 8.40 Gbits/sec 162 192 KBytes
- - - - - - - - - - - - - - - - - - - - - - - - -
[ ID] Interval Transfer Bitrate Retr
[ 5] 0.00-10.00 sec 9.85 GBytes 8.46 Gbits/sec 162 sender
[ 5] 0.00-10.04 sec 9.85 GBytes 8.42 Gbits/sec receiver
iperf Done.
```
**网络中发生了数据包的重传,有可能是数据包在网络中丢了,也有可能是数据包乱序导致的。**那么,我们怎么来判断到底是哪一种情况引起的重传呢?
最直接的方法就是用tcpdump去抓包不过对于大流量的网络用tcpdump抓包瞬间就会有几个GB的数据。可是这样做的话带来的额外系统开销比较大特别是在生产环境中这个方法也不太好用。
所以这里我们有一个简单的方法那就是运行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
在刚才的问题重现里我们运行netstat命令后统计了快速重传的次数。那什么是快速重传fast retransmit这里我给你解释一下。
我们都知道TCP协议里发送端sender向接受端receiver发送一个数据包接受端receiver都回应ACK。如果超过一个协议栈规定的时间RTO发送端没有收到ACK包那么发送端就会重传Retransmit数据包就像下面的示意图一样。
<img src="https://static001.geekbang.org/resource/image/bc/4b/bc6a6059f49a5b61e95ba3705894b64b.jpeg" alt="">
不过呢这样等待一个超时之后再重传数据对于实际应用来说太慢了所以TCP协议又定义了快速重传 fast retransmit的概念。它的基本定义是这样的**如果发送端收到3个重复的ACK那么发送端就可以立刻重新发送ACK对应的下一个数据包。**
就像下面示意图里描述的那样接受端没有收到Seq 2这个包但是收到了Seq 35的数据包那么接收端在回应Ack的时候Ack的数值只能是2。这是因为按顺序来说收到Seq 1的包之后后面Seq 2一直没有到所以接收端就只能一直发送Ack 2。
那么当发送端收到3个重复的Ack 2后就可以马上重新发送 Seq 2这个数据包了而不用再等到重传超时之后了。
<img src="https://static001.geekbang.org/resource/image/21/5d/21935661dda5069f8dyy91e6cd5b295d.jpeg" alt="">
虽然TCP快速重传的标准定义是需要收到3个重复的Ack不过你会发现在Linux中常常收到一个Dup Ack重复的Ack就马上重传数据了。这是什么原因呢
这里先需要提到 **SACK** 这个概念SACK也就是选择性确认Selective Acknowledgement。其实跟普通的ACK相比呢SACK会把接收端收到的所有包的序列信息都反馈给发送端。
你看看下面这张图,就能明白这是什么意思了。
<img src="https://static001.geekbang.org/resource/image/2d/55/2d61334e4066391ceeb90cac0bb25d55.jpeg" alt="">
那有了SACK对于发送端来说在收到SACK之后就已经知道接收端收到了哪些数据没有收到哪些数据。
在Linux内核中会有个判断你可以看看下面的这个函数大概意思是这样的如果在接收端收到的数据和还没有收到的数据之间两者数据量差得太大的话超过了reordering*mss_cache也可以马上重传数据。
这里你需要注意一下,**这里的数据量差是根据bytes来计算的而不是按照包的数目来计算的所以你会看到即使只收到一个SACKLinux也可以重发数据包。**
```
static bool tcp_force_fast_retransmit(struct sock *sk)
{
struct tcp_sock *tp = tcp_sk(sk);
return after(tcp_highest_sack_seq(tp),
tp-&gt;snd_una + tp-&gt;reordering * tp-&gt;mss_cache);
}
```
好了了解了快速重传的概念之后我们再来看看如果netstat中有大量的"fast retransmits"意味着什么?
如果你再用netstat查看"reordering"就可以看到大量的SACK发现的乱序包。
```
-bash-4.2# nsenter -t 51598 -n netstat -s | grep reordering
Detected reordering 501067 times using SACK
```
**其实在云平台的这种网络环境里,网络包乱序+SACK之后产生的数据包重传的量要远远高于网络丢包引起的重传。**
比如说像下面这张图里展示的这样Seq 2与Seq 3这两个包如果乱序的话那么就会引起Seq 2的立刻重传。
<img src="https://static001.geekbang.org/resource/image/a9/d6/a99709757a45279324600a45f7a44cd6.jpeg" alt="">
### Veth接口的数据包的发送
现在我们知道了网络包乱序会造成数据包的重传接着我们再来看看容器的veth接口配置有没有可能会引起数据包的乱序。
在上一讲里我们讲过通过veth接口从容器向外发送数据包会触发peer veth设备去接收数据包这个接收的过程就是一个网络的softirq的处理过程。
在触发softirq之前veth接口会模拟硬件接收数据的过程通过enqueue_to_backlog()函数把数据包放到某个CPU对应的数据包队列里softnet_data
```
static int netif_rx_internal(struct sk_buff *skb)
{
int ret;
net_timestamp_check(netdev_tstamp_prequeue, skb);
trace_netif_rx(skb);
#ifdef CONFIG_RPS
if (static_branch_unlikely(&amp;rps_needed)) {
struct rps_dev_flow voidflow, *rflow = &amp;voidflow;
int cpu;
preempt_disable();
rcu_read_lock();
cpu = get_rps_cpu(skb-&gt;dev, skb, &amp;rflow);
if (cpu &lt; 0)
cpu = smp_processor_id();
ret = enqueue_to_backlog(skb, cpu, &amp;rflow-&gt;last_qtail);
rcu_read_unlock();
preempt_enable();
} else
#endif
{
unsigned int qtail;
ret = enqueue_to_backlog(skb, get_cpu(), &amp;qtail);
put_cpu();
}
return ret;
}
```
从上面的代码我们可以看到在缺省的状况下也就是没有RPS的情况下enqueue_to_backlog()把数据包放到了“当前运行的CPU”get_cpu()对应的数据队列中。如果是从容器里通过veth对外发送数据包那么这个“当前运行的CPU”就是容器中发送数据的进程所在的CPU。
对于多核的系统这个发送数据的进程可以在多个CPU上切换运行。进程在不同的CPU上把数据放入队列并且raise softirq之后因为每个CPU上处理softirq是个异步操作所以两个CPU network softirq handler处理这个进程的数据包时处理的先后顺序并不能保证。
所以veth对的这种发送数据方式增加了容器向外发送数据出现乱序的几率。
<img src="https://static001.geekbang.org/resource/image/c1/3b/c11581ec8f390b13ebc89fdb4cc2043b.jpeg" alt="">
### RSS和RPS
那么对于veth接口的这种发包方式有办法减少一下乱序的几率吗
其实我们在上面netif_rx_internal()那段代码中,有一段在"#ifdef CONFIG_RPS"中的代码。
我们看到这段代码中在调用enqueue_to_backlog()的时候传入的CPU并不是当前运行的CPU而是通过get_rps_cpu()得到的CPU那么这会有什么不同呢这里的RPS又是什么意思呢
要解释RPS呢需要先看一下RSS这个RSS不是我们之前说的内存RSS而是和网卡硬件相关的一个概念它是Receive Side Scaling的缩写。
现在的网卡性能越来越强劲了从原来一条RX队列扩展到了N条RX队列而网卡的硬件中断也从一个硬件中断变成了每条RX队列都会有一个硬件中断。
每个硬件中断可以由一个CPU来处理那么对于多核的系统多个CPU可以并行的接收网络包这样就大大地提高了系统的网络数据的处理能力.
同时在网卡硬件中可以根据数据包的4元组或者5元组信息来保证同一个数据流比如一个TCP流的数据始终在一个RX队列中这样也能保证同一流不会出现乱序的情况。
下面这张图大致描述了一下RSS是怎么工作的。
<img src="https://static001.geekbang.org/resource/image/ea/33/ea365c0d44625cf89c746d91799f9633.jpeg" alt="">
RSS的实现在网卡硬件和驱动里面而RPSReceive Packet Steering其实就是在软件层面实现类似的功能。它主要实现的代码框架就在上面的netif_rx_internal()代码里,原理也不难。
就像下面的这张示意图里描述的这样在硬件中断后CPU2收到了数据包再一次对数据包计算一次四元组的hash值得到这个数据包与CPU1的映射关系。接着会把这个数据包放到CPU1对应的softnet_data数据队列中同时向CPU1发送一个IPI的中断信号。
这样一来后面CPU1就会继续按照Netowrk softirq的方式来处理这个数据包了。
<img src="https://static001.geekbang.org/resource/image/e0/f9/e0413b74cbc1b5edcde5442fe94e11f9.jpeg" alt="">
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 &gt; /sys/devices/virtual/net/veth57703b6/queues/rx-0/rps_cpus
# cat /sys/devices/virtual/net/veth57703b6/queues/rx-0/rps_cpus
fff
```
## 重点小结
好了,今天的内容讲完了,我们做个总结。我们今天讨论的是容器中网络包乱序引起重传的问题。
由于在容器平台中看到大部分的重传是快速重传fast retransmits我们先梳理了什么是快速重传。快速重传的基本定义是**如果发送端收到3个重复的ACK那么发送端就可以立刻重新发送ACK对应的下一个数据包而不用等待发送超时。**
不过我们在Linux系统上还会看到发送端收到一个重复的ACK就快速重传的这是因为Linux下对SACK做了一个特别的判断之后就可以立刻重传数据包。
我们再对容器云平台中的快速重传做分析,就会发现这些重传大部分是由包的乱序触发的。
通过对容器veth网络接口进一步研究我们知道它可能会增加数据包乱序的几率。同时在这个分析过程中我们也看到了Linux网络RPS的特性。
**RPS和RSS的作用类似都是把数据包分散到更多的CPU上进行处理使得系统有更强的网络包处理能力。它们的区别是RSS工作在网卡的硬件层而RPS工作在Linux内核的软件层。**
在把数据包分散到各个CPU时RPS保证了同一个数据流是在一个CPU上的这样就可以有效减少包的乱序。那么我们可以把RPS的这个特性配置到veth网络接口上来减少数据包乱序的几率。
不过我这里还要说明的是RPS的配置还是会带来额外的系统开销在某些网络环境中会引起softirq CPU使用率的增大。那接口要不要打开RPS呢这个问题你需要根据实际情况来做个权衡。
同时你还要注意TCP的乱序包并不一定都会产生数据包的重传。想要减少网络数据包的重传我们还可以考虑协议栈中其他参数的设置比如/proc/sys/net/ipv4/tcp_reordering。
## 思考题
在这一讲中我们提到了Linux内核中的tcp_force_fast_retransmit()函数。那么你可以想想看这个函数中的tp-&gt;recording和内核参数 /proc/sys/net/ipv4/tcp_reordering是什么关系它们对数据包的重传会带来什么影响
```
static bool tcp_force_fast_retransmit(struct sock *sk)
{
struct tcp_sock *tp = tcp_sk(sk);
return after(tcp_highest_sack_seq(tp),
tp-&gt;snd_una + tp-&gt;reordering * tp-&gt;mss_cache);
}
```
欢迎你在留言区分享你的思考或疑问。如果学完这一讲让你有所收获,也欢迎转发给你的同事、或者朋友,一起交流探讨。