Docker原理
一、docker底层原理
第一部分分析了docker使用的namespace和cgroup底层技术。首先对技术进行简明扼要的介绍,接着分析和比较了新旧两个版本的docker使用这些技术的关键流程。
1、namespace相关知识
1.1. 分类
- Mount namespace提供了文件系统的隔离,通过隔离文件系统挂载点来实现。这样在容器中访问文件系统时,访问的将是自己的文件系统,而不是母机或者其他容器的文件系统
- UTS namespace提供了主机名和域名的隔离,这样每个容器就可以拥有了独立的主机名和域名,在网络上可以被视作一个独立的节点而非宿主机上的一个进程
- IPC namespaces提供了进程间通信的隔离,这样只有在一个namespace下的进程才能够互相通信
- PID namespaces提供了进程号的隔离,这样启动一个容器时,它的init进程在容器中显示的进程名为1,成为容器中所有进程的父进程
- Network namespaces提供了网络相关系统资源的隔离,这样容器就拥有自己的网络设备、IP地址、IP路由表、/proc/net目录、端口号等等
- User namespaces提供了用户和用户组ID的隔离,使用了这个参数后,内部看到的UID和GID已经与外部不同了,这样用户就能在容器中拥有完全的root权限,但是在容器外却没有特权
1.2 相关系统调用
- clone() — 实现线程的系统调用,用来创建一个新的进程,并可以通过设计上表中的系统调用参数达到namespace隔离
- unshare() — 使某进程脱离某个namespace, unshare()运行在原先的进程上,不需要启动一个新进程
- setns() — 把某进程加入到某个namespace,在进程都结束的情况下,也可以通过挂载的形式把namespace保留下来,通过setns()系统调用,你的进程从原先的namespace加入我们准备好的新namespace
1.3 v1.8.3使用流程
(1)综述
在run start 流程中,创建出linuxContainer对象后,linuxContainer对象的newInitProcess方法会进行一些容器init进程的初始化配置操作,其中包括配置将要启动的init进程的命名空间相关的系统调用参数,以便在启动子进程的时候能够使用上述的六种命名空间。
(2)具体流程
一方面,在调用父进程创建子进程时,首先会调用newInitProcess方法,该方法会调用configs包中的namespace对象的CloneFlags方法,获取容器进行clone系统调用所需要的系统调用参数(CLONE_NEWNS等),并将这个参数传递给cmd对象的SysProcAttr.Cloneflags参数(cmd.SysProcAttr.Cloneflags = cloneFlags)。
在后续调用cmd对象的Start方法时,会将其SysProcAttr.Cloneflags变量传递给syscall包的ProcAttr对象的syscall.SysProcAttr.Cloneflags参数中。最后,在syscall包调用forkAndExecInChild方法创建init进程时会调用clone系统调用,并将syscall.SysProcAttr.Cloneflags存放的参数传递给clone系统调用,就可以启用namespace。
另一方面,在子进程也就是容器的init进程启动后,也会进行一些namespace相关的配置工作。如果设置启用了 Mount namespace,则会在libcontainer包中linuxStandardInit对象的Init方法中调用setupRootfs函数,进行/dev、/proc、/sys等目录的重新挂载操作,将母机中的目录挂载到容器相应目录上。
如果设置了UTS namespaces ,则会在上面调用完setupRootfs函数后,调用syscall.Sethostname,动态设置容器的主机名。若设置了User namespaces,调用Sethostname函数后,会调用finalizeNamespace函数完成uid和gid的设置。(Todo : 网络部分展示还没有分析清楚)。
1.4 v1.11.2使用流程
与上述的v1.8.3版本不同, v1.11.2版本并未使用clone系统调用设置init进程的namespace,而是使用setns系统调用将init进程加入到特定空间中。具体来说,在newInitProcess方法中,首先会创建一个映射,存放namespace配置项中的NamespaceType到namespace path之间的映射关系。接着调用bootstrapData函数,将这些配置数据存放在io.Reader类型中,并将其存放在initProcess对象中,最终会将这些数据放入到父子进程通信的管道中,调用initProcess对象的execSetns方法,执行一段c程序,完成setns系统调用,最终设置init进程的namesapce。但是,在init进程中对namespace的设置和处理与v1.8.3版本基本一致。
2. cgroup基础知识
2.1 概述
Linux CGroup全称Linux Control Group, 是Linux内核的一个功能,用来限制,控制与分离一个进程组群的资源。其主要功能如下:
- Resource limitation: 限制资源使用,比如内存使用上限以及文件系统的缓存限制
- Prioritization: 优先级控制,比如:CPU利用和磁盘IO吞吐
- Accounting: 一些审计或一些统计,主要目的是为了计费
- Control: 挂起进程,恢复执行进程
2.2 子系统分类
在/sys/fs下有一个cgroup的目录,这个目录下还有很多子目录,比如: cpu,cpuset,memory,blkio……这些,这些都是cgroup的子系统。control group子系统有:
- blkio — 这个子系统为块设备设定输入/输出限制,比如物理设备(磁盘,固态硬盘,USB 等等)。
- cpu — 这个子系统使用调度程序提供对CPU的cgroup 任务访问。
- cpuacct — 这个子系统自动生成cgroup 中任务所使用的CPU报告。
- cpuset — 这个子系统为cgroup 中的任务分配独立 CPU(在多核系统)和内存节点。
- devices — 这个子系统可允许或者拒绝cgroup 中的任务访问设备。
- freezer — 这个子系统挂起或者恢复cgroup 中的任务。
- memory — 这个子系统设定cgroup 中任务使用的内存限制,并自动生成内存资源使用报告。
- net_cls — 这个子系统使用等级识别符(classid)标记网络数据包,可允许 Linux 流量控制程序(tc)识别从具体cgroup 中生成的数据包。
- net_prio — 这个子系统用来设计网络流量的优先级
- hugetlb — 这个子系统主要针对于HugeTLB系统进行限制,这是一个大页文件系统。
2.3. 使用示例
以blkio限制为例,看看cgroup如何实现磁盘I/O的限制。我们先看一下我们的硬盘IO,我们的模拟命令如下:(从/dev/sda1上读入数据,输出到/dev/null上)
(1) 运行sudo dd if=/dev/sda1 of=/dev/null
,并通过iotop命令我们可以看到相关的IO速度是55MB/s(虚拟机内):
TID PRIO USER DISK READ DISK WRITE SWAPIN IO> COMMAND
8128 be/4 root 55.74 M/s 0.00 B/s 0.00 % 85.65 % dd if=/de~=/dev/null…
(2)然后,先创建一个blkio(块设备IO)的cgroup: mkdir /sys/fs/cgroup/blkio/haoel
,把读IO限制到1MB/s,并把前面那个dd命令的pid放进去(注:8:0 是设备号,你可以通过ls -l /dev/sda1获得):
root@ubuntu:~# echo '8:0 1048576' > /sys/fs/cgroup/blkio/haoel/blkio.throttle.read_bps_device
root@ubuntu:~# echo 8128 > /sys/fs/cgroup/blkio/haoel/tasks
(3)再用iotop命令,你马上就能看到读速度被限制到了1MB/s左右。
TID PRIO USER DISK READ DISK WRITE SWAPIN IO> COMMAND
8128 be/4 root 973.20 K/s 0.00 B/s 0.00 % 94.41 % dd if=/de~=/dev/null...
2.4. v1.8.3使用流程
v1.8.3版本中cgroup有两种类型,分别为systemd和raw。这两种类型存储cgroup的路径不相同,调用子系统的函数路径也不相同。 docker使用cgroup的关键的数据结构是是一个Manager接口,该接口包含一个cgroup结构体,该结构体中包含有container的cgroup路径等关键信息,docker正是通过这个接口使用cgroup。
在initProcess对象的start方法中调用 p.manager.Apply。p.manger由linuxContainer对象的cgroups.Manager成员赋值,而后者的获取源自LinuxFactory对象调用相应的NewCgroupsManager函数从上层–execdriver中产生的configs.Config对象的cgroup成员处获取。
如果为systemd类型,就会调用apply_systemd.go中的Manager对象的Apply方法,该方法最终调用上述不同子系统的Manager对象的Set方法,也就是调用cgroup/fs/文件加下相应文件中的Manager对象的Set方法。
如果为raw类型,会调用ibcontainer包fs文件夹下的apply_raw.go文件中的Manager对象的Apply方法。这个方法调用getCgroupData函数,返回cgroupData指针,并将该指针作为参数调用不同子系统的Apply方法。这些子系统的Apply方法位于fs包相应文件下,例如blkio子系统的BlkioGroup对象的Apply方法位于fs包的blkio.go文件中,其他类似。子系统的Apply方法会调用同文件的Set方法,Set方法向特定文件写入特定容器的pid,完成cgroup的资源限制动作。
2.5. v1.11.2使用流程
v1.11.2版本使用的libcontainer包取消了systemd类型,因此只有raw类型。所以其走的整体路径就是v1.8.3中raw类型的路径。
因为v1.11.2版本中调整了docker的架构,删除了execdriver,因此不同于v1.8.3中p.manger的获取。v1.11.2版本会直接调用runc,而不是绕过runc直接调用libcontainer包,所以在v1.11.2版本中增加了一个Spec对象,而其p.manger是由runc包中的createContainer函数调用CreateLibcontainerConfig函数获取config对象得到的。
二、Docker关键进程交互
从docker 1.11.0版本开始,docker在架构上有较大的变动,Docker Daemon的架构由原来一个模块,拆分为docker、containerd、docker-containerd-shim和docker-runc四个模块。 这四个模块对应四个关键进程,第二部分基于docker1.11.2版本,从容器的启动、停止和暂停三个应用场景,分析和研究这四个关键进程的交互关系。
1. 相关进程概述
docker架构调整后,在进行容器相关操作时,不仅仅依赖于docker daemon守护进程,而是增加了几个相关的进程,包括containerd进程、containerd-shim进程以及runc进程。containerd进程是一个后台的守护进程,只处理containers,管理容器的开始,停止,暂停和销毁。
containerd-shim进程会从runc进程手上接管创建的容器init进程。使用containerd-shim进程有三个作用:
(1) 它能让runc进程直接退出,当runc进程执行启动容器的命令后。
(2) 当docker daemon或者containerd守护进程挂掉后,它能够保证容器的STDIO和其他fds都保持打开的状态。也就是说,如果没有containerd-shim命令,结束daemon或者containerd守护进程后,容器就会退出。
(3) 它能保证容器的退出状态被上报到更上层的工具(例如docker)中,而不需要docker自己成为容器的父进程,或者做一个wait4操作。
runC 是一种无需进入 Docker Engine,直接控制 libcontainer 的小型命令行工具,是一种管理和运行 OCI 容器的单机二进制文件。因此runc进程主要负责实际上容器的创建和销毁工作。上述进程之间的关系如下图所示:
容器的创建或者销毁等过程也是这些进程之间交互的过程,下面基于特定场景对进程间的交互关系进行分析。
2. 容器场景分析
2.1. 容器启动场景
容器启动场景对应docker start命令,其主要操作是将一个已经存在的容器启动起来,创建容器的init进程。
如上图所示,假设现在有三个创建容器的命令,这三个命令首先会被docker daemon进程接收到,并分别创建一个goroutine来处理。这些goroutine最终都会调用远程containerd守护进程的api,接着containerd守护进程就会产生三个对应的goroutine来处理相应请求。
containerd进程产生的每个goroutine都会创建一个子进程,这个子进程执行containerd-shim命令。然后,containerd-shim进程又会创建一个子进程,该子进程执行runc命令,特别说明的是执行的runc命令带有-d和–console参数,这样runc创建子进程后不会等待子进程会直接退出。同时在创建runc子进程之前会调用osutils包的SetSubreaper函数,让当前的containerd-shim进程在子进程退出后能够接收runc创建的所有子进程,保证runc进程创建的容器init进程能够被containerd-shim进程接管。
最终,runc进程创建子进程,也即是容器的init进程,接着runc进程退出,将其子进程交付给containerd-shim进程。这也就是containerd-shim进程的第一个作用。
2.2. 容器重启/停止场景
容器的重启/停止场景对应docker的几个命令:
- 容器的重启过程,对应docker restart命令,主要由两个子流程组成,分别是stop子流程和start子流程。docker restart命令先执行stop命令,再执行start命令。start子流程上面已经分析过,因此主要分析stop子流程,对应容器的停止场景。
- 容器友好的停止场景对应docker stop命令,该命令会向init进程发送一个SIGTERM停止信号,并等待一段时间,如果过了这段时间进程没有停止,那么就向进程发送SIGKILL信号,从而强制杀死进程。因此stop流程两个阶段基本相同,只是发送的信号不同,因此下面分析stop流程的前一个阶段。
- docker kill命令也是一种容器的停止场景,在这种场景下会强制结束容器的init进程,该命令会向init进程发送SIGKILL信号。
如上图所示,stop子流程经过docker daemon进程处理后,会通过远程api调用containerd包中的apiServer的Signal方法,并传递容器id和进程终止信号SIGTERM 。该方法会生成一个SignalTask,将其送入supervisor中,supervisor随后会处理这个task,调用对应的signal方法。supervisior中维护着一个映射,这个映射中存放了容器id和容器结构体的对应关系,因此通过传递的容器id,可以获取其对应的runtime包的Container对象。Container对象有一个Processes方法,该方法可以获取容器中的所有进程,接着调用Signal方法将传入的进程终止信号送入所有进程中,结束所有进程。
从容器的启动过程可以看出,一个容器对应两个父子进程----containerd-shim进程和init进程。那么容器停止过程中,进程交互如下:
(1) 首先,在启动containerd-shim进程后,containerd-shim进程会创建runc进程,等runc进程退出后,containerd-shim进程不会退出,而是将对应容器的init进程收为自己的子进程,并且通过设置Pdeathsig参数,保证在containerd-shim进程退出时,容器的init进程也会结束。
(2) 接着,在启动runc进程之前,containerd-shim进程会使用signal包的Notify方法监听系统中的所有信号。因此当containerd-shim进程将对应容器的init进程收为自己的子进程后,containerd-shim进程会接收所有信号,选出发送syscall.SIGCHLD的进程,并将该进程的进程号和对应的子进程(也就是容器的init进程)比较,如果发现是该进程发送的,就将标志为exitShim设为true。又因为容器中可能不仅仅只有init进程,所以此时会调用sync包WaitGroup对象的wait方法,等待容器的所有进程退出,如果所有进程都退出,那么containerd-shim进程就会退出。
(3) 在使用containerd进程启动容器时,对应的containerd-shim进程会将创建的容器init进程的pid返回给containerd进程,并将该进程加入到Monitor中进行监控,这个监控使用epoll机制,监控指定进程使用一个exit管道,当管道断开时,Monitor就监控到容器进行了退出操作,就会进行后续处理。具体来说,当容器进程退出后,containerd进程会创建一个exitTask,并调用supervisior对象进行处理,在处理过程中会创建一个DeleteTask,传入supervisior对象进行处理,它会删除容器占用的所有资源,并创建子进程,执行runc delete命令。在这里也体现了containerd-shim的第三个作用,通过它能将容器的退出状态传递给上层。
(4) 进入runc delete命令,该命令首先会调用getContainer函数,getContainer函数使用loadFactory,根据容器id,返回一个linux based container factory,其中存放容器的root目录等。接着调用factory对象的load方法,该方法会调用loadState方法,从容器root目录中的state.json文件中获取运行着的容器的状态,并将这些状态重新整合,返回一个libcontainer包的Container对象。因为此时容器进程已经停止,因此接着会调用destroy函数,该函数的参数为上面返回的Container对象。最终调用state_linux.go文件中的destroy函数,这个函数会向相同pid名字空间的进程发送停止信号,并等待这些进程退出,然后销毁容器对应的cgroupManger,最终修改容器的状态。2.3. 容器暂停/恢复场景
容器的暂停/恢复场景对应docker pause/resume命令,这两个命令的处理过程基本相同,不同的只是对容器状态记录的修改。因此下面只分析容器的暂停场景。暂停场景仅仅涉及到daemon进程、containerd进程和runc进程,并不会涉及到containerd-shim进程。
如上图所示,暂停场景分为下面几个阶段:
(1) 容器的pause、resume子流程经过docker daemon进程处理后,会通过远程api调用containerd包中的apiServer的UpdateContainer方法,该方法会创建一个updateTask,其中会携带容器id作为参数,并将其送入supervisor中。
(2) supervisor随后会处理这个task,调用对应的updateContainer方法。和上面的stop流程相同,根据参数容器id就能找到对应的runtime包的Container对象,并最终调用这个Container对象的Pause/Resume方法。这个方法会fork一个子进程,来执行runc pause/resume命令,使用容器id作为参数。
(3) runc pause/resume命令,首先会调用getContainer函数,同上文所述,创建并返回一个libcontainer包中的container对象,并调用该对象的pause/resume方法。该方法获取容器状态(包括init进程id、cgroup参数等等),并调用cgroup包中的Manager对象的Freeze方法(pause和resume的不同在于传递的参数不通)。Freeze方法会调用cgourp的freezer子系统,完成其中进程的挂起/恢复。
三、Docker命令流程分析
1. v1.11.2 run命令流程分析
1.1 总体流程
Docker run命令的入口函数是api/client/container/run.go中的NewRunCommand函数,它首先对输入的命令行参数进行收集处理,再调用同文件中的runRun函数。runRun函数会依次调用github.com/docker/engine-api/client包中的APIClient对象的三个方法——ContainerCreate、ContainerAttach和ContainerStart。如下图所示,这三个方法对应run命令的三个子流程,下面具体介绍这三个子流程:
1.2 重要数据结构
- DockerCli对象是docker命令行客户端
- Container对象是容器对象,主要保存容器相关的信息
- Daemon对象保存docker deamon相关信息
- Spec对象保存容器的基本配置信息
1.3 create子流程
Client-server交互阶段
该阶段的入口函数为ContainerCreate函数,它位于engine-api/client/container_create.go 中,如下图所示,主要功能是向server端发送“/containers/create”请求,并获取相应的回复。经过路由分发后会调用postContianersCreate函数,该函数最终会调用daemon包中的ContainerCreate函数,该函数为下一阶段的入口函数。
Daemon处理阶段
该阶段的入口函数为doaemon包中create.go中的ContainerCreate函数,它仅仅封装了同文件中的containerCreate函数。containerCreate函数首先进行配置文件的验证和调整,最终调用同文件中的create函数。create函数的具体流程为:
(1)调用daemon包中的newContainer函数,返回一个Container对象
(2)调用daemon包中的setRWLayer函数,它首先调用image包中的store对象的Get方法获取Image对象,然后调用Image对象的ChainID方法,获取其layerID,最后调用CreateRWLayer函数创建容器的RWLayer
(3) 调用docker/pkg/idtools包中的GetRootUIDGID函数,获取相应的uid-gid对
(4)调用docker/pkg/idtools包中的MkdirAs函数,函数创建一个目录,将其赋值给Container对象的Root参数中,并根据这个uid-gid对调整新建目录的所有权
(5)调用daemon包中的createContainerPlatformSpecificSettings函数,这个函数跟平台有关,如果为linux,则调用create_unix.go中的相应函数。linux的createContainerPlatformSpecificSettings函数具体流程如下:
- 调用daemon包的Mount函数,它设置容器的BaseFS。它首先挂载上面创建的RWLayer,返回指向writeable layer的文件系统路径,并赋值给container对象的BaseFS参数。Mount函数的调用链路径为Mount函数->layer包中mounted.layer.go的Mount函数->aufs包中的Get函数->aufs包中的mount函数->aufs包中的aufsMount函数->aufs包中mount_linux.go中的mount函数->syscall包中的Mount函数
- 调用container包的SetupWorkingDirectory函数,它根据container.Config.WorkingDir参数和Container.BaseFS的路径创建容器的工作目录
- 调用store包的volumeStore对象的CreateWithRef方法和container包中的AddMountPointWithVolume函数,处理volume相关的操作
(6)进行一些网络相关的操作
(7) 调用container包中的ToDisk函数,将容器保存到disk上
(8) 调用daemon包中的Register函数,进行注册工作
1.4 attach子流程
Client-server交互阶段
该阶段的入口函数为ContainerAttach函数,它位于engine-api/client/container_attach.go 中,如下图所示,主要功能是向server发送“/containers+containerID+/attach”请求,并获取相应回复。经过路由分发后调用postContianersAttach函数,该函数最终会调用daemon包中的ContainerAttach函数,该函数为下一阶段的入口函数。
Daemon处理阶段
该阶段的入口函数为ContainerAttach函数,如下图所示,它的具体流程为:
(1)获取容器Container对象
(2)调用容器对象的GetStream方法,获取inStream, outStream, errStream三个流
(4)根据容器的配置信息,判断是否将三个流赋值给stdin,stdout和 stderr变量
(5)将上面三个变量作为参数,调用daemon包的containerAttach函数。
containerAttach函数是实现attach的关键函数,,如果设置了logs参数,该函数会获取系统日志,并调用LogContainerEvent函数,进行日志相关处理。如果设置了stream参数,调用container包中的Attach函数,该函数连接容器的TTY,最终完成attach子流程。
1.5 start子流程
Client-server交互阶段
该阶段的入口函数为ContainerStart函数,它位于engine-api/client/container_start.go 中,如下图所示,其主要功能是向server发送/containers/ +containerID+/start” 请求,并获取相应回复。经过路由分发后调用postContianersStart函数,该函数最终会调用daemon包中的ContainerStart函数,它是下一阶段的入口函数。
Daemon处理阶段
(1)该阶段入口函数为daemon包start.go中的ContainerStart函数,它仅仅包装了同文件中的containerStart函数。该函数为daemon阶段的处理的核心函数,其具体流程如下:
(2)调用 daemon包中的conditionalMountOnStart函数,这个函数与平台有关,如果为linux,则调用daemon_unix.go中的相应函数,该函数会调用daemon包中的Mount函数,Mount函数设置容器的BaseFS
(3)处理网络相关操作
(4)调用 createSpec函数,如果为linux,则调用oci_linux.go中的相应函数,该函数主要返回一个libcontainerd包中的Spec结构体。其具体过程为:
- 调用oci.DefaulteSpec函数,根据oci标准获取一些容器基本信息
- 调用populateCommonSpec函数,将容器的一些基本配置信息填充到容器中,主要包括创建调用SetupWorkingDirectory函数,以及填充process相关信息,填充Spec结构体
- 设置cgrouppath、调用各种set函数,设置资源、命名空间等等
- 调用 daemon/volumes_unix.go中的setupMounts函数,该函数产生一系列的Mount操作,遍历挂载点并对每个挂在点调用setup函数(setup函数完成已配置挂载点的挂载或者创建源目录,调用链为setup->localVolume对象的Mount函数->localVolume对象的mount函数->pkg/mount包中的Mount函数),返回一个数组,数组中每个元素都是Mount结构体
- 调用setMounts函数,将信息整合成spec.Mount规定的形式,放入Spec结构体中
(5)调用libcontainerd 包的client_linux.go中的Create函数,它是下一阶段的入口函数
libcontainer处理阶段
该阶段的入口函数为libcontainerd 包的client_linux.go中的Create函数,如下图所示,其具体流程为:
(1)调用 getContainer函数,返回一个container对象
(2)调用 getRootIDs函数,返回uid和gid
(3)调用 prepareBundleDir函数,返回string对象(dir路径)
(4)调用 newContainer函数,返回一个新的container对象
(5)调用 idtools包的MkdirAllAs函数,该函数创建一个目录,并改变其所有权
(6)调用 os包的Create函数,其流程为:
- 创建一个文件,用来存放spec对象
- 调用libcontainerd/container_linux.go中的start函数,在其中会创建一个特定的请求对象,其类型为 - containerd包中CreateContainerRequest结构体
- 调用api/types/api_pb.go中的APIClient接口中的 CreateContainer函数,进入containerd处理阶段,- 上面的请求对象作为该函数的参数
Containerd处理阶段(使用containerd v0.2.2版本)
Containerd阶段的代码都位于/github.com/docker/containerd目录中。该阶段入口函数为api/types/api_pb.go中的APIClient接口中的 CreateContainer函数,该函数会进一步调用其他关键函数。其调用链为:
(1)CreateContainer函数调用api/types/server/server.go中的apiServer对象中的CreateContainer函数,该函数生成一个StartTask对象,并将该对象通过通道送到Supervisor中处理,调用其handleTask函数
(2)handleTask函数调用supervisor包的create.go中的start函数,start函数会加入一些相关信息,创建一个新的container对象,并创建一个startTask对象,将其通过通道送到worker中处理,调用worker.go中的Start函数
(3)Start函数为最终调用函数,其具体流程为:
- 调用runtime/container_linux.go中container对象的Start函数,它录入各种process相关信息,并且创建process对象和cmd对象,同时调用startCmd函数,在其中调用cmd对象的start函数,新建一个子进程并执行docker-containerd-shim命令,启动containerd-shim进程
- 调用MonitorOOM函数,通知OOM事件接着调用monitorProcess函数
runc处理阶段
runc命令的入口函数会根据相应参数进入指定的libcontainer包中的相应函数,上面的runc命令参数为start,最终会调用libcontainer包中的process.go中initProcess对象的start函数,该函数会创建一个子进程,该进程为将要创建容器的init进程(这里要特别说明的是由于只调用了cmd对象的start函数,因此runc进程不会等待子进程执行结束,而是会直接返回,因此当容器创建成功后,创建其的runc进程已经释放了),其具体创建过程为:
(1)调用cmd对象的Start函数
(2)Start函数会调用os包的StartProcess函数,该函数位于os/doc.go中,它仅仅是的startProcess函数的封装
(3)startProcess函数位于os/exec_posix.go中,它调用syscall\exec_unix.go中的StartProcess函数
(4)syscall包中的StartProcess函数是一个与平台相关的函数,在linux下它仅仅是对同文件中forkExec函数的封装
(5)forkExec函数首先进行一些信息的处理,然后调用forkExecPipe函数,最后调用forkAndExecInChild函数,该函数在syscall\exec_linux.go中,这个函数是实现容器init进程启动并在init的关键函数
如下图所示,forkAndExecInChild函数的调用流程如下:
- 调用RawSyscall(SYS_GETPID, 0, 0, 0)函数,取得父进程的PID
- 分配管道以便父子进程通信
- 接着调用RawSyscall6(SYS_CLONE, uintptr(SIGCHLD)|sys.Cloneflags, 0, 0, 0, 0, 0)函数,开始fork进程
- fork成功后,进入子进程中,根据SysProcAttr结构体中的配置信息,进行相应的处理和操作。其中,如果chroot字段不为空,就进行change root操作,调用 RawSyscall(SYS_CHROOT, uintptr(unsafe.Pointer(chroot)), 0, 0)函数
- 向父进程发送死亡信号,并进行一些善后处理工作
2. v1.8.3 run命令流程分析
2.1 总体流程
Docker run命令的入口函数是api/client/container/run.go中的cmdRun函数,同1.12.0版本一样,该命令最终将整个过程分为三个子流程,前面两个子流程的处理过程与1.12.0版本基本一致,因此主要介绍start子流程。
2.2 start子流程
client-server交互阶段
与1.12.0版本过程基本相同,最终都是通过路由找到postContainersStart函数,进入daemon处理过程,进入下一阶段入口函数doaemon包中start.go中的ContainerStart函数。
daemon与libcontainer阶段
ContainerStart函数调用container对象的Start函数,该函数完成下列流程:
(1)前期准备工作
- 给容器加锁container.Lock
- 调用container对象的Mount函数,获取容器的basefs
- 做一些启动容器的准备工作,调用container.prepareStorage、initializeNetworking、 setupLinkedContainers、setupWorkingDirectory、setupWorkingDirectory、createDaemonEnvironment函数
(2)挂载点生成及注册
- 调用container.setupMounts函数,新建mountpoint结构体,加入/etc/hostname、/etc/hosts 、/etc/resolv.conf 等挂载点
- 调用container.waitForStart函数,该函数最终调用monitor.go中的containerMonitor结构体中的Start函数,Start函数
- 调用execdriver包中的NewPipes函数,创建管道
- 调用daemon包中的Deamon结构体的Run函数,它封装了execdriver包中的Driver接口中的Run方法,该接口在native包中driver对象中实现,因此调用diver对象的Run方法,调用driver对象的createContainer方法(daemon/execdriver/native/create.go),该方法首先调用一个initContainer操作,该操作中的template.New函数会产生各种默认的模板信息,将其填充到Config对象中,其中就包括一些默认挂载点的信息,该函数最终返回一个配置结构体
(3)逻辑容器和逻辑process的创建
逻辑容器的创建在diver对象的Run方法中实现,其实现流程为:
- 创建Process结构体(libcontainer中)
- 调用setupPipes函数
- 调用libcontainer包中的Factory对象的Create方法,该方法最终返回libcontainer中定义的Container接口,但是真正返回的是一个linuxContainer对象(在Linux系统下)
- 调用libcontainer包中的Container接口的Start方法,该方法为逻辑容器启动的入口函数
(4)容器的启动
逻辑容器的启动在libcontainer包中的Container接口的Start方法中实现,实现流程为:
因为doInit为真,表示创建容器的init进程,因此调用 newParentProcess函数创建一个initProcess对象,它实现了parentProcess接口
调用initProcess对象的start方法,该方法进入进程启动的调用链,实现基本基于os包和syscall包,用于fork一个进程,并exec,其调用链如下:
- 调用os/exec.go中的Cmd对象的Run函数
- 调用 os/exec.go中的Cmd对象的Start函数
- 调用os包的StartProcess函数
- 调用os包中的startProcess函数
- 调用syscall包中的StartProcess函数
- 调用syscall包中的forkExec函数,该函数创建一个父子进程通信的管道,接着调用forkAndExecI nChild函数
- forkAndExecInChild函数根据逻辑process创建并启动进程,同时在进程中执行/proc/self/exe native操作(默认情况下execdriver使用native),进入容器最终的配置阶段
initProcess对象的start方法完成进程启动后调用cgroups.Manager的Apply方法,该方法会创建根据 cgroup类型(一般有raw和systemd两种类型)创建相应文件,如果为systemd类型创建/sys/fs/cgroup/cpuset/system.slice/docker-[id].scope及其下子目录,如果为raw类型,创建/sys/fs/cgroup/cpuset/docker/[id]及其下子目录。上述目录主要的用途是母机存放其上容器的cpu、memory等统计信息
接着initProcess对象的start方法sendConfig函数,通过管道将相应的配置信息传递给子进程,以便子进程完成容器的相关配置工作
(5)容器的配置
容器中的init进程首先会调用StartInitialization()函数,使用newContainerInit函数通过管道从父进程接收各种配置参数,最终调用libcontainer包中的standard_linux_Init对象的init方法。然后对容器进行如下配置:
调用joinExistingNamespaces函数,将init进程加入其指定的namespace中,这里会将init进程加入到前面已经创建好的netns中,这样init进程就拥有了自己独立的网络栈,完成了网络创建和配置的最后一步
- 设置进程的会话ID
- 进行一些网络相关的配置
- 调用setupRootfs函数,将前面注册好的挂载点全部挂载到物理主机上,这样就完成了volume的创建,并切换根目录到新挂载的文件系统下
- 设置hostname,加载profile信息
- 最后调用system包中的Execv函数,用exec系统调用来执行用户所指定的在容器中运行的程序,默认情况下是/bin/bash命令
3. 新旧版本start流程比较(v1.8.3 vs v.1.12.0)
新版本中start命令的大致流程:
在新版本的docker中,不仅有daemon守护进程,还有containerd守护进程。当执行docker run命令时,daemon进程会使用远程api调用containerd守护进程中的相应函数。containerd守护进程会创建一个子进程,在其中执行docker-containerd-shim命令,因此会产生一个相应的docker-containerd-shim进程。接着,docker-containerd-shim进程也会创建一个子进程,在其中执行docker-runc命令,产生一个runc进程。
最终runc进程会创建一个子进程,该进程为容器的init进程,执行用户指定的命令。不同于前面的命令,runc进程在创建子进程时,使用的cmd对象的start方法,因此runc进程不会等待容器init进程结束,而会直接结束,因此最终当容器创建成功后,runc进程将结束。
因此,在新版本中,当容器创建成功后,会产生一个对应的docker-containerd-shim进程和容器的init进程。
旧版本中start命令的大致流程:
在旧版本的docker中,只有一个daemon守护进程。当执行docker run命令时,daemon进程会创建一个goroutine,同时保证除非发生错误,否则这个goroutine不会退出,从而不会让daemon守护进程退出。这个goroutine最终会调用libcontainer包中的Start函数,也就是说,不同于新版本,旧版本绕过runc包,直接调用其封装的libcontainer包。libcontainer包中的Start函数会调用创建一个子进程,该进程为容器的init进程,执行用户指定的命令。
因此,在旧版本的docker中,容器创建成功后,只会产生容器的init进程。
4. v1.11.2 ps命令流程分析
4.1 总体流程
Docker ps命令的流程主要有两个阶段,Client-server交互阶段和Daemon处理阶段。
4.2 Client-server交互阶段
入口函数为NewPsCommand函数,它会调用runPs函数,runPs函数调用github.com/docker/engine-api/client包中的APIClient对象的ContainerList函数。
如下图所示,ContainerList函数向server发送 "/containers/json"的get请求,根据router找到相应的处理函数getContainersJSON函数。该函数最终会调用daemon包中的Containers函数,该函数为下一阶段的入口函数。
4.3 Daemon处理阶段
该阶段入口函数为daemon包中的Containers函数,该函数仅仅封装了同文件中的reduceContainers函数,因此reduceContainers函数才是实现docker ps命令的关键函数。
如下图所示,reduceContainers函数首先调用filterByNameIDMatches函数,获取要显示的容器列表。然后遍历容器列表,调用reducePsContainer函数。
reducePsContainer函数是针对一个容器进行筛选。其具体流程为:
(1) 调用contianer包中的Container对象的Lock函数
(2)调用list.go中的includeContainerInList函数,进一步筛选该容器是否需要显示
(3)将内部的容器结构体转化成api结构体
(4)调用contianer包中的Container对象的unlock函数