你好,我是程远。

在上一讲,我们讲了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: <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
808: eth0@if809: <BROADCAST,MULTICAST,UP,LOWER_UP> 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 Namespace,eth0 是这个Network Namespace里的网络接口。而宿主机上也有自己的eth0,宿主机上的eth0对应着真正的物理网卡,可以和外面通讯。

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

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

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

好,整体的思路已经理清楚了,接下来我们做具体分析。我们先来看第一步,怎么让数据包从容器的Network Namespace发送到Host Network Namespace上面。

你可以查看一下Docker 网络的文档或者Kubernetes网络的文档,这些文档里面介绍了很多种容器网络配置的方式。

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

在这些方法中,我们使用最多的就是veth的方式,用Docker启动的容器缺省的网络接口用的也是这个veth。既然它这么常见,所以我们就用veth作为例子来详细讲解。至于另外一类macvlan/ipvlan的方式,我们在下一讲里会讲到。

那什么是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

我在这里解释一下,这个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虚拟设备接口。我给你画了一张示意图,图里直观展示了这对接口在容器和宿主机上的位置。

现在,我们再来看看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

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

从这张示意图中,我们可以看出来,容器和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。

这样就可以查到,到底在哪个设备接口上没有收到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 > 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

重点小结

这一讲,我们主要解决的问题是如何给容器配置网络接口,让容器可以和外面通讯;同时我们还学习了当容器网络不通的时候,我们应该怎么来做一个简单调试。

解决容器与外界通讯的问题呢,一共需要完成两步。第一步是,怎么让数据包从容器的Network Namespace发送到Host Network Namespace上;第二步,数据包到了Host Network Namespace之后,还需要让它可以从宿主机的eth0发送出去。

我们想让数据从容器Netowrk Namespace发送到Host Network Namespace,可以用配置一对veth虚拟网络设备的方法实现。而让数据包从宿主机的eth0发送出去,就用可bridge+nat的方式完成。

这里我讲的是最基本的一种配置,但它也是很常用的一个网络配置。针对其他不同需要,容器网络还有很多种。那你学习完这一讲,了解了基本的概念和操作之后呢,还可以查看更多的网上资料,学习不同的网络配置。

遇到容器中网络不通的情况,我们先要理解自己的容器以及容器在宿主机上的配置,通过对主要设备上做tcpdump可以找到具体在哪一步数据包停止了转发。

然后我们结合内核网络配置参数,路由表信息,防火墙规则,一般都可以定位出根本原因,最终解决这种网络完全不通的问题。

但是如果是网络偶尔丢包的问题,这个就需要用到其他的一些工具来做分析了,这个我们会在之后的章节做讲解。

思考题

我们这一讲的例子呢,实现了从容器访问外面的IP。那么如果要实现节点外的程序来访问容器的IP,我们应该怎么配置网络呢?

欢迎你在留言区分享你的思考和问题。如果这篇文章对你有启发,也欢迎分享给你的朋友,一起学习进步。