# 16 | 容器网络配置(1):容器网络不通了要怎么调试? 你好,我是程远。 在上一讲,我们讲了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`,结果就会发现在宿主机上,却是可以连通这个地址的。 ```shell # 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: 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: 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对应着真正的物理网卡,可以和外面通讯。 ![](https://static001.geekbang.org/resource/image/68/98/6848619c9d4db810560fe1a712fb2d98.jpeg) 那你可以先想想,我们要让容器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网络设备了。 ```shell # docker run -d --name if-test --network none centos:8.1.1911 sleep 36000 cf3d3105b11512658a025f5b401a09c888ed3495205f31e0a0d78a2036729472 # docker exec -it if-test ip addr 1: lo: 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,你可以执行一下后面的这个脚本。 ```shell 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虚拟设备接口。我给你画了一张示意图,图里直观展示了这对接口在容器和宿主机上的位置。 ![](https://static001.geekbang.org/resource/image/56/89/569287c365c99d3778858b7bc42b5989.jpeg) 现在,我们再来看看veth的定义了,其实它也很简单。veth就是一个虚拟的网络设备,一般都是成对创建,而且这对设备是相互连接的。当每个设备在不同的Network Namespaces的时候,Namespace之间就可以用这对veth设备来进行网络通讯了。 比如说,你可以执行下面的这段代码,试试在veth\_host上加上一个IP,172.17.1.1/16,然后从容器里就可以ping通这个IP了。这也证明了从容器到宿主机可以利用这对veth接口来通讯了。 ```shell # 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 ``` 这个命令执行完之后,容器和宿主机的网络配置就会发生变化,这种配置是什么样呢?你可以参考一下面这张图的描述。 ![](https://static001.geekbang.org/resource/image/a0/69/a006f0707d02d38917983523c9356869.jpeg) 从这张示意图中,我们可以看出来,容器和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: ```shell # 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: ```shell # 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: ```shell # 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: ```shell # 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表确认一下,是没有问题的,具体的操作命令如下: ```shell # 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了! ```shell # 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,我们应该怎么配置网络呢? 欢迎你在留言区分享你的思考和问题。如果这篇文章对你有启发,也欢迎分享给你的朋友,一起学习进步。