【笔记】Linux命名空间—实验

之前花费一点时间学习了一下Linux命名空间,这里翻译一下并做一次记录,方便以后复习回顾。以下的实验均在ubuntu 18.04环境上面测试通过,文中的代码可以在这里进行下载。

实验一、UTS命名空间

UTS命名空间提供了主机名和域名的隔离,这样使得每个容器就可以拥有独立的主机名和域名,使其可以在网络上被视为一个独立的节点。此外,UTS命名空间是扁平化的结构,不同的命名空间之间没有层级关系。
在子进程中修改主机名

printf("在子进程中!\n");
sethostname("NewNamepace", 12);
execv(child_args[0], child_args);
return 1;

父进程调用clone函数创建子进程,并使用CLONE_NEWUTS标志创建新的UTS命令空间。

int child_pid = clone(childFunc, child_stack + STACK_SIZE, CLONE_NEWUTS | SIGCHLD, NULL);

上述的代码试着不加CLONE_NEWUTS参数运行上述代码,发现主机名也变了,输入exit以后主机名也会变回来,似乎没什么区别。实际上不加CLONE_NEWUTS参数进行隔离而使用sethostname已经把宿主机的主机名改掉了。你看到exit退出后还原只是因为bash只在刚登录的时候读取一次UTS,当你重新登陆或者使用uname命令进行查看时,就会发现产生了变化。

实验二、IPC命名空间

进程间通信采用的方法包括常见的信号量、消息队列和共享内存。容器内部进程间通信对宿主机来说,实际上是具有相同PID 命名空间中的进程间通信,因此需要一个唯一的标识符来进行区别。进程间通信所需资源也需要唯一的标识,申请IPC资源就申请了这样一个全局唯一的32位ID,所以IPC 命名空间中实际上包含了系统IPC标识符以及实现POSIX消息队列的文件系统。在同一个IPC 命名空间下的进程彼此可见,而与其他的IPC 命名空间下的进程则互相不可见。

要使用IPC命名空间,只需要对上面的代码做一点小的改动,只需要把“CLONE_NEWPIC”标记添加到“clone”的调用中,而不需要其它额外的步骤,这里将IPC命名和也能和其他namespace组合使用。

child_pid = clone(childFunc, child_stack + STACK_SIZE, CLONE_NEWUTS | CLONE_NEWIPC | SIGCHLD, argv[1]);

上述方式创建了一个新的IPC命名空间,并且不会与其他任何应用冲突。先执行demo_ipc_namespaces,创建UTS和IPC命名空间,并创建队列,执行结果如下:

$ sudo ./demo_ipc_namespaces newnamespace
uts.nodename in child: newnamespace.
root@newnamespace:~/namespace/Exp2#
root@newnamespace:~/namespace/Exp2# ipcmk -Q
消息队列 id:0
root@newnamespace:~/namespace/Exp2# ipcs -q
--------- 消息队列 -----------
键        msqid      拥有者  权限     已用字节数 消息
0xc04e4277 0          root       644        0            0

打开另一个终端,查看队列,发现上面的队列并不存在:

yupf@yupf:~$ ipcs -q
--------- 消息队列 -----------
键        msqid      拥有者  权限     已用字节数 消息

这里“父进程”和“子进程”现在不是隔离的吗?那么如果需要让它们通信,应该如何解决这一问题?这里引入了管道的方式,执行ipc_pipe程序,在子进程执行write操作,在父进程执行read操作:

$ sudo ./ipc_pipe
 - Hello ?
I am child!, 4
I am child!, 3
I am child!, 2
I am child!, 1
I am child!, 0
 - World !

实验三、PID命名空间

PID命名空间隔离非常实用,它对进程PID重新标号,即两个不同命名空间下的进程可以有同一个PID。每个PID 命名空间都有自己的计数程序。内核为所有的PID 命名空间维护了一个树状结构,最顶层的是系统初始时创建的,我们称之为root 命名空间。他创建的新PID 命名空间就称之为child 命名空间(树的子节点),而原先的PID 命名空间就是新创建的PID 命名空间的parent 命名空间(树的父节点)。通过这种方式,不同的PID 命名空间会形成一个等级体系。所属的父节点可以看到子节点中的进程,并可以通过信号量等方式对子节点中的进程产生影响。反过来,子节点不能看到父节点PID 命名空间中的任何内容。

  • 每个PID 命名空间中的第一个进程“PID 1“,都会像传统Linux中的init进程一样拥有特权,起特殊作用。
  • 一个命名空间中的进程,不可能通过kill或ptrace影响父节点或者兄弟节点中的进程,因为其他节点的PID在这个命名空间中没有任何意义。
  • 如果你在新的PID 命名空间中重新挂载/proc文件系统,会发现其下只显示同属一个PID 命名空间中的其他进程。
  • 在root 命名空间中可以看到所有的进程,并且递归包含所有子节点中的进程。

实验1:PID命名空间创建

要使用IPC命名空间,只需要对上面的代码做一点小的改动,只需要把“CLONE_NEWPID”标记添加到“clone”的调用中即可。

child_pid = clone(childFunc, child_stack + STACK_SIZE, CLONE_NEWUTS | CLONE_NEWPID | SIGCHLD, argv[1]);

在子进程中打印进程PID已经父进程的PID,这里执行sleep程序的目的是为了区分主进程和子进程的进程列表。执行pidns_init_sleep的情况如下所示:

yupf@yupf:~/namespace/Exp3$ sudo ./pidns_init_sleep /proc2
[sudo] yupf 的密码:
PID returned by clone(): 5659
childFunc() : PID = 1
childFunc() : PPID = 0
Mounting procfs at /proc2

pidns_init_sleep 前两行的输出从两个不同的 PID 命名空间展示了子进程的 PID:一个是调用 clone() 的命名空间,另一个是子进程所在的命名空间。换言之,子进程有两个 PID,在父空间为5659,在调用 clone() 后生成的新的 PID 命名空间中为 1。

接下来的一行输出是位于子进程所在的 PID 命名空间中的父进程 ID(getppid() 的返回值)。父进程 ID 为0,还挺奇怪的。如之前所述,PID 命名空间构成了一个层次体系:一个进程仅仅能“看到”那些位于其自己 PID 命名空间和其子命名空间内的进程。因为调用 clone() 的父进程是一个不同的命名空间,所以子进程不能“看到”其父进程;因此,getppid() 展示其父进程 PID 为 0。

实验2:proc文件系统

每一个Linux系统都有一个 /proc/PID 的目录,这里面包含了描述进程的多个伪文件(前文中已经展示过),这种构想可以直接理解为PID命名空间的模型。在一个命名空间中,/proc/PID 仅显示该PID命名空间或者其子命名空间之一的进程信息。

为了使PID命名空间相对应的 /proc/PID 目录可见,需要在该命名空间中挂载proc文件系统 (即 “procfs”) 。当一个 shell 运行在 PID 命名空间中时(也许是通过 system() 库函数产生的),可以使用和pidns_init_sleep代码中一样的挂载命令。

mkdir(mount_point, 0555);       /* Create directory for mount point */
mount("proc", mount_point, "proc", 0, NULL);
printf("Mounting procfs at %s\n", mount_point);

mount_point 变量是在调用 pidns_init_sleep 时,从命令行中提供的参数初始化而来。

在在如上运行 pidns_init_sleep 的 shell 中,我们将一个新的 procfs 挂载在 /proc2。在真实的用法中,该 procfs(如果需要的话)通常被挂载在 /proc。然而,挂载在 /proc2 可避免对系统中剩余的进程造成麻烦:因为这些进程和我们的测试程序在同一个挂载空间,所以挂载在 /proc 会使得根 PID 命名空间不可见,从而与系统中剩余的进程混淆。

因此,在我们的 shell 中,挂载在 /proc 的 procfs 会显示父 PID 命名空间中可见进程的 PID 子目录,但挂载在 /proc2 的 porcfs 仅显示位于子 PID 命名空间中的进程的 PID 子目录。另外,值得一提的是,尽管子 PID 命名空间中的进程可以看到 /proc 挂载点暴露的 PID 目录,但那些 PID 对于子 PID 命名空间没有意义,因为这些进程所发出的系统调用是在其 PID 命名空间中解释的。

如果需要在子 PID 命名空间中运行类似于 ps 的工具,那么必须有一个挂载在传统挂载点 /proc 的 procfs,因为那些工具依赖于 /proc。有两种方式可在父 PID 命名空间中,在不影响 /proc 挂载点的情况下达到该目的。第一种,如果一个子进程通过 CLONE_NEWS 标志创建,那么该子进程将在与系统中其余进程位于不同的挂载点中。这种情况下,挂载在 /proc 的新 procfs 不会造成任何麻烦。另外一种,不用 CLONE_NEWNS 标志,子进程可通过 chroot() 改变其根目录并在 /proc 挂载一个 procfs。

这里停止了程序,并在父命名空间中使用 ps 检查父进程和子进程的一些细节,可以看出sleep的父进程就是pidns_init_sleep, readlink 命令展示了 /proc/PID/ns/pid 符号链接的(不同)内容,这里可以5658和5659的符号链接内容,这里不做展示了。

^Z
[1]+  已停止               sudo ./pidns_init_sleep /proc2
yupf@yupf:~/namespace/Exp3$ ps -C sleep -C pidns_init_sleep -o "pid ppid stat cmd"
  PID  PPID STAT CMD
 5658  5657 T    ./pidns_init_sleep /proc2
 5659  5658 S    sleep 600

至此,从命名空间的角度来看,我们也可以使用我们新挂载的 procfs 来获取关于新 PID 命名空间中的进程的信息。我们可通过如下命令来获取命名空间中的一个 PID 列表:

$ ls -d /proc2/[1-9]*
/proc2/1

正如所见,该 PID 命名空间仅包含了一个进程,其 PID 为 1。也可以使用 /proc/PID/status 文件来获取相同的信息:

$ cat /proc2/1/status | egrep '^(Name|PP*id)'
Name:   sleep
Pid:    1
PPid:   0

**注意:**因为此时没有进行mount 命名空间的隔离,所以这一步操作实际上已经影响了 root 命名的文件系统,当你退出新建的PID 命名空间以后再执行ps a就会发现出错,再次执行mount -t proc proc /proc可以修复错误。

实验3:嵌套的PID命名空间

如前所述,PID名称空间分层嵌套在父进程和子进程的关系中。在PID名称空间中,可以看到同一名称空间中的所有其他进程,以及作为后代名称空间成员的所有进程。

从其所在的PID名称空间到根PID名称空间,一个进程在PID名称空间层次结构的每一层中都将具有一个PID。调用getpid()始终报告与进程所在的名称空间关联的PID。 此处显示的程序用来显示一个进程在每个可见的命名空间中都有不同的PID。

该程序在嵌套的PID名称空间中递归创建一系列子进程。调用程序时指定的命令行参数确定要创建多少个子代和PID名称空间:

$ ./multi_pidns 5

为了创建新的子进程,每一次递归都会将一个procfs 文件系统挂载在一特定命名的挂载点上。在最后一次递归之后,最后一个孩子执行sleep程序,如下所示。

Mounting procfs at /proc4
Mounting procfs at /proc3
Mounting procfs at /proc2
Mounting procfs at /proc1
Mounting procfs at /proc0
Final child sleeping

查看每个 procfs 中的 PID,我们看到每个连续的 procfs"级别"包含较少的 PID,这反映了每个 PID 命名空间仅显示该 PID 命名空间或其后代命名空间的成员的进程这一事实:

^Z
[2]+  已停止               sudo ./multi_pidns 5
$ ls -d /proc4/[1-9]*
/proc4/1  /proc4/2  /proc4/3  /proc4/4  /proc4/5
$ ls -d /proc3/[1-9]*
/proc3/1  /proc3/2  /proc3/3  /proc3/4
$ ls -d /proc2/[1-9]*
/proc2/1  /proc2/2  /proc2/3
$ ls -d /proc1/[1-9]*
/proc1/1  /proc1/2
$ ls -d /proc0/[1-9]*
/proc0/1

grep 命令允许我们在所有可见的名称空间中查看递归的尾端(即,在最深嵌套的命名空间中执行睡眠的进程)的 PID:

$ grep -H 'Name:.*sleep' /proc?/[1-9]*/status
/proc0/1/status:Name:   sleep
/proc1/2/status:Name:   sleep
/proc2/3/status:Name:   sleep
/proc3/4/status:Name:   sleep
/proc4/5/status:Name:   sleep

换句话说,在嵌套最深的 PID 命名空间 (/proc0)中,执行睡眠的进程具有 PID 1,在创建的最顶层 PID 命名空间 (/proc4) 中,该进程具有 PID 5。

如果您运行本文中显示的测试程序,值得一提的是,它们将留下装载点和装载目录。终止程序后,shell 命令(如以下内容)应足以清理操作:

# umount /proc?
# rmdir /proc?

实验4:init进程和信号量

当新建一个PID 命名空间时,默认启动的进程PID为1。在传统的UNIX系统中,PID为1的进程是init,地位非常特殊。它作为所有进程的父进程,维护一张进程表,不断检查进程的状态,一旦有某个子进程因为程序错误成为了“孤儿”进程,init就会负责回收资源并结束这个子进程。因此,PID 命名空间维护这样一个树状结构,非常有利于系统的资源监控与回收。而新建PID命名空间时的进程ID为1的进程也具有相似的作用。

程序ns_child_exec.c将会在一个新的PID 命名空间创建一个子进程,并且执行相应的shell命令去显示shell的PID。如果 PID 为 1,则 shell 是 PID 命名空间的 init 进程,该进程在 shell 运行时存在。

$ sudo ./ns_child_exec -p sh -c 'echo $$'
1

下面的程序显示init进程的两个主要的功能,其一是系统初始化,另一个是回收孤儿进程

可以将ns_child_exec程序与simple_init一起启动运行于新 PID 命名空间中 init 进程:

$ sudo ./ns_child_exec -p ./simple_init
init$

现在,我们将使用我们到目前为止提出的两个程序,以及另一个小程序,orphan.c,以演示在PID命名空间内成为孤立的进程是由PID命名空间 init进程采用的,而不是系统范围的 init进程。

孤立程序执行fork( ) 来创建子进程。然后,当子进程继续运行时,父进程退出;当父级退出时,子级将成为孤立项。子级执行循环,该循环一直持续到它成为孤立(即 getppid( ) 返回 1);一旦孩子成为孤儿,它就终止了。父级和子级打印消息,以便我们可以看到两个进程何时终止以及子进程何时成为孤立进程。

$ sudo ./ns_child_exec -p ./simple_init -v
        init: my PID is 1
init$ ./orphan

init: created child 2
Parent (PID = 2) created child with PID 3
Parent (PID = 2; PPID = 1) terminating
        init: SIGCHLD handler: PID 2 terminated
init$
Child (PID=3) now an orphan (parent PID=1)
Child (PID=3) terminating
        init: SIGCHLD handler: PID 3 terminated

上述输出中,以 init: 为前缀的缩进消息由 simple_init 程序打印。所有其他消息(init$ 标识除外)都由 orphan 程序打印。从输出中,我们可以看到子进程(PID 3)在其父进程(PID 2)终止时成为孤儿进程。此时,子进程被 PID 命名空间中的 init 进程(pid 1)收养。

PID namespace中的init进程如此特殊,自然内核也为他赋予了特权——信号量屏蔽。如果init中没有写处理某个信号量的代码逻辑,那么与init在同一个PID namespace下的进程(即使有超级权限)发送给它的该信号量都会被屏蔽。这个功能的主要作用是防止init进程被误杀。

那么其父节点PID namespace中的进程发送同样的信号量会被忽略吗?父节点中的进程发送的信号量,如果不是SIGKILL(销毁进程)或SIGSTOP(暂停进程)也会被忽略。但如果发送SIGKILL或SIGSTOP,子节点的init会强制执行(无法通过代码捕捉进行特殊处理),也就是说父节点中的进程有权终止子节点中的进程。

一旦init进程被销毁,同一PID namespace中的其他进程也会随之接收到SIGKILL信号量而被销毁。理论上,该PID namespace自然也就不复存在了。但是如果/proc/[pid]/ns/pid处于被挂载或者打开状态,namespace就会被保留下来。然而,保留下来的namespace无法通过setns()或者fork()创建进程,所以实际上并没有什么作用。

实验5:挂载一个 procfs 文件系统

在该系列之前的文章中,PID 命名空间的 /proc 文件系统(procfs)被挂载在别的地方而非传统的 /proc 挂载点。这运行我们使用 shell 命令去查看与每个新 PID 命名空间相关的 /proc/PID 目录中的内容,并使用 ps 命令查看根 PID 命名空间中的可见进程。

然而,类似于 ps 的工具是根据挂载在 /proc 的 procfs 中的内容来获取信息的。因此,如果我们想要 ps 工具运行于正确的 PID 命名空间中,需要为该命名空间挂载一个正确的 procfs。因为 simple_init 程序允许我们执行 shell 命令,所以我们可以通过执行 mount 命令来展示:

yupf@yupf:~/namespace/Exp3$ sudo ./ns_child_exec -p -m ./simple_init
init$mount -t proc proc /proc
init$ps a
  PID TTY      STAT   TIME COMMAND
    1 pts/3    S      0:00 ./simple_init
    3 pts/3    R+     0:00 ps a
init$

ps 命令可列出通过 /proc 访问的所有进程。本例中,我们只看到了两个进程,说明该命名空间中只有两个进程在运行。

当运行上述 ns_child_exec 命令时,我们使用了 -m 选项,会将创建的子进程(运行 simple_init 的进程)放到一个单独的挂载命名空间中。效果是,mount 命令不会影响命名空间之外的 /proc 挂载。

实验6:再探unshare( ) 和 setns( )

在开篇我们就讲到了unshare()和setns()这两个API,而这两个API在PID namespace中使用时,也有一些特别之处需要注意。

unshare()允许用户在原有进程中建立namespace进行隔离。但是创建了PID namespace后,原先unshare()调用者进程并不进入新的PID namespace,接下来创建的子进程才会进入新的namespace,这个子进程也就随之成为新namespace中的init进程。

类似的,调用setns()创建新PID namespace时,调用者进程也不进入新的PID namespace,而是随后创建的子进程进入。

可使用本文章中的介绍的 ns_exec.c 的加强版来演示一起使用 setns() 和 PID 命名空间的某些方面,在我们弄懂发生了什么之前会很惊讶。执行程序,根据程序的输出可知道什么时候收养子进程:

# ./ns_child_exec -p ./simple_init -v
        init: my PID is 1
init$ 

然后打开第二个终端窗口,通过 ns_run 程序来执行 orphan 程序。这将影响到创建于被 simple_init 控制的 PID 命名空间中的两个进程:

# ps -C sleep -C simple_init
  PID TTY          TIME CMD
 6147 pts/0    00:00:00 simple_init

# ./ns_run -f -n /proc/6147/ns/pid ./orphan
Parent (PID = 2) created child with PID 3
Parent (PID = 2; PPID = 0) terminating
#
Child (PID=3) now an orphan (parent PID=1)
Child (PID=3) terminating

看一下当 orphan 程序执行时“父”进程(PID 2)的输出,可见其父进程 ID 为 0。这反映了启动 orphan 进程(ns_run)的进程在不同的命名空间中 — 其成员对“父”进程不可见。正如前述文章,getppid() 在本例中返回 0。

回到第一个终端的窗口,可看到如下输出:

init$   init: SIGCHLD handler: PID 3 terminated

下图展示了在 orphan “父” 进程终止前不同进程之间的关系。箭头表示进程之间的父-子关系。
在这里插入图片描述

simple_init 获取了 orphan 程序创建的“子”进程(PID 3),但没有获取“父”进程(PID 2)。因为“父”进程被其位于另一个命名空间的父进程(ns_run)获取。下图展示了在 orphan “父”进程终止后,“子”进程终止前,进程之间的关系。
在这里插入图片描述

为什么创建其他namespace时unshare()和setns()会直接进入新的namespace而唯独PID namespace不是如此呢?因为调用getpid()函数得到的PID是根据调用者所在的PID namespace而决定返回哪个PID,进入新的PID namespace会导致PID产生变化。而对用户态的程序和库函数来说,他们都认为进程的PID是一个常量,PID的变化会引起这些进程奔溃。

换句话说,一旦程序进程创建以后,那么它的PID namespace的关系就确定下来了,进程不会变更他们对应的PID namespace。

实验四、Mount命名空间

不同mount namespace中的文件结构发生变化也互不影响。你可以通过/proc/[pid]/mounts查看到所有挂载在当前namespace中的文件系统,还可以通过/proc/[pid]/mountstats看到mount namespace中文件设备的统计信息,包括挂载文件的名字、文件系统类型、挂载位置等等。

Mount命名空间是添加到Linux的第一个名称空间类型,出现在2002年的Linux 2.4.19中。它们隔离了命名空间中的进程看到的安装点列表。或者换句话说,每个Mount命名空间都有自己的安装点列表,这意味着不同命名空间中的进程可以查看并能够操纵单个目录层次结构的不同视图。

首次引导系统时,只有一个Mount命名空间,即所谓的“初始命名空间”。通过将CLONE_NEWNS 标志与clone() 系统调用(在新名称空间中创建新的子进程)或 unshare() 系统调用(将调用者移至新命名空间)一起 使用,来创建新的Mount命名空间。创建新的Mount命名空间后,它将收到从clone() 或 unshare()调用者的命名空间复制的挂载点列表的副本 。

在clone()或unshare()调用之后,可以在每个命名空间中独立添加和删除挂载点(通过 mount() 和 umount())。(默认情况下)对挂载点列表的更改仅对进程所在的Mount命名空间中的进程可见。这些更改在其他Mount命名空间中不可见。

Mount命名空间有多种用途。例如,它们可用于提供每个用户的文件系统视图。其他用途包括 为新的PID命名空间挂载/ proc文件系统, 而不会引起其他进程的副作用,以及将 chroot()样式隔离到单个目录层次结构的一部分。在某些使用情况下,Mount命名空间与绑定挂载结合在一起。

概念1:共享子树

一旦完成了Mount命名空间的实现,用户空间程序员就会遇到可用性问题:Mount命名空间在命名空间之间提供了太多的隔离。例如,假设将新磁盘加载到光盘驱动器中。在原始实现中,使该磁盘在所有装入命名空间中可见的唯一方法是在每个命名空间中分别装入磁盘。在许多情况下,最好执行单个安装操作,使该磁盘在系统上所有(或某些子集)Mount命名空间中可见。

由于刚刚描述的问题,Linux 2.6.15中添加了共享子树功能(在2006年初,即首次实现Mount命名空间后约三年)。共享子树的主要好处是允许在命名空间之间自动,受控地传播安装和卸载事件。例如,这意味着将光盘安装在一个Mount命名空间中会触发该光盘在所有其他命名空间中的安装。

在共享子树功能下,每个挂载点都标记有“传播类型”,它确定在此挂载点下创建和删除的挂载点是否传播到其他挂载点。有四种不同的传播类型:

  • MS_SHARED:此安装点与属于其“对等组”成员的其他安装点共享安装和卸载事件(下面将对其进行详细说明)。当在此安装点下添加或删除安装点时,此更改将传播到对等组,因此安装或卸载也将在每个对等安装点下进行。传播也会反向发生,因此对等安装上的安装和卸载事件也将传播到该安装点。
  • MS_PRIVATE:这是共享安装点的反面。安装点不会将事件传播到任何对等方,也不会从任何对等方接收传播事件。
  • MS_SLAVE:此传播类型位于共享和私有之间。从安装具有一个主服务器-一个共享对等组,其成员将安装和卸载事件传播到从安装。但是,从安装不会将事件传播到主对等组。
  • MS_UNBINDABLE:此安装点是不可绑定的。与专用安装点一样,此安装点不会向对等方传播事件,也不会向对等方传播事件。此外,此安装点不能作为绑定安装操作的源。

值得在上面掩盖的几点上进行扩展。首先是传播类型是每个安装点设置。在命名空间中,某些挂载点可能被标记为共享,而其他挂载点则被标记为私有(或从属或不可绑定)。

要强调的第二点是,传播类型确定紧接在安装点下的安装和卸载事件的传播。因此,如果在共享装载X下创建一个子装载Y,则该子装载将传播到对等组中的其他装载点。但是,X的传播类型对于在Y下创建和删除的安装点无效。是否传播Y下的事件将取决于为Y定义的传播类型。类似地,在卸载X本身时是否会传播卸载事件将取决于X的父安装的传播类型。

顺便说一句,也许值得澄清的是,“事件”一词在这里是“发生某事”的抽象术语。事件传播的概念并不意味着在挂载点之间传递某种消息。相反,它带有这样的想法:在一个安装点上进行某些安装或卸载操作会触发一个或多个其他安装点的匹配操作。

最后,挂载既可以是主对等组的从属,又可以与自己的一组对等组共享事件,即所谓的从属和共享挂载。在这种情况下,安装可能会从主机接收传播事件,然后这些事件将传播到其对等对象。

概念2:对等组

初次使用不用去执行下面的语句,理解就好。

对等组是一组将彼此传播安装和卸载事件的安装点。当共享的传播类型的安装点在创建新名称空间期间被复制或用作绑定安装的源时,对等组将获取新成员。在两种情况下,新的安装点都成为同一个对等方的成员。组作为现有安装点。相反,当卸载点显式或隐式卸载挂载名称空间时,由于最后一个成员进程终止或移至另一个名称空间,因此挂载点不再是对等组的成员。

例如,假设在以初始安装名称空间运行的shell中,我们将根安装点设为私有,并创建两个共享的安装点:

sh1#mount --make-private / 
sh1#mount --make-shared /dev/sda3 /X 
sh1#mount --make-shared /dev/sda5 /Y

如shell提示中的“ # ”所示,在示例shell会话中使用的各种安装命令需要特权 ,以创建安装点并更改其传播类型。

然后,在第二个终端上,我们使用unshare命令创建一个新的安装命名空间,在其中运行shell:

sh2# unshare -m --propagation unchanged sh
	-m选项创建一个新的安装名称空间;--propagation不变 选项的用途将在 后面说明。

返回第一个终端,然后从/ X安装点创建绑定安装 :

sh1#mkdir /Z 
sh1#mount --bind /X /Z

完成这些步骤后,我们在下图中显示了这种情况。
在这里插入图片描述
在这种情况下,有两个对等组:

  • 第一个对等组包含安装点X,X’(创建第二个名称空间时创建的安装点X的副本)和Z(从初始名称空间中的源安装点X创建的绑定安装)。
  • 第二个对等组包含安装点Y和Y’(创建第二个名称空间时创建的安装点Y的副本)。

请注意,在第二个名称空间创建之后在初始名称空间中创建的绑定安装Z 不会在第二个名称空间中复制,因为父安装(/)被标记为私有。

sh1# cat /proc/self/mountinfo | sed 's/ - .*//'
61 0 8:2 / / rw,relatime
81 61 8:3 / /X rw,relatime shared:1
124 61 8:5 / /Y rw,relatime shared:2
228 61 8:3 / /Z rw,relatime shared:1

从此输出中,我们首先看到根挂载点是私有的。这由可选字段中没有任何标签表示。我们还看到安装点/X和/Z是同一对等组(ID为1)中的共享安装点,这意味着这两个安装之一下的安装和卸载事件将传播到另一个。装载/ Y是在不同对等组(ID 2)中的共享装载,根据定义,该装载不将事件传播到对等组1中的装载或从其传播事件。

还可通过 proc/pid/mountinfo 文件查看挂载点之间的父子关系。每个记录中的第一个字段是挂载点的 ID。第二个字段是父挂载的 ID。从上面的输出中,我们可以看到挂载点 /X、/Y 和 /Z 都是根挂载的子项,因为它们的父 ID 都是 61。

在第二个 shell(在第二个命名空间)中运行相同的命令,我们看到:

sh2# cat /proc/self/mountinfo | sed 's/ - .*//'
147 146 8:2 / / rw,relatime
221 147 8:3 / /X rw,relatime shared:1
224 147 8:5 / /Y rw,relatime shared:2

再次,我们看到根挂载点是私有的。然后我们看到 /X 是对等组 1 中的共享挂载,与最初挂载命名空间中的挂载 /X 和 /Z 相同。最后,我们看到 /Y 是对等组 2 中的共享装载,与最初挂载名空间中的挂载 /Y 相同。最后要注意的是,在第二个命名空间中复制的挂载点有自己的 ID,与最初命名空间中相应挂载的 ID 不同。

讨论:默认值

由于情况有些复杂,因此到目前为止,我们避免讨论默认的传播类型是指新安装点的含义。从内核的角度来看,创建新设备挂载时的默认值如下:

  • 如果安装点具有父级(即,它是非根安装点)且父级的传播类型为MS_SHARED,则新安装的传播类型也为 MS_SHARED。
  • 否则,新安装的传播类型为MS_PRIVATE。

根据这些规则,根挂载将为MS_PRIVATE,并且所有后代挂载在默认情况下也将为MS_PRIVATE。但是,可以说MS_SHARED是更好的默认设置,因为它是更常用的传播类型。因此,systemd将所有安装点的传播类型设置为MS_SHARED。因此,在大多数现代Linux发行版中,默认传播类型实际上是MS_SHARED。但是,这并不是硬道理,因为util-linux unshare实用程序还有话要说。创建新的安装名称空间时, 取消共享 假定用户想要一个完全隔离的名称空间,并通过执行以下命令等效(将根目录下的所有安装递归标记为私有),将所有安装点设为私有:

mount --make-rprivate /

为了防止这种情况,我们可以在创建新名称空间时使用其他选项:

unshare -m --propagation unchanged <cmd>

实验:通过 /proc/pid/mountinfo 检查传播类型和对等组

/proc/pid/mountinfo 文件显示了有关进程 PID 所在挂载命名空间中的挂载点的信息。位于同一挂载命名空间中的所有进程都将在此文件中看到相同的视图。此文件旨在提供比旧的、不可扩展的 /proc/pid/mounts 文件更多的挂载点信息。此文件中的每个记录中都包含一组(可能为空)“可选字段”,这些字段显示每个挂载的传播类型和对等组(用于共享挂载)信息。

对于共享安装,/proc/PID/mountinfo中相应记录中的可选字段 将包含形式shared:N的标记。在这里,共享标签指示安装程序正在与对等组共享传播事件。对等组由N标识,N是唯一标识对等组的整数。这些ID从1开始编号,当对等组由于其所有成员都离开该组而不再存在时,可以将其回收。属于同一对等组的所有安装点将在/proc/PID/mountinfo文件中显示具有相同N的shared:N标记。

因此,例如,如果我们在上面示例中的第一个 shell 中列出 /proc/self/mountinfo 的内容,将看到以下内容(使用 sed 过滤掉一些不相关的信息):

下面的实验对于理解不同的挂载方式还是很有帮助的,可以动手操作一下。

实验1:MS_SHARED 和 MS_PRIVATE

MS_SHARED 和 MS_PRIVATE 传播类型大致相反。共享挂载点是对等组的成员。对等组中的挂载点之间互相传递挂载和卸载事件。相比之下,私有挂载点不属于对等组;它既不向对等方传播事件,也不从对等方接收事件。在下面的 shell 会话中,我们将演示这两种传播类型的不同之处。

假设在最初的挂载命名空间中,我们已经有两个挂载点,/mntS 和 /mntP。在命名空间中的 shell 中,我们将 /mntS 标记为共享,将 /mntP 标记为私有,并在 /proc/self/mountinfo 中查看这些挂载:

root@yupf:~# mkdir /mntS
root@yupf:~# mkdir /mntP
root@yupf:~# mount /dev/sda7 /mntS
root@yupf:~# mount /dev/sda6 /mntP
root@yupf:~# cat /proc/self/mountinfo | grep '/mnt' | sed 's/ - .*//'
950 26 8:7 / /mntS rw,relatime shared:689
951 26 8:6 / /mntP rw,relatime shared:690
root@yupf:~# mount --make-shared /mntS
root@yupf:~# mount --make-private /mntP
root@yupf:~# cat /proc/self/mountinfo | grep '/mnt' | sed 's/ - .*//'
950 26 8:7 / /mntS rw,relatime shared:689
951 26 8:6 / /mntP rw,relatime

从输出中,我们看到 /mntS 是对等组 1 中的共享挂载,而 /mntP 没有标记,这表明它是私有挂载。我们在第二个终端中创建一个新的挂载命名空间,在其中运行第二个 shell 并检查挂载:

root@yupf:~# unshare -m --propagation unchanged sh
# cat /proc/self/mountinfo | grep '/mnt' | sed 's/ - .*//'
1027 953 8:7 / /mntS rw,relatime shared:689
1028 953 8:6 / /mntP rw,relatime

新的挂载命名空间得到了最初挂载命名空间的挂载点的拷贝。这些新的挂载点保持相同的传播类型,但具有唯一的挂载 ID(记录中的第一个字段)。接着在第二个终端中,我们在 /mntS 和 /mntP 下创建挂载并检查结果:

root@yupf:~# mkdir /mntS/a
root@yupf:~# mkdir /mntP/b
root@yupf:~# mount /dev/sda7  /mntS/a
root@yupf:~# mount /dev/sda6  /mntP/b
root@yupf:~# cat /proc/self/mountinfo | grep '/mnt' | sed 's/ - .*//'
1027 953 8:7 / /mntS rw,relatime shared:689
1028 953 8:6 / /mntP rw,relatime
1029 1027 8:7 / /mntS/a rw,relatime shared:690
1031 1028 8:6 / /mntP/b rw,relatime

从上面可以看出,/mntS/a 为共享的(从其父挂载继承此设置),而 /mntP/b 为私有挂载。

返回到第一个终端并检查设置,我们看到在共享挂载点 /mntS 下创建的新挂载传播到其对等挂载(位于最初挂载命名空间中),但在私有挂载点 /mntP 下创建的新挂载没有传播:

root@yupf:~# cat /proc/self/mountinfo | grep '/mnt' | sed 's/ - .*//'
950 26 8:7 / /mntS rw,relatime shared:689
951 26 8:6 / /mntP rw,relatime
1030 950 8:7 / /mntS/a rw,relatime shared:690

记得删除不必要的挂载点和目录。

实验2: MS_SLAVE

将挂载点设为从节点,可让它收到主对等组的挂载和卸载事件,同时防止它将事件传播到该主节点。如果我们希望(比方说)在主对等组(在另一个装载命名空间中)中挂载光盘时接收挂载事件,但希望防止从属挂载下的挂载和卸载事件在其他命名空间中产生副作用,则这非常有用。

通过将最初挂载命名空间中的两个(现有)挂载点标记为共享来演示从属行为产生的效果:

root@yupf:~# mkdir /mntX
root@yupf:~# mkdir /mntY
root@yupf:~# mount /dev/sda7 /mntY
root@yupf:~# mount /dev/sda6 /mntX
root@yupf:~# mount --make-shared /mntX
root@yupf:~# mount --make-shared /mntY
root@yupf:~# cat /proc/self/mountinfo | grep '/mnt' | sed 's/ - .*//'
950 26 8:7 / /mntY rw,relatime shared:689
951 26 8:6 / /mntX rw,relatime shared:690

在第二个终端上,我们创建一个新的挂命名空间并检查复制后的挂载点:

root@yupf:~# unshare -m --propagation unchanged bash
root@yupf:~# cat /proc/self/mountinfo | grep '/mnt' | sed 's/ - .*//'
1027 953 8:7 / /mntY rw,relatime shared:689
1028 953 8:6 / /mntX rw,relatime shared:690

在新的挂载命名空间中,我们将其中一个挂载点标记为从属。将共享挂载更改为从属挂载的效果是,使其成为其以前所属对等组的从属。

root@yupf:~# mount --make-slave /mntY
root@yupf:~# cat /proc/self/mountinfo | grep '/mnt' | sed 's/ - .*//'
1027 953 8:7 / /mntY rw,relatime master:689
1028 953 8:6 / /mntX rw,relatime shared:690

在上述输出中,/mntY 挂载点用 master:2 标记。标记名可能有悖常理:它表示挂载点是从挂载,从 ID 为 2 的主对等组接收传播事件。如果一个挂载既是另一个对等组的从属,又与它自己的对等组共享事件,那么 /proc/pid/mountinfo 记录中的可选字段将同时显示一个 master:M 标记和一个 shared:N 标记。

继续停在新命名空间中,我们在 /mntX 和 /mntY 下创建挂载,当我们检查新挂载命名空间中挂载点的状态时,我们看到 /mntX/a 被创建为新的共享挂载(从其父挂载继承“共享”设置),/mntY/b 被创建为私有挂载(即,在可选字段中没有显示标记)::

root@yupf:~# mkdir /mntX/a
root@yupf:~# mkdir /mntY/b
root@yupf:~# mount /dev/sda6 /mntX/a
root@yupf:~# mount /dev/sda7 /mntY/b
root@yupf:~# cat /proc/self/mountinfo | grep '/mnt' | sed 's/ - .*//'
1027 953 8:7 / /mntY rw,relatime master:689
1028 953 8:6 / /mntX rw,relatime shared:690
1029 1028 8:6 / /mntX/a rw,relatime shared:691
1031 1027 8:7 / /mntY/b rw,relatime

回到第一个终端,我们看到 mount/mntX/a 传播了到最初命名空间中的对等方 /mntX ,但是 mount/mntY/b 没有传播:

root@yupf:~# cat /proc/self/mountinfo | grep '/mnt' | sed 's/ - .*//'
950 26 8:7 / /mntY rw,relatime shared:689
951 26 8:6 / /mntX rw,relatime shared:690
1030 951 8:6 / /mntX/a rw,relatime shared:691

接下来,我们在第一个挂载命名空间中的 /mntY 下创建一个新的挂载点:

root@yupf:~# mkdir /mntY/c
root@yupf:~# mount /dev/sda7 /mntY/c
root@yupf:~# cat /proc/self/mountinfo | grep '/mnt' | sed 's/ - .*//'
950 26 8:7 / /mntY rw,relatime shared:689
951 26 8:6 / /mntX rw,relatime shared:690
1030 951 8:6 / /mntX/a rw,relatime shared:691
1032 950 8:7 / /mntY/c rw,relatime shared:692

再切换到第二个终端,检查第二个挂载命名空间中的挂载点时,看到在这种情况下,新挂载已传播到从属挂载点,并且新挂载是从属挂载(到对等组4):

root@yupf:~# cat /proc/self/mountinfo | grep '/mnt' | sed 's/ - .*//'
1027 953 8:7 / /mntY rw,relatime master:689
1028 953 8:6 / /mntX rw,relatime shared:690
1029 1028 8:6 / /mntX/a rw,relatime shared:691
1031 1027 8:7 / /mntY/b rw,relatime
1033 1027 8:7 / /mntY/c rw,relatime master:692

实验3:绑定挂载

稍后,我们将研究MS_UNBINDABLE 传播类型的使用。但是,事先简要描述绑定挂载的概念很有用,该功能最早出现在Linux 2.4中。

绑定挂载可用于使文件或目录子树在单个目录层次结构中的另一个位置可见。在某些方面,绑定挂载就像硬链接,但在一些重要方面有所不同:

  • 无法创建到目录的硬链接,但是可以将mount绑定到目录。
  • 硬链接只能建立到同一文件系统上的文件,而绑定挂载可以跨越文件系统边界(甚至可以超出chroot()监狱)。
  • 硬链接需要对文件系统进行修改。相比之下,绑定挂载是挂载名称空间的安装列表中的一条记录,换句话说,是实时系统的属性。

可以使用mount() MS_BIND标志以编程方式创建绑定挂载,也可以使用mount --bind在命令行上创建绑定挂载。在以下示例中,我们首先创建一个包含文件的目录,然后将该目录绑定挂载到新位置:

# mkdir dir1                 # Create source directory
# touch dir1/x               # Populate the directory
# mkdir dir2                 # Create target for bind mount
# mount --bind dir1 dir2     # Create bind mount
# ls dir2                    # Bind mount has same content
x

然后,我们在新的安装点下创建一个文件,并观察到新文件在原始目录下也可见,这表明绑定挂载引用的是同一目录对象:

# touch dir2/y
# ls dir1
x  y

默认情况下,在创建目录的绑定挂载时,只有该目录才会挂载在新位置。如果该目录树下有任何挂载,则不会在挂载目标下复制它们。也可以通过使用标志 MS_BIND和MS_REC调用mount()或从命令行使用 mount --rbind选项执行递归绑定挂载。在这种情况下,源树下的每个挂载都将复制到目标树中的相应位置。

实验4:MS_UNBINDABLE

共享、私有和从属传播类型是用来管理对等挂载点(通常位于不同命名空间中)之间挂载事件的传播的。不可挂载点用来解决不同的问题,即挂载命名空间出现前的问题。这个问题就是所谓的“挂载点爆炸”,当在低级别挂载点重复执行高级别子树的递归绑定挂载时发生。我们通过一个 shell 会话演示该问题,然后看看不可绑定的挂载是如何解决该问题的。

首先,假设我们有一个有两个挂载点的系统,如下所示:

# mount | awk '{print $1, $2, $3}'
/dev/sda1 on /
/dev/sdb6 on /mntX

现在假设我们要递归地将根目录绑定挂载到几个用户主目录下。我们将为第一个用户执行此操作并检查挂装点。首先创建一个新的命名空间,在该命名空间中,我们递归地将所有挂载点标记为从属,以防止对其它挂载命名空间产生副作用:

# unshare -m sh
# mount --make-rslave /
# mount --rbind / /home/cecilia
# mount | awk '{print $1, $2, $3}'
/dev/sda1 on /
/dev/sdb6 on /mntX
/dev/sda1 on /home/cecilia
/dev/sdb6 on /home/cecilia/mntX

当我们对第二个用户重复递归绑定操作时,可看到爆炸问题:

# mount --rbind / /home/henry
# mount | awk '{print $1, $2, $3}'
/dev/sda1 on /
/dev/sdb6 on /mntX
/dev/sda1 on /home/cecilia
/dev/sdb6 on /home/cecilia/mntX
/dev/sda1 on /home/henry
/dev/sdb6 on /home/henry/mntX
/dev/sda1 on /home/henry/home/cecilia
/dev/sdb6 on /home/henry/home/cecilia/mntX

在 /home/henry 下,我们不仅递归地添加了/mntX 挂载,还添加了在上一步中创建的 /home/cecilia 的递归挂载。在为第三个用户重复该步骤并简单地计算挂载量后,很明显,爆炸是指数级的:

# mount --rbind / /home/otto
# mount | awk '{print $1, $2, $3}' | wc -l
16

通过使每个新的挂载不可绑定来避免该绑定爆炸问题。这样做的效果是,根目录的递归绑定挂载不会复制不可绑定挂载。回到最初的场景,我们为第一个用户创建一个不可绑定挂载,并通过 /proc/self/mountinfo 检查挂载:

# mount --rbind --make-unbindable / /home/cecilia
# cat /proc/self/mountinfo | grep /home/cecilia | sed 's/ - .*//' 
108 83 8:2 / /home/cecilia rw,relatime unbindable
...

在 /proc/self/mountinfo 记录的可选字段中,显示了一个带有不可绑定标记的不可绑定挂载。

现在,我们为其他两个用户创建 unbindable 递归绑定挂载:

# mount --rbind --make-unbindable / /home/henry
# mount --rbind --make-unbindable / /home/otto

在检查挂载点列表时,我们看到没有挂载点爆炸,因为不可绑定挂载没有被复制到用户的目录下:

# mount | awk '{print $1, $2, $3}'
/dev/sda1 on /
/dev/sdb6 on /mntX
/dev/sda1 on /home/cecilia
/dev/sdb6 on /home/cecilia/mntX
/dev/sda1 on /home/henry
/dev/sdb6 on /home/henry/mntX
/dev/sda1 on /home/otto
/dev/sdb6 on /home/otto/mntX

实验五、Network命名空间

Network namespace主要提供了关于网络资源的隔离,包括网络设备、IPv4和IPv6协议栈、IP路由表、防火墙、/proc/net目录、/sys/class/net目录、端口(socket)等等。一个物理的网络设备最多存在在一个network namespace中,你可以通过创建veth pair(虚拟网络设备对:有两端,类似管道,如果数据从一端传入另一端也能接收到,反之亦然)在不同的network namespace间创建通道,以此达到通信的目的。

一般情况下,物理网络设备都分配在最初的root namespace(表示系统默认的namespace,在PID namespace中已经提及)中。但是如果你有多块物理网卡,也可以把其中一块或多块分配给新创建的network namespace。需要注意的是,当新创建的network namespace被释放时(所有内部的进程都终止并且namespace文件没有被挂载或打开),在这个namespace中的物理网卡会返回到root namespace而非创建该进程的父进程所在的network namespace。

当我们说到network namespace时,其实我们指的未必是真正的网络隔离,而是把网络独立出来,给外部用户一种透明的感觉,仿佛跟另外一个网络实体在进行通信。为了达到这个目的,容器的经典做法就是创建一个veth pair,一端放置在新的namespace中,通常命名为eth0,一端放在原先的namespace中连接物理网络设备,再通过网桥把别的设备连接进来或者进行路由转发,以此网络实现通信的目的。

这部分实验完全通过命令行进行操作。

实验1:Network命名空间管理

首先创建一个名为netns1网络命名空间,需要使用管理员权限。当 使用ip 工具创建网络命名空间时,它会在 /var/run/netns 下为其创建绑定挂载;这允许命名空间一直存在,即使没有进程在其中运行,还有助于操作命名空间自身。由于网络命名空间在可用之前需要大量配置,因此这些交给了系统管理员。

yupf@yupf:~$ ip netns add netns1
mkdir /var/run/netns failed: Permission denied
yupf@yupf:~$ sudo ip netns add netns1
[sudo] yupf 的密码:
yupf@yupf:~$ ls /var/run/netns
netns1

接下来我们演示一下在新建的网络命名空间中运行网络管理命令,形式为“ip netns exec”,例如:

yupf@yupf:~$ sudo ip netns exec netns1 ip link list
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

当然也可以删除新建的网络命名空间。

yupf@yupf:~$ sudo ip netns delete netns1
yupf@yupf:~$ ls /var/run/netns
yupf@yupf:~$

实验2:Network命名空间配置

新的网络命名空间将有一个环回设备,但没有其他网络设备。每个网络设备(物理或虚拟接口、网桥等)只能存在于单个网络命名空间中。此外,不能将物理设备(连接到实际硬件的设备)分配给除 root 外的命名空间。相反,可以创建虚拟网络设备(例如虚拟以太网或 veth)并将其分配给命名空间。这些虚拟设备允许命名空间内的进程通过网络进行通信;配置、路由等决定它们可以与谁通信。

首次创建时,新命名空间中的 lo 环回设备被关闭,因此即使 ping 环回设备也会失败,打开环回设备即可ping环回:

root@yupf:~# ip netns add netns1
root@yupf:~# ip netns exec netns1 ping 127.0.0.1
connect: 网络不可达
root@yupf:~# ip netns exec netns1 ip link set dev lo up
root@yupf:~# ip netns exec netns1 ping 127.0.0.1
PING 127.0.0.1 (127.0.0.1) 56(84) bytes of data.
64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.034 ms
64 bytes from 127.0.0.1: icmp_seq=2 ttl=64 time=0.048 ms

但是此时netns1命名空间仍不能与根命名空间通信。为此,需要创建和配置虚拟以太网设备,首先创建一对已经连接的虚拟以太网,即发送到veth0的数据包将被veth1接收,反之亦然,然后将将 veth1 分配给 netns1 命名空间;最后需要给两个设备配置ip地址。

root@yupf:~# ip link add veth0 type veth peer name veth1
root@yupf:~# ip link set veth1 netns netns1
root@yupf:~# ip netns exec netns1 ifconfig veth1 10.1.1.1/24 up
root@yupf:~# ifconfig veth0 10.1.1.2/24 up
root@yupf:~# ifconfig veth0
veth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 10.1.1.2  netmask 255.255.255.0  broadcast 10.1.1.255
        inet6 fe80::b861:3bff:fef7:c0dc  prefixlen 64  scopeid 0x20<link>
        ether ba:61:3b:f7:c0:dc  txqueuelen 1000  (以太网)
        RX packets 9  bytes 726 (726.0 B)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 51  bytes 6966 (6.9 KB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0
root@yupf:~# ip netns exec netns1 ifconfig veth1
veth1: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 10.1.1.1  netmask 255.255.255.0  broadcast 10.1.1.255
        inet6 fe80::a8e8:49ff:fe5a:ad02  prefixlen 64  scopeid 0x20<link>
        ether aa:e8:49:5a:ad:02  txqueuelen 1000  (以太网)
        RX packets 54  bytes 7230 (7.2 KB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 10  bytes 796 (796.0 B)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

接下来这两个位于不同命名空间的设备就可以进行通信了。

root@yupf:~# ping 10.1.1.1
PING 10.1.1.1 (10.1.1.1) 56(84) bytes of data.
64 bytes from 10.1.1.1: icmp_seq=1 ttl=64 time=0.087 ms
64 bytes from 10.1.1.1: icmp_seq=2 ttl=64 time=0.072 ms
^C
--- 10.1.1.1 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1004ms
rtt min/avg/max/mdev = 0.072/0.079/0.087/0.011 ms
root@yupf:~# ip netns exec netns1 ping 10.1.1.2
PING 10.1.1.2 (10.1.1.2) 56(84) bytes of data.
64 bytes from 10.1.1.2: icmp_seq=1 ttl=64 time=0.065 ms
64 bytes from 10.1.1.2: icmp_seq=2 ttl=64 time=0.074 ms
^C
--- 10.1.1.2 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1005ms
rtt min/avg/max/mdev = 0.065/0.069/0.074/0.009 ms

实验六、User命名空间

User namespace主要隔离了安全相关的标识符(identifiers)和属性(attributes),包括用户ID、用户组ID、root目录、key(指密钥)以及特殊权限。说得通俗一点,一个普通用户的进程通过clone()创建的新进程在新user namespace中可以拥有不同的用户和用户组。这意味着一个进程在容器外属于一个没有特权的普通用户,但是他创建的容器进程却属于拥有所有权限的超级用户

实验1:User命名空间创建

要使用User命名空间,只需要把“CLONE_NEWUSER”标记添加到“clone”的调用中即可。

int child_pid = clone(child_main, child_stack+STACK_SIZE, CLONE_NEWUSER | SIGCHLD, NULL);

创建用户命名空间不需要使用特权用户,所有用户名称空间都是使用非特权用户ID=1000创建的。demo_userns.c在一个新的User命名空间中创建一个子线程,该子线程显示了eUID、eGID和其capabilities。

$ id -u
1000
$ id -g
1000
$ ./demo_userns
eUID = 65534; eGID = 65534; capabilities: ....

通过验证我们可以得到以下信息。

  • user namespace被创建后,第一个进程被赋予了该namespace中的全部权限,这样这个init进程就可以完成所有必要的初始化工作,而不会因权限不足而出现错误。
  • 可以看到namespace内部看到的UID和GID已经与外部不同了,默认显示为65534,表示尚未与外部namespace用户映射(如果用户ID在名称空间内没有映射,则返回用户ID的系统调用将返回文件/ proc / sys / kernel / overflowuid中定义的值,在标准系统上,该值默认为65534)。我们需要对user namespace内部的这个初始user和其外部namespace某个用户建立映射,这样可以保证当涉及到一些对外部namespace的操作时,系统可以检验其权限(比如发送一个信号量或操作某个文件)。同样用户组也要建立映射。
  • 还有一点虽然不能从输出中看出来,但是值得注意。用户在新namespace中有全部权限,但是他在创建他的父namespace中不含任何权限。就算调用和创建他的进程有全部权限也是如此。所以哪怕是root用户调用了clone()在user namespace中创建出的新用户在外部也没有任何权限。
  • 最后,user namespace的创建其实是一个层层嵌套的树状结构。最上层的根节点就是root namespace,新创建的每个user namespace都有一个父节点user namespace以及零个或多个子节点user namespace,这一点与PID namespace非常相似。

实验2:映射User ID和Group ID

创建新用户名称空间后的第一步就是定义用于将在该名称空间中创建的进程的用户ID和组ID的映射。通过在/proc/[pid]/uid_map和/proc/[pid]/gid_map两个文件中写入对应的绑定信息可以实现这一点,格式如下。

ID-inside-ns   ID-outside-ns   length

D-inside-ns和length值一起定义了命名空间内要映射到命名空间外的ID范围。 ID-outside-ns值指定外部范围的起点。解释ID-outside-ns的方式取决于打开文件/ proc / PID / uid_map(或/ proc / PID / gid_map)的进程与进程PID是否在同一用户名称空间中:

  • 如果两个进程在同一名称空间中,则ID-outside-ns会被解释为进程PID的父用户名称空间中的用户ID(组ID)。 此处的常见情况是进程正在写入其自己的映射文件(/ proc / self / uid_map或/ proc / self / gid_map)。
  • 如果两个进程位于不同的命名空间中,则ID-outside-ns会在打开/ proc / PID / uid_map(/ proc / PID / gid_map)的进程的用户命名空间中解释为用户ID(组ID)。 然后,写进程将定义相对于其自己的用户名称空间的映射。

还是上面的程序,我们为其增加参数,使其不断循环,得到如下输出:

eUID = 65534; eGID = 65534; capabilities: = cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,cap_fseti
eUID = 65534; eGID = 65534; capabilities: = cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,

现在我们打开另外一个终端,在新的用户名称空间中为子进程创建用户ID映射:

$ ps -C demo_userns -o 'pid uid comm'
  PID   UID COMMAND
24578  1000 demo_userns
24579  1000 demo_userns
$ echo '0 1000 1' > /proc/24579/uid_map

我们再回到前一个终端:

eUID = 0; eGID = 65534; capabilities: = cap_chown,cap

换句话说,父User名称空间中的用户ID 1000(以前映射到65534)已映射到demo_userns创建的User名称空间中的用户ID 0。 从这一点来看,新的User名称空间中处理此用户ID的所有操作将看到数字0,而父User名称空间中的相应操作将看到与用户ID 1000相同的过程。对于eGID可以使用相同的操作。

实际操作过程发现写入gid_map可能会存在权限不足的问题,可以增加权限:

write /proc/29595/gid_map: Operation not permitted
需要通过下面的方式修改capability
$ sudo setcap cap_setgid,cap_setuid+ep /bin/bash
$ exec /bin/bash
$ sudo setcap cap_setgid,cap_setuid+ep userns_child_exec
$ exec userns_child_exec

关于对uid_map文件或者gid_map文件的写入有以下的规则:

  1. 定义映射是每个名称空间的一次性操作:我们只能对用户名称空间中确切一个进程的uid_map文件执行一次写入。
  2. / proc / PID / uid_map文件由创建命名空间的用户ID拥有,并且只能由该用户(或特权用户)写入。此外,必须满足以下所有要求: 首先: 写入进程必须在进程PID的用户名称空间中具有CAP_SETUID(对于gid_map为CAP_SETGID)功能。其次无论功能如何,写入进程都必须位于进程PID的用户名称空间中,或者位于进程PID的(直接)父用户名称空间中。最后以下条件之一必须为真:
    (1)写入uid_map(gid_map)的数据由单行组成,该行将(仅)将父用户名称空间中的写入进程的有效用户ID(组ID)映射到用户名称空间中的用户ID(组ID)。此规则允许用户名称空间(即clone()创建的子进程)中的初始过程为其自身的用户ID(组ID)编写映射。
    (2)该进程在父用户名称空间中具有CAP_SETUID(对于gid_map为CAP_SETGID)功能。这样的过程可以定义到父用户名称空间中任意用户ID(组ID)的映射。如前所述,新用户名称空间中的初始过程在父名称空间中没有任何功能。因此,只有父名称空间中的进程才能编写一个映射,该映射映射父用户名称空间中的任意ID。

接着进行验证,运行ns_child_exec 程序,其目的是执行一个shell在一个新的User命名空间中,当我们尝试去定义一个映射时,发生错误:

yupf@yupf:~/namespace/Exp6$ ./ns_child_exec -U bash
nobody@yupf:~/namespace/Exp6$ echo '0 1000 1' > /proc/$$/uid_map
bash: echo: 写错误: 不允许的操作

出现这个错误的原因是,当前的shell在这个新的命名空间中没有capabilities ,如下:

nobody@yupf:~/namespace/Exp6$ id -u
65534
nobody@yupf:~/namespace/Exp6$ id -g
65534
nobody@yupf:~/namespace/Exp6$ cat /proc/$$/status | egrep 'Cap(Inh|Prm|Eff)'
CapInh: 0000000000000000
CapPrm: 0000000000000000
CapEff: 0000000000000000

问题发生在执行bash shell的execve()调用上:当具有非零用户ID的进程执行execve()时,将清除该进程的capabilities。为了避免这个问题,需要在执行execve()之前在User命名空间中先创建用户ID映射。

userns_child_exec.c 程序执行与 ns_child_exec 程序执行相同的任务,并有相同的命令行界面,但它可有两个附加的命令行选项 -M 和 -G。这些选项接受用于定义新用户命名空间的用户和组 ID 映射的字符串参数。例如,以下命令将新用户命名空间中的用户 ID 1000 和组 ID 1000 映射到 0:

yupf@yupf:~/namespace/Exp6$ ./userns_child_exec -U -M '0 1000 1' -G '0 1000 1' bash
root@yupf:~/namespace/Exp6# id
uid=0(root) gid=0(root)=0(root),65534(nogroup)
root@yupf:~/namespace/Exp6# cat /proc/$$/status | egrep 'Cap(Inh|Prm|Eff)'
CapInh: 0000000000000000
CapPrm: 0000003fffffffff
CapEff: 0000003fffffffff
root@yupf:~/namespace/Exp6#

userns_child_exec 程序中有一些巧妙之处。首先,父进程(clone() 的调用者)或子进程可以更新新用户命名空间中的用户 ID 和组 ID 映射。然而,根据上述规则,子进程只能定义自身有效用户 ID 的映射。只有父进程可以定义子进程的任意用户和组 ID 的映射。此外,父进程必须具有适当的 capabilities,即 CAP_SETUID、CAP_SETGID 和(父进程打开映射文件所需的权限)CAP_DAC_OVERRIDE。

实验3:查看用户和组 ID 映射

到目前为止的示例展示了通过 /proc/PID/uid_map 和 /proc/PID/gid_map 文件来定义映射的用法。还可用这些文件查看控制进程的映射。当写入这些文件时,第二个(ID-outside-ns)值的解释取决于打开文件的进程。如果打开文件的进程与进程 PID 在同一个用户命名空间中,则 ID-outside-ns 是关于父用户命名空间定义的。如果打开文件的进程位于不同的用户命名空间中,则会根据打开文件的进程的用户命名空间定义 ID-outside-ns。

我们可以通过创建几个运行 shell 的用户命名空间,并检查命名空间中进程的 uid_map 文件来说明这一点。首先,借助运行 shell 的进程来创建新的用户命名空间:

yupf@yupf:~/namespace/Exp6$ ./userns_child_exec -U -M '0 1000 1' -G '0 1000 1' bash
root@yupf:~/namespace/Exp6# echo $$
29899
root@yupf:~/namespace/Exp6# cat /proc/29899/uid_map
         0       1000          1
root@yupf:~/namespace/Exp6# id -u
0

切换到另一个终端窗口,创建一个使用不同用户和组 ID 映射的同级用户命名空间:

$ ./userns_child_exec -U -M '200 1000 1' -G '200 1000 1' bash
$ cat /proc/self/uid_map
       200       1000          1
$ id -u
200
$ echo $$
30097

继续留在第二个终端窗口,该终端运行在第二个用户命名空间,看一下另一个用户命名空间中进程的用户 ID 映射:

$ cat /proc/29899/uid_map
         0        200          1

上述输出显示,另一个用户命名空间中的用户 ID 0 映射到此命名空间中的用户 ID 200。注意,同一个命令在另一个用户命名空间中执行时输出不同,因为内核根据从文件中读取的用户命名空间来生成 ID-outside-ns 值。

回到第一个终端,展示第二个用户命名空间中进程的的用户 ID 映射,可以看到如下相反的映射:

# cat /proc/30097/uid_map
       200          0          1

再次,此处的输出与执行于另一个用户命名空间中的相同命令的输出不同,因为 ID-outside-ns 值是根据从文件中读取的进程的用户命名空间生成的。当然,第一个命名空间中的用户 ID 0 和第二个用户命名空间中的用户 ID 200 均映射到最初的命名空间中的用户 ID 1000。可以在运行于最初的用户命名空间中的第三个 shell 执行如下命令:

$ cat /proc/29899/uid_map
         0       1000          1
$ cat /proc/30097/uid_map
       200       1000          1

实验4:用户命名空间和 capabilities

每个进程都有三组相关的 capabilities:允许的,有效的和可继承的。用户命名空间改变了解释(有效的)capabilities 的方式。首先,在特定用户命名空间中有一个 capability,允许进程操作由该命名空间管理的资源。当我们讨论用户命名空间与其他类型命名空间的交互时,将进一步讨论这一点。此外,进程是否具有特定用户命名空间中的capabilities 取决于它是否是命名空间的成员以及用户命名空间之间是否有亲缘关系。规则如下:

  • 一个进程如果是命名空间的成员,它的某个 capability 位于其有效的 capability 组中,那么它在该命名空间内有该 capability。一个进程可通过多种方式在其有效的 capability 组中获得 capabilities。最常见的是,它运行了一个授予 capabilities 的程序(set-user-ID 的程序或拥有关联文件的 capabilities 的程序),或它是通过 clone(CLONE_NEWUSER) 产生的子进程,可获得全部 capabilities。
  • 如果一个进程在用户命名空间有一个 capability,那么它的所有子(以及被删除的后代)命名空间中也有该 capability。换言之:新用户命名空间中的成员仍然会受到父名空间中的特权进程的影响。
  • 当一个用户命名空间被创建,内核会将创建该用户命名空间进程的有效用户 ID 记录为该用户命名空间的“主人”。一个进程的有效用户 ID 与用户命名空间主人的有效用户 ID 匹配,且该进程是父命名空间的成员,那么该进程会在新命名空间拥有全部的 capabilities。根据前面的规则,这些 capabilities 也会传播到所有的后代命名空间中。这意味这在创建一个新用户命名空间后,其它被父命名空间中同一用户所拥有的进程在新的命名空间也会拥有所有的 capabilities。

下面通过一个实验验证第三点(userns_setns_test.c), 该程序采用一个命令行参数:/ proc / PID / ns / user文件的路径名,用于标识用户名称空间。 该实验会在新的用户名称空间中创建一个子进程,然后在父级中创建子进程,然后该子进程试使用setns()加入命令行上指定的名称空间。 ;如上所述,setns()要求调用方在目标名称空间中具有CAP_SYS_ADMIN功能。为了进行演示,我们将该程序与上一个实验中开发的userns_child_exec.c程序结合使用。 首先,我们使用该程序启动在新的用户命名空间中运行的shell(这里使用ksh,只是创建一个独特的命名进程):

$ id -u
1000
$ readlink /proc/$$/ns/user
user:[4026531837]
$ ./userns_child_exec -U -M '0 1000 1' -G '0 1000 1' ksh
ksh$ echo $$
4628
ksh$ readlink /proc/$$/ns/user    # This shell is in a new namespace
user:[4026532318]

打开另外一个终端,并执行测试程序:

$ readlink /proc/$$/ns/user
user:[4026531837]
$ ./userns_setns_test /proc/4628/ns/use
parent: readlink("/proc/self/ns/user") ==> user:[4026531837]
parent: setns() succeeded

child:  readlink("/proc/self/ns/user") ==> user:[4026532319]
child:  setns() failed: Operation not permitted

下图展示来进程之间(黑色箭头)和命名空间之间(蓝色箭头)的亲缘关系:
在这里插入图片描述
在每个 shell 中看一下 readlink 命令的输出,可以看到当最初用户命名空间(4026531837)中的 userns_setns_test 程序运行时,父进程被创建了。这样,根据前面三条规则,因为父进程与创建新用户命名空间(4026532318)的进程有着相同的有效用户 ID(1000),所以在该用户命名空间中拥有所有的 capabilities,包括 CAP_SYS_ADMIN;因此,父进程中的 setns() 成功了。

另一方面,被 userns_setns_test 创建的子进程位于不同的命名空间(4026532319)— 运行 ksh 进程的命名空间的同级命名空间。这样,就违反了上述第二条规则,因为该命名空间不是 4026532318 命名空间的祖先。因此,该子进程在那个命名空间中没有 CAP_SYS_ADMIN capability,setns() 也会失败。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值