docker 深入理解之namespace
namespace 名称空间
docker容器主要通过资源隔离来实现的,应该具有的6种资源隔
namespace 的六项隔离
namespace | 系统调用参数 | 隔离的内容 |
UTS | CLONE_NEWUTS | 主机名域名 |
IPC | CLONE_NEWIPC | 信号量、消息队列与共享内存 |
PID | CLONE_NEWPID | 进程编号 |
Network | CLONE_NEWNET | 网络设备、网络栈、端口等 |
MOUNT | CLONE_NEWNS | 挂载点 |
USER | CLONE_NEWUSER | 用户与组 |
namespace API 操作4种方式
包括clone(),setns(),unshare()以及/proc下部分文件
1.通过clone()在创建进程的同时创建namespace,clone()实际是Linux系统调用fork()的一种更用的实现方式,它可以通过flags开控制使用多少功能。一共有20多种CLON_*的flag(标志位)参数来控制进程的方方面面(如是否与父进程共享虚拟内存等)
*1 child_func 传入子进程运行的程序主函数
*2 child_stack 传入子进程使用的栈空间
*3 args 则用于传入用户参数
查看/proc/pid/ns文件
# ls -l /proc/664/ns/ 总用量 0 lrwxrwxrwx. 1 root root 0 3月 1 08:05 ipc -> ipc:[4026531839] lrwxrwxrwx. 1 root root 0 3月 1 08:05 mnt -> mnt:[4026532432] lrwxrwxrwx. 1 root root 0 3月 1 08:05 net -> net:[4026531956] lrwxrwxrwx. 1 root root 0 3月 1 08:05 pid -> pid:[4026531836] lrwxrwxrwx. 1 root root 0 3月 1 08:05 user -> user:[4026531837] lrwxrwxrwx. 1 root root 0 3月 1 08:05 uts -> uts:[4026531838]
如果两个进程执行的namespace编号一样,说明他们在同一个namespace下,否则就会创建不同的namespace里面./proc/pid/ns 里设置这些link的另一个作用,一旦上述link文件被打开,只要打开的文件描述符(fd)存在,那么就是该namesfpace下所有进程都结束,这个namespace也会一直存在,后续进程可以加进来。在docker中,通过文件描述符定位和加入一个已存在的namespace是最基本的方式。
另外把/proc/[pid]/ns 目录文件使用 --bind 方式挂起来可以起到同样的作用
# touch ~/cx # mount --bind /proc/591/ns/uts ~/cx
通过setns()加入一个已经存在的namespace
进程在结束的情况下,也可以通过挂载的形式把namespace保留下来,保留下来的目的是为了后续进程加入做准备,在docker中 使用docker exec 命令在已经运行着的容器中执行一个新命令,就需要用到该方法。通过setens()系统调用,进程从原来的namespace加入某个已存在的namesfpace,使用该方法,通常不影响进程的调用者,也为新加入的pid namespace 生效,会在setns() 函数执行后使用clone()创建子进程执行命令,让原先的进程结束运行
int setns(int fd, int nstype);
*参数fd表示要加入的namespace的文件描述符,它是指向/proc/[pid]/ns目录的文件描述符,可以通过直接打开该目录连接或者打开一个挂载了该目录下链接的文件得到
*参数nytype表示让调用者可以检查fd指向的namespace类型是否符合实际要求,该参数为0表示不检查
为了把新加入的namespace利用起来,需要引进execve()系列函数,此函数可以执行用户命令,最常见的就是调用/bin/bash并接受参数,运行起一个shll
fd = open(argv[1], O_RDONLY); 获取namespace的文件描述符 setns(fd, 0); 加入新的namespace execvp(argv[2], &argv[2]); 执行程序
编译后程序为setns-test,加入到namespace中这些shll命令了
# ./setns-test ~/cx /bin/bash
通过unshare()在原先的进程上进行namespace隔离
注意系统调用是unshare,它与clone很像,不同的是,unshare是运行在原先的进程上,不需要启动新的进程
int unshare(int flage);
调用unshare()的主要作用就是,不启动新的进程就可以起到隔离效果,相当于跳出原先的namespace进行操作。这样可以在原先的进程进行一些隔离的操作。Linux自带的unshare命令,就是通过unshare()系统调用实现的。docker目前没有使用
fork()系统调用
系统调用函数fork()并不属于namespace的API。当程序调用fork()函数是时,系统会创建新的进程,为其分配资源,像存储数据和代码的空间,然后把原来进程的所有值都复制到新的进程中,只有少量数值与原来的进程值不同,相当于复制了本身。fork()的神奇之处在于它不仅仅被调用一次,却能返回两次(父子进程各一次),通过返回值不同就可以区分父进程与子进程,可能会有3中返回值
*在父进程中,fork()返回新创建子进程的ID
*在子进程中,fork()返回0
*如果出现错误,fork()返回一个负值
[root@mast ~]# vim cx.c #include <unistd.h> #include <stdio.h> int main (){ pid_t fpid; fpid=fork(); if (fpid < 0)printf("error in fork!"); else if (fpid==0) { printf("I am child. process id is %d\n",getpid()); } else { printf("i am parent. process id is %d\n",getpid()); } return 0; } [root@mast ~]# gcc -Wall cx.c && ./a.out i am parent. process id is 23877 I am child. process id is 23878
使用fork()后,父进程有义务监控子进程的运行状态,并在子进程退出后自己才能真正退出,否则子进程就会成为孤儿进程
UTS namespace
UTS namespace提供了主机名和域的隔离,这样docker容器就有独立的主机名和域名了,在网络中视为一个独立节点,而非宿主机上一个进程,docker中,每个镜像本身基本都以自身所提供的服务名称来命名镜像的hostname
[root@mast ~]# cat 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@mast ~]# gcc -Wall uts.c -o uts.o && ./uts.o 程序开始: 在子进程中! [root@mast ~]# exit exit 已退出
修改代码,加入UTS隔离,运行代需要root权限,以防止普通用户任意修改主机名导致set-user-ID相关的应用运行出错
[root@mast ~]# cat 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"); sethostname("network",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; } [root@mast ~]# gcc -Wall uts.c -o uts.o && ./uts.o 程序开始: 在子进程中! [root@network ~]# hostname network [root@network ~]# exit exit 已退出 [root@mast ~]# hostname mast
不加CLONE_NEWUTS,运行查看区别
[root@mast ~]# vim 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"); sethostname("network",12); 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@mast ~]# gcc -Wall uts.c -o uts.o && ./uts.o 程序开始: 在子进程中! [root@network ~]# hostname network [root@network ~]# exit exit 已退出 [root@mast ~]# hostname network
似乎没什么区别,实际上不加CLONE_NEWUTS此参数进行隔离时,由于使用sethostname函数,所以宿主机的主机名被修改,而exit退出后看到的主机名还是原来的主机名,是因为bash只在登录的时候读取一次UTS,不会实时读取最新主机名,当重新登录或者使用uname进行查看时,就会发生变化
IPC namespace 的实现
进程间通信涉及的IPC资源包括常见的信号量,消息队列 和共享内存。申请IPC资源就申请了一个全局唯一的32位id,所以IPC namespace中实际包括了系统的IPC标识符以及实现POSIX消息队列的文件系统。在不同一个IPC namespace下实现的进程互相不可见的
#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("net",12); execv(child_args[0], child_args); return 1; } int main(){ printf("程序开始:\n"); int child_pid = clone(child_main, child_stack + STACK_SIZE, CLONE_NEWIPC | CLONE_NEWUTS | SIGCHLD, NULL); waitpid(child_pid, NULL, 0); printf("已退出\n"); return 0; } [root@mast ~]# ipcmk -Q 消息队列 id:0 [root@mast ~]# ipcs -q --------- 消息队列 ----------- 键 msqid 拥有者 权限 已用字节数 消息 0x54ed33bf 0 root 644 0 0 [root@mast ~]# gcc -Wall ipc.c -o ipc.o && ./ipc.o 程序开始: 在子进程中! [root@net ~]# ipcs -q --------- 消息队列 ----------- 键 msqid 拥有者 权限 已用字节数 消息 [root@net ~]# exit exit 已退出 [root@mast ~]# ipcs -q --------- 消息队列 ----------- 键 msqid 拥有者 权限 已用字节数 消息 0x54ed33bf 0 root 644 0 0
目前使用IPC namespace机制的系统不多,比较有名的PostgreSQL。docker也使用的IPC namespace实现了容器与宿主机、容器与容器之间的IPC隔离
PID namespace 的实现
PID namespace 隔离非常实用,它对进程的PID重新标号,即两个不同的namespace下的进程可以有相同的PID。每个PID namespace 都有自己的计算程序。内核为所有的PID namespace 维护了一个树状的结构,最顶层是系统初始化时创建的,被称为root namespace ,而它创建的新的PID namespace 被称为child namespace 树的子节点 而原来的PID namespace就是新建的namespace的父节点。通过这种方式,不同的PID namespace会形成一个层级结构,所属父节点可以看子节点中的进程,可以通过信号等手段对子节点中的进程产生影响,反之子节点无法看到父节点的 PID namespace 中任何内容
* 每个 PID namespace中的第一的进程的“PID 1” ,都像传统的Linux中init进程一样拥有特殊权限
* 一个namespace中进程,不可能通过kill或prtace影响父节点或者兄弟节点中进程,因为其他节点的进程PID在此节点的namespace中没有意义,也不存在
* 如果新的PID namespace中挂载了proc文件系统,会发现其下面只显示同属一个PID namespace 中的其他进程
* root namespace 中可以看到所有进程,并且递归包含所有子节点中的进程
[root@mast ~]# vim pid.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"); sethostname("net",12); execv(child_args[0], child_args); return 1; } int main(){ printf("程序开始:\n"); int child_pid = clone(child_main, child_stack + STACK_SIZE, CLONE_NEWPID | CLONE_NEWIPC | CLONE_NEWUTS | SIGCHLD, NULL); waitpid(child_pid, NULL, 0); printf("已退出\n"); return 0; } [root@mast ~]# echo $$ 1472 [root@mast ~]# gcc -Wall pid.c -o pid.o && ./pid.o 程序开始: 在子进程中! [root@net ~]# echo $$ 1 [root@net ~]# exit exit 已退出 [root@mast ~]# echo $$ 1472
PID namespace 中的init进程
Unix 系统中,PID为1 的为init ,地位特殊,它被称为所有进程的父进程,维护一张进程表,不断检查进程状态,一旦发现某子进程因为父进程错误称为孤儿进程init就会负责收养这个子进程并最终回收资源,结束进程,所以在要实现的容器中,启动第一个进程也要有实现类似init的功能,维护后续启动进程的运行状态
信号与init进程
内核为pid namespace中init进程赋予了一些特殊权限信号屏蔽。如果init中没有编写处理某个信号的代码逻辑,那么与init在同一个PID namespace下的进程(既使有超级权限)发生给它的该信号都会屏蔽。这个功能主要防止init进程被误杀。当父节点PID namespace中的进程发送同样的信号给子节点中的init进程,这时候父节点中的进程发送的信号,如果不是SIGKILL(销毁进程)或SIGSTOP(暂停进程)也会被屏蔽。如果发送SIGKILL或SIGSTOP,子节点就会强行执行(无法通过代码捕捉进行特殊处理),也就说父节点中进程有权终止子节点中的进程
8 一旦init进程被销毁。同意PID namespace中的其他进程也随之收到SIGKILL信号而被销毁。理论上,该PID namespace也不存在了,但如果/proc/[pid]/ns/pid 处于被挂起或者打开的状态。namespace就会保留下来然而保留下来的namespace 无法通setns()或fork()创建进程,所以没什么作用
当一个容器内存在多个进程时,容器内的init进程可以对信号进行捕捉,当SIGTERM或SIGINT 等信号到来时,对其子进程信息进行保存、资源回收等处理工作,在docker daemon源码中也可以看到类似处理方式,当结束信号来临,结束容器并回收相应的资源
unshare()和setns()
unshare()允许用户在原有进程中创建名称空间进行隔离。但创建PID namespace后,原先unshare()调用者进程不进入新的PID namespace,接下来创建的子进程才会进入新的PID namespace,这个子进程也随之称为新namespace的init进程
类似的,sentns()创建新的PID namespace时 ,调用者进程也不进去新的PID namespace,而是随后创建的子进程进入
因为调用getpid()函数得到的PID是根据调用者所在的PID namespace而决定返回那个PID ,进入新的PID namespace会导致PID 发生变化。而对用户态的程序和函数库来说它们都认为进程的PID 是常量,PID变化会引起这些进程崩溃。换句话说,一旦进程创建后,那么它的PID namespace的关系就确定下来了,进程不会更改它们对应的PID namespace ,在docker exec 会使用setns()函数加入一个已经存在的命名空间,但最终还是会调用clone()函数,原因就在于此
mount namespace
mount namespace 通过隔离文件系统挂载点对隔离文件系统提供支持,同时也是第一个Linux namespace,所以标志位比较特殊,隔离后,不同mount namespace 中的文件结构发生变化也互不影响。可以通过/proc/[pid]/mounts 查看到所以namespace中文件设备统计信息,包括挂载文件的名、文件系统类型、挂载位置等
进程在创建mount namespace时, 会把当前的文件系统结构复制给新的namespace 。新的namespace中所有mount 操作都只影响自身的文件系统,对外界不产生任何影响。这种做法非常严格的实现了隔离,但对某些情况可能并不适用。例如父节点namespace中进程挂载一张光盘,这是子节点的namespace复制目录的结构是无法自动挂载上这张光盘的,因为这种操作会影响父节点的文件系统;2006 年引入的挂载传播(mount propagation)解决了这个问题,这样的关系包含共享关系和从属关系,系统用这些关系决定任何挂载对象中的挂载事件如何传播到其他挂载对象上
* 共享关系:如果两个挂载对象具有共享关系,那么挂载对象中的挂载事件会传播到另一个挂载对象上,反之亦然
* 从属挂载:如果两个挂载对象形成从属关系,那么一个挂载对象中的挂载事件会传播到另一个挂载对象上,反之不行;在这种关系里,从属对象是事件接受者
挂载状态有
名称 | |
共享挂载 | 传播事件的挂载对象 |
从属挂载 | 接受传播事件的挂载对象 |
共享/从属挂载 | 即具备传播事件也具备接受传播事件的挂载对象 |
私有挂载 | 既不传播也不接受事件的挂载对象 |
不可绑定挂载 | 另一种特殊挂载对象,他们与私有挂载相似,但不允许执行绑定挂载 |
图来自网络
最上一层的mount namespace 下的 /bin 目录 与 child namespace 通过master slave 方式进行挂载传播,当mount namespace中的/bin 目录发生变化时,发生的挂载事件能够自动传播到 child namespace中;/lib 目录使用完全共享挂载,各 namespace 之间发生变化时都会影响;proc 目录使用私有挂载传播方式,各namespace之间互相隔离;最后/root目录一般管理员所有,不能让其他namespace挂载绑定
[root@mast ~]# cat mount.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"); sethostname("net",12); execv(child_args[0], child_args); return 1; } int main(){ printf("程序开始:\n"); int child_pid = clone(child_main, child_stack + STACK_SIZE, CLONE_NEWNS | CLONE_NEWPID | CLONE_NEWIPC | CLONE_NEWUTS | SIGCHLD, NULL); waitpid(child_pid, NULL, 0); printf("已退出\n"); return 0; }
CLONE_NEWNS 生效之后子进程进行的挂载与卸载操作都将只作用于这个mount namespace 因此在上下文种提到的处于单独PID namespace隔离中的进程在加入 mount namespace的隔离后,即使该进程重新挂载了/proc文件系统,当进程退出后, root mount namespace 主机的/proc文件系统是不会被破坏的
network namespace
network namespace 主要提供网络资源的隔离,包括网络设备、ipv4 和ipv6 协议栈、IP路由表、防火墙、/proc/net 目录、/sys/class/net目录、套接字等。一个物理的网络设备最多存在于一个network namespace中,可以通过创建veth pair (虚拟网络设备对:有对端两端类似管道,如果数据从一段传入另一端也能接受到,反之亦然)不同的network namespace间创建通道,已达到通信目的
一般情况下,物理网络设备在最初的root namespace(表示系统默认的namespace)中,但如果有多个物理网卡,也可以把其中一个分配给新创建的network namespace,需要注意的是当新建的network namespace被释放是(所有的内部进程都将终止并且namespace文件没有被挂载或被打开),在这个namespace中物理网卡会返回到root namespace,而非创建该进程的父进程所在的namespace。
当然说到network namespace时, 指的未必是真正的网络隔离,而是把网络独立出来,给外部用户一种透明的感觉,仿佛在于一个独立网络通信,为达到目的,容器经典做法就是创建一个veth pair,把一端绑定到docker0网桥上,另一端接入新建的namespace中连接物理设备,再通过把多个设备接入物理网桥或者进行路由转发,来实现通信目的
在建立veth pari 之前新旧namespace通过pipe(管道)通信,以docker daemom 启动容器的过程为例,假设容器内初始化的进程称为init 。docker daemom 在宿主机上负责创建这个veth pair ,把一端邦定到docker0网桥上,另一端接入新的network namespace进程中,这个过程执行期间,docker daemom 和init就通过pipe进行通信,直到管道的另一端传来docker daemom关于veth设备的信息,并关闭管道。init进程才结束等待的过程,并把eth0 启动起来
user namespace
user namespac主要隔离了安全相关的标识符(identifier)和属性(attribute),包括用户ID、用户组ID、root目录、key以及特殊权限,通俗的讲,一个普通用户的进程通过clone()创建的新进程在新的user namespace中可以拥有不同的用户和用户组。这意味着一个进程在容器外属于一个没有特权的普通用户,但它创建的容器进程却属于拥有所有权限的超级用户,这个技能为容器提供了极大的自由。
user namespace 是目前的6个namespace中最后一个支持的,并且知道Linux内核3.8版本的时侯还未完全实现(还有部分文件系统不支持)。user namespace 实际上并不成熟,很多开发版担心安全问题,在编译内核的时候并未开启USER_NS。docker在1.10版本里对user namespace进行支持。只要在启动docker daemom的时候指定了--userns-remap,那么用户运行容器时,容器内部的root用户并不等于宿主机内的root用户,而是映射到宿主机的普通用户。
Linux中,特权用户的user的ID0 最后将看到userID 非0 的用户启动user namespace后userID 可以变成0
*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 十分相似