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 in

各种架构

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
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
[root@qcloud-vm system]# docker -v
Docker version 18.09.2, build 6247962
[root@qcloud-vm system]#
[root@qcloud-vm system]# systemctl status docker
● docker.service - Docker Application Container Engine
Loaded: loaded (/usr/lib/systemd/system/docker.service; disabled; vendor preset: disabled)
Active: active (running) since Wed 2019-02-20 16:19:55 CST; 18h ago
Docs: https://docs.docker.com
Main PID: 20233 (dockerd)
Tasks: 10
Memory: 106.5M
CGroup: /system.slice/docker.service
└─20233 /usr/bin/dockerd -H fd://
[root@qcloud-vm system]#
[root@qcloud-vm system]# systemctl status containerd
● containerd.service - containerd container runtime
Loaded: loaded (/usr/lib/systemd/system/containerd.service; disabled; vendor preset: disabled)
Active: active (running) since Wed 2019-02-20 15:53:52 CST; 18h ago
Docs: https://containerd.io
Main PID: 14753 (containerd)
Tasks: 30
Memory: 85.7M
CGroup: /system.slice/containerd.service
├─14753 /usr/bin/containerd
├─19935 containerd-shim -namespace moby -workdir /var/lib/containerd/io.containerd.runtime.v1.linux/moby/58622134b6ce3184b11b91ae1bd5b357bbb247d...
└─22097 containerd-shim -namespace moby -workdir /var/lib/containerd/io.containerd.runtime.v1.linux/moby/48fa22b740d033bfe0754c887c891d3aad8fb5f...

[root@qcloud-vm system]#
[root@qcloud-vm system]# docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
48fa22b740d0 nginx "nginx -g 'daemon of…" 2 seconds ago Up 1 second 0.0.0.0:80->80/tcp nginx-1
58622134b6ce nginx "nginx -g 'daemon of…" 20 minutes ago Up 20 minutes 80/tcp nginx
[root@qcloud-vm system]#
[root@qcloud-vm system]# ps -ef | grep containerd
root 14753 1 0 Feb20 ? 00:00:23 /usr/bin/containerd
root 19935 14753 0 10:24 ? 00:00:00 containerd-shim -namespace moby -workdir /var/lib/containerd/io.containerd.runtime.v1.linux/moby/58622134b6ce3184b11b91ae1bd5b357bbb247d6e8e3abeb022f2b878a733b75 -address /run/containerd/containerd.sock -containerd-binary /usr/bin/containerd -runtime-root /var/run/docker/runtime-runc
root 22097 14753 0 10:45 ? 00:00:00 containerd-shim -namespace moby -workdir /var/lib/containerd/io.containerd.runtime.v1.linux/moby/48fa22b740d033bfe0754c887c891d3aad8fb5f6494f28c38be33f18972ba22c -address /run/containerd/containerd.sock -containerd-binary /usr/bin/containerd -runtime-root /var/run/docker/runtime-runc
[root@qcloud-vm system]#
[root@qcloud-vm system]# pstree -a | grep containerd
|-containerd
| |-containerd-shim -namespace moby -workdir...
| | `-9*[{containerd-shim}]
| |-containerd-shim -namespace moby -workdir...
| | `-9*[{containerd-shim}]
| `-10*[{containerd}]
| |-grep --color=auto containerd
[root@qcloud-vm system]#
[root@qcloud-vm system]# ps -ef | grep docker-proxy
root 22092 20233 0 10:45 ? 00:00:00 /usr/bin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 80 -container-ip 172.17.0.3 -container-port 80
root 22262 19774 0 10:46 pts/0 00:00:00 grep --color=auto docker-proxy
[root@qcloud-vm system]#
[root@qcloud-vm system]# ps -ef | grep docker-proxy
root 22092 20233 0 10:45 ? 00:00:00 /usr/bin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 80 -container-ip 172.17.0.3 -container-port 80
root 22262 19774 0 10:46 pts/0 00:00:00 grep --color=auto docker-proxy
[root@qcloud-vm system]# ps -ef | grep 20233
root 20233 1 0 Feb20 ? 00:00:17 /usr/bin/dockerd -H fd://
root 22092 20233 0 10:45 ? 00:00:00 /usr/bin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 80 -container-ip 172.17.0.3 -container-port 80
root 22305 19774 0 10:46 pts/0 00:00:00 grep --color=auto 20233

通过上面的关系实践,我们基本了解了整个docker中进程的关系:

  • 系统启动的时候启动了docker daemon和containerd两个守护进程;
  • 用户docker daemon响应docker client的请求,并下发请求到containerd;
  • 如果container指定了端口映射,docker daemon还会启动docker-proxy来做端口映射;
  • containerd是通过containerd-shim来运行runc,并真正创建容器的。

CRI-O