Namespace

容器依靠namespace实现资源隔离

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下的进程可以感知彼此的变换,而对外界的进程一无所知,从而达到独立和隔离的目的。

namespace API操作

clone()函数

通过clone()函数创建一个独立的namespace进程(最常用,Docker也使用该方法)

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

clone()实际上是LInux系统调用fork()的一种通用实现方式,

  • child_func传入子进程运行的程序主函数
  • child_stack传入子进程使用的栈空间
  • flags表示使用哪些CLONE_*表示为,有CLONE_NEWipc、CLONE_NEWNS、CLONEE_NEWNET、CLONE_NEWPID、CLONE_NEWUSER、CLONE_NEWUTS。
  • args可用于传入用户参数。

fork()函数

当系统调用fork()函数时,系统会创建新的进程,为其分配资源,例如存储空间,然后把原来进程的所有值都复制到新进程种,只有少量数值与原来的进程值不通,相当于复制了本身。

fork()的神奇之处在于它仅仅被调用一次,却能够返回两次(父进程和子进程各一次),通过返回值的不通可以区分父子进程:

  • 在父进程中,fork()返回新创建子进程的进程ID。
  • 在子进程种,fork()返回0。
  • 如果出现错误,fork返回一个负值

unshare()函数

在原先进程上进行namespace隔离。unshare()运行在原先的进程上,不需要启动一个新进程。

int unshare(int flags);

调用unshare()的主要作用就是,不启动新进程就可以起到隔离的效果,相当于跳出原先的namespace进行操作。这样,就可以在原进程进行一些需要隔离的操作。Linux中自带的unshare命令,就是通过unshare()系统调用实现的。

UTS namespace

UTS(Unix Time-sharing System) namesapce提供了主机名和域名的隔离,这样每个Docker容器旧拥有独立的主机名和域名,在网络上可以视为一个独立的节点,而非宿主机上的一个进程。在Docker中,每个镜像基本都以自身所提供的服务名称来命名镜像的hostname,且不会对宿主机产生影响。

编辑以下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#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* con st child_ args [] = {
"/bin/bash", NULL
};

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

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

编译上述代码:

1
2
3
4
5
6
7
root@local:~# gcc -Wall uts.e -o uts.o &&./uts.o 
程序开始:
在子进程中!
root@local:~# exit
exit
已退出
root@local:~#

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//[... ] 
int child_ main (void* arg) {
printf("在子进程中!\n");
sethostname("NewNamespace", 12);
execv(child_args[o], child_args);
return 1;
}

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

再次运行,可以看到hostname已经变化

1
2
3
4
5
6
root@local:~# gee -Wall namespaee.e -o main.a &&./main.a 程序开始:
在子进程中!
root@NewNamespaee:~# exit
exit
已退出
root@local:~# < - 回到原来的hostname

值得一提的是, 不加CLONE_NEWUTS参数运行上述代码, 发现主机名同样改变了,并且输入exit后主机名也恢复了,似乎并没有区别。实际上,不加CLONE_NEWUTS参数进行隔离时,由于使用sethostname函数,所以宿主机的主机名被修改了。而看到exit退出后主机名还 原, 是因为bash只在刚登录时读取一次UTS, 不会实时读取最新的主机名。 当重新登录或者使用 uname命令进行查看时, 就会发现产生的变化。

IPC namespace

进程间通信(IPC)涉及的IPC资源包括常见的信号量、消息队列和共享内存。申请IPC资源就申请了一个而全局唯一的32位ID,所以IPC namesapce中实际上包含了系统IPC标识符以及实现POSIX消息队列的文件系统。在同一个IPC namesapce下的进程彼此可见,不通IPC namespac下的进程则不可见。

IPC namespace在实现代码上与UTS namespace相似, 只是标识位有所变化,需要加上CLONE_NEWIPC参数。主要改动如下:

1
2
3
4
//[...] 
int child_pid = clone(child_main, child_stack+STACK_SIZE,
CLONE_NEWIPC I CLONE_NEWUTS I SIGCHLD, NULL);
//[... ]

首先在shell中使用ipcmk -Q命令创建一个message queue。(测试方法参考自:http://crosbymichael.com/creating-containers-part-l.html 。)

1
2
3
4
5
6
7
root@local:~# ipcmk -Q Message queue id: 32769

#通过ipcs-q可以查看到巳经开启的messagequeue,序号为32769
root@local:~# ipcs -q
------Message Queues--------
key msqid owner perms used-bytes messages
OX4Cf5e29f 32769 root 644 0 0

然后可以编译运行加入了IPCnamespace隔离的ipc.c,在新建的子进程中调用的shell中执行 ipcs -q查看messagequeue。

1
2
3
4
5
6
7
8
9
10
root@local: ~# gee -Wall ipc. e -o ipe. o &&. /ipe. o 
程序开始:
在子进程中!
root@NewNamespaee:~# ipes -q
------Message Queues--------
key msqid owner perms used-bytes messages
root@NewNamespaee:~# exit
exit
已退出

从结果显示中可以发现,子进程找不到原先声明的message queue了,已经实现了IPC的 隔离。
目前使用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就是新创建的IPID 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中可以查看到所有进程,并且递归包含所有子节点中的进程。

PID namespace中的init进程

​ 传统的Unix中,PID为1的进程时init,地位非常特殊,作为所有进程的父进程,维护一张进程表,不断检查进程的状态,一旦有某个子进程因为父进程错误而成为了“孤儿”进程,init就会负责收养这个进程并最终回收资源,结束进程。所以在容器中启动的第一个进程也要实现类似init进程的功能,维护所有后续启动进程的运行状态。

​ 当系统中存在树状嵌套结构的PIDnamespace时,若某个子进程成为孤儿进程,收养该子进 程的责任就交给了该子进程所属的PIDnamespace中的init进程。

​ 如果需要在一个Docker容器中运行多个进程,最先启 动的命令进程应该是具有资源监控与回收等管理能力的,如bash

信号与init进程

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

​ 父节点中的进程发送的信号,如果不是SIGKILL (销毁进程)或SIGSTOP (暂停进程) 也会被忽略。但如果发送SIGKILL或SIGSTOP,子节点的init会强制执行(尤法通过代码捕捉进行特殊处理),也即是说父节点中的进程有权终止子节点中的进程。

挂载proc文件系统

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

此时并没有进行mount namespace的隔离,所以该操作实际上已经影响了root namespace的文件系统。当退出新建的PID namespace以后,再执行ps a时,就会发现出错,再次执行mount -t proc proc /proc可以修复错误。后面还会介绍通过mount namespace来隔离文件系统,当我们基于mount namespace实现了容器proc文件系统隔离后,我们就能在Docker容器中使用ps等命令看到与PID namespace对应的进程列表。

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的, 因为这种操作会影响 到父节点的文件系统。

​ 2006年引入的挂载传播(mount propagation)解决了这个问题,挂载传播定义了挂载对象(mount object)之间的关系,这样的关系包括共享关系和从属关系,系统用这些关系决定任何挂载对象中的挂载事件如何传播到其他挂载对象。

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

network namespace

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

​ 容器并未做到真正的网络隔离,而是把网络独立出来,容器创建一个veth paie,一段放置再新的namespace中,通常命名为eth0,一段放在原先的namespace中连接物理网络设备,再通过把多个设备接入网桥或者进行路由转发实现通信。

​ 再建立起veth pair之前,新旧namespac通过pipe(管道)实现通信。以Docker daemon启动容器过程为厘子,假设容器内初始化的进程为init。Docker daemon在宿主机上负责创建这个veth pair,把一端帮i的那个到docker0网桥上,另一端接入新建的network namespace进程上。这个过程期间,Docker daemon和init就通过pipe进行通信。Docker daemon完成veth pair的创建之前,init在短刀的另一端循环等待,直到管道另一端传来Docker daemon关于veth设备的信息,并关闭管道。init才结束等待,并把它的“veth0”启动起来。

image-20220611163422509

user namespaces

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