Namespaces

Namespaces

简单的讲就是,Linux Namespace允许用户在独立进程之间隔离CPU等资源。进程的访问权限及可见性仅限于其所在的Namespaces。因此,用户无需担心在一个Namespace内运行的进程与在另一个Namespace内运行的进程冲突。甚至可以同一台机器上的不同容器中运行具有相同PID的进程。同样的,两个不同容器中的应用程序可以使用相同的端口。

Linux Container Namespace

Namespaces用于环境隔离,Linux kernel支持的Namespace包括UTS, IPC, PID, NET, NS, USER以及新加入的CGROUP等,UTS用于隔离主机名和域名,使用标识CLONE_NEWUTSIPC用于隔离进程间通信资源如消息队列等,使用标识CLONE_NEWIPCPID隔离进程,NET用于隔离网络,NS用于隔离挂载点,USER用于隔离用户组。默认情况下,通过clone系统调用创建子进程的namespace与父进程是一致的,而你可以在clone系统调用中通过flag参数设置隔离的名字空间来隔离,当然也可以更加方便的直接用unshare命令来创建新的namespace。查看一个进程的各Namespace命令如下:

root@host:/home/vagrant# ls -ls /proc/self/ns/
0 lrwxrwxrwx 1 root root 0 May 17 22:04 cgroup -> cgroup:[4026531835]
0 lrwxrwxrwx 1 root root 0 May 17 22:04 ipc -> ipc:[4026531839]
0 lrwxrwxrwx 1 root root 0 May 17 22:04 mnt -> mnt:[4026531840]
0 lrwxrwxrwx 1 root root 0 May 17 22:04 net -> net:[4026531957]
0 lrwxrwxrwx 1 root root 0 May 17 22:04 pid -> pid:[4026531836]
0 lrwxrwxrwx 1 root root 0 May 17 22:04 user -> user:[4026531837]
0 lrwxrwxrwx 1 root root 0 May 17 22:04 uts -> uts:[4026531838]

PID Namespace

在容器中,有自己的Pid namespace,因此我们看到的只有PID1的初始进程以及它的子进程,而宿主机的其他进程容器内是看不到的。通常来说,Linux启动后它会先启动一个PID1的进程,这是系统进程树的根进程,根进程会接着创建子进程来初始化系统服务。PID namespace允许在新的namespace创建一棵新的进程树,它可以有自己的PID1的进程。在PID namespace的隔离下,子进程名字空间无法知道父进程名字空间的进程,如在Docker容器中无法看到宿主机的进程,而父进程名字空间可以看到子进程名字空间的所有进程。如图所示:

Pid Namespace

Linux内核加入PID Namespace后,对pid结构进行了修改,新增的upid结构用于跟踪namespacepid

## 加入PID Namespace之前的pid结构
 struct pid {
    atomic_t count;             /* reference counter */
    int nr;                 /* the pid value */
    struct hlist_node pid_chain;        /* hash chain */
    ...
};

## 加入PID Namespace之后的pid结构
struct upid {
    int nr;                 /* moved from struct pid */
    struct pid_namespace *ns;
    struct hlist_node pid_chain;        /* moved from struct pid */
};

struct pid {
     ...
     int level;             /* the number of upids */
     struct upid numbers[0];
};

可以通过unshare测试下PID namespace,可以看到新的bash进程它的pid namespace与父进程的不同了,而且它的pid1

root@host:/home/vagrant# unshare --fork --pid bash
root@host:/home/vagrant# echo $$
1
root@host:/home/vagrant# ls -ls /proc/self/ns/
0 lrwxrwxrwx 1 root root 0 May 19 15:24 cgroup -> cgroup:[4026531835]
0 lrwxrwxrwx 1 root root 0 May 19 15:24 ipc -> ipc:[4026531839]
0 lrwxrwxrwx 1 root root 0 May 19 15:24 mnt -> mnt:[4026531840]
0 lrwxrwxrwx 1 root root 0 May 19 15:24 net -> net:[4026531957]
0 lrwxrwxrwx 1 root root 0 May 19 15:24 pid -> pid:[4026532232]
0 lrwxrwxrwx 1 root root 0 May 19 15:24 user -> user:[4026531837]
0 lrwxrwxrwx 1 root root 0 May 19 15:24 uts -> uts:[4026531838]

僵尸进程

容器的本质实际上是一个进程,是一个视图被隔离,资源受限的进程。容器里面PID=1的进程就是应用本身,这意味着管理虚拟机等于管理基础设施,因为我们是在管理机器,但管理容器却等于直接管理应用本身。这也是之前说过的不可变基础设施的一个最佳体现,这个时候,你的应用就等于你的基础设施,它一定是不可变的。

在容器中,1号进程一般是entry point进程,针对上面这种将孤儿进程的父进程置为1号进程进而避免僵尸进程处理方式,容器是处理不了的。进而就会导致容器中在孤儿进程这种异常场景下僵尸进程无法彻底处理的窘境。所以说,容器的单进程模型的本质其实是容器中的1号进程并不具有管理多进程、多线程等复杂场景下的能力。如果一定在容器中处理这些复杂情况的,那么需要开发者对entry point进程赋予这种能力。这无疑是加重了开发者的心智负担,这是任何一项大众技术或者平台框架都不愿看到的尴尬之地。

例子里面有一个程序叫做Helloworld,这个Helloworld程序实际上是由一组进程组成的,需要注意一下,这里说的进程实际上等同于Linux中的线程。因为Linux中的线程是轻量级进程,所以如果从Linux系统中去查看Helloworld中的pstree,将会看到这个Helloworld实际上是由四个线程组成的,分别是{api、main、log、compute}。也就是说,四个这样的线程共同协作,共享Helloworld程序的资源,组成了Helloworld程序的真实工作情况。这是操作系统里面进程组或者线程组中一个非常真实的例子,以上就是进程组的一个概念。

Helloworld程序由四个进程组成,这些进程之间会共享一些资源和文件。那么现在有一个问题:假如说现在把Helloworld程序用容器跑起来,你会怎么去做?

当然,最自然的一个解法就是,我现在就启动一个Docker容器,里面运行四个进程。可是这样会有一个问题,这种情况下容器里面PID=1的进程该是谁?比如说,它应该是我的main进程,那么问题来了“谁”又负责去管理剩余的3个进程呢?

这个核心问题在于,容器的设计本身是一种“单进程”模型,不是说容器里只能起一个进程,由于容器的应用等于进程,所以只能去管理PID=1的这个进程,其他再起来的进程其实是一个托管状态。所以说服务应用进程本身就具有“进程管理”的能力。

比如说Helloworld的程序有system的能力,或者直接把容器里PID=1的进程直接改成systemd,否则这个应用,或者是容器是没有办法去管理很多个进程的。因为PID=1进程是应用本身,如果现在把这个PID=1的进程给kill了,或者它自己运行过程中死掉了,那么剩下三个进程的资源就没有人回收了,这个是非常严重的一个问题。

反过来,如果真的把这个应用本身改成了systemd,或者在容器里面运行了一个systemd,将会导致另外一个问题:使得管理容器不再是管理应用本身了,而等于是管理systemd,这里的问题就非常明显了。比如说我这个容器里面run的程序或者进程是systemd,那么接下来,这个应用是不是退出了?是不是fail了?是不是出现异常失败了?实际上是没办法直接知道的,因为容器管理的是systemd。这就是为什么在容器里面运行一个复杂程序往往比较困难的一个原因。

这里再帮大家梳理一下:由于容器实际上是一个“单进程”模型,所以如果你在容器里启动多个进程,只有一个可以作为PID=1的进程,而这时候,如果这个PID=1的进程挂了,或者说失败退出了,那么其他三个进程就会自然而然的成为孤儿,没有人能够管理它们,没有人能够回收它们的资源,这是一个非常不好的情况。

注意:Linux容器的“单进程”模型,指的是容器的生命周期等同于PID=1的进程(容器应用进程)的生命周期,而不是说容器里不能创建多进程。当然,一般情况下,容器应用进程并不具备进程管理能力,所以你通过exec或者ssh在容器里创建的其他进程,一旦异常退出(比如ssh终止)是很容易变成孤儿进程的。

反过来,其实可以在容器里面run一个systemd,用它来管理其他所有的进程。这样会产生第二个问题:实际上没办法直接管理我的应用了,因为我的应用被systemd给接管了,那么这个时候应用状态的生命周期就不等于容器生命周期。这个管理模型实际上是非常非常复杂的。

NS Namespace

NS Namespace用于隔离挂载点,不同NS Namespace的挂载点互不影响。创建一个新的Mount Namespace效果有点类似chroot,不过它隔离的比chroot更加完全。这是历史上的第一个Linux Namespace,由此得到了NS这个名字而不是用的Mount

在最初的NS Namespace版本中,挂载点是完全隔离的。初始状态下,子进程看到的挂载点与父进程是一样的。在新的Namespace中,子进程可以随意mount/umount任何目录,而不会影响到父Namespace。使用NS Namespace完全隔离挂载点初衷很好,但是也带来了某些情况下不方便,比如我们新加了一块磁盘,如果完全隔离则需要在所有的Namespace中都挂载一遍。为此,Linux2.6.15版本中加入了一个shared subtree特性,通过指定Propagation来确定挂载事件如何传播。比如通过指定MS_SHARED来允许在一个peer group(namespace和父namespace就属于同一个组)共享挂载点,mount/umount事件会传播到peer group成员中。使用MS_PRIVATE不共享挂载点和传播挂载事件。其他还有MS_SLAVENS_UNBINDABLE等选项。可以通过查看cat /proc/self/mountinfo来看挂载点信息,若没有传播参数则为MS_PRIVATE的选项。

Mount Namespace

例如你在初始namespace有两个挂载点,通过mountmake-shared /dev/sda1 /mntS设置/mntSshared类型,mount –make-private /dev/sda1 /mntP设置/mntPprivate类型。当你使用unshare -m bash新建一个namespace并在它们下面挂载子目录时,可以发现/mntS下面的子目录mount/umount事件会传播到父namespace,而/mntP则不会。

在前面例子Pid namespace隔离后,我们在新的名字空间执行ps -ef可以看到宿主机进程,这是因为ps命令是从/proc文件系统读取的数据,而文件系统我们还没有隔离,为此,我们需要在新的NS Namespace重新挂载proc文件系统来模拟类似Docker容器的功能。

root@host:/home/vagrant# unshare --pid --fork --mount-proc bash
root@host:/home/vagrant# ps -ef
UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 15:36 pts/1    00:00:00 bash
root         2     1  0 15:36 pts/1    00:00:00 ps -ef

可以看到,隔离了NS namespace并重新挂载了proc后,ps命令只能看到2个进程了,跟我们在Docker容器中看到的一致。

NET Namespace

Docker容器中另一个重要特性是网络独立(之所以不用隔离一词是因为容器的网络还是要借助宿主机的网络来通信的),使用到LinuxNET Namespace以及vetveth主要的目的是为了跨NET namespace之间提供一种类似于Linux进程间通信的技术,所以veth总是成对出现,如下面的veth0veth1。它们位于不同的NET namespace中,在veth设备任意一端接收到的数据,都会从另一端发送出去。veth实现了不同namespace的网络数据传输。

Docker Bridge

Docker中,宿主机的veth端会桥接到网桥中,接收到容器中的veth端发过来的数据后会经由网桥docker0再转发到宿主机网卡eth0,最终通过eth0发送数据。当然在发送数据前,需要经过iptables MASQUERADE规则将源地址改成宿主机ip,这样才能接收到响应数据包。而宿主机网卡接收到的数据会通过iptables DNAT根据端口号修改目的地址和端口为容器的ip和端口,然后根据路由规则发送到网桥docker0中,并最终由网桥docker0发送到对应的容器中。

Docker里面网络模式分为bridge,host,overlay等几种模式,默认是采用bridge模式网络如图所示。如果使用host模式,则不隔离直接使用宿主机网络。overlay网络则是更加高级的模式,可以实现跨主机的容器通信。

USER Namespace

user namespace用于隔离用户和组信息,在不同的namespace中用户可以有相同的UIDGID,它们之间互相不影响。父子namespace之间可以进行用户映射,如父namespace(宿主机)的普通用户映射到子namespace(容器)root用户,以减少子namespaceroot用户操作父namespace的风险。user namespace功能虽然在很早就出现了,但是直到Linux kernel 3.8之后这个功能才趋于完善。

创建新的user namespace之后第一步就是设置好usergroup的映射关系。这个映射通过设置 /proc/PID/uid_map(gid_map) 实现,格式如下,ID-inside-ns是容器内的uid/gid,而ID-outside-ns则是容器外映射的真实uid/gid。比如0 1000 1表示将真实的uid=1000映射为容器内的uid=0length为映射的范围。

ID-inside-ns   ID-outside-ns   length

不是所有的进程都能随便修改映射文件的,必须同时具备如下条件:

  • 修改映射文件的进程必须有PID进程所在user namespaceCAP_SETUID/CAP_SETGID权限。

  • 修改映射文件的进程必须是跟PID在同一个user namespace或者PID的父namespace

  • 映射文件uid_mapgid_map只能写入一次,再次写入会报错。

Docker1.10之后的版本可以通过在docker daemon启动时加上 --userns-remap=[USERNAME] 来实现USER Namespace的隔离。我们指定了username=test启动dockerd,查看subuid文件可以发现test映射的uid范围是165536165536+65536= 231072,而且在docker目录下面对应test有一个独立的目录165536.165536存在。

root@host:/home/vagrant# cat /etc/subuid
vagrant:100000:65536
test:165536:65536

root@host:/home/vagrant# ls /var/lib/docker/165536.165536/
builder/  containerd/  containers/  image/  network/  ...

运行 docker images -a 等命令可以发现在启用user namespace之前的镜像都看不到了。此时只能看到在新的user namespace里面创建的docker镜像和容器。而此时我们创建一个测试容器,可以在容器外看到容器进程的uid_map已经设置为ssj,这样容器中的root用户映射到宿主机就是test这个用户了,此时如果要删除我们挂载的/bin目录中的文件,会提示没有权限,增强了安全性。

### dockerd 启动时加了 --userns-remap=test
root@host:/home/vagrant# docker run -it -v /bin:/host/bin --name demo alpine /bin/ash
/ # rm /host/bin/which
rm: remove '/host/bin/which'? y
rm: can't remove '/host/bin/which': Permission denied

### 宿主机查看容器进程uid_map文件
root@host:/home/vagrant# CPID=`ps -ef|grep '\/bin\/ash'|awk '{printf $2}'`
root@host:/home/vagrant# cat /proc/$CPID/uid_map
         0     165536      65536

其他Namespace

UTS namespace用于隔离主机名等。可以看到在新的uts namespace修改主机名并不影响原namespace的主机名。

root@host:/home/vagrant# unshare --uts --fork bash
root@host:/home/vagrant# hostname
host
root@host:/home/vagrant# hostname modified
root@host:/home/vagrant# hostname
modified
root@host:/home/vagrant# exit
root@host:/home/vagrant# hostname
host

IPC Namespace用于隔离IPC消息队列等。可以看到,新老ipc namespace的消息队列互不影响。

root@host:/home/vagrant# ipcmk -Q
Message queue id: 0
root@host:/home/vagrant# ipcs -q

------ Message Queues --------
key        msqid      owner      perms      used-bytes   messages
0x26c3371c 0          root       644        0            0

root@host:/home/vagrant# unshare --ipc --fork bash
root@host:/home/vagrant# ipcs -q

------ Message Queues --------
key        msqid      owner      perms      used-bytes   messages

CGROUP NamespaceLinux4.6以后才支持的新namespace。容器技术使用namespacecgroup实现环境隔离和资源限制,但是对于cgroup本身并没有隔离。没有cgroup namespace前,容器中一旦挂载cgroup文件系统,便可以修改整全局的cgroup配置。有了cgroup namespace后,每个namespace中的进程都有自己的cgroup文件系统视图,增强了安全性,同时也让容器迁移更加方便。

上一页
下一页