Docker 引擎结构
曾经的Docker
曾经的Docker引擎主要包含两部分:LXC
和Docker daemon
:
- Docker daemon是单一的二进制文件,包含诸如Docker客户端、Docker API、容器运行时、镜像构建等。
- LXC提供了对诸如命名空间(Namespace)和控制组(CGroup)等基础工具的操作能力,它们是基于Linux内核的容器虚拟化技术。
这两者皆有弊端:
- LXC 是基于Linux,无法做到跨平台。此外,过度依赖外部组件,影响发展。后开发 Libcontainer 取代LXC成为默认的执行驱动。
- Docker daemon:难以变更,且运行较慢。后开展拆解和重构工作,所有容器执行和容器运行时的代码已经完全从daemon中移除,并重构为小而专的工具。
目前的Docker
Docker Engine 目前主要由以下组件构成:Docker客户端(Docker Client)
、Docker守护进程(Docker daemon)
、containerd
、runc
。
启动容器示例
使用Docker命令行工具执行 docker run 命令时,Docker客户端会将其转换为合适的API格式,并发送到正确的API端点。
API是在daemon中实现的,一旦daemon接收到创建新容器的命令,它就会向containerd发出调用。daemon使用一种CRUD风格的API,通过gRPC与containerd进行通信。
虽然名叫containerd,但是它并不负责创建容器,而是指挥runc去做。containerd将Docker镜像转换为OCI bundle,并让runc基于此创建一个新的容器。
然后,runc与操作系统内核接口进行通信,基于所有必要的工具(Namespace、CGroup等)来创建容器。容器进程作为runc的子进程启动,启动完毕后,runc将会退出。
这种模型的优势在于:容器与Docker daemon是解耦的,对Docker daemon 的维护和升级不会影响到运行中的容器。
containerd
所有的容器执行逻辑被重构到一个新的名为containerd(发音为container-dee)的工具中。它的主要任务是容器的生命周期管理——start | stop | pause | rm … 。
runc
runc是 OCI (开放容器计划) 容器运行时规范的参考实现。runc实质上是一个轻量级的、针对 Libcontainer 进行了包装的命令行交互工具。
runc生来只有创建容器这一个作用,它是一个CLI包装器,实质上就是一个独立的容器运行时工具。
shim
containerd 指挥 runc 来创建新容器。事实上,每次创建容器时它都会fork一个新的runc实例。不过,一旦容器创建完毕,对应的runc进程就会退出。因此,即使运行上百个容器,也无须保持上百个运行中的runc实例。
一旦容器进程的父进程runc退出,相关联的containerd-shim进程就会成为容器的父进程。作为容器的父进程,shim的部分职责如下。
保持所有STDIN和STDOUT流是开启状态,从而当daemon重启的时候,容器不会因为管道的关闭而终止。
将容器的退出状态反馈给daemon。
Linux中的实现
在 Linux 系统中,前面谈到的组件由单独的二进制来实现,具体包括:
dockerd —- Docker daemon
docker-containerd —- containerd
docker-containerd-shim —- shim
docker-runc —- runc
daemon在经过剥离精简后的主要功能包括镜像管理、镜像构建、REST API、身份验证、安全、核心网络以及编排。
Docker 底层原理
Namespace
每个容器都有自己单独的命名空间,运行在其中的应用都像是在独立的操作系统中运行一样,命名空间保证了容器之间彼此互不影响。 namespace 是由 Linux 内核提供的,用于进程间资源隔离的一种技术,使得 a,b 进程可以看到 S 资源;而 c 进程看不到。
Linux 提供了多种 namespace,用于对多种不同资源进行隔离。容器的实质是进程,但与直接在宿主机执行的进程不同,容器进程运行在属于自己的独立的命名空间,因此容器可以拥有自己的 root 文件系统、自己的网络配置、自己的进程空间,甚至自己的用户 ID 空间。
当使用docker run –pid host --rm -it alpine sh
在宿主机上运行一个简单的 alpine 容器,容器会与主机共用同一个 pid namespace。然后在容器内部执行指令 ps -a 会发现进程数量与宿主机的一样。
涉及到Namespace
的操作接口包括clone()
、setns()
、unshare()
以及还有/proc
下的部分文件。在宿主机上执行ls -l /proc/self/ns
看到的就是当前系统所支持的 namespace。
1 | lrwxrwxrwx 1 root root 0 Jan 2 15:05 ipc -> ipc:[4026531839] |
pid 命名空间
:不同用户的进程就是通过 pid 命名空间隔离开的,且不同命名空间中可以有相同 pid。
net 命名空间
:进行网络隔离。
ipc 命名空间
:ipc即为进程间通信 (Inter-process communication), 包括信号量、消息队列和共享内存等。容器的进程间交互实际上还是 host 上具有相同 pid 命名空间中的进程间交互,因此需要在 IPC 资源申请时加入命名空间信息,每个 IPC 资源有一个唯一的 32 位 id。
mnt 命名空间
:mnt 命名空间允许不同命名空间的进程看到的文件结构不同,这样每个命名空间 中的进程所看到的文件目录就被隔离开了。同 chroot 不同,每个命名空间中的容器在 /proc/mounts 的信息只包含所在命名空间的 mount point。
uts 命名空间
:隔离主机名和域名信息
user 命名空间
:每个容器可以有不同的用户和组 id, 也就是说可以在容器内用容器内部的用户执行程序而非主机上的用户。
cgroups
cgroups (控制组)用来对共享资源进行隔离、限制、审计等。只有能控制分配到容器的资源,才能避免当多个容器同时运行时的对系统资源的竞争。例如可以设定一个 memory 使用上限,一旦进程组(容器)使用的内存达到限额再申请内存,就会出发 OOM(out of memory),这样就不会因为某个进程消耗的内存过大而影响到其他进程的运行。
联合文件系统UFS
如果单单就隔离性来说,vagrant 也已经做到了。 docker 火爆的原因在于它允许用户将容器环境打包成为一个镜像进行分发,而且镜像是分层增量构建的,这可以大大降低用户使用的门槛。
容器可以近似理解为镜像的运行时实例,默认情况下也算是在镜像层的基础上增加了一个可写层。所以,一般情况下如果你在容器内做出的修改,均包含在这个可写层中。
UFS(Union File System )将多个物理位置不同的文件目录联合起来,挂载到某一个目录下,形成一个抽象的文件系统。
从右侧以 UFS 的视角来看,lowerdir 和 upperdir 是两个不同的目录,UFS 将二者合并起来,得到 merged 层展示给调用方。从左侧的 docker 角度来理解,lowerdir 就是镜像,upperdir 就相当于是容器默认的可写层。在运行的容器中修改了文件,可以使用 docker commit 指令保存成为一个新镜像。
有了 UFS 的分层概念,我们易于理解 Dockerfile 中的FROM alpine
这种描述。
1 | docker info --format '{{.Driver}}' 获取使用的存储驱动(默认是 overlay2) |