Docker架构概览
Docker架构概览
Docker使用了传统的client-server架构模式,总架构图如图所示。用户通过Docker client与Docker daemon建立通信,并将请求发送给后者。而Docker的后端是松耦合结构,不同模块各司其职,有机组合,完成用户的请求。

Docker通过driver模块来实现对Docker容器执行环境的创建和管理。
- 容器:当需要创建Docker容器时,可通过镜像管理(image management)部分的distribution和registry模块从Docker registry中下载镜像,并通过镜像管理的image、reference和layer存储镜像的元数据,通过镜像存储驱动graphdriver将镜像文件存储于具体的文件系统中;
- 网络:当需要为Docker容器创建网络环境时,通过网络管理模块network调用libnetwork创建并配置Docker容器的网络环境;
- 数据卷:当需要为容器创建数据卷volume时,则通过volume模块调用某个具体的volumedriver,来创建一个数据卷并负责后续的挂载操作;
- 控制容器:当需要限制Docker容器运行资源或执行用户指令等操作时,则通过execdriver来完成。libcontainer是对cgroups和namespace的二次封装,execdriver是通过libcontainer来实现对容器的具体管理,包括利用UTS、IPC、PID、Network、Mount、User等namespace实现容器之间的资源隔离和利用cgroups实现对容器的资源限制。
- 当运行容器的命令执行完毕后,一个实际的容器就处于运行状态,该容器拥有独立的文件系统、相对安全且相互隔离的运行环境。Docker1.9版本以后,volume和network的生命周期都是独立于容器的,与容器一样是Docker中的一等公民,Docker用户可以单独增删改查volume或者network,然后在创建容器的时候根据需要配置给容器。
1. Docker daemon
Docker daemon是Docker架构中的主要用户接口。Docker daemon是Docker最核心的后台进程,它负责响应来自Docker client的请求,然后将这些请求翻译成系统调用完成容器管理操作。该进程会在后台启动一个API Server,负责接收由Docker client发送的请求;接收到的请求将通过Docker daemon分发调度,再由具体的函数来执行请求。
- 它提供了API Server用于接收来自Docker client的请求
- 根据不同的请求分发给Dockerdaemon的不同模块执行相应的工作,其中对容器运行时、volume、镜像以及网络方面的具体实现已经放在daemon以外的模块或项目中。
2. Docker client
Docker client是一个泛称,用来向Docker daemon发起请求,执行相应的容器管理操作。它既可以是命令行工具docker,也可以是任何遵循了Docker API的客户端。目前,社区中维护着的Docker client种类非常丰富,涵盖了包括C#(支持Windows)、Java、Go、Ruby、JavaScript等常用语言,甚至还有使用Angular库编写的WebUI格式的客户端,足以满足大多数用户的需求。
3. 镜像管理
Docker通过distribution、registry、layer、image、reference等模块实现了Docker镜像的管理,我们将这些模块统称为镜像管理(image management)。在Docker 1.10以前的版本中,这一功能是通过graph组件来完成的。
- distribution负责与Docker registry交互,上传下载镜像以及存储与v2 registry有关的元数据。
- registry模块负责与Docker registry有关的身份验证、镜像查找、镜像验证以及管理registry mirror等交互操作。
- image模块负责与镜像元数据有关的存储、查找,镜像层的索引、查找以及镜像tar包有关的导入、导出等操作。
- reference负责存储本地所有镜像的repository和tag名,并维护与镜像ID之间的映射关系。
- layer模块负责与镜像层和容器层元数据有关的增删查改,并负责将镜像层的增删查改操作映射到实际存储镜像层文件系统的graphdriver模块。
4. Driver
execdriver、volumedriver、graphdriver前面提到,Docker daemon负责将用户请求转译成系统调用,进而创建和管理容器。而在具体实现过程中,为了将这些系统调用抽象成为统一的操作接口方便调用者使用,Docker把这些操作分成了容器执行驱动、volume存储驱动、镜像存储驱动3种,分别对应execdriver、volumedriver和graphdriver。
- execdriver是对Linux操作系统的namespaces、cgroups、apparmor、SELinux等容器运行所需的系统操作进行的一层二次封装,其本质作用类似于LXC,但是功能要更全面。这也就是为什么LXC会作为execdriver的一种实现而存在。当然,execdriver最主要的实现,也是现在的默认实现,是Docker官方编写的libcontainer库。
- volumedriver是volume数据卷存储操作的最终执行者,负责volume的增删改查,屏蔽不同驱动实现的区别,为上层调用者提供一个统一的接口。Docker中作为默认实现的volumedriver是local,默认将文件存储于Docker根目录下的volume文件夹里。其他的volumedriver均是通过外部插件实现的。
- graphdriver是所有与容器镜像相关操作的最终执行者。graphdriver会在Docker工作目录下维护一组与镜像层对应的目录,并记下镜像层之间的关系以及与具体的graphdriver实现相关的元数据。这样,用户对镜像的操作最终会被映射成对这些目录文件以及元数据的增删改查,从而屏蔽掉不同文件存储实现对于上层调用者的影响。在Linux环境下,目前Docker已经支持的graphdriver包括aufs、btrfs、zfs、devicemapper、overlay和vfs。
5. Network
在Docker 1.9版本以前,网络是通过networkdriver模块以及libcontainer库完成的,现在这部分功能已经分离成一个libnetwork库独立维护了[插图]。libnetwork抽象出了一个容器网络模型(Container Network Model, CNM),并给调用者提供了一个统一抽象接口,其目标并不仅限于Docker容器。CNM模型对真实的容器网络抽象出了沙盒(sandbox)、端点(endpoint)、网络(network)这3种对象,由具体网络驱动(包括内置的Bridge、Host、None和overlay驱动以及通过插件配置的外部驱动)操作对象,并通过网络控制器提供一个统一接口供调用者管理网络。网络驱动负责实现具体的操作,包括创建容器通信所需的网络,容器的network namespace,这个网络所需的虚拟网卡,分配通信所需的IP,服务访问的端口和容器与宿主机之间的端口映射,设置hosts、resolv.conf、iptables等。
client和daemon
docker命令的两种模式:client模式和daemon模式。
6. client模式
docker命令对应的源文件是docker/docker.go(如果不做说明,根路径是项目的根目录docker/),它的使用方式如下:
1 | docker [OPTIONS] COMMAND [arg ...] |
其中OPTIONS参数称为flag,任何时候执行一个docker命令,Docker都需要先解析这些flag,然后按照用户声明的COMMAND向指定的子命令执行对应的操作。如果子命令为daemon, Docker就会创建一个运行在宿主机的daemon进程(docker/daemon. go#mainDaemon),即执行daemon模式。其余子命令都会执行client模式。处于client模式下的docker命令工作流程包含如下几个步骤。
1.解析flag信息
docker命令支持大量的OPTION,或者说flag,这里列出对于运行在client模式下的docker比较重要的一些flag。
- Debug,对应-D和——debug参数,它将向系统中添加DEBUG环境变量且赋值为1,并把日志显示等级调为DEBUG级,这个flag用于启动调试模式。
- LogLevel,对应-l和——log-level参数,默认等级为info,即只输出普通的操作信息。用户可以指定的日志等级现在有panic、fatal、error、warn、info、debug这几种。
- Hosts,对应-H和——host=[]参数,对于client模式,就是指本次操作需要连接的Docker daemon位置,而对于daemon模式,则提供所要监听的地址。若Hosts变量或者系统环境变量DOCKER_HOST不为空,说明用户指定了host对象;否则使用默认设定,默认情况下Linux系统设置为unix:///var/run/docker.sock。
- protoAddrParts,这个信息来自于-H参数中://前后的两部分的组合,即与Docker daemon建立通信的协议方式与socket地址。
2.创建client实例
client的创建就是在已有配置参数信息的基础上,调用api/client/cli.go#NewDockerCli,需要设置好proto(传输协议)、addr(host的目标地址)和tlsConfig(安全传输层协议的配置),另外还会配置标准输入输出及错误输出。
3.执行具体的命令
Docker client对象创建成功后,剩下的执行具体命令的过程就交给cli/cli.go来处理了。
1. 从命令映射到对应的方法
cli主要通过反射机制,从用户输入的命令(比如run)得到匹配的执行方法(比如CmdRun),这也是所谓“约定大于配置”的方法命名规范。
同时,cli会根据参数列表的长度判断是否用于多级Docker命令支持(例如未来也许会加入一条命令,如docker grouprun可以指定一组Docker容器一起运行某个命令),然后根据找到的执行方法,把剩余参数传入并执行。若传入的方法不合法或参数不正确,则返回docker命令的帮助信息并退出。
在v1.10的Docker中,每一个类似api/client/commnds.go#CmdRun的方法都被剥离出来作为一个单独的文件存在,例如读者想要学习docker run这个命令的执行过程,就需要寻找api/client/run.go这个文件。
2. 执行对应的方法,发起请求
找到具体的执行方法后,就开始执行。虽然请求内容会有所不同,但执行流程大致相同。基本的执行流程如下所示。
- 解析传入的参数,并针对参数进行配置处理。
- 获取与Docker daemon通信所需要的认证配置信息。
- 根据命令业务类型,给Docker daemon发送POST、GET等请求。
- 读取来自Docker daemon的返回结果。
由此可见,在请求执行过程中,大多都是将命令行中关于请求的参数进行初步处理,并添加相应的辅助信息,最终通过指定的协议给Docker daemon发送Docker client API请求,主要的任务执行均由Docker daemon完成。
7. daemon模式
Docker运行时如果使用docker daemon子命令,就会运行Docker daemon。
一旦docker进入了daemon模式,剩下的初始化和启动工作就都由Docker的docker/daemon. go#CmdDaemon来完成。
Docker daemon通过一个server模块(api/server/server.go)接收来自client的请求,然后根据请求类型,交由具体的方法去执行。因此daemon首先需要启动并初始化这个server。另一方面,启动server后,Docker进程需要初始化一个daemon对象(daemon/daemon.go)来负责处理server接收到的请求。下面是Docker daemon启动与初始化过程的详细解析。
1. API Server的配置和初始化过程
首先,在docker/daemon.go#CmdDaemon中,Docker会继续按照用户的配置完成server的初始化并启动它。这个server在3.2节中提到过,它又称为API Server,顾名思义就是专门负责响应用户请求并将请求交给daemon具体方法去处理的进程。它的启动过程如下。
- 整理解析用户指定的各项参数。
- 创建PID文件。
- 加载所需的server辅助配置,包括日志、是否允许远程访问、版本以及TLS认证信息等。
- 根据上述server配置,加上之前解析出来的用户指定的server配置(比如Hosts),通过goro-utine的方式启动APIServer。这个server监听的socket位置就是Hosts的值。
- 创建一个负责处理业务的daemon对象(对应daemon/damone.go)作为负责处理用户请求的逻辑实体。
- 对APIserver中的路由表进行初始化,即将用户的请求和对应的处理函数相对应起来。
- 设置一个channel,保证上述goroutine只有在server出错的情况下才会退出。
- 设置信号捕获,当Docker daemon进程收到INT、TERM、QUIT信号时,关闭API Server,调用shutdownDaemon停止这个daemon。
- 如果上述操作都成功,API Server就会与上述daemon绑定,并允许接受来自client的连接。
- 最后,Docker daemon进程向宿主机的init守护进程发送“READY=1”信号,表示这个Docker daemon已经开始正常工作了。
那么shutdownDaemon是如何来关闭一个daemon的呢?这个流程包括如下步骤。
创建并设置一个channel,使用select监听数据。在正确完成关闭daemon工作后将该channel关闭,标识该工作的完成;否则在超时(15秒)后报错。
调用daemon/daemon.go#Shutdown方法执行如下工作。
遍历所有运行中的容器,先使用SIGTERM软杀死容器进程,如果10秒内不能完成,则使用SIGKILL强制杀死。
如果netController被初始化过,调用#libnetwork/controller.go#GC方法进行垃圾回收。
结束运行中的镜像存储驱动进程。
server会通过与daemon对象绑定来接收并处理完成具体的请求(类似一个API接收器绑定了一个业务逻辑处理器
2. daemon对象的创建与初始化过程
既然API Server是同daemon对象绑定起来共同完成工作的,那么这个daemon对象是如何创建出来的呢?事实上,这个过程对应的正是daemon/daemon.go#NewDaemon方法。
NewDaemon过程会按照Docker的功能点,逐条为daemon对象所需的属性设置用户或者系统指定的值,这是一个相当复杂的过程。
这个过程需要完成的配置至少包括了如下功能点:Docker容器的配置信息、检测系统支持及用户权限、配置工作路径、加载并配置graphdriver、创建Docker网络环境、创建并初始化镜像数据库、创建容器管理驱动、检测DNS配置和加载已有Docker容器等。
1. Docker容器的配置信息
容器配置信息的主要功能是:供用户自由配置Docker容器的可选功能,使得Docker容器的运行更贴近用户期待的运行场景。配置信息的处理包含以下几个部分。
- 设置默认的网络最大传输单元:当用户没有对-mtu参数进行指定时,将其设置为1500。否则,沿用用户指定的参数值。
- 检测网桥配置信息:此部分配置为进一步配置Docker网络提供铺垫,将在3.8.2节中详细介绍。
2. 检测系统支持及用户权限
初步处理完Docker的配置信息之后,Docker对自身运行的环境进行了一系列的检测,主要包括3个方面。
- 操作系统类型对Docker daemon的支持,目前Docker daemon只能运行在Linux系统上。
- 用户权限的级别,必须是root权限。
- 内核版本与处理器的支持,只支持amd64架构的处理器,且内核版本必须升至3.10.0及以上。
3. 配置daemon工作路径
配置Docker daemon的工作路径,主要是创建Dockerdaemon运行中所在的工作目录,默认为/var/lib/docker。若该目录不存在,则会创建,并赋予0700权限。
4. 配置Docker容器所需的文件环境
这一步Docker daemon会在Docker工作根目录/var/lib/docker下面初始化一些重要的目录和文件,来构建Docker容器工作所需的文件系统环境。
- 创建容器配置文件目录。这些配置文件里包含了这个容器的所有元数据。 Docker daemon在创建Docker容器之后,需要将容器内的配置文件放到这个目录下统一管理。目录默认位置为:/var/lib/docker/containers,它下面会为每个具体容器保存如下几个配置文件,其中xxx为容器ID:

配置graphdriver目录。它用于完成Docker容器镜像管理所需的底层存储驱动层。所以,在这一步的配置工作就是加载并配置镜像存储驱动graphdriver,创建存储驱动管理镜像层文件系统所需的目录和环境,初始化镜像层元数据存储。
创建graphdriver时,首先会从环境变量DOCKER_DRIVER中读用户指定的驱动,若为空,则开始遍历优先级数组选择一个graphdriver。在Linux环境下,优先级从高到低依次为aufs、btrfs、zfs、devicemapper、overlay和vfs。在不同操作系统下,优先级列表的内容和顺序都会不同,而且随着内核的发展以及驱动的完善,会继续发生变化。
需要注意,目前vfs在Docker中是用来管理volume的,并不作为镜像存储使用。另外,由于目前在overlay文件系统上运行的Docker容器不兼容SELinux,因此当config中配置信息需要启用SELinux并且driver的类型为overlay时,该过程就会报错。
当识别出对应的driver(比如aufs)后,Docker会执行这个driver对应的初始化方法(位于daemon/graphdriver/aufs/aufs.go),这个初始化的主要工作包括:尝试加载内核aufs模块来确定Docker主机支持aufs;发起statfs系统调用获取当前Docker主目录(/var/lib/docker/)的文件系统信息,确定aufs是否支持该文件系统;创建aufs驱动根目录(默认:/var/lib/docker/aufs)并将该目录配置为私有挂载;在根目录下创建mnt、diff和layers目录作为aufs驱动的工作环境。上述工作完成后,graphdriver的配置工作就完成了。
配置镜像目录。主要工作是在Docker主目录下创建一个image目录,来存储所有镜像和镜像层管理数据,默认目录为“/var/lib/docker/image/”。在image目录下,每一个graphdriver都有一个具体的目录用于存储使用该graphdriver存储的镜像相关的元数据。
根据上一步graphdriver的选择情况(这里以aufs为例),创建image/aufs/layerdb/目录作为镜像层元数据存储目录,并创建MetadataStore用来管理这些元数据。根据graphdriver与元数据存储结构创建layerStore,用来管理所有的镜像层和容器层,将逻辑镜像层的操作映射到物理存储驱动层graphdriver的操作;创建用于对registry的镜像上传下载的uploadManager和downloadManager。
创建image/aufs/imagedb/目录用于存储镜像的元数据,并根据layerStore创建imageStore,用来管理镜像的元数据。
调用volume/local/local.go#New创建volume驱动目录(默认为/var/lib/docker/volumes), Docker中volume是宿主机上挂载到Docker容器内部的特定目录。volumes目录下有一个metadata.db数据库文件用于存储volume相关的元数据,其余以volume ID命名的文件夹用于存储具体的volume内容。默认的volume驱动是local,用户也可以通过插件的形式使用其他volume驱动来存储。
准备“可信镜像”所需的工作目录。在Docker工作根目录下创建trust目录。这个存储目录可以根据用户给出的可信url加载授权文件,用来处理可信镜像的授权和验证过程。
创建distributionMetadataStore和referenceStore。referenceStore用于存储镜像的仓库列表。记录镜像仓库的持久化文件位于Docker根目录下的image/[graphdriver]/repositories.json中,主要用于做镜像ID与镜像仓库名之间的映射。distributionMetadataStore存储与第二版镜像仓库registry有关的元数据,主要用于做镜像层的diff_id与registry中镜像层元数据之间的映射。
将持久化在Docker根目录中的镜像、镜像层以及镜像仓库等的元数据内容恢复到daemon的imageStore、layerStore和referenceStore中。
执行镜像迁移。由于Docker 1.10版本以后,镜像管理部分使用了基于内容寻址存储(content-addressablestorage)。升级到1.10以上的新版本后,在第一次启动daemon时,为了将老版本中的graph镜像管理迁移到新的镜像管理体系中,这里会检查Docker根目录中是否存在graph文件夹,如果存在就会读取graph中的老版本镜像信息,计算校验和并将镜像数据写入到新版的imageStore和layerStore中。读者需要注意的是,迁移镜像中计算校验和是一个非常占用CPU的工作,并且在未完成镜像迁移时,Docker daemon是不会响应任何请求的,所以如果你本地的老版本镜像和容器比较多,或者是在对服务器负载和响应比较敏感的线上环境尝试升级Docker版本,那就要注意妥善安排时间了。Docker官方也提供了迁移工具让用户在老版本daemon运行的时候进行镜像的迁移。
综上,这里Docker daemon需要在Docker根目录(/var/lib/docker)下创建并初始化一系列跟容器文件系统密切相关的目录和文件。这些文件和目录的具体作用我们会在讲解镜像和volume的时候做详细解释,这里先给读者进行一个简单的总结。

5. 创建Docker daemon网络创建
Docker daemon运行环境的时候,创建网络环境是极为重要的一个部分。这不仅关系着容器对外的通信,同样也关系着容器间的通信。网络部分早已被抽离出来作为一个单独的模块,称为libnetwork。libnetwork通过插件的形式为Docker提供网络功能,使得用户可以根据自己的需求实现自己的driver来提供不同的网络功能。截止到Docker 1.10版本,libnetwork实现了host、null、bridge和overlay的驱动。其中,bridge driver为默认驱动,和之前版本中的Docker网络功能是基本等价的,这一部分的解析在本书3.8节中会详述。需要注意的是,同之前的Docker网络一样,bridgedriver并不提供跨主机通信的能力,overlay driver则适用于多主机环境。
6. 初始化execdriver
execdriver是Docker中用来管理Docker容器的驱动。Docker会调用execdrivers中的NewDriver()函数来创建新的execdriver。在创建execdriver的时候,需要注意以下5部分信息。
- 运行时中指定使用的驱动类别,在默认配置文件中默认使用native,即其对应的容器运行时为libcontainer;
- 用户定义的execdriver选项,即-exec-opt参数值;
- 用户定义的-exec-root参数值,Docker execdriver运行的root路径,默认为/var/run/docker;
- Docker运行时的root路径,默认为/var/lib/docker;
- 系统功能信息,包括容器的内存限制功能、交换区内存限制功能、数据转发功能以及AppArmor安全功能等。AppArmor通过host主机是否存在/sys/kernel/security/apparmor来判断是否加入AppArmor配置。
最后,如果选择了native作为这个execdriver的驱动实现,上述driver的创建过程就会新建一个libcontainer,这个libcontainer会在后面创建和启动Linux容器时发挥作用。
7. daemon对象诞生
Docker daemon进程在经过以上诸多设置以及创建对象之后,最终创建出了daemon对象实例,其属性总结如下。
- ID:根据传入的证书生成的容器ID,若没有传入则自动使用ECDSA加密算法生成。
- repository:部署所有Docker容器的路径。
- containers:用于存储具体Docker容器信息的对象。
- execCommands: Docker容器所执行的命令。
- referenceStore:存储Docker镜像仓库名和镜像ID的映射。
- distributionMetadataStore: v2版registry相关的元数据存储。
- trustKey:可信任证书。
- idIndex:用于通过简短有效的字符串前缀定位唯一的镜像。
- sysInfo: Docker所在宿主机的系统信息。
- configStore: Docker所需要的配置信息。
- execDriver: Docker容器执行驱动,默认为native类型。
- statsCollector:收集容器网络及cgroup状态信息。
- defaultLogConfig:提供日志的默认配置信息。
- registryService:镜像存储服务相关信息。
- EventsService:事件服务相关信息。
- volumes: volume所使用的驱动,默认为local类型。
- root: Docker运行的工作根目录。
- uidMaps: uid的对应图。
- gidMaps: gid的对应图。
- seccompEnabled:是否使用seccompute。
- nameIndex:记录键和其名字的对应关系。
- linkIndex:容器的link目录,记录容器的link关系。
8. 恢复已有的Docker容器
当Docker daemon启动时,会去查看在daemon.repository也就是在/var/lib/docker/containers中的内容。若有已经存在的Docker容器,则将相应信息收集并进行维护,同时重启restart policy为always的容器。综上所述,Docker daemon的启动看起来非常复杂,这是Docker在演进的过程中不断添加功能点造成的。但不管今后Docker的功能点增加多少,Docker daemon进程的启动都将遵循以下3步。
- 首先是启动一个API Server,它工作在用户通过-H指定的socket上面;
- 然后Docker使用NewDaemon方法创建一个daemon对象来保存信息和处理业务逻辑;
- 最后将上述API Server和daemon对象绑定起来,接收并处理client的请求。只不过,NewDaemon方法的长度会不断增加而已。
提示 上述过程主要涉及daemon/daemon.go#NewDaemon这部分源码,读者若有兴趣可以进一步深入研究。
8. 从client到daemon
在前面的小节中已经描述过Docker client和daemon启动并初始化的全过程,接下来的问题是,一个已经在运行的daemon是如何响应并处理来自client的请求的呢?就从docker run这个命令说起吧。
1. 发起请求
(1) docker run命令开始运行,用户端的Docker进入client模式,开始了讲述的client工作过程;
(2) 经过初始化,新建出了一个client;
(3) 上述client通过反射机制找到了CmdRun方法。CmdRun在解析过用户提供的容器参数等一系列操作后,最终发出了这样两个请求:

至此,client的主要任务结束。
前面说过,daemon在启动后维护了一个API Server来响应上述请求,同样遵循“约定大于配置”的原则,daemon端负责响应第一个create请求的方法是:api/server/server.go#postCon-tainersCreate。
在1.6版本及以前,Docker daemon会将一个创建容器的Job交给所谓的Docker Engine来接管接下来的任务。不过这个过程已经被完全废弃并且再也不会回来了。所以接下来我们会按照1.10版本的方式讲解daemon创建和启动容器的过程,解析过程读者完全不需要翻阅代码。
2. 创建容器
估计读者已经猜到,在这一步Docker daemon并不需要真正创建一个Linux容器,它只需要解析用户通过client提交的POST表单,然后使用这些参数在daemon中新建一个container对象出来即可。这个container实体就是container/container_unix.go,其中的CommonContainer字段定义在container/container.go中,为Linux平台和Windows平台上容器共有的属性,本书主要以Linux平台为主,这里将Linux平台上容器最重要的定义片段一并列举如下。容器共有的属性,本书主要以Linux平台为主,这里将Linux平台上容器最重要的定义片段一并列举如下。

这里需要额外注意的是daemon属性,即container是能够知道管理它的daemon进程信息的,很快会看到这个关系的作用。上述过程完成后,container的信息会作为Response返回给client, client会紧接着发送start请求。
3. 启动容器
这时候daemon这边的重点来了。API Server接收到start请求后会告诉Docker daemon进行container启动容器操作,这个过程是daemon/start.go。
注意 1.7版本以后的Docker不仅把所有的client端的请求都使用了一个对应的api/client/{请求名称}.go文件来定义,在daemon端,所有请求的处理过程也放在daemon/{请求名称}.go文件中来定义。读者可以根据自己想了解的内容寻找对应的文件。
此时,由于container所需的各项参数,比如NetworkSettings、ImageID等,都已经在创建容器过程中赋好了值,Docker daemon会在start.go中直接执行daemon.ContainerStart,就能够在宿主机上创建对应的容器了。等一下,创建容器的过程不是要创建namespace,配置cgroup,挂载rootfs吗?谁负责做这些事情呢?答案当然还是Docker daemon。containerMonitor将daemon设置为自己的supervisor。所以经过一系列调用后,daemon.ContainerStart实际上执行操作的是:

即告诉daemon进程:请使用本container相关信息作参数,执行对应execdriver的Run方法。
提示 具体来说,daemon/container.go#ContainerStart其实是经过了waitForStart以及daemon/monitor.go#start的连续调用最终变成daemon.Run的,读者可以自行跟踪上述几个方法。
4. 最后一步
“万事俱备,只欠东风。”在Docker daemon已经完成了所有的准备工作,最后下达了执行Run操作的命令后,Docker该如何指挥操作系统,来为用户启动一个容器出来呢?
答案很简单,所有需要跟操作系统打交道的任务都交给ExecDriver.Run(具体是哪种Driver由container决定)来完成。
读者应该已经了解,execdriver是daemon的一个重要组成部分,它封装了对namepace、cgroup等所有对OS资源进行操作的所有方法。而在Docker中,execdriver的默认实现(native)就是libcontainer了。
所以在这最后一步,Docker daemon只需要向execdriver提供如下三大参数,接着等待返回结果就可以了。
- commandv:该容器需要的所有配置信息集合(container的属性之一);
- pipes:用于将容器stdin、stdout、stderr重定向到daemon;
- startCallback():回调方法。
在下一节里,将会带领读者进入libcontainer的世界,深入解析它如何仅凭借上述三项内容完成Docker容器的创建。
建议 希望读者能够学会上述跟踪一个Docker命令运行的过程,以1.10版本为例总结如下。
- docker/docekr.go是所有命令的起始。它创建出来的client(DockerCli)对应api/client/cli.go。
- api目录下是所有与“client如何发送请求”“server如何响应请求”相关的文件。
- api/client/xxx.go中定义Cmdxxx函数,其中调用的cli.client.xxx函数指明了该命令发起何种HTTP请求。
- api/server/router中则按照不同的请求类型定义了所有响应具体请求的方法。
- 每个请求的处理函数都会对应一个daemon/xxx.go文件,daemon会使用其中相应的函数来对请求进行处理。
- 处理过程中负责执行具体动作的daemon对象是daemon/daemon.go#NewDaemon创建出来的。
- daemon所使用到的Container对象即container/container_unix.go。
- 一般daemon对象的具体动作再执行下去就是去调用execdriver了。比如启动容器调用的就是daemon/execdriver/native/driver.go#Run(三大参数),然后交由底层模块处理。
9. libcontainer
说到底,容器是一个与宿主机系统共享内核但与系统中的其他进程资源相隔离的执行环境。Docker通过对namespaces、cgroups、capabilities以及文件系统的管理和分配来“隔离”出一个上述执行环境,这就是Docker容器。
如果读者直接阅读Docker execdriver的代码,可能会觉得这部分对libcontainer的使用比较晦涩难懂。其实,execdriver首先要完成的工作是在拿到了Docker daemon提交command信息之后,生成一份专门的容器配置。
这个容器配置的生成过程虽然复杂,但是原理很简单。
例如,在Docker daemon提交过来的command中,包含namespace、cgroups以及未来容器中将要运行的进程的重要信息。其中Network、Ipc、Pid等字段描述了隔离容器所需的namespace。

CommonCommand字段中包含了Linux平台和Windows平台通用的配置信息。

Resources字段包含了该容器cgroups的配置信息,定义如下:

ProcessConfig字段描述容器中未来要运行的进程信息,定义如下:

这时,execdriver会加载一个预定义的容器配置模板container,然后在模板中添加来自command的相关信息:

等到上述容器配置模板container中的所有项都按照command提供的内容填好之后,一份该容器专属的容器配置container就生成好了。注意,小写的container其实是一个存储配置信息的对象,后面我们很快会提到大写的Container,它才是libcontainer里的容器对象。container可以理解为libcontainer与Docker daemon之间进行信息交换的标准格式。之后,libcontainer就能根据这份配置知道它需要在宿主机上创建MOUNT、UTS、IPC、PID、NET这5个namespace以及相应的cgroups配置,从而创建出Docker容器。在接下来的两节里,我们将从源代码层次分析上述整个过程。
提示 上述容器配置模板位于daemon/execdriver/native/template/default_template_linux.go。其中大多数启动容器所需的基础配置,比如namespace、系统目录的挂载等属性都已经定义好了。CommonCommand的定义在daemon/execdriver/driver.go#CommonCommand中,读者可以做对比。上述实现的全过程是daemon/execdriver/native/driver.go#Run按照先后顺序调用下述几个方法。
- 创建容器配置:daemon/execdriver/native/create.go#createContainer;
- 加载模板:daemon/execdriver/native/driver_linux.go#InitContainer;
- 在createContainer方法中执行一系列creatXXX方法填充模板。
1. libcontainer的工作方式
上一节讲到,execdriver的Run方法通过Docker daemon提交的command信息创建了一份可以供libcontainer解读的容器配置container。这一节将讲解execdriver接下来如何调用libcontainer加载容器配置container,继而创建真正的Docker容器。Open Container Initiative(OCI)组织成立以后,libcontainer进化为runC,因此从技术上说,未来libcontainer/runC创建的将是符合OpenContainer Format(OCF)标准的容器。这个阶段,execdriver需要借助libcontainer处理以下事情。
- 创建libcontainer构建容器需要使用的“进程”,进程对象(非真正进程),称为Process;
- 设置容器的输出管道,这里使用的就是Docker daemon提供给libcontainer的pipes,详见上一节的描述;
- 使用名为Factory的工厂类,通过factory.Create(<容器ID>, <容器配置container>)创建一个“逻辑”上的容器,称为Container,在这个过程中,容器配置container会填充到Container对象的config项里,container的使命至此就完成了;
- 执行Container.Start(Process)启动物理的容器;
- execdriver执行由Docker daemon提供的startCallback完成回调动作;
- execdriver执行Process.Wait,一直等上述Process的所有工作都完成。
可以看到,libcontainer对Docker容器做了一层更高级的抽象,它定义了Process和Container来对应Linux中“进程”与“容器”的关系。一旦“物理”的容器创建成功,其他调用者就可以通过容器ID获取这个逻辑容器Container,接着使用Container.Stats得到容器的资源使用信息,或者执行Container.Destory来销毁这个容器。
综上,libcontainer中最主要的内容是Process、Container以及Factory这3个逻辑实体的实现原理,而execdriver或者其他调用者只要依次执行“使用Factory创建逻辑容器Container”、“启动逻辑容器Container”和“用逻辑容器创建物理容器”,即可完成Docker容器的创建。
2. libcontainer实现原理
这一节,我们先把前面Docker daemon借助execdriver创建和启动容器的过程归纳为如下一段伪代码,使读者对这个过程有个初步的认识,也方便读者能在接下来介绍Process、Factory和Container的过程中进行参照和对比。

好了,接下来进入正题,我们先从Factory创建逻辑容器Container开始。
1.用Factory创建逻辑容器Container
libcontainer中Factory存在的意义就是能够创建一个逻辑上的“容器对象”Container,这个逻辑上的“容器对象”并不是一个运行着的Docker容器,而是包含了容器要运行的指令及其参数、namespace和cgroups配置参数等。对于Docker daemon来说,容器的定义只需要一种就够了,不同的容器只是实例的内容(属性和参数)不一样而已。对于libcontainer来说,由于它需要与底层系统打交道,不同的平台就需要创建出完全异构的“逻辑容器对象”(比如Linux容器和Windows容器),这也就解释了为什么这里会使用“工厂模式”:今后libcontainer可以支持更多平台上各种类型的容器的实现,而execdriver调用libcontainer创建容器的方法却不会受到影响。下面解释一下Factory的Create操作具体做的事情。
- 验证容器运行的根目录(默认/var/lib/docker/containers)、容器ID(字母、数字和下划线构成,长度范围为1~1024)和容器配置这三项内容的合法性。
- 验证上述容器ID与现有的容器不冲突。
- 在根目录下创建以ID为名的容器工作目录(/var/lib/docker/containers/{容器ID})。
- 返回一个Container对象,其中的信息包括了容器ID、容器工作目录、容器配置、初始化指令和参数(即dockerinit),以及cgroups管理器(这里有直接通过文件操作管理和systemd管理两个选择,默认选第一种)。
2.启动逻辑容器Container
前面提到,Container主要包含了容器配置、控制等信息,是对不同操作系统下容器实现的抽象,目前Linux平台下的容器更为完善。
参与物理容器创建过程的Process一共有两个实例,第一个叫Process,用于物理容器内进程的配置和IO的管理,前面的伪码中创建的Process就是指它;另一个叫ParentProcess,负责从物理容器外部处理物理容器启动工作,与Container对象直接进行交互。启动工作完成后,Parent-Process负责执行等待、发信号、获得容器内进程pid等管理工作。
ParentProcess出现在哪里呢?答案是在启动逻辑容器的时候,即Container.Start()执行的时候。Container的Start()启动过程主要有两个工作:创建ParentProcess实例,然后执行Parent-Process.start()来启动物理容器。创建ParentProcess的过程如下。
创建ParentProcess的过程如下:
创建一个管道(pipe),用来与容器内未来要运行的进程通信(这个pipe不同于前面的输出流pipes,后面会做解释)。
根据逻辑容器Container中与容器内未来要运行的进程相关的信息创建一个容器内进程启动命令cmd对象,这个对象由Golang语言中的os/exec包进行声明,Docker会调用os/exec包中的内置函数,根据cmd对象来创建一个新的进程,即容器中的第一个进程dockerinit。而cmd对象则需要从Container中获得的属性包括启动命令的路径、命令参数、输入输出、执行命令的根目录以及进程管道pipe等。
为cmd添加一个环境变量_LIBCONTAINER_INITTYPE=standard来告诉将来的容器进程(dock-erinit)当前执行的是“创建”动作。设置这个标志是因为libcontainer还可以进入已有的容器执行子进程,即docker exec指令执行的效果。
将容器需要配置的namespace添加到cmd的Cloneflags中,表示将来这个cmd要运行在上述namespace中。若需要加入user namespace,还要针对配置项进行用户映射,默认映射到宿主机的root用户。
将Container中的容器配置和Process中的Entrypoint信息合并为一份容器配置加入到ParentProcess当中。
实际上,ParentProcess是一个接口,上述过程真正创建的是一个称为initProcess的具体实现对象。cmd、pipe、cgroup管理器和容器配置这4部分共同组成了一个initProcess。这个对象是用来“创建容器”所需的ParentProcess,这主要是为了同setnsProcess区分,后者的作用是进入已有容器。逻辑容器Container启动的过程实际上就是initProcess对象的构建过程,而构建initProcess则是为创建物理容器做准备。
接下来逻辑容器Container执行initProcess.start(),真正的Docker容器终于可以诞生了。
3.用逻辑容器创建物理容器
逻辑容器Container通过initProcess.start()方法新建物理容器的过程如下。
- Docker daemon利用Golang的exec包执行initProcess.cmd,其效果等价于创建一个新的进程并为它设置namespace。这个cmd里指定的命令就是容器诞生时的第一个进程。对于libcontainer来说,这个命令来自于execdriver新建容器时加载daemon的initPath,即Docker工作目录下的/var/lib/docker/init/dockerinit-{version}文件。dockerinit进程所在的namespace即用户为最终的Docker容器指定的namespace。
- 把容器进程dockerinit的PID加入到cgroup中管理。至此我们可以说dockerinit的容器隔离环境已经初步创建完成。
- 创建容器内部的网络设备,包括lo和veth。这一部分涉及netlink等,将在3.8节详细介绍。
- 通过管道发送容器配置给容器内进程dockerinit。
- 通过管道等待dockerinit根据上述配置完成所有的初始化工作,或者出错返回。
综上所述,ParentProcess即(initProcess,后面不再进行区分了)启动了一个子进程dockerinit作为容器内的初始进程,接着,ParentProcess作为父进程通过pipe在容器外面对dockerinit管理和维护。那么在容器内部,dockerinit又做了哪些初始化工作呢?
dockerinit进程只有一个功能,那就是执行reexec.init(),该init方法做什么工作,是由对应的execdrive注册到reexec当中的具体实现来决定的。对于libcontainer来说,这里要注册执行的是Factory当中的StartInitialization()。再次提醒读者,接下来的所有动作都发生在容器内部。
- 创建pipe管道所需的文件描述符。
- 通过管道获取ParentProcess传来的容器配置,如namespace、网络等信息。
- 从配置信息中获取并设置容器内的环境变量,如区别新建容器和在已存在容器中执行命令的环境变量_LIBCONTAINER_INITTYPE。
- 如果用户在docker run中指定了-ipc、-pid、-uts参数,则dockerinit还需要把自己加入到用户指定的上述namespace中。
- 初始化网络设备,这些网络设备正是在ParentProcess中创建出来的lo和veth。这里的初始化工作包括:修改名称、分配MAC地址、设置MTU、添加IP地址和配置默认网关等。
- 设置路由和RLIMIT参数。
- 创建mount namespace,为挂载文件系统做准备。
- 在上述mount namespace中设置挂载点,挂载rootfs和各类文件设备,比如/proc。然后通过pivot_root切换进程根路径到rootfs的根路径。
- 写入hostname等,加载profile信息。
- 比较当前进程的父进程ID与初始化进程一开始记录下来的父进程ID,如果不相同,说明父进程异常退出过,终止这个初始化进程。否则执行最后一步。
- 最后一步,使用execv系统调用执行容器配置中的Args指定的命令。
上述Args指定的命令是什么呢?读者不妨回顾一下3.4.2节一开始总结的那段伪码。可以发现,Args[0]正是用户指定的Entrypoint, Args[1,2,3..]则是该指令后面跟的运行参数。所以当容器创建成功后,它里面运行起来的进程已经从dockerinit变成了用户指定的命令Entrypoint(如果不指定,Docker默认的是Entrypoint为/bin/sh -c)。execv调用就是为了保证这个“替换”发生后的Entrypoint指令继续使用原先dockerinit的PID等信息。
如果用户执行的是docker run -i -t ubuntu

我们可以清晰地看到,Docker daemon如何将容器创建所需的配置和用户需要启动的命令交给libcontainer,后者又如何根据这些信息创建逻辑容器和父进程(步骤①),接下来父进程执行Cmd.start,才真正创建(clone)出了容器的namespace环境,并且通过dockerinit以及管道来完成整个容器的初始化过程。在整个过程中,容器进程则经历了3个阶段的变化。
Docker daemon进程进行“用Facotry创建逻辑容器Container”“启动逻辑容器Container”等准备工作,构建ParentProcess对象,然后利用它创建容器内的第一个进程dockerinit。
dockerinit利用reexec.init()执行StartInitialization()。这里dockerinit会将自己加入到用户指定的namespace(如果指定了的话),然后再开始进行容器内部的各项初始化工作。
StartInitialization()使用execv系统调用执行容器配置中的Args指定的命令,即Entry-Point和docker run的[COMMAND]参数。
那么Docker daemon如何管理已经创建后的Docker容器呢?下面我们就来说明容器创建后的通信方式——管道。
4. Docker daemon与容器之间的通信方式
前面解析的容器进程启动后需要做初始化工作,就使用了namespace隔离后的两个进程间的通信。我们把负责创建容器的进程称为父进程,容器进程称为子进程。父进程克隆出子进程以后,依旧是共享内存的。如何让子进程感知内存中写入了新数据依旧是一个问题,一般有以下4种方法。
发送信号通知(signal)
对内存轮询访问(poll memory)
sockets通信(sockets)
文件和文件描述符(files and file-descriptors)
对于signal而言,本身包含的信息有限,需要额外记录,namespace带来的上下文变化使其操作更为复杂,并不是最佳选择。显然通过轮询内存的方式来沟通是一个非常低效的做法。另外,因为Docker会加入network namespace,实际上初始时网络栈也是完全隔离的,所以socket方式并不可行。
Docker最终选择的方式就是管道——文件和文件描述符方式。在Linux中,通过pipe(int fd[2])系统调用就可以创建管道,参数是一个包含两个整型的数组。调用完成后,在fd[1]端写入的数据,就可以从fd[0]端读取,如下所示:

调用pipe函数后,创建的子进程会内嵌这个打开的文件描述符,对fd[1]写入数据后可以在fd[0]端读取。通过管道,父子进程之间就可以通信。这个通信完成的标志就在于EOF信号的传递。众所周知,当打开的文件描述符都关闭时,才能读到EOF信号。因此libcontainer中父进程在通过pipe向子进程发送完毕初始化所需信息后,先关闭自己这一端的管道,然后等待子进程关闭另一端的管道文件描述符,传来EOF表示子进程已经完成了这些初始化工作。综上,在libcontainer中,ParentProcess进程与容器进程(cmd,也就是dockerinit进程)的通信方式如图3-10所示。

3.使用runC与libcontainer进行交互
Linux基金会于2015年6月成立OCI(Open Container Initiative)组织,旨在围绕容器格式和运行时制定一个开放的工业化标准。制定容器格式标准(Open Container Format, OCF)的宗旨概括来说就是不受上层结构的限定,如特定的客户端、编排栈等,同时也不受特定的供应商或项目的限定,即不限于某种特定操作系统、硬件、CPU架构、公有云等。runC直接对libcontainer包进行调用,去除了Docker包含的诸如镜像、volume等高级特性,以最朴素简洁的方式达到符合OCF标准的容器管理实现。
在本节的最后,我们必须要介绍一下runc的使用方法。nsinit是一个为了方便不通过Docker就可以直接使用libcontainer而开发的命令行工具。它可以用于启动一个容器或者在已有的容器中执行命令。使用nsinit需要有rootfs及相应的配置文件。
1. runC的构建
使用runC需要相关容器配置文件以及rootfs,相关配置文件可以使用runc spec命令来进行生成。其中的config.json为容器的基础配置文件,runtime.json为容器的运行时文件。其中rootfs最简单、最常用的是使用Docker busybox,可以使用如下命令生成。

官方的README文档中已经给出具体的构建步骤,此处不再赘述。在准备好相关配置文件以及rootfs之后,只需执行如下命令,即可进行创建并运行一个名为runc-test的容器(需要root权限)。

执行完成后会在/run/runc之下生成一个以容器ID命名的文件夹,该文件夹下会生成一个state.json文件,表示容器的状态,其中的内容与配置参数中的内容类似,展示容器的状态。
2. runC的使用
目前runC定义了多个指令,使用runc -h就可以看到,对于每个单独的指令使用——help就能获得更详细的使用参数,如runc start ——help。
runC这个命令行工具是借助开源项目github.com/codegangsta/cli实现的,main.go中定义了各个子命令,而每个子命令又使用单独的.go文件进行实现,这使得runC本身的代码非常简洁明了。具体的命令功能如下。
- delete:删除任何被此容器所占用的资源。
- events:展示容器中类似于OOM提示,CPU、内存和IO的使用情况。
- exec:在容器中执行一个新的进程。
- init:这是一个内置的参数,用户并不能直接使用。这个命令是在容器内部执行,为容器进行namespace初始化,并在完成初始化后执行用户指令。所以在代码中,运行runc exec后,传入到容器中运行的实际上是runc init,把用户指令作为配置项传入。
- kill:给容器中的init发送一个特性的信号(默认为SIGTERM)。
- list:列出在给定目录下被runC启动的容器。
- ps:展示容器中运行的进程。
- pause/resume:暂停/恢复容器中的进程。
- spec:使用内置的默认参数加上执行命令时用户添加的部分参数,生成一份容器可用的标准配置文件。
- start:创建并启动一个容器。
- state:展示容器状态,就是读取state.json文件。
- update:更新容器的资源文件。
- checkpoint:保存容器的检查点快照并结束容器进程,需要填——image-path参数,后面是检查点保存的快照文件路径,完整的命令示例如下。
- restore:从容器检查点快照恢复容器进程的运行,参数同上。

综上,runC与Docker execdriver进行的工作基本相同,在Docker的源码中并不会涉及runC包的调用,但runC为libcontainer自身的调试和使用带来了极大的便利。
本节主要介绍了用于Docker容器管理的libcontainer,从libcontainer的使用入手解读源码实现方式。我们深入到容器进程内部,感受到了libcontainer周到全面的设计。总体而言,libcontainer本身主要分为三大块工作内容,一是容器的创建及初始化,二是容器生命周期管理,三是进程管理,调用方为Docker的execdriver。容器的监控主要通过cgroups的状态统计信息,未来会加入进程追踪等更丰富的功能。另一方面,libcontainer在安全支持方面也为用户尽可能多地提供了支持和选择。