docker的实现原理

docker相对于虚拟机

  • 优点是通过镜像备份和恢复(类似于盗版windows的ghost系统),并且镜像的DockerFile以及镜像本身都是可描述的
  • 缺点是隔离不彻底:内核和部分资源(例如时间)是共享的宿主系统的

docker的缺点是因为他的本质是限制和隔离,他并不是一个独立的操作系统,只是在”宿主系统“上限制了资源使用和隔离开了各种资源的视角

限制:Cgroup

cgroup在/sys/fs/cgroup 路径下,有很多诸如 cpuset、cpu、 memory 这样的子目录,也叫子系统

可以通过 cfs_period 和 cfs_quota 限制进程在 cfs_period时间内只能被分配到 cfs_quota 的CPU时间

当在子目录下创建出额外的目录,例如/sys/fs/cgroup/cpu/test,这个目录称为一个控制组。OS会在你新创建的目录下自动生成该子系统对应的资源限制文件。例如

1
2
3
4
~ cat /sys/fs/cgroup/cpu/test/cpu.cfs_period_us
100000
~ cat /sys/fs/cgroup/cpu/test/cpu.cfs_quota_us
-1

此时没有任何限制,向其中写入限制

1
echo 20000 > /sys/fs/cgroup/cpu/test/cpu.cfs_quota_us

此时这个控制组限制的进程只能用到20%的CPU带宽。接下来把被限制的进程的PID写入container组里的tasks文件,上面的设置就会对该进程生效

1
echo xxx > /sys/fs/cgroup/cpu/test/tasks

docker进程PID在启动后会被自动写入到控制组里,从而实现的资源限制

隔离:Namespace

当我们在宿主机上运行 sh 时,OS会给它分配一个进程编号,例如 PID=100,这是进程的唯一标识。

现在我们通过 Docker 把这个 sh 程序运行在容器中,Docker 会施展一个障眼法,让它看不见前面的进程,这样它便会错误地认为自己是第一个进程。

这种机制,其实是对被隔离应用的进程空间做了手脚,使得这些进程只能看到重新计算过的进程编号,比如 PID=1。可实际上,他们在宿主机的OS里,还是原来的第 100 号进程。

这种技术,就是 Linux 里面的 Namespace 机制,这是其中的PID Namespace,Linux 还提供了 Mount、Network 和 User 等 Namespace,用来对各种不同的进程上下文进行障眼法操作。

例如 Network Namespace,用于让被隔离进程看到当前 Namespace 里的网络设备和配置。

在宿主机上通过/proc/xxx/ns可以看到每个进程所有的Namespace

1
2
3
4
5
6
7
8
9
10
11
~ sudo docker inspect -f '{{.State.Pid}}' b39ac899c6c3
24300
~ sudo ls -l /proc/24300/ns/
total 0
lrwxrwxrwx 1 root root 0 Nov 22 04:03 cgroup -> cgroup:[4026531835]
lrwxrwxrwx 1 root root 0 Nov 22 04:03 ipc -> ipc:[4026533230]
lrwxrwxrwx 1 root root 0 Nov 22 03:01 mnt -> mnt:[4026533147]
lrwxrwxrwx 1 root root 0 Nov 22 03:01 net -> net:[4026534341]
lrwxrwxrwx 1 root root 0 Nov 22 04:03 pid -> pid:[4026534337]
lrwxrwxrwx 1 root root 0 Nov 22 04:03 user -> user:[4026531837]
lrwxrwxrwx 1 root root 0 Nov 22 04:03 uts -> uts:[4026533229]

docker exec正是通过这种读取这里的信息,调用setns进入其namespace

setns进入其他进程的Namespace

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#define _GNU_SOURCE
#include <fcntl.h>
#include <sched.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>

#define errExit(msg) do { perror(msg); exit(EXIT_FAILURE);} while (0)

int main(int argc, char *argv[]) {
int fd;

fd = open(argv[1], O_RDONLY);
if (setns(fd, 0) == -1) {
errExit("setns");
}
execvp(argv[2], &argv[2]);
errExit("execvp");
}

它接收两个参数,第一个参数是当前进程要加入的 Namespace 文件的路径,比如 /proc/25686/ns/net;第二个参数是你要在这个 Namespace 里运行的进程,比如 /bin/bash。

这段代码的的核心操作,则是通过 open() 系统调用打开了指定的 Namespace 文件,并把这个文件的描述符 fd 交给 setns() 使用。在 setns() 执行后,当前进程就加入了这个文件对应的 Linux Namespace 中。

1
2
3
4
5
6
~ gcc -o set_ns set_ns.c
~ ls /tmp/|wc -l
956
~ sudo ./set_ns /proc/24300/ns/mnt /bin/bash
~ ls /tmp/|wc -l
6

进入了镜像的mount Namespace以后,就进入了其隔离的文件系统中了,Linux自带的nsenter命令也能达到相同的效果

1
2
3
~ sudo nsenter -t 24300 -m
~ ls /tmp/|wc -l
6

此时在宿主机上观察set_ns进程的ns信息,是否也是刚才看到的4026533147

1
2
3
4
5
~ ps -ef|grep bash
root 35361 35197 0 22:24 pts/0 00:00:00 sudo ./set_ns /proc/24300/ns/mnt /bin/bash
root 35362 35361 0 22:24 pts/0 00:00:00 /bin/bash
~ sudo ls -l /proc/35362/ns/|grep 4026533147
lrwxrwxrwx 1 root root 0 Nov 24 22:27 mnt -> mnt:[4026533147]

验证下来是一致的

mount Namespace

这是用来控制文件系统隔离的,我认为这是最重要的一个namespace。

  • 普通的进程一般对其他资源隔离没有那么敏感,但是对文件系统隔离的要求是必须要有的

  • 另外一方面来说,由于Linux的cgroup系统也是用文件系统控制的,因此文件系统隔离是docker in docker的基石。通过文件系统隔离,可以做到无限套娃

写一段程序来验证mount Namespace的功能:创建子进程时开启指定的namespace

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
#define _GNU_SOURCE
#include <sys/mount.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <stdio.h>
#include <sched.h>
#include <signal.h>
#include <unistd.h>
#define STACK_SIZE (1024 * 1024)
static char container_stack[STACK_SIZE];
char* const container_args[] = {
"/bin/bash",
NULL
};

int container_main(void* arg)
{
printf("Container - inside the container!\n");
execv(container_args[0], container_args);
printf("Something's wrong!\n");
return 1;
}

int main()
{
printf("Parent - start a container!\n");
int container_pid = clone(container_main, container_stack+STACK_SIZE, CLONE_NEWNS | SIGCHLD , NULL);
waitpid(container_pid, NULL, 0);
printf("Parent - container stopped!\n");
return 0;
}

在main函数中通过clone系统调用创建了子进程container_main,并且声明要为它启用Moun tNamespace(CLONE_NEWNS标志)

而这个子进程执行的是/bin/bash程序。所以这个shell就运行在了Mount Namespace的隔离环境中。

1
2
3
4
~ gcc -o mount_ns mount_ns.c
~ sudo ./mount_ns
Parent - start a container!
Container - inside the container!

随后使用内存文件系统重新挂载/tmp,并创建一个test文件

1
2
3
4
5
6
~ ls /tmp/|wc -l
956
~ mount -t tmpfs tmpfs /tmp
~ ls /tmp/|wc -l
0
~ touch /tmp/test

此时在宿主机的shell上查看,会发现并没有隔离,不符合预期

1
2
~ ls /tmp/test|wc -l
1

MS_PRIVATE

这是因为每个挂载点存在一个传递类型(propagation type)的标记,有四种传递类型:

  • MS_SHARED:该挂载点和它的 “对等组”(peer group)共享挂载和卸载事件。当一个挂载点被删除或者添加到namespace中,这些事件会被传递到它的对等组,这样挂载和卸载事件会发生在所有对等挂载点。传递也会发生在相反的方向。也就是说,事件的传递是双向的。
  • MS_PRIVATE: 和共享挂载相反,标记为private的事件不会传递到任何的对等组,挂载操作默认使用该标志。
  • MS_SLAVE: 这个传递类型介于shared 和 slave之间,一个slave mount拥有一个master(一个共享的对等组),该对等组中的成员可以将事件传递到他的slave mount。但是slave mount不能将事件传递给master mount。
  • MS_UNBINDABLE: 该挂载点是不可绑定的。

当前的/目录传递类型如下:

1
2
3
~ findmnt -o TARGET,PROPAGATION /
TARGET PROPAGATION
/ shared

因此,新创建的进程继承了这个挂载点属性,修改container_main加入mount语句重新挂载为MS_PRIVATE

1
2
3
4
5
6
7
8
int container_main(void* arg)
{
printf("Container - inside the container!\n");
mount("", "/", NULL, MS_PRIVATE, "");
execv(container_args[0], container_args);
printf("Something's wrong!\n");
return 1;
}

再次在新进程bash中创建文件

1
2
3
4
5
6
~ ls /tmp/|wc -l
956
~ mount -t tmpfs tmpfs /tmp
~ ls /tmp/|wc -l
0
~ touch /tmp/foo

此时在宿主机上查看,已经完成了隔离

1
2
~ ls /tmp/foo|wc -l
0

因此,mount namespace的功能相当于挂载点版本的fork,让拉起的子进程继承父进程全部的挂载点,并且可以通过MS_PRIVATE,隔离和其他namespace的挂载点可见性

在隔离了namespace以后,拷贝了操作系统数据,再pivot_root更换root目录,就相当于进入了一个无镜像低配Docker

现成命令

linux本身提供了现成的命令不用自己写代码来实现

1
2
~ unshare --mount --fork
~ mount --make-private /

第一行命令对应开启namespace,第二行命令对应设置MS_PRIVATE

这样一样可以在bash中mount了新的/tmp,并且和宿主系统隔离开

精简版Docker demo

对 Docker 项目来说,它最核心的原理实际上就是为待创建的用户进程:

  1. 启用 Linux Namespace 配置
  2. 设置指定的 Cgroups 参数
  3. 切换进程的根目录

在这一小节测试中,创建一个低配Docker,可以运行tree命令查看该Docker的操作系统文件,选择tree命令是因为他的依赖最少

1
2
3
4
5
6
~ which tree
/usr/bin/tree
~ ldd /usr/bin/tree
linux-vdso.so.1 => (0x00007ffce718e000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007efd1be8d000)
/lib64/ld-linux-x86-64.so.2 (0x00007efd1c46e000)

命令流程如下:

1
2
3
~ unshare --mount --fork
~ mount --make-private /
~ mount -t tmpfs tmpfs /tmp

这还是和以前一样,下面就是拷贝tree和他依赖的动态库了

1
2
3
4
5
6
7
8
9
10
11
~ mkdir -pv /tmp/lib/x86_64-linux-gnu
mkdir: created directory '/tmp/lib'
mkdir: created directory '/tmp/lib/x86_64-linux-gnu'
~ mkdir -pv /tmp/lib64
mkdir: created directory '/tmp/lib64'
~ mkdir -pv /tmp/bin
mkdir: created directory '/tmp/bin'

~ cp /lib/x86_64-linux-gnu/libc.so.6 /tmp/lib/x86_64-linux-gnu
~ cp /lib64/ld-linux-x86-64.so.2 /tmp/lib64/ld-linux-x86-64.so.2
~ cp /usr/bin/tree /tmp/bin/

接着使用pivot_root修改当前的rootfs目录,其中第一个参数是新的rootfs目录,第二个参数是存放原来的老的rootfs目录,这里直接和第一个一致,来隐藏原rootfs目录

并且卸载原rootfs目录,如果不卸载的话,还是可以通过/..访问到原rootfs目录,

这里umount的-l是lazy的意思,意思是延迟umount,否则当前指令依赖原rootfs,会报错busy

但是卸载指令umount依赖项非常多,依赖动态库还依赖/dev等等,所以简单起见没有拷贝进来,会有报错,可以忽略

1
2
3
4
~ cd /tmp
~ pivot_root . .
~ umount -l .
bash: /bin/umount: No such file or directory

这样tree命令Docker Demo就完成了:

1
2
3
4
5
6
7
8
9
10
11
~ tree /
/
|-- bin
| `-- tree
|-- lib
| `-- x86_64-linux-gnu
| `-- libc.so.6
`-- lib64
`-- ld-linux-x86-64.so.2

4 directories, 3 files

小结

纯享版shell文件:

1
2
3
4
5
6
7
8
9
10
11
12
unshare --mount --fork
mount --make-private /
mount -t tmpfs tmpfs /tmp
mkdir -pv /tmp/lib/x86_64-linux-gnu
mkdir -pv /tmp/lib64
mkdir -pv /tmp/bin
cp /lib/x86_64-linux-gnu/libc.so.6 /tmp/lib/x86_64-linux-gnu
cp /lib64/ld-linux-x86-64.so.2 /tmp/lib64/ld-linux-x86-64.so.2
cp /usr/bin/tree /tmp/bin/
cd /tmp
pivot_root . .
#umount -l .

镜像

overlay文件系统

像上一节一样每个Docker启动都拷贝一次文件,假设一个镜像 500MB,100 个容器就需要拷贝 50GB 的文件,而这 50GB 里的内容,库文件都是差不多的。在容器运行时,这类文件也不会被改动,基本上都是只读的。

为了有效地减少冗余的镜像数据,出现针对容器的文件系统,称为 UnionFS。

UnionFS 实现的主要功能是把多个目录(处于不同的分区)挂载在一个目录下。这种多目录挂载的方式可以解决容器镜像的问题。

把 ubuntu18.04 基础镜像的文件放在一个目录 ubuntu18.04/ 下,容器额外的程序文件 app_1_bin 放在 app_1/ 目录下。

把两个目录挂载到 container_1/ 目录下,作为容器 1 看到的文件系统; 对于容器 2,就可以把 ubuntu18.04/ 和 app_2/ 一起挂载到 container_2 下。

这样就只要保留一份 ubuntu18.04。

UnionFS 类似的有很多种实现,包括在 Docker 里最早使用的 AUFS,还有目前使用的 OverlayFS。

OverlayFS 的 mount 命令牵涉到四类目录:lower,upper, merged 和 work,其中work目录是用来给OverlayFS 存放临时文件的,不需要关心

  • lower:不会被修改的,(只读)。支持多个 lowerdir,并且根据mount的传入逆序,从底层向上层加载
  • upper:如果有文件的创建,修改,删除操作,都会在这一层反映出来,是可读写的
  • merged:挂载点(mount point)目录,也是用户看到的目录,用户实际的操作在这里进行

例如下图,lower有个文件/tmp/in_lower,upper有个文件/tmp/in_upper,那么合并后的/tmp/merged就能同时看到in_lower和in_upper文件

而对于两个目录都有的文件,merged会以upper中为准,最后的in_both是绿色的

镜像数据

镜像数据中存在多个layer层,实际上是不同的只读文件系统目录,用于overlay文件系统的lower层挂载

查看某个镜像元数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
~ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
tedcy/proxy_pool latest 0ce6ad07407c 2 weeks ago 119MB
~ cat /var/lib/docker/image/overlay2/imagedb/content/sha256/0ce6ad07407c9d7b2a39b9ce4a0bb6257aaedce8d0372b65c8979de9bf8d26b2 |jq .|tail -n 17
"rootfs": {
"type": "layers",
"diff_ids": [
"sha256:8d3ac3489996423f53d6087c81180006263b79f206d3fdec9e66f0e27ceb8759",
"sha256:1965cfbef2abbbc503a96e5dfd4db0baf850d58849a1d897c51c63e8a8bc71f1",
"sha256:671e3248113cc5479cb7fad8a05e164e54469ddba2a1ef5ff15274408398f862",
"sha256:efa76becf38b19133e11077c28ccbe1904e9bd8ab2bb32763d067ba02f5b3fc7",
"sha256:3156423bd38f1fb1e2a56d70c87029f4b57bbe7507ec6f728d76d698af231d90",
"sha256:ee2297a030399cb24e7c0c25ce6c1bf888bf71cb31bb12f5fec63af5c284487f",
"sha256:d88370c9249bfae53da4d7721dc335eb6c93ecbf48e7678567e1c765112b2145",
"sha256:14da6b13dd3e32c251e2354503895653b5bd1c08b75925b0391c6febef410029",
"sha256:9f3943f155ae7460a6be697bc6969dab13fce4910aa092a0559ef0257414c515",
"sha256:e47bbd58a357e28230e3e3e1876750a0997b354a34f5db23a522d9f27b5f03ac",
"sha256:5fed4f0a0f981870db3f28b67688462e3b646f96c1c88e5cfe9a49ef0d2fee60"
]
}
}

这些layer在服务器上以压缩包的形式存储,docker pull以后都是目录了

1
2
3
4
5
6
7
8
9
10
~ docker image inspect tedcy/proxy_pool
"GraphDriver": {
"Data": {
"LowerDir": "/var/lib/docker/overlay2/baec1789146877edafae6ab77e88018f79347a781c4bb7c5e05d51bc091e2199/diff:/var/lib/docker/overlay2/f48c749b6563b9f10de1d4636614106280e08012cbced9a2e9e3575a17a64f09/diff:/var/lib/docker/overlay2/056eed682fc1700604e4474687fcb3c4f5e8ed2945d73733529b2bc74e72cc98/diff:/var/lib/docker/overlay2/a87ab04fdff152cd1d1d30cc7708ff3e55d142dba4b2162a0463e3b8ef5d99c9/diff:/var/lib/docker/overlay2/5f9571ff68a7870fdf9990fe3cb498c2c6f80c0eeab9968a4bf4072286dc5938/diff:/var/lib/docker/overlay2/9001afc48493d28a3b0d7b6bb43d53c1491e12a1cd4ea8c8917e0b250b279f2c/diff:/var/lib/docker/overlay2/262c7cc36490c293017e7a701cd9591af42e7b2f8fd4faa8c4d38b5280aed043/diff:/var/lib/docker/overlay2/6f3431c630a4c57bdf73997f0e23c3dbf671b81589af5abd71964239c1d6a0e3/diff:/var/lib/docker/overlay2/d823a70669eee3d58291d2ad493371873a6f00d1b1d1a9da46bf2452d8128e8f/diff:/var/lib/docker/overlay2/f6e8a6b02c3e9ca522ae7ebd4a9234c700a95e51555a0e4bdb78507ae66967f7/diff",
"MergedDir": "/var/lib/docker/overlay2/dc28e4bede3eb1406ac7aa97ddef1e91c133f6e16415971f7a627d0a0748f94e/merged",
"UpperDir": "/var/lib/docker/overlay2/dc28e4bede3eb1406ac7aa97ddef1e91c133f6e16415971f7a627d0a0748f94e/diff",
"WorkDir": "/var/lib/docker/overlay2/dc28e4bede3eb1406ac7aa97ddef1e91c133f6e16415971f7a627d0a0748f94e/work"
},
"Name": "overlay2"
},

layers的sha256值和下面LowerDir中目录名sha256值不一样,从个数来看显然也是一一对应的,第一层是可以对上的,其他层比较复杂

例如元数据的首个layer8d3ac3489996423f53d6087c81180006263b79f206d3fdec9e66f0e27ceb8759

1
2
~ cat /var/lib/docker/image/overlay2/layerdb/sha256/8d3ac3489996423f53d6087c81180006263b79f206d3fdec9e66f0e27ceb8759/cache-id 
f6e8a6b02c3e9ca522ae7ebd4a9234c700a95e51555a0e4bdb78507ae66967f7

正是lower的最后一个传入数据,之前说过lower的多个目录是逆序加载的

1
2
~ ls /var/lib/docker/overlay2/f6e8a6b02c3e9ca522ae7ebd4a9234c700a95e51555a0e4bdb78507ae66967f7/diff
bin dev etc home lib media mnt opt proc root run sbin srv sys tmp usr var

这一层正好是当前操作系统的所有基础目录,符合预期

基于镜像的Docker demo

源码在这里:https://github.com/tedcy/docker_demo

分成两个模块

  • docker2fs

    用于将镜像信息拉取下来,转换成本地目录,并且生成config.json以及manifest.json的元数据文件

    可以用docker pull拉取以后,使用上文提到的镜像数据,为了省事,还是使用了github.com/google/go-containerregistr

    拉取的镜像是我自己写死的docker push上传的

  • runInNamespace

    读取config.json,设置环境变量

    读取manifest.json,挂载layers

    随后执行/bin/sh

docker2fs基本都是调用api,没什么好说的

runInNamespace模块的主要代码https://github.com/tedcy/docker_demo/blob/9bbf521851ef210d569ab4ad4066897bba269305/runInNamespace/runInNamespace.go#L276

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
func childProcess(configPath, manifestPath, baseDir, volumeDir string) {
// 本文[MS_PRIVATE]一节中提到的将子进程的namespace隔离开
err := mountRecPrivate()
if err != nil {
fmt.Printf("mountRecPrivate 时出错: %v\n", err)
return
}
// 从镜像元数据读取环境变量,并设置
err = setEnv(configPath)
if err != nil {
fmt.Printf("设置环境变量时出错: %v\n", err)
return
}

// 从镜像元数据中读取layers,并且通过overlay文件系统挂载下载解压好的目录
targetDir := filepath.Join(baseDir, "merged")
err = setLayers(manifestPath, baseDir, targetDir)
if err != nil {
fmt.Printf("设置 layers 时出错: %v\n", err)
return
}

// 挂载一些基础的文件系统,比如没有挂载/dev的话,执行umount指令会报错找不到/dev/null
err = mountBaseFs(targetDir)
if err != nil {
fmt.Printf("挂载基础文件系统时出错: %v\n", err)
return
}

// 挂载volume,和宿主机共享数据
err = mountVolume(volumeDir, targetDir)
if err != nil {
fmt.Printf("挂载 volume 时出错: %v\n", err)
return
}

// 更换rootfs
err = chroot(targetDir)
if err != nil {
fmt.Printf("chroot 时出错: %v\n", err)
return
}

// 启动 sh shell 并连接标准输入输出
cmd := exec.Command("/bin/sh")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Printf("运行 /bin/sh 时出错: %v\n", err)
}
}

执行结果中包含了全部的命令行操作,并在最后一行进入了新进程/bin/sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
~ cd runInNamespace
~ go build
~ ./runInNamespace
mounting recursive private: mount --make-rprivate /
setting env vars: [PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin LANG=C.UTF-8 GPG_KEY=0D96DF4D4110E5C43FBFB17F2D347EA6AA65421D PYTHON_VERSION=3.6.15 PYTHON_PIP_VERSION=21.2.4 PYTHON_SETUPTOOLS_VERSION=57.5.0 PYTHON_GET_PIP_URL=https://github.com/pypa/get-pip/raw/3cb8888cc2869620f57d5d2da64da38f516078c7/public/get-pip.py PYTHON_GET_PIP_SHA256=c518250e91a70d7b20cceb15272209a4ded2a0c263ae5776f129e0d9b5674309]
mounting tmpfs filesystem: mount -t tmpfs tmpfs /tmp/proxy_pool/overlay
making dirs: mkdir -pv [/tmp/proxy_pool/overlay/upper /tmp/proxy_pool/overlay/work /tmp/proxy_pool/overlay/merged]
mounting overlay filesystem: mount -t overlay overlay -o lowerdir=/tmp/proxy_pool/layers/0dbd1f52c305ff43d3d1743e04db1acc0b7eae91b1797718f4e4599d048df03d:/tmp/proxy_pool/layers/f794fdf4705e933c5977a53ea6e5f03911a8f2aca53cbc7bb980d32708fd989e:/tmp/proxy_pool/layers/14e16fc1fc9035fd8ce45206c8801f1a6c6e02345f54fe70f1dcf1bbd724d61e:/tmp/proxy_pool/layers/cafeeb65b6db41d02b801b3c5c9fce82196eaeed915274e70dd2da888b602746:/tmp/proxy_pool/layers/45fa106c3478c75775fe40fafbbcfb9d266a65570eca1a1871dba258c0da841a:/tmp/proxy_pool/layers/998360aaa0772154c781571bfb3af90054c8a57bcd95f05166bd087c381adcf2:/tmp/proxy_pool/layers/b064415ed3d75cd9bf462c4ea1a29aebe67dfe8fc76e672dc6bd72f29e91061b:/tmp/proxy_pool/layers/52bedcb3e853dd5d782c46d387f2af404af9ec75c2aba3a3114e9fbf913c82ed:/tmp/proxy_pool/layers/acb0e804800ed3c10624ddeac73a6ebd3f2d6dbb03970f054233ae066cd033ba:/tmp/proxy_pool/layers/8786870f287676cca49c1e1e5029467c087ad293b824fcddc489cbe2819745b2:/tmp/proxy_pool/layers/59bf1c3509f33515622619af21ed55bbe26d24913cedbca106468a5fb37a50c3,upperdir=/tmp/proxy_pool/overlay/upper,workdir=/tmp/proxy_pool/overlay/work /tmp/proxy_pool/overlay/merged
mounting proc filesystem: mount -t proc none /tmp/proxy_pool/overlay/merged/proc
mounting sys filesystem: mount -t sysfs none /tmp/proxy_pool/overlay/merged/sys
mounting dev filesystem: mount -t devtmpfs devtmpfs /tmp/proxy_pool/overlay/merged/dev
mounting devpts filesystem: mount -t devpts devpts /tmp/proxy_pool/overlay/merged/dev/pts
mounting shm filesystem: mount -t tmpfs shm /tmp/proxy_pool/overlay/merged/dev/shm
mounting run filesystem: mount -t tmpfs tmpfs /tmp/proxy_pool/overlay/merged/run
mounting tmp filesystem: mount -t tmpfs tmpfs /tmp/proxy_pool/overlay/merged/tmp
mounting volume filesystem: mount --bind /tmp/proxy_pool/volume /tmp/proxy_pool/overlay/merged/volume
change current dir : cd /tmp/proxy_pool/overlay/merged
change rootfs: pivot_root . .
unmounting old root: umount -l .
root@devmachine/#

执行命令验证是否镜像数据加载完整,这是一个python的爬虫镜像

1
2
3
4
5
6
7
8
9
10
11
12
~ ls /app/
Dockerfile __pycache__ db fetcher log setting.py test.py
LICENSE _config.yml docker-compose.yml handler proxyPool.py start.sh util
README.md api docs helper requirements.txt test
~ df -h
Filesystem Size Used Available Use% Mounted on
overlay 62.5G 4.0K 62.5G 0% /
devtmpfs 62.5G 20.0K 62.5G 0% /dev
shm 62.5G 0 62.5G 0% /dev/shm
tmpfs 62.5G 0 62.5G 0% /run
tmpfs 62.5G 0 62.5G 0% /tmp
overlay 10.0G 5.2G 4.8G 52% /volume

试一下volume的功能是不是正常,"docker"内执行:

1
~ touch /volume/test

宿主系统上查看文件,确实存在了:

1
2
~ ls /tmp/proxy_pool/volume/
test

volume机制

容器技术使用了 rootfs 机制和 Mount Namespace,构建出了一个同宿主机完全隔离开的文件系统环境。这时我们就需要考虑这样两个问题:

  1. 容器里进程新建的文件,怎么才能让宿主机获取到?
  2. 宿主机上的文件和目录,怎么才能让容器里的进程访问到?

这正是 Docker Volume 要解决的问题:Volume 机制,允许你将宿主机上指定的目录或者文件,挂载到容器里面进行读取和修改操作。

根据上面的分析,不难猜到,只要在更换rootfs之前,把宿主机的指定目录mount进来就行了

这里就可以使用Linux 的绑定挂载(bind mount)机制。它允许你将一个目录或文件,而不是整个设备,挂载到一个指定的目录上。并且这时你在该挂载点上进行的任何操作,只是发生在被挂载的目录或者文件上,而原挂载点的内容则会被隐藏起来且不受影响。

绑定挂载实际上是一个 inode 替换的过程。在 Linux 中,inode 可以理解为存放文件内容的“对象”,而 dentry,也叫目录项,就是访问这个 inode 所使用的“指针”。

mount --bind /home /test,会将 /home 挂载到 /test 上。其实相当于将 /test 的 dentry 重定向到 /home 的 inode。这样修改 /test 目录时,实际修改的是 /home 目录的 inode。

mount overlay失败问题

代码在挂载overlay的时候始终不能成功,报错

1
2
3
4
~ mkdir -pv /tmp/overlay/lower /tmp/overlay/upper /tmp/overlay/work/ /tmp/overlay/merged/
~ cd /tmp/overlay
~ mount -t overlay overlay -olowerdir=./lower/,upperdir=./upper/,workdir=./work/ ./merged/
mount: /mnt/overlay/merged: wrong fs type, bad option, bad superblock on overlay, missing codepage or helper program, or other error.

拿着这个报错去网上找也没啥头绪,后面搜了些资料才发现dmesg可以看到完整的报错

1
2
~ dmesg -T|tail -n 1
[Sun Nov 24 02:56:36 2024] overlayfs: filesystem on './upper/' not supported as upperdir

这个提示就明确多了,查看了一下upper目录的文件系统

1
2
3
~ df -Th /tmp/overlay/upper
Filesystem Type Size Used Avail Use% Mounted on
overlay overlay 10G 5.3G 4.8G 53% /

用来因为我在docker里面测试,整个/目录都已经是overlay文件系统挂载的了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
~ df -Th
Filesystem Type Size Used Avail Use% Mounted on
overlay overlay 10G 5.3G 4.8G 53% /
tmpfs tmpfs 64M 0 64M 0% /dev
tmpfs tmpfs 63G 0 63G 0% /sys/fs/cgroup
/dev/mapper/k8svg-4432484fe81611eea362f01090d782fa ext4 9.8G 9.8G 0 100% /data
/dev/sdm1 xfs 466G 53G 413G 12% /etc/hosts
tmpfs tmpfs 63G 8.0K 63G 1% /etc/podinfo
shm tmpfs 64M 0 64M 0% /dev/shm
/dev/sda2 xfs 94G 13G 81G 14% /home/dspeak/yyms/hostinfo
tmpfs tmpfs 63G 0 63G 0% /proc/acpi
tmpfs tmpfs 63G 0 63G 0% /proc/scsi
tmpfs tmpfs 63G 0 63G 0% /sys/firmware
taf-nfs.huya.info:/data1/dev_home/chengyue nfs4 33T 7.0T 26T 22% /root

所以正确的挂载方式是upper和work先挂载到正确的文件系统上,ext4,tmpfs都可以,其他没试过

所以最后选用了tmpfs进行测试

支持NVIDIA Runtime的Docker demo

起因是我的ServerLess平台缺少NVIDIA的一些动态库,我让业务自己把so打进镜像里面

业务说这是通用镜像,不可能把所有机型的各种版本的nvidia的动态库都自己打进去

我觉得很有道理,所以研究了一下Docker是如何支持的

发现镜像的每个layer确实是不包含这个so的,但是镜像启动以后,docker实例的overlay文件系统的upper目录就会存在这个文件:

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
~ docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
ba457f9fe2bd xxx.com/machine-learn/xxx:v0.0.8 "/opt/nvidia/nvidia_…" 2 days ago Exited (130) 2 days ago hopeful_ganguly
~ docker inspect ba457f9fe2bd | jq '.[0].GraphDriver.Data.UpperDir'
"/data1/docker/overlay2/3384fecdb91556adf6f1ea46921c57f7cfd7fcb8b9c49b831d4173de6095c4e3/diff"
~ /data1/docker# tree ./overlay2/3384fecdb91556adf6f1ea46921c57f7cfd7fcb8b9c49b831d4173de6095c4e3/diff
./overlay2/3384fecdb91556adf6f1ea46921c57f7cfd7fcb8b9c49b831d4173de6095c4e3/diff
├── etc
│   └── ld.so.cache
├── Leaf-NAS
├── usr
│   ├── bin
│   │   ├── nvidia-cuda-mps-control
│   │   ├── nvidia-cuda-mps-server
│   │   ├── nvidia-debugdump
│   │   ├── nvidia-persistenced
│   │   └── nvidia-smi
│   └── lib
│   ├── firmware
│   │   └── nvidia
│   │   └── 550.54.15
│   │   ├── gsp_ga10x.bin
│   │   └── gsp_tu10x.bin
│   └── x86_64-linux-gnu
│   ├── libcudadebugger.so.1 -> libcudadebugger.so.550.54.15
│   ├── libcudadebugger.so.530.30.02
│   ├── libcudadebugger.so.550.54.15
│   ├── libcuda.so -> libcuda.so.1
│   ├── libcuda.so.1 -> libcuda.so.550.54.15
│   ├── libcuda.so.530.30.02
│   ├── libcuda.so.550.54.15
│   ├── libnvidia-allocator.so.1 -> libnvidia-allocator.so.550.54.15
│   ├── libnvidia-allocator.so.550.54.15
│   ├── libnvidia-cfg.so.1 -> libnvidia-cfg.so.550.54.15
│   ├── libnvidia-cfg.so.550.54.15
│   ├── libnvidia-gpucomp.so.550.54.15
│   ├── libnvidia-ml.so.1 -> libnvidia-ml.so.550.54.15
│   ├── libnvidia-ml.so.550.54.15
│   ├── libnvidia-nvvm.so.4 -> libnvidia-nvvm.so.550.54.15
│   ├── libnvidia-nvvm.so.530.30.02
│   ├── libnvidia-nvvm.so.550.54.15
│   ├── libnvidia-opencl.so.1 -> libnvidia-opencl.so.550.54.15
│   ├── libnvidia-opencl.so.550.54.15
│   ├── libnvidia-pkcs11-openssl3.so.550.54.15
│   ├── libnvidia-pkcs11.so.550.54.15
│   ├── libnvidia-ptxjitcompiler.so.1 -> libnvidia-ptxjitcompiler.so.550.54.15
│   ├── libnvidia-ptxjitcompiler.so.530.30.02
│   └── libnvidia-ptxjitcompiler.so.550.54.15
├── var
│   └── cache
│   └── ldconfig
│   └── aux-cache

这说明在容器启动前,容器运行时有个过程把so拷贝进去了

根据docker的文档 https://github.com/opencontainers/runtime-spec/blob/main/config.md#prestart

应该是这个prestart的hook把数据加载进来的,这个hook是通过配置文件控制的

1
2
3
4
5
6
7
8
9
10
11
~ cat /etc/docker/daemon.json
{
"data-root": "/data1/docker",
"default-runtime": "nvidia",
"runtimes": {
"nvidia": {
"path": "/usr/bin/nvidia-container-runtime",
"runtimeArgs": []
}
}
}

所以就是这个东西搞进来的,再根据nvidia官方文档https://developer.nvidia.com/zh-cn/blog/gpu-containers-runtime/

是使用了https://github.com/NVIDIA/libnvidia-container这个项目搞进来的

这个项目的README写的比较简陋,说make就行,摸索了一下

在ubuntu16.04下编译libnvidia-container 1.17.0,实际上需要

1
~ apt-get install -y bmake libcap-dev libseccomp-dev gperf pkg-config

其中pkg-config如果不装不会有任何报错,但是会不链接libseccomp,导致链接undefine refrenece xxx

随后编译的时候指定,关闭了NVCGO,否则还需要依赖指定版本的golang

1
~ make WITH_NVCGO=no -j

编译出来可执行文件依赖动态库libnvidia-container.so.1.17.0,需要打包在一起

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
~ tree /root
.
├── libnvidia-container.so.1 -> libnvidia-container.so.1.17.0
├── libnvidia-container.so.1.17.0
└── nvidia-container-cli

0 directories, 3 files
~ LD_LIBRARY_PATH=/root ldd /root/nvidia-container-cli
linux-vdso.so.1 => (0x00007ffd54026000)
libnvidia-container.so.1 => ./libnvidia-container.so.1 (0x00007f91d8636000)
libdl.so.2 => /root/./../lib/x86_64-linux-gnu/libdl.so.2 (0x00007f91d8250000)
libcap.so.2 => /root/./../lib/x86_64-linux-gnu/libcap.so.2 (0x00007f91d804a000)
libc.so.6 => /root/./../lib/x86_64-linux-gnu/libc.so.6 (0x00007f91d7c80000)
libseccomp.so.2 => /root/./../lib/x86_64-linux-gnu/libseccomp.so.2 (0x00007f91d7a42000)
/lib64/ld-linux-x86-64.so.2 (0x00007f91d8454000)

随后实现Docker demo的完整shell如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 老三样,开启namespace,设置根目录MS_PRIVATE,创建tmpfs来作为rootfs(这里不用挂overlay,其实创建空目录不用挂tmpfs也可以)
unshare --mount --fork
mount --make-private /
mount -t tmpfs tmpfs /tmp

# 下载一个ubuntu 16.04的base系统文件到期望的rootfs /tmp下面
curl http://cdimage.ubuntu.com/ubuntu-base/releases/16.04/release/ubuntu-base-16.04.6-base-amd64.tar.gz | tar -C /tmp -xz
cd /tmp

# 挂载基础文件系统
mount -t proc none proc
mount -t sysfs none sys
mount -t tmpfs none tmp
mount -t tmpfs none run

# 执行prestart hook, nvidia-runtime工具
LD_LIBRARY_PATH=/root /root/nvidia-container-cli --load-kmods configure --ldconfig=@/sbin/ldconfig.real --no-cgroups --compute --utility --video --graphics --display --ngx --compat32 --device all $(pwd)

# 更换rootfs
pivot_root . .
umount -l .

nvidia-smi

执行输出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Fri Nov 29 10:52:34 2024       
+-----------------------------------------------------------------------------------------+
| NVIDIA-SMI 550.54.15 Driver Version: 550.54.15 CUDA Version: 12.4 |
|-----------------------------------------+------------------------+----------------------+
| GPU Name Persistence-M | Bus-Id Disp.A | Volatile Uncorr. ECC |
| Fan Temp Perf Pwr:Usage/Cap | Memory-Usage | GPU-Util Compute M. |
| | | MIG M. |
|=========================================+========================+======================|
| 0 NVIDIA L20 Off | 00000000:00:03.0 Off | 0 |
| N/A 31C P8 34W / 350W | 67MiB / 46068MiB | 0% Default |
| | | N/A |
+-----------------------------------------+------------------------+----------------------+

+-----------------------------------------------------------------------------------------+
| Processes: |
| GPU GI CI PID Type Process name GPU Memory |
| ID ID Usage |
|=========================================================================================|
+-----------------------------------------------------------------------------------------+

收工!

总结

实现一个docker的流程如下:

  • 新进程开启namespace
  • 设置当前根目录MS_PRIVATE
  • 设置环境变量
  • 挂载overlay
  • 挂载基础文件系统
  • 执行prestart hook, 例如nvidia-runtime工具
  • 挂载volume
  • 更换rootfs
  • 写入cgroup
  • 启动1号进程

参考资料

深入篇(1):docker是如何隔离与限制的?

深入篇(2):mount namespace的来源

深入篇(3):unionfs与layer

深入篇(4):docker exec 底层实现

深入篇(5):volume

chroot与pivot_root总结 完整的chroot与pivot_root使用例子