本以为这么成熟的东西应该很简单,但是没想到,坑深不见底,所以只从用法来,原理层得有更多的沉淀才能写出来。
完全搞定的话起码得掌握如下知识:
golang
namespace
cgroup
capabilities
桥接网络
unionfs(overlay)
这个是虚拟化
的核心知识,也是kernel
中关于资源隔离的实现,内核中提供的隔离方式有如下几种:
#define CLONE_NEWNS 0x00020000 /* New mount namespace group */ 文件系统
#define CLONE_NEWCGROUP 0x02000000 /* New cgroup namespace */ 物理资源限制
#define CLONE_NEWUTS 0x04000000 /* New utsname namespace */ 主机名和域名
#define CLONE_NEWIPC 0x08000000 /* New ipc namespace */ 信号量,消息队列和共享内存
#define CLONE_NEWUSER 0x10000000 /* New user namespace */ 用户和用户组
#define CLONE_NEWPID 0x20000000 /* New pid namespace */ 进程号
#define CLONE_NEWNET 0x40000000 /* New network namespace */ 网络设备,网络栈,端口等网络资源
简单来阐述一下就是,namespace
可以看作是进程的一个属性,进程享有当前namespace
中的资源,而一个namespace
中可以有多个进程,他们共享这个namespace
的资源,而当你修改了namespace
中的资源后,也只会影响当前namespace
下的进程。
为了控制namespace
linux自然提供了相应的API用在开发时使用:
clone()
setns()
unshare()
三种API分别对应了三种情况:
- 创建进程时同时创建新的
namespace
- 将进程加入到一个已经存在的
namespace
- 在一个已经存在的进程上进行
namespace
隔离
写一份代码简单测试一下:
/*==============================================================================
# Author: lang [email protected]
# Filetype: C source code
# Environment: Linux & Archlinux
# Tool: Vim & Gcc
# Date: 2019.09.17
# Descprition: namespace learning
================================================================================*/
#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#define STACK_SIZE (1024*1024) /* Stack size for cloned child */
static char child_stack[STACK_SIZE];
int child_main(){
printf("进入子进程\n");
char *arg[] = {"/bin/bash",NULL};
char *newhostname = "UTSnamespace";
sethostname(newhostname,sizeof(newhostname));
execv("/bin/bash",arg);
return 1;
}
int main(void){
printf("创建子进程\n");
int child_pid = clone(child_main,child_stack+STACK_SIZE,CLONE_NEWUSER | CLONE_NEWIPC | CLONE_NEWCGROUP | CLONE_NEWNET | CLONE_NEWNS | CLONE_NEWPID | CLONE_NEWUTS | SIGCHLD,NULL);
waitpid(child_pid,NULL,0);
printf("退出子进程\n");
return 0;
}
按照最后的效果来说,实际上也只有network
和rootfs
上有些问题,可以通过执行ifconfig
和ps
来验证这两个问题。
但是继续使用的时候就会发现user
其实也是一个问题,因为按照道理来说,一个虚拟化空间中,使用的初始用户应该是root
才是,也就是一个在虚拟化进程中具有所有资源访问权限的用户,但是为了安全,这个用户在其父user namespace
应该为一个普通权限。但是就像如上代码运行的结果一样,只是单纯的新建一个user namespace
,会导致虚拟化进程user namespace
中的user
为如下这种情况:
[nobody@g0dA lang]$ id
uid=65534(nobody) gid=65534(nobody) 组=65534(nobody)
现在很多发行版默认禁用了非特权用户命名空间:
kernel.unprivileged_userns_clone
,因此可以执行sysctl kernel.unprivileged_userns_clone=1
此刻的userid
和groupid
因为没有映射,使用的是由/proc/sys/kernel/overflowuid(overflowgid)
提供出来的默认映射ID。
首先要知道linux当进程要去读取/写入文件的时候,内核都会检查该进程user namespace
的uid
和gid
,以确认是否具有权限,简单来说内核注重的是uid
和gid
,而只有通过映射
后,才能控制一个user namespace
的用户在其余user namespace
中的权限,这点的重要性主要体现在诸如给其余user namespace中进程发送信号
或者是访问其余user namespace
的文件。
关于进程的user namespace
的映射需要用到/proc/PID/uid_map(gid_map)
,两个内容格式相同:
ID-inside-ns ID-outside-ns length
为了安全性考虑,对于两个文件的写入也有着严格的权限限制:
- 两个文件只允许拥有该
user namespace
中CAP_SETUID
权限的进程写入一次且不允许修改 - 写入的进程必须是
user namespace
的父namespace
或者是子namespace
- 最后一个字段通常填
1
表示只映射一个,如果填写大于1则按照顺序一一映射
那如果用宿主机的root
映射user namespace
中的root
会有问题吗?
答案是不会,其实docker
就是这种映射方式,然而当子user namespace
的用户访问父user namespace
的资源的时候,启动进程的capabilities
都为空,所以虽然是root
映射,然而子user namespace
的root
在父user namespace
中只相当于一个普通用户。
Linux 3.19后对
gid_map
做了更改,需要先向/proc/PID/setgroups
文件中写入deny
才能修改gid_map
,这点是为了安全性考虑。因为在user namespace
中,一个普通的帐号的新的user namespace
中有了所有的capabilities
,就可以通过调用setgroups
让自己获取更大的权限
修改一下代码:
/*==============================================================================
# Author: lang [email protected]
# Filetype: C source code
# Environment: Linux & Archlinux
# Tool: Vim & Gcc
# Date: 2019.09.17
# Descprition: namespace learning
================================================================================*/
#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/capability.h>
#define STACK_SIZE (1024*1024) /* Stack size for cloned child */
int parent_uid;
int parent_gid;
static char child_stack[STACK_SIZE];
//[...]
void set_uid_map(pid_t pid, int inside_id, int outside_id, int length) {
char path[256];
sprintf(path, "/proc/%d/uid_map", pid);
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) {
/* 3.19之后需要先修改/proc/PID/setgroups
* 将内容从allow修改为deny
* 否则无法修改gid_map的内容
* */
char path2[256];
sprintf(path2,"/proc/%d/setgroups",pid);
FILE* setgroups = fopen(path2,"w");
fprintf(setgroups, "deny");
fclose(setgroups);
char path[256];
sprintf(path, "/proc/%d/gid_map", pid);
FILE* gid_map = fopen(path, "w");
fprintf(gid_map, "%d %d %d", inside_id, outside_id, length);
fclose(gid_map);
}
int child_main(){
printf("进入子进程:%d\n",getpid());
cap_t caps;
set_uid_map(getpid(), 0, parent_uid, 1);
set_gid_map(getpid(), 0, parent_gid, 1);
caps = cap_get_proc();
printf("capabilities: %s\n",cap_to_text(caps,NULL));
char *arg[] = {"/bin/bash",NULL};
char *newhostname = "UTSnamespace";
//sethostname(newhostname,sizeof(newhostname));
execv("/bin/bash",arg);
return 1;
}
int main(void){
printf("创建子进程\n");
parent_uid = getuid();
parent_gid = getgid();
int child_pid = clone(child_main,child_stack+STACK_SIZE,CLONE_NEWUSER | CLONE_NEWIPC | CLONE_NEWCGROUP | CLONE_NEWNET | CLONE_NEWNS | CLONE_NEWUTS | SIGCHLD,NULL);
waitpid(child_pid,NULL,0);
printf("退出子进程\n");
return 0;
}
当
user namespace
被创建后,第一个进程
被认定为init
进程,需要被赋予该namespace
中的全部capabilities
,这样才能完成所有必要的初始化工作。
这个点得结合mount namespace
一起说。mount namespace
会隔离文件系统的挂载点,使得不同的mount namespace
拥有独立的挂载信息,并且不会相互影响,当clone
或者unshare
创建新的mount namespace
的时候,新创建的namespace
会拷贝一份老namespace
的挂载列表,从此之后相互之间的挂载卸载不会相互影响,这些对于构建专属文件系统目录非常有用。
这儿得引入docker
的一个文件系统技术原理 -- UnionFS
,将多个目录内容联合挂载到相同的目录下,而目录的物理位置是分开的。
UnionFS
的实现也有很多技术:
aufs
overlay
DeviceMapper
具体是用哪一种,其实还要看一下你自己的系统支持,因为这些实际上这是挂载格式而已,那么看一下系统支持的文件系统类型是有必要的。
/lib/modules/kernl-version/kernel/fs/
到此目录下查看当前内核版本支持的文件系统。不过也可以看一下docker
使用的情况:
$docker info | grep Storage
Storage Driver: overlay2
因此一个虚拟化容器启动后,最好的方式是通过pivot_root
的方式切换当前根目录,挂载到全新的rootfs
上。而这个全新的rootfs
可以通过overlayfs
创建出来,这样可以达到一个读写分层的效果。
sudo mount -t overlay overlay -olowerdir=LLL,upperdir=UUU,workdir=WWW MMM
其中还有关于aufs的一段轶事, aufs不能进入内核的原因很有意思
而关于文件系统
的分层还有一个比较重要的原因就是很多用户态工具都是配合文件系统
来使用,因此如果还是用host
的文件系统
就无法达成隔离的效果。
这部分是虚拟化容器里非常有意思的一点,倘若只是单纯的做了网络隔离,那么新的network namespace
会有一个本地环回接口,而且还是默认关闭的,需要手动开启。
网络这一块我是真的差,unix网络这一块还没开始学习,很烦
就解决方案来说,最广泛的就是docker
本身在用的方法,用bridge
和veth-pair
结合做出来的容器网络。
普通用户执行某些管理员才有权限的操作,有三种方法:
- sudo
- SUID
- capabilities
关于capabilities的机制很简单,就是在权限检查时,当执行线程的身份是非root时,会检查该线程是否具有特权操作对应的capabilities然后决定是否继续执行。
capability 名称 | 描述 |
---|---|
CAP_AUDIT_CONTROL | 启用和禁用内核审计;改变审计过滤规则;检索审计状态和过滤规则 |
CAP_AUDIT_READ | 允许通过 multicast netlink 套接字读取审计日志 |
CAP_AUDIT_WRITE | 将记录写入内核审计日志 |
CAP_BLOCK_SUSPEND | 使用可以阻止系统挂起的特性 |
CAP_CHOWN | 修改文件所有者的权限 |
CAP_DAC_OVERRIDE | 忽略文件的 DAC 访问限制 |
CAP_DAC_READ_SEARCH | 忽略文件读及目录搜索的 DAC 访问限制 |
CAP_FOWNER | 忽略文件属主 ID 必须和进程用户 ID 相匹配的限制 |
CAP_FSETID | 允许设置文件的 setuid 位 |
CAP_IPC_LOCK | 允许锁定共享内存片段 |
CAP_IPC_OWNER | 忽略 IPC 所有权检查 |
CAP_KILL | 允许对不属于自己的进程发送信号 |
CAP_LEASE | 允许修改文件锁的 FL_LEASE 标志 |
CAP_LINUX_IMMUTABLE | 允许修改文件的 IMMUTABLE 和 APPEND 属性标志 |
CAP_MAC_ADMIN | 允许 MAC 配置或状态更改 |
CAP_MAC_OVERRIDE | 忽略文件的 DAC 访问限制 |
CAP_MKNOD | 允许使用 mknod() 系统调用 |
CAP_NET_ADMIN | 允许执行网络管理任务 |
CAP_NET_BIND_SERVICE | 允许绑定到小于 1024 的端口 |
CAP_NET_BROADCAST | 允许网络广播和多播访问 |
CAP_NET_RAW | 允许使用原始套接字 |
CAP_SETGID | 允许改变进程的 GID |
CAP_SETFCAP | 允许为文件设置任意的 capabilities |
CAP_SETPCAP | 参考 capabilities man page |
CAP_SETUID | 允许改变进程的 UID |
CAP_SYS_ADMIN | 允许执行系统管理任务,如加载或卸载文件系统、设置磁盘配额等 |
CAP_SYS_BOOT | 允许重新启动系统 |
CAP_SYS_CHROOT | 允许使用 chroot() 系统调用 |
CAP_SYS_MODULE | 允许插入和删除内核模块 |
CAP_SYS_NICE | 允许提升优先级及设置其他进程的优先级 |
CAP_SYS_PACCT | 允许执行进程的 BSD 式审计 |
CAP_SYS_PTRACE | 允许跟踪任何进程 |
CAP_SYS_RAWIO | 允许直接访问 /devport、/dev/mem、/dev/kmem 及原始块设备 |
CAP_SYS_RESOURCE | 忽略资源限制 |
CAP_SYS_TIME | 允许改变系统时钟 |
CAP_SYS_TTY_CONFIG | 允许配置 TTY 设备 |
CAP_SYSLOG | 允许使用 syslog() 系统调用 |
CAP_WAKE_ALARM | 允许触发一些能唤醒系统的东西(比如 CLOCK_BOOTTIME_ALARM 计时器) |
而Linux capabilities
分为进程capabilities
和文件capabilities(文件扩展属性)
。
线程的有5种capabilities
集合:
- Permitted:定义了线程
capabilities
的上限,Effectice
和Inheritable
集合中的capabilities
必须包含在此集合中。 - Effective:线程执行时内核会检查的
capabilities
集合,决定线程是否能执行特权操作 - Inheritable:执行
exec()
调用后,能够被继承的capabilities
,但是此集合中的capabilities
是被添加到新线程的Permitted
集合中,而不是添加到Effective
- Bounding:
Inheritable
的超集,如果capabilities
不在此集中,即使是在Permitted
中存在线程也不能把capabilities
添加到Inheritable
中,执行fork()
会传递给子进程该集合,并且在execve
后依然保持不变。 - Ambient:
4.3 kernel
之后的特性,简单来说在此集合中的capabilities
会被自动继承下去。
文件capabilities
需要有文件系统的支持,如果挂载时使用了nouuid
,则所有capabilities
会被忽略。
直接引用别人整理的公式:
我们用 P 代表执行 execve() 前线程的 capabilities,P' 代表执行 execve() 后线程的 capabilities,F 代表可执行文件的 capabilities。那么:
P’(ambient) = (file is privileged) ? 0 : P(ambient)
P’(permitted) = (P(inheritable) & F(inheritable)) |
(F(permitted) & P(bounding))) | P’(ambient)
P’(effective) = F(effective) ? P’(permitted) : P’(ambient)
P’(inheritable) = P(inheritable) [i.e., unchanged]
P’(bounding) = P(bounding) [i.e., unchanged]
- 如果用户是 root 用户,那么执行 execve() 后线程的 Ambient 集合是空集;如果是普通用户,那么执行 execve() 后线程的 Ambient 集合将会继承执行 execve() 前线程的 Ambient 集合。
- 执行 execve() 前线程的 Inheritable 集合与可执行文件的 Inheritable 集合取交集,会被添加到执行 execve() 后线程的 Permitted 集合;可执行文件的 capability bounding 集合与可执行文件的 Permitted 集合取交集,也会被添加到执行 execve() 后线程的 Permitted 集合;同时执行 execve() 后线程的 Ambient 集合中的 capabilities 会被自动添加到该线程的 Permitted 集合中。
- 如果可执行文件开启了 Effective 标志位,那么在执行完 execve() 后,线程 Permitted 集合中的 capabilities 会自动添加到它的 Effective 集合中。
- 执行 execve() 前线程的 Inheritable 集合会继承给执行 execve() 后线程的 Inheritable 集合。
不过如果是通过fork()
的话,子进程则完全复制父进程的capabilities
。
首先记录一个好用的命令:
ps xf -o pid,ppid,stat,args
在真实系统下当孤儿进程
产生后,内核会按照如下的方式去收养在其退出时去回收PCB
,以免产生僵尸进程,这是init进程
程序提供的功能:
- 找到相同线程组里其它可用线程
- 沿着它的进程树向祖先进程找一个最近的
child_subreaper
并且运行着的进程
kernel 3.4以后支持
prctl
系统调用,将调用进程标记为child subreaper
属性,获取到收养能力
,但是仅限于收养
,资源回收方面还需要自己写逻辑,这是pid_namespace
的一个属性
- 该namespace下进程号为1的进程
这涉及到kernel
中的两个函数:
forget_original_parent()
find_new_reaper()
然而在docker容器
内部,1号
进程极大可能是一个无init
能力的进程,即entrypoint进程
,简单来说,就是最终被内部的1号进程
接管,然后又因为此进程没有调用wait/waitpid
获取该进程状态信息从而导致该孤儿进程
在结束/退出
时变成僵尸进程
,长期驻留在系统中,最后占用所有的进程号从而导致系统无法产生新进程返回EAGAIN
。
说段历史,docker
在1.11
版本以后更改了架构
Runc
进程会在创建容器后自动退出,然后容器的初始进程被Containerd-shim
进程领养,因为shim
设置了child_subreaper
,其后代(runc
)产生的孤儿进程
都会被该进程领养,而有因为containerd-shim
进程在容器的PID namespace
中不可见,所以在容器中是属于0号进程
。
但是这儿却有了BUG产生,也或许是docker
和kernel
的不合,那就是在kernel 4.4
以前,find_new_reaper
函数在查找祖先进程的child_subreaper
属性,并没有限制ns_level
,这就导致容器内部的所有进程拖孤
都会查找到containerd-shim
进程,而又恰好containerd-shim
有拖孤能力,就完美的解决了僵尸进程的产生。
然而kernel 4.4
以上打了一个patch
From c6c70f4455d1eda91065e93cc4f7eddf4499b105 Mon Sep 17 00:00:00 2001
From: Oleg Nesterov <[email protected]>
Date: Mon, 30 Jan 2017 19:17:35 +0100
Subject: exit: fix the setns() && PR_SET_CHILD_SUBREAPER interaction
find_new_reaper() checks same_thread_group(reaper, child_reaper) to
prevent the cross-namespace reparenting but this is not enough if the
exiting parent was injected by setns() + fork().
Suppose we have a process P in the root namespace and some namespace X.
P does setns() to enter the X namespace, and forks the child C.
C forks a grandchild G and exits.
The grandchild G should be re-parented to X->child_reaper, but in this
case the ->real_parent chain does not lead to ->child_reaper, so it will
be wrongly reparanted to P's sub-reaper or a global init.
Signed-off-by: Oleg Nesterov <[email protected]>
Signed-off-by: Eric W. Biederman <[email protected]>
---
kernel/exit.c | 13 ++++++++-----
1 file changed, 8 insertions(+), 5 deletions(-)
diff --git a/kernel/exit.c b/kernel/exit.c
index 8f14b86..5cfbd59 100644
--- a/kernel/exit.c
+++ b/kernel/exit.c
@@ -578,15 +578,18 @@ static struct task_struct *find_new_reaper(struct task_struct *father,
return thread;
if (father->signal->has_child_subreaper) {
+ unsigned int ns_level = task_pid(father)->level;
/*
* Find the first ->is_child_subreaper ancestor in our pid_ns.
- * We start from father to ensure we can not look into another
- * namespace, this is safe because all its threads are dead.
+ * We can't check reaper != child_reaper to ensure we do not
+ * cross the namespaces, the exiting parent could be injected
+ * by setns() + fork().
+ * We check pid->level, this is slightly more efficient than
+ * task_active_pid_ns(reaper) != task_active_pid_ns(father).
*/
- for (reaper = father;
- !same_thread_group(reaper, child_reaper);
+ for (reaper = father->real_parent;
+ task_pid(reaper)->level == ns_level;
reaper = reaper->real_parent) {
- /* call_usermodehelper() descendants need this check */
if (reaper == &init_task)
break;
if (!reaper->signal->is_child_subreaper)
--
reaper
查找的流程中限制了ns_level
,这就导致docker本来处理僵尸进程
的能力又失去了,因此在1.13
后又产生了大的改动,就是在docker run
的启动参数中加了一个--init
的传参,会生成一个/sbin/docker-init
程序并启动。这样在docker启动后其实先启动的是init
程序,然后由init
程序去拉起entrypoint
进程,这样容器内再有孤儿进程产生
会被直接挂载到init
进程下,而此init进程
又是真正具有处理能力的程序,从而避免了僵尸进程
的产生,且1号进程
被kill
时,会销毁该pid namespace
,相同pid namespace
中的所有进程都将收到SIGKILL
信号而销毁掉,就此docker
和kernel
又回到了一个相互和谐的时代。
这个
docker-init
是tini,很好用的一个容器内init程序
- 深入理解Docker容器引擎runC执行框架
- Docker背后的内核知识——Namespace资源隔离
- RunC 是什么?
- runc source code——network
- managing-containers-runc
- master/config.md
- Linux的capability深入分析(2)
- runc 启动容器过程分析(附 CVE-2019-5736 实现过程)
- linux-capabilities机制
- Docker技术原理之Linux UnionFS(容器镜像)
- 理解Docker(3):Docker 使用 Linux namespace 隔离容器的运行环境
- Linux Namespace系列(07):user namespace (CLONE_NEWUSER) (第一部分)
- linuxea:了解uid和gid如何在docker容器中工作
- linux namespace
- Linux Namespaces in operation記錄 - part 5
- aufs不能进入内核的原因
- 把玩overlay文件系统
- Docker存储驱动之--overlay2
- Linux Capabilities 入门教程:概念篇
- 谁是Docker容器的init(1)进程
- PID namespace与Docker容器init进程
- 进程托孤