Docker网络基础
-
Docker网络架构
如图3-16所示,Docker daemon通过调用libnetwork对外提供的API完成网络的创建和管理等功能。libnetwork中则使用了CNM来完成网络功能的提供。而CNM中主要有沙盒(sandbox ),端点(endpoint)和网络(network)这3种组件。libnetwork中内置的5种驱动则为libnetwork提供了不同类型的网络服务。下面分别对CNM中的3个核心组件和libnetwork中的5种内置驱动进行介绍。
CNM中的3个核心组件如下:
- 沙盒:一个沙盒包含了一个容器网络栈的信息。沙盒可以对容器的接口、路由和DNS设置等进行管理。沙盒的实现可以是Linux network namespace, FreeBSD Jail或者类似的机制。一个沙盒可以有多个端点和多个网络。
- 端点:一个端点可以加入一个沙盒和一个网络。端点的实现可以是veth pair, Open vSwitch内部端口或者相似的设备。一个端点只可以属于一个网络并且只属于一个沙盒。
- 网络:一个网络是一组可以直接互相联通的端点。网络的实现可以是Linux bridge, VLAN等。一个网络可以包含多个端点。
libnetwork中的5种内置驱动如下:
- bridge驱动。此驱动为Docker的默认设置,使用这个驱动的时候,libnetwork将创建出来的Docker容器连接到Docker网桥上。作为最常规的模式,bridge模式已经可以满足Docker容器最基本的使用需求了。然而其与外界通信使用NAT,增加了通信的复杂性,在复杂场景下使用会有诸多限制。
- host驱动。使用这种驱动的时候,libnetwork将不为Docker容器创建网络协议栈,即不会创建独立的network namespace。Docker容器中的进程处于宿主机的网络环境中,相当于Docker容器和宿主机共用同一个network namespace,使用宿主机的网卡、IP和端口等信息。但是,容器其他方面,如文件系统、进程列表等还是和宿主机隔离的。host式很好地解决了容器与外界通信的地址转换问题,可以直接使用宿主机的IP进行通信,不存在虚拟化网络带来的额外性能负担。但是host动也降低了容器与容器之间、容器与宿主机之间网络层面的隔离性,引起网络资源的竞争与冲突。因此可以认为host驱动适用于对于容器集群规模不大的场景。
- overlay驱动。此驱动采用IETF标准的VXLAN方式,并且是VXLAN中被普遍认为最适合大规模的云计算虚拟化环境的SDN controller模式。在使用的过程中,需要一个额外的配置存储服务,例如Consul, etcd或ZooKeeper。还需要在启动Docker daemon的的时候额外添加参数来指定所使用的配置存储服务地址。
- remote驱动。这个驱动实际上并未做真正的网络服务实现,而是调用了用户自行实现的网络驱动插件,使libnetwork实现了驱动的可插件化,更好地满足了用户的多种需求。用户只要根据libnetwork提供的协议标准,实现其所要求的各个接口并向Docker daemon进行注册。
- null驱动。使用这种驱动的时候,Docker容器拥有自己的network namespace,但是并不为Docker容器进行任何网络配置。也就是说,这个Docker容器除了network namespace自带的loopback网卡外,没有其他任何网卡、IP、路由等信息,需要用户为Docker容器添加网卡、配置IP等。这种模式如果不进行特定的配置是无法正常使用的,但是优点也非常明显,它给了用户最大的自由度来自定义容器的网络环境。
-
bridge驱动实现机制分析
-
docker0网桥
图3-18即为Docker默认网络模式(bridge模式)下的网络环境拓扑图,Docker创建了docker0网桥,并以veth pair连接各容器的网络,容器中的数据通过docker0网桥转发到eth0网卡上。
这里网桥的概念等同于交换机,为连在其上的设备转发数据帧。网桥上的veth网卡设备相当于交换机上的端口,可以将多个容器或虚拟机连接在其上,这些端口工作在二层,所以是不需要配置IP信息的。图中docker0网桥就为连在其上的容器转发数据帧,使得同一台宿主机上的Docker容器之间可以相互通信。既然docker0是二层设备,其上怎么也配置了IP呢?docker0是普通的Linux网桥,它是可以在上面配置IP的,可以认为其内部有一个可以用于配置IP信息的网卡接口(如同每一个Open vSwitch网桥都有一个同名的内部接口一样)。在Docker的桥接网络模式中,docker0的IP地址作为连于之上的容器的默认网关地址存在。
docker0网桥是在Docker daemon启动时自动创建的,其IP默认为172.17.0.1/16,之后创建Docker容器都会在docker0子网的范围内选取一个未占用的IP使用,并连接到docker0网桥上。Docker提供了如下参数可以帮助用户自定义docker0的设置:
--bip=CIDR:设置docker0的IP地址和子网范围,使用CIDR格式,如192.168.100.1 /24。注意这个参数仅仅是配置docker0的,对其他自定义的网桥无效。
--fixed-cidr=CIDR:限制Docker容器获取IP的范围。Docker容器默认获取的IP范围为Docker网桥(docker0网桥或者–bridge指定的网桥)的整个子网范围,此参数可将其缩小到某个子网范围内,所以这个参数必须在Docker网桥的子网范围内。如dockero的IP为172.17.0.1/16,可将一fixed-cidr设为172.17.1.1/24,那么Docker容器的IP范围将为172.17.1.1~172.17.1.2540
--mtu=BYTES:指定dockero的最大传输单元(MTU)。 除了使用docker0网桥外,还可以使用自己创建的网桥,使用–bridge=BRIDGE参数指定。
以上参数在Docker daemon启动时指定,如docker daemon --fixed-cidr=172.17.1.1/24。在Ubuntu中,也可以将这些参数写在DOCKER OPTS变量中(位于/etc/default/docker文件中),然后重启Docker服务。
-
iptables规则
Docker安装完成后,将默认在宿主机系统上增加一些iptables规则,以用于Dockerg器和容器之间以及和外界的通信,可以使用iptables-save命令查看。其中nat表上的POSTROUTING链有这么一条规则:
-A PDSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE
这条规则关系着Docker容器和外界的通信,含义是将源地址为172.17.0.0/ 16的数据包(即Docker容器发出的数据),当不是从docker。网卡发出时做SNAT(源地址转换,将IP包的源地址替换为相应网卡的地址)。这样一来,从Docker容器访问外网的流量,在外部看来就是从宿主机上发出的,外部感觉不到Docker容器的存在。那么,外界想要访问Docker容器的服务时该怎么办?
外界访问Docker容器是通过iptables做DNAT(目的地址转换)实现的。此外,Docker的forward规则默认允许所有的外部IP访问容器,可以通过在filter的DOCKER链上添加规则来对外部的IP访问做出限制,如只允许源IP为 8.8.8.8的数据包访问容器,需要添加如下规则:-A FORWARD -i docker0 -o docker0 -j ACCEPT
这是满足相互通信的第二步。当Docker daemon启动参数一icc ( icc参数表示是否允许容器间相互通信)设置为false时,以上规则会被设置为DROP, Docker容器间的相互通信就被禁止,这种情况下,想让两个容器通信就需要在docker run时使用–link选项。
在Docker容器和外界通信的过程中,还涉及了数据包在多个网卡间的转发(如从docker0网卡到宿主机eth0的转发),这需要内核将ip-forward功能打开,即将ip forward系统参数设为1 。Docker
daemon启动的时候默认会将其设为1(–ip-forward=true),也可以通过以下命令手动设置:echo 1 > /proc/sys/net/ipv4/ip_forward cat /proc/sys/net/ipv4/ip_forward 1
-
Docker容器的DNS和主机名
同一个Docker镜像可以启动很多个Docker容器,通过查看,它们的主机名并不一样,也即是说主机名并非是被写人镜像中的。实际上容器中/etc目录下有3个文件是容器启动后被虚拟文件覆盖掉的,分别是/etc/hostname, /etc/hosts, /etc/resolv.conf。
-
Docker daemon的网络配置原理
Docker自身的网络主要分为两部分:Docker daemon的网络配置和libcontainer的网络配置。Docker daemon的网络指daemon启动时,在主机系统上所做的网络设置可以被所有Docker容器所使用;libcontainer的网络配置则针对具体的容器,是在使用docker run命令启动容器时,根据传人的参数为容器做的网络配置工作。
-
与网络相关的配置参数
Docker daemon启动的过程中,在配置参数Config结构体中保存了一个用于网络配置的结构体bridgeConfig。 daemon启动过程中与网络相关的参数配置都定义在bridgeConfig中,主要包括以下
几项:- EnableIptables:默认值为true,对应于Docker daemon启动时的–iptables参数,作用为是否允许Docker daemon在宿主机上添加iptables规则。
- EnableIpMasq:默认为true,对应于Docker daemon启动时的–ip-masq参数,作用为是否为Docker容器通往外界的包做SNAT。
- DefaultIp:对应–ip参数,默认值为“0.0.0.0"。这个变量的作用为:当启动容器做端口映射时,将DefaultIp作为默认使用的IP地址。
- EnableIpForward, Iface, IP , FixedCIDR, InterContainerCommunication分别对应–ip-forward,
–bridge、–bip、–fixed-cidr、–icc.
-
初始化过程
-
网络参数校验
关于网络方面,主要检查了3对互斥配置选项:
首先是Iface和IP,也就是不能在指定自定义网桥的同时又指定新建网桥的IP。为什么?如果指定了自定义的网桥,那么该网桥已经存在,无需指定网桥的IP地址;相反,若用户指定网桥IP ,那么该网桥肯定还未新建成功,则Docker daemon在新建网桥时使用默认网桥名docker0,并绑定IP。
第二对互斥选项是EnableIptables和InterContainerCommunication, 如果InterContainerCommunication为false,就需要修改iptables规则,而EnableIptables为false则规定不允许Docker daemon修改宿主机的iptables规则,因此两参数不能同为false。
此外,EnableIpMasq选项也与iptables规则相关,因此在iptables规则禁止的情况下,EnableIpMasq也不能为true。
-
是否初始化bridge驱动
参数校验完成后,接着判断工face和disableNetworkBridge的值是否相同,Iface保存的是网桥名称,disableNetworkBridge是一个字符串常量,值为none。因此,若用户通过传过来的参数将Iface设为none,则config.DisableBridge变量为true,否则为false。
接下来会调用libnetwork.New()生成网络控制器controller,这个控制器主要用于创建和管理Network。然后会通过null驱动和host驱动来进行默认的网络创建。
最后会根据DisableBridge的值来决定bridge驱动是否进行初始化。若DisableNetwork为false,则运行initBridgeDriver函数。initBridgeDriver函数就是完成默认的bridge驱动的初始化任务。 -
处理网桥参数
已经知道Docker网桥默认为docker0,也可以通过一bridge参数指定自定义的网桥。处理用户自定义网桥的流程分为如下两步:
(1)将用户指定的网桥名称传入Iface,若Iface不为空,则将其传赋值给bridgeName。如果Iface为空,则将bridgeName指定为DefaultNetworkBridge。 DefaultNetworkBridge是一个字符串常量,为docker0,即表示当用户没有传人网桥参数时,启用默认网桥docker0.
(2)首先,寻找Docker网桥名是否在宿主机上有对应的显卡,如果存在则返回其IP等信息,否则则从系统预定义的IP列表中分配一个可用IP。如果用户没有使用–bip来指定Docker网桥的IP地址,那么上面得到的IP会被写人ipamV4Conf结构体中,此结构体用于保存关于Docker网桥上有关IPV4的相关信息,如果用户进行了指定则会将指定的IP信息写入ipamV4Conf结构体中。接下来,如果FixedCIDR参数不为空,则将用户传人的网络范围写人到ipamV4Con于结构体中。如果默认的网关不为空,则将其信息写入到ipamV4Conf结构体中。然后,如果FixedCIDRv6,则将用户指定的IPV6网络范围和相关的IPV6配置信息写人ipamV6Conf中。最后使用上述信息作为参数调用controller.NewNetwork()函数,并指定bridge驱动来创建Docker网桥。 -
创建网桥设置队列
当需要Docker daemon创建网络时,则调用controller.NewNetwork()函数来通过libnetwork完成创建,实现过程的主要步骤如下:
(1)使用IP管理器的默认驱动创建IP管理器,并使用IP管理器从其自身维护的IP池中获取参数中指定的IP地址段。
(2)在确保新的网络设置和已经存在的网络不冲突之后,创建与这个驱动(即bridge驱动)相符的配置结构体network。接下来根据配置中的网桥名寻找对应的网桥。如果网桥不存在,则将创建网桥的步骤加人设置队列。
(3)定义关于网络隔离的iptables规则设置的函数,在接下来的步骤中加入到设置队列中,以确保不同网络之间相互隔离。
(4)将IPV4配置到网桥上、IPV6配置、IPV6转发、开启本地回环接口的地址路由、开启iptables,IPV4和IPV6的网关信息配置、网络隔离的iptables规则设置和网桥网络过滤等步骤加人到设置队列中。
(5)最后,运行设置队列中的所有步骤,主要通过netlink进行系统调用来完成Docker网桥的创建和配置工作。 -
更新相关配置信息
完成上述操作后,libnetwork会将各种相关配置信息存储到Docker的LibKV数据仓库中,以备后续的查找和使用。
-
libcontainer网络配置原理
Docker容器的网络就是在创建特定容器的时候,根据传人的参数为容器配置特定的网络环境,主要内容包括为容器配置网卡、IP、路由、DNS等一系列任务。Docker容器一般使用docker run命令来创建,其关于网络方面的参数有一net、–dns等。下面我们深人libcontainer组件,分析容器内部的网络环境是如何建立起来的。
-
命令行参数阶段
当docker run命令执行的时候,会首先创建一个。ockerCli类型的变量来表示Docker客户端,然后根据具体的命令调用相应的函数来完成请求,如run命令就是调用CmdRun函数来完成的。CmdRun函数主要实现的功能有以下几点:
- 解析docker run命令的参数,并存人相应的变量(config, hostConfig, networkingConfig,cmd等)中。
- 发送请求给Docker daemon ,创建Docke溶器对象,完成容器启动前的准备工作。
- 发送请求给Docker daemon ,启动容器。
Docker run命令中提供的关于容器配置的参数首先保存在了Config , HostConfig以及NetworkingConfig这3个结构中。结构体定义都放在在engine-api项目中的types包中,Config保存的是不依赖于宿主机的信息,也就是可以迁移的信息,其他与宿主机关联的信息都保存在HostConfig中,在Docker将网络模块独立为一个项目后,将网络参数部分从原来的配置中抽出为NetworkingConfig。Config中保存有Hostname(容器主机名)、NetworkDisabled(是否关闭容器网络功能)、MacAddress(网卡MAC地址)等;HostConfig保存有Dns(容器的DNS) , NetworkMode(容器的网络模式);NetworkingConfig保存了一组端点参数与所属网络名的map, docker run与docker network connect的网络配置均会保存在该map中。
解析完docker run命令行参数以后,Docker客户端利用Docker daemon暴露的API接口,分别将创建容器与启动容器的请求发送至Docker daemon,完成容器的创建和启动。因此CmdRun函数除了解析并组装与网络相关的命令行参数外,不做网络方面的具体配置,具体的网络配置还是由Docker daemon来完成。
-
创建容器阶段
当Docker客户端将创建容器的请求发送给Docker daemon后,Docker daemon开始创建容器,主要完成以下工作:
-
校验hostConfig, Config与NetworkingConfig中的参数。
-
根据需要调整HostConfig的参数。
-
根据传入的容器配置和名称创建对应的容器。
容器创建的最终返回一个Container对象,Container对象就是容器的数据结构表示,其中有一个名为NetworkSettings的属性,描述了容器的具体网络信息,其结构主要包含如下属性:
- Bridge:容器所连接到的网桥。
- SandboxID:容器对应Sandbox的ID。
- HairpinMode:是否开启hairpin模式。
- Ports:容器映射的端口号。
- SandboxKey: Sandbox对应network namespace文件的路径。
- Networks:保存了容器端点配置与所属网络名的map。
- IsAnonymousEndpoint:容器是否未指定名字name。
-
-
容器启动阶段
容器创建完成之后,Docker客户端会发送启动容器请求。daemon首先获取到需要启动的容器,然后调用容器的Start函数去真正启动容器,其中与网络相关的主要有以下3个函数:
-
initializeNetwork:初始化Container对象中与网络相关的属性;
initializeNetworking函数主要用来设置容器的主机名以及/etc/hosts文件,根据不同的容器网络模式配置有不同的设置,处理流程如下:
(1)若网络模式为container模式,则说明容器与其他容器共用网络。首先找到被引用的容器对象,然后让新容器使用被引用容器的hostname, hosts, resolv.conf文件,并和被引用主机同一个主机名和域名。
(2)若网络模式为host模式,则将容器的主机名和域名设置为与主机相同。首先调用os.Hostname()获取宿主机的主机名,分离出主机名和域名分别填写到容器Config对应的域中,然后继续执行下一步。
(3) host模式或其他情况则执行allocateNetwork函数,然后创建hostname文件并填入域名和主机名。allocateNetwork函数主要为容器清理遗留的Sandbox,更新NetworkSettings属性,并对每一个容器加入的网络调用connectToNetwork函数。connectToNetwork函数会调用libnetwork.network.CreateEndpoint和libnetwork.controller.NewSandbox为容器当前网络创建Endpoint和Sandbox(Sandbox对应一个容器,仅创建一次),将Endpoint加人到该Sandbox中,以及为容器更新NetworkSettings属性。 -
populateCommand:填充Docker Container内部需要执行的命令,Command中含有进程启动命令,还含有容器环境的配置信息,也包括网络配置;
populateCommand函数主要为容器初始化容器的执行命令。简单来说,Command类型包含了两部分内容:第一,运行容器内进程的外部命令exec.Cmd;第二,运行容器时启动进程需要的所有环境基础信息:包括容器进程组的使用资源、网络环境、使用设备、工作路径以及namespace相关信息等。
网络环境的相关属性为Network,主要包括MTU、待加入container网络的容器ID、网络namespace的路径以及是否为Host网络模式。populateCommand函数初始化网络相关属性的流程如下:
(I)判断容器的网络。
(2)若容器禁用了网络,则不对Network属性做任何动作;若容器为host模式或者execdriver不支持钩子,则将Network中的NamespacePath设置为容器对应的SandboxKey;若容器为container模式,则将被引用容器的ID赋值给Network的ContainerID属性。
(3)当Network类型初始化完成之后,将其传递给Command对象。 -
container.waitForStart:实现Docker Container内部进程的启动,进程启动之后,为进程创建网络环境等。
在容器启动函数Start执行的最后一步调用了waitForStart函数。查看waitForStart函数可知,该函数首先为要启动的容器创建了一个容器监控对象,用来监控容器中第一个进程的执行;然后启动容器进程并开始监控。监控对象启动的是最终调用daemon的Run函数,该函数主要工作就是为execdriver封装一个Hooks结构体并将setNetworkNamespaceKey作为Hooks.PreStart钩子函数,最后调用execdriver的Run函数来完成进程的启动。到这里,Docker daemon启动容器的动作就转交给了execdriver来完成,那么接下来就继续进人execdriver中进行分析。
-
-
execdriver网络执行流程
execdriver是Docker daemon的执行驱动,用来启动容器内部进程的执行。这里主要是配置表示命名空间的namespaces属性,namespaces列出了当启动容器进程时需要新创建的命名空间。network namespace的配置是通过调用execdriver的createNetwork函数实现的。该函数根据Docke溶器的不同网络模式执行不同的动作,流程如下:
(1)根据execdriver.Command对象中的Network属性判断出采用不同的方式配置网络。
(2)若Network.ContainerID不为空,则为container模式,则首先在处于活动状态的容器列表中查找被引用的容器,接着找到被引用容器中进程的networknamespace路径。假如被引用容器的第一个进程在主机中的PID为12345,则network namespace的路径为/proc/12345/ns/net。然后将该路径放入到libcontainer.Config.Namespaces中。
(3)若Network.NamespacePath不为空,对应host模式,则将Network.NamespacePath写入libcontainer.Config.Namespaces中。
(4)其他情况下,表示目前暂时无法获得network namespace,则为libcontianer设置PreStart钩子函数,主要工作是遍历execdriver提供的preStart钩子函数并执行。前面daemon中调用Run函数时已经将setNetworkNamespaceKey函数封装为PreStart钩子函数了。 createNetwork函数执行完后,就已经把network namespace信息或者能够配置network namespace的钩子函数全部记录到libcontainer里了。然后容器就开始执行,所以接着进入libcontainer中继续跟踪容器的网络。
-
libcontainer网络执行阶段
在libnetwork被分离出来前,Docker网络的内核态配置是由libcontianer完成的,但在Docker容器启动的调用流程下,libcontainer只是负责触发libcontianer.Config.Hooks中的Prestart钩子函数来完成容器网络的底层配置,具体触发的地方位于libcontainer/process_ linux.go文件中的initProcess.start方法中,在容器的init进程启动时调用。虽然随后的createNetworkInterfaces函数仍然存在并被调用了,但由于该函数是通过遍历libcontainer.Config.Networks数组内定义好的网络信息来配置网络的,而前面execdrive讲未填充该数组,所以Docker容器启动流程下并不会在libcontainer中创建网络环境。
在这里讲解一下容器启动前触发的Prestart钩子函数 setNetworkNamespaceKey,虽然该函数真正定义的地方是在daemon/container_ operations_ unix.go。setNetworkNamespaceKey的主要工作是获取network namespace并与容器对应的sandbox关联起来。首先通过容器pid获取容器network namespace文件的位置—/proc/[pid]/ns/net,再通过容器ID获取其对应的sandbox,最后调用sandbox的SetKey完成底层网络的创建。下面本书将带领读者继续探究libnetwork中对网络的配置。 -
libcontainer实现内核态网络配置
libnetwork对内核态网络的配置包括启动容器和libcontainer网络执行流程两个阶段,下面我们分别进行介绍:
-
启动容器
函数network.CreateEndpoint通过处理传人的endpoint参数和默认配置构建endpoint对象,再调用addEndpoint函数,获取network对应的驱动driver,调用驱动层的CreateEndpoint在网络驱动层创建endpoint。下面以默认的bridge驱动为例讲解创建endpoint的流程。
位于drivers/bridge/bridge.go的CreateEndpoint是最终创建endpoint的地方,实际工作为veth网络栈的创建,主要流程如下:
(I)处理endpoint对象的参数并创建bridgeEndpoint对象。
(2)分别生成host和container端(也就是sandbox) veth设备的名字并组建Veth对象,调用
netlink.LinkAdd函数创建veth pair设备,再分别为两个veth设备配置MTU。
(3)调用addTobridge将host端veth设备的加入网桥。
(4)将 container端veth设备的名字和传入的interface参数(MAC地址、IP地址等)配置给bridgeEndpoint,停用该veth设备并配置MAC地址。
(5)启用host端的veth设备。
(6)调用allocatePorts函数处理端口映射。 -
libcontainer网络执行流程
前面提到在libcontianer发的配置网络的钩子函数最后调用了libnetwork的sanbox.SetKey函数,主要流程如下:
(1)如果原来的sandbox的network namespace已经存在的话,则释放资源。
(2)据sandbox的Key创建文件并与传入的network namespace路径进行绑定挂载,这样便将Sandbox与network namespace通过Key属性关联起来。
(3)如果原来的network namespace存在并且为它配备了resolver(用于网络名解析),则为当前
sandbox重新启动。
(4)遍历sandbox所有的endpoint,对每一个调用populateNetworkResources函数配置网络资源。该函数主要流程为根据endpoint需要为sandbox启动resolver;根据endpoint的interface信息调用AddInterface函数创建实际的interface;根据与该sandbox的关联信息(joinInfo)创建静态路由;遍历sandbox的所有endpoint,为每一个更新网关(gateway );更新sandbox的持久化数据。
-
传统link原理解析
在使用Docke溶器部署服务的时候,经常会遇到需要容器间交互的情况,如Web应用与数据库服务。前面我们了解到容器间的通信由Docker daemon的启动参数–icc控制。但是很多情况下,为了保证容器以及主机的安全,–icc通常设置为false。这种情况下该如何解决容器间的通信呢?通过容器向外界进行端口映射的方式可以实现通信,但这种方式不够安全,因为提供服务的容器仅希望个别容器可以访问。除此之外,这种方式需要经过NAT,效率也不高。这时候,就需要使用Docker的连接(( linking)系统了。Docker的连接系统可以在两个容器之间建立一个安全的通道,使得接收容器(如Web应用)可以通过通道得到源容器(如数据库服务)指定的相关信息。
-
使用link通信
link是在容器创建的过程中通过–link参数创建的。还是以Web应用与数据库为例来演示link的使用。首先,新建一个含有数据库服务的Docker容器,取名为db。然后,新建一个包含Web应用的Docker容器,取名为web,并将web连接到db上,操作如下。
$sudo docker run -d --name db training/postgres $sudo docker run -d -P --name web --link db:webdb training/webapp python app.py
--link参数的格式是这样的 --link <name or id>:alias。其中name是容器通过一name参数指定或自动生成的名字,如“db" "web”等,而不是容器的主机名。alias为容器的别名,如本例中的webdb.
这样一个link就创建完成了,web容器可以从db容器中获取数据。web容器叫作接收容器或父容器,db容器叫作源容器或子容器。一个接收容器可以设置多个源容器,一个源容器也可以有多个接收容器。那么,link究竟做了什么呢?Docker将连接信息以下面两种方式保存在接收容器中:
- 设置接收容器的环境变量。
- 更新接收容器的/etc/hosts文件。
-
设置接收容器的环境变量
当两个容器通过一link建立了连接后,会在接收容器中额外设置一些环境变量,以保存源容器的一些信息。这些环境变量包含以下几个方面:
- 每有一个源容器,接收容器就会设置一个名为<alias> NAME环境变量,alias为源容器的别名,如上面例子的web容器中会有一个WEBDB NAME=/web/webdb的环境变量。
- 预先在源容器中设置的部分环境变量同样会设置在接收容器的环境变量中,这些环境变量包括Dockerfile中使用ENV命令设置的,以及docker run命令中使用-e、–env=[]参数设置的。如db容器中若包含doc=docker的环境变量,则web容器的环境变量则包含WEBDB ENV doc=docker.
- 接收容器同样会为源容器中暴露的端口设置环境变量。如db容器的IP为172.17.0.2,且暴露了8000的tcp端口,则在web容器中会看到如下环境变量。其中,前4个环境变量会为每一个暴露的端口设置,而最后一个则是所有暴露端口中最小的一个端口的URL(若最小的端口在TCP和UDP上都使用了,则TCP优先).
从上面的示例中,看到–link是docker run命令的参数,也就是说link是在启动容器的过程中创建的。我们发现在容器启动过程中(daemon/start.go中的containerStart函数)需要调用setupLinkedContainers函数,发现这个函数最终返回的是env变量,这个变量中包含了由于link操作,所需要额外为启动容器创建的所有环境变量,其执行过程如下:
(1)找到要启动容器的所有子容器,即所有连接到的源容器。
(2)遍历所有源容器,将link信息记录起来。
(3)将link相关的环境变量(包括当前容器和源容器的IP、源容器的名称和别称、源容器中设置的环境变量以及源容器暴露的端口信息)放人到env中,最后将env变量返回。
(4)若以上过程中出现错误,则取消做过的修改。 -
更新接收容器的/etc/hosts文件
Docker容器的IP地址是不固定的,容器重启后IP地址可能就和之前不同了。在有link关系的两个容器中,虽然接收方容器中包含有源容器IP的环境变量,但是如果源容器重启,接收方容器中的环境变量不会自动更新。这些环境变量主要是为容器中的第一个进程所设置的,如sshd等守护进程。因此,link操作除了在将link信息保存在接收容器中之外,还在/etc/hosts中添加了一项-------源容器的IP和别名(–link参数中指定的别名),以用来解析源容器的IP地址。并且当源容器重启后,会自动更新接收容器的/etc/hosts文件。需要注意的是这里仍然用的是别名,而不是源容器的主机名(实际上,主机名对外界是不可见的)。因此,可以用这个别名来配置应用程序,而不需要担心IP的变化。
Docker容器/etc/hosts文件的设置也是在容器启动的时候完成的。在前面我们介绍过initializeNetworking函数,在非container模式下,会调用这样一条函数链allocateNetwork->connecToNetwork->libnetwork.controller.NewSandbox来创建当前容器的sandbox,在这个过程中会调用setupResolutionFiles来配置hosts文件和DNS。配置hosts文件分为两步,一是调用buildHostsFiles函数构建当前sandbox(对应当前容器)的hosts文件,先找到接收容器(将要启动的容器)的所有源容器,然后将源容器的别名和IP地址添加到接收容器的/etc/hosts文件中;二是调用updateParentHosts来更新所有父sandbox(也就是接收容器对应的sandbox)的hosts文件,将源容器的别名和IP地址添加到接收容器的/etc/hosts文件中。
这样,当一个容器重启以后,自身的hosts文件和以自己为源容器的接收容器的hosts文件都会更新,保证了link系统的正常工作。 -
建立iptables规则进行通信
在接收容器上设置了环境变量和更改了/etc/hosts文件之后,接收容器仅仅是得到了源容器的相关信息(环境变量、IP地址),并不代表源容器和接收容器在网络上可以互相通信。当用户为了安全起见,将Docker daemon的–icc参数设置为false时,容器间的通信就被禁止了。那么,Docker daemon如何保证两个容器间的通信呢?答案是为连接的容器添加特定的iptables规则。
接着刚刚web和db的例子来具体解释,当源容器(db容器)想要为外界提供服务时,必定要暴露一定的端口,如db容器就暴露了tcp/5432端口。这样,仅需要web容器和db容器在db容器的tcp/5432端口上进行通信就可以了,假如web容器的IP地址为172.17.0.2/16 , db容器的IP地址为172.17.0.1/16,则web容器和db容器建立连接后,在主机上会看到如下iptables规则。-A DOCKER -s 172.17.0.2/32 -d 172.17.0.1/32 -i docker0 -o docker0 -p tcp -m tcp --dport 5432 -j ACCEPT -A DOCKER -s 172.17.0.1/32 -d 172.17.0.2/32 -i docker0 -o docker0 -p tcp -m tcp --dport 5432 -j ACCEPT
这两条规则确保了web容器和db容器在db容器的tcp/5432端口上通信的流量不会被丢弃掉,从而保证了接收容器可以顺利地从源容器中获取想要的数据。处理端口映射的过程是在启动容器阶段创建endpoint的过程中。仍然以bridge驱动为例CreateEndpoint最后会调用allocatePort来处理端口暴露。这里需要注意以下两点:
(1)得到源容器所有暴露出来的端口。注意这里是容器全部暴露的端口,而不仅仅是和主机做了映射的端口。
(2)遍历源容器暴露的端口,为每一个端口添加如上的两条iptables规则。 Link是一种比端口映射更亲密的Docker容器间通信方式,提供了更安全、高效的服务,通过环境变量和/etc/hosts文件的设置提供了从别名到具体通信地址的发现,适合于一些需要各组件间通信的应用。