神奇的Docker到底做了什么

神奇的Docker到底做了什么

神奇的Docker

Docker可谓是近些年越来越受欢迎的一个工具。至于Docker是个什么玩意儿,私以为没必要介绍了,不知道的自然不会对这个文章感兴趣,感兴趣的自然也有办法知道Docker是个什么玩意儿。

那就直接进入正题吧。

都知道,在Docker容器里运行的应用程序,是工作在一个虚拟的环境里的,在这个环境里,进程ID、文件系统、网络等等,全都是“假象”,都是Docker通过某种方式“捏造”出来的。像个沙箱,程序只知道傻乎乎地在其中运行,并不知道自己已经处在Matrix之中了。

Docker能够对程序所访问的资源进行偷梁换柱,而程序丝毫没有察觉,依旧感觉自己萌萌哒。那,Docker是怎么做到这么神奇的一件事的呢?

Prerequisites

  • 了解Docker的功能
  • Linux编程
  • 使用搜索引擎的能力
  • 脑子

Linux namespace

与其是说Doccker是怎么做到这一件事的,不如说是Linux kernel已经提供了做这么一个事的可能性。明白这一点很重要。Docker之所以能够实现这么神奇的一个效果,归功于Linux kernel开发者们早已在内核中提供了能够实现这样功能的接口。

也就是说,Docker实际上是在内核提供的这个功能上做了丰富的封装,让内核提供的这个feature能够更好地被使用而已。

那什么是namespace呢?命名空间?嗯,命名空间。这么说还是有些难以理解。或者说,这里讲的namespace就像是程序世界里的VR。

人类通过自身的感官感知这个世界,当我们戴上VR头盔的时候,我们看到的是另外一个世界,是一个和没有戴头盔的小伙伴不同的世界。

而程序则是通过syscall去感知和自己所在的世界,也是通过syscall和自己所在的世界进行交互。syscall就像是它们的眼睛、皮肤和手。至于什么是syscall,这个就说来话长了,syscall充当着程序和OS内核之间交互的桥梁,程序访问文件、进行网络通讯等,都需要经过syscall。

而Docker则是通过namespace这个内核提供的feature,给程序戴上了VR头盔,让程序以为自己在一个鲜花于草坪的世界里,实际上周围都是臭水沟。

clone才是一切的根源

在Linux中,clone这个系统调用可以用来创建一个新的进程,在man中可以看到clone这个函数的原型:

int clone(int (*fn)(void *), void *child_stack,
         int flags, void *arg, ...
         /* pid_t *ptid, struct user_desc *tls, pid_t *ctid */ );

clone具体具备些什么功能,可以参考Linux编程相关的资料。clone能干啥呢?克隆?可以这么理解,就是它能够“生”出一个活着的(正在运行的)程序,也就是创建一个新的进程。

不知道从哪个版本开始,Linux内核为clone这个syscall提供了好几个与namespace相关的选项,参见man clone,这些选项可以从flags参数传入,这几个选项分别是:

  • CLONE_NEWIPC
  • CLONE_NEWNET
  • CLONE_NEWNS
  • CLONE_NEWPID
  • CLONE_NEWUTS
  • CLONE_NEWUSER

也就是在使用clone的时候可以传入这些选项:

pid_t child_pid = clone(child_func, child_stack, CLONE_NEWPID | CLONE_NEWNS | SIGCHLD, NULL);

这些选项都能起个什么作用呢?程序在用clone创建新的进程的时候,我们可以告诉clone,新产生的小宝宝需要终生佩戴一个什么样的VR设备,让他在我们“捏造”的“幻境”中了此一生。

看着像是个悲剧。

拿CLONE_NEWPID开刀

当然了,直接看man clone就知道这些个选项到底起个什么作用了。这里还是找个demo来试一下:

#define _GNU_SOURCE

#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <sys/utsname.h>
#include <unistd.h>

#define STACK_4M    (4 * 1024 * 1024)

static char child_stack[STACK_4M];

static int child_func() {

    printf("PID: %ld\n", (long)getpid());
    printf("PPID: %ld\n", (long)getppid());

    return 0;
}

int main() {

    pid_t child_pid = clone(child_func, child_stack + STACK_4M, SIGCHLD, NULL);

    printf("clone() = %ld\n", (long)child_pid);
    waitpid(child_pid, NULL, 0);
    return 0;
}

程序启动后,通过clone调用,创建了一个进程,新的进程运行后所执行的代码由child_func指定。在上述例子的clone中第三个参数并没有指定任何一个和namespace相关的参数。也就是说这是个幸运儿,他出生后是活在真实的世界中的。所以你可以看到这样一个输出:

clone() = 2276
PID: 2276
PPID: 2275

具体的数字视情况而定,关键是clone的返回值要和PID相等,这就妥了。clone返回的是子进程的ID,也就对应着child_func这段子进程的代码中getpid的返回值,这两者应该一致。

也就是说,这个demo中,爸爸还是知道谁是自己的孩子的,孩子也知道谁是自己的爸爸的。

好,那么在clone函数中加入新的flag,把刚生下来的孩子直接扔到VR中长大吧,这样他也就不认得自己的爸爸了。

pid_t child_pid = clone(child_func, child_stack + STACK_4M, 
                    CLONE_NEWPID | CLONE_NEWUSER | SIGCHLD, NULL);

这里在clone参数中添加了两个选项:

  • CLONE_NEWPID
  • CLONE_NEWUSER

也就是说,在创建进程的时候产生一个新的PID namespace和user namespace。那么,新产生的进程所看到的PID和用户相关的“世界”就是由clone捏造的VR世界了。程序运行后输出如下:

clone() = 2533
PID: 1
PPID: 0

这下,新产生的进程已经不认得真实的自己了(进程获取自己的PID得到的是1),也不认得自己的爸爸了(获取父进程的ID得到的是0)。但是它的爸爸还是认得儿子的(父进程中clone的返回值依旧还像个正常的PID)。

我们在child_func里再加入一行代码:

 system("whoami");

打印出子进程当前所在的用户吧。运行一看:

clone() = 2625
PID: 1
PPID: 0
nobody

这家伙连创建自己的上帝(用户)都不知道了,这归功于clone在创建子进程的时候指定了CLONE_NEWUSER选项,即子进程在一个新的user namespace中,它也感知不到namespace之外的用户。

为了更进一步验证在新的namespace中会发生些什么事情,我们再对child_func进行进一步修改:

static int child_func() {

    printf("PID: %ld\n", (long)getpid());
    printf("PPID: %ld\n", (long)getppid());

    pid_t pid = fork();
    if(pid == 0) {
        char * const args[] = { "/bin/bash", NULL};
        execv(args[0], args);
    } else {
        wait(NULL);
    }

    return 0;
}

这个函数中会fork出一个新的进程,并在其中启动一个bash,这样,我们就可以在新的namespace中自由地验证对namespace的各种猜想了。

程序启动后,你看到的界面则是这样的:

clone() = 2824
PID: 1
PPID: 0
nobody@ubuntu-14:~$ 

这会儿,程序打开了一个新的shell,里头的用户是nobody(因为创建新进程后没有做用户相关的设定)。

这里的用户和前面例子中whoami显示出来的用户一致,也就是说这个shell目前也是运行在“VR环境”中。

在当前这个shell中执行与PID和用户相关的命令,我们看到的是一个全新的环境:

nobody@ubuntu-14:~$ echo $$
2
nobody@ubuntu-14:~$ whoami
nobody

而退出这个shell之后,执行的结果如下:

vagrant@ubuntu-14:~/shared$ echo $$
2712
vagrant@ubuntu-14:~/shared$ whoami
vagrant

这就说明,在child_func中启动的shell,运行在一个完全不同的环境之中,这个环境之中的的程序看不到外面的世界。它不知道系统中还运行了什么东西,也不知道还有些什么用户。

那我运行个ps aux或者htop呢?

结果出乎意料,说好的是运行在独立的环境中呢?说好的看不到外面的世界呢?咋这就把外面的一堆程序都列出来了?

还有

其实,这里并不是namespace的锅,而是ps和htop这样的命令,是通过挂载于/proc下的文件系统来获取信息的,而我们只给clone指定了CLONE_NEWPID和CLONE_NEWUSER两个namespace相关的参数。

那根据man clone,就得再加上CLONE_NEWNS咯。

别急,加上CLONE_NEWNS还没完事儿,还得将新的环境下的proc给重新mount上去,这样才算真正完事儿了。

也就是说,clone这下应该这么用了:

pid_t child_pid = clone(child_func, child_stack + STACK_4M, 
                    CLONE_NEWNS | CLONE_NEWPID | CLONE_NEWUSER | SIGCHLD, NULL);

并且,在child_func中加入挂载proc的代码:

static int child_fn() {

    printf("PID: %ld\n", (long)getpid());
    printf("PPID: %ld\n", (long)getppid());

    char *mount_point = "/proc";
    mkdir(mount_point, 0555);
    if(mount("proc", mount_point, "proc", 0, NULL) == -1) {
        printf("error when mount\n");
    }

    pid_t pid = fork();
    if(pid == 0) {

        char * const args[] = { "/bin/bash", NULL};
        execv(args[0], args);
    } else {

        wait(NULL);
    }

    return 0;
}

这样,重新编译并运行程序,打开在新的namespace下的shell,执行ps aux看到的将是这样:

nobody@ubuntu-14:~$ ps aux
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
nobody       1  0.0  0.0   5228    88 pts/0    S    13:48   0:00 ./a.out
nobody       2  1.0  0.4  24024  5072 pts/0    S    13:48   0:00 /bin/bash
nobody      15  0.0  0.1  18452  1252 pts/0    R+   13:48   0:00 ps aux

对,这下我们的整个shell看起来就像是运行在一个非常干净的系统中一样的了,没有任何多余的进程。也就是说,在新的namespace中运行的各种程序,都成功地被“VR”给欺骗了。

而实际上,我们在原有的环境中执行ps aux后看到的进程,肯定不止这么点的。

总结

通过现象的对比和代码,我们也算体会了一把namespace的作用,就像一个沙盒,能够建立出一个干净、隔离的新环境,让应用运行在新的环境下,其中的程序只能看到沙箱之中的内容,无法感知也无法干涉到外面的世界。

除了上述例子中使用到的参数,clone还有几个用于控制其它namespace的参数,比如网络、进程间通讯等namespace,这些选项告诉了clone分别应该在哪些地方为子进程建立隔离的namespace。

而Docker中实现虚拟化功能的核心之一,也就正是这里所说到的namespace。

The end。文中若有疏漏,还望指教。

编辑于 2018-05-20