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。
写一段程序来验证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上查看,会发现并没有隔离,不符合预期
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
此时在宿主机上查看,已经完成了隔离
因此,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 项目来说,它最核心的原理实际上就是为待创建的用户进程:
启用 Linux Namespace 配置
设置指定的 Cgroups 参数
切换进程的根目录
在这一小节测试中,创建一个低配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 ) { err := mountRecPrivate() if err != nil { fmt.Printf("mountRecPrivate 时出错: %v\n" , err) return } err = setEnv(configPath) if err != nil { fmt.Printf("设置环境变量时出错: %v\n" , err) return } targetDir := filepath.Join(baseDir, "merged" ) err = setLayers(manifestPath, baseDir, targetDir) if err != nil { fmt.Printf("设置 layers 时出错: %v\n" , err) return } err = mountBaseFs(targetDir) if err != nil { fmt.Printf("挂载基础文件系统时出错: %v\n" , err) return } err = mountVolume(volumeDir, targetDir) if err != nil { fmt.Printf("挂载 volume 时出错: %v\n" , err) return } err = chroot(targetDir) if err != nil { fmt.Printf("chroot 时出错: %v\n" , err) return } 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 2 ~ ls /tmp/proxy_pool/volume/ test
volume机制
容器技术使用了 rootfs 机制和 Mount
Namespace,构建出了一个同宿主机完全隔离开的文件系统环境。这时我们就需要考虑这样两个问题:
容器里进程新建的文件,怎么才能让宿主机获取到?
宿主机上的文件和目录,怎么才能让容器里的进程访问到?
这正是 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
编译出来可执行文件依赖动态库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使用例子