目录

容器 | 容器实现原理-Docker、Containerd 使用的 runc 的实现

什么是容器?容器其实是一种特殊的进程而已,只是这个进程运行在自己的 “运行环境” 中,比如有自己的文件系统而不是使用主机的文件系统(文件系统这个对我来说印象是最深刻的,也是让人对容器很更好理解的一个切入点)。

有一个计算数值总和的小程序,这个程序的输入来自一个文件,计算完成后的结果则输出到另一个文件中。为了让这个程序可以正常运行,除了程序本身的二进制文件之外还需要数据,而这两个东西放在磁盘上,就是我们平常所说的一个“程序”,也就是代码的可执行镜像。

当“程序”被执行起来之后,它就从磁盘上的二进制文件变成了计算机内存中的数据、寄存器里的值、堆栈中的指令、被打开的文件,以及各种设备的状态信息的一个集合。像这样一个程序运行起来后的计算机执行环境的总和,就是进程,而计算机执行环境的总和就是它的动态表现。

而容器技术的核心功能,就是通过约束和修改进程的动态表现,从而为其创造出一个“边界”也就是独立的“运行环境”。那么怎么去造成这个边界呢?

  • 对于 Docker 等大多数 Linux 容器来说,Cgroups 技术是用来制造约束的主要手段;
  • Namespace 技术则是用来修改进程视图的主要方法;

下面我们使用 C 语言和 Namespace 技术来手动创建一个容器,演示 Linux 容器最基本的实现原理。

1. 自己实现一个容器

Linux 中关于 Namespace 的系统调用主要有这么三个:

  • clone()—实现线程的系统调用,用来创建一个新的进程,同时可以设置 Namespace 的一些参数。
  • unshare()—使某个进程脱离某个 namespace。
  • setns()—把某进程加入到某个 namespace。

我们使用 clone 来创建一个子进程,通过创建出来的效果可以看到,子进程的 PID 是跟在父亲节点后面的,而不是 1。

 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/types.h>
#include <sys/wait.h>
#include <sys/mount.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 [%5d] - inside the container!\n", getpid());
    execv(container_args[0], container_args);
    printf("Something's wrong!\n");
    return 1;
}

int main() {
    printf("Parent [%5d] - start a container!\n", getpid());
    int container_id = clone(container_main, container_stack + STACK_SIZE, SIGCHLD, NULL);
    waitpid(container_id, NULL, 0);
    printf("Parent - container stopped!\n");
    return 0;
}

https://img.dawnguo.cn/Container/image-20201006152404552.png

接下去这段代码我们给创建出来的进程设置 PID namespace 和 UTS namespace。从实际的效果我们可以看到子进程的 pid 为 1,而子进程中打开的 bash shell 显示的主机名为 container_dawn。是不是有点容器那味了?这里子进程在自己的 PID Namespace 中的 PID 为 1,因为 Namespace 的隔离机制,让这个子进程误以为自己是第 1 号进程,相当于整了个障眼法。但是,实际上这个进程在宿主机的进程空间中的编号不为 1,是一个真实的数值,比如 14624。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
int container_main(void* arg) {
    printf("Container [%5d] - inside the container!\n", getpid());
    sethostname("container_dawn", 15);
    execv(container_args[0], container_args);
    printf("Something's wrong!\n");
    return 1;
}

int main() {
    printf("Parent [%5d] - start a container!\n", getpid());
    int container_id = clone(container_main, container_stack + STACK_SIZE, 
                                CLONE_NEWUTS | CLONE_NEWPID | SIGCHLD, NULL);
    waitpid(container_id, NULL, 0);
    printf("Parent - container stopped!\n");
    return 0;
}

https://img.dawnguo.cn/Container/image-20201006151528916.png

最后我们改变一下这个进程可以看到的文件系统,我们首先使用 docker export 将 busybox 镜像导出成一个 rootfs 目录,这个 rootfs 目录的情况如图所示,已经包含了 /proc/sys 等特殊的目录。

https://img.dawnguo.cn/Container/image-20201006153550729.png

接下去我们在代码中使用 chroot() 函数将创建出来的子进程的根目录改变成上述的 rootfs 目录。从实现的效果来看,创建出来的子进程的 PID 为 1,并且这个子进程将上述提到的 rootfs 目录当成了自己的根目录。

 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
char* const container_args[] = {
    "/bin/sh",
    NULL
};

int container_main(void* arg) {
    printf("Container [%5d] - inside the container!\n", getpid());
    
    if (chdir("./rootfs") || chroot("./") != 0) {
        perror("chdir/chroot");
    }

    execv(container_args[0], container_args);
    printf("Something's wrong!\n");
    return 1;
}

int main() {
    printf("Parent [%5d] - start a container!\n", getpid());
    int container_id = clone(container_main, container_stack + STACK_SIZE, 
                                CLONE_NEWUTS | CLONE_NEWPID | CLONE_NEWNS | SIGCHLD, NULL);
    waitpid(container_id, NULL, 0);
    printf("Parent - container stopped!\n");
    return 0;
}

https://img.dawnguo.cn/Container/image-20201006155920456.png

需要注意的是所使用的 shell 需要改一下,因为 busybox 中没有 /bin/bash,假如还是 /bin/bash 的话是会报错的,因为 chroot 改变子进程的根目录视图之后,最终是从 rootfs/bin/ 中找 bash 这个程序的。

上面其实已经基本实现了一个容器,接下去我们实现一下 Docker 卷的基本原理(假设你已经知道卷是什么了)。在代码中,我们将 /tmp/t1 这个目录挂载到 rootfs/mnt 这个目录中,并采用 MS_BIND 的方式,这种方式使得 rootfs/mnt (进入容器之后就是 mnt 目录)的视图其实就是 /tmp/t1 的视图,你对 rootfs/mnt 的修改其实就是对 /tmp/t1 修改,rootfs/mnt 相当于 /tmp/t1 的另一个入口而已。当然,在实验之前,你先确保 /tmp/t1 和 rootfs/mnt 这两个目录都已经被创建好了。实验效果见代码之后的那张图。

 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
char* const container_args[] = {
    "/bin/sh",
    NULL
};

int container_main(void* arg) {
    printf("Container [%5d] - inside the container!\n", getpid());

    /*模仿 docker 中的 volume*/
    if (mount("/tmp/t1", "rootfs/mnt", "none", MS_BIND, NULL)!=0) {
        perror("mnt");
    }

    /* 隔离目录 */
    if (chdir("./rootfs") || chroot("./") != 0) {
        perror("chdir/chroot");
    }

    execv(container_args[0], container_args);
    printf("Something's wrong!\n");
    return 1;
}

int main() {
    printf("Parent [%5d] - start a container!\n", getpid());
    int container_id = clone(container_main, container_stack + STACK_SIZE, 
                                CLONE_NEWUTS | CLONE_NEWPID | CLONE_NEWNS | SIGCHLD, NULL);
    waitpid(container_id, NULL, 0);
    printf("Parent - container stopped!\n");
    return 0;
}

https://img.dawnguo.cn/Container/image-20201006164148992.png

除了上述所使用的 PID、UTS、Mount namespace,Linux 操作系统还提供了 IPC、Network 和 User 这些 Namespace。

2. 总结

通过上面我们可以看到,容器的创建和普通进程创建没什么区别。都是父进程先创建一个子进程,只是对于容器来说,这个子进程接下去通过内核提供的隔离机制再给自己创建一个独立的资源环境。

同理,在使用 Docker 的时候,其实也并没有一个真正的 Docker 容器运行在宿主机里面。Docker 项目启动还是用户原来的应用进程,只是在创建进程的时候,Docker 为这个进程指定了它所需要启用的一组 Namespace 参数。这样,这个进程只能“看”到当前 Namespace 所限定的资源、文件、设备、状态或者配置。而对于宿主机以及其他不相关的程序,这个进程就完全看不到了。这时,进程就会以为自己是 PID Namespace 里面 1 号进程,只能看到各自 Mount Namespace 里面挂载的目录和文件,只能访问 Network Namespace 里的网络设备。这种就使得进程运行在一个独立的“运行环境”里,也就是容器里面。

因此,对接一开始所说的,还想再唠叨一句:**容器其实就是一种特殊的进程而已。**只是这个进程和它运行所需的所有资源都打包在了一起,进程执行时所使用的资源也都是打包中的。相比虚拟机的方式,本质是进程的容器则仅仅是在操作系统上划分出了不同的“运行环境”,从而使得占用资源更少,部署速度更快。

3. 巨人的肩膀

  1. 极客时间.《深入剖析 Kubernetes》.张磊.“白话容器基础(一):从进程说开去”

  2. https://coolshell.cn/articles/17010.html