当前位置: > Linux服务器 > Docker >

Docker背后的内核知识:命名空间资源隔离(3)

时间:2015-03-16 01:24来源:linux.it.net.cn 作者:IT



 
 
 

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”启动起来。整个效果类似下图所示。

图2 Docker网络示意图

跟其他namespace类似,对network namespace的使用其实就是在创建的时候添加CLONE_NEWNET标识位。也可以通过命令行工具ip创建network namespace。在代码中建立和测试network namespace较为复杂,所以下文主要通过ip命令直观的感受整个network namespace网络建立和配置的过程。

首先我们可以创建一个命名为test_ns的network namespace。


					
  1. # ip netns add test_ns

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

通过ip netns exec命令可以在新创建的network namespace下运行网络管理命令。


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

上面的命令为我们展示了新建的namespace下可见的网络链接,可以看到状态是DOWN,需要再通过命令去启动。可以看到,此时执行ping命令是无效的。


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

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


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

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


					
  1. # ip link add veth0 type veth peer name veth1
  2. # ip link set veth1 netns test_ns
  3. # ip netns exec test_ns ifconfig veth1 10.1.1.1/24 up
  4. # ifconfig veth0 10.1.1.2/24 up
  • 第一条命令创建了一个网络设备对,所有发送到veth0的包veth1也能接收到,反之亦然。
  • 第二条命令则是把veth1这一端分配到test_ns这个network namespace。
  • 第三、第四条命令分别给test_ns内部和外部的网络设备配置IP,veth1的IP为10.1.1.1,veth0的IP为10.1.1.2。

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


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

读者有兴趣可以通过下面的命令查看,新的test_ns有着自己独立的路由和iptables。


					
  1. ip netns exec test_ns route
  2. ip netns exec test_ns iptables -L

路由表中只有一条通向10.1.1.2的规则,此时如果要连接外网肯定是不可能的,你可以通过建立网桥或者NAT映射来决定这个问题。如果你对此非常感兴趣,可以阅读Docker网络相关文章进行更深入的讲解。

做完这些实验,你还可以通过下面的命令删除这个network namespace。


					
  1. # 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包。


  1. #include <sys/capability.h>

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


  1. int child_main(void* args) {
  2. printf("在子进程中!\n");
  3. cap_t caps;
  4. printf("eUID = %ld; eGID = %ld; ",
  5. (long) geteuid(), (long) getegid());
  6. caps = cap_get_proc();
  7. printf("capabilities: %s\n", cap_to_text(caps, NULL));
  8. execv(child_args[0], child_args);
  9. return 1;
  10. }

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


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

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


  1. $ id -u
  2. 1000
  3. $ id -g
  4. 1000

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


  1. sun@ubuntu$ gcc userns.c -Wall -lcap -o userns.o && ./userns.o
  2. 程序开始:
  3. 在子进程中!
  4. eUID = 65534; eGID = 65534; capabilities: = cap_chown,cap_dac_override,[...]37+ep <<--此处省略部分输出,已拥有全部权限
  5. 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两个文件中写入对应的绑定信息可以实现这一点,格式如下。


  1. 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的函数。


  1. //[...]
  2. void set_uid_map(pid_t pid, int inside_id, int outside_id, int length) {
  3. char path[256];
  4. sprintf(path, "/proc/%d/uid_map", getpid());
  5. FILE* uid_map = fopen(path, "w");
  6. fprintf(uid_map, "%d %d %d", inside_id, outside_id, length);
  7. fclose(uid_map);
  8. }
  9. void set_gid_map(pid_t pid, int inside_id, int outside_id, int length) {
  10. char path[256];
  11. sprintf(path, "/proc/%d/gid_map", getpid());
  12. FILE* gid_map = fopen(path, "w");
  13. fprintf(gid_map, "%d %d %d", inside_id, outside_id, length);
  14. fclose(gid_map);
  15. }
  16. int child_main(void* args) {
  17. cap_t caps;
  18. printf("在子进程中!\n");
  19. set_uid_map(getpid(), 0, 1000, 1);
  20. set_gid_map(getpid(), 0, 1000, 1);
  21. printf("eUID = %ld; eGID = %ld; ",
  22. (long) geteuid(), (long) getegid());
  23. caps = cap_get_proc();
  24. printf("capabilities: %s\n", cap_to_text(caps, NULL));
  25. execv(child_args[0], child_args);
  26. return 1;
  27. }
  28. //[...]

编译后即可看到user已经变成了root。


  1. $ gcc userns.c -Wall -lcap -o usernc.o && ./usernc.o
  2. 程序开始:
  3. 在子进程中!
  4. eUID = 0; eGID = 0; capabilities: = [...],37+ep
  5. 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背后运行的原理提供帮助。


(责任编辑:IT)
------分隔线----------------------------
栏目列表
推荐内容