Docker核心原理

每次看docker相关的资料,都是零零碎碎的,没有系统性的去把所有知识融汇起来。最近花时间好好学习了一下,基本算是能够把docker所用的linux相关的知识给串通起来;这里简单记录一下,希望便于后续进一步学习或者对后来人有一定的帮助。

核心技术

docker是一个牛X的产品,它所用到的核心技术,在linux中已经有较老的历史了,有很多人早已看到linux的这些特性,也不乏有类似docker这样使用这些技术的公司。但是Solomon Hykes和他年青的小伙伴儿们看到这这个商机,成立Dotcloud公司,把创建“大规模的创新工具”的想法付诸现实;最终发展成为被大众接纳,甚至渐渐爱不释手的docker。

那么,到底docker的幕后都用到了哪些更牛X的技术呢?我们就来聊下那些幕后英雄们!

  • NameSpace
  • Cgroup
  • AUFS

NameSpace

Namespace为docker的进程(容器)作了逻辑隔离。

接触较多,也比较好理解的可能莫属linux network namespace了。我们可以在linux上手动执行命令ip net add net-1, 就可以在创建一个名为net-1的网络命名空间,这个命名空间就相当于是位于linux系统中另一个逻辑网络区域,它和系统当前的命名空间相互隔离。

有了上面形象的理解后,我们再来看linux下docker使用到的其他命名空间(以下各个参数,都可以在/proc/{pid}/ns/*目录下查询到):

UTS

UTS Namespace 主要用来隔离nodenamedomainname两个系统标识。在UTSNamespace里, 每个Namespace都允许有自己的hostname,docker就是靠它来保障容器的hostname与宿主机不一样的。

PID

PID Namespace 用来隔离进程ID。同一个进程在不同的PID Namespace里可以拥有不同的PID,比如每一个container中的1号进程,其实在宿主机上都有自己的进程ID,我们如果要在外部操作它,就需要通过docker inspect等命令来找到它在宿主机上的ID号,并对其进行操作。

在C语言中,创建新的PID命名空间,需要调用clone()系统函数,并且传入CLONE_NEWPID参数。指定该参数后,子进程无法获取到parent pid的信息,认为自己没有父进程(已经被隔离)。

IPC

IPC Namespace 用来隔离System V IPC和POSIX message queues。每一个IPC Namespace都有自己的System V IPC和POSIX message queue。这一点通过在容器内外分别执行ipcs -a可以验证。

MOUNT

Mount Namespace 用来隔离各个进程看到的挂载点视图。在不同Namespace的进程中,看到的文件系统层次是不一样的。在Mount Namespace中调用 mount()和 umount()仅仅只影响当前Namespace的文件系统,而对全局的文件系统是没有影响的。

在C语言中,需要在clone()函数中传入CLONE_NEWNS参数。

NET

这块在开篇已经讲到,主要是将新启的进程放到自己的network namespace中,保障与外部网络的隔离性。

根命名空间和子命名空间中的通信,可以通过使用veth来实现,veth创建后默认在根命名空间中,只需要将一个veth的另一端放入对应的子NS中即可。

USER

用来隔离用户和用户组,一个进程的user id和group id在namespace内部与在宿主机上是不同的。比如宿主机上非root用户,在容器内部可以是root用户。

实践

在golang的实现中,我们应该熟悉以下代码:

1
2
3
4
5
6
7
8
9
cmd := exec.Command(initCmd, "init")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS |
syscall.CLONE_NEWNET | syscall.CLONE_NEWIPC,
}
// ...省略...
if err := cmd.Start(); err != nil {
log.Error(err)
}

上面代码在创建子进程的时候,就分别指定了各个命名空间的参数,这样创建出的子进程就拥有对应的子命名空间。

Cgroup

如果说namespace主要为docker作了进程间的隔离,那Cgroup就真正为这层隔离加上物理的资源限制。它提供了对一组进程及将来子进程的资源限制、控制和统计的能力,这些资源包括CPU、内存、存储、网络等。

Cgroup主要包含三大组件:

  • hierarchy
  • 子系统
  • 进程组

hierarchy

hierarchy 是把一组cgroup串成一个树状结构,一个这样的树便是一个hierarchy,通过这种树状结构,Cgroups可以做到继承。我们甚至可以将其理解为cgroup文件系统,在挂载了该文件系统的目录下,创建子目录就会有对应的cgroup相关文件生成;而这种目录结构是可以层层继承的,下面hierarchy对应子系统的能力也随着这样的继承关系而层层继承。

子系统

  • cpu – 使用调度程序提供对 CPU 的 cgroup 任务访问。
  • cpuacct – 自动生成 cgroup 中任务所使用的 CPU 报告。
  • cpuset – 为 cgroup 中的任务分配独立 CPU(在多核系统)和内存节点。
  • devices – 可允许或者拒绝 cgroup 中的任务访问设备。
  • memory – 设定 cgroup 中任务使用的内存限制,并自动生成由那些任务使用的内存资源报告。
  • blkio – 为块设备设定输入/输出限制,比如物理设备(磁盘,固态硬盘,USB 等等)。
  • net_cls – 使用等级识别符(classid)标记网络数据包,可允许 Linux 流量控制程序(tc)识别从具体 cgroup 中生成的数据包。
  • ns – 名称空间子系统。
  • freezer – 挂起或者恢复 cgroup 中的任务。

在linux上,这些子系统都对应已经挂载到了对应的目录。假设docker要为某一个容器指定memory的限制,在ubuntu下,docker会直接在/sys/fs/cgrop/memory/docker/下创建目录(名称为容器ID),并在目录下创建对应的限制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
root@vpn:/sys/fs/cgroup# ls /sys/fs/cgroup/  # <=这里是系统所有的子系统
blkio cpu cpuacct cpu,cpuacct cpuset devices freezer hugetlb memory net_cls net_cls,net_prio net_prio perf_event pids systemd
root@vpn:/sys/fs/cgroup#
root@vpn:/sys/fs/cgroup# mount -l | grep cgroup # <=查看其挂在路径
tmpfs on /sys/fs/cgroup type tmpfs (ro,nosuid,nodev,noexec,mode=755)
cgroup on /sys/fs/cgroup/systemd type cgroup (rw,nosuid,nodev,noexec,relatime,xattr,release_agent=/lib/systemd/systemd-cgroups-agent,name=systemd)
cgroup on /sys/fs/cgroup/cpu,cpuacct type cgroup (rw,nosuid,nodev,noexec,relatime,cpu,cpuacct)
cgroup on /sys/fs/cgroup/cpuset type cgroup (rw,nosuid,nodev,noexec,relatime,cpuset)
cgroup on /sys/fs/cgroup/blkio type cgroup (rw,nosuid,nodev,noexec,relatime,blkio)
cgroup on /sys/fs/cgroup/hugetlb type cgroup (rw,nosuid,nodev,noexec,relatime,hugetlb)
cgroup on /sys/fs/cgroup/devices type cgroup (rw,nosuid,nodev,noexec,relatime,devices)
cgroup on /sys/fs/cgroup/pids type cgroup (rw,nosuid,nodev,noexec,relatime,pids)
cgroup on /sys/fs/cgroup/perf_event type cgroup (rw,nosuid,nodev,noexec,relatime,perf_event)
cgroup on /sys/fs/cgroup/net_cls,net_prio type cgroup (rw,nosuid,nodev,noexec,relatime,net_cls,net_prio)
cgroup on /sys/fs/cgroup/memory type cgroup (rw,nosuid,nodev,noexec,relatime,memory)
cgroup on /sys/fs/cgroup/freezer type cgroup (rw,nosuid,nodev,noexec,relatime,freezer)
root@vpn:/sys/fs/cgroup#
root@vpn:/sys/fs/cgroup# ls ./cpu/docker/ # <=查看cpu下docker的目录,可以看到有一个容器正在被cpu subsystem控制
7bdb229c1974123cfa6f408a142bcf496c46c04381087f0f2878c5c61763fe44 cgroup.procs cpuacct.usage cpu.cfs_period_us cpu.shares notify_on_release
cgroup.clone_children cpuacct.stat cpuacct.usage_percpu cpu.cfs_quota_us cpu.stat tasks

进程组

cgroup文件系统中对应每一个子目录,都会自动创建tasks文件,而受该subsystem限制的进程号就逐一列到该task文件中,如下:

1
2
3
4
5
6
root@vpn:/sys/fs/cgroup/cpu/docker/7bdb229c1974123cfa6f408a142bcf496c46c04381087f0f2878c5c61763fe44# ls
cgroup.clone_children cpuacct.stat cpuacct.usage_percpu cpu.cfs_quota_us cpu.stat tasks
cgroup.procs cpuacct.usage cpu.cfs_period_us cpu.shares notify_on_release
root@vpn:/sys/fs/cgroup/cpu/docker/7bdb229c1974123cfa6f408a142bcf496c46c04381087f0f2878c5c61763fe44#
root@vpn:/sys/fs/cgroup/cpu/docker/7bdb229c1974123cfa6f408a142bcf496c46c04381087f0f2878c5c61763fe44# cat tasks
32186

AUFS

这里不讲AUFS的概念,直接讲这个流程:

  • 首先,docker启动容器需要有一个镜像,但是这个镜像解压缩后不允许被修改,是readOnly的;
  • 接下来,你启动容器后,要修改容器里面的文件,docker就为container创建了另外一个目录,你对镜像做的任何操作的内容都位于这个叠加的文件上;
  • 对于container来讲,它看不到后面那些文件是readOnly,哪些是readWrite的,它就像用户一样,其实都在使用这个虚拟的联合文件系统,这就是AUFS。

为了提高效率,AUFS使用了写时复制技术:

写时复制( copy-on-write),也叫隐式共享, 是一种对可修改资源实现高效复制的资源管理技术。它的思想是,如果一个资源是重复的,但没任何修改,这时并不需要立即创建一个新的资源,这个资源可以被新旧实例共享。创建新资源发生在第一次写操作,也就是对资源进行修改的时候。通过这种资源共享的方式,可以显著地减少 未修改资源复制带来的消耗,但是也会在进行资源修改时增加小部分的开销。

具体到docker上,aufs的实现体现在 /var/lib/docker/aufs/ 目录下的三个文件夹:

1
2
root@vpn:/var/lib/docker/aufs# ls
diff layers mnt

容器最终mount的是 mnt 目录下的文件,而readWrite的层都位于 diff 目录中。所有,我们要实现一个commit命令,本质就是将容器位于 mnt 中的文件打包出来。

实践

东拉西扯的讲了一堆核心原理,突然觉得其他也没啥好讲的了,就来看看docker的创建流程吧。

docker run

docker-run-流程

主进程(也就是runc)

  • 这里的创建父进程,其实就是container进程,只是首先运行的命令是docker init;
  • 然后docker run 在这个阶段需要为container的1号进程准备各种设置,也就是前面提到的各种namespace,cgroup、aufs等;
  • 同样,网络和存储,涉及外部需要准备的资源都需要docker run进程来处理;

container进程(容器1号进程)

  • 主要等待主进程ready后,从管道中读取各种配置信息,比如环境变量等;
  • 设置根目录,挂载自己的 /proc 和 /tmpfs等,这样才能在内部查询到进程号;
  • 执行启动容器中指定的entrypoint(虽然进程号一样,但是之前执行的是docker init的内容)。

各种架构

DockerD - Containerd - OCI

dockerd-containerd

其实就是我们有一个oci的config文件,然后结合runc就可以运行起来一个容器了。至于containd就是访问runc的一层rpc,它同时实现对image的一些管理功能(比如pull镜像)。

从下图中可以看到,dockerd 还管理了docker-proxy为容器配置网络;而containerd-shim就是runc的实现,每一个容器都有一个container-shim与之一一对应,这样就算dockerd挂了,也不影响container。

1
2
3
4
5
6
root@vpn:~# pstree | grep docker -C 8
|-dockerd-+-containerd-+-containerd-shim-+-ssserver
| | | `-8*[{containerd-shim}]
| | `-10*[{containerd}]
| |-docker-proxy---4*[{docker-proxy}]
| `-20*[{dockerd}]

CRI-O