namespace隔离资源的6个方面
namespace | 系统调用参数 | 隔离内容 | 隔离目的 |
---|---|---|---|
UTS | CLONE_NEWUTS | 主机名与域名 | 以便在网络中标识自己 |
IPC | CLONE_NEWIPC | 信号量、消息队列和共享内存 | 以便进程间通信 |
PID | CLONE_NEWPID | 进程编号 | 以便与宿主机的PID进行隔离 |
Network | CLONE_NEWNET | 网络设备、网络栈、端口等 | 以便容器有独立的IP、端口、路由等 |
Mount | CLONE_NEWNS | 挂载点(文件系统) | 以便隔离文件系统和挂载点 |
User | CLONE_NEWUSER | 用户和用户组 | 以便隔离用户权限 |
1.内核namespace操作的4中方式
namespace的API包括:
- clone()
- setns()
- unshare()
- /proc下的各种文件
为了区分哪几项进行隔离,在使用API时可以指定以下几个参数的一个或多个,通过|(位或)操作符来实现,即上表中的6个参数
1.1 通过clone()在创建进程的同时创建namespace
使用clone()来创建一个独立的namespace的进程,是最常见的做法,也是Docker使用namespace最基本的方法,调用方式如下:
int clone(int (*child_func)(void *), void *child_stack,int flags, void *arg);
clone()实际上使用Linux系统调用fork()的一种更通用的实现方式,它可以通过flags来控制使用多少功能。一共有20多种CLONE_* 的flag(标志位)参数来控制clone进程的方方面面(如是否与父进程共享虚拟内存等)
- child_func传入子进程运行的程序主函数
- child_stack传入子进程使用的栈空间
- flags表示使用哪些CLONE_* 标志位,与namespace相关的主要包括CLONE_NEWUTS、CLONE_NEWIPC、CLONE_NEWPID、CLONE_NEWNET、CLONE_NEWNS、CLONE_NEWUSER。
- args则可用于传入用户参数
1.2 查看/proc/[pid]/ns文件
用户可以在/proc/[pid]/ns
文件下看到指向不同namespace号的文件,效果如下所示,形如[4026531839]
即为namespace号
如果两个进程指向的namespace编号相同,就说明它们在同一个namespace下,否则便在不同namespace里面。/proc/[pid]/ns
里设置这些link的另外一个作用是,一旦上述link文件被打开,只要打开的文件描述符(fd)存在,那么就算该namespace下的所有进程都已经结束,这个namespace也会一直存在,后续进程也可以再加入进来。在Docker中,通过文件描述符定位和加入一个存在的namespace是最基本的方式。
另外,把/proc/[pid]/ns
目录文件使用--bind
的方式挂载起来可以起到同样的作用,命令如下:
$ touch ~/uts
$ mount --bind /proc/9762/ns/uts ~/uts
1.3通过setns()加入一个已经存在的namespace
之前提到,在进程都结束的情况下,也可以通过挂载的形式把namespace保留下来,保留namespace的目的是为以后有进程加入做准备。在Docker中,使用docker exec
命令在已经运行着的容器中执行一个新的命令,就需要用到该方法。通过setns()系统调用,进程从原先的namespace加入某个已经存在的namespace,使用方法如下。通常为了不影响进程的调用者,也为了使新加入的pid namespace
生效,会在setns()函数执行后使用clone()创建子进程继续执行命令,让原先的进程结束运行。
int setns(int fd,int nstype)
- 参数fd表示要加入namespace的文件描述符。它是一个指向
/proc/[pid]/ns
目录的文件描述符,可以通过直接打开该目录下的链接或者打开一个挂载了该目录下链接的文件得到 - 参数nstype让调用者可以检查fd指向的namespace类型是否符合实际要求。该参数为0表示不检查
为了把新加入的namespace利用起来,需要引入execve()系列函数,该函数可以执行用户命令,最常用的就是调用/bin/bash并接受参数,运行起一个shell,用法如下:
fd = open(argv[1],O_RDONLY); /* 获取namespace文件描述符 */
setns(fd,0); /* 加入新的namespace */
execvp(argv[2],&argv[2]); /* 执行程序 */
假设编译后的程序名称为setns-test
$ ./setns-test ~/uts /bin/bash # ~/uts 是通过mount --bind绑定的/proc/9762/ns/net
至此,就可以在新加入的namespace中执行shell命令了。
1.4 通过unshare()在原先进程上进行namespace隔离
unshare()与clone()很像,不同的是,unshare()运行在原先的进程上,不需要启动一个新的进程
int unshare(int flags);
调用unshare()的主要作用就是,不启动新进程就可以起到隔离的效果,相当于跳出原先的namespace进行操作。这样,就可以在原进程进行一些需要的隔离操作。Linux中自带的unshare命令,就是通过unshare系统调用()实现的。Docker目前并没有使用这个系统调用,不做展开。
clone与fork的区别:
clone与fork的调用方式不同,clone需要传入一个函数,该函数在子进程中执行
clone与fork的最大的不同在于clone不再复制父进程的栈空间,而是创建一个新的栈空间,需要分配 新的栈空间的大小,所以它不是简单的继承或复制,是全新地创建一块栈空间
2.Docker内部对namespace资源隔离使用的方式解析
每种资源在底层隔离的方式不一样,如网络资源底层就是对网络设备的隔离限制,同一个namespace只有一个网卡。
2.1 UTS namespace
UTS(UNIX Time-sharing System)namespace
提供了主机名和域名的隔离,这样每个Docker容器就可以拥有独立的主机名和域名了,在网络上可以被视作一个独立的节点,而非宿主机上的一个进程。Docker中,每个镜像基本都以自身所提供的服务名称来命名镜像的hostname,且不会对宿主机产生任何影响,其原理就是利用了UTS namespace
下面通过代码感受一下UTS的隔离效果。
#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;
}
上述代码通过新建了一个子进程,子进程是/bin/bash,父进程通过waitpid等待子进程退出。编译并运行上述代码,执行下面命令,效果如下
[root@wxtest062vm62 uts] gcc -Wall uts.c -o uts.o && ./uts.o
程序开始:
在子进程中!
[root@wxtest062vm62 uts] exit
exit
已退出
下面修改代码,加入UTS隔离。需要root权限
#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");
sethostname("NewNamespace",12);
execv(child_args[0],child_args);
return 1;
}
int main()
{
printf("程序开始:\n");
int child_pid = clone(child_main,child_stack + STACK_SIZE,CLONE_NEWUTS|SIGCHLD,NULL);
waitpid(child_pid,NULL,0);
printf("已退出\n");
return 0;
}
再次编译运行发现hostname已经变化
[root@wxtest062vm62 uts]# gcc -Wall uts.c -o uts.o && ./uts.o
程序开始:
在子进程中!
[root@NewNamespace uts]# exit
exit
已退出
[root@wxtest062vm62 uts]#
值得一提的是,会发现main函数中clone如果不加CLONE_NEWUTS参数运行上述代码,会是一样的结果。实际上,不加CLONE_NEWUTS参数进行隔离时,由于使用了sethostname函数,所以宿主机的主机名被修改了。而看到exit退出后主机名还原,是因为bash只在刚登录时读取一次UTS,不会实时读取最新的主机名。当重新登录或者使用uname命令进行查看时,就会发现产生的变化。
[root@wxtest062vm62 uts]# uname -a
Linux NewNamespace 3.10.0-957.21.3.el7.x86_64 #1 SMP Tue Jun 18 16:35:19 UTC 2019 x86_64 x86_64 x86_64 GNU/Linux
可以看到如果不加CLONE_NEWUTS的执行,这样的情况下主机名的hostname是被修改了的。
2.2 IPC namespace
进程间通信(Inter-Process Communication,IPC)涉及的IPC资源包括常见的信号量、消息队列和共享内存。申请IPC资源就申请了一个全局唯一的32位ID,所以IPC namespace中实际上包含了系统IPC标识符以及实现POSIX消息队列的文件系统。在同一个IPC namespace下的进程彼此可见,不同IPC namespace下的进程则互相不可见。
来段代码感受效果(与uts的代码相同,主要改动如下,加了CLONE_NEWIPC参数)
...
int child_pid = clone(child_main,child_stack + STACK_SIZE,CLONE_NEWIPC|CLONE_NEWUTS|SIGCHLD,NULL);
...
首先在shell中使用ipcmk -Q
命令创建一个消息队列
[root@wxtest062vm6 ipc]# ipcmk -Q
消息队列 id:0
[root@wxtest062vm6 ipc]# ipcs -q
--------- 消息队列 -----------
键 msqid 拥有者 权限 已用字节数 消息
0x6a83017c 0 root 644 0 0
然后编译运行加入了IPC namespace隔离的ipc.c,在新建的子进程中调用的shell中执行ipcs -q
查看消息队列
[root@wxtest062vm6 ipc]# gcc -Wall ipc.c -o ipc.o && ./ipc.o
程序开始:
在子进程中!
[root@NewNamespace ipc]# ipcs -q
--------- 消息队列 -----------
键 msqid 拥有者 权限 已用字节数 消息
[root@NewNamespace ipc]#
从结果显示可以发现,子进程找不到原先声明的消息队列了,实现了IPC的隔离。
2.3 PID namespace
PID namespace
隔离非常实用,它对进程PID重新标号,即两个不同namespace下的进程可以有相同的PID。每个PID都有自己的计数程序。内核为所有的PID namespace
维护了一个树状结构,最顶层的是系统初始创建的,被称为root namespace
。它创建的新的PID namespace
被称为child namespace
(树的子节点),而原先的PID namespace
就是新创建的PID namespace
的parent namespace
(树的父节点)。通过这种方式,不同的PID namespace
会形成一个层级体系。所属的父节点可以看到子节点中的进程,并可以通过信号等方式对子节点中的进程产生影响。反过来,子节点却不能看到父节点PID namespace
中的任何内容,由此会产生下面的结论:
- 每个
PID namespace
中的第一个进程"PID 1",都会像传统Linux中的init进程一样拥有特权,起特殊作用。 - 一个namespace中的进程,不可能通过kill或ptrace影响父节点或者兄弟节点中的进程,因为其他节点的PID在这个namespace中没有任何意义。
- 如果你在新的
PID namespace
中重新挂载/proc文件系统,会发现其下只显示同属一个PID namespace
中的其他进程 - 在
root namespace
中可以看到所有的进程,并且递归包含所有子节点中的进程
像这样,就可以监控Docker daemon所在的PID namespace
下的所有进程及其子进程,再进行筛选,就可以在外部监控Docker中运行程序的方法了
来段代码感受效果(与ipc的代码相同,主要改动如下,加了CLONE_NEWPID参数)
...
int child_pid = clone(child_main,child_stack + STACK_SIZE,CLONE_NEWPID|CLONE_NEWIPC|CLONE_NEWUTS|SIGCHLD,NULL);
...
编译运行看到下面的结果
[root@wxtest062vm6 pid]# gcc -Wall pid.c -o pid.o && ./pid.o
程序开始:
在子进程中!
[root@NewNamespace pid]# echo $$
1 <<--这里shell的PID变成了1
[root@NewNamespace pid]# exit
exit
已退出
[root@wxtest062vm6 pid]# echo $$
20852
这样已经回到了正常状态。如果在子进程的shell中执行了ps aux
之类的命令,发现还是可以看到所有父进程的PID,真实子进程的pid是22342
...
root 22341 20852 0 16:06 pts/1 00:00:00 ./pid.o
root 22342 22341 0 16:06 pts/1 00:00:00 /bin/bash
...
那是因为还没有对文件系统挂载点进行隔离,ps/top之类的命令调用的是真实系统下的/proc文件内容,看到的自然是所有的进程。所以,与其他的namespace不同的是,为了实现一个稳定安全的容器,PID namespace
还需要进行一些额外的工作才能确保进程顺利运行,下面逐一介绍
2.3.1 PID namespace
中的init进程
PID为1的进程是init进程,地位非常特殊。它作为所有进程的父进程,维护一张进程表,不断检查进程的状态,一旦有某个子进程因为父进程错误退出成为了“孤儿”进程,init就会负责收养这个子进程并最终回收资源,结束进程。所有在要实现的容器中,启动的第一个进程也需要实现类似init的功能,维护所有后续启动进程的运行状态。
当系统中存在树状嵌套结构的PID namespace
时,若某个子进程成为孤儿进程,收养孩子进程的责任就交给了该子进程所属的PID namespace
中的init进程。
总的来说,PID namespace
维护这样一个树状结构,有利于系统的资源监控与回收。因此如果确实需要在一个在Docker容器中运行多个进程,最先启动的命令进程应该是具有资源监控与回收等管理能力的,如bash
2.3.2 信号与init进程
内核还为PID namespace
中的init进程赋予了其他特权——信号屏蔽。如果init中没有编写处理某个信号的代码逻辑,那么与init在同一个PID namespace
下的进程(即使有超级权限)发送给它的该信号都会被屏蔽。这个功能的主要作用是防止init进程被误杀。
那么父节点PID namespace
中的进程发送同样的信号给子节点中的init进程,这会被忽略吗?父节点中发送的信号,如果不是 SIGKILL(销毁进程)或 SIGSTOP(暂停进程)也会被忽略。但是如果发送SIGKILL或SIGSTOP,子节点的init会强制执行(无法通过代码捕捉进行特殊处理),也即是说父节点中的进程有权终止子节点中的进程。
一旦init进程被销毁,同一PID namespace
中的其他进程都会受到SIGKILL信号而被销毁。理论上,该PID namespace
就不复存在了。但是如果/proc/[pid]/ns/pid
处于被挂载或者打开的状态,namespace就会被保留下来。然而,保留下来的namespace无法通过setns()或者fork()创建进程,所以实际上也没有什么用。
当一个容器内存在多个进程时,容器内的init进程可以对信号进行捕获,当SIGTERM或SIGINT等信号到来时,对其子进程做信息保存、资源回收等处理工作。在Docker daemon的源码中也可以看到类似的处理方式,当结束信号来临时,结束容器进程并回收相应资源。
2.3.3 挂载proc文件系统
前文提到,如果在新的PID namespace
中使用ps命令查看,看到的还是所有进程,因为与pid执行相关的/proc文件系统(procfs)没有挂载到一个与原/proc不同的位置。如果只想看到PID namespace
本身应该看到的进程,需要重新挂载/proc
[root@NewNamespace pid]# mount -t proc proc /proc
[root@NewNamespace pid]# ps a
PID TTY STAT TIME COMMAND
1 pts/1 S 0:00 /bin/bash
91 pts/1 R+ 0:00 ps a
[root@NewNamespace pid]#
这样实际看到就只有两个进程在运行中
注意:此时并没有进行
mount namespace
的隔离,所以该操作实际上已经影响了root namespace
的文件系统。当退出新建的PID namespace
时,再执行ps命令会出错,再次执行mount -t proc proc /proc
可以修复错误。后面还会介绍通过mount namespace
来隔离文件系统,当我们基于mount namespace
实现的容器/proc文件系统的隔离以后,就可以在Docker容器中使用ps等命令看到与PID namespace
对应的进程列表。
2.3.4 unshare()和setns()
unshare()允许用户在原有进程中建立命名空间进行隔离。但创建了PID namespace
后,原先unshare()调用者进程并不进入新的PID namespace
,接下来创建的子进程才会进入新的PID 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
。在Docker中,docker exec
会使用setns()函数加入已经存在的namespace,但是最终还是会调用clone()函数,原因就在于此(需要调用clone函数创建新的子进程)。
2.4 mount namespace
mount namespace
通过隔离文件系统挂载点对隔离文件系统提供支持,它是历史上第一个Linux namespace
,所以标志位比较特殊,就是CLONE_NEWNS。隔离后,不同mount namespace
中的文件结构发生变化也不影响,可以通过/proc/[pid]/mounts
查看到所有挂载在当前namespace中的文件系统,还可以通过/proc/[pid]/mountstats
看到mount namespace
中文件设备的统计信息,包括挂载文件的名字、文件系统类型、挂载位置等。
进程在创建mount namespace
时,会把当前的文件结构复制给新的namespace。新namespace中的所有mount操作都只影响自身的文件系统,对外界不会产生任何影响。这种做法非常严格地实现了隔离,但对某些情况可能不适用。比如父节点namespace中的进程挂载了一张CD-ROM,这时子节点namespace复制的目录结构是无法自动挂载上这张CD-ROM的,因为这种操作会影响到父节点的文件系统。
挂载传播(mount propagation)解决了这个问题,挂载传播定义了挂载对象(mount object)之间的关系,这样的关系包括共享关系和从属关系,系统用这些关系决定任何挂载对象中的挂载事件如何传播到其他挂载对象。
- 共享关系(share relationship)。如果两个挂载对象具有共享关系,那么一个挂载对象中的挂载事件会传播到另一个挂载对象,反之亦然。
- 从事关系(slave relationship)。如果两个挂载对象具有从属关系,那么一个挂载对象中的挂载事件会传播到另一个挂载对象,但是反之不行;在这种关系中,从属对象是事件的接收者。
一个挂载状态可能为以下一种:
- 共享挂载(share)
- 从属挂载(slave)
- 共享/从属挂载(share and slave)
- 私有挂载(private)
- 不可绑定挂载(unbindable)
传播事件的挂载对象称为共享挂载;接收传播事件的挂载对象称为从属挂载;同属兼有前述两者特征的对象称为共享/从属挂载;既不传播也不接收传播事件的挂载对象称为私有挂载;另一种特殊的挂载对象称为不可绑定挂载,它们与私有挂载相似,但是不允许执行绑定挂载,即创建mount namespace
时这块文件对象不可被复制。通过下图可以看出它们的状态变化。
最上层的mount namespace
下的/bin目录与child namespace
通过master slave
方式进行挂载传播,当mount namespace
中的/bin目录发生变化时,发生的挂载事件能够自动传播到child namespace
中;/lib目录使用完全的共享挂载传播,各namespace之间发生的变化都会互相影响;/proc目录使用私有挂载传播的方式,各mount namespace
之间相互隔离;最后的/root目录一般都是管理员所有,不能让其他的mount namespace
挂载绑定
默认情况下,所有挂载状态都是私有的。
设置为共享挂载的命令如下。
mount --make-shared <mount-object>
从共享挂载状态的挂载对象克隆的挂载对象,其状态也是共享,它们相互传播挂载事件。
设置为从属挂载的命令如下。
mount --make-slave <shared-mount-object>
来源于从属挂载对象克隆的挂载对象也是从属的挂载,它也从属于原来的从属挂载的主挂载对象
将一个从属挂载对象设置为共享/从属挂载,可以执行如下命令,或者将其移动到一个共享挂载对象下。
mount --make-shared <shared-mount-object>
如果想把修改过的挂载对象重新标记为私有的,可以执行如下命令
mount --make-private <mount-object>
通过执行一下命令,可以将挂载对象标记为不可绑定的。
mount --make-unbindable <mount-object>
在代码中实现mount namespace
隔离与其他namespace类似,加上CLONE_NEWNS标志位即可。
...
int child_pid = clone(child_main,child_stack + STACK_SIZE,CLONE_NEWNS|CLONE_NEWPID|CLONE_NEWIPC|CLONE_NEWUTS|SIGCHLD,NULL);
...
CLONE_NEWNS生效以后,子进程进行的挂载与卸载操作都将只作用于这个mount namespace
,因此在上文中提到的处理单独PID namespace
隔离中的进程在加上mount namespace
隔离之后,即使重新挂载了/proc文件系统,进程退出后,root mount namespace
(主机)的/proc文件系统是不会被破坏的。
[root@NewNamespace mount]# mount -t proc proc /proc
[root@NewNamespace mount]# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 17:29 pts/1 00:00:00 /bin/bash
root 95 1 0 17:29 pts/1 00:00:00 ps -ef
[root@NewNamespace mount]# exit
已退出
[root@wxtest062vm6 mount]# ps -ef
...
#这里没有执行出错,还是正确的
2.5 network namespace
当我们在容器中启动一个Apache进程时,出现了“80端口已经被占用”的错误,原来主机上已经运行了一个Apache进程,这是就需要借助network namespace
技术来进行网络隔离。
network namespace
主要提供了关于网络资源的隔离,包括网络设备IPv4和IPv6协议栈、IP路由表、防火墙(iptables)、/proc/net目录、/sys/class/net目录、套接字(socket)等。一个物理的网络设备最多存在于一个network namespace
中,可以通过创建veth pair
(虚拟网络设备对:有两段,类似管道,如果数据从一端传入另一端也能接收到,反之亦然)在不同的network namespace
间创建通道,以达到通信的目的。
一般情况下,物理网络设备都分配在最初的root namespace
(表示系统默认的namespace)中。但是如果有多块物理网卡,也可以把其中一块或多块分配给新创建的network namespace
。需要注意的是,当新创建的network namespace
被释放时(所有内部的进程都终止并且namespace文件没有挂载或被打开),在这个namespace中的物理网卡会返回到root namespace
,而非创建该进程的父进程所在的network namespace
。
当说到network namespace
时,指的未必是真正的网络隔离,而是把网络独立出来,给外部用户一种透明的感觉,仿佛在与一个独立实体网络进行通信。为了达到该目的,容器的经典做法就是创建一个veth pair
,一端放置在新的namespace中,通常命名为eth0,一端防止原先的namespace中连接物理网络设备,再通过把多个设备接入网桥或者进行路由转发,来实现通信的目的。
在建立起veth pair
之前,新旧namespace该如何通信呢?答案是pipe(管道)。以Docker daemon启动容器的过程为例,假设容器内初始化的进程称为init。Docker daemon在宿主机上负责创建这个veth pair
,把一端绑定到docker0网桥上,另一端接入新建的network namespace
进程中。这个过程执行期间,Docker daemon和init就通过pipe进行通信。具体来说,就是在Docker daemon完成veth pair
的创建之前,init在管道的另一端循环等待,直到管道另一端传来Docker daemon关于veth设备的信息,并关闭管道。init才结束等待的过程,并把它的“eth0”启动起来。整个结构如下图所示
network的隔离代码演示如下
...
int child_pid = clone(child_main,child_stack + STACK_SIZE,CLONE_NEWNET|CLONE_NEWNS|CLONE_NEWPID|CLONE_NEWIPC|CLONE_NEWUTS|SIGCHLD,NULL);
...
执行结果如下
[root@wxtest062vm6 network]# gcc -Wall network.c -o network.o && ./network.o
程序开始:
在子进程中!
[root@NewNamespace network]# ifconfig
[root@NewNamespace network]# route
Kernel IP routing table
Destination Gateway Genmask Flags Metric Ref Use Iface
[root@NewNamespace network]# exit
已退出
[root@wxtest062vm6 network]# ifconfig
...
docker0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
...
可以看到网络资源已经被隔离起来了。
2.6 user namespace
user namespace
主要隔离了安全相关的标识符(identifier)和属性(attribute),包括用户ID、用户组ID、root目录、key(指密钥)以及特殊权限。通俗地讲,一个普通用户的进程通过clone()创建的新进程在新user namespace
中可以拥有不同的用户和用户组。这意味着一个进程在容器外属于一个没有特权的普通用户,但是它创建的容器进程却属于拥有所有权限的超级用户,这个技术为容器提供了很大自由。
Linux中,特殊的user ID就是0。使用user namespace
的方法跟别的namespace相同荣,即调用clone()或unshare()时加入CLONE_NEWUSER标志位。
用代码来演示一下隔离效果,需要在子进程函数中加入geteuid()和getegid()得到namespace内部的user ID,通过cap_get_proc()得到当前进程用户拥有的权限,并通过cap_to_text()输出
#define _GNU_SOURCE
#include <sys/types.h>
#include <sys/capability.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");
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;
}
int main()
{
printf("程序开始:\n");
int child_pid = clone(child_main,child_stack + STACK_SIZE,CLONE_NEWUSER|SIGCHLD,NULL);
waitpid(child_pid,NULL,0);
printf("已退出\n");
return 0;
}
在编译之前先查看一下当前用户的uid和guid,
[pigff@wxtest062vm6 user]# id -u
1000
[pigff@wxtest062vm6 user]# id -g
1000
然后开始编译运行。
[pigff@wxtest062vm6 user]# gcc user.c -Wall -lcap -o user.o && ./user.o
程序开始:
在子进程中!
eUID = 65534; eGID = 65534; capabilities: = cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_linux_immutable,cap_net_bind_service,cap_net_broadcast,cap_net_admin,cap_net_raw,cap_ipc_lock,cap_ipc_owner,cap_sys_module,cap_sys_rawio,cap_sys_chroot,cap_sys_ptrace,cap_sys_pacct,cap_sys_admin,cap_sys_boot,cap_sys_nice,cap_sys_resource,cap_sys_time,cap_sys_tty_config,cap_mknod,cap_lease,cap_audit_write,cap_audit_control,cap_setfcap,cap_mac_override,cap_mac_admin,cap_syslog,35,36+ep
[nobody@wxtest062vm6 user]#
通过验证可以得到以下信息:
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
相似
namespace实际上就是按层次关联起来,每个namespace都发源于最初的root 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,最后一个字段length
表示映射范围,通常填1,表示只映射1个,如果填大于1的值,则按顺序建立一一映射。
修改代码,添加设置uid和gid的函数
...
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* uid_map = fopen(path,"w");
fprintf(uid_map,"%d %d %d",inside_id,outside_id,length);
fclose(uid_map);
}
int child_main(void* args) {
printf("在子进程中!\n");
cap_t caps;
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了
[pigff@wxtest062vm6 user]# gcc user.c -Wall -lcap -o user.o && ./user.o
程序开始:
在子进程中!
eUID = 0; eGID = 0; capabilities: = cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_linux_immutable,cap_net_bind_service,cap_net_broadcast,cap_net_admin,cap_net_raw,cap_ipc_lock,cap_ipc_owner,cap_sys_module,cap_sys_rawio,cap_sys_chroot,cap_sys_ptrace,cap_sys_pacct,cap_sys_admin,cap_sys_boot,cap_sys_nice,cap_sys_resource,cap_sys_time,cap_sys_tty_config,cap_mknod,cap_lease,cap_audit_write,cap_audit_control,cap_setfcap,cap_mac_override,cap_mac_admin,cap_syslog,35,36+ep
[root@wxtest062vm6 user]#
至此,就已经完成了绑定的工作。这种情况可以实现在普通用户下执行,最终在user namespace
中成为root用户,对应到外部是一个uid为1000的普通的用户。
如果需要把user namespace
与其他namespace混合使用,那么依旧需要root权限。解决方案是先以普通用户的身份创建user namespace
,然后在新建的namespace中作为root,在clone()进程加入其他类型的namespace隔离。
Docker不仅使用了
user namespace
,还是用了再user namespace
中涉及的Capability
机制。管理员可以独立对指定的Capability
进行使用或禁止。Docker同时使用user namespace
和Capability
,这在很大程度上加强了容器的安全性