# 第12章 Docker 网络实现
在第11章里,我们了解了各种Docker原生支持的存储引擎和它们是如何帮助Docker打包镜像以及更高效地从已构建的镜像中运行容器等内容。实际上,Docker真正的强大之处在于使用它来构建分布式应用。这些分布式应用通常由散布在整个计算机网络内为了完成一些计算任务而交互的一定数量的服务程序组成。在本章中,我们将解答一个看上去很简单的问题:如何才能使得运行在Docker容器内部的应用程序在网络上能够实现相互访问?为了解答这一问题,我们首先需要了解一下Docker的网络模型。
Docker的网络实现包含3方面的内容。分别是IP的分配、(域)名的解析以及容器或服务的发现这3块。上述所有的概念即是著名的[**零配置网络**](http://zeroconf.org)(即zerocnf)的理论基础。简而言之,**零配置网络**即是一组在没有任何人工干预的情况下自动创建和配置一个TCP/IP网络的技术。
在每台宿主机上,Docker容器的分布密度快速波动(并且一般也是如此)。对于这样的复杂情况,自动化网络配置的概念就显得尤为重要。管理和运维这样的环境,以及针对其有时候横跨多个云服务平台的复杂情况建立起一套理想而安全的网络基础设施,想想都觉得会是一个非常艰巨的挑战。在这些复杂场景下实现零配置网络的“**愿景**”是我们在计算机网络方面的终极目标。它可以帮助开发和运维人员从以前手工配置网络的重担中解放出来。接下来,将探讨Docker为了尽可能的实现这一愿景所提出的众多可选的网络解决方案。
当外界的计算机网络访问这些在Docker容器内部运行的应用服务时,用户们常常面临如下问题:
- 该如何去访问一个运行在Docker容器里的应用或者服务?
- 如果应用程序在Docker容器内部运行,那么该如何以一种相对安全的方式去连接或者发现所依赖的应用服务?
- 在网络上,该如何加固这些运行在Docker容器内部的应用?
在这一章里,我们将试图寻找上述问题的答案。我们将会从Docker内部的网络实现原理讲起,这应该可以让你对当前的网络模型有一个很好的大体上的了解。接下来,我们将研究一些具体的实战案例,用户可以在任意一台Docker宿主机上执行里面的命令。最后,我们将探讨在现有的网络模型不满足需求的情况下的一些可选的替代方案。话不多说,是时候实际行动了!
Docker的网络模型非常简单,但同时也相当强大。默认情况下(即Docker守护进程的默认配置),无需用户任何人工的干预,所有新创建的Docker容器都会自动地连上Docker的内部网络:你简单地运行一下类似于`docker run <image> <cmd>`的命令,一旦你的容器启动,它便会自动地出现在网络上。这听上去挺神奇的,不妨让我们看一下它背后的实现原理吧。
当Docker守护进程以其默认的配置参数在一台宿主机上启动时,它会创建一个Linux[网桥设备](https://en.wikipedia.org/wiki/Bridging_%28networking%29)并将其命名为`docker0`。该网桥随后会自动地分配一个满足[RFC 1918](https://tools.ietf.org/html/rfc1918)定义的私有IP段的随机IP地址和子网。该子网决定了所有新创建容器将被分配的容器IP地址所属的网段。当前的网络模型如图12-1所示。
![图像说明文字](https://box.kancloud.cn/7bf231e1bd251d58219dd849ffc961d7_700x699.jpeg)
图12-1
除了创建网桥设备,Docker守护进程还会在宿主机上修改一些`iptables`规则。它会创建一个叫做`DOCKER`的特殊过滤链并且把它**插入**到`FORWARD`链的最上面。它也修改了一些`iptables nat`表的规则使得容器可以建立对外的连接。设置了这些规则以后Docker容器内部之间的网络连接在接收端便会显示对方的内网IP地址,不过,容器对外的网络连接仍由宿主机上的一个IP地址发出,而不是最开始发起连接的那个容器的IP地址。这一点有时候会让初学者感到困惑。
如果不希望Docker修改宿主机上的`iptables`规则,那么**必须**以`--iptables`参数设置为`false`的方式启动Docker服务。也可以通过简单地设置环境变量`DOCKER_OPTS`,然后重启服务进程达成这一点。默认情况下Docker配置里的这个参数是设置为`true`的。
**注意**:`DOCKER_OPTS`*必须在Docker守护进程启动前设置好。如果你希望你所做的更改能够持久化,甚至于在Docker宿主机重启后仍然能生效,那么你必须修改一些init服务配置或脚本文件。这在各个Linux发行版之间存在着些许不同,在Ubuntu上你可以通过修改*`/etc/default/docker`*文件来完成这一需求。*
Docker通过创建一个单独的`iptables`链来管理容器之间的访问,这使得系统管理员们可以很方便地以修改`DOCKER`链的方式来管理容器的外部访问,在此过程中他们无需接触宿主机上任何其他的`iptables`规则,这也就避免了一些意外修改的情况。在`DOCKER`链里追加和修改规则本质上即是Docker如何管理容器之间的**链接**的具体实现,这些内容我们将稍后介绍。
Docker守护进程会为每个新建的容器创建一个新的网络命名空间。然后它会生成一对veth设备。veth(Virtual Ethernet的简写)是一类特殊的Linux网络设备,它通常是成对(或者说结对)出现的,而且大致上来说它扮演的角色类似于是一个“网络管道”:从一端传入的任何数据都会被传到另外一端。事实证明,这很方便地实现了在Linux内核里不同网络命名空间之间的相互通信。Docker会将veth对等接口中的其中一个连接到容器的网络命名空间里然后在宿主机的网络命名空间里持有另外一个,后者的名称是一个带有**veth**前缀的随机生成的字串。veth对里的每一个对等接口只有在它当前所属的命名空间下才能看到。
随后,Docker会将宿主机上的veth对等接口绑定到`docker0`网桥上,然后为另外一个容器的veth对等接口分配一个之前Docker守护进程启动时选定的私有IP网段里的IP地址。一旦宿主机上的veth对等接口桥接上了`docker0`设备,Docker会立马在宿主机上的路由表上为之前选定的私有IP网段插入一条新的路由记录,并且开启宿主机上的IP转发。这便使得用户可以在宿主机上直接与容器通信。关于这一设定的详细过程如图12-2所示。
![图像说明文字](https://box.kancloud.cn/6561dbddeb0072d9df142a10e930b2ed_700x955.jpeg)
图12-2
默认情况下,Docker容器只能从内网访问,它们一般不对外提供路由。Docker并不提倡从外网访问容器,因此外界的宿主机一般很难直接和它们通信。
**注意**:*如果以将*`--iptables`*参数设置为*`false`*的方式启动Docker服务,它将不会再在宿主机上操作任何*`iptables`*的规则。它也不再会配置IP转发,这就意味着你的容器将无法再访问外界的应用甚至本地上其他的容器。如果这不是你预期的结果,可能就要考虑一下在系统上手动开启IP转发的配置。在Linux上,你可以通过设置*`/proc/sys/net/ipv4/ip_forward`*内核参数为**“**1**”**来实现这一点。*
在容器更换迭代频繁的情况下通过手工的方式来管理IP地址毕竟不是一个长久之计,一旦容器的数量上了规模将没办法继续这样做。这正是Docker在IP地址分配方面需要解决的问题。正如之前提到的那样,IP自动分配是**零配置网络**实现的基石之一,并且Docker手上已然有了一个实现的方案。Docker能够做到在没有任何人工干预的情况下为新创建的容器自动地分配IP地址。Docker守护进程会维护一组已经分配给那些正在运行的Docker容器的IP地址以避免为新容器再分配相同的IP地址。当一个容器停止或者被删除的时候,它的IP地址也会被回收到由Docker守护进程维护的IP地址池里,这样,当新容器启动时便可以直接复用这些IP资源。
如果在容器销毁后,释放的IP地址映射到容器对应[MAC地址](https://en.wikipedia.org/wiki/MAC_address)的[缓存ARP](https://en.wikipedia.org/wiki/Address_Resolution_Protocol)上的记录没有被立即清除,立刻复用该IP可能会导致宿主机本地网络的ARP冲突。Docker采取的办法是为每个已分配的IP地址生成一串随机MAC地址来解决这个问题。该MAC地址生成器确保是强一致的:相同的IP地址生成的MAC地址将会是完全一致的。Docker也允许用户在创建新容器时手动指定MAC地址,然而,由于上述所提到的ARP冲突的问题,我们并不建议这样做,除非你想出一些其他的机制可以规避它。
太好了!IP(和MAC地址)的分配都是在没有任何用户手动干涉的情况下**“自动”**完成。一旦新容器被创建出来,它们便会以其自动分配的IP地址出现在Docker的私有网络里。而这正是你想要借助**零配置网络**实现的。Docker甚至还更进了一步:为了使得运行在容器里的服务之间能够相互通信,Docker还必须支持**端口的分配**。
当一个容器启动时,Docker**可以**为它自动分配任意的UDP或者TCP端口并且使之能够在宿主机上被访问。用户可以在构建容器的镜像时通过在`Dockerfile`里使用`EXPOSE`指令来指定对外**公开**的端口,也可以通过`--expose`命令在容器启动的时候显式声明。该命令允许用户定义某个范围内的端口而不只是单个的端口映射。然而,要知道声明大范围的端口段的影响在于所有相关的信息都是可以通过Docker服务的远程API获取的,因此查询一个占有巨大端口段的容器的话很容易就会暴露Docker守护进程。
Docker守护进程随后会从Linux宿主机上的文件——`/proc/sys/net/ipv4/ip_local_port_range`定义的一个端口范围里挑选出一个随机的端口号。如果失败,如当Docker守护进程运行在非Linux宿主机上时,Docker将会改为从这个端口范围(49153~65535)里申请对应的端口。这并不是自动完成的:Docker守护进程只维护正在宿主机上运行的容器公开的端口号。用户必须明确地通过被Docker称之为**发布端口**的方式触发一次宿主机的端口映射。发布端口使得用户可以绑定任意**公开的端口**到宿主机上任何一个对外可路由的IP地址。如果用户在之前构建镜像的时候没有设置任何公开的端口,那么发布端口将对运行在Docker容器里的服务的对外可用性没有任何影响。
用户可以通过执行如下命令来找出指定容器对外公开的具体端口信息:
`# docker inspect -f '{{.Config.ExposedPorts}}' <container_id>`**警告**:*用户只能在启动新容器的时候为其发布对应的公开端口。一旦容器已经开始运行,我们将没有办法再发布其他的公开端口。必须从头开始重新创建容器!*
用户可以选择发布所有的公开端口或者只发布那些用户挑选出来的愿意让它对外可访问的。Docker提供了非常便利的命令行参数来实现各种组合。用户可以通过Docker帮助来了解所有可用的参数选项。
端口分配的背后主要在于`iptables`的妙用,Docker就是通过灵活运用之前提到的`DOCKER`链和`nat`表实现这一点的。为了帮助读者更好地理解这项功能,我们将通过图12-3所示的具体案例来讲解相关内容。
![图像说明文字](https://box.kancloud.cn/600a44c658e4abcbab2dc2fbf9b0fa08_700x761.jpeg)
图12-3
假设,我们想在Docker容器里运行`nginx` Web服务并且需要通过宿主机上的可对外路由的IP地址`1.2.3.4`和对应的[TCP](https://en.wikipedia.org/wiki/Transmission_Control_Protocol)端口`80`使其能够被外界访问。我们需要用到`library/nginx`的Docker镜像,实际上,当用户运行`docker pull nginx`命令的时候,Docker从Docker Hub上拉取的默认镜像便是我们需要用到的`library/nginx`镜像。我们通过执行如下命令(记得带上`-p`参数)来完成上述任务:
```
# sudo docker run -d -p 1.2.3.4:80:80 nginx
a10e2dc0fdfb2dc8259e9671dccd6853d77c831b3a19e3c5863b133976ca4691
#
```
可以通过执行以下命令来验证由这个镜像创建出的容器是否的确公开了TCP 80端口:
```
# sudo docker inspect -f '{{.Config.ExposedPorts}}'a10e2dc0fdfb
map[443/tcp:map[] 80/tcp:map[]]
```
可以看到,我们用来实例化容器的`nginx`镜像本身还公开了443端口。现在,容器已经开始运行,用户可以通过执行以下命令轻松地来检索它公开的所有端口(也称为主机端口绑定):
```
# sudo docker inspect -f '{{.HostConfig.PortBindings}}'
a10e2dc0fdfb
map[80/tcp:[map[HostIp:1.2.3.4 HostPort:80]]]
```
**提示**:*这里有一个简便命令行来检查一个特定Docker容器IP∶port绑定的内容:*`docker port container_id`*。*
太棒了!`nginx`如今在Docker容器里运行并且它能够通过之前给定的IP地址和端口对外提供服务。我们现在可以通过执行简单的`curl`命令在外面的宿主机(当然,我们假定你的防火墙没有禁止外界对80端口的访问)上访问其默认的`nginx`站点:
```
# curl -I 1.2.3.4:80
HTTP/1.1 200 OK
Server: nginx/1.7.11
Date: Wed, 01 Apr 2015 12:48:47 GMT
Content-Type: text/html
Content-Length: 612
Last-Modified: Tue, 24 Mar 2015 16:49:43 GMT
Connection: keep-alive
ETag: "551195a7-264"
Accept-Ranges: bytes
```
当用户在宿主机上对外公开一个端口时,Docker守护进程会在`DOCKER`链里追加一条新的`iptables`规则,它将会把宿主机上所有目标是`1.2.3.4:80`的流量重定向到一个特定容器的80端口上,并且会据此修改`nat`表的规则。用户可以通过运行如下命令轻松地检索这些信息:
```
# iptables -nL DOCKER
Chain DOCKER (1 references)
target prot opt source destination
**ACCEPT tcp -- 0.0.0.0/0 1.2.3.4
tcp dpt:80**
# iptables -nL -t nat
Chain PREROUTING (policy ACCEPT)
target prot opt source destination
DOCKER all -- 0.0.0.0/0 0.0.0.0/0
ADDRTYPE match dst-type LOCAL
Chain INPUT (policy ACCEPT)
target prot opt source destination
Chain OUTPUT (policy ACCEPT)
target prot opt source destination
DOCKER all -- 0.0.0.0/0 !127.0.0.0/8
ADDRTYPE match dst-type LOCAL
Chain POSTROUTING (policy ACCEPT)
target prot opt source destination
MASQUERADE all -- 172.17.0.0/16 0.0.0.0/0
**MASQUERADE tcp -- 172.17.0.5
172.17.0.5 tcp dpt:80**
Chain DOCKER (2 references)
target prot opt source destination
**DNAT tcp -- 0.0.0.0/0 1.2.3.4 tcp
dpt:80 to:172.17.0.5:80**
```
**注意**:*当关闭或删除一个发布了一定数量端口的容器时,Docker会删除它最初创建的所有必需的*`iptables`*规则,因此用户不必再担心这些。*
最后,让我们来看一看如果我们使用`-P`选项来启动一个新容器的话会发生什么,使用该参数意味着我们无需再为公开端口指定任何主机端口或者IP地址映射信息,Docker将自动帮我们完成端口的映射。我们将和之前的例子一样使用相同的`nginx`镜像:
```
# docker run -d -P nginx
995faf55ede505c001202b7ee197a552cb4f507bc40203a3d86705e9d08ee71d
```
同之前提到的那样,由于我们没有显式地指定将容器端口绑定到宿主机的接口上,因此Docker将会把这些端口映射到宿主机上的一个随机端口上。可以通过执行以下命令来发现该容器绑定的端口信息:
```
# docker port 995faf55ede5
443/tcp -> 0.0.0.0:49153
80/tcp -> 0.0.0.0:49154
```
当然,也可以通过执行以下命令直接检索网络端口的信息:
```
# docker inspect -f '{{ .NetworkSettings.Ports }}' 995faf55ede5
map[443/tcp:[map[HostIp:0.0.0.0 HostPort:49153]] 80/tcp:
[map[HostIp:0.0.0.0 HostPort:49154]]]
```
可以看到两个公开的TCP端口分别被绑定到了**宿主机上所有网络接口**的`49153`和`49154`端口。读者可能已经猜到了,Docker正是通过它的远程API公开的这些信息,它为需要相互通信的各个容器实现了一个非常简单的服务发现。当用户启动一个新容器时可以通过一个环境变量来传入对应的宿主机端口映射信息。关于这一点,我们还可以通过一个更好也更安全的方式来实现,这部分内容我们将在本章的稍后部分详细介绍。
所有的这一切看起来棒极了。我们不用再去手动管理运行在宿主机上的Docker容器的IP地址:Docker包揽了一切!然而,当我们开始扩大Docker基础设施的规模的时候,一些问题可能就会暴露出来。由于Docker守护进程接管了特定的Docker宿主机上所有IP分配的活,我们该怎样确保它不会在不同的宿主机上针对不同的容器分配相同的IP地址呢?甚至于我们是否需要关注这个问题呢?针对这些问题的答案也是很常见的:**这得看情况**。我们将在12.5节进一步探讨相关话题。现在让我们先转到零配置网络实现的下一个重要组成部分:域名解析。
Docker提供了一些配置参数允许用户在容器基础设施里管理域名的解析。同以往一样,为了利用好这些功能而又不会引发一些不可预料的结果,我们需要先了解一下Docker内部是如何处理域名解析的。
当Docker创建新容器时,默认情况下它会给该容器分配一个随机生成的**主机名**以及一个唯一的**容器名**。这是两个完全不同但是又容易搞混的概念,尤其对于新手来讲,他们在使用时常常会混淆这两个概念。
主机名可以理解为就是一个普通的Linux主机名:它允许运行在容器里的进程通过解析该容器的主机名来获取它对应的IP地址。容器的主机名**并不是一个可以在容器外部环境解析的域名**,而且默认情况下它会被设置为**容器的ID**,即一串允许用户从命令行或者通过远程API在宿主机上定位任意容器的唯一字符串[\[1\]](part0018.xhtml#anchor121)。**容器名**,从另一方面来说,是一个Docker内部的概念,它与Linux无关。
可以通过使用Docker命令行工具执行如下命令来查询容器名:
`docker inspect -f '{{.Name}}' container_id`容器名在Docker里主要有两个用途:
- 与容器ID相比,它使得用户可以通过远程API使用友好、可读的名称来查找容器;
- 它有助于构建一个基本的基于Docker的容器发现。
用户可以使用Docker客户端提供的一些特定的命令行参数来重载Docker守护进程设定的默认值。
容器的主机名和容器名可以被设置成相同的值,但是除非你有一些自动管理它们的方法,否则我们建议不要这样做,因为在一个高密度的容器部署的条件下,这可能会变成一个难以维护的情况并将成为运维人员的噩梦。
在容器镜像构建时无论是主机名还是容器名都不会硬编码到容器镜像里面。Docker实际上会主动在Docker宿主机上生成`/etc/hostname`和`/etc/hosts`文件,然后在新创建的容器启动时将两者绑定挂载到里面。用户可以通过检查宿主机里下述文件的内容来确认这一点:
```
# cat /var/lib/docker/containers/container_id/hostname
# cat /var/lib/docker/containers/container_id/hosts
```
**提示**:*可以通过在命令行里运行*`docker inspect -f '{{printf "%s\n%s" .HostnamePath .HostsPath}}' container_id`*命令得到相应文件的具体路径信息。*
我们将在本章的后面部分讨论更多关于**容器名**概念的详细内容;现在我们只需要记住,如果用户想通过一个友好、可读的名字而不是随机生成的容器ID来查找Docker容器,就可以给它们分配一个自定义的名字。用户**无法**修改一个已经创建好的容器的名称——遇到这种情况只能从头重新创建它。
**注意**:*从0.7版本起,Docker会用一些著名的科学家和黑客的名字来命名容器。如果你也想为Docker项目做一份贡献,可以在[GitHub](https://github.com/docker/docker)上开一个[Pull Request](https://help.github.com/articles/using-pull-requests/)加入你认为值得推荐的名人。在Docker里,处理这个的[Go](https://golang.org)包叫做`namesgenerator,`在Docker的代码库中的`pkg`子目录下找到它。*
现在你已经知道容器是如何将它的主机名解析为对应的IP地址了,那么是时候再去了解下它是如何解析外部DNS域名了。如果你猜到容器可能是像平常Linux主机那样使用`/etc/resolv.conf`文件来实现,那么恭喜你,答对了!Docker会在宿主机上为每个新创建的容器生成`/etc/resolv.conf`文件,然后当启动该容器时将这个文件挂载到容器里。用户可以通过在Docker宿主机上检索以下文件的内容来验证这一点:
`# sudo cat /var/lib/docker/containers/container_id/resolv.conf`**提示**:*可以通过在命令行执行*`docker inspect -f '{{.ResolvConfPath}}' container_id`*来找出上述文件的具体路径。*
默认情况下,Docker会将宿主机上的`/etc/resolv.conf`文件复用到新创建的容器里。在**1.5**版以后,当用户在宿主机上通过修改这个文件更改了DNS配置时,如果希望这些变动也应用到现有已经在运行的容器(那些使用之前的配置创建的),则**必须重启**这些容器方能使之生效。如果你还在用老版本,那就不是这样了,你必须从头**重新创建**这些容器。
如果不想Docker容器采用宿主机的DNS配置,可以通过修改环境变量`DOCKER_OPTS`来重载它们,可以在启动守护进程时通过命令行参数或者通过修改宿主机上一个特定的配置文件将变动固化下来(在Ubuntu Linux发行版上这个文件是`/etc/default/docker`)。
假设用户现在有一个专门的DNS服务器,并且希望自己的Docker容器都使用它来完成DNS的解析。再假定这个DNS服务器可以用`1.2.3.4` IP地址访问并且它管理了[example.comexample.com](http://example.com)域。这样的话,用户可能需要按如下方式修改环境变量`DOCKER_OPTS`:
`DOCKER_OPTS="--dns 1.2.3.4 --dns-search example.com”`为了让Docker守护进程应用新配置,需要重启一次守护进程。用户可以通过执行以下命令检查`/etc/resolv.conf`的内容来验证新创建的容器现在是否真的采用了新的DNS配置:
```
# sudo cat /var/lib/docker/containers/container_id/resolv.conf
nameserver 1.2.3.4
search example.com
```
所有在Docke守护进程的DNS配置修改之前已经启动的容器将仍然保持原来的配置。如果希望这些容器采用新配置,就必须要从头重新创建它们,简单的重启容器无法实现预期效果!
**注意**:*之前所提到的Docker守护进程的DNS选项参数将不会重载宿主机上的DNS配置。它们只会作为自Docker守护进程开始应用这一新配置起在宿主机上创建的所有容器的默认DNS配置。然而用户也可以针对每个容器显式地重载他们的配置。*
Docker甚至允许更细粒度地控制容器的DNS配置。用户可以在启动新容器时像这样重载其全局的DNS配置:
```
# sudo docker run -d --dns 8.8.8.8 nginx
995faf55ede505c001202b7ee197a552cb4f507bc40203a3d86705e9d08ee71d
# sudo cat $(docker inspect -f '{{.ResolvConfPath}}'
995faf55ede5)
nameserver 8.8.8.8
search example.com
```
读者可能已经注意到了这里面的一个小细节。正如在上述命令的输出中所能看到的,我们只是为新容器设置了`--dns`的配置,但是`/etc/resolv.conf`里面的search指令已经被修改了。Docker将把命令行上指定的参数配置和Docker守护进程本身规定的配置两者做一次**合并**。当Docker守护进程设置`--dns-search`为一些特定域时,如果用户在启动新容器的时候只重载了`--dns`参数,容器将会继承Docker服务设定的搜索域配置,而不是宿主机上的配置。目前我们没有办法改变这一行为,因此必须留意这一行为!
此外,当Docker守护进程指定的DNS设置发生变动时,采用自定义DNS配置创建的容器将不会受到任何影响,即使在这之后重启这些容器也同样如此。如果想让它们转为采用Docker守护进程的配置,那么必须以不指定任何自定义参数的方式重新创建它们。
**警告**:*如果用户在正在运行的容器里直接修改了*`/etc/resolv.conf、/etc/hostname`*或者*`/etc/hosts`*文件,要注意*`docker commit`*不会保存用户所**做**的这些变更,并且在容器重启之后,这些变更也不会被保留下来!*
正如在本章中所看到的,Docker在无需任何人工干预的情况下自动地完成了每个容器的DNS配置。这再一次完美的践行了**零配置网络**的准则。甚至于如果用户把容器导出并迁移到其他的宿主机,Docker仍会采用之前的DNS配置,所以用户无需担心再为它们从头配置一次DNS。
能够在容器启动后在其内部直接解析外部DNS域名固然是非常方便的,但是如果我们希望能从容器里访问其他容器里的服务呢?正如之前所了解的,容器的主机名在他们外部是无法解析的,因此不能使用容器的主机名来完成容器之间的通信。而为了使一个容器能够和另外一个容器通信,就必须知道其他容器的IP地址,可以通过查询Docker远程API得到这些信息。但是这一点在容器内部是没有办法做到的,除非用户在容器启动时绑定挂载Docker守护进程的套接字或者在`docker0`网桥接口上公开Docker API服务。此外,查询API本身有一定的额外开销,这会带来不必要的复杂度,并且有点背离我们最初设定零配置网络的初衷。要解决这个问题归根结底还是得依靠零配置网络的最后一个核心组成:服务发现。
Docker提供了一个开箱即用的、虽然基础,但是功能却非常强大的服务发现机制:**Docker链接**。正如我们之前所了解到的,在Docker的内网里,所有的容器都是可以访问得到的,因此默认只要它们知道彼此的IP地址,相互之间便能够直接通信。但是仅仅发现其他容器的IP地址还不够,还得找出容器化的服务接受外界请求时连接所需的端口信息。
Docker链接使得用户可以让任意容器发现其他Docker容器的IP地址和公开的端口。Docker提供了一个非常方便的命令行参数来实现这一点,我们不必再大费周折,它会帮我们自动搞定这一切。该参数即`--link`。当创建一个新容器并将它*链接*到其他容器时,Docker会将所有连接方面的具体数据以多个环境变量的形式导出到源容器里。这可能比较难理解。让我们通过以下的具体案例来讲解得更清楚些。
我们将通过`nginx`容器来讲解如何完成Docker链接,首先从检索IP地址和分配端口开始。可以通过执行如下命令来找出容器的IP地址:
```
# docker ps -q
a10e2dc0fdfb
# docker inspect -f '{{.NetworkSettings.IPAddress}}'
a10e2dc0fdfb
172.17.0.2
```
`--link`参数遵循如下语法格式:`container_id:alias`。这里面的`container_id`是运行中的容器的`id`,而`alias`**(**别名**)**是一个随便起的名字,关于这块内容我们将在后面部分详细解释。我们将会试着在一个新的“一次性”容器(带上`--rm`标志即意味着一旦容器退出便会将容器删除)里ping `nginx`容器的IP地址。
```
# docker run --rm -it --link=a10e2dc0fdfb:test busybox ping -c
2 172.17.0.2
PING 172.17.0.2 (172.17.0.2): 56 data bytes
64 bytes from 172.17.0.2: seq=0 ttl=64 time=0.615 ms
64 bytes from 172.17.0.2: seq=1 ttl=64 time=0.248 ms
--- 172.17.0.2 ping statistics ---
2 packets transmitted, 2 packets received, 0% packet loss
round-trip min/avg/max = 0.248/0.431/0.615 ms
```
正如预期那样,默认情况下,在内网里容器之间可以相互通信(除非容器之间的通信被禁用了,关于这一点,可以在12.5节中了解更多的内容)。
当容器被链接时,Docker会自动更新源容器上的`/etc/hosts`文件,将命令行里带的链接别名和目标容器的IP地址关联上,在我们这个例子里便是简单的"test"。可以通过运行如下命令来验证这一点(见输出最底下那条记录):
```
# docker run --rm -it --link=a10e2dc0fdfb:**test** busybox
cat /etc/hosts
172.17.0.13 a51e855bac00
127.0.0.1 localhost
::1 localhost ip6-localhost ip6-loopback
fe00::0 ip6-localnet
ff00::0 ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
**172.17.0.2 test**
```
这意味着相比于之前做的ping测试那样直接使用目标容器的IP地址,还可以像下面这样通过被链接的容器的别名来引用它:
```
# docker run --rm -it --link=edb055f7f592:test busybox ping -c
2 test
PING test (172.17.0.2): 56 data bytes
64 bytes from 172.17.0.2: seq=0 ttl=64 time=0.492 ms
64 bytes from 172.17.0.2: seq=1 ttl=64 time=0.230 ms
--- test ping statistics ---
2 packets transmitted, 2 packets received, 0% packet loss
round-trip min/avg/max = 0.230/0.361/0.492 ms
```
我们前面还提到Docker在链接两个容器时还会为之创建一些环境变量,它们可以帮助源容器轻松发现目标容器公开的服务端口。当源容器尝试和目标容器建立连接时它无需知道对方的IP地址和公开的端口,只需读取环境变量里的对应内容即可,它们是Docker为**每个公开的端口**自动创建的,并且会推送到源容器的执行环境里。这些环境变量的名字遵循以下格式:
```
ALIAS_NAME
ALIAS_PORT
ALIAS_PORT_<EXPOSEDPORT>_TCP
ALIAS_PORT_<EXPOSEDPORT>_TCP_PROTO
ALIAS_PORT_<EXPOSEDPORT>_TCP_PORT
ALIAS_PORT_<EXPOSEDPORT>_TCP_ADDR
...
...
```
可以通过运行以下命令和检查对应的输出结果来验证这一点:
```
# docker run --rm -it --link=a10e2dc0fdfb:test busybox env
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/
bin
HOSTNAME=ff03fba501ea
TERM=xterm
TEST_PORT=tcp://172.17.0.2:80
TEST_PORT_80_TCP=tcp://172.17.0.2:80
TEST_PORT_80_TCP_ADDR=172.17.0.2
TEST_PORT_80_TCP_PORT=80
TEST_PORT_80_TCP_PROTO=tcp
TEST_PORT_443_TCP=tcp://172.17.0.2:443
TEST_PORT_443_TCP_ADDR=172.17.0.2
TEST_PORT_443_TCP_PORT=443
TEST_PORT_443_TCP_PROTO=tcp
TEST_NAME=/mad_euclid/test
TEST_ENV_NGINX_VERSION=1.7.11-1~wheezy
HOME=/root
```
从上述案例可以看出,我们用来测试链接的`nginx`容器,公开了两个TCP端口:80和443。Docker在这里创建了两个环境变量:针对每个公开的端口分别是`TEST_PORT_80_TCP`和`TEST_PORT_443_TCP`。通过链接的方式导出这些环境变量,Docker在实现了一个简单可移植的服务发现功能的同时又保证了容器的安全可靠。然而天上永远不会掉馅饼。链接虽然是一个很棒的概念,但是你会发现它又是相对静态的。
当链接的目标容器消亡时,源容器便会丢失同链接容器所提供的服务的连接。在容器更替非常频繁的动态环境下这可能会是一个问题。除非用户在自己的应用里实现了一些基本的健康检测机制,或者说至少会做一些故障转移,否则还是应该时刻关注这方面的情况。
此外,当用户恢复发生故障的目标容器时,源容器的`/etc/hosts`文件将会自动加上目标容器新申请的IP地址,但是**通过链接的方式注入源容器的环境变量将不会被更新**,因此如果用户事先不知道服务的端口信息的话可能还是会不太理想。另外,最好不要依赖这些环境变量来发现目标容器的IP地址,我们更建议使用链接别名的方式,它会通过`/etc/hosts`文件自动解析更新后的IP地址。
解决链接本身数据更新不及时问题的一种办法是使用一个名为[docker-grand-ambassador](https://github.com/cpuguy83/docker-grand-ambassador)的工具。它致力于解决在使用Docker链接时可能会遇到的一些问题。更高级并且广泛适用的一个方案便是采用现有的一些不错的DNS服务软件来解决容器的服务发现,毕竟它不会引入额外的复杂度。目前,开源界已经有一些现成的DNS服务的实现方案,他们提供了对Docker容器开箱即用的服务发现的支持,并且不用耗费太大力气便能将其集成到Docker基础设施里。
加上这一节讨论的Docker的服务发现,至此我们关于零配置网络的几个核心组件的介绍基本告一段落。可以看到,当用户在同一台宿主机上运行所有的容器时,Docker能够高水准地满足零配置网络的所有需求。然而,遗憾的是,一旦用户把自己的容器扩展到多台机器或者甚至是多个云服务厂商时Docker便无法完美地交付。我们非常期待[Docker Swarm](https://github.com/docker/swarm/)即将发布的新特性以及新的[网络模型](https://blog.docker.com/2015/04/docker-networking-takes-a-step-in-the-right-direction-2/),它们应该能解决这一节所遇到的问题[\[2\]](part0018.xhtml#anchor122)。
现在,让我们转到一些更高级的网络主题,深入探讨下Docker的核心网络模型,并且我们会在这里讨论下前面章节中遇到的一些问题的解决方案。
这一节我们将首先从网络安全方面谈起,然后我们会转到探讨关于多个Docker宿主机跨主机的容器间如何通信的话题,最后我们将讨论一下网络命名空间的共享。
#### 12.5.1 网络安全
网络安全实际上是一个相当复杂的话题,关于它的内容甚至可以出一本单独的书来讲解。在本节中我们将会只覆盖到里面的几个小部分,主要是在设计Docker基础设施或者是使用外部供应商提供的Docker基础服务时需要注意的一些地方。我们的重点会放在Docker原生提供的一些解决方案,当然我们会在本节的末尾部分介绍一些其他的替代方案。
默认情况下,Docker允许容器之间可以不受限制地随意通信。很显然,正如你所预见,这可能会存在一些潜在的安全风险。一旦Docker网络上的某个容器出了问题并且对同一网络内的其他容器发动了拒绝[服务攻击](https://en.wikipedia.org/wiki/Denial-of-service_attack)的话该怎么办?这些网络攻击,有些可能是恶意发起的,也有可能只是软件bug所引发的。这类情况在多租户的环境下尤其应该得到重视。
幸运的是,Docker允许完全禁用容器之间的通信,只要在Docker守护进程启动时传入一个特定的参数即可。该参数名为`--icc`[\[3\]](part0018.xhtml#anchor123),默认情况下该参数是设置为`true`的。如果想完全禁用Docker容器之间的通信,那么必须通过修改环境变量`DOCKER_OPTS`将该参数设置成`false`,随后重启Docker守护进程使之生效。此方法的实现原理是,Docker守护进程会在`FORWARD`链里插入一条新的`DROP`策略的`iptables`规则,它会丢弃所有目标是Docker容器的包。在Docker守护进程重启完成后,可以通过执行如下命令来验证这一点:
```
# sudo iptables -nL FORWARD
Chain FORWARD (policy ACCEPT)
target prot opt source destination
DOCKER all -- 0.0.0.0/0 0.0.0.0/0
**DROP all -- 0.0.0.0/0 0.0.0.0/0**
ACCEPT all -- 0.0.0.0/0 0.0.0.0/0
ctstate RELATED,ESTABLISHED
ACCEPT all -- 0.0.0.0/0 0.0.0.0/0
```
从这一刻起,容器之间就再也无法互相通信了。我们可以对之前已经在运行的`nginx`容器再作一次简单的`ping`测试来验证这一点。首先,我们得拿到`nginx`容器对应的IP地址:
```
# docker inspect -f '{{.NetworkSettings.IPAddress}}'
a10e2dc0fdfb
172.17.0.2
```
现在,让我们从一个一次性容器里ping它:
```
# docker run --rm -it busybox ping -c 2 172.17.0.2
PING 172.17.0.2 (172.17.0.2): 56 data bytes
--- 172.17.0.2 ping statistics ---
2 packets transmitted, 0 packets received, 100% packet loss
```
正如所见,由于容器间的通信已经被禁用了,因此该ping测试的结果显示的是**完全**失败。然而,我们依旧可以在宿主机上连接`nginx`容器:
```
# ping -c 2 172.17.0.2
PING 172.17.0.2 (172.17.0.2) 56(84) bytes of data.
64 bytes from 172.17.0.2: icmp_seq=1 ttl=64 time=0.098 ms
64 bytes from 172.17.0.2: icmp_seq=2 ttl=64 time=0.066 ms
--- 172.17.0.2 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 999ms
rtt min/avg/max/mdev = 0.066/0.082/0.098/0.016 ms
```
此外,所有之前已经发布的端口保持不变,因此运行在`nginx`容器里的`nginx`服务仍然可以正常访问:
```
# curl -I 1.2.3.4:80
HTTP/1.1 200 OK
Server: nginx/1.7.11
Date: Wed, 01 Apr 2015 12:48:47 GMT
Content-Type: text/html
Content-Length: 612
Last-Modified: Tue, 24 Mar 2015 16:49:43 GMT
Connection: keep-alive
ETag: "551195a7-264"
Accept-Ranges: bytes
```
那么我们现在该怎样**只**启用那些需要相互通信的容器之间的访问呢?答案当然还是`iptables`。如果不想手工管理这些`iptables`规则,别担心,Docker提供了一个很方便的命令行参数,这样的话,当用户创建新容器时,Docker便会帮助用户自动地完成这些配置。事实上,这个参数我们在服务发现一节里便已经介绍过,没错,它就是:`--link`。Docker容器之间的链接不仅可以为我们提供一个简单的服务发现机制,它也为链接的容器之间提供了一个安全的网络通信:**只有**相互链接的容器之间才能相互通信,并且它们**只能**访问那些公开的服务端口。Docker正是通过在`DOCKER`链里插入一个**“双向通信”**的`iptables`规则来实现的这一点。
一些实际案例可能更有助于我们加强对这部分内容的理解。下面,我们仍然复用之前已经在运行的`nginx`容器,并且将它链接到一个一次性容器。让我们首先验证一下链接是否真的只允许特定的公开端口的通信。因为`nginx`容器仍然在运行,所以它的IP地址自然还是之前分配的那个:
```
# docker inspect -f '{{.NetworkSettings.IPAddress}}'
a10e2dc0fdfb
172.17.0.2
```
然后,我们再用一个一次性容器去ping它:
```
# docker run --rm -it -link=a10e2dc0fdfb:test busybox ping -c 2
172.17.0.2
PING 172.17.0.2 (172.17.0.2): 56 data bytes
--- 172.17.0.2 ping statistics ---
2 packets transmitted, 0 packets received, 100% packet loss
```
由于ping使用的是[ICMP协议](https://en.wikipedia.org/wiki/Internet_Control_Message_Protocol),它是一个网络层协议并且没有端口的概念,因此出现上述的ping**完全**失败的结果也就不足为奇了。我们可以通过执行如下命令来验证`nginx`容器里工作的默认站点是否仍然如预期那样完美地提供服务:
```
# docker run --rm -link=a10e2dc0fdfb:test -ti busybox wget
172.17.0.2
--2015-04-01 14:11:58-- http://172.17.0.2/
Connecting to 172.17.0.2:80... connected.
HTTP request sent, awaiting response... 200 OK
Length: 612 [text/html]
Saving to: 'index.html'
100%
[===============================================================>]
612 --.-K/s in 0s
2015-04-01 14:11:58 (16.4 MB/s) - 'index.html' saved [612/612]
```
还可以通过执行如下命令来检查`DOCKER`链的内容,从而查看Docker在容器被链接时创建的`iptables`规则:
```
# iptables -nL DOCKER
Chain DOCKER (1 references)
target prot opt source destination
ACCEPT tcp -- 0.0.0.0/0 172.17.0.2
tcp dpt:80
ACCEPT tcp -- 172.17.0.9 172.17.0.2
tcp dpt:443
ACCEPT tcp -- 172.17.0.2 172.17.0.9
tcp spt:443
ACCEPT tcp -- 172.17.0.9 172.17.0.2
tcp dpt:80
ACCEPT tcp -- 172.17.0.2 172.17.0.9
tcp spt:80
```
就像我们之前所讲的那样,加固容器间通信的另外一种做法便是直接修改`DOCKER`链里的`iptables`规则。当然,建议最好不要这么做,因为当宿主机上运行了大量高密度容器的时候,这样做的话维护会是一个很大的问题,毕竟用户必须跟踪每个容器的生命周期,然后在容器交替迭代过程中不断地增删规则。
关于安全性另外一个很重要的点便是网络分段的问题。目前Docker在容器网络的分段方面没有提供任何原生的支持。所有容器之间的网络流量都是经过`docker0`网桥传输的。Linux里面的网桥是一种运行在**混杂**模式下的特殊的网络设备。这意味着任意一个在宿主机拥有root权限的人都有能力查看所有容器之间的网络交互的情况。这在多租户的环境下可能尤其需要注意,因此用户应该始终确保在自己的容器间两端往返的任意网络传输都是加密的。
正如之前所说,关于网络安全有说不完的内容。在这里,我们只是讨论了一些皮毛,并且重点放在一些基础概念上。现在,我们将继续下一个部分,探讨一下我们该如何完成跨Docker宿主机的容器间网络通信。
#### 12.5.2 多主机的容器间通信
与之前一样,有几种选择可供我们实现这一目标。有了之前介绍的端口发布和容器链接方面的理论基础,我们可以推广到Docker社区里著名的**大使模式**(ambassador pattern),它巧妙地结合了这些概念。
大使模式的工作原理是在所有你想互联的Docker宿主机上运行一个特殊的容器,然后使用它来完成不同Docker宿主机之间容器的相互通信。这个特殊容器会运行一个`socat`网络代理,它负责Docker宿主机之间连接的代理转发。图12-4清晰地展现了这一具体过程。
![图像说明文字](https://box.kancloud.cn/a9e7f05a10c7691d63bb5a7bd1b49770_700x155.jpeg)
图12-4
读者可通过阅读Docker的[官方文档](http://docs.docker.com/engine/articles/ambassador_pattern_linking/)来了解这一模式的更多内容。该模式的核心理念可以归结为在宿主机上发布端口,然后通过链接到在其他宿主机上运行的大使容器,根据该宿主机上的环境变量找出发布并对我们公开的端口服务信息,以此来实现跨宿主机的容器间的网络通信。该模式的弊端在于,用户必须在每台宿主机上运行一个额外的容器来处理代理转发。而好处是,如果用户是通过容器名称而不是容器的ID引用目标容器,那么此模式有助于提高容器的可移植性。另外,当想导出一个容器并且把它迁移到其他的Docker宿主机时,如果那台宿主机刚好运行了一个容器,并且它和用户所导出的容器应该链接的那个容器名字相同的话,用户只需要启动那个迁移好的容器即可。
既然我们已经介绍了一些Docker原生提供的实现跨宿主机的容器间网络通信的解决方案,那么是时候来看一些更复杂的概念了。具体来说,我们将介绍如何在用户自己的专有内网里运行和集成容器服务。
Docker允许用户为自己想要运行的容器明确指定一个IP网段。正如之前所了解到的,容器的IP地址是从Docker守护进程启动时随机选择的一个内网IP网段里申请和分配的。如果用户希望将容器运行在自己的内网环境里,可以在Docker守护进程启动时传入一个特定参数来指定自己的IP网段。这似乎是在用户的私有内网里实现跨宿主机的容器间通信的首选方案。
必须首先为`docker0`网桥分配一个IP地址,然后再为Docker容器指定一个IP地址段。这个IP地址段**必须是**该网桥所在网段的**一个子网**。因此,如果我们想让我们的容器在`192.168.20.0/24`网络上,我们可以将`DOCKER_OPTS`环境变量设置成下面的值:
`DOCKER_OPTS="--bip=192.168.20.5/24--fixed-cidr=192.168.20.0/25"`这看起来似乎是一个非常简便的实现Docker容器自定义内网配置的方法,实际上还需要为之在宿主机上添加一些特殊路由和`iptables`规则。这一方案的另外一个弊端或许你已经想到了,那便是IP地址的自动分配问题,这一点我们在之前讨论过。由于Docker守护进程之间不会通信,因此IP地址的分配可能会导致地址冲突。这也正是Docker期望解决的问题,在之后的版本如果引入了[libnetwork](https://github.com/docker/libnetwork)的话,这个问题应该可以迎刃而解。在此期间,可以利用Docker的第三方工具的集成来解决这个问题。
针对Docker私有多主机网络的实现,业界还有一种流行的解决方案便是使用开放[虚拟交换机](http://openvswitch.org)(OVS)和[GRE隧道](https://en.wikipedia.org/wiki/Generic_Routing_Encapsulation)。这背后的想法便是用OVS创建一个网桥来取代默认的`docker0`,然后在内网的宿主机之间创建一个安全的GRE隧道。这样做的话,用户至少需要对OVS如何运转有一些基本的了解。而且,希望互联的Docker宿主机越多,其复杂程度也越高(关于这一点,在Docker以后的版本里会通过引入[libnetwork](https://github.com/docker/libnetwork)加以解决,或者也可以使用[Swarm](https://github.com/docker/swarm)来规避这些问题)。默认情况下,使用OVS仍然无法解决我们早些时候提到的Docker跨多宿主机的IP地址分配的问题,因此用户需要采取一些额外的措施。关于如何将OVS用于Docker的更多内容请阅读这篇文章:https://goldmann.pl/blog/2014/01/21/ connecting-docker-containers-on-multiple-hosts/。
Docker还提供了另外一个更为简单的方案来实现跨多主机的容器间网络通信:**共享网络命名空间**。
#### 12.5.3 共享网络命名空间
共享网络命名空间的概念自[Kubernetes](http://kubernetes.io)项目发起以来开始被广泛大众接受,而它还有一个众所周知的名字,那便是[pod](https://github.com/GoogleCloudPlatform/kubernetes/blob/master/docs/pods.md)。下面,我们将介绍如何将网络命名空间共享用于Docker宿主机的互联和通信。
当启动一个新容器时,可以为之指定一个特殊的**“网络模式”**。网络模式允许用户指定Docker守护进程采用何种方式为新建的容器创建网络命名空间。Docker客户端也提供了`--net`命令行参数来实现这一点。默认情况下它设置为`bridge`,这一点本章的开头部分便已经介绍过。
为了能够共享任意数量的容器之间的网络命名空间,我们传入`--net`命令行参数时必须遵循下面的格式:`container:NAME_or_ID`。
为了使用这一参数,必须事先创建好一个“源”容器,该容器将创建一个其他容器都能加入的“基础”网络命名空间。同样地,我们将通过一个简单的案例来详细说明这一点。
我们将会创建一个新容器,它将加入我们之前运行的`nginx`容器所创建的网络命名空间里。但是在此之前,让我们先列出`nginx`容器里所有的网络接口的情况:
```
# docker exec -it a10e2dc0fdfb ip link list
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UN-
KNOWN mode DEFAULT
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
71: eth0: <BROADCAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state
UP mode DEFAULT
link/ether 02:42:ac:11:00:05 brd ff:ff:ff:ff:ff:ff
```
上述网络接口信息应该没什么特别的地方。现在,让我们新建一个新的一次性容器,它将加入上述容器的网络空间里并随后列出它所有可用的网络接口情况:
```
# docker run --rm -it --net=container:a10e2dc0fdfb busybox ip
link list
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UN-
KNOWN mode DEFAULT group default
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
71: eth0: <BROADCAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state
UP mode DEFAULT group default
link/ether 02:42:ac:11:00:05 brd ff:ff:ff:ff:ff:ff
```
与文档里所描述的一样,这也正是我们所预见的情况:新创建的容器可以看到并且绑定到`nginx`容器的网络接口上。新容器没有创建任何新的网络命名空间——它所做的只是加入到一个现有的命名空间而已。
网络空间共享的优势在于它只占用Linux内核很小一部分的资源,并且它可以作为一些场景下简单的网络管理的解决方案:它不需要用户关注任何`iptables`的规则,并且如果在内网上已经建立了网络连接,那么新进来的网络连接可以直接复用源自同一容器IP的连接。这一方案的另一个优势在于用户可以通过回环接口与运行在容器里的服务通信。但是,用户无法再把不同的服务绑定到相同的IP地址和端口上。
此外,如果源容器停止运行,用户必须重新创建它并让所有其他的容器重新加入它建立的新的网络命名空间里:网络命名空间是不能循环利用的!当然,用户可以通过创建一个“已命名”的网络命名空间来解决这个问题。读者可以在https://speakerdeck.com/gyre007/exploring-networking-in-linux-containers这篇演讲中找到关于网络命名空间的更多内容。目前,Docker并没有一个很好的办法来列举出所有共享了网络命名空间的容器。
现在,我们对于Docker容器之间的网络命名空间共享已经有了一个很好的认识,接下来,让我们一起来看看如何利用它来连接多个Docker宿主机以实现相互通信。窍门同样在于`--net`命令行参数的灵活运用。
Docker允许用户在创建容器的时候和它的宿主机共享网络命名空间。该宿主机本身在PID 1进程的命名空间里运行它的网络栈,这样的话,对于容器来说它们可以很轻松地加入宿主机的网络命名空间里。直白点儿讲便是:容器的网络栈和宿主机之间是共享的。
这就意味着加入宿主机的网络命名空间的容器将具备宿主机网络的**只读**权限(除非带上`—privileged`标志)。当用户在与宿主机共享网络命名空间的容器里启动一个新服务时,该服务会绑定到宿主机上任何可用的IP地址上,因此用户不必费多大劲便能实现它在宿主机上对外提供服务。
**警告**:*尽量避免在宿主机上共享网络命名空间,因为这可能会导致用户的宿主机存在一些潜在的安全风险。在Docker的安全问题完全解决之前用户都必须时刻小心这一点!*
下面,我们将通过一个非常简单的案例来加深对这一方案的实际理解。创建一个新容器,列出里面所有可用的网络接口,然后和宿主机上的网络接口做对比。首先,我们通过运行如下命令来列举宿主机上所有的网络接口:
```
# ip link list
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default
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 pfifo_fast state UP mode DEFAULT group default qlen 1000
link/ether 04:01:47:4f:c6:01 brd ff:ff:ff:ff:ff:ff
4: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default
link/ether 56:84:7a:fe:97:99 brd ff:ff:ff:ff:ff:ff
```
这里面没什么特殊的地方:我们有一个回环设备、一个以太网设备和一个Docker网桥。现在,让我们创建一个新的一次性容器,并且使得它和宿主机共享网络命名空间,然后列举该容器里所有可用的网络接口信息:
```
# docker run --rm --net=host -it busybox ip link list
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default
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 pfifo_fast state UP mode DEFAULT group default qlen 1000
link/ether 04:01:47:4f:c6:01 brd ff:ff:ff:ff:ff:ff
4: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default
link/ether 56:84:7a:fe:97:99 brd ff:ff:ff:ff:ff:ff
```
如你所见,该容器没有创建任何新的网络接口,并且宿主机上的所有接口对它都是可见的。这样一来便轻松实现了宿主机之间容器的相互连接和通信,它只是省去了Docker私有网络这一部分而已。然而有利就有弊,这同样也使得用户的容器直接暴露在了网络上,并且抹去了很多在私有网络命名空间里运行容器时所具备的优势。
此外,当我们在宿主机上共享网络命名空间时必须留意一个看上去不是那么显眼的事情。网络命名空间里包含了Unix套接字,这意味着宿主机上所有各种操作系统服务所公开的Unix套接字同样对容器可见。这有时候可能会导致一些意想不到的[事情](https://github.com/docker/docker/issues/6401)发生。当用户在一个与宿主机共享网络命名空间的容器里准备关闭服务的时候切记这一点,并且尽量避免将这些容器运行在授权模式下。
上述情况的一个简单的可用场景便是当用户没有私有的SSH密钥时,在**Docker构建时**(build time)用户可以通过SSH客户端进行网络代理,如果将它打包到容器的镜像里会造成一些安全隐患(除非用户在每次构建后回收密钥)。这个问题的解决办法之一是运行一个`socat`代理容器,它与宿主机共享网络命名空间,然后访问宿主机上的`ssh-agent`。随后该代理将会监听一个TCP端口,在构建时用户可以通过这个端口代理所有的内部传输。在这篇[gist](https://gist.github.com/milosgajdos83/0ca37bc08a28338d1475)中你可以看到和这相关的一个具体实例。
关于网络命名空间共享更安全的一个版本是先照常创建一个网络命名空间,然后使用`ip link set netns`命令把宿主机上的接口移到新的网络命名空间中,随后在容器里配置它。目前,Docker还没有对此提供任何命令行方面的支持。
这种类型的配置在大多数的云环境下都无法正常运行,因为它们很少会提供多个物理网络端口,并且也不接受在现有的端口里添加额外的MAC地址。然而,如果在带有多个物理端口的物理硬件下运行,像SR-IOV端口、VLAN或者macvtap接口,这种办法应该是可行的。
大多数的Docker部署都是使用标准的IPv4,但是许多大型数据中心的运营商,像Google和Facebook,在内部已经开始转向使用新的IPv6协议。IPv6地址不会短缺,它的地址位长足足有128位,而且为单个主机分配的IP地址是一个/64,或者是18 446 744 073 709 551 616个地址!
这意味着完全可以为每个容器分配一个它自己的全局可路由的IPv6地址,这也代表着跨主机的容器之间的通信会变得简单很多。
当前版本的Docker已经加入了对IPv6的支持,并且相应的[配置文档](http://docs.docker.com/engine/userguide/networking/)也已经整理了出来。Docker需要为容器分配一个至少/80的地址段,这意味着它可以在一个/64位地址的宿主机上工作,或者甚至在更大规模的时候还可以把单个的/64段分到不同的多台宿主机上,在这个配置下理论上最多可以有4096台宿主机。不同宿主机之间的容器甚至可以直接路由。
同样,在云环境下,至今仍然鲜有提供IPv6支持的厂商,但是这一点在未来有望得到改善。Digital Ocean每台宿主机上只提供了最小仅16个的IPv6地址段,虽然它宣称支持这一功能,但是实际实施起来还是有一定的难度,关于启用IPv6地址支持的方法,在其官方文档里的NDP代理部分有相应介绍。然而,也有像来自Bytemark的BigV和Vultr,他们提供的云服务支持一个全长的/64段IPv6的支持,有了它们,为Docker引入IPv6的支持也将不再是一件难事。
IPv4地址的短缺问题迫使更多的人把目光放到了IPv6的身上,它提供了一种没有NAT回归简单的网络拓扑结构,但是由于缺乏广泛的支持,IPv6的实际应用仍然存在问题。
正如本章所介绍的,Docker引擎提供了大量可供选择的方案来帮助连接基础架构中的容器。遗憾的是,更高级的配置需要大量的人力投入,并且需要用户对于像路由和`iptables`这样的网络内部原理有深刻的理解,而这对于只想运行应用而不关心底层网络细节的用户却是一件很痛苦的事情。此外,Docker提供的原生参数是相对静态的,并且在跨多宿主机方面的扩展性并不是很好。我们希望所有的问题最终都能通过将在新版Docker里发布的网络API来解决,该API基于早前提过的`libnetwork`实现。当然,在实际发布前也不必太过担心,快速迭代的Docker生态环境会帮你解决遇到的问题。
在标准的Docker网络试图将网络配置抽象化的同时,使用`iptables`以及潜在的隧道协议是有性能开销的。[New Relic](https://blog.newrelic.com)发现在某些情况下,对于需要高性能网络的应用程序来说,使用标准的Docker网络配置也许会比原生的本地网络要慢上20多倍。
业界已经有大量可供使用的工具来解决上述讨论的Docker网络方面的问题。最简单的,但也是非常强大的便是一个由Docker公司的[Jérôme Petazzoni](https://twitter.com/jpetazzo)独立开发的名为[pipework](https://github.com/jpetazzo/pipework)的工具。pipework本质上是一个简单的shell脚本,它支持像容器多IP地址配置等高级网络配置,允许使用Mac VLAN设备甚至是DHCP来完成IP地址的分配。甚至有人制作了一个专门的[Docker镜像](https://hub.docker.com/r/dreamcat4/pipework/),使得可以在容器中运行pipework。可以利用`docker compose`将其整合到应用程序中。
另一个非常有用的工具是由Weaveworks公司提供的,名为[weave](https://github.com/weaveworks/weave)。如果详细介绍weave可能需要一个单独的章节,但是在这里,我们只提供一些简单的介绍,读者可以通过本节提到的各种链接地址进一步了解与它相关的内容。weave可以跨多宿主机和云服务创建覆盖网络,如此一来用户便可以轻松地连接到各个Docker容器。weave还提供了一些非常强大的功能,如安全性方面的[增强](http://www.weave.works/weave-net-cryptography-faq/)和早前提到过的`weave-dns`,它实现了一个简易的基于DNS的服务发现。根据经验来看,weave算是市场上目前最易上手的工具,因为它不需要用户考虑太多底层网络方面的细节。该企业最近发布了一些非常有意思的功能,如[weave scope](https://github.com/weaveworks/scope),这是一个为容器提供更好的可视化效果的工具,它在高密度容器的环境里可以说是无价之宝。读者可以通过这篇\[文章\](http://thenewstack.io/how-to-detect-map-and-monitor-docker-containers-with-weave-scope- from-weaveworks/)对scope有一个大致的了解。weave版本1.0发布了一个重大特性,即[快速网络路径](http://www.weave.works/weave-fast-datapath/),它是基于我们之前讨论过的OVS实现的。使用该快速路径就意味着用户的网络不再是强加密的,但是这仍然是一个值得权衡的事情。最后,随着Docker近期发布的[Docker插件系统](https://blog.docker.com/2015/06/extending-docker-with-plugins/)的支持,原生Docker的集成也变得更加容易,用户只要把Weave当做[Docker的插件](http://www.weave.works/weave-as-a-docker-network-plugin/)使用就行了。
[CoreOS](https://coreos.com)也开发了一个名为[flannel](https://github.com/coreos/flannel)的覆盖网络解决方案。flannel最开始开发是为了解决Kubernetes原生依靠Google云平台为中央网络的网络依赖问题,但是自第一个版本发布以来该项目发生了很大的转变,如今它提供了一些如[VXLAN](https://en.wikipedia.org/wiki/Virtual_Extensible_LAN)这样很有意思的特性。flannel将它的配置信息存储在[etcd](https://github.com/coreos/etcd)中,这使得它可以和CoreOS操作系统轻松地整合在一起。
最后,在Docker网络领域最新的一员是一个名为[calico](http://www.projectcalico.org)的项目,它和OpenStack一样为Docker提供了一个3层的解决方案。目前该项目利用的是ClusterHQ的[powerstrip](https://github.com/ClusterHQ/powerstrip)工具,使用该工具可以在Docker引擎的原生插件实现前完成简单的插件注册功能。calico项目的最大优势便是它提供了原生的IPv6支持,并且易于在多Docker宿主机之间扩展。calico本身是通过某种跨多宿主机的分布式BGP路由来实现的这一点。这是一个值得长期关注的项目。
介绍完这些第三方工具之后,我们也将结束本次的Docker网络之旅。希望这里所描述的内容能够真正地帮助你更好地了解该如何建立适合自己的Docker基础设施的网络模型。
现在,我们将继续前行,在第13章中我们将讨论如何完成跨多宿主机的Docker容器的调度。
- - - - - -
[\[1\]](part0018.xhtml#ac121) 从1.10版起,Docker会根据镜像及镜像层数据的安全散列生成一个内容可寻址的安全ID。——译者注
[\[2\]](part0018.xhtml#ac122) 在本书纸版发行前,Docker v1.9已经发布,实现了跨主机的网络互通,并且支持不同的插件方式。——译者注
[\[3\]](part0018.xhtml#ac123) icc是Inter Container Communication的缩写。——译者注
- 版权信息
- 版权声明
- 内容提要
- 对本书的赞誉
- 译者介绍
- 前言
- 本书面向的读者
- 谁真的在生产环境中使用Docker
- 为什么使用Docker
- 开发环境与生产环境
- 我们所说的“生产环境”
- 功能内置与组合工具
- 哪些东西不要Docker化
- 技术审稿人
- 第1章 入门
- 1.1 术语
- 1.1.1 镜像与容器
- 1.1.2 容器与虚拟机
- 1.1.3 持续集成/持续交付
- 1.1.4 宿主机管理
- 1.1.5 编排
- 1.1.6 调度
- 1.1.7 发现
- 1.1.8 配置管理
- 1.2 从开发环境到生产环境
- 1.3 使用Docker的多种方式
- 1.4 可预期的情况
- 为什么Docker在生产环境如此困难
- 第2章 技术栈
- 2.1 构建系统
- 2.2 镜像仓库
- 2.3 宿主机管理
- 2.4 配置管理
- 2.5 部署
- 2.6 编排
- 第3章 示例:极简环境
- 3.1 保持各部分的简单
- 3.2 保持流程的简单
- 3.3 系统细节
- 利用systemd
- 3.4 集群范围的配置、通用配置及本地配置
- 3.5 部署服务
- 3.6 支撑服务
- 3.7 讨论
- 3.8 未来
- 3.9 小结
- 第4章 示例:Web环境
- 4.1 编排
- 4.1.1 让服务器上的Docker进入准备运行容器的状态
- 4.1.2 让容器运行
- 4.2 连网
- 4.3 数据存储
- 4.4 日志
- 4.5 监控
- 4.6 无须担心新依赖
- 4.7 零停机时间
- 4.8 服务回滚
- 4.9 小结
- 第5章 示例:Beanstalk环境
- 5.1 构建容器的过程
- 部署/更新容器的过程
- 5.2 日志
- 5.3 监控
- 5.4 安全
- 5.5 小结
- 第6章 安全
- 6.1 威胁模型
- 6.2 容器与安全性
- 6.3 内核更新
- 6.4 容器更新
- 6.5 suid及guid二进制文件
- 6.6 容器内的root
- 6.7 权能
- 6.8 seccomp
- 6.9 内核安全框架
- 6.10 资源限制及cgroup
- 6.11 ulimit
- 6.12 用户命名空间
- 6.13 镜像验证
- 6.14 安全地运行Docker守护进程
- 6.15 监控
- 6.16 设备
- 6.17 挂载点
- 6.18 ssh
- 6.19 私钥分发
- 6.20 位置
- 第7章 构建镜像
- 7.1 此镜像非彼镜像
- 7.1.1 写时复制与高效的镜像存储与分发
- 7.1.2 Docker对写时复制的使用
- 7.2 镜像构建基本原理
- 7.2.1 分层的文件系统和空间控管
- 7.2.2 保持镜像小巧
- 7.2.3 让镜像可重用
- 7.2.4 在进程无法被配置时,通过环境变量让镜像可配置
- 7.2.5 让镜像在Docker变化时对自身进行重新配置
- 7.2.6 信任与镜像
- 7.2.7 让镜像不可变
- 7.3 小结
- 第8章 存储Docker镜像
- 8.1 启动并运行存储的Docker镜像
- 8.2 自动化构建
- 8.3 私有仓库
- 8.4 私有registry的扩展
- 8.4.1 S3
- 8.4.2 本地存储
- 8.4.3 对registry进行负载均衡
- 8.5 维护
- 8.6 对私有仓库进行加固
- 8.6.1 SSL
- 8.6.2 认证
- 8.7 保存/载入
- 8.8 最大限度地减小镜像体积
- 8.9 其他镜像仓库方案
- 第9章 CI/CD
- 9.1 让所有人都进行镜像构建与推送
- 9.2 在一个构建系统中构建所有镜像
- 9.3 不要使用或禁止使用非标准做法
- 9.4 使用标准基础镜像
- 9.5 使用Docker进行集成测试
- 9.6 小结
- 第10章 配置管理
- 10.1 配置管理与容器
- 10.2 面向容器的配置管理
- 10.2.1 Chef
- 10.2.2 Ansible
- 10.2.3 Salt Stack
- 10.2.4 Puppet
- 10.3 小结
- 第11章 Docker存储引擎
- 11.1 AUFS
- 11.2 DeviceMapper
- 11.3 BTRFS
- 11.4 OverlayFS
- 11.5 VFS
- 11.6 小结
- 第12章 Docker 网络实现
- 12.1 网络基础知识
- 12.2 IP地址的分配
- 端口的分配
- 12.3 域名解析
- 12.4 服务发现
- 12.5 Docker高级网络
- 12.6 IPv6
- 12.7 小结
- 第13章 调度
- 13.1 什么是调度
- 13.2 调度策略
- 13.3 Mesos
- 13.4 Kubernetes
- 13.5 OpenShift
- Red Hat公司首席工程师Clayton Coleman的想法
- 第14章 服务发现
- 14.1 DNS服务发现
- DNS服务器的重新发明
- 14.2 Zookeeper
- 14.3 基于Zookeeper的服务发现
- 14.4 etcd
- 基于etcd的服务发现
- 14.5 consul
- 14.5.1 基于consul的服务发现
- 14.5.2 registrator
- 14.6 Eureka
- 基于Eureka的服务发现
- 14.7 Smartstack
- 14.7.1 基于Smartstack的服务发现
- 14.7.2 Nerve
- 14.7.3 Synapse
- 14.8 nsqlookupd
- 14.9 小结
- 第15章 日志和监控
- 15.1 日志
- 15.1.1 Docker原生的日志支持
- 15.1.2 连接到Docker容器
- 15.1.3 将日志导出到宿主机
- 15.1.4 发送日志到集中式的日志平台
- 15.1.5 在其他容器一侧收集日志
- 15.2 监控
- 15.2.1 基于宿主机的监控
- 15.2.2 基于Docker守护进程的监控
- 15.2.3 基于容器的监控
- 15.3 小结
- DockOne社区简介
- 看完了