Docker之十三:Docker 的核心技术
- Docker 的基本架构
- 服务端
- 客户端
- 镜像仓库
- 命名空间
- 什么是命名空间 ?
- Linux 内核命名空间
- 进程命名空间
- IPC 命名空间
- 网络命名空间
- 挂载命名空间
- UTS 命名空间
- 用户命名空间
- 控制组
- 联合文件系统
- Docker 存储原理
- Docker 存储结构
- 多种文件系统比较
- Linux 网络虚拟化
- 基本原理
- 网络创建过程
- 手动配置网络
本节内容来源于《Docker技术入门与实战》。
Docker 的基本架构
Docker的架构如下图所示:
Docker 采用标准的 C/S 架构,包括客户端和服务端两大核心组件,同时通过镜像仓库来存储镜像。客户端和服务端可以运行在一个机器上,也可以通过 socket 或者 TESTful API 来通信。
服务端
Docker 服务端一般在宿主主机后台运行,dockerd 作为服务端接受来自客户地请求,并通过 containerd 具体处理与容器相关地请求,包括创建、运行、删除容器等操作。服务端主要包括四个组件:
- dockerd:为客户端提供 RESTful API,响应来自客户端地请求,采用模块化地架构,通过专门地 Engine 模块分发管理各个来自客户端地任务。
- docker-proxy:是 dockerd 的子进程,当需要进行容器端口映射时,docker-proxy 完成网络映射配置。
- containerd:是dockerd 的子进程,提供 gRPC 接口响应来自 dockerd 的请求,对下管理 runC 镜像和容器环境。
- containerd-shim:是 containerd 的子进程,为 runC 容器提供支持,同时作为容器内进程的根进程。
runC 是从Docker公司开源的libcontainer 项目演化而来的,目前作为一种具体的开放容器标准实现加入 OCI(Open Containers Initiative)。runC 已经支持了 Linux 系统中容器的相关技术栈,同时正在实现对其他操作系统的兼容。用户可以直接使用 docker-runc 命令来直接使用 OCI 规范的容器。 runC 为一个轻量级的工具,它是用来运行容器的,我们可以理解 runC 为一个命令行小工具,可以部经过 Docker 引擎直接运行容器。
RESTful API 就是REST风格的API。REST,表示性状态转移(representation state transfer),就是用 URI (Uniform Resource Identifier,统一资源标识符)表示资源,用HTTP方法(GET, POST, PUT, DELETE)表征对这些资源的操作。
gRPC 是一款 RPC 框架。RPC 称为远程过程调用,简单的说,就是将一个服务调用封装在一个本地方法中,让调用者像使用本地方法一样调用服务,对其屏蔽实现细节。而具体的实现是通过调用方和服务方的一套约定,基于TCP长连接进行数据交互达成。
dockerd 默认监听本地的 unix:///var/run/docker.sock 套接字,只允许本地的 root 用户或 docker 用户组成员访问。可以通过 -H 选项来修改监听方式。
$ sudo dockerd -H 127.0.0.1:1234
客户端
Docker 客户端为用户提供一系列可执行命令,使用这些命令可以实现于 Docker 服务端交互。
客户端默认通过本地的 unix:///var/run/docker.sock 套接字向服务端发送命令。如果服务端没有监听到默认的地址,则需要客户端在执行命令的时候显式地指定服务端地址。
# 假定服务端监听本地地 TCP 连接 1234端口为 tcp://127.0.0.1:1234,只有通过 -H 参数指定正确地地址信息才能连接到服务端
$ docker -H tcp://127.0.0.1:1234 info
镜像仓库
Docker 使用镜像仓库(Registry)存储和分发 Docker 镜像。镜像仓库提供了对不同寻出后端的支持,存放镜像文件,并且支持 RESTful API,接收来自 dockerd 的命令,包括拉取和上传镜像等。
命名空间
什么是命名空间 ?
命名空间(namespace)是 Linux 内核的强大特征,它将内核的全局资源做封装,使得每个 namespace 都有一份独立的资源,使得不同的进程在各自的命名空间内对同一种资源的使用不会互相干扰。命名空间的这种特性,为容器虚拟化带来极大的便利,保证了运行在同一宿主机内的不同容器之间彼此互不影响。
Linux 内核总共实现了六种命名空间:
- 进程命名空间(PID):隔离进程 ID
- IPC 命名空间(IPC):隔离 System V IPC 和 POSIX 消息队列
- 网络命名空间(Network):隔离网络资源
- 挂载命名空间(Mount):隔离文件挂载点
- UTS 命名空间(UTS):隔离主机名和域名
- 用户命名空间(User):隔离用户 ID 和用户组 ID
Docker 容器每次启动时候,通过调用 *func setNamespaces(daemon Daemon, s specs.Spces, ccontainer.Container) error 方法来完成对各个 namespace 的配置。
Linux 内核命名空间
进程命名空间
Linux 通过进程命名空间管理进程号,对于同一进程(同一个task_struct),在不同的命名空间中,看到的进程号不相同。
每个进程命名空间有一套自己的进程号管理方法。进程命名空间是一个父子关系的结构,子空间中的进程对于父空间是可见的。新fork出的一个进程,在父命名空间和子命名空间将分别对应不同的进程号。
IPC 命名空间
容器中的进程交互还是采用了 Linux 常见的进程间交互(Inteprocess Communication,IPC),包括信号量、消息队列和共享内存等方式。PID 命名空间和 IPC 命名空间可以组合起来一起使用,同一个 IPC 命名空间内的进程可以彼此可见,允许进行交互;不同空间的进程无法交互。
网络命名空间
通过网络命名空间,可以实现网络隔离。一个网络命名空间为进程提供了一个完全独立的网络协议栈视图。包括网络设备接口、IPv4 和 IPv6 协议栈、IP 路由表、防火墙规则、socket等,每个容器的网络就能隔离起来了。
Docker 采用虚拟网络设备(Virtual Network Device,VND)的方式,将不同命名空间的网络设备连接到一起,默认情况下,Docker 在宿主机上创建多个虚拟网桥(如默认网桥 docked0),容器中的虚拟网卡通过网桥进行连接。
# 使用docker network ls 可以查看当前系统中的网桥
$ docker network ls
使用 brctl 工具(安装 bridge-utils 工具包),可以查看连接到网桥上的虚拟网口信息。
$ btctl show
挂载命名空间
挂载(Mount,MNT) namespace 可以将一个进程的根文件系统限制到一个特定的目录下。
挂载命名空间允许不同命名空间的进程看到的本地文件位于宿主主机中不同路径下,每个命名空间中的进程所看到的文件目录彼此是隔离的。
UTS 命名空间
UTS(Unix Time-sharing System)namespace 允许每个容器拥有独立的主机名和域名,从而可以虚拟出一个有独立主机名和网络空间的环境,就跟网络上的一台独立主机一样。
如果没有手动指定主机名称,Docker 容器的主机名就是返回的容器 ID 的前6字节前缀(使用 run 启动一个容器时终端返回的 ID),可以使用 --hostname 指定主机名称:
$ docker run --hostname test --name test -d ubuntu
# 执行此命令,容器启动后,返回的就是 test 而不是一长串的字符
用户命名空间
每个用户可以有不同的用户和组 ID,可以在容器内使用特定的内部用户执行程序,而非本地用户上存在的用户。
每个容器内部都可以由最高权限的 root 账号,但跟宿主主机不在一个命名空间。通过使用隔离的用户命名空间,可以提高安全性,避免容器内的进程获取到额外的权限;同时通过使用不同用户也可以进一步在同期内控制权限。
控制组
控制组(CGroups)是 Linux 内核的一个特性,主要用来对共享资源(CPU、内存、block I/O 和网络带宽等)进行隔离、限制、审计等。只有将分配到容器的资源进行控制,才能避免多个容器同时运行时对宿主主机系统的资源竞争。
控制组有四个功能:
- 资源限制(resource limiting):可为组设置一定的内存限制。比如,为一个进程组设置一个内存使用上限,一旦使用达到这个上线,就会发出 Out of Memory 警告。
- 优先级(prioritization):通过优先级让一些组优先得到更多的 CPU 资源。
- 资源审计(accounting):用来统计系统实际上把多少资源用到合适的目的上,可以使用 cpuacct 子系统记录某个进程组使用的 CPU 时间。
- 隔离(isolation):为组隔离命令空间,使得一个组不会看到另一个组的进程、网络连接和文件系统。
- 控制(control):执行挂起、恢复和重启等操作。
Docker 容器每次启动时候,通过调用 func setCapabilities(s specs.Spces, ccontainer.Container) error 方法来完成对各个 namespace 的配置。安装 Docker 后,用户可以在 /sys/cgroup/memory/docker/ 目录下看到 Docker 组应用的各种限制,包括全局限制和位于子目录中对某个容器的单独限制。用户可以通过修改这些文件值来控制组,从而限制 Docker 应用资源。也可以在创建或者启动容器时为每个容器指定资源的限制,如使用 -c | --cpu-shares[=0] 参数调整容器使用 CPU 的权重;使用 -m | --memory[=MEMORY] 参数来调整容器最多使用内存的大小。
联合文件系统
联合文件系统(UnionFS)是一种轻量级的高性能分层文件系统,它支持将文件系统中的修改信息作为一次提交,并层层叠加,同时可以将不同目录挂载到同一个虚拟文件系统下,应用看到的是挂载的最终结果。联合文件系统是实现 Docker 镜像的技术基础。
Docker 镜像可以通过分层来进行继承。例如,用户基于基础镜像(用来生成其他镜像的基础,往往没有父镜像)来制作各种不同的应用镜像。这些镜像共享同一个基础镜像层,提高了存储效率。此外,当用户改变了一个 Docker 镜像(比如升级程序到新的版本),则会创建一个新的层(layer)。因此,用户不用替换整个原镜像或者重新建立,只需要添加新层即可。用户分发镜像的时候,也只需要分发被改动的新层内容(增量部分)。这让Docker的镜像管理变得十分轻量和快速。
Docker 存储原理
Docker 目前通过插件化方式支持多种文件系统后端。Debian/Ubuntu 上成熟的 AUFS (Another Union File System,或 v2 版本往后的 Advanced multi layered Unification File System),就是一种联合文件系统实现。AUFS支持为每一个成员目录(类似Git的分支)设定只读 (readonly)、读写(readwrite)或写出(whiteout-able)权限,同时AUFS里有一个类似分层的概念,对只读权限的分支可以逻辑上进行增量地修改(不影响只读部分的)。
Docker 镜像自身就是由多个文件层组成,每一层有基于内容的唯一的编号(层ID)。可以通过docker history查看一个镜像由哪些层组成。
# 查看 ubuntu:16.04 镜像层
$ docker history ubuntu:16.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 存储后端,则最关键的就是 auf s目录,保存 Docker 镜像和容器相关数据和信息,包括 layers、diff 和 mnt 三个子目录:
- layers 子目录包含层属性文件,用来保存各个镜像层的元数据。
- diff 子目录包含层内容子目录,用来保存所有镜像层的内容数据。
- mnt 子目录下面的子目录是各个容器最终的挂载点,所有相关的 AUFS 层在这里挂载到一起,形成最终效果。一个运行中容器的根文件系统就挂载在这下面的子目录上。
多种文件系统比较
Docker目前支持的联合文件系统种类包括 AUFS、btrfs、Device Mapper、overlay、overlay2、vfs、zfs 等。
文件系统 | 说明 |
AUFS | 最早支持的文件系统,对 Debian / Ubuntu 支持好,虽然没有合并到 Linux 内核中,但成熟度很高 |
btrfs | 参考 zfs 等特性设计的文件系统,由 Linux 社区开发,试图未来取代 Device Mapper,成熟度有待提高 |
Device Mapper | RadHat 公司和 Docker 团队一起开发用于支持 RHEL 的文件系统,内核支持,性能略慢,成熟度高 |
overlay | 类似于 AUFS 的层次化文件系统,性能更好,从 Linux 3.18 开始已经合并到内核,但成熟度有待提高 |
overlay2 | Docker 1.12 后推出,原生支持 128 层,效率比 OverlayFS 高,教新版本的 Docker 支持,要求内核大于 4.0 |
vfs | 基于普通文件系统(ext、nfs 等)的中间层抽象,性能差,比较占用空间,成熟度也一般 |
zfs | 最初设计为 Solaris 10 上的写时文件系统,拥有不少好的特性,但对于 Linux 支持还不够成熟 |
AUFS 应用广泛,支持相对成熟,推荐生成环境使用,比较新的内核,可以尝试使用 overlay2。
Linux 网络虚拟化
Docker 的本地网络实现其实就是利用了 Linux 上的网络命名空间和虚拟网络设备(特别时 veth pair)。
基本原理
要实现网络通信,需要至少一个物理或虚拟网络接口与外界相同,手发数据,如需在不同子网之间进行通信,还需要路由机制。
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 命令可以手动管理网络。
手动配置网络
- 使用 –net=none 启动一个容器,此时 docker 将不对容器网络进行配置
$ docker run -i -t --rm --net=none ubuntu /bin/bash
- 在本地主机查找容器的进程 ID,并为它创建网络命名空间
$ docker inspect -f '{{.State.Pid}}' f60823c9cdae
2309
$ pid=2309
$ sudo mkdir -p /var/run/netns
$ sudo ln -s /proc/$pid/ns/net /var/run/netns/$pid
- 检查桥接网卡的 IP 和 子网掩码信息
$ ip addr show docker0
4. 创建一对 “veth pair” 接口 A 和 B,绑定 A 接口到网桥 docker0,并启用它
$ sudo ip link add A type veth peer name B
$ sudo brctl addif docker0 A
$ sudo ip link set A up
- 将 B 接口放到容器的命名空间,命名为 eth0,启动它并配置一个可用 IP(桥接网段)和默认网关
$ sudo ip link set B netns $pid
$ sudo ip netns exec $pid ip link set dev B name eth0
$ sudo ip netns exec $pid ip addr add 172.17.0.80/16 dev eth0
$ sudo ip netns exec $pid ip route add default via 172.17.0.1
当容器终止后,Docker 会清空容器,容器内的网络接口会随网络命名空间一起被清除,A 接口会自动从 docker 0 卸载并清除(宿主机可清除 /var/run/netns)。