目录

容器 | Namespace 整体介绍

1. Namespace

Linux Namespace 是 Linux 提供的一种内核级别环境隔离的方法。这种隔离机制和 chroot 很类似,chroot 是把某个目录修改为根目录,从而无法访问外部的内容。Linux Namesapce 在此基础之上,提供了对 UTS、IPC、Mount、PID、Network、User 等的隔离机制,从而实现每个容器资源的相互隔离,使得容器内部只能访问到自己的 Namespace 资源。

维基百科定义:Namespace 是 Linux 内核的一项功能,该功能对内核资源进行分区,以使一组进程看到一组资源,而另一组进程看到另一组资源。Namespace 的工作方式通过为一组资源和进程设置相同的 Namespace 而起作用,但是这些 Namespace 引用了不同的资源。资源可能存在于多个 Namespace 中。这些资源可以是进程 ID、主机名、用户 ID、文件名、与网络访问相关的名称和进程间通信。

Linux 5.6 内核中提供了如下几种类型的 Namespace。

Namespace名称 系统调用参数 相关内核版本 隔离
Mount Namespaces CLONE_NEWNS Linux 2.4.19 Mount points
UTS Namespaces CLONE_NEWUTS Linux 2.6.19 HostName and NIS domain name
IPC Namespaces CLONE_NEWIPC Linux 2.6.19 System V IPC 和 POSIX meessage queues
PID Namespaces CLONE_NEWPID Linux 2.6.24 Process IDs
Network Namespaces CLONE_NEWNET 始于Linux 2.6.24 完成于 Linux 2.6.29 Network devices, stacks, ports, etc.
User Namespaces CLONE_NEWUSER 始于 Linux 2.6.23 完成于 Linux 3.8) User and group IDs
Cgroup Namespace CLONE_NEWCGROUP Linux 4.6 Cgroups root directory
Time Namespace CLONE_NEWTIME Linux 5.6 Boot and monotonic clocks

虽然 Linux 内核提供了 8 种 Namespace,但是最新版本的 Docker (2020.09)只使用了其中的 6 种,分别为 Mount Namespace、IPC Namespace、UTS Namespace、PID Namespace、Net Namespace、User Namespace。

Linux Namespace 官方文档:Namespaces in operation

namespace 有三个系统调用可以使用:

  • clone() — 实现线程的系统调用,用来创建一个新的进程,并可以通过设计上述参数达到隔离。
  • unshare() — 使某个进程脱离某个 namespace
  • setns(int fd, int nstype) — 把某进程加入到某个 namespace

下面使用这几个系统调用来演示 Namespace 的效果,更加详细地可以看 DOCKER基础技术:LINUX NAMESPACE(上)DOCKER基础技术:LINUX NAMESPACE(下)

1.1. UTS Namespace

UTS Namespace 主要是用来隔离主机名的,也就是每个容器都有自己的主机名。我们使用如下的代码来进行演示。注意:假如在容器内部没有设置主机名的话会使用主机的主机名的;假如在容器内部设置了主机名但是没有使用 CLONE_NEWUTS 的话那么改变的其实是主机的主机名。

 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
#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());
    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 | SIGCHLD, NULL);
    waitpid(container_id, NULL, 0);
    printf("Parent - container stopped!\n");
    return 0;
}

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

1.2. PID Namespace

每个容器都有自己的进程环境中,也就是相当于容器内进程的 PID 从 1 开始命名,此时主机上的 PID 其实也还是从 1 开始命名的,就相当于有两个进程环境:一个主机上的从 1 开始,另一个容器里的从 1 开始。

为啥 PID 从 1 开始就相当于进程环境的隔离了呢?因此在传统的 UNIX 系统中,PID 为 1 的进程是 init,地位特殊。它作为所有进程的父进程,有很多特权。另外,其还会检查所有进程的状态,我们知道如果某个进程脱离了父进程(父进程没有 wait 它),那么 init 就会负责回收资源并结束这个子进程。所以要想做到进程的隔离,首先需要创建出 PID 为 1 的进程。

但是,【kubernetes 里面的话】

 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-20201007144548580.png

如果此时你在子进程的 shell 中输入 ps、top 等命令,我们还是可以看到所有进程。这是因为,ps、top 这些命令是去读 /proc 文件系统,由于此时文件系统并没有隔离,所以父进程和子进程通过命令看到的情况都是一样的。

1.3. IPC Namespace

常见的 IPC 有共享内存、信号量、消息队列等。当使用 IPC Namespace 把 IPC 隔离起来之后,只有同一个 Namespace 下的进程才能相互通信,因为主机的 IPC 和其他 Namespace 中的 IPC 都是看不到了的。而这个的隔离主要是因为创建出来的 IPC 都会有一个唯一的 ID,那么主要对这个 ID 进行隔离就好了。

想要启动 IPC 隔离,只需要在调用 clone 的时候加上 CLONE_NEWIPC 参数就可以了。

 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 | CLONE_NEWIPC | SIGCHLD, NULL);
    waitpid(container_id, NULL, 0);
    printf("Parent - container stopped!\n");
    return 0;
}

1.4. Mount Namespace

Mount Namespace 可以让容器有自己的 root 文件系统。需要注意的是,在通过 CLONE_NEWNS 创建 mount namespace 之后,父进程会把自己的文件结构复制给子进程中。所以当子进程中不重新 mount 的话,子进程和父进程的文件系统视图是一样的,假如想要改变容器进程的视图,一定需要重新 mount(这个是 mount namespace 和其他 namespace 不同的地方)。

另外,子进程中新的 namespace 中的所有 mount 操作都只影响自身的文件系统(注意这边是 mount 操作,而创建文件等操作都是会有所影响的),而不对外界产生任何影响,这样可以做到比较严格地隔离(当然这边是除 share mount 之外的)。

下面我们重新挂载子进程的 /proc 目录,从而可以使用 ps 来查看容器内部的情况。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
int container_main(void* arg) {
    printf("Container [%5d] - inside the container!\n", getpid());

    sethostname("container_dawn", 15);

    if (mount("proc", "/proc", "proc", 0, NULL) !=0 ) {
        perror("proc");
    }

    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-20201007153350925.png

这里会有个问题就是在退出子进程之后,当再次使用 ps -elf 的时候会报错,如下所示

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

这是因为 /proc 是 share mount,对它的操作会影响所有的 mount namespace,可以看这里:http://unix.stackexchange.com/questions/281844/why-does-child-with-mount-namespace-affect-parent-mounts

上面仅仅重新 mount 了 /proc 这个目录,其他的目录还是跟父进程一样视图的。一般来说,容器创建之后,容器进程需要看到的是一个独立的隔离环境,而不是继承宿主机的文件系统。接下来演示一个山寨镜像,来模仿 Docker 的 Mount Namespace。也就是给子进程实现一个较为完整的独立的 root 文件系统,让这个进程只能访问自己构成的文件系统中的内容(想想我们平常使用 Docker 容器的样子)。

  • 首先我们使用 docker export 将 busybox 镜像导出成一个 rootfs 目录,这个 rootfs 目录的情况如图所示,已经包含了 /proc/sys 等特殊的目录。

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

  • 之后我们在代码中将一些特殊目录重新挂载,并使用 chroot() 系统调用将进程的根目录改成上文的 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
    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
    
    char* const container_args[] = {
        "/bin/sh",
        NULL
    };
      
    int container_main(void* arg) {
        printf("Container [%5d] - inside the container!\n", getpid());
        sethostname("container_dawn", 15);
          
        if (mount("proc", "rootfs/proc", "proc", 0, NULL) != 0) {
            perror("proc");
        }
        if (mount("sysfs", "rootfs/sys", "sysfs", 0, NULL)!=0) {
            perror("sys");
        }
        if (mount("none", "rootfs/tmp", "tmpfs", 0, NULL)!=0) {
            perror("tmp");
        }
        if (mount("udev", "rootfs/dev", "devtmpfs", 0, NULL)!=0) {
            perror("dev");
        }
        if (mount("devpts", "rootfs/dev/pts", "devpts", 0, NULL)!=0) {
            perror("dev/pts");
        }
        if (mount("shm", "rootfs/dev/shm", "tmpfs", 0, NULL)!=0) {
            perror("dev/shm");
        }
        if (mount("tmpfs", "rootfs/run", "tmpfs", 0, NULL)!=0) {
            perror("run");
        }
      
        if ( chdir("./rootfs") || chroot("./") != 0 ){
            perror("chdir/chroot");
        }
      
        // 改变根目录之后,那么 /bin/bash 是从改变之后的根目录中搜索了
        execv(container_args[0], container_args);
        perror("exec");
        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-20201007212853123.png

实际上,Mount Namespace 是基于 chroot 的不断改良才被发明出来的,chroot 可以算是 Linux 中第一个 Namespace。那么上面被挂载在容器根目录上、用来为容器镜像提供隔离后执行环境的文件系统,就是所谓的容器镜像,也被叫做 rootfs(根文件系统)。需要明确的是,rootfs 只是一个操作系统所包含的文件、配置和目录,并不包括操作系统内核

1.5. Cgroup Namespace

跟 Namespace 情况类似,Cgoups 对资源的限制能力也有很多不完善的地方,其中被提及最多的是 /proc 文件系统的问题。/proc 目录存储着当前内核运行状态的一系列特殊文件,用户可以通过访问这些文件,查看系统以及当前正在运行的进程的信息,比如 CPU 使用情况、内存占用率等。这些文件也是 top 指令查看系统信息的主要数据来源。

但是,如果你在容器里执行 top 指令,就会发现,它显示的信息居然还是宿主机的 CPU 和内存数据。这是因为 /proc 文件系统并不知道用户通过 Cgroups 给这个容器做了什么样的资源限制,所以它返回的还是整个宿主机的。也就说,在一个容器查看 /proc/$PID/cgroup,或者在容器挂载 cgroup 时,会看到整个系统的 cgroup 信息。那么这个问题会导致,容器内的应用程序读取到的 CPU 核数、可用内存等信息还是宿主机的,而不是做了限制之后的。这带来了安全隐患,容器中一旦挂载 cgroup filesystem,可以修改整全局的 cgroup 配置。

当然,为了解决上面的那个问题。直观的做法就是容器不挂载宿主机的该目录就可以了,但是在容器中有时还是会需要访问该文件的。而且想在容器内部运行 systemd,我们希望每个容器都自己的 cgroup 结构。之前的话,可以通过 lxcfs 来实现隔离,lxcfs 在宿主机上维护进程组的信息,然后容器启动的时候将 lxcfs 维护的进程组信息所在的目录挂载到容器的 /proc 目录,当容器中获取 /proc 信息时,实际上获取宿主机上对该容器的进程组信息。

Linux 从内核 4.6 开始,支持 cgroup namespace,cgroup namespace 相比 lxcfs 更加安全。有了 cgroup namespace 后,每个 namespace 中的进程都有自己 cgroupns rootcgroup filesystem 视图。

cgroup namespace 带来了以下一些好处:

  • 可以限制容器的 cgroup filesytem 视图,使得在容器中也可以安全的使用 cgroup。

  • 此外,会使容器迁移更加容易;在迁移时,/proc/self/cgroup 需要复制到目标机器,这要求容器的 cgroup 路径是唯一的,否则可能会与目标机器冲突。有了 cgroupns,每个容器都有自己的 cgroup filesystem 视图,不用担心这种冲突。

20201008理解:/proc 存的是当前宿主机内核运行状态的情况,比如 CPU 使用情况、内存占用率等。容器在它看来其实就是一个做了限制的进程而已,它做计算还是针对整个内核的情况。但是,容器内的实际使用情况应该是根据容器中的相关限制来得出的,比如已经对容器做了 CPU 使用的限制,那么 top 命令得到的应该是在这个限制下,容器内进程的实际使用情况。

1.6. User Namespace

「详细请看相应章节的内容」

1.7. Network Namespace

「详细请看相应章节的内容」

1.8. Namespace 情况查看

Cgroup 的操作接口是文件系统,位于 /sys/fs/cgroup 中。假如想查看 namespace 的情况同样可以查看文件系统,namespace 主要查看 /proc/<pid>/ns 目录。

我们以上面的 [PID Namespace 程序](#PID Namespace) 为例,当这个程序运行起来之后,我们可以看到其 PID 为 11702。

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

之后,我们保持这个子进程运行,然后打开另一个 shell,查看这个程序创建的子进程的 PID,也就是容器中运行的进程在主机中的 PID。

最后,我们分别查看 /proc/11702/ns/proc/11703/ns 这两个目录的情况,也就是查看这两个进程的 namespace 情况。可以看到其中 cgroup、ipc、mnt、net、user 都是同一个 ID,而 pid、uts 是不同的 ID。如果两个进程的 namespace 编号相同,那么表示这两个进程位于同一个 namespace 中,否则位于不同 namespace 中。

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

如果可以查看 ns 的情况之外,这些文件一旦被打开,只要 fd 被占用着,即使 namespace 中所有进程都已经结束了,那么创建的 namespace 也会一直存在。比如可以使用 mount --bind /proc/11703/ns/uts ~/uts,让 11703 这个进程的 UTS Namespace 一直存在。

2. 总结

Namespace 技术实际上修改了应用进程看待整个计算机“视图”,即它的”视图“已经被操作系统做了限制,只能”看到“某些指定的内容,这仅仅对应用进程产生了影响。但是对宿主机来说,这些被隔离了的进程,其实还是进程,跟宿主机上其他进程并无太大区别,都由宿主机统一管理。只不过这些被隔离的进程拥有额外设置过的 Namespace 参数。那么 Docker 项目在这里扮演的,更多是旁路式的辅助和管理工作。如下左图所示

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

因此,相比虚拟机的方式,容器会更受欢迎。这是假如使用虚拟机的方式作为应用沙盒,那么必须要由 Hypervisor 来负责创建虚拟机,这个虚拟机是真实存在的,并且里面必须要运行一个完整的 Guest OS 才能执行用户的应用进程。这样就导致了采用虚拟机的方式之后,不可避免地带来额外的资源消耗和占用。根据实验,一个运行着 CentOS 的 KVM 虚拟机启动后,在不做优化的情况下,虚拟机就需要占用 100-200 MB 内存。此外,用户应用运行在虚拟机中,它对宿主机操作系统的调用就不可避免地要经过虚拟机软件的拦截和处理,这本身就是一层消耗,尤其对资源、网络和磁盘 IO 的损耗非常大。

而假如使用容器的方式,容器化之后应用本质还是宿主机上的一个进程,这也就意味着因为虚拟机化带来的性能损耗是不存在的;而另一方面使用 Namespace 作为隔离手段的容器并不需要单独的 Guest OS,这就使得容器额外的资源占用几乎可以忽略不计。

总得来说,“敏捷”和“高性能”是容器相对于虚拟机最大的优势,也就是容器能在 PaaS 这种更加细粒度的资源管理平台上大行其道的重要原因。

但是!基于 Linux Namespace 的隔离机制相比于虚拟化技术也有很多不足之处,其中最主要的问题就是隔离不彻底。

  • 首先,容器只是运行在宿主机上的一种特殊进程,那么容器之间使用的还是同一个宿主机上的操作系统。尽管可以在容器里面通过 mount namesapce 单独挂载其他不同版本的操作系统文件,比如 centos、ubuntu,但是这并不能改变共享宿主机内核的事实。这就意味着你要在 windows 上运行 Linux 容器,或者在低版本的 Linux 宿主机上运行高版本的 Linux 容器都是行不通的。

    而拥有虚拟机技术和独立 Guest OS 的虚拟机就要方便多了。

  • 其次,在 Linux 内核中,有很多资源和对象都是不能被 namespace 化的,比如时间。假如你的容器中的程序使用 settimeofday(2) 系统调用修改了时间,整个宿主机的时间都会被随之修改。

    相比虚拟机里面可以随意折腾的自由度,在容器里部署应用的时候,“什么能做,什么不能做” 是用户必须考虑的一个问题。之外,容器给应用暴露出来的攻击面是相当大的,应用“越狱”的难度也比虚拟机低很多。虽然,实践中可以使用 Seccomp 等技术对容器内部发起的所有系统调用进行过滤和甄别来进行安全加固,但这种方式因为多了一层对系统调用的过滤,也会对容器的性能产生影响。因此,在生产环境中没有人敢把运行在物理机上的 Linux 容器直接暴露到公网上。

另外,容器是一个“单进程”模型。容器的本质是一个进程,用户的应用进程实际上就是容器里 PID=1 的进程,而这个进程也是后续创建的所有进程的父进程。这也就意味着,在一个容器中,你没办法同时运行两个不同的应用,除非能事先找到一个公共的 PID=1 的程序来充当两者的父进程,比如使用 systemd 或者 supervisord。容器的设计更多是希望容器和应用同生命周期的,而不是容器还在运行,而里面的应用早已经挂了。

上面这段话个人的理解是:因为创建出子进程之后,子进程需要运行的,而此时父进程需要等待子进程运行结束,相当于只有子进程在运行。比如容器中的第一个进程往往就是业务需要的进程,也就是 entrypoint 指定的程序运行起来的进程。而创建出子进程会导致这个进程被暂停。即使将子进程改为后台执行,但是由于容器中 PID=1 的进程压根没有管理后台进程的能力,所以还是会有进程无法管理。

3. 巨人的肩膀

  1. 极客时间.《深入剖析 Kubernetes》.张磊.“白话容器基础(二):隔离与限制”

  2. DOCKER基础技术:LINUX NAMESPACE(上)

  3. DOCKER基础技术:LINUX NAMESPACE(下)