持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第25天。
Docker容器本质上是宿主机上的进程,通过namespace实现资源隔离,通过cgroups实现资源限制,通过copy-on-write机制实现高效的文件操作
namespace资源隔离
1、进行name space API操作的4种方式
2、UTS name space(主机名与域名隔离)
3、IPC name space(信号量、消息队列和共享内存的隔离)
4、PID name space(进程编号隔离)
5、mount name space
6、network name space
7、user name space
namespace资源隔离
事实上为了实现docker的资源隔离,总计需要完成6项隔离
1.文件系统隔离
这里最容易想到的就是chroot命令,即命令使用后给用户的感受就是根目录/的挂载点切换了。
2.网络的隔离
为了在分布式环境下进行通信和定位,容器需要有独立的IP、端口、路由等。
3.独立的主机名
为了在网络中标识自己。
4.进程间通信的隔离。
5.用户权限的隔离
对用户和用户组的隔离。
6.PID的隔离
运行在容器中的应用需要有进程号,自然也需要与宿主机中的PID进行隔离。
以上的6项隔离,Linux内核中提供了这6种namespace隔离的系统调用,如下表
| namespace | 系统调用参数 | 隔离内容 |
|---|---|---|
| UTS | CLONE_NEWUTS | 主机名与域名 |
| IPC | CLONE_NEWIPC | 信号量、消息队列和共享内存 |
| PID | CLONE_NEWPID | 进程编号 |
| Network | CLONE_NEWNET | 网络设备、网络栈、端口等 |
| Mount | CLONE_NEWNS | 挂载点(文件系统) |
| User | CLONE_NEWUSER | 用户和用户组 |
事实上Linux内核实现namespace的主要目的之一就是实现轻量级虚拟化服务,在同一个namespace下的进程可以感知彼此的变化,但是对外界的进程一无所知,于是让容器里的进程产生错觉,认为自己处于一个独立的系统环境中,从而达到独立和隔离的目的。
1、进行namespace API操作的4种方式
namespace的API包括了四种,分别为clone( )、setns( )、unshare( )以及/proc下的部分文件
-
clone( )
使用它来创建一个独立namespace的进程是最常见的做法,也是最基本的方法。 -
查看/proc/[pid]/ns文件
从3.8版本的内核开始,用户可以在此目录下看到指向不同namespace号的文件,如果两个进程指向的namespace编号相同,就说明在同一个namespace下,否则不在。
一旦上述link文件被打开,只要打开的文件描述符存在,就算该namespace下的所有进程都结束,这个namespace也会一直存在,后续的进程可以加入进来。
另外此目录文件使用--bind方式挂载起来也可以有同样的作用。 -
setns( )
用于让进程从原先的namespace加入某个已经存在的namespace。
通常为了不影响进程的调用者,也为了使新加入的pid namespace生效,会在setns( )函数执行后使用clone( )创建子进程继续执行命令,让原先的进程结束运行。
int setns(int fd,int nstype); /*fd表示要加入namespace的文件描述符,是一个指向/proc/[pid]/ns目录的文件描述符,可以通过直接打开该目录下的链接或者打开一个挂载了该目录下链接的文件得到;nstype让调用者可以检查fd指向的namespace类型是否符合实际要求,为0则表示不检查*/
一般使用的过程中,还要使用execve( )系列函数,把新加入的namespace利用起来,用法如下
fd = open(argv[1],O_RDONLY); /*获取namespace文件描述符*/setns(fd,0); /*加入新的namespace*/execvp(argv[2],&argv[2]); /*执行程序*/# ./setns-test ~/uts /bin/bash /*~/uts是绑定的一个namespace目录文件*/
这段程序的作用就是在新加入的namespace中执行shell命令。
- unshare( )
在原先进程上进行namespace隔离。其实它跟clone( )很像,不同之处在于unshare运行在原先的进程上,而clone则启动一个新的进程。
int unshare(int flags);
跳出原先的namespace进行操作,就可以在原进程进行一些需要隔离的操作,Linux中的unshare命令就是通过unshare( )系统调用实现的。
Docker目前并没有使用这个系统调用。
2、UTS namespace(主机名与域名隔离)
它提供了主机名和域名的隔离,于是每个Docker容器都可以拥有独立的主机名和域名,在网络上可以被视作一个独立的节点,而不是宿主机上的一个进程。
Docker中每个镜像基本都以自身提供的服务名来命名镜像的hostname,不会对宿主机产生任何影响。
下面的程序用来对UTS有个直观的了解
#define _GNU_SOURCE #include <sys/types.h>#include <stdio.h>#include <sys/wait.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, SIGCHLD|CLONE_NEWUTS,NULL); printf("%d",child_pid); waitpid(child_pid,NULL,0); printf("已退出\n"); return 0;}
程序中必须使用root权限来执行此进程,否则clone会出错返回-1。
在程序中,进行clone产生子进程时,使用了CLONE_NEWUTS,于是在子进程中可以使用sethostname函数设置新的主机名,退出后主机名回复正常。(这里的sethostname是临时修改主机名,可以用 hostname 临时主机名 来在命令行修改)
但 是实际上不加CLONE_NEWUTS也是可以达到相同的修改主机名效果的,但是实际上这是修改了宿主机的主机名,退出后看到的hostname实际上是 假的,因为bash只在刚登陆的时候读取一次UTS,并不实时读,当执行程序时此bash一直存在,于是不会改变,但是当重新登陆又或者是使用uname 命令查看的时候,就会发生变化。
3、IPC namespace(信号量、消息队列和共享内存的隔离)
申请IPC资源就申请了一个全局唯一的32位ID,所以IPC namespace实际上包含了系统IPC标识符以及实现POSIX消息队列的文件系统。
ipcmk -Q /*创建一个消息队列*/ipcs -q /*查看已开启的消息队列*/
在将前面的代码中的clone系统调用中添加了CLONE_NEWIPC标志后,会发现新的子进程中已经不在原先的消息队列中了。
这里引发了一点疑惑,关于ipcmk这个命令可以发现它用于创建信号量、消息队列、共享内存这三个IPC方式,但是管道难道不是吗?这里为什么不提它?
其实IPC即进程间通信的方式很多,但是上面这三种是经典的方式,而管道的实现方法其实与它们不同,管道使用read和write的方式进行使用,而这三个却不是这样,所以创建的方法等都不一样。
4、PID namespace(进程编号隔离)
内核为所有的PID namespace维护了一个树状结构,最顶层的是系统初始时创建,称为root namespace,其创建的新的PID namespace称为child namespace,原先的PID namespace即为parent namespace。
parent namespace可以看到child namespace中的进程,并且可以通过信号等方式对其中的进程产生影响,但是反过来是不行的。
在进行了PID namespace的隔离以后,按理说在子进程的shell中执行ps应该是不能看到父进程的PID的,但是实际上还是能够看到,这是为什么?
这是因为对文件系统挂载点还没有进行隔离,而ps命令调用的是真实系统下/proc文件内容,于是看到的自然是所有的进程。 因此可以看出对于PID namespace与其它几个namespace比较不同,它还需要进行一些额外的工作才能确保进程顺利运行。
其实在新的PID namespace中重新挂载/proc文件系统,就会发现其只显示同属于此PID namespace中的其他进程,这就是因为重新挂载/proc文件系统。
-
PID namespace中的init进程
对于PID为1的进程init,它的地位十分重要。作为所有进程的父进程,它维护了一张进程表,不断检查进程的状态,负责收养“孤儿进程”,并最后回收资源结束进程。因此需要实现的容器中,启动的第一个进程也要有着类似于init进程的功能。
系统中如果存在树状嵌套结构的PID namespace时,如果某个子进程成为孤儿进程,需要init进程收养,那么就由此子进程所在的PID namespace的init进程进行收养。
可以看出系统维护这样一个PIDnamespace的树状结构有助于资源监控与回收。
另外,因为init进程需要能够进行资源监控与回收这样的管理能力,因此如果需要在一个容器中运行多个进程,那么就需要注意最先启动的进程应该具备这些管理能力,bash就是一个这样的程序。 -
信号与init进程
内核为PID namespace中的init进程赋予了信号屏蔽的特权,在init进程中如果没有处理某个信号的代码的话,那么分为了以下几种情况:
1.同属一个PID namespace的进程发送的信号都会被屏蔽(即使有root权限也不例外);
2.父节点发送的信号,不是SIGKILL或者SIGSTOP也会被忽略;
3.父节点发送SIGKILL或者SIGSTOP信号,子节点的init会强制执行。
此外,一个init进程被销毁,但是/proc/[pid]/ns/pid处于被挂载或者被打开状态,namespace就会被保留下来,但是无法通过setns( )或者fork( )创建进程,于是相当于没有任何作用,这个namespace也就相当于废了。
因此可以意识到init进程对于一个PIDnamespace的重要性。 -
挂载proc文件系统
前面说过在进入新的PID namespace后都需要重新挂载/proc文件系统才能够使得ps等命令只能看到当前PID namespace下的进程。
mount -t proc proc /proc
此时并没有进行mount namespace的隔离,因此这个操作实际影响到了root namespace的文件系统,在退出此容器后运行ps会出错,退出后需要再执行一次/proc的挂载才能正确运行。
这肯定不符合隔离的要求,因此mount namespace就能够解决这个问题,当我们基于mount namespace实现了容器proc文件系统隔离后,就能够正常运行。
-
unshare( ) 和 setns( )
unshare让用户在原有进程中建立命名空间进行隔离,但是对于PID namespace却有些不同。在创建了PID namespace后,原先的unshare的调用者并不进入新的namespace,接下来创建的子进程才会进入新的namespace,于是这个子进程也就成了新的namespace中的init进程。这里有些问题,在实际的man手册中我没有找到unshare的参数中有CLONE_NEWPID这个选项,是不支持PID namespace的创建吗?
setns也相同,调用者进程也不进入新的PID namespace,是随后创建的子进程进入。
这里和上面一样,也没有CLONE_NEWPID这个选项
那么为什么PID namespace会例外呢?
首先调用getpid()是根据调用者所在PID namespace而决定的,进入了一个新的PID namespace会导致PID发生变化,对于用户态的程序和库函数都认为进程的PID是一个常量,PID的变化会引起进程的崩溃。
简单来说,进程创建后,PID namespace就确定下来了,进程不会变更它们对应的PID namespace,如果变更就会引起进程崩溃。
5、mount namespace
通过隔离文件系统挂载点对隔离文件系统提供支持,另外是历史上第一个Linux namespace,这也是为什么它的标志是CLONE_NEWNS的原因。
我们可以在/proc/[pid]/mounts文件中查看到挂载在当前namespace中的所有文件系统:
在同一个目录下的mountstats中可以看到文件设备的统计信息:
进 程在创建mount namespace时,将当前的文件结构复制给新的namespace,于是在新的namespace中的所有mount操作都只影响到自身的文件系统, 对外界没有任何影响。但是对于一些情况比如父节点namespace中挂载了一个CD-ROM,子节点namespace复制的目录结构是无法自动挂载上 这张CD-ROM的,这种操作会影响到父节点的文件系统(这里我不懂为什么)。
这个问题在后面得到了解决,这个解决办法就是挂载传播的引入,其中分为了几种情况:
1.共享关系
一个挂载对象中的挂载事件会传播到另一个挂载对象。
典型例子:/lib,各namespace之间发生的变化都会互相影响。
2.从属关系
一个挂载对象中的挂载事件会传播到另一个挂载对象,但是反之不行。
典型例子:/bin,父节点的namespace中发生的变化会自动影响到子节点,但是反之不行。
3.私有挂载
既不传播也不接受传播事件的挂载对象
典型例子:/proc,相互之间并不互相影响,于是之前说的关于ps命令的情况,只要通过mount namespace就可以解决。
4.不可绑定挂载
与私有挂载类似,但是不允许执行绑定挂载。其实就是说创建mount namespace时这块文件对象不能被复制。
典型例子:/root,管理员私有,不能让其他mount namespace挂载绑定。
上图比较清晰地描述了四种挂载传播的情况。
默认情况下所有挂载状态都是私有的。
6、network namespace
提供网络资源的隔离,包括网络设备、IPv4和IPv6协议栈、IP路由表、防火墙、/proc/net目录、/sys/class/net目录、套接字(socket)等。
一个物理网络设备最多存在于一个network namespace中,但是我们不可能为了多个容器而购买多台网络设备,于是我们需要能够在network namespace间进行通信,于是类似于管道的veth pair被创造出来达到这个目的。
一般来说,物理网络设备分配在root namespace中(系统默认),如果有多块网卡就可以分配给新创建的network namespace。另外,新创建的network namespace被释放时,此物理网卡会回到root namespace,而不是它的父节点namespace。
其实network namespace并不是说真正的网络隔离,而是把网络独立出来,让用户认为自己仿佛是在与一个独立的网络实体进行通信,为了达到这个目的,一般来说就是创建veth pair,一端放在新的namespace中,命名为eth0,另一端放在原先的namespace中连接物理网络设备(docker0),最后通过把多个设备接入网桥或者路由进行转发,从而实现通信。
7、user namespace
user namespace主要隔离了安全相关的标识符和属性,其中包括了UID、GID、root目录、key(秘钥)以及特殊权限。
因此简单的来看,一个普通的进程通过clone( )创建的新进程在新的user namespace中可以拥有不同的用户和用户组,这么做的最大的意义在于在容器外一个普通用户,它创建的容器进程却属于拥有所有权限的超级用户,这点为容器技术提供了很大的自由。
下面要用一个程序来实验user namespace机制,但是在实验前需要了解一些东西。
-
Capabilities机制
实际上Docker目前并没有使用user namespace,但是使用了在user namespace中涉及的Capability机制。那么什么是Capability机制呢?Capability是从2.1版本开始的,它打破了 UNIX/LINUX中超级用户/普通用户的概念,让普通用户也能够做一些只有超级用户可以完成的工作。它与sudo不同,sudo可以配置某个用户可以 执行某个指令或更改某个文件,但是capability可以让某个程序拥有某种能力。比如它可以让一个程序能够kill掉其他进程,但是它不能挂载设备节 点到目录也不能重启系统,因为我们只指定给了它kill的能力,至于其他的能力则没有。
每 个进程拥有三组能力集,分别称为cap_effective, cap_inheritable, cap_permitted,这三组能力集都各有一串16位十六进制码,其中每一位都代表了一种capability的有无。其中 cap_permitted表示进程所拥有的最大能力集;cap_effective表示进程当前可用的能力集,可以看做是cap_permitted的 一个子集;而cap_inheitable则表示进程可以传递给其子进程的能力集。系统根据进程的cap_effective能力集进行访问控制,比如一 个进程要能够杀死别的进程,那么Linux系统的内核就会检查cap_effective中的CAP_KILL具体对应的那一个二进制位是否为1,即是否 有此能力。cap_effective为cap_permitted的子集,进程可以通过取消cap_effective中的某些能力来放弃进程的一些特 权。
图 中是/proc/[pid]/status文件的具体内容,倒数第4行至倒数第2行的三行就分别对应了三个能力集,这是1号进程init的能力集,因此可 以看到CapPrm(cap_permitted)、CapEff(cap_effective)字段为从低位开始37位全置1,这表示总共拥有37种能 力,而在capability的man手册中可以看到所有的能力,总计37种,因此init进程拥有者所有的能力,即相当于超级用户拥有所有的权限,而 CapInh(cap_inheritable)字段全为0,表示它的子进程无法继承任何capability。
在下面的实验中会查看一个进程的capabilities,需要安装libcap-dev,同时在编写程序时如果要使用到Capabilities包的函数就需要包含头文件,最后在编译的时候需要加入-lcap参数才能够编译成功。
/*这个程序就是在前面的测试程序经过修改而成,所以大部分相同*/#define _GNU_SOURCE#include <sys/types.h>#include <stdio.h>#include <sys/wait.h>#include <sched.h>#include <signal.h>#include <unistd.h>#include <sys/capability.h> /*调用Capabilities包的函数*/#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; /*Capabilities包所定义的类型,用于存放capabilities的代码*/ printf("eUID = %ld; eGID = %ld",(long)geteuid(),(long)getegid()); caps = cap_get_proc(); /*通过cap_get_proc( )来得到当前进程的用户拥有的权限*/ printf("capabilities:%s\n",cap_to_text(caps,NULL)); /*通过cap_to_text( )将获取的权限进行输出*/ execv(child_args[0],child_args); return 1;}int main(){ printf("程序开始:\n"); int child_pid = clone(child_main,child_stack + STACK_SIZE,SIGCHLD|CLONE_NEWUSER,NULL); printf("%d",child_pid); waitpid(child_pid,NULL,0); printf("已退出\n");
图中是结果,可以看到在新的进程中uid与gid都为65534,而新的进程拥有着所有的能力,我们来看一下原进程bash的特权有哪些
可以看到原进程中的三个能力集全部为0,即没有任何特权。
接下来列举一些user namespace的特性,有些在上面的实验中已经反映了出来:
1.user namespace被创建后,第一个进程被赋予了该namespace中的全部权限,从而完成所有必要的初始化工作
2.从namespace内部观察到的UID和GID与外部不同,默认显示为65534,这是由于其尚未与外部namespace用户进行映射。在接下来的操作中需要对内部的这个初始user与外部namespace的某个用户进行映射,同时用户组也需要进行映射。
3.用户在新namespace中有全部权限,但是在创建它的父namespace中不含任何权限,因此就算是root用户在user namespace中创建出的新用户在外部也没有任何权限
4.user namespace的创建其实是一个层层嵌套的树状结构,最上层的根节点必然是root namespace,新创建的每个user namespace都有父节点,这和PID namespace很相似。如下图所示。
前面的第2点特性中说过接下来需要进行用户绑定,那么如何进行用户绑定呢?通过在/proc/[pid]/uid_map和gid_map两个文件中写入对应的绑定信息就可以实现绑定,格式如下
ID-inside-ns ID-outside-ns length /*第一个字段表示新建的user namespace中对应的user/group ID;第二个字段表示namespace外部映射的user/group ID;最后一个字段表示映射范围,通常填1,表示只映射1个,如果是大于1的值,则按顺序一一映射*/
这里有几点需要注意,这两个文件只允许拥有该user namespace中CAP_SETUID权限的进程写入一次,不允许修改,另外写入的进程必须是该user namespace的父namespace或者子namespace
那么下面在原先的程序中添加用户绑定的部分
#define _GNU_SOURCE#include <sys/types.h>#include <stdio.h>#include <sys/wait.h>#include <sched.h>#include <signal.h>#include <unistd.h>#include <sys/capability.h>#define STACK_SIZE (1024*1024)static char child_stack[STACK_SIZE];char * const child_args[]={ "/bin/bash", NULL};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()); /*将路径名写入path数组*/ FILE * uid_map = fopen(path,"w"); /*创建/proc/[pid]/uid_map文件*/ 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){ printf("在子进程中!\n"); cap_t caps; set_uid_map(getpid(),0,1000,1); set_gid_map(getgid(),0,1000,1); printf("eUID = %ld; eGID = %ld\n",(long)geteuid(),(long)getegid()); caps = cap_get_proc(); printf("capabilities:%s",cap_to_text(caps,NULL)); 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|CLONE_NEWUSER,NULL); printf("%d",child_pid); waitpid(child_pid,NULL,0); printf("已退出\n"); return 0;}
可以看到在新的namespace中的第一个进程bash中显示的用户为root即uid为0,即实现了在user namespace中为root用户,而在外部为普通用户。