在通过namespace和cgroup对容器进程进行隔离和限制后,像是给进程的世界里围上了一圈的墙。那么如果容器进程往脚下看会看到什么景象呢?换句话说,容器里的进程看到的文件系统该是什么样的呢?
可能你立刻就能想到,这应该是一个关于Mount Namespace(用于让被隔离进程只‘看到’,当前Namespace里的挂载点信息)的问题:容器里的进程理应看到
一套完全独立的文件系统。这样它就可以在自己的容器目录下进行操作,而不会受宿主机以及其他容器的影响。
我们通过一段程序来验证一下,它的作用是创建子进程时开启指定的namespace:
#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的隔离环境中。
这样我们就进入了这个‘容器’。可如果在‘容器’里执行ls指令,就会发现一个有趣的现象: /tmp目录下的内容跟宿主机的内容是—样的。
也就是说即使开启了Mount Namespace,容器进程‘看到’的文件系统也跟宿主机完全一样。这是怎么回事呢?
Mount Namespace 修改的,是容器进程对文件系统“挂载点”的认知。这意味着只有在“挂载”这个操作发生后,进程的视图才会被改变。而在此之前,新创建的容器会直接继承宿主机的各个挂载点。
我们在容器进程执行前可以添加一步重新挂载 /tmp 目录的操作:
int container_main(void* arg)
{
printf("Container - inside the container!\n");
// 如果你的机器的根目录的挂载类型是 shared,那必须先重新挂载根目录
mount("", "/", NULL, MS_PRIVATE, "");
mount("none", "/tmp", "tmpfs", 0, "");
execv(container_args[0], container_args);
printf("Something's wrong!\n");
return 1;
}
加上一句mount(“none”, “/tmp”, “tmpfs”, 0, “”)
。告诉容器以 tmpfs格式,重新挂载 /tmp 目录。
编译执行:
可以看到,这次 /tmp 变成了一个空目录,这意味着重新挂载生效了。我们可以用 mount -l 检查一下:
$ mount -l | grep tmpfs
none on /tmp type tmpfs (rw,relatime)
可以看到,容器里的 /tmp 目录是以 tmpfs 方式单独挂载的。
更重要的是,因为我们创建的新进程启用了 Mount Namespace,所以这次重新挂载的操作,只在容器进程的 Mount Namespace 中有效。如果在宿主机上用 mount -l 来检查一下这个挂载,你会发现它是不存在的:
# 在宿主机上
$ mount -l | grep tmpfs
作为一个普通用户,我们希望的是一个更友好的情况:每当创建一个新容器时,我希望容器进程看到的文件系统就是一个独立的隔离环境,而不是继承自宿主机的文件系统。怎么才能做到这一点呢?
可以在容器进程启动前重新挂载它的整个根目录“/”。而由于 Mount Namespace 的存在,这个挂载对宿主机不可见,所以容器进程就可以在里面随便折腾了。
在 Linux 里有一个名为 chroot 的命令可以帮助你在 shell 中方便地完成这个工作。顾名思义,它的作用就是帮你“change root file system”,即改变进程的根目录到你指定的位置。
假设现在有一个 $HOME/test 目录,想要把它作为一个 /bin/bash 进程的根目录。
首先,创建一个 test 目录和几个 lib 文件夹:
mkdir -p $HOME/test
mkdir -p $HOME/test/{bin,lib64,lib}
cd $T
把 bash 命令拷贝到 test 目录对应的 bin 路径下:
cp -v /bin/{bash,ls} $HOME/test/bin
把 bash 命令需要的所有 so 文件拷贝到 test 目录对应的 lib 路径下:
T=$HOME/test
list="$(ldd /bin/ls | egrep -o '/lib.*.[0-9]')"
for i in $list; do cp -v "$i" "${T}${i}"; done
cp /usr/lib64/libtinfo.so.5 /$HOME/test/lib64
最后执行 chroot 命令,告诉操作系统,我们将使用 $HOME/test 目录作为 /bin/bash 进程的根目录:
chroot $HOME/test /bin/bash
这时,你如果执行 "ls /",就会看到,它返回的都是 $HOME/test 目录下面的内容,而不是宿主机的内容。
对于被 chroot 的进程来说,它并不会感受到自己的根目录已经被“修改”成 $HOME/test 了。
这种视图被修改的原理,是不是跟Linux Namespace 很类似呢?
Mount Namespace 正是基于对 chroot 的不断改良才被发明出来的,它也是 Linux 里的第一个 Namespace。
为了能让容器的根目录看起来更“真实”,我们一般会在这个容器的根目录下挂载一个完整OS的文件系统,比如 Ubuntu的 ISO。这样在容器启动后,通过执行 "ls /" 查看根目录下的内容,就是 Ubuntu 的所有目录和文件。
而这个挂载在容器根目录上、用来为容器进程提供隔离后执行环境的文件系统,就是所谓的“容器镜像”。它还有一个更为专业的名字,叫作:rootfs(根文件系统)。
所以,一个最常见的 rootfs,或者说容器镜像,会包括如下所示的一些目录和文件,比如 /bin,/etc,/proc 等等
而你进入容器之后执行的 /bin/bash,就是 /bin 目录下的可执行文件,与宿主机的 /bin/bash 完全不同。
对 Docker 项目来说,它最核心的原理实际上就是为待创建的用户进程:
- 启用 Linux Namespace 配置;
- 设置指定的 Cgroups 参数;
- 切换进程的根目录(Change Root)。
这样,一个完整的容器就诞生了。不过,Docker 项目在最后一步的切换上会优先使用 pivot_root 系统调用,如果系统不支持,才会使用 chroot。