免费注册


容器底层---超细节的Namespace机制讲解

2021-01-08 人浏览

Namespace

Linux Namespace 是 Linux 提供的一种内核级别环境隔离的方法。这种隔离机制和 chroot 很类似,chroot 是把某个目录修改为根目录,从而无法访问外部的内容。Linux Namesapce 在此基础之上,提供了对 UTS、IPC、Mount、PID、Network、User 等的隔离机制,如下所示。

分类 系统调用参数 相关内核版本
Mount Namespaces CLONE_NEWNS Linux 2.4.19
UTS Namespaces CLONE_NEWUTS Linux 2.6.19
IPC Namespaces CLONE_NEWIPC Linux 2.6.19
PID Namespaces CLONE_NEWPID Linux 2.6.19
Network Namespaces CLONE_NEWNET 始于Linux 2.6.24 完成于 Linux 2.6.29
User Namespaces CLONE_NEWUSER 始于 Linux 2.6.23 完成于 Linux 3.8)

Linux Namespace 官方文档:Namespaces in operation

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

  • clone() --- 实现线程的系统调用,用来创建一个新的进程,并可以通过设计上述参数达到隔离。

  • unshare() --- 使某个进程脱离某个 namespace

  • setns(int fd, int nstype) --- 把某进程加入到某个 namespace

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

UTS Namespace

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

  1. #define _GNU_SOURCE
  2. #include <sys/types.h>
  3. #include <sys/wait.h>
  4. #include <sys/mount.h>
  5. #include <stdio.h>
  6. #include <sched.h>
  7. #include <signal.h>
  8. #include <unistd.h>
  9. #define STACK_SIZE (1024 * 1024)
  10. static char container_stack[STACK_SIZE];
  11. char* const container_args[] = {
  12. "/bin/bash",
  13. NULL
  14. };
  15. int container_main(void* arg) {
  16. printf("Container [%5d] - inside the container!\n", getpid());
  17. sethostname("container_dawn", 15);
  18. execv(container_args[0], container_args);
  19. printf("Something's wrong!\n");
  20. return 1;
  21. }
  22. int main() {
  23. printf("Parent [%5d] - start a container!\n", getpid());
  24. int container_id = clone(container_main, container_stack + STACK_SIZE,
  25. CLONE_NEWUTS | SIGCHLD, NULL);
  26. waitpid(container_id, NULL, 0);
  27. printf("Parent - container stopped!\n");
  28. return 0;
  29. }

PID Namespace

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

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

但是,【kubernetes 里面的话】

  1. int container_main(void* arg) {
  2. printf("Container [%5d] - inside the container!\n", getpid());
  3. sethostname("container_dawn", 15);
  4. execv(container_args[0], container_args);
  5. printf("Something's wrong!\n");
  6. return 1;
  7. }
  8. int main() {
  9. printf("Parent [%5d] - start a container!\n", getpid());
  10. int container_id = clone(container_main, container_stack + STACK_SIZE,
  11. CLONE_NEWUTS | CLONE_NEWPID | SIGCHLD, NULL);
  12. waitpid(container_id, NULL, 0);
  13. printf("Parent - container stopped!\n");
  14. return 0;
  15. }

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

IPC Namespace

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

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

  1. int container_main(void* arg) {
  2. printf("Container [%5d] - inside the container!\n", getpid());
  3. sethostname("container_dawn", 15);
  4. execv(container_args[0], container_args);
  5. printf("Something's wrong!\n");
  6. return 1;
  7. }
  8. int main() {
  9. printf("Parent [%5d] - start a container!\n", getpid());
  10. int container_id = clone(container_main, container_stack + STACK_SIZE,
  11. CLONE_NEWUTS | CLONE_NEWPID | CLONE_NEWIPC | SIGCHLD, NULL);
  12. waitpid(container_id, NULL, 0);
  13. printf("Parent - container stopped!\n");
  14. return 0;
  15. }

Mount Namespace

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

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

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

  1. int container_main(void* arg) {
  2. printf("Container [%5d] - inside the container!\n", getpid());
  3. sethostname("container_dawn", 15);
  4. if (mount("proc", "/proc", "proc", 0, NULL) !=0 ) {
  5. perror("proc");
  6. }
  7. execv(container_args[0], container_args);
  8. printf("Something's wrong!\n");
  9. return 1;
  10. }
  11. int main() {
  12. printf("Parent [%5d] - start a container!\n", getpid());
  13. int container_id = clone(container_main, container_stack + STACK_SIZE,
  14. CLONE_NEWUTS | CLONE_NEWPID | CLONE_NEWNS | SIGCHLD, NULL);
  15. waitpid(container_id, NULL, 0);
  16. printf("Parent - container stopped!\n");
  17. return 0;
  18. }

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

这是因为 /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 等特殊的目录。

  • 之后我们在代码中将一些特殊目录重新挂载,并使用 chroot() 系统调用将进程的根目录改成上文的 rootfs 目录。

    1. char* const container_args[] = {
    2. "/bin/sh",
    3. NULL
    4. };
    5. int container_main(void* arg) {
    6. printf("Container [%5d] - inside the container!\n", getpid());
    7. sethostname("container_dawn", 15);
    8. if (mount("proc", "rootfs/proc", "proc", 0, NULL) != 0) {
    9. perror("proc");
    10. }
    11. if (mount("sysfs", "rootfs/sys", "sysfs", 0, NULL)!=0) {
    12. perror("sys");
    13. }
    14. if (mount("none", "rootfs/tmp", "tmpfs", 0, NULL)!=0) {
    15. perror("tmp");
    16. }
    17. if (mount("udev", "rootfs/dev", "devtmpfs", 0, NULL)!=0) {
    18. perror("dev");
    19. }
    20. if (mount("devpts", "rootfs/dev/pts", "devpts", 0, NULL)!=0) {
    21. perror("dev/pts");
    22. }
    23. if (mount("shm", "rootfs/dev/shm", "tmpfs", 0, NULL)!=0) {
    24. perror("dev/shm");
    25. }
    26. if (mount("tmpfs", "rootfs/run", "tmpfs", 0, NULL)!=0) {
    27. perror("run");
    28. }
    29. if ( chdir("./rootfs") || chroot("./") != 0 ){
    30. perror("chdir/chroot");
    31. }
    32. // 改变根目录之后,那么 /bin/bash 是从改变之后的根目录中搜索了
    33. execv(container_args[0], container_args);
    34. perror("exec");
    35. printf("Something's wrong!\n");
    36. return 1;
    37. }
    38. int main() {
    39. printf("Parent [%5d] - start a container!\n", getpid());
    40. int container_id = clone(container_main, container_stack + STACK_SIZE,
    41. CLONE_NEWUTS | CLONE_NEWPID | CLONE_NEWNS | SIGCHLD, NULL);
    42. waitpid(container_id, NULL, 0);
    43. printf("Parent - container stopped!\n");
    44. return 0;
    45. }
  • 最后,查看实现效果如下图所示。

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

User Namespace

容器内部看到的 UID 和 GID 和外部是不同的了,比如容器内部针对 dawn 这个用户显示的是 0,但是实际上这个用户在主机上应该是 1000。要实现这样的效果,需要把容器内部的 UID 和主机的 UID 进行映射,需要修改的文件是 /proc/<pid>/uid_map  /proc/<pid>/gid_map,这两个文件的格式是

ID-INSIDE-NS  ID-OUTSIDE-NS LENGTH
  • ID-INSIDE-NS :表示在容器内部显示的 UID 或 GID

  • ID-OUTSIDE-NS:表示容器外映射的真实的 UID 和 GID

  • LENGTH:表示映射的范围,一般为 1,表示一一对应

比如,下面就是将真实的 uid=1000 的映射为容器内的 uid =0:

  1. $ cat /proc/8353/uid_map
  2. 0 1000 1

再比如,下面则表示把 namesapce 内部从 0 开始的 uid 映射到外部从 0 开始的 uid,其最大范围是无符号 32 位整型(下面这条命令是在主机环境中输入的)。

  1. $ cat /proc/$/uid_map
  2. 0 0 4294967295

默认情况,设置了 CLONE_NEWUSER 参数但是没有修改上述两个文件的话,容器中默认情况下显示为 65534,这是因为容器找不到真正的 UID,所以就设置了最大的 UID。如下面的代码所示:

  1. #define _GNU_SOURCE
  2. #include <stdio.h>
  3. #include <stdlib.h>
  4. #include <sys/types.h>
  5. #include <sys/wait.h>
  6. #include <sys/mount.h>
  7. #include <sys/capability.h>
  8. #include <stdio.h>
  9. #include <sched.h>
  10. #include <signal.h>
  11. #include <unistd.h>
  12. #define STACK_SIZE (1024 * 1024)
  13. static char container_stack[STACK_SIZE];
  14. char* const container_args[] = {
  15. "/bin/bash",
  16. NULL
  17. };
  18. int container_main(void* arg) {
  19. printf("Container [%5d] - inside the container!\n", getpid());
  20. printf("Container: eUID = %ld; eGID = %ld, UID=%ld, GID=%ld\n",
  21. (long) geteuid(), (long) getegid(), (long) getuid(), (long) getgid());
  22. printf("Container [%5d] - setup hostname!\n", getpid());
  23. //set hostname
  24. sethostname("container",10);
  25. execv(container_args[0], container_args);
  26. printf("Something's wrong!\n");
  27. return 1;
  28. }
  29. int main() {
  30. const int gid=getgid(), uid=getuid();
  31. printf("Parent: eUID = %ld; eGID = %ld, UID=%ld, GID=%ld\n",
  32. (long) geteuid(), (long) getegid(), (long) getuid(), (long) getgid());
  33. printf("Parent [%5d] - start a container!\n", getpid());
  34. int container_pid = clone(container_main, container_stack+STACK_SIZE,
  35. CLONE_NEWUTS | CLONE_NEWPID | CLONE_NEWUSER | SIGCHLD, NULL);
  36. printf("Parent [%5d] - Container [%5d]!\n", getpid(), container_pid);
  37. printf("Parent [%5d] - user/group mapping done!\n", getpid());
  38. waitpid(container_pid, NULL, 0);
  39. printf("Parent - container stopped!\n");
  40. return 0;
  41. }

当我以 dawn 这个用户执行的该程序的时候,那么会显示如下图所示的效果。使用 root 用户的时候是同样的:

 

接下去,我们要开始来实现映射的效果了,也就是让 dawn 这个用户在容器中显示为 0。代码是几乎完全拿耗子叔的博客上的,链接可见文末:

  1. int pipefd[2];
  2. void set_map(char* file, int inside_id, int outside_id, int len) {
  3. FILE* mapfd = fopen(file, "w");
  4. if (NULL == mapfd) {
  5. perror("open file error");
  6. return;
  7. }
  8. fprintf(mapfd, "%d %d %d", inside_id, outside_id, len);
  9. fclose(mapfd);
  10. }
  11. void set_uid_map(pid_t pid, int inside_id, int outside_id, int len) {
  12. char file[256];
  13. sprintf(file, "/proc/%d/uid_map", pid);
  14. set_map(file, inside_id, outside_id, len);
  15. }
  16. int container_main(void* arg) {
  17. printf("Container [%5d] - inside the container!\n", getpid());
  18. printf("Container: eUID = %ld; eGID = %ld, UID=%ld, GID=%ld\n",
  19. (long) geteuid(), (long) getegid(), (long) getuid(), (long) getgid());
  20. /* 等待父进程通知后再往下执行(进程间的同步) */
  21. char ch;
  22. close(pipefd[1]);
  23. read(pipefd[0], &ch, 1);
  24. printf("Container [%5d] - setup hostname!\n", getpid());
  25. //set hostname
  26. sethostname("container",10);
  27. //remount "/proc" to make sure the "top" and "ps" show container's information
  28. mount("proc", "/proc", "proc", 0, NULL);
  29. execv(container_args[0], container_args);
  30. printf("Something's wrong!\n");
  31. return 1;
  32. }
  33. int main() {
  34. const int gid=getgid(), uid=getuid();
  35. printf("Parent: eUID = %ld; eGID = %ld, UID=%ld, GID=%ld\n",
  36. (long) geteuid(), (long) getegid(), (long) getuid(), (long) getgid());
  37. pipe(pipefd);
  38. printf("Parent [%5d] - start a container!\n", getpid());
  39. int container_pid = clone(container_main, container_stack+STACK_SIZE,
  40. CLONE_NEWUTS | CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWUSER | SIGCHLD, NULL);
  41. printf("Parent [%5d] - Container [%5d]!\n", getpid(), container_pid);
  42. //To map the uid/gid,
  43. // we need edit the /proc/PID/uid_map (or /proc/PID/gid_map) in parent
  44. set_uid_map(container_pid, 0, uid, 1);
  45. printf("Parent [%5d] - user/group mapping done!\n", getpid());
  46. /* 通知子进程 */
  47. close(pipefd[1]);
  48. waitpid(container_pid, NULL, 0);
  49. printf("Parent - container stopped!\n");
  50. return 0;
  51. }

实现的最终效果如图所示,可以看到在容器内部将 dawn 这个用户 UID 显示为了 0(root),但其实这个容器中的 /bin/bash 进程还是以一个普通用户,也就是 dawn 来运行的,只是显示出来的 UID 是 0,所以当查看 /root 目录的时候还是没有权限。

User Namespace 是以普通用户运行的,但是别的 Namespace 需要 root 权限,那么当使用多个 Namespace 该怎么办呢?我们可以先用一般用户创建 User Namespace,然后把这个一般用户映射成 root,那么在容器内用 root 来创建其他的 Namespace。

Network Namespace

隔离容器中的网络,每个容器都有自己的虚拟网络接口和 IP 地址。在 Linux 中,可以使用 ip 命令创建 Network Namespace(Docker 的源码中,它没有使用 ip 命令,而是自己实现了 ip 命令内的一些功能)。

下面就使用 ip 命令来讲解一下 Network Namespace 的构建,以 bridge 网络为例。bridge 网络的拓扑图一般如下图所示,其中 br0 是 Linux 网桥。

在使用 Docker 的时候,如果启动一个 Docker 容器,并使用 ip link show 查看当前宿主机上的网络情况,那么你会看到有一个 docker0 还有一个 veth****  的虚拟网卡,这个 veth 的虚拟网卡就是上图中 veth,而 docker0 就相当于上图中的 br0。

那么,我们可以使用下面这些命令即可创建跟 docker 类似的效果(参考自耗子叔的博客,链接见文末参考,结合上图加了一些文字)。

  1. ## 1. 首先,我们先增加一个网桥 lxcbr0,模仿 docker0
  2. brctl addbr lxcbr0
  3. brctl stp lxcbr0 off
  4. ifconfig lxcbr0 192.168.10.1/24 up #为网桥设置IP地址
  5. ## 2. 接下来,我们要创建一个 network namespace ,命名为 ns1
  6. # 增加一个 namesapce 命令为 ns1 (使用 ip netns add 命令)
  7. ip netns add ns1
  8. # 激活 namespace 中的 loopback,即127.0.0.1(使用 ip netns exec ns1 相当于进入了 ns1 这个 namespace,那么 ip link set dev lo up 相当于在 ns1 中执行的)
  9. ip netns exec ns1 ip link set dev lo up
  10. ## 3. 然后,我们需要增加一对虚拟网卡
  11. # 增加一对虚拟网卡,注意其中的 veth 类型。这里有两个虚拟网卡:veth-ns1 和 lxcbr0.1,veth-ns1 网卡是要被安到容器中的,而 lxcbr0.1 则是要被安到网桥 lxcbr0 中的,也就是上图中的 veth。