Linux Namespace

在Linux系统中,Namespace是在内核级别以一种抽象的形式来封装系统资源,通过将系统资源放在不同的Namespace中,来实现资源隔离的目的。不同的Namespace程序,可以享有一份独立的系统资源。Namespace的一个作用就是来实现容器。
Linux提供了系统资源的隔离机制,如下:

Namespace Flag Page Isolates
Cgroup CLONE_NEWCGROUP cgroup_namespaces Cgroup root directory
IPC CLONE_NEWIPC ipc_namespaces System V IPC,POSIX message queues 隔离进程间通信
Network CLONE_NEWNET network_namespaces Network devices,stacks, ports, etc. 隔离网络资源
Mount CLONE_NEWNS mount_namespaces Mount points 隔离文件系统挂载点
PID CLONE_NEWPID pid_namespaces Process IDs 隔离进程的ID
Time CLONE_NEWTIME time_namespaces Boot and monotonic clocks
User CLONE_NEWUSER user_namespaces User and group IDs 隔离用户和用户组的ID
UTS CLONE_NEWUTS uts_namespaces Hostname and NIS domain name 隔离主机名和域名信息

Namespace View

从3.8内核开始,用户可以用过/proc/$pid/ns/ 查看Namespace的文件信息,例如PID为12583的进程情况如下:
image.png
其中 4026532869 为Namespace ID,  如果两个进程的Namespace相同意味着它们处于同一个命名空间中。

Namespace Lifetime

如果没有任何其他因素, Namespace 将在其中的最后一个进程终止或者离开该 Namespace 时自动删除。当然也存在特殊的情况,例如下面这段操作将会使 Namespace 一直驻留。
通过挂载的方式打开文件描述符:

touch ~/mnt
mount --bind /proc/12583/mnt ~/mnt

这样就可以保留PID为12583的 Mount Namespace ,即使 12583 进程销毁或者退出,ID为 4026532869Mount Namespace 依然存在。
此外, Namespace 不会被自动删除的情况还有:

  • 是一个拥有等级体系的 Namespace ,且有一个 child Namespace
  • 是一个 user Namespace ,且拥有一个或多个 nonuser Namespace
  • 是一个 PID Namespace ,且有一个进程通过 /proc/[pid]/ns/pid_for_children 软链接引用这个 Namespace
  • 是一个 IPC Namespace , 且进程通信的 mqueue 对应挂载文件系统引用这个 Namespace
  • 是一个 PID Namespace ,且 proc(5) 对应挂载文件系统引用这个 Namespace

Namespace API

涉及到 Namespace 操作接口的API有 clone(2)setnx(2)unshare(2)ioctl(2)
**

clone(2)

这个系统调用创建一个新的独立的 Namespace 进程,函数描述如下:

int clone(int (*child_func)(void *), void *child_stack, int flags, void *arg);

通过flags参数控制创建进程的特性,例如新创建进程是否与父进程共享虚拟内存等。例如传入 CLONE_NEWNS 标志使得新创建的进程拥有独立的 Mount Namespace ,同样也可以传入多个flag使得新创建的进程拥有多种特性,
例如传入这个flags创建的新进程将同时拥有独立的 Mount NamespaceUTS NamespaceIPC Namespace

flags = CLONE_NEWNS | CLONE_NEWUTS | CLONE_NEWIPC ;

**

setns(2)

这个系统调用允许进程加入指定的 Namespace 中,它的函数描述如下:

int setns(int fd, int nstype);
  • fd参数:表示文件描述符。可以通过打开 /proc/$pid/ns/  的方式将指定的 Namespace 保留下来,也就是说可以通过文件描述符的方式索引到某个 Namespace
  • nstype 参数:用来检查 fd 关联 Namespace 是否与 nstype 表明的 Namespace 一致,如果填0则不检查该项。

unshare(2)

使该进程脱离 Namespace ,并加入到一个新的 Namespace 中。与 setnx() 不同的是, unshare() 不用关联之前存在的 Namespace ,只需要指定需要分离的 Namespace 即可,该调用会自动创建一个新 Namespace 。函数描述如下:

int unshare(int flags);

其中 flags 用于指明要分离的资源类别,它支持 flagsclone 系统调用支持的 flags 类似。

ioctl(2)

可以使用 ioctl(2) 操作发现关于 namespace 的信息。函数描述如下:

int ioctl(int fd, unsigned long request, ...);
  • fd 参数:必须是一个打开的文件描述符
  • 第二个参数:是依赖于设备的请求代码
  • 第三个参数:指向内存的无类型指针

other

unshare() 与 setns() 系统调用对 PID Namespace 处理不尽相同,当 unshare PID Namespace 时,调用进程会为它的子进程分配一个新的 PID Namespace ,但是调用进程本身不会被迁移到新的 Namespace 中,而且调用进程第一个创建的子进程在新 Namespace 中的 PID 为1,并成为新 Namespace 中的 init 进程。
setnx() 系统调用也类似,调用者进程不会进入新的 PID Namespace ,而是随后创建的子进程会进入。
那为什么创建其它的 Namespace 时 unshare() 和 setns() 会直接进入新的 Namespace ,而唯独 PID Namespace 不是如此呢?
因为调用 getpid() 函数得到的 PID 是根据调用者所在的 PID Namespace 而决定返回哪个 PID ,进入新的 PID Namespace 会导致PID产生变化。而对用户态的程序和库函数来说,它们都会认为进程的 PID 是一个常量, PID 的变化会引起这些进程崩溃。一旦程序进程创建以后,那么它的 PID Namespace 的关系就确定下来了,进程不会变更它们对应的 PID Namespace 。

IPC Namespace

IPC(Interprocess Communication) Namespace 是对进程通信的隔离,进程间通信常用的方法包括信号量、消息队列和共享内存。然而与虚拟机不同的是,容器内部进程通信对宿主机来说,实际上是具有相同 PID Namespace 的进程间的通信,因此需要一个唯一的标识符进行区别。申请IPC资源就申请这样一个全局唯一的32位ID,所以IPC Namespace中实际上包含了系统IPC标识符以及实现POSIX消息队列的文件系统。在同一个 IPC Namespace 下的进程彼此可见,而与其它的IPC Namespace下的进程则互相不可见。
目前使用 IPC Namespace 机制的系统不多,其中比较有名的有 Postgre SQL。Docker 本身是通过 socket 或 tcp 进行通信。

Network Namespace

当我们兴致勃勃地在新建的 namespace 中启动一个“Apache”进程时,却出现了“80 端口已被占用”的错误,原来主机上已经运行了一个“Apache”进程。怎么办?这就需要用到 network namespace 技术进行网络隔离。
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 中连接物理网络设备,再通过网桥把别的设备连接进来或者进行路由转发,以此网络实现通信的目的。
也许有读者会好奇,在建立起 veth pair 之前,新旧 namespace 该如何通信呢?答案是 pipe(管道)。我们以 Docker Daemon 在启动容器 dockerinit 的过程为例。Docker Daemon 在宿主机上负责创建这个 veth pair,通过 netlink 调用,把一端绑定到 docker0 网桥上,一端连进新建的 network namespace 进程中。建立的过程中,Docker Daemon 和 dockerinit 就通过 pipe 进行通信,当 Docker Daemon 完成 veth-pair 的创建之前,dockerinit 在管道的另一端循环等待,直到管道另一端传来 Docker Daemon 关于 veth 设备的信息,并关闭管道。dockerinit 才结束等待的过程,并把它的“eth0”启动起来。整个效果类似下图所示。
image.png
与其它Namespace类似,对Network Namespace的使用其实就是在创建的时候添加 CLONE_NEWNET 标识位,当然,也可以通过命令行ip创建Network Namespace。在代码中建立和测试Network Namespace比较负责,所以下面将会用ip命令直观的感受Network Namespace网络建立与配置的过程。
首先,创建一个 test_ns 的Network Namespace。

ip netns add test_ns

当 ip 命令工具创建一个 network namespace 时,会默认创建一个回环设备(loopback interface:lo),并在 /var/run/netns 目录下绑定一个挂载点,这就保证了就算 network namespace 中没有进程在运行也不会被释放,也给系统管理员对新创建的 network namespace 进行配置提供了充足的时间。
通过 ip netns exec 命令可以在新创建的 network namespace 下运行网络管理命令。

ip netns exec test_ns ip link list
3: lo: <LOOPBACK> mtu 16436 qdisc noop state DOWN
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

上面的命令为我们展示了新建的 namespace 下可见的网络链接,可以看到状态是 DOWN, 需要再通过命令去启动。可以看到,此时执行 ping 命令是无效的。

ip netns exec test_ns ping 127.0.0.1
connect: Network is unreachable

启动命令如下,可以看到启动后再测试就可以 ping 通。

ip netns exec test_ns ip link set dev lo up
ip netns exec test_ns 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_req=1 ttl=64 time=0.050 ms
...

这样只是启动了本地的回环,要实现与外部 namespace 进行通信还需要再建一个网络设备对,命令如下。

ip link add veth0 type veth peer name veth1
ip link set veth1 netns test_ns
ip netns exec test_ns ifconfig veth1 10.1.1.1/24 up
ifconfig veth0 10.1.1.2/24 up
  • 第一条命令创建了一个网络设备对,所有发送到 veth0 的包 veth1 也能接收到,反之亦然。
  • 第二条命令则是把 veth1 这一端分配到 test_ns 这个 network namespace。
  • 第三、第四条命令分别给 test_ns 内部和外部的网络设备配置 IP,veth1 的 IP 为 10.1.1.1,veth0 的 IP 为 10.1.1.2。

此时两边就可以互相连通了,效果如下。

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_req=1 ttl=64 time=0.095 ms
...

ip netns exec test_ns 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_req=1 ttl=64 time=0.049 ms
...

可以通过下面的命令查看,新的 test_ns 有着自己独立的路由和 iptables。

ip netns exec test_ns route
ip netns exec test_ns iptables -L

路由表中只有一条通向 10.1.1.2 的规则,此时如果要连接外网肯定是不可能的,你可以通过建立网桥或者 NAT 映射来决定这个问题。如果你对此非常感兴趣,可以阅读 Docker 网络相关文章进行更深入的讲解。
做完这些实验,你还可以通过下面的命令删除这个 network namespace。

ip netns delete netns1

这条命令会移除之前的挂载,但是如果 namespace 本身还有进程运行,namespace 还会存在下去,直到进程运行结束。
通过 network namespace 我们可以了解到,实际上内核创建了 network namespace 以后,真的是得到了一个被隔离的网络。但是我们实际上需要的不是这种完全的隔离,而是一个对用户来说透明独立的网络实体,我们需要与这个实体通信。所以 Docker 的网络在起步阶段给人一种非常难用的感觉,因为一切都要自己去实现、去配置。你需要一个网桥或者 NAT 连接广域网,你需要配置路由规则与宿主机中其他容器进行必要的隔离,你甚至还需要配置防火墙以保证安全等等。所幸这一切已经有了较为成熟的方案,这些会在在 Docker 后续部分进行详解。

Mount Namespace

Mount Namespace 用来隔离文件系统的挂载点,不同 Mount Namesace 的进程拥有不同的挂载点,同时也拥有了不同的文件系统视图。 Mount Namespace 是历史上第一个支持的 Namespace 。

Mount

挂载的过程是通过 mount 系统调用完成的,它有两个参数:一个是已存在的普通文件名,一个是可以直接访问的特殊文件,这个特殊文件一般用来关联一些存储卷,这个存储卷可以包含自己的目录层级和文件系统结构。
mount 的效果就像访问一个普通的文件一样访问位于其它设备上的文件系统的根目录,也就是将该设备上该目录的根节点挂到了另外一个文件系统的页节点上,达到了这个文件系统扩充容量的目的。
可以通过 /proc 文件系统查看一个进程的挂载信息:

cat /proc/$pid/mountinfo

如下图,我用该命令查看一个在Docker容器里的Java进程的挂载信息:
image.png
输出的格式如下:
qwe.png

Mount Propagation

进程在创建 mount namespace 时,会把当前的文件结构复制给新的 namespace 。新的 namespace 中所有 mount 操作都只影响自身的文件系统,而对外界不会产生任何影响。这样非常严格实现了隔离,但是某些情况下可能并不适用。例如父节点 namespace 中的进程挂载了一张 CD-ROM ,这时子节点的 namespace 拷贝的目录结构就无法自动挂载这张 CD-ROM ,因为这种操作会影响父节点的文件系统。
2006年引入了挂载传播(mount propagation)解决了这个问题,挂载传播定义了挂载对象(mount object)之间的关系,系统利用这些关系决定任何挂载对象中的挂载事件传播到其它挂载对象。所谓传播事件,就是一个挂载对象状态变化导致的其它挂载对象的挂载与解除挂载动作的事件。

  • 如果两个挂载对象具有共享关系(share relationship),那么一个挂载对象的挂载事件会传播到另一个挂载对象,反之亦然。
  • 如果两个挂载对象形成从属关系(master slave),那么一个挂载对象的挂载事件会传播到另一个挂载对象,但反之不行。在这种关系中,从属对象是事件的接受者。

一个挂载状态可以为如下的其中一种:

  • 共享状态(shared)
  • 从属状态(slave)
  • 共享/从属状态(shared and slave)
  • 私有挂载(private)
  • 不可绑定挂载(unbindable)

传播事件的挂载对象称为共享挂载(shared mount);接收传播事件的挂载对象称为从属挂载(slave mount)。既不传播也不接收传播事件的挂载对象称为私有挂载(private mount)。另一种特殊的挂载对象称为不可绑定的挂载(unbindable mount),它们与私有挂载相似,但是不允许执行绑定挂载,即创建 mount namespace 时这块文件对象不可被复制。
image.png
共享挂载的应用场景非常明显,就是为了文件数据的共享所必须的一种挂载方式;从属挂载更大的意义在于一些“只读”场景;私有挂载则是纯粹的隔离,作为独立个体存在;不可绑定挂载则有助于防止没必要的文件拷贝。

默认情况下,所有挂载都是私有的。从共享挂载克隆的挂载对象也是共享的挂载,它们互相传播挂载事件。
从属挂载克隆的挂载对象也是从属的挂载,它也从属于原来的从属挂载的主挂载对象。

mount --make-shared /mntS      # 将挂载点设置为共享关系属性
mount --make-private /mntP     # 将挂载点设置为私有关系属性
mount --make-slave /mntY       # 将挂载点设置为从属关系属性
mount --make-unbindable /mntU  # 将挂载点设置为不可绑定属性

PID Namespace

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

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

到这里,可能你已经联想到一种在外部监控 Docker 中运行程序的方法了,就是监控 Docker Daemon 所在的 PID namespace 下的所有进程即其子进程,再进行删选即可。
下面我们通过运行代码来感受一下 PID namespace 的隔离效果。修改上文的代码,加入 PID namespace 的标识位,并把程序命名为 pid.c。

int child_pid = clone(child_main, child_stack+STACK_SIZE,
           CLONE_NEWPID | CLONE_NEWIPC | CLONE_NEWUTS 
           | SIGCHLD, NULL);

编译运行可以看到如下结果。

root@local:~# gcc -Wall pid.c -o pid.o && ./pid.o
程序开始:
在子进程中!
root@NewNamespace:~# echo $$
1                      <<-- 注意此处看到 shell 的 PID 变成了 1
root@NewNamespace:~# exit
exit
已退出

打印 $$ 可以看到 shell 的 PID,退出后如果再次执行可以看到效果如下。

root@local:~# echo $$
17542

已经回到了正常状态。在子进程的 shell 中执行了 ps aux/top 之类的命令,发现还是可以看到所有父进程的 PID,那是因为我们还没有对文件系统进行隔离,ps/top 之类的命令调用的是真实系统下的 /proc 文件内容,看到的自然是所有的进程。
此外,与其他的 namespace 不同的是,为了实现一个稳定安全的容器,PID namespace 还需要进行一些额外的工作才能确保其中的进程运行顺利。

PID namespace 中的 init 进程

当我们新建一个 PID namespace 时,默认启动的进程 PID 为 1。我们知道,在传统的 UNIX 系统中,PID 为 1 的进程是 init,地位非常特殊。他作为所有进程的父进程,维护一张进程表,不断检查进程的状态,一旦有某个子进程因为程序错误成为了“孤儿”进程,init 就会负责回收资源并结束这个子进程。所以在你要实现的容器中,启动的第一个进程也需要实现类似 init 的功能,维护所有后续启动进程的运行状态。
看到这里,可能读者已经明白了内核设计的良苦用心。PID namespace 维护这样一个树状结构,非常有利于系统的资源监控与回收。Docker 启动时,第一个进程也是这样,实现了进程监控和资源回收,它就是 dockerinit。

信号与 init 进程

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() 创建进程,所以实际上并没有什么作用。
我们常说,Docker 一旦启动就有进程在运行,不存在不包含任何进程的 Docker,也就是这个道理。

挂载 proc 文件系统

如果你在新的 PID namespace 中使用 ps 命令查看,看到的还是所有的进程,因为与 PID 直接相关的 /proc 文件系统(procfs)没有挂载到与原 /proc 不同的位置。所以如果你只想看到 PID namespace 本身应该看到的进程,需要重新挂载 /proc,命令如下。

root@NewNamespace:~# mount -t proc proc /proc
root@NewNamespace:~# ps a
  PID TTY      STAT   TIME COMMAND
    1 pts/1    S      0:00 /bin/bash
   12 pts/1    R+     0:00 ps a

可以看到实际的 PID namespace 就只有两个进程在运行。
注意:因为此时我们没有进行 mount namespace 的隔离,所以这一步操作实际上已经影响了 root namespace 的文件系统,当你退出新建的 PID namespace 以后再执行 ps a 就会发现出错,再次执行 mount -t proc proc /proc 可以修复错误。

unshare() 和 setns()

unshare() 和 setns() 这两个 API在 PID namespace 中使用时,也有一些特别之处需要注意。
unshare() 允许用户在原有进程中建立 namespace 进行隔离。但是创建了 PID namespace 后,原先 unshare() 调用者进程并不进入新的 PID namespace,接下来创建的子进程才会进入新的 namespace,这个子进程也就随之成为新 namespace 中的 init 进程。
类似的,调用 setns() 创建新 PID namespace 时,调用者进程也不进入新的 PID namespace,而是随后创建的子进程进入。

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

User Namespaces

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

User namespace 是目前的六个 namespace 中最后一个支持的,并且直到 Linux 内核 3.8 版本的时候还未完全实现(还有部分文件系统不支持)。因为 user namespace 实际上并不算完全成熟,很多发行版担心安全问题,在编译内核的时候并未开启 USER_NS。实际上目前 Docker 也还不支持 user namespace,但是预留了相应接口,相信在不久后就会支持这一特性。所以在进行接下来的代码实验时,请确保你系统的 Linux 内核版本高于 3.8 并且内核编译时开启了 USER_NS(如果你不会选择,可以使用 Ubuntu14.04)。

Linux 中,特权用户的 user ID 就是 0,演示的最终我们将看到 user ID 非 0 的进程启动 user namespace 后 user ID 可以变为 0。使用 user namespace 的方法跟别的 namespace 相同,即调用 clone() 或 unshare() 时加入 CLONE_NEWUSER 标识位。老样子,修改代码并另存为 userns.c,为了看到用户权限(Capabilities) ,可能你还需要安装一下libcap-dev 包。

首先包含以下头文件以调用 Capabilities 包。

#include <sys/capability.h>
其次在子进程函数中加入 geteuid() 和 getegid() 得到 namespace 内部的 user ID,其次通过 cap_get_proc() 得到当前进程的用户拥有的权限,并通过 cap_to_text()输出。
int child_main(void* args) {
printf("在子进程中!\n");
cap_t caps;
printf("eUID = %ld;  eGID = %ld;  ",
(long) geteuid(), (long) getegid());
caps = cap_get_proc();
printf("capabilities: %s\n", cap_to_text(caps, NULL));
execv(child_args[0], child_args);
return 1;
}

在主函数的 clone() 调用中加入我们熟悉的标识符。

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

至此,第一部分的代码修改就结束了。在编译之前我们先查看一下当前用户的 uid 和 guid,请注意此时我们是普通用户。

$ id -u
1000
$ id -g
1000

然后我们开始编译运行,并进行新建的 user namespace,你会发现 shell 提示符前的用户名已经变为 nobody。

sun@ubuntu$ gcc userns.c -Wall -lcap -o userns.o && ./userns.o
程序开始:
在子进程中!
eUID = 65534;  eGID = 65534;  capabilities: = cap_chown,cap_dac_override,[...]37+ep  <<-- 此处省略部分输出,已拥有全部权限
nobody@ubuntu$

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

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

接下来我们就要进行用户绑定操作,通过在 /proc/[pid]/uid_map 和 /proc/[pid]/gid_map 两个文件中写入对应的绑定信息可以实现这一点,格式如下。

ID-inside-ns   ID-outside-ns   length

写这两个文件需要注意以下几点。

  • 这两个文件只允许由拥有该 user namespace 中 CAP_SETUID 权限的进程写入一次,不允许修改。
  • 写入的进程必须是该 user namespace 的父 namespace 或者子 namespace。
  • 第一个字段 ID-inside-ns 表示新建的 user namespace 中对应的 user/group ID,第二个字段 ID-outside-ns 表示 namespace 外部映射的 user/group ID。最后一个字段表示映射范围,通常填 1,表示只映射一个,如果填大于 1 的值,则按顺序建立一一映射。

明白了上述原理,我们再次修改代码,添加设置 uid 和 guid 的函数。

//[...]
void set_uid_map(pid_t pid, int inside_id, int outside_id, int length) {
char path[256];
sprintf(path, "/proc/%d/uid_map", getpid());
FILE* uid_map = fopen(path, "w");
fprintf(uid_map, "%d %d %d", inside_id, outside_id, length);
fclose(uid_map);
}
void set_gid_map(pid_t pid, int inside_id, int outside_id, int length) {
char path[256];
sprintf(path, "/proc/%d/gid_map", getpid());
FILE* gid_map = fopen(path, "w");
fprintf(gid_map, "%d %d %d", inside_id, outside_id, length);
fclose(gid_map);
}
int child_main(void* args) {
cap_t caps;
printf("在子进程中!\n");
set_uid_map(getpid(), 0, 1000, 1);
set_gid_map(getpid(), 0, 1000, 1);
printf("eUID = %ld;  eGID = %ld;  ",
(long) geteuid(), (long) getegid());
caps = cap_get_proc();
printf("capabilities: %s\n", cap_to_text(caps, NULL));
execv(child_args[0], child_args);
return 1;
}
//[...]

编译后即可看到 user 已经变成了 root。

$ gcc userns.c -Wall -lcap -o usernc.o && ./usernc.o
程序开始:
在子进程中!
eUID = 0;  eGID = 0;  capabilities: = [...],37+ep
root@ubuntu:~#

至此,你就已经完成了绑定的工作,可以看到演示全程都是在普通用户下执行的。最终实现了在 user namespace 中成为了 root 而对应到外面的是一个 uid 为 1000 的普通用户。

如果你要把 user namespace 与其他 namespace 混合使用,那么依旧需要 root 权限。解决方案可以是先以普通用户身份创建 user namespace,然后在新建的 namespace 中作为 root 再 clone() 进程加入其他类型的 namespace 隔离。

讲完了 user namespace,我们再来谈谈 Docker。虽然 Docker 目前尚未使用 user namespace,但是他用到了我们在 user namespace 中提及的 Capabilities 机制。从内核 2.2 版本开始,Linux 把原来和超级用户相关的高级权限划分成为不同的单元,称为 Capability。这样管理员就可以独立对特定的 Capability 进行使能或禁止。Docker 虽然没有使用 user namespace,但是他可以禁用容器中不需要的 Capability,一次在一定程度上加强容器安全性。

当然,说到安全,namespace 的六项隔离看似全面,实际上依旧没有完全隔离 Linux 的资源,比如 SELinux、 Cgroups 以及 /sys、/proc/sys、/dev/sd* 等目录下的资源。关于安全的更多讨论和讲解,会在后文中接着探讨。

UTS Namespace

UTS(UNIX Time-sharing System) Namespace提供了主机名和域名的隔离,这样每个容器就可以拥有了独立的主机名和域名,在网络上可以被视作一个独立的节点而非宿主机上的一个进程。
下面我们通过代码来感受一下 UTS 隔离的效果,首先需要一个程序的骨架,如下所示。打开编辑器创建 uts.c 文件,输入如下代码。

#define _GNU_SOURCE
#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 child_stack[STACK_SIZE];
char* const child_args[] = {
  "/bin/bash",
  NULL
};

int child_main(void* args) {
  printf("在子进程中!\n");
  execv(child_args[0], child_args);
  return 1;
}

int main() {
  printf("程序开始: \n");
  int child_pid = clone(child_main, child_stack + STACK_SIZE, SIGCHLD, NULL);
  waitpid(child_pid, NULL, 0);
  printf("已退出\n");
  return 0;
}

编译并运行上述代码,执行如下命令,效果如下。

root@local:~# gcc -Wall uts.c -o uts.o && ./uts.o
程序开始:
在子进程中!
root@local:~# exit
exit
已退出 
root@local:~#

下面,将修改代码,加入 UTS 隔离。运行代码需要 root 权限,为了防止普通用户任意修改系统主机名导致 set-user-ID 相关的应用运行出错。

//[...]
int child_main(void* arg) {
  printf("在子进程中!\n");
  sethostname("Changed Namespace", 12);
  execv(child_args[0], child_args);
  return 1;
}

int main() {
//[...]
int child_pid = clone(child_main, child_stack+STACK_SIZE,
    CLONE_NEWUTS | SIGCHLD, NULL);
//[...]
}

参考

《Namespace — Linux manual page》
《Applying mount namespace》

posted @ 2020-07-29 17:13  Mr_Zack  阅读(3717)  评论(0编辑  收藏  举报