Linux内核提供的一种隔离机制,Docker就是使用内核namespace的隔离机制来实现对应的资源隔离。共有六种隔离:
namespace | 系统调用参数 | 隔离内容 |
---|---|---|
UTS | CLONE_NEWUTS | 主机名和域名 |
IPC | CLONE_NEWIPC | 信号量、消息队列、共享内存 |
PID | CLONE_NEWPID | 进程号 |
Mount | CLONE_NEWNS | 文件系统(挂载点) |
Network | CLONE_NEWNET | 网络设备、网络栈、端口 |
User | CLONE_NEWUSER | 用户和用户组 |
UTS 全称为 UNIX Time-sharing System 该种隔离提供对于主机名和域名的隔离,Docker利用该种隔离机制为每个Docker提供独立的机器名和域名,在网络中就可以被当做单独的一个服务节点使用。
IPC的全称是 Inter-Process Communication 是Linux系统提供的进程间通信机制,常见的包括 信号量、消息队列、共享内存等。申请IPC资源其实就是向内核申请了一个全局唯一的32位ID,在同一个IPC namespace下的进程彼此可见,不同IPC namespace下的进程互不可见。Docker运用该机制实现了容器之间IPC的隔离。
PID 是Linux系统的进程号,每个pid namespace 中都有一个计数器用于标识当前最大的PID,pid namespace隔离可以对每个namespace中pid计数器重新标号,不同pid namespace中的进程可以拥有相同的pid号。内核中对PID namespace 的组织是个树状结构,在树形的父节点中可以看到子节点的所有进程,反之则不行。即父级 pid namespace可以看到子级pid namespace中所有的进程,并可以对其进行管理,而子级pid namespace中进程无法看到且管理兄弟或父级pid namespace。
Mount namespace 通过隔离文件系统的挂载点提供对隔离文件系统的支持。在创建新的mount ns时,系统会复制当前的文件结构给新的namespace。在新的nsmespace中进行的相关操作则不会再影响以前的ns。
Network ns主要提供关于网络资源的隔离,包括网络设备、协议栈、路由表、防火墙、/proc/net目录、/sys/class/net目录、socket等的隔离。
User ns主要提供用户安全相关的隔离,例如用户组ID、用户ID、root目录、秘钥文件等隔离。通过该隔离手段可以达到更加灵活的权限控制。例如,某个用户在宿主机上拥有普通用户权限,但是其进入容器后却可以用于容器的root权限,也就是说,容器里的root用户并不是真正的root用户,映射到宿主机上只是宿主机上一个普通用户而已。
注:因 PID namespace 较为复杂且坑比较多,所以这里仅以pid namespace为例,其他可自行探索。
clone.c
#define _GNU_SOURCE
#include
#include
#include
#include
#include
#define STACK_SIZE 65535
char *const bash_args[] = {
"/bin/bash",
NULL
};
int childBash() {
printf("child bash my PID: %ld\n", (long)getpid());
execv(bash_args[0], bash_args);
return -1;
}
int childProcess() {
printf("child process my PID: %ld\n", (long)getpid());
char *stack = malloc(STACK_SIZE);
if (!stack) {
perror("Failed to allocate memory\n");
return EXIT_FAILURE;
}
pid_t child_pid = clone(childBash, stack + STACK_SIZE, SIGCHLD, NULL);
printf("bash() = %ld\n", (long)child_pid);
waitpid(child_pid, NULL, 0);
return 0;
}
int main(int agrc, char *argv[]) {
char *stack = malloc(STACK_SIZE);
if (!stack) {
perror("Failed to allocate memory\n");
return EXIT_FAILURE;
}
pid_t child_pid = clone(childProcess, stack + STACK_SIZE, CLONE_NEWPID | SIGCHLD, NULL);
printf("clone() = %ld\n", (long)child_pid);
waitpid(child_pid, NULL, 0);
printf("child terminated!\n");
return EXIT_SUCCESS;
}
从上图中我们可以看到,子进程自己无论是调用getpid()函数还是bash中执行 echo $$ 中看到的pid都是新ns的pid,说明clone时创建新ns已生效。但是通过ps看到bash的pid却是和父进程是同一个视角,这个是因为ps 依赖于proc文件系统,我们没有单独挂载文件系统导致的。要想解决这个问题,我们只需要步即可:
- 调用clone时传入CLONE_NEWNS flag
- 重新挂载子进程的proc即可
完整源代码如下:
clone_v2.c
#define _GNU_SOURCE
#include
#include
#include
#include
#include
#define STACK_SIZE 65535
char *const bash_args[] = {
"/bin/bash",
NULL
};
int childBash() {
printf("child bash my PID: %ld\n", (long)getpid());
execv(bash_args[0], bash_args);
return -1;
}
int childProcess() {
printf("child process my PID: %ld\n", (long)getpid());
// 重新挂载proc
system("mount -t proc proc /proc");
char *stack = malloc(STACK_SIZE);
if (!stack) {
perror("Failed to allocate memory\n");
return EXIT_FAILURE;
}
pid_t child_pid = clone(childBash, stack + STACK_SIZE, SIGCHLD, NULL);
printf("bash() = %ld\n", (long)child_pid);
waitpid(child_pid, NULL, 0);
return 0;
}
int main(int agrc, char *argv[]) {
char *stack = malloc(STACK_SIZE);
if (!stack) {
perror("Failed to allocate memory\n");
return EXIT_FAILURE;
}
// 增加CLONE_NEWNS flag
pid_t child_pid = clone(childProcess, stack + STACK_SIZE, CLONE_NEWPID |CLONE_NEWNS | SIGCHLD, NULL);
printf("clone() = %ld\n", (long)child_pid);
waitpid(child_pid, NULL, 0);
printf("child terminated!\n");
return EXIT_SUCCESS;
}
效果如下:
现在我们看到,几个视图下都一样了。
注意:如果你运行完上述程序退出后执行ps 命令发现报错“Error, do this: mount -t proc proc /proc”,说明你的proc默认是shared挂载的,可运行“mount --make-private /proc”将proc显式指定为private挂载方式。具体原因可搜索“Linux 挂载传播”相关内容。
setns.c
#define _GNU_SOURCE
#include
#include
#include
#include
#include
#define errExit(msg) do { perror(msg); exit(EXIT_FAILURE); \
} while (0)
char *const bash_args[] = {
"/bin/bash",
NULL
};
int childBash() {
printf("child bash my PID: %ld\n", (long)getpid());
execv(bash_args[0], bash_args);
return -1;
}
int main(int argc, char *argv[]) {
int fd;
if (argc < 2) {
fprintf(stderr, "%s /proc/PID/ns/FILE \n", argv[0]);
exit(EXIT_FAILURE);
}
fd = open(argv[1], O_RDONLY); /* Get descriptor for namespace */
if (fd == -1)
errExit("open");
if (setns(fd, 0) == -1) /* Join that namespace */
errExit("setns");
// 这里有坑, Linux man手册中的例子不能改变pid ns
// 因为pid namespace 比较特殊, 调用后无法改变当前进程的pid ns
// 所以直接在父进程中exec 是不能改变pid ns 的
// 必须得 fork 一个子进程, 子进程的pid ns才会被加入新 pid ns
pid_t pid = fork();
if (pid == -1) {
errExit("fork");
}
if (pid > 0) {
waitpid(pid, NULL, 0);
} else {
childBash();
}
}
我们使用上个例子中clone.c先创建一个新的pid ns,再使用setns切换进新创建的pid ns后,可以看到新进程已经被加入了clone.c创建出来的新pid ns。事实上,docker exec命令就是使用此系统调用实现的。
unshare.c
#define _GNU_SOURCE
#include
#include
#include
#include
#include
#define errExit(msg) do { perror(msg); exit(EXIT_FAILURE); \
} while (0)
char *const bash_args[] = {
"/bin/bash",
NULL
};
int childBash() {
printf("child bash my PID: %ld\n", (long)getpid());
execv(bash_args[0], bash_args);
return -1;
}
int main(int argc, char *argv[]) {
if (unshare(CLONE_NEWPID) == -1)
errExit("unshare");
pid_t pid = fork();
if (pid == -1) {
errExit("fork");
}
if (pid > 0) {
printf("child pid %ld\n", (long)pid);
waitpid(pid, NULL, 0);
} else {
childBash();
}
}
运行后可以看到unshare会分离当前ns,子进程也会加入新ns中。
- clone调用时会创建新进程,可通过设置flags参数将新进程加入新namespace中
- setns可将进程加入一个已存在的namespace中
- unshare可分离当前的namespace到新的namespace中
- CLONE_NEWPID namespace较为特殊,setns和unshare系统调用无法将调用者的pid ns更改为新的ns
- 如果 proc下的ns文件(/proc/xxx/ns/xx)被打开或者挂载,即使改ns所拥有的的进程已经挂掉,那么该ns也不会被删除。
- 同一个pid namespace下的1号进程,具有信号屏蔽功能,即同一个pid namespace下的其他进程即使有root权限,发给1号进程的信号会被屏蔽掉。但是其父级namespace中进程可以发给子namespace中1号进程SIGKILL 或 SIGSTOP 信号,此时该ns下所有的进程都会收到SIGKILL信号,从而都被杀死。
- 使用setns或者unshare切换 pid ns时,调用者进程并不会进入新的pid ns中。因为进入新的pid ns会导致用户态程序看到的的pid发生变化,从而导致引发程序崩溃。