**【Docker 内核详解 - namespace 资源隔离】**系列包含:
- namespace 资源隔离(一):进行 namespace API 操作的 4 种方式
- namespace 资源隔离(二):UTS namespace & IPC namespace
- namespace 资源隔离(三):PID namespace
- namespace 资源隔离(四):Mount namespace & Network namespace
- namespace 资源隔离(五):User namespaces
namespace 资源隔离(五):User namespaces
user namespace
主要隔离了安全相关的 标识符(identifier
)和 属性(attribute
),包括用户 ID、用户组 ID、root
目录、key
(指密钥)以及特殊权限。通俗地讲,一个普通用户的进程通过 clone()
创建的新进程在新 user namespace
中可以拥有不同的用户和用户组。这意味着一个进程在容器外属于一个没有特权的普通用户,但是它创建的容器进程却属于拥有所有权限的超级用户,这个技术为容器提供了极大的自由。
user namespace
是目前的 6 6 6 个 namespace
中最后一个支持的,并且直到 Linux 内核 3.8 3.8 3.8 版本的时候还未完全实现(还有部分文件系统不支持)。user namespace
实际上并不算完全成熟,很多发行版担心安全问题,在编译内核的时候并未开启 USER_NS
。Docker 在 1.10 1.10 1.10 版本中对 user namespace
进行了支持。只要用户在启动 Docker daemon 的时候指定了 --userns-remap
,那么当用户运行容器时,容器内部的 root
用户并不等于宿主机内的 root
用户,而是映射到宿主上的普通用户。在进行接下来的代码实验时,请确保系统的 Linux 内核版本高于 3.8 3.8 3.8 并且内核编译时开启了 USER_NS
(如果不会选择,请使用 Ubuntu 14.04 14.04 14.04)。
Linux 中,特权用户的 user ID 就是 0 0 0,演示的最后将看到 user ID 非 0 0 0 的进程启动 user namespace
后 user ID 可以变为 0 0 0。使用 user namespace
的方法跟别的 namespace
相同,即调用 clone()
或 unshare()
时加入 CLONE_NEWUSER
标识位。修改代码并另存为 userns.c
,为了看到用户权限(Capabilities
),还需要安装 libcap-dev
包。
首先包含以下头文件以调用 Capabilities
包。
c
#include <sys/capability.h>
其次在子进程函数中加入 geteuid()
和 getegid()
得到 namespace
内部的 user ID,通过 cap_get_proc()
得到当前进程的用户拥有的权限,并通过 cap_to_text()
输出。
c
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()
调用中加人我们熟悉的标识符。
c
// [...]
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
。
$ 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 65534 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
非常相似。
从下图中可以看到,namespace
实际上就是按层次关联起来,每个 namespace
都发源于最初的 root namespace
并与之建立映射。
接下来就要进行用户绑定操作,通过在 /proc/[pid]/uid_map
和 /proc/[pid]/gid_map
两个文件中写入对应的绑定信息就可以实现这一点,格式如下。
c
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 1,表示只映射一个,如果填大于 1 1 1 的值,则按顺序建立一一映射。
明白了上述原理,再次修改代码,添加设置 uid
和 gid
的函数。
c
// [...]
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 uscrns.c -Wall -lcap -o usernc.o && ./usernc.o
程序开始:
在子进程中!
eUID = 0; eGID = 0; capabilities: = [..],37+ep
root@ubuntu:~#
至此,就已经完成了绑定的工作,可以看到演示全程都是在普通用户下执行的,最终实现了在 user namespace
中成为 root
用户,对应到外部则是一个 uid
为 1000 1000 1000 的普通用户。
如果要把 user namespace
与其他 namespace
混合使用,那么依旧需要 root
权限。解决方案是先以普通用户身份创建 user namespace
,然后在新建的 namespace
中作为 root
,在 clone()
进程加入其他类型的 namespace
隔离。
讲解完 user namespace
,再来谈谈 Docker。Docker 不仅使用了 user namespace
,还使用了在 user namespace
中涉及的 Capabilities
机制。从内核 2.2 2.2 2.2 版本开始,Linux 把原来和超级用户相关的高级权限划分为不同的单元,称为 Capability
。这样管理员就可以独立对特定的 Capability
进行使用或禁止。Docker 同时使用 user namespace
和 Capability,这在很大程度上加强了容器的安全性。
说到安全,namespace
的 6 6 6 项隔离看似全面,实际上依旧没有完全隔离 Linux 的资源,比如 SELinux
、cgroups
及 /sys
、/proc/sys
、/dev/sd*
等目录下的资源。关于安全,将会在后续博客中进一步探讨。
本系列从 namespace
使用的 API 开始,结合 Docker 逐步对 6 个 namespace
进行了讲解。相信把讲解过程中所有的代码整合起来,读者也能实现一个属于自己的 "shell" 容器了。虽然 namespace
技术使用非常简单,但要真正把容器做到安全易用却并非易事。PID namespace
中,需要实现一个完善的 init
进程来维护好所有进程;network namespace
中,还有复杂的路由表和 iptables
规则没有配置;user namespace
中还有许多权限问题需要考虑。其中的某些方面 Docker 已经做得不错,而有些方面才刚刚起步,这些内容我们会在后续博客中详细介绍。