架构

Docker的核心组件包括:

  • Docker 客户端 - Client
  • Docker 服务器 - Docker daemon
  • Docker 镜像 - Image
  • Docker 仓库 - Registry
  • Docker 容器 - Container

Docker采用的是Client/Server 架构。客户端向服务器发送请求,服务器负责构建、运行和分发容器。

架构如下图所示。

docker x86构建 arm_Docker

客户端

Docker客户端一般通过Docker command来发起请求,另外可以通过Docker提供的一整套RESTful API来发起请求,这中方式更多地被应用在应用程序的代码中。

服务器

也就是Docker daemon,以 Linux 后台服务的方式运行。这是驱动整个Docker功能的核心引擎。

在Java中Daemon的就是一个守护线程,负责为用户线程提供服务。Docker中的作用也是一样的,接收客户端发来的各种请求,并实现请求所要求的功能,同时针对请求返回相应的结果。

默认配置下,Docker daemon 只能响应来自本地 Host 的客户端请求。如果要允许远程客户端请求,需要在配置文件中打开 TCP 监听。

容器

Docker 容器就是 Docker 镜像的运行实例。

功能上,Docker通过Libcontainer实现对容器生命周期的管理、信息的设置和查询,以及监控和通信等功能。而容器以镜像为基础,同时又为镜像提供了一个标准的隔离的执行环境。

概念上,容器很好诠释了Docker集装箱的理念。

用户可以通过 CLI(docker)或是 API 启动、停止、移动或删除容器。可以这么认为,对于应用软件,镜像是软件生命周期的构建和打包阶段,而容器则是启动和运行阶段。

仓库

Registry 是存放 Docker 镜像的仓库,Registry 分私有和公有两种。

Docker Hub(https://hub.docker.com/) 是默认的 Registry,由 Docker 公司维护,上面有数以万计的镜像,用户可以自由下载和使用。

出于对速度或安全的考虑,用户也可以创建自己的私有 Registry。

镜像

可将 Docker 镜像看成只读模板,通过它可以创建 Docker 容器。如果说容器提供了一个完整的、隔离的运行环境,那么镜像则是这个运行环境的静态体现,是一个还没有运行起来的运行环境。

相对于传统虚拟化的ISO镜像,Docker镜像要轻量化很多,它只是一个可定制的rootfs(根文件系统)。它的另一个创新是它是层级的并且是可复用的。

Docker镜像通常是通过Dockerfile来创建的,Dockerfile提供了镜像内容的定制,同时也体现了层级关系的建立。Docker也可通过docker commit命令来将手动修改后的内容生成镜像。

rootfs
内核空间是 kernel,Linux 刚启动时会加载 bootfs 文件系统,之后 bootfs 会被卸载掉。

用户空间的文件系统是 rootfs,包含我们熟悉的 /dev, /proc, /bin 等目录。
对于 base 镜像来说,底层直接用 Host 的 kernel,自己只需要提供 rootfs 就行了。

实现原理

Docker不是虚拟化方法。它依赖于实际实现基于容器的虚拟化或操作系统级虚拟化的其他工具。为此,Docker最初使用LXC驱动程序,然后移动到libcontainer现在重命名为runc

LXC

LXC是一种容器引擎,所谓容器引擎就是一种驱动和管理容器生命周期的runtime工具。

所谓runtime管理工具,就是指只对容器运行时的相关状态和操作进行管理的工具。

LXC利用Linux上相关技术实现容器,Docker则在如下的几个方面进行了改进:

  • 移植性:通过抽象容器配置,容器可以实现一个平台移植到另一个平台;
  • 镜像系统:基于AUFS的镜像系统为容器的分发带来了很多的便利,同时共同的镜像层只需要存储一份,实现高效率的存储;
  • 版本管理:类似于GIT的版本管理理念,用户可以更方面的创建、管理镜像文件;
  • 仓库系统:仓库系统大大降低了镜像的分发和管理的成本;
  • 周边工具:各种现有的工具(配置管理、云平台)对Docker的支持,以及基于Docker的Pass、CI等系统,让Docker的应用更加方便和多样化。

Libcontainer

Libcontainer是一种容器引擎,是一种runtime管理工具,Docker所有对容器的管理操作都是通过调用Libcontainer的API来实现的。

Libcontainer提供的功能有:运行容器、暂停容器、恢复容器、向容器发送信号、获取容器信息、修改容器配置、Checkpoint容器。

容器的技术核心就是Nampspace和Cgroup,所谓运行一个容器就是创建属于容器的Namespace和Cgroup。

Cgroup: 是control group的缩写,属于Linux内核提供的一个特性,用于限制和隔离一组进程对系统资源的使用,也就是做资源Qos,这些资源主要包括CPU、内存、IO和网络。Cgroup可以对进程进行任意分组,不同组获取资源的限额不同。Cgroup的原生接口通过cgroupfs提供,类似于procfs和sysfs,是一种虚拟文件系统。Cgroup通过内部的子系统实现不同资源的分配管理。

NameSpace: 是将内核的全局资源做封装,使得每个Namespace都有一份独立的资源,不同进行在各自的Namespace内对同一种资源的使用不会相互干扰。

对Namespace的操作主要通过这三个系统调用来完成的:

  • clone:用来给新的进程创建新的Namespace。
  • setns:用来给已有的进程创建新的Namespace的。
  • unshare:将进程放到已有的Namespace中。

Linux内核总共实现了六种Namespace:

  • UTS Namespace
    用于对主机名和域名进行隔离。
  • IPC Namespace
    用于进程间通信的消息隔离和标识。
  • PID Namespace
    用于隔离进程PID号,这样不同的Namespace中的进程PID号就可以是一样的了。
  • Mount Namespace
    用来隔离文件系统挂载点,每个进程能看到的文件系统都记录在/proc/$$/mounts里,进程系统对文件系统挂载卸载就不会影响到其他的Namespace。
  • Network Namespace
    用于网络相关资源的隔离,每个Network Namespace都有各自的网络设备、IP、路由表、端口号等。
  • User Namespace
    用于隔离用户和组ID,一个进程在Namespace里的用户和组ID与它在host里的ID可以不一样,同一个用户在不同Namespace的权限也不同了。

runC

runC也是一款容器引擎,是runtime引擎,它由Libcontainer演变而来,现在runC已经代替Libcontainer称为Docker的引擎了。

runC在底层还是使用了Libcontainer的库。

runC通过读取用户编写的JSON文件,获取容器所需的所有信息,然后把内容填充到Libcontainer提供的Config文件中,让Libcontainer去做底层的工作。

创建容器

具体流程入下图所示。

  1. 在p.cmd.Start()的时候,就创建了新的Namespace,子进程就在自己的Namespace中了。
  2. 然后Daemon线程执行p.manager.Apply()操作的时候,就会创建新的Cgroup,并把刚创建的子进程放到新的Cgroup中。
  3. 进一步,daemon线程在做了一些网络配置之后,就会把容器的配置信息通过管道发给子进程,同时让子进程继续执行,而daemon线程则进入pip wait状态。
  4. 容器剩下的初始化操作就时由子进程完成了。其中很重要的一步就是rootfs切换,首先子进程会根据config中的配置,把host上的相关目录mount到容器的rootfs中,或挂载到一些虚拟文件系统上,这些挂载信息可能时-v指定的volume、容器的Cgroup信息、proc文件系统等。然后正式执行容器中的init进程。
  5. 当容器已经完成创建和运行操作,通知通知父进程,daemon线程会回到Dockers的函数中,等待执行容器进程结束的操作,整个过程完成。
  • 创建Namespace
    通过在clone系统调用中传入相应的Namespace的flag来实现的。Libcontainer只需要知道Docker那边传过来的参数中包含了哪些clone flag,然后将其而配置到cmd.SysProcAttr.CloneFlags中,之后执行cmd.Start()就创建出来一个拥有自己命名空间的子进程。
  • 创建Cgroup
    创建一个Cgroup就是在cgroupfs的挂载目录中创建一个新的文件夹,Cgroup的子系统中队不同的资源进行管理,所系,每个资源目录下都需要创建一个新的文件夹。创建时需要把容器的1号进程放到Cgroup中,确保所有容器相关的进程都在这个新的Cgroup中。