容器网络模型需要解决容器 IP 地址的管理和容器之间的互相通信。其中容器IP地址的管理包括容器IP地址的分配与回收,容器之间的相互通信包括同一主机容器之间和跨主机容器之间通信两种场景。本文将基于 Docker 容器网络模型,介绍主流容器网络模型的原理与实现。
CNM vs CNI
关于容器网络,Docker与Kubernetes分别提出了不同的规范标准:
CNM基于libnetwork,是Docker内置的模型规范,它的总体架构如下图所示:
可以看到,CNM规范主要定义了以下三个组件:
- Sandbox: 每个Sandbox包一个容器网络栈(network stack)的配置:容器的网口、路由表和DNS设置等,Sandbox可以通过Linux网络命名空间netns来实现
- Endpoint: 每个Sandbox通过Endpoint加入到一个Network里,Endpoint可以通过Linux虚拟网络设备veth对来实现
- Network: 一组能相互直接通信的Endpoint,Network可以通过Linux网桥设备bridge,VLAN等实现
可以看到,底层实现原理还是我们之前介绍过的Linux虚拟网络设备,网络命名空间等。CNM规范的典型场景是这样的:用户可以创建一个或多个Network,一个容器Sandbox可以通过Endpoint加入到一个或多个Network,同一个Network中容器Sanbox可以通信,不同Network中的容器Sandbox隔离。这样就可以实现从容器与网络的解耦,也就是锁,在创建容器之前,可以先创建网络,然后决定让容器加入哪个网络。
但是,为什么Kubernetes没有采用 CNM 规范标准,而是选择CNI,感兴趣的话可以去看看Kubernetes的官方博客文章Why Kubernetes doesn’t use libnetwork,总的来说,不使用CNM最关键的一点是,是因为Kubernetes考虑到CNM在一定程度上和container runtime耦合度太高,因此以Kubernetes为领导的其他一些组织开始制定新的CNI规范。CNI并不是 Docker 原生支持的,它是为容器技术设计的通用型网络接口,因此CNI接口可以很容易地从高层向底层调用,但从底层到高层却不是很方便,所以一些常见的CNI插件很难在Docker层面激活。但是这两个模型全都支持插件化,也就是说我们每个人都可以按照这两套网络规范来编写自己的具体网络实现。
Docker 网络模式
docker run创建Docker容器时,可以用—net选项指定容器的网络模式,Docker有以下5种网络模式:∂
网络模式 | 简介 |
---|---|
bridge | 为每一个容器分配、设置 IP 等,并将容器连接到一个 docker0 虚拟网桥,默认为该模式。 |
host | 容器将不会虚拟出自己的网卡,配置自己的 IP 等,而是使用宿主机的 IP 和端口。 |
none | 容器有独立的 Network namespace,但并没有对其进行任何网络设置,如分配 veth pair 和网桥连接,IP 等。 |
container | 新创建的容器不会创建自己的网卡和配置自己的 IP,而是和一个指定的容器共享 IP、端口范围等。 |
overlay |
bridge network
bridge桥接网络是docker默认的网络模型,如果我们在创建容器的时候不指定网络模型,则默认使用bridge
。bridge网络模型可以解决单宿主机上的容器之间的通信以及容器服务对外的暴露,实现原理也很简单:
可以看到,bridge网络模型主要依赖于大名鼎鼎的docker0网桥以及veth虚拟网络设备对实现,通过之前笔记对于linux虚拟网络设备的了解,我们知道veth设备对对于从一个设备发出的数据包,会直接出现在另一个网络设备上,即使不在一个netns网络命名空间中,所以将veth设备对实际上是连接不同netns网络命名空间的”网线”,docker0网桥设备充当不同容器网络的网关。事实上,我们一旦而当以bridge网络模式创建容器时,会自动创建相应的veth设备对,其中一端连接到docker0网桥,另外一端连接到容器网络的eth0虚拟网卡。
首先我们在安装了docker的宿主机上查看网桥设备docker0和路由规则:
1 | # ip link show docker0 |
然后使用默认的bridge网络模式创建一个容器,并查看宿主机端的veth设备对:
1 | # docker run -d --name mynginx nginx:latest |
可以看到新的veth设备对的一端veth42772d8
已经连接到docker0
网桥,那么另外一端呢?
1 | # ls /var/run/docker/netns/ |
正如我们设想的那样,veth设备的另外一端处于新的netns网络命名空间62fd67d9ef3e
中,并且IP地址为172.17.0.2/16
,与docker0
处于同一子网中。
Note: 如果我们创建了映射到
/var/run/docker/netns/
的符号链接/var/run/netns
,就不用使用nsenter
命令或者进入容器内部查看veth设备对的另外一端,直接使用如下iproute2
工具包
1 | # ip netns show |
模拟一下bridge网络模型的实现,基本的网络拓扑图如下所示:
首先创建两个netns网络命名空间:
1
2
3
4
5
6# ip netns add netns_A
# ip netns add netns_B
# ip netns
netns_B
netns_A
default在default网络命名空间中创建网桥设备mybr0,并分配IP地址
172.18.0.1/16
使其成为对应子网的网关:1
2
3
4
5
6
7
8# ip link add name mybr0 type bridge
# ip addr add 172.18.0.1/16 dev mybr0
# ip link show mybr0
12: mybr0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
link/ether ae:93:35:ab:59:2a brd ff:ff:ff:ff:ff:ff
# ip route
...
172.18.0.0/16 dev mybr0 proto kernel scope link src 172.18.0.1接下来,创建veth设备对并连接在第一步创建的两个netns网络命名空间:
1
2
3
4
5
6
7# ip link add vethA type veth peer name vethpA
# ip link show vethA
14: vethA@vethpA: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
link/ether da:f1:fd:19:6b:4a brd ff:ff:ff:ff:ff:ff
# ip link show vethpA
13: vethpA@vethA: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
link/ether 86:d6:16:43:54:9e brd ff:ff:ff:ff:ff:ff将上一步创建的veth设备对的一端
vethA
连接到mybr0
网桥并启动:1
2
3
4# ip link set dev vethA master mybr0
# ip link set vethA up
# bridge link
14: vethA state LOWERLAYERDOWN @vethpA: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 master mybr0 state disabled priority 32 cost 2将veth设备对的另一端
vethpA
放到netns网络命名空间netns_A
中并配置IP启动:1
2
3
4
5
6
7
8
9# ip link set vethpA netns netns_A
# ip netns exec netns_A ip link set vethpA name eth0
# ip netns exec netns_A ip addr add 172.18.0.2/16 dev eth0
# ip netns exec netns_A ip link set eth0 up
# ip netns exec netns_A ip addr show type veth
13: eth0@if14: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
link/ether 86:d6:16:43:54:9e brd ff:ff:ff:ff:ff:ff link-netnsid 0
inet 172.18.0.2/16 scope global eth0
valid_lft forever preferred_lft forever现在就可以验证从
netns_A
网络命名空间中访问mybr0
网关:1
2
3
4
5
6
7
8# ip netns exec netns_A ping -c 2 172.18.0.1
PING 172.18.0.1 (172.18.0.1) 56(84) bytes of data.
64 bytes from 172.18.0.1: icmp_seq=1 ttl=64 time=0.096 ms
64 bytes from 172.18.0.1: icmp_seq=2 ttl=64 time=0.069 ms
--- 172.18.0.1 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1004ms
rtt min/avg/max/mdev = 0.069/0.082/0.096/0.016 m若想要从从
netns_A
网络命名空间中非172.18.0.0/16
的地址,就需要增加一条默认默认路由:1
2
3
4# ip netns exec netns_A ip route add default via 172.18.0.1
# ip netns exec netns_A ip route
default via 172.18.0.1 dev eth0
172.18.0.0/16 dev eth0 proto kernel scope link src 172.18.0.2
Note: 如果你此时尝试去ping其他的公网地址,eg.
google.com
,是ping不通的,是因为ping的出去的数据包(ICMP包)的源地址没有做源地址转换(snat),导致ICMP包有去无回;Docker是通过设置iptables
实现源地址转换的。
接下来,按照上述步骤创建连接
default
和netns_B
网络命名空间veth设备对:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16# ip link add vethB type veth peer name vethpB
# ip link set dev vethB master mybr0
# ip link set vethB up
# ip link set vethpB netns netns_B
# ip netns exec netns_B ip link set vethpB name eth0
# ip netns exec netns_B ip addr add 172.18.0.3/16 dev eth0
# ip netns exec netns_B ip link set eth0 up
# ip netns exec netns_B ip route add default via 172.18.0.1
# ip netns exec netns_B ip add show eth0
15: eth0@if16: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
link/ether 0e:2f:c6:de:fe:24 brd ff:ff:ff:ff:ff:ff link-netnsid 0
inet 172.18.0.3/16 scope global eth0
valid_lft forever preferred_lft forever
# ip netns exec netns_B ip route show
default via 172.18.0.1 dev eth0
172.18.0.0/16 dev eth0 proto kernel scope link src 172.18.0.3默认情况下把Linux会把网桥设备bridge的
FORWORD
功能禁用,所以在netns_A
里面是ping不通netns_B
的,需要额外增加一条iptables规则:1
# iptables -A FORWARD -i mybr0 -j ACCEPT
现在就可以验证两个netns网络命名空间之间可以互通:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16# ip netns exec netns_A ping -c 2 172.18.0.3
PING 172.18.0.3 (172.18.0.3) 56(84) bytes of data.
64 bytes from 172.18.0.3: icmp_seq=1 ttl=64 time=0.091 ms
64 bytes from 172.18.0.3: icmp_seq=2 ttl=64 time=0.093 ms
--- 172.18.0.3 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1027ms
rtt min/avg/max/mdev = 0.091/0.092/0.093/0.001 ms
# ip netns exec netns_B ping -c 2 172.18.0.2
PING 172.18.0.2 (172.18.0.2) 56(84) bytes of data.
64 bytes from 172.18.0.2: icmp_seq=1 ttl=64 time=0.259 ms
64 bytes from 172.18.0.2: icmp_seq=2 ttl=64 time=0.078 ms
--- 172.18.0.2 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1030ms
rtt min/avg/max/mdev = 0.078/0.168/0.259/0.091 ms
实际上,此时两个netns网络命名空间处于同一个子网中,所以网桥设备mybr0
还是在二层(数据链路层)起到的作用,只需要对方的MAC地址就可以访问。
但是如果需要从两个netns网络命名空间访问其他网段的地址,这个时候就需要设置默认网桥设备mybr0
充当的默认网关地址就发挥作用了:来自于两个netns网络命名空间的数据包发现目标IP地址并不是本子网地址,于是发给网关mybr0
,此时网桥设备mybr0
其实工作在三层(IP网络层),它收到数据包之后,查看本地路由与目标IP地址,寻找下一跳的地址。
当然,如果需要从两个netns网络命名空间访问其他公网地址.eg. google.com
,需要这是iptables来做源地址转换,这里就不细细展开来说。
host network
- host 网络模式需要在创建容器时通过参数
--net host
或者--network host
指定; - 采用 host 网络模式的 Docker Container,可以直接使用宿主机的 IP 地址与外界进行通信,若宿主机的 eth0 是一个公有 IP,那么容器也拥有这个公有 IP。同时容器内服务的端口也可以使用宿主机的端口,无需额外进行 NAT 转换;
- host 网络模式可以让容器共享宿主机网络栈,这样的好处是外部主机与容器直接通信,但是容器的网络缺少隔离性。
该模式将禁用Docker容器的网络隔离。因为容器共享了宿主机的网络命名空间,直接暴露在公共网络中。因此,你需要通过端口映射(port mapping)来进行协调。
1 | $ docker run -it --name box2 --net host busybox |
我们可以从上例中看到:容器和宿主机具有相同的IP地址9.134.218.214
。 在下图中,我们可以看到:当使用host模式网络时,容器实际上继承了宿主机的IP地址。该模式比bridge模式更快(因为没有路由开销),但是它将容器直接暴露在公共网络中,是有安全隐患的。
none network
- none 网络模式是指禁用网络功能,只有 lo 接口 local 的简写,代表 127.0.0.1,即 localhost 本地环回接口。在创建容器时通过参数
--net none
或者--network none
指定; - none 网络模式即不为 Docker Container 创建任何的网络环境,容器内部就只能使用 loopback 网络设备,不会再有其他的网络资源。可以说 none 模式为 Docke Container 做了极少的网络设定,但是俗话说得好“少即是多”,在没有网络配置的情况下,作为 Docker 开发者,才能在这基础做其他无限多可能的网络定制开发。这也恰巧体现了 Docker 设计理念的开放。
1 | $ docker run -it --name box5 --net none busybox |
container network
- Container 网络模式是 Docker 中一种较为特别的网络的模式。在创建容器时通过参数
--net container:已运行的容器名称|ID
或者--network container:已运行的容器名称|ID
指定; - 处于这个模式下的 Docker 容器会共享一个网络栈,这样两个容器之间可以使用 localhost 高效快速通信。
Container 网络模式即新创建的容器不会创建自己的网卡,配置自己的 IP,而是和一个指定的容器共享 IP、端口范围等。同样两个容器除了网络方面相同之外,其他的如文件系统、进程列表等还是隔离的。
1 | $ docker run -d -P --name nginx19 --net=bridge nginx:1.9.1 |
通过以上测试可以发现,Docker 守护进程只创建了一对对等虚拟设备接口用于连接 nginx19 容器和宿主机,而 box04 容器则直接使用了 nginx19 容器的网卡信息。
1 | # ip addr |
macvlan network
overlay network
上一篇我们介绍的bridge网络模型主要用于解决同一主机间的容器相互访问以及容器对外暴露服务的问题,并没有涉及到怎么解决跨主机容器之间互相访问的问题。
对于跨主机的容器间的相互访问问题,我们能想到的最直观的解决方案就是直接使用宿主机host网络,这时,容器完全复用复用宿主机的网络设备以及协议栈,容器的IP就是主机的IP,这样,只要宿主机主机能通信,容器也就自然能通信。但是这样,为了暴露容器服务,每个容器需要占用宿主机上的一个端口,通过这个端口和外界通信。所以,就需要手动维护端口的分配,不要使不同的容器服务运行在一个端口上,正因为如此,这种容器网络模型很难被推广到生产环境。
因此解决跨主机通信的可行方案主要是让容器配置与宿主机不一样的IP地址,往往是在现有二层或三层网络之上再构建起来一个独立的overlay网络,这个网络通常会有自己独立的IP地址空间、交换或者路由的实现。但是由于容器有自己独立配置的IP地址,underlay平面的底层网络设备如交换机、路由器等完全不感知这些IP的存在,也就导致容器的IP不能直接路由出去实现跨主机通信。
为了解决容器独立IP地址间的访问问题,主要有以下两个思路:
- 修改底层网络设备配置,加入容器网络IP地址的管理,修改路由器网关等,该方式主要和SDN(Software define networking)结合。
- 完全不修改底层网络设备配置,复用原有的underlay平面网络,解决容器跨主机通信,主要有如下两种方式:
- 隧道传输(Overlay): 将容器的数据包封装到原主机网络的三层或者四层数据包中,然后使用主机网络的IP或者TCP/UDP传输到目标主机,目标主机拆包后再转发给目标容器。Overlay隧道传输常见方案包括Vxlan、ipip等,目前使用Overlay隧道传输技术的主流容器网络有Flannel等。
- 修改主机路由:把容器网络加到主机路由表中,把主机网络设备当作容器网关,通过路由规则转发到指定的主机,实现容器的三层互通。目前通过路由技术实现容器跨主机通信的网络如Flannel host-gw、Calico等。
在开始之前,我们总结一些在容器网络的介绍文章里面看到各种技术术语:
- IPAM: IP Address Management,即IP地址管理。IPAM并不是容器时代特有的词汇,传统的标准网络协议比如DHCP其实也是一种IPAM,负责从MAC地址分发IP地址;但是到了容器时代我们提到IPAM,我们特指为每一个容器实例分配和回收IP地址,保证一个集群里面的所有容器都分配全局唯一的IP地址;主流的做法包括:基于CIDR的IP地址段分配地或精确为每一个容器分配IP。
- Overlay:在容器时代,就是在主机现有二层(数据链路层)或三层(IP网络层)基础之上再构建起来一个独立的网络,这个overlay网络通常会有自己独立的IP地址空间、交换或者路由的实现。
- IPIP: 一种基于Linux网络设备TUN实现的隧道协议,允许将三层(IP)网络包封装在另外一个三层网络包之发送和接收,详情请看之前IPIP隧道的介绍笔记。
- IPSec: 跟IPIP隧道协议类似,是一个点对点的一个加密通信协议,一般会用到Overlay网络的数据隧道里。
- VXLAN:最主要是解决VLAN支持虚拟网络数量(4096)过少的问题而由VMware、Cisco、RedHat等联合提出的解决方案。VXLAN可以支持在一个VPC(Virtual Private Cloud)划分多达1600万个虚拟网络。
- BGP: 主干网自治网络的路由协议,当代的互联网由很多小的AS自治网络(Autonomous system)构成,自治网络之间的三层路由是由BGP实现的,简单来说,通过BGP协议AS告诉其他AS自己子网里都包括哪些IP地址段,自己的AS编号以及一些其他的信息。
- SDN: Software-Defined Networking,一种广义的概念,通过软件方式快速配置网络,往往包括一个中央控制层来集中配置底层基础网络设施。
Docker原生支持overlay网络来解决容器间的跨主机通信问题,事实上,对于Docker原生支持的overlay网络,Laurent Bernaille在DockerCon 2017上详细剖析了它的实现原理,甚至还有从头开始一步步实现Docker的overlay网络的实践教程,这三篇文章为:
- Deep dive into docker overlay networks part 1
- Deep dive into docker overlay networks part 2
- Deep dive into docker overlay networks part 3
所以在这里我就只是大致介绍一下Docker原生支持的overlay网络模型的大致原理:
从上面的网络模型图可以看出,对于docker原生的overlay网络来说,处理容器对外访问的南北流量个容器之间相互访问的东西流量分别使用不同的Linux网络设备:
- 南北流量:类似于bridge网络模型,通过主机的网桥设备充当网关,然后使用veth设备对分别连接主机网桥和容器内网卡设备,最后通过主机网卡发送接收对外的数据包,需要注意的是,对外数据包需要做地址转化nat
- 东西流量:另外在主机上单独增加一个网桥设备,然后使用veth设备对分别连接主机网桥和容器内网卡设备,同时主机内网桥设备还绑定了vxlan设备,vxlan设备将跨主机的容器数据包封装成vxlan数据包发送到目标主机,然后解封装后转发给对应的容器。
需要注意的是,虽然跨主机的两个容器是通过Overlay通信的,但容器自己不能感知,因为它们只认为彼此都在一个子网中,只需要知道对方的MAC地址,可以通过ARP协议广播学习获取IP与MAC地址转换。当然通过VXLAN隧道广播ARP包理论上也没有问题,问题是该方案将导致广播包过多,广播的成本会很大。
Docker给出的方案是通过ARP代理+静态配置解决ARP广播问题,容器的地址信息保存到到KV数据库etcd中。这样就可以通过静态配置的方式填充IP和MAC地址表(neigh表)替换使用ARP广播的方式,所以vxlan设备还负责本地容器的ARP代理:
1 | # ip link show vxlan0 | grep proxy_arp |
上面neign信息中的PERMANENT
代表静态配置而不是通过学习获取的,而192.168.0.103
和192.168.0.104
是另外两个容器的IP地址。每当有新的容器创建时,Docker通过通知节点更新本地neigh ARP表。
另外,容器之间的数据包最终还是通过VXLAN隧道传输的,因此需要知道数据包的目标容器在哪个Node节点。当Node数量达到一定数量级之后,如果采用和ARP一样的广播洪泛的方式学习,那么显然同样存在性能问题,实际上也很少使用这种方案,在硬件SDN中通常使用BGP EVPN技术实现VXLAN的控制平面,而Docker解决的办法和ARP类似,通过静态配置的方式填充VTEP(VXLAN Tunnel Endpoint)表,我们可以查看容器网络namespace的转发表(Forward database,简称fdb):
1 | # bridge fdb |
上面的转发表信息表示MAC地址82:fa:1d:48:14:04
的对端VTEP地址为10.0.0.10
,而82:fa:1d:48:14:04
的对端VTEP地址为10.0.0.10
,permanent说明这两条转发表记录都是静态配置的,而这些数据来源依然是KV数据库etcd,这些VTEP地址为容器所在的主机的IP地址。