Docker背后的内核知识——Namespace资源隔离

本博文转载自http://www.infoq.com/cn/articles/docker-kernel-knowledge-namespace-resource-isolationhtml


Docker这么火,喜欢技术的朋友可能也会想,若是要本身实现一个资源隔离的容器,应该从哪些方面下手呢?也许你第一反应可能就是chroot命令,这条命令给用户最直观的感受就是使用后根目录/的挂载点切换了,即文件系统被隔离了。而后,为了在分布式的环境下进行通讯和定位,容器必然须要一个独立的IP、端口、路由等等,天然就想到了网络的隔离。同时,你的容器还须要一个独立的主机名以便在网络中标识本身。想到网络,顺其天然就想到通讯,也就想到了进程间通讯的隔离。可能你也想到了权限的问题,对用户和用户组的隔离就实现了用户权限的隔离。最后,运行在容器中的应用须要有本身的PID,天然也须要与宿主机中的PID进行隔离。linux

由此,咱们基本上完成了一个容器所须要作的六项隔离,Linux内核中就提供了这六种namespace隔离的系统调用,以下表所示。docker

Namespaceshell

系统调用参数ubuntu

隔离内容安全

UTSbash

CLONE_NEWUTS网络

主机名与域名socket

IPCtcp

CLONE_NEWIPC

信号量、消息队列和共享内存

PID

CLONE_NEWPID

进程编号

Network

CLONE_NEWNET

网络设备、网络栈、端口等等

Mount

CLONE_NEWNS

挂载点(文件系统)

User

CLONE_NEWUSER

用户和用户组

表 namespace六项隔离

实际上,Linux内核实现namespace的主要目的就是为了实现轻量级虚拟化(容器)服务。在同一个namespace下的进程能够感知彼此的变化,而对外界的进程一无所知。这样就可让容器中的进程产生错觉,仿佛本身置身于一个独立的系统环境中,以此达到独立和隔离的目的。

须要说明的是,本文所讨论的namespace实现针对的均是Linux内核3.8及其之后的版本。接下来,咱们将首先介绍使用namespace的API,而后针对这六种namespace进行逐一讲解,并经过程序让你亲身感觉一下这些隔离效果(参考自http://lwn.net/Articles/531114/)。

1. 调用namespace的API

namespace的API包括clone()、setns()以及unshare(),还有/proc下的部分文件。为了肯定隔离的究竟是哪一种namespace,在使用这些API时,一般须要指定如下六个常数的一个或多个,经过|(位或)操做来实现。你可能已经在上面的表格中注意到,这六个参数分别是CLONE_NEWIPC、CLONE_NEWNS、CLONE_NEWNET、CLONE_NEWPID、CLONE_NEWUSER和CLONE_NEWUTS。

(1)经过clone()建立新进程的同时建立namespace

使用clone()来建立一个独立namespace的进程是最多见作法,它的调用方式以下。

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

clone()其实是传统UNIX系统调用fork()的一种更通用的实现方式,它能够经过flags来控制使用多少功能。一共有二十多种CLONE_*的flag(标志位)参数用来控制clone进程的方方面面(如是否与父进程共享虚拟内存等等),下面外面逐一讲解clone函数传入的参数。

  • 参数child_func传入子进程运行的程序主函数。

  • 参数child_stack传入子进程使用的栈空间

  • 参数flags表示使用哪些CLONE_*标志位

  • 参数args则可用于传入用户参数

在后续的内容中将会有使用clone()的实际程序可供你们参考。

(2)查看/proc/[pid]/ns文件

从3.8版本的内核开始,用户就能够在/proc/[pid]/ns文件下看到指向不一样namespace号的文件,效果以下所示,形如[4026531839]者即为namespace号。

$ ls -l /proc/$$/ns         <<-- $$ 表示应用的PID
total 0
lrwxrwxrwx. 1 mtk mtk 0 Jan  8 04:12 ipc -> ipc:[4026531839]
lrwxrwxrwx. 1 mtk mtk 0 Jan  8 04:12 mnt -> mnt:[4026531840]
lrwxrwxrwx. 1 mtk mtk 0 Jan  8 04:12 net -> net:[4026531956]
lrwxrwxrwx. 1 mtk mtk 0 Jan  8 04:12 pid -> pid:[4026531836]
lrwxrwxrwx. 1 mtk mtk 0 Jan  8 04:12 user->user:[4026531837]
lrwxrwxrwx. 1 mtk mtk 0 Jan  8 04:12 uts -> uts:[4026531838]

若是两个进程指向的namespace编号相同,就说明他们在同一个namespace下,不然则在不一样namespace里面。/proc/[pid]/ns的另一个做用是,一旦文件被打开,只要打开的文件描述符(fd)存在,那么就算PID所属的全部进程都已经结束,建立的namespace就会一直存在。那如何打开文件描述符呢?把/proc/[pid]/ns目录挂载起来就能够达到这个效果,命令以下。

# touch ~/uts
# mount --bind /proc/27514/ns/uts ~/uts

若是你看到的内容与本文所描述的不符,那么说明你使用的内核在3.8版本之前。该目录下存在的只有ipc、net和uts,而且以硬连接存在。

(3)经过setns()加入一个已经存在的namespace

上文刚提到,在进程都结束的状况下,也能够经过挂载的形式把namespace保留下来,保留namespace的目的天然是为之后有进程加入作准备。经过setns()系统调用,你的进程从原先的namespace加入咱们准备好的新namespace,使用方法以下。

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。

# ./setns ~/uts /bin/bash   # ~/uts 是绑定的/proc/27514/ns/uts

至此,你就能够在新的命名空间中执行shell命令了,在下文中会屡次使用这种方式来演示隔离的效果。

(4)经过unshare()在原先进程上进行namespace隔离

最后要提的系统调用是unshare(),它跟clone()很像,不一样的是,unshare()运行在原先的进程上,不须要启动一个新进程,使用方法以下。

int unshare(int flags);

调用unshare()的主要做用就是不启动一个新进程就能够起到隔离的效果,至关于跳出原先的namespace进行操做。这样,你就能够在原进程进行一些须要隔离的操做。Linux中自带的unshare命令,就是经过unshare()系统调用实现的,有兴趣的读者能够在网上搜索一下这个命令的做用。

(5)延伸阅读:fork()系统调用

系统调用函数fork()并不属于namespace的API,因此这部份内容属于延伸阅读,若是读者已经对fork()有足够的了解,那大可跳过。

当程序调用fork()函数时,系统会建立新的进程,为其分配资源,例如存储数据和代码的空间。而后把原来的进程的全部值都复制到新的进程中,只有少许数值与原来的进程值不一样,至关于克隆了一个本身。那么程序的后续代码逻辑要如何区分本身是新进程仍是父进程呢?

fork()的神奇之处在于它仅仅被调用一次,却可以返回两次(父进程与子进程各返回一次),经过返回值的不一样就能够进行区分父进程与子进程。它可能有三种不一样的返回值:

  • 在父进程中,fork返回新建立子进程的进程ID

  • 在子进程中,fork返回0

  • 若是出现错误,fork返回一个负值

下面给出一段实例代码,命名为fork_example.c。

#include <unistd.h>
#include <stdio.h>
int main (){
    pid_t fpid; //fpid表示fork函数返回的值
    int count=0;
    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@local:~# gcc -Wall fork_example.c && ./a.out
I am parent. Process id is 28365
I am child. Process id is 28366

使用fork()后,父进程有义务监控子进程的运行状态,并在子进程退出后本身才能正常退出,不然子进程就会成为“孤儿”进程。

下面咱们将分别对六种namespace进行详细解析。

2. UTS(UNIX Time-sharing System)namespace

UTS namespace提供了主机名和域名的隔离,这样每一个容器就能够拥有了独立的主机名和域名,在网络上能够被视做一个独立的节点而非宿主机上的一个进程。

下面咱们经过代码来感觉一下UTS隔离的效果,首先须要一个程序的骨架,以下所示。打开编辑器建立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@local:~# gcc -Wall uts.c -o uts.o && ./uts.o
程序开始:
在子进程中!
root@local:~# exit
exit
已退出
root@local:~#

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

//[...]
int child_main(void* arg) {
  printf("在子进程中!\n");
  sethostname("Changed Namespace", 12);
  execv(child_args[0], child_args);
  return 1;
}

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

再次运行能够看到hostname已经变化。

root@local:~# gcc -Wall namespace.c -o main.o && ./main.o
程序开始:
在子进程中!
root@NewNamespace:~# exit
exit
已退出
root@local:~#  <- 回到原来的hostname

也许有读者试着不加CLONE_NEWUTS参数运行上述代码,发现主机名也变了,输入exit之后主机名也会变回来,彷佛没什么区别。实际上不加CLONE_NEWUTS参数进行隔离而使用sethostname已经把宿主机的主机名改掉了。你看到exit退出后还原只是由于bash只在刚登陆的时候读取一次UTS,当你从新登录或者使用uname命令进行查看时,就会发现产生了变化。

Docker中,每一个镜像基本都以本身所提供的服务命名了本身的hostname而没有对宿主机产生任何影响,用的就是这个原理。

3. IPC(Interprocess Communication)namespace

容器中进程间通讯采用的方法包括常见的信号量、消息队列和共享内存。然而与虚拟机不一样的是,容器内部进程间通讯对宿主机来讲,其实是具备相同PID namespace中的进程间通讯,所以须要一个惟一的标识符来进行区别。申请IPC资源就申请了这样一个全局惟一的32位ID,因此IPC namespace中实际上包含了系统IPC标识符以及实现POSIX消息队列的文件系统。在同一个IPC namespace下的进程彼此可见,而与其余的IPC namespace下的进程则互相不可见。

IPC namespace在代码上的变化与UTS namespace类似,只是标识位有所变化,须要加上CLONE_NEWIPC参数。主要改动以下,其余部位不变,程序名称改成ipc.c。(测试方法参考自:http://crosbymichael.com/creating-containers-part-1.html

//[...]
int child_pid = clone(child_main, child_stack+STACK_SIZE,
           CLONE_NEWIPC | CLONE_NEWUTS | SIGCHLD, NULL);
//[...]

咱们首先在shell中使用ipcmk -Q命令建立一个message queue。

root@local:~# ipcmk -Q
Message queue id: 32769

经过ipcs -q能够查看到已经开启的message queue,序号为32769。

root@local:~# ipcs -q
------ Message Queues --------
key        msqid   owner   perms   used-bytes   messages
0x4cf5e29f 32769   root    644     0            0

而后咱们能够编译运行加入了IPC namespace隔离的ipc.c,在新建的子进程中调用的shell中执行ipcs -q查看message queue。

root@local:~# gcc -Wall ipc.c -o ipc.o && ./ipc.o
程序开始:
在子进程中!
root@NewNamespace:~# ipcs -q
------ Message Queues --------
key   msqid   owner   perms   used-bytes   messages
root@NewNamespace:~# exit
exit
已退出

上面的结果显示中能够发现,已经找不到原先声明的message queue,实现了IPC的隔离。

目前使用IPC namespace机制的系统很少,其中比较有名的有PostgreSQL。Docker自己经过socket或tcp进行通讯。

4. PID namespace

PID namespace隔离很是实用,它对进程PID从新标号,即两个不一样namespace下的进程能够有同一个PID。每一个PID namespace都有本身的计数程序。内核为全部的PID namespace维护了一个树状结构,最顶层的是系统初始时建立的,咱们称之为root namespace。他建立的新PID namespace就称之为child namespace(树的子节点),而原先的PID namespace就是新建立的PID namespace的parent namespace(树的父节点)。经过这种方式,不一样的PID namespaces会造成一个等级体系。所属的父节点能够看到子节点中的进程,并能够经过信号等方式对子节点中的进程产生影响。反过来,子节点不能看到父节点PID namespace中的任何内容。由此产生以下结论(部份内容引自:http://blog.dotcloud.com/under-the-hood-linux-kernels-on-dotcloud-part)。

  • 每一个PID namespace中的第一个进程“PID 1“,都会像传统Linux中的init进程同样拥有特权,起特殊做用。

  • 一个namespace中的进程,不可能经过kill或ptrace影响父节点或者兄弟节点中的进程,由于其余节点的PID在这个namespace中没有任何意义。

  • 若是你在新的PID namespace中从新挂载/proc文件系统,会发现其下只显示同属一个PID namespace中的其余进程。

  • 在root namespace中能够看到全部的进程,而且递归包含全部子节点中的进程。

到这里,可能你已经联想到一种在外部监控Docker中运行程序的方法了,就是监控Docker Daemon所在的PID namespace下的全部进程即其子进程,再进行删选便可。

下面咱们经过运行代码来感觉一下PID namespace的隔离效果。修改上文的代码,加入PID namespace的标识位,并把程序命名为pid.c。

//[...]
int child_pid = clone(child_main, child_stack+STACK_SIZE,
           CLONE_NEWPID | CLONE_NEWIPC | CLONE_NEWUTS 
           | SIGCHLD, NULL);
//[...]

编译运行能够看到以下结果。

root@local:~# gcc -Wall pid.c -o pid.o && ./pid.o
程序开始:
在子进程中!
root@NewNamespace:~# echo $$
1                      <<--注意此处看到shell的PID变成了1
root@NewNamespace:~# exit
exit
已退出

打印$$能够看到shell的PID,退出后若是再次执行能够看到效果以下。

root@local:~# echo $$
17542

已经回到了正常状态。可能有的读者在子进程的shell中执行了ps aux/top之类的命令,发现仍是能够看到全部父进程的PID,那是由于咱们尚未对文件系统进行隔离,ps/top之类的命令调用的是真实系统下的/proc文件内容,看到的天然是全部的进程。

此外,与其余的namespace不一样的是,为了实现一个稳定安全的容器,PID namespace还须要进行一些额外的工做才能确保其中的进程运行顺利。

(1)PID namespace中的init进程

当咱们新建一个PID namespace时,默认启动的进程PID为1。咱们知道,在传统的UNIX系统中,PID为1的进程是init,地位很是特殊。他做为全部进程的父进程,维护一张进程表,不断检查进程的状态,一旦有某个子进程由于程序错误成为了“孤儿”进程,init就会负责回收资源并结束这个子进程。因此在你要实现的容器中,启动的第一个进程也须要实现相似init的功能,维护全部后续启动进程的运行状态。

看到这里,可能读者已经明白了内核设计的良苦用心。PID namespace维护这样一个树状结构,很是有利于系统的资源监控与回收。Docker启动时,第一个进程也是这样,实现了进程监控和资源回收,它就是dockerinit。

(2)信号与init进程

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

那么其父节点PID namespace中的进程发送一样的信号会被忽略吗?父节点中的进程发送的信号,若是不是SIGKILL(销毁进程)或SIGSTOP(暂停进程)也会被忽略。但若是发送SIGKILL或SIGSTOP,子节点的init会强制执行(没法经过代码捕捉进行特殊处理),也就是说父节点中的进程有权终止子节点中的进程。

一旦init进程被销毁,同一PID namespace中的其余进程也会随之接收到SIGKILL信号而被销毁。理论上,该PID namespace天然也就不复存在了。可是若是/proc/[pid]/ns/pid处于被挂载或者打开状态,namespace就会被保留下来。然而,保留下来的namespace没法经过setns()或者fork()建立进程,因此实际上并无什么做用。

咱们常说,Docker一旦启动就有进程在运行,不存在不包含任何进程的Docker,也就是这个道理。

(3)挂载proc文件系统

前文中已经提到,若是你在新的PID namespace中使用ps命令查看,看到的仍是全部的进程,由于与PID直接相关的/proc文件系统(procfs)没有挂载到与原/proc不一样的位置。因此若是你只想看到PID namespace自己应该看到的进程,须要从新挂载/proc,命令以下。

root@NewNamespace:~# mount -t proc proc /proc
root@NewNamespace:~# ps a
  PID TTY      STAT   TIME COMMAND
    1 pts/1    S      0:00 /bin/bash
   12 pts/1    R+     0:00 ps a

能够看到实际的PID namespace就只有两个进程在运行。

注意:由于此时咱们没有进行mount namespace的隔离,因此这一步操做实际上已经影响了 root namespace的文件系统,当你退出新建的PID namespace之后再执行ps a就会发现出错,再次执行mount -t proc proc /proc能够修复错误。

(4)unshare()和setns()

在开篇咱们就讲到了unshare()和setns()这两个API,而这两个API在PID namespace中使用时,也有一些特别之处须要注意。

unshare()容许用户在原有进程中创建namespace进行隔离。可是建立了PID namespace后,原先unshare()调用者进程并不进入新的PID namespace,接下来建立的子进程才会进入新的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。

5. Mount namespaces

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)之间的关系,系统用这些关系决定任何挂载对象中的挂载事件如何传播到其余挂载对象(参考自:http://www.ibm.com/developerworks/library/l-mount-namespaces/)。所谓传播事件,是指由一个挂载对象的状态变化致使的其它挂载对象的挂载与解除挂载动做的事件。

  • 共享关系(share relationship)。若是两个挂载对象具备共享关系,那么一个挂载对象中的挂载事件会传播到另外一个挂载对象,反之亦然。

  • 从属关系(slave relationship)。若是两个挂载对象造成从属关系,那么一个挂载对象中的挂载事件会传播到另外一个挂载对象,可是反过来不行;在这种关系中,从属对象是事件的接收者。

一个挂载状态可能为以下的其中一种:

  • 共享挂载(shared)

  • 从属挂载(slave)

  • 共享/从属挂载(shared and slave)

  • 私有挂载(private)

  • 不可绑定挂载(unbindable)

传播事件的挂载对象称为共享挂载(shared mount);接收传播事件的挂载对象称为从属挂载(slave mount)。既不传播也不接收传播事件的挂载对象称为私有挂载(private mount)。另外一种特殊的挂载对象称为不可绑定的挂载(unbindable mount),它们与私有挂载类似,可是不容许执行绑定挂载,即建立mount namespace时这块文件对象不可被复制。

0310020.png

图1 mount各种挂载状态示意图

共享挂载的应用场景很是明显,就是为了文件数据的共享所必须存在的一种挂载方式;从属挂载更大的意义在于某些“只读”场景;私有挂载其实就是纯粹的隔离,做为一个独立的个体而存在;不可绑定挂载则有助于防止没有必要的文件拷贝,如某个用户数据目录,当根目录被递归式的复制时,用户目录不管从隐私仍是实际用途考虑都须要有一个不可被复制的选项。

默认状况下,全部挂载都是私有的。设置为共享挂载的命令以下。

mount --make-shared <mount-object>

从共享挂载克隆的挂载对象也是共享的挂载;它们相互传播挂载事件。

设置为从属挂载的命令以下。

mount --make-slave <shared-mount-object>

从从属挂载克隆的挂载对象也是从属的挂载,它也从属于原来的从属挂载的主挂载对象。

将一个从属挂载对象设置为共享/从属挂载,能够执行以下命令或者将其移动到一个共享挂载对象下。

mount --make-shared <slave-mount-object>

若是你想把修改过的挂载对象从新标记为私有的,能够执行以下命令。

mount --make-private <mount-object>

经过执行如下命令,能够将挂载对象标记为不可绑定的。

mount --make-unbindable <mount-object>

这些设置均可以递归式地应用到全部子目录中,若是读者感兴趣能够搜索到相关的命令。

在代码中实现mount namespace隔离与其余namespace相似,加上CLONE_NEWNS标识位便可。让咱们再次修改代码,而且另存为mount.c进行编译运行。

//[...]
int child_pid = clone(child_main, child_stack+STACK_SIZE,
           CLONE_NEWNS | CLONE_NEWPID | CLONE_NEWIPC 
           | CLONE_NEWUTS | SIGCHLD, NULL);
//[...]

执行的效果就如同PID namespace一节中“挂载proc文件系统”的执行结果,区别就是退出mount namespace之后,root namespace的文件系统不会被破坏,此处就再也不演示了。

6. Network namespace

经过上节,咱们了解了PID namespace,当咱们兴致勃勃地在新建的namespace中启动一个“Apache”进程时,却出现了“80端口已被占用”的错误,原来主机上已经运行了一个“Apache”进程。怎么办?这就须要用到network namespace技术进行网络隔离啦。

Network namespace主要提供了关于网络资源的隔离,包括网络设备、IPv4和IPv6协议栈、IP路由表、防火墙、/proc/net目录、/sys/class/net目录、端口(socket)等等。一个物理的网络设备最多存在在一个network namespace中,你能够经过建立veth pair(虚拟网络设备对:有两端,相似管道,若是数据从一端传入另外一端也能接收到,反之亦然)在不一样的network namespace间建立通道,以此达到通讯的目的。

通常状况下,物理网络设备都分配在最初的root namespace(表示系统默认的namespace,在PID namespace中已经说起)中。可是若是你有多块物理网卡,也能够把其中一块或多块分配给新建立的network namespace。须要注意的是,当新建立的network namespace被释放时(全部内部的进程都终止而且namespace文件没有被挂载或打开),在这个namespace中的物理网卡会返回到root namespace而非建立该进程的父进程所在的network namespace。

当咱们说到network namespace时,其实咱们指的未必是真正的网络隔离,而是把网络独立出来,给外部用户一种透明的感受,仿佛跟另一个网络实体在进行通讯。为了达到这个目的,容器的经典作法就是建立一个veth pair,一端放置在新的namespace中,一般命名为eth0,一端放在原先的namespace中链接物理网络设备,再经过网桥把别的设备链接进来或者进行路由转发,以此网络实现通讯的目的。

也许有读者会好奇,在创建起veth pair以前,新旧namespace该如何通讯呢?答案是pipe(管道)。咱们以Docker Daemon在启动容器dockerinit的过程为例。Docker Daemon在宿主机上负责建立这个veth pair,经过netlink调用,把一端绑定到docker0网桥上,一端连进新建的network namespace进程中。创建的过程当中,Docker Daemon和dockerinit就经过pipe进行通讯,当Docker Daemon完成veth-pair的建立以前,dockerinit在管道的另外一端循环等待,直到管道另外一端传来Docker Daemon关于veth设备的信息,并关闭管道。dockerinit才结束等待的过程,并把它的“eth0”启动起来。整个效果相似下图所示。

0310021.png

图2 Docker网络示意图

跟其余namespace相似,对network namespace的使用其实就是在建立的时候添加CLONE_NEWNET标识位。也能够经过命令行工具ip建立network namespace。在代码中创建和测试network namespace较为复杂,因此下文主要经过ip命令直观的感觉整个network namespace网络创建和配置的过程。

首先咱们能够建立一个命名为test_ns的network namespace。

# ip netns add test_ns

当ip命令工具建立一个network namespace时,会默认建立一个回环设备(loopback interface:lo),并在/var/run/netns目录下绑定一个挂载点,这就保证了就算network namespace中没有进程在运行也不会被释放,也给系统管理员对新建立的network namespace进行配置提供了充足的时间。

经过ip netns exec命令能够在新建立的network namespace下运行网络管理命令。

# ip netns exec test_ns ip link list
3: lo: <LOOPBACK> mtu 16436 qdisc noop state DOWN
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

上面的命令为咱们展现了新建的namespace下可见的网络连接,能够看到状态是DOWN,须要再经过命令去启动。能够看到,此时执行ping命令是无效的。

# ip netns exec test_ns ping 127.0.0.1
connect: Network is unreachable

启动命令以下,能够看到启动后再测试就能够ping通。

# ip netns exec test_ns ip link set dev lo up
# ip netns exec test_ns ping 127.0.0.1
PING 127.0.0.1 (127.0.0.1) 56(84) bytes of data.
64 bytes from 127.0.0.1: icmp_req=1 ttl=64 time=0.050 ms
...

这样只是启动了本地的回环,要实现与外部namespace进行通讯还须要再建一个网络设备对,命令以下。

# ip link add veth0 type veth peer name veth1
# ip link set veth1 netns test_ns
# ip netns exec test_ns ifconfig veth1 10.1.1.1/24 up
# ifconfig veth0 10.1.1.2/24 up
  • 第一条命令建立了一个网络设备对,全部发送到veth0的包veth1也能接收到,反之亦然。

  • 第二条命令则是把veth1这一端分配到test_ns这个network namespace。

  • 第3、第四条命令分别给test_ns内部和外部的网络设备配置IP,veth1的IP为10.1.1.1,veth0的IP为10.1.1.2。

此时两边就能够互相连通了,效果以下。

# ping 10.1.1.1
PING 10.1.1.1 (10.1.1.1) 56(84) bytes of data.
64 bytes from 10.1.1.1: icmp_req=1 ttl=64 time=0.095 ms
...
# ip netns exec test_ns ping 10.1.1.2
PING 10.1.1.2 (10.1.1.2) 56(84) bytes of data.
64 bytes from 10.1.1.2: icmp_req=1 ttl=64 time=0.049 ms
...

读者有兴趣能够经过下面的命令查看,新的test_ns有着本身独立的路由和iptables。

ip netns exec test_ns route
ip netns exec test_ns iptables -L

路由表中只有一条通向10.1.1.2的规则,此时若是要链接外网确定是不可能的,你能够经过创建网桥或者NAT映射来决定这个问题。若是你对此很是感兴趣,能够阅读Docker网络相关文章进行更深刻的讲解。

作完这些实验,你还能够经过下面的命令删除这个network namespace。

# ip netns delete netns1

这条命令会移除以前的挂载,可是若是namespace自己还有进程运行,namespace还会存在下去,直到进程运行结束。

经过network namespace咱们能够了解到,实际上内核建立了network namespace之后,真的是获得了一个被隔离的网络。可是咱们实际上须要的不是这种彻底的隔离,而是一个对用户来讲透明独立的网络实体,咱们须要与这个实体通讯。因此Docker的网络在起步阶段给人一种很是难用的感受,由于一切都要本身去实现、去配置。你须要一个网桥或者NAT链接广域网,你须要配置路由规则与宿主机中其余容器进行必要的隔离,你甚至还须要配置防火墙以保证安全等等。所幸这一切已经有了较为成熟的方案,咱们会在Docker网络部分进行详细的讲解。

7. User namespaces

User namespace主要隔离了安全相关的标识符(identifiers)和属性(attributes),包括用户ID、用户组ID、root目录、key(指密钥)以及特殊权限。说得通俗一点,一个普通用户的进程经过clone()建立的新进程在新user namespace中能够拥有不一样的用户和用户组。这意味着一个进程在容器外属于一个没有特权的普通用户,可是他建立的容器进程却属于拥有全部权限的超级用户,这个技术为容器提供了极大的自由。

User namespace是目前的六个namespace中最后一个支持的,而且直到Linux内核3.8版本的时候还未彻底实现(还有部分文件系统不支持)。由于user namespace实际上并不算彻底成熟,不少发行版担忧安全问题,在编译内核的时候并未开启USER_NS。实际上目前Docker也还不支持user namespace,可是预留了相应接口,相信在不久后就会支持这一特性。因此在进行接下来的代码实验时,请确保你系统的Linux内核版本高于3.8而且内核编译时开启了USER_NS(若是你不会选择,可使用Ubuntu14.04)。

Linux中,特权用户的user ID就是0,演示的最终咱们将看到user ID非0的进程启动user namespace后user ID能够变为0。使用user namespace的方法跟别的namespace相同,即调用clone()或unshare()时加入CLONE_NEWUSER标识位。老样子,修改代码并另存为userns.c,为了看到用户权限(Capabilities),可能你还须要安装一下libcap-dev包。

首先包含如下头文件以调用Capabilities包。

#include <sys/capability.h>

其次在子进程函数中加入geteuid()和getegid()获得namespace内部的user ID,其次经过cap_get_proc()获得当前进程的用户拥有的权限,并经过cap_to_text()输出。

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;
}

在主函数的clone()调用中加入咱们熟悉的标识符。

//[...]
int child_pid = clone(child_main, child_stack+STACK_SIZE,
            CLONE_NEWUSER | SIGCHLD, NULL);
//[...]

至此,第一部分的代码修改就结束了。在编译以前咱们先查看一下当前用户的uid和guid,请注意此时咱们是普通用户。

$ id -u
1000
$ id -g
1000

而后咱们开始编译运行,并进行新建的user namespace,你会发现shell提示符前的用户名已经变为nobody。

sun@ubuntu$ gcc userns.c -Wall -lcap -o userns.o && ./userns.o
程序开始:
在子进程中!
eUID = 65534;  eGID = 65534;  capabilities: = cap_chown,cap_dac_override,[...]37+ep  <<--此处省略部分输出,已拥有所有权限
nobody@ubuntu$

经过验证咱们能够获得如下信息。

  • 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很是类似。

接下来咱们就要进行用户绑定操做,经过在/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。最后一个字段表示映射范围,一般填1,表示只映射一个,若是填大于1的值,则按顺序创建一一映射。

明白了上述原理,咱们再次修改代码,添加设置uid和guid的函数。

//[...]
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* gid_map = fopen(path, "w");
    fprintf(gid_map, "%d %d %d", inside_id, outside_id, length);
    fclose(gid_map);
}
int child_main(void* args) {
    cap_t caps;
    printf("在子进程中!\n");
    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。

$ gcc userns.c -Wall -lcap -o usernc.o && ./usernc.o
程序开始:
在子进程中!
eUID = 0;  eGID = 0;  capabilities: = [...],37+ep
root@ubuntu:~#

至此,你就已经完成了绑定的工做,能够看到演示全程都是在普通用户下执行的。最终实现了在user namespace中成为了root而对应到外面的是一个uid为1000的普通用户。

若是你要把user namespace与其余namespace混合使用,那么依旧须要root权限。解决方案能够是先以普通用户身份建立user namespace,而后在新建的namespace中做为root再clone()进程加入其余类型的namespace隔离。

讲完了user namespace,咱们再来谈谈Docker。虽然Docker目前还没有使用user namespace,可是他用到了咱们在user namespace中说起的Capabilities机制。从内核2.2版本开始,Linux把原来和超级用户相关的高级权限划分红为不一样的单元,称为Capability。这样管理员就能够独立对特定的Capability进行使能或禁止。Docker虽然没有使用user namespace,可是他能够禁用容器中不须要的Capability,一次在必定程度上增强容器安全性。

固然,说到安全,namespace的六项隔离看似全面,实际上依旧没有彻底隔离Linux的资源,好比SELinux、 Cgroups以及/sys、/proc/sys、/dev/sd*等目录下的资源。关于安全的更多讨论和讲解,咱们会在后文中接着探讨。

8. 总结

本文从namespace使用的API开始,结合Docker逐步对六个namespace进行讲解。相信把讲解过程当中全部的代码整合起来,你也能实现一个属于本身的“shell”容器了。虽然namespace技术使用起来很是简单,可是要真正把容器作到安全易用却并不是易事。PID namespace中,咱们要实现一个完善的init进程来维护好全部进程;network namespace中,咱们还有复杂的路由表和iptables规则没有配置;user namespace中还有不少权限上的问题须要考虑等等。其中有些方面Docker已经作的很好,有些方面也才刚刚开始。但愿经过本文,能为你们更好的理解Docker背后运行的原理提供帮助。

相关文章
相关标签/搜索