《自己动手写docker》笔记与总结

1.预备的Linux基础知识概念

1.1 Namespace:

帮助进程隔离出自己单独的空间。隔离出来的命名空间中,普通用户能够具有root权限,进程隔离后,它的pid为1,但在原来的命名空间中,它的pid还是原来的pid

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

1.2 cGroup:

帮助控制每个命名空间的大小,保证它们之间不会相互争抢,即:提供了资源限制,控制,统计的能力,这些资源包括CPU,内存;存储,网络设备等 Cgroups包含三个组件:

  • 1.cgroup:对进程分组的管理,一个CGroup包括一组进程,可以把一个subsystem的参数关联在这一组进程上。

  • 2.subsystem: 资源控制模块,可以对GPU ,内存等资源进行限制。

  • 3.hierrachy: 把cgroup串成一串树状结构,这个树便是hierarchy,有了hierarchy,cgroups可以做到继承

1.3 三者的关系:

系统每创建一个新的hierarchy,所有的进程都会加入到这个hierarchy的根节点上。 一个subsystem 只会附加到一个hierarchy上。 一个进程可以加入多个CGroup,但这些CGroup必须在不同的hierarchy

创建一个挂载点:

mkdir cgroup-test

挂载一个hierarchy:

sudo mount -t cgroup -o none,name = cgroup-test cgroup-test ./cgroup-test

在这个cgroup-test目录中创建子文件夹,kernel会把他看做这个hierarchy的子节点,也就是子cgroup,他会继承父节点的CGroup属性。 在一个CGroup中加入一个进程,就是把进程id写入到这个cgroup目录下的 tasks 文件中

docker为每个容器创建CGroup,从而对容器进行资源限制。

1.6 union file system

把其他文件系统联合到一个联合挂载点的的文件系统服务 对这个联合的虚拟文件系统的写操作,系统是真正的写在了一个新的文件中,所以看起来这个虚拟的联合系统可以对任何文件进行写操作,但它并没有真正的改变原来的文件系统,这就是写时复制copy-on-write是一种高效的资源共享方式,它的思想是:如果一个资源是重复的,但没有任何修改,这时并不需要立即创建一个新的资源, 这个资源可以被新旧实例共享。创建新资源发生在第一次写操作, 也就是对资源进行修改的时候

2.docker 如何利用aufs来构建镜像:

镜像层文件都存储在:/var/lib/docker/aufs/diff 目录下 /var/lib/docker/aufs/layers 则存储着镜像是如何堆栈这些镜像层的metadata

在基础镜像上,提交了一个新的镜像后, /var/lib/docker/aufs/diff下会多一个新的镜像层,里面包含了新镜像相对与上一次基础镜像的不同的部分。

docker在启动容器时,会创建一个只读init-layer来存储容器的相关环境的内容,同时还会创建一个可写的write-layer来执行所有的写操作。容器退出时,这个可写层也会在/var/lib/docker/aufs/diff 下面,所以即使容器退出也不会丢失数据,只有容器删除时,这个层才会删除。

创建容器后,目录:/var/lib/docker/container/${container_id}下存放容器的 metadataconfig文件 同时从AUFS角度看,/sys/fs/aufs/***xxx** 最后是多出的一个新的目录,里面记录了这个aufs联合文件系统中的各层的可读可写权限,只有最上层的write-layer才是可写的,下层的镜像文件都是只读的。

实际操作AUFS: 准备如下工作目录:

每个目录下准备几个文件:

注意:第一个container-layer是可写层。

可以看到五个目录下的文件都被挂载到挂载点mnt目录上了。 再看系统的AUFS:

可以从目录 /sys/fs/aufs目录下多了一个si_43f72278adf2331a 目录,查看其中内容发现里面存了刚才的联合文件系统各目录的读写权限,只有第一个container-layer是可写的。 此时可以把挂载点: ./mnt看做为一个单独的联合虚拟文件系统,在这里面做一些操作: echo -e “\nwrite to mnts image-layer1/txt” >> ./mnt/image-layer1.txt 可以看到:

的确添加了一行,我们查看:

没有改变。但此时container-layer中多了一个文件:

这就是 写时复制: 当尝试向 mnt/image-layer1.txt文件进行写操作的时候, 系统首先在 mnt 目录下 查找名为 image-layer1.txt 的 文件 ,将其拷贝到 read-write 层的 container-layer 目录中,接着对 container-layer 目录中的 image-layer1.txt文件进行写操作

3.构造容器

3.1 创建容器进程:

Linux下的/proc 目录是内核提供,他不是一个真正的文件系统,常驻内存中,包含一些系统运行时信息,比如系统内存,mount设备等信息,它以文件的形式为访问内核提供接口。 这个目录中有许多数字,其实就是一些进程的PID,进入 /proc/N 目录,下面就是一些 N进程的信息。 实现一个 docker run 命令。

首先要通过我们的 mian.go 进程fork出一个子进程跑起来:

/* run */
cmd := exec.Command("/proc/selft/exe" ,args...) 
cmd.SysProcAttr = &syscall.SysProcAttr{
    Cloneflags : syscall.CLONE_NEWUTS I syscall . CLONE_NEWPID I syscall.CLONE
    NEWNS I syscall . CLONE
}

/创建一个系统调用,这个系统调用就是子进程-容器进程,子进程会自己调用自己,还给子进程传递了参数/ /* 再加载一些namespace 和输出重定向 */

// 然后就可以让子进程跑起来 cmd.start () /* cmd.start()是运行之前创建好的系统调用 ,会创建出一个隔离了namespace的这子进程, 这个进程会调用 /proc/self/exe 也就是自己调用自己,发送init参数,也就是调用我们那些的init方法 来初始化容器内的一些环境 */

这样就启动了我们的容器进程。 然后写给子进程初始化的init方法:

/* init */
defaultMountFlags := syscall.MS_NOEXEC I syscall.MS_NOSUID I syscall.MS_NODEV 
syscall . Mount ( ”proc ”,” /proc ”,” proc ”, untptr(defaultMountFlags ) ,”” )
argv := []string(command)
syscall.Exec(command, argv, os .Environ()) 

容器进程在自己调用自己后,调用init:先挂载 /proc 文件系统,然后执行系统调用: syscall.Exec() 如果没有syscall.Exec 那么容器启动后,容器内第一个进程(pid=1) ,就成了容器自己的 init 进程,而不是用户的进程(例如:bash),所以syscall.Exec的给魔法是:他会调用kernel的 int execve( char *filename ...)这个系统函数,它的作用是:执行当前filename对应的程序,然后覆盖当前进程的镜像、堆栈、数据、pid也都会被将要运行的进程所替换。 所以: ./mydocker run -ti /bin/sh 指定用户运行命令/bin/sh 进入容器后:ps -ef会发现: pid : /bin/sh =1 ,ps -ef =5?

3.2 增加容器资源隔离:

以内存(memory)限制的subsystem创建为例: 首先应找到当前subsystem在虚拟文件系统中的hierarchy的路径: 可以查询文件: /proc/self/mountinfo 文件,其中有一行: 30 27 0:24 / /sys/fs/cgroup/memory rw,nosuid,nodev,noexec,relatime shared:l3 - cgroup cgroup rw , memory

其中: option,表示挂载的subsystem类型是:memory,

cgroup 的hierarchy是通过 例如:sudo mount -t cgroup -o none,name = cgroup-test cgroup-test ./cgroup-test 以cgroup类型文件挂载上去的。

挂载memory 的subsystem的hierarchy在虚拟文件系统的绝对路径。

那么在 /sys/fs/cgroup/memory中创建文件夹,对应创建的cgroup, 就可以用来做内存的限制, 具体方法为: 读入/proc/self/mountinfo 文件,然后逐行检查最后一个 ,分割开的string 如:memory 是否与当前想要检测的subsystem 字符串相同,若相同,则取该行的第四个字符串也就是该subsystem 挂载的group的根目录:cgroupRoot

在cgroupRoot 目录下创建mydocker-cgroup 子目录,来创建新的group,然后把 我们的内存限制写在这个目录下的 memory.limits_in_bytes文件中,再把当前进程的id(父进程(main.go)的id)写在 tasks文件中,就实现了对容器进程的内存资源限制。 注意,cgroup操作的 进程对象是mydocker进程,操作的cgroup目录是宿主机的目录

3.3管道通信

为了方便父进程mydocker和子进程 容器进程之间传递参数和解析,使用一个半双工的管道,父进程在写端写入参数命令,子进程在读端读入并解析。 在创建子进程系统调用时:增加了:

readPipe, writePipe, err := NewPipe ()

cmd := exec.Command(”/proc/self/exe”, ”init”)
cmd.ExtraFiles = []*os . File{ readPipe }

即将读端的句柄传入子进程系统调用,等子进程执行时,就可以读了 。

4.构造镜像

镜像其实就是一个文件系统,docker 通过 docker export将镜像打成tar 包

4.1 pivot_root:

一个系统调用,目的是改变当前的root文件系统; 可以将当前进程的root 文件系统移动到: put_old,然后使new_root成为新的root文件系统。 使整个系统都切换到新的root文件系统,移除对以前文件系统的依赖,然后就可以unmount 原来的root文件系统了。 实现: * 1.在当前目录创建 put_old目录, * 2.pivotRoot( $PWD ,putold) ,使得整个系统root切换到当前目录,然后将之前系统的root放到put_old中 * 3.cd / && unmount(put_old) ,当前工作切换到根目录,并unmount原来的root系统。

实现容器进程的系统调用时设置: cmd.Dir = /root/busybox ,指定子进程的工作目录,这个目录就成了容器进程的root。

4.2 使用AUFS 来包装 busybox:

前一节中使用宿主机/root/busybox 目录作为文件的根目录,但在容器内对文 件的操作仍然会直接影响到宿主机的/root/busybox 目录,现在要实现容器与镜像的隔离,使得容器的操作不会对镜像产生任何影响: 三步: CreateReadOnlyLayer :新建 busybox 文件夹,将 busybox.tar解压到 busybox 目录下, 作为容器的只读层 CreateWriteLayer:建了一个名为 writeLayer的文件夹,作为容器唯一的可写层 CreateMountPoint:首先创建了/root/ mnt 文件夹,作为挂载点,然后把 writeLayer 目录和 busybox 目录 mount (-t aufs) 到/root/ mnt 目录下。

这样 /root/mnt目录就是容器使用的宿主机的目录,作为容器的工作目录。

4.3实现镜像打包:

commit命令的任务就是,把容器运行状态的内容保存成镜像存储下来,所以就是把当前目录打一个tar包

5.容器进阶

5.1实现容器进程后台运行:

容器从操作系统角度来看,就是一个进程,他是从宿主机的mydocker进程fork出来的子进程。父进程和子进程是异步的运行关系,即父进程不知道子进程什么时候退出,当父进程退出后,为了使得子进程无故占用资源而变成孤儿进程,pid=1的init进程便会接管这个孤儿进程。 如果是 -it模式,父进程创建好子进程后,parent.wait() 等待子进程结束 ,如果是 -d 则直接退出。

5.2:实现查看运行中的容器: mydocker ps

在run 函数中,将容器的名字和pid,运行状态等信息记录下来,记录在 /var/run/mydocker/CONTAINER_NAME/config.json 文件中。 mydocker ps 时就将所有的config文件读入,然后反序列化处理后定向到标准输出。

5.3:实现容器日志:mydocker log

将容器进程的标准输出挂载到“/var/run/mydocker/容器名/container.log,,文件中, 这样就可以在调用 mydocker logs 的时候去读取这个文件,并将进程内的标准输出打印出来

5.4:实现再次进入容器 docker exec 容器名

如果以-d模式运行,我们还不能在宿主机中再次进入容器的运行环境中。

setns是一个系统调用,可以根据提供的 PID再次进入到指定的 Namespace 中。它需要先 打开/proc/[pid]/ns/文件夹下对应的文件,然后使当前进程进入到指定的 Namespace 中 如果是c语言则很好调用这个setns ,但是go语言有点麻烦,要用到CGO C函数会在所有的GO运行环境前执行,但是不是 mydocker exec命令的情况下也会执行,所以这里有一点trick: 在execContainer时,mydocker 进程生成了一个子进程系统调用:

cmd := exec.Command(”/proc/self/exe”,”exec”)

/* 这里和 mydocker run的地方有点像,不过是把 init 换成了 exec ,init时会去执行我们的init函数,实现 容器的初始化,exec会去执行那段C代码*/

为了不让C代码每次都调用,在cmd.run之前,要先设置两个环节变量:

os.Setenv(ENV_EXEC_PID, pid) 
os.Setenv(ENV EXEC CMD, cmdStr)

注意的是,在main.go启动之前,c代码就会启动一次,所以在C代码的最前面应该对这个两个环境变量进行检查,如果没有设置,说明不是exec 那么直接return掉,不执行后面的 setns调用。 所以cmd.run() 后,会再次执行 C代码,这次已经设置了两个环节变量,所以c代码后面的逻辑会执行: 根据想要进入的容器的pid 在 /proc/$PID/ns/namespace[] 中去读取五种namespace然后进入容器进程。