参考文章:[Docker 技术入门与实战 第3板]
基本架构
Docker 目前采用了标准的 C/S 架构,包括客户端,服务端两大核心组件,同时通过镜像仓库来存储镜像。客户端和服务端既可以运行在一个机器上,也可以通过 socket 或者 RESTful API 来进行通信。
服务端
Docker 服务端一般在宿主机后台运行,dockerd
作为服务端接受来自客户端的请求,并通过 containerd
具体处理与容器相关的请求,包括创建,运行,删除容器等。
服务端主要包括四个组件:
dockerd
: 为客户端提供 RESTful API,响应来自客户端的请求,采用模块化架构,通过专门的 Engine 模块来分发管理各个来自客户端的任务。可以单独升级;docker-proxy
: 是 dockerd 的子进程,当需要进行容器端口映射时,docker-proxy 完成网络组映射配置;containerd
: 是 dockerd 的子进程,提供 gRPC 接口响应来自 dockerd 的请求,对下管理 runC 镜像和容器环境。可以单独升级;containerd-shim
: 是 containerd 的子进程,为 runC 容器提供支持,同时作为容器内进程的根进程。
dockerd 默认监听本地的 unix://var/run/docker.sock
套接字,只允许本地的 root 用户或者 docker 用户组成员访问。可以通过 -H
选项来修改监听的方式。
- 例如,让 dockerd 监听本地的 TCP 连接 1234 端口,代码如下:
1 | $ sudo dockerd -H 127.0.0.1:1234 |
此外,Docker 还支持通过 TLS 认证方式来验证访问。docker-proxy
只有当启动容器并且使用端口映射的时候才会执行,负责配置容器的端口映射规则:
1 | $ docker run -itd -p 80:80 ubuntu:latest /bin/bash |
客户端
客户端为用户提供一系列可执行命令,使用这些命令可以实现与 Docker 服务端交互。
用户使用的 Docker 可执行命令即为客户端程序。与 Docker 服务端保持运行方式不同,客户端发送命令后,等待服务端返回;一旦收到返回后,客户端立刻执行结束并退出。用户执行新的命令,需要再次调用客户端命令。
客户端默认通过本地的 unix://var/run/docker.socket
套接字向服务端发送命令。如果服务端没有监听在默认的地址,则需要客户端在执行命令的时候,显示地指定服务端地址。
例如,假定服务端监听在本地的 TCP 链接 1234 端口为 tcp://127.0.0.1:1234
,则只有通过 -H
参数指定了正确的地址信息才能连接到服务端:
1 | docker -H tcp://127.0.0.1:1234 info |
镜像仓库
镜像时使用容器的基础,Docker 使用镜像仓库(Registry)在大规模场景下存储和分发 Docker 镜像。镜像仓库提供了对不同存储后端的支持,存放镜像文件,并且支持 RESTful API,接收来自 dockerd 的命令,包括拉取,上传镜像等。
命名空间
命名空间是 Linux 内核的一个强大特性,为容器虚拟化的实现带来了极大便利。利用这一特性,每个容器都可以拥有自己单独的命名空间,运行在其中的应用都像是在独立的操作系统环境中一样。命名空间机制保证了容器之间彼此互不影响。
在操作系统中,包括内核,文件系统,网络,进程号,用户号,进程间通信等资源,所有的资源都是应用进程直接共享的。要想实现虚拟化,除了要实现对内存,CPU,网络IO,硬盘IO,存储空间等的限制外,还要实现文件系统,网络,PID,UID,IPC 等的相互隔离。
进程命名空间
Linux 通过进程命名空间管理进程号,对于同一个进程(同一个 task_struct),在不同的命名空间中,看到的进程号不相同。每个进程命名空间有一套自己的进程号管理方法。进程命名空间是一个父子关系的结构,子空间中的进程对父进程是可见的。新 fork 出的一个进程,在父命名空间和子命名空间将分别对应不同的进程号。
IPC 命名空间
容器中的进程交互还是采用了 Linux 常见的进程间交互方法(Inter Process Communication IPC),包括信号量,消息队列和共享内存等方式。PID 命名空间和 IPC 命名空间可以组合起来一起使用,同一个 IPC 命名空间内的进程可以彼此可见,允许进行交互;不同空间的进程则无法交互。
网络命名空间
有了进程命名空间后,不同命名空间中的进程号可以相互隔离,但是网络端口还是共享本地系统的端口。
通过网络命名空间,可以实现网络隔离。一个网络命名空间为进程提供了一个完全独立的网络协议栈的视图。包括网络设备几口,IPv4 和 IPv6 协议栈,IP 路由表,防火墙规则,sockets等,这样每个容器的网络就能隔离开来。
Docker 采用虚拟网络设备(Virtual Network Device,VND)的方式,将不同命名空间的网络设备连接到一起。默认情况下,Docker 在宿主机上创建多个虚拟网桥(如默认的网桥 docker0),容器中的虚拟网卡通过网桥进行连接。
使用 docker network ls
命令可以查看当前系统中的网桥:
1 | $ docker network ls |
使用 brctl
工具(需要安装 bridge-utils
工具包),还可以看到连接到网桥上的虚拟网口的信息。每个容器默认分配一个网桥上上的虚拟网口,并将 docker0
的 IP 地址设置为默认的网关,容器发起的网络流量通过宿主机的 iptables
规则进行转发:
1 | $ brctl show |
挂载命名空间
类似于 chroot
,挂载(Mount,MNT)命名空间可以将一个进程的根文件系统限制到一个特定的目录下。
挂载命名空间允许不同命名空间的进程看到本地文件位于宿主机中不同路径下,每个命名空间中的进程所看到的文件目录彼此是隔离的。例如,不同命名空间中的进程,都认为自己独占了一个完整的根文件系统(rootfs),但实际上,不同命名空间中的文件彼此隔离,不会造成相互影响,同时也无法影响宿主机文件系统中的其他路径。
UTS 命名空间
UTS(UNIX Time-sharing System)命名空间允许每个容器都拥有独立的主机名和域名,从而虚拟出一个独立主机名和网络空间的环境,就跟网络上一台独立的主机一样。
如果没有手动指定主机名称,则 Docker 容器的主机名就是返回容器 ID 的前 6 字节前缀,否则为指定的用户名
1 | # docker run --name test1 -d ubuntu:18.04 /bin/sh "while True; do echo 'Hello, World'; sleep 1; done" |
用户命名空间
每个容器可以由不同的用户和组 id,也就是说,可以在容器内使用特定的内部用户执行程序,而非本地系统上存在的用户。
每个容器内部都可以有最高权限的 root 账号,但跟宿主机不在同一个命名空间。通过使用隔离的用户命名空间,可以提高安全性,避免容器内的进程获取到额外的权限;同时通过使用不同的用户也可以进一步在容器内控制权限。
控制组
控制组(CGroups)是 Linux 内核的一个特性,主要用来对共享资源进行隔离,限制,审计等。只有将分配到容器的资源进行控制,才能避免多个容器同时运行时对宿主机系统的资源竞争。每个控制组是一组对资源的限制,支持层级化结构。
控制组提供如下功能:
- 资源限制:可将组设置一定的内存限制。比如,内存自系统可以为进程组设定一个内存使用上限,一旦进程组使用的内存达到限额再申请内存,就会触发 Out of Memory 警告;
- 优先级:通过优先级让一组优先得到更多的 CPU 等资源;
- 资源审计:用来控制系统实际上把多少资源用到适合的目的上,可以使用
cpuacct
子系统记录某个进程组使用的 CPU 时间; - 隔离:为组隔离命名空间,这样使得一个组不会看到另一个组的进程,网络连接和文件系统。
- 控制:执行挂起,恢复和重启等操作。
安装 Docker 后,用户可以在 /sys/fs/cgroup/memory/docker/
目录下看到对 Docker 组应用的各种限制,包括全局限制和位于子目录中对于某个容器的单独限制:
1 | sudo ls /sys/fs/cgroup/memory/docker/ |
用户可以通过修改这些文件的值来控制组,从而限制 Docker 应用资源。例如,通过下面的命令可以限制 Docker 组中的所有进程使用的物理内存总量不超过 100 MB:
1 | echo 104857600 > /sys/fs/cgroup/memory/docker/memory.limit_in_bytes |
进入对应的容器文件夹,可以看到对应容器的限制和目前的使用状态:
1 | # cd 6ad386a4efd4cfd00063558af3e3d633c90c7878d4b349bf58536bacd8983834/ |
同时,可以在创建或启动容器时为每个容器指定资源的限制,例如使用 -c|--cpu-shards[=0]
参数可以调整容器使用 CPU 的权重;使用 -m|--memory[=MEMORY]
参数可以调整容器最多使用内存的大小。
联合文件系统
联合文件系统(UnionFS)是一种轻量级的高性能分层文件系统,它支持将文件系统中的修改信息作为一次提交,并层层叠加,同时可以将不同目录挂载到同一个虚拟文件系统下,应用最终看到的是挂载的最终结果。联合文件系统是实现 Docker 镜像的基础。
Docker 存储原理
Docker 目前通过插件化方式支持多种文件系统后端。Debian/Ubuntu 上成熟的 AUFS 就是一种联合文件系统实现。AUFS 支持为每一个成员目录设定只读,读写或写出权限,同时 AUFS 里有一个类似分层的概念,对只读权限的分支可以逻辑上进行增量地修改(不影响只读部分)。
Docker 镜像自身就是由多个文件层组成,每一层有基于内容的唯一的编号(层ID)。可以通过 docker history
查看每一个镜像由哪些层组成。
例如查看 Ubuntu:18.04
镜像由5层组成,没成执行了不同的命令。如下所示:
1 | docker history ubuntu:18.04 |
对于 Docker 来说,这些层的内容都是不可修改的,只读的。而当 Docker 利用镜像启动一个容器时,将在镜像文件系统的最顶端再挂载一个新的可读写的层给容器。容器中的内容更新将会发生在可读写层。当所操作的对象位于较深的某层时,需要先复制到最上层的可读写层。当数据对象较大时,往往意味着较差的 IO 性能,因此,对于 IO 敏感型应用,一般推荐将容器修改的数据通过 volume
方式挂载,而不是直接修改镜像内数据。
另外,对于频繁启停 Docker 容器的场景下,文件系统的 IO 性能也将十分关键。
Docker 存储架构
所有的镜像和容器都存储在 Docker 指定的存储目录下,以 Ubuntu 宿主机系统为例,默认路径是 /var/lib/docker
。在这个目录下面,存储有 Docker 镜像和容器运行相关的文件和目录,可能包括 builder
,containerd
,containers
,image
,network
,aufs/overlay2
,plugins
,runtimes
,swarm
,tmp
,trust
,volumes
等。
其中,如果使用 AUFS 存储后端,则最关键的就是 aufs
目录,保存了 Docker 镜像和容器相关数据和信息。包括 layers
,diff
和 mnt
三个子目录。1.9 版本和之前的版本中,命名跟镜像层的 ID 是匹配的;而自 1.10 开始,层数据相关的文件和目录名与层 ID 不再匹配。
多种文件系统比较
Docker 目前支持的联合文件系统类型包括 AUFS
,btrfs
,Device Mapper
,overlay
,overlay2
,vfs
,zfs
等。多种文件系统目前的支持情况如下:
- AUFS: 最早支持的文件系统,对 Debian/Ubuntu 支持好,虽然没有合并到 Linux 内核中,但成熟度很高;
- btrfs: 参考 zfs 等特性设计的文件系统,由 Linux 社区开发,视图取代 Device Mapper,成熟度有待提高;
- Device Mapper: RedHat 公司和 Docker 团队一起开发用于支持 RHEL 的稳健性同,内核支持,性能略慢,成熟度高;
- overlay: 类似于 AUFS 的层次化文件系统,性能更好,从 Linux 3.18 开始已经合并到内核,但成熟度有待提高;
- overlay2: Docker 1.12 后推出,原生支持 128层,效率比 OverlayFS 高,较新版本的 Docker 支持,要求内核大于 4.0
- vfs: 基于普通文件系统的中间抽象层,性能差,比较占空间,成熟度一般;
- zfs: 最初设计为 Solaris 10 上的写实文件系统,拥有不少好的特性,但对 Linux 支持还不够成熟。
目前,AUFS 应用最广泛,支持也相对成熟,推荐生产环境考虑。对于较新的内核,可以尝试 overlay2,作为 Docker 最新推荐使用的文件系统,将具有更多的特性和潜力。
Linux 网络虚拟化
Docker 的本地网络实现其实就是利用了 Linux 上的网络命名空间和虚拟网络设备(特别是 veth pair)。熟悉这两部分的基本概念有助于理解 Docker 网络的实现过程。
基本原理
直观上看,要实现网络通信,机器需要至少一个网络接口(物理接口或虚拟接口)与外界相通,并可以收发数据包;此外,如果不同子网之间要进行通信,还需要额外的路由机制。
Docker 中的网络接口默认都是虚拟接口。虚拟接口的最大优势就是转发效率极高。这是因为 Linux 通过在内核中进行数据复制来实现虚拟接口之间的数据转发,即发送接口的发送缓存中的数据包将被直接复制到接收接口的接收缓存中,而无须通过外部物理网络设备进行交换。对于本地系统和容器内系统来看,虚拟接口跟一个正常的以太网卡相比并无区别,只是它的速度要快得多。
Docker 容器网络就很好的利用了 Linux 虚拟网络技术,它在本地主机和容器内分别创建一个虚拟接口 veth,并连通(这样的一对虚拟接口叫做 veth pair)。
网络创建过程
一般情况下,Docker 创建一个容器的时候,会具体执行如下操作:
- 创建一对虚拟接口,分别放在本地主机和新容器的命名空间中;
- 本地主机一端的虚拟接口连接到默认的 docker0 网桥或指定网桥,并且有一个以 veth 开头的唯一名字,如 veth1234;
- 容器另一端的虚拟接口将放到新创建的容器中,并修改名字为 eth0。这个接口只在容器的命名空间中可见;
- 从网桥可用地址段中获取一个空闲地址分配给容器的 eth0(例如 172.17.0.2/16),并配置默认路由网关为 docker0 网卡的内部接口 docker0 的 IP地址(例如 172.17.42.1/16)。
完成这些之后,容器就可以使用它所能看到的 eth0 虚拟网卡来连接其他容器和访问外部网络。
用户也可以通过 docker network
命令来手动管理网络。
在使用 docker [container] run
命令启动容器的时候,可以通过 --net
参数指定容器的网络配置。有 5 个可选值,bridge
,none
,container
,host
和用户自定义的网络;
--net=bridge
: 默认值,在 Docker 网桥 docker0 上为容器创建新的网络栈;--net=none
: 让 Docker 将新容器放到隔离的网络栈中,但是不进行网络配置。之后用户可以自定配置;--net=container:NAME_or_ID
: 让 Docker 将新建容器的进程放到一个已存在容器的网络栈中,新容器进程有自己的文件系统,进程列表和资源限制,但会和已存在的容器共享 IP 地址和端口等网络资源,两者进程可以直接通过 lo 环回接口通信;--net=host
: 告诉 Docker 不要将容器网络放到隔离的命名空间中,即不要容器化容器内的网络。此时容器使用本地主机的网络,它拥有完全的本地主机接口访问权限。容器进程根主机其他 root 进程一样可以打开低范围的端口,可以访问本地网络服务,还可以让容器做一些影响整个主机系统的事情,比如重启主机。因此使用这个选项的时候要非常小心。如果进一步使用--privileged=true
参数,容器甚至会被允许直接配置主机的网络栈;--net=user_defined_network
: 用户自行用network
相关创建的一个网络,之后将容器连接到指定的已创建网络上去。
手动配置网络
用户使用 --net=none
后,Docker 将不对容器网络进行配置。下面介绍手动完成配置网络的整个过程。通过这个过程,可以了解到 Docker 配置网络的更多细节。
- 首先,启动一个
ubuntu:18.04
容器,指定--net=none
参数:
1 | docker run -it --rm --net=none ubuntu:18.04 /bin/bash |
- 在本地主机查找容器的进程 ID,并为它创建网络命名空间:
1 | $ docker inspect -f {{".State.Pid"}} 7c720560fcf7 |
- 检查桥接网卡的 IP 和子网掩码信息
1 | # ip addr show docker0 |
- 创建一对 veth pair 接口 A 和 B,绑定 A 接口道网桥 docker 0,并启用它
1 | sudo ip link add A type veth peer name B |
- 将 B 接口放到容器的网络命名空间,命名为 eth0,启动它并配置一个可用 IP(桥接网段)和默认网关
1 | sudo ip link set B netns $pid |
以上,就是 Docker 配置网络的具体过程。当容器终止后,Docker 自动清空容器,容器内的网络接口会随网络命名空间一起被清除,A接口也被自动从 docker0 卸载并清除,此外,在删除 /var/run/netns/
下的内容之前,用户可以使用 ip netns exec
命令在指定网络命名空间中进行配置,从而更新容器内的网络配置。