linux namespace
linux namespace
linux通过namespace技术为进程提供虚拟视图,这项技术是容器的基础。本文主要介绍每个namespace的实现原理,但很可能不对技术本身做探讨。比如会讨论如何实现cgroup的虚拟视图,但不会研究cgroup的控制器的实现原理。
目前在内核(v5.19-rc2,为写作之日的最新版本)中已经支持的namespace有8个。
1 | https://github.com/torvalds/linux/blob/v5.19-rc2/include/linux/nsproxy.h#L31 |
这些namespace起作用的场景是3个系统调用:
- clone, 他接收namespace等参数,并完成fork进程的功能。
https://man7.org/linux/man-pages/man2/clone.2.html - unshare,他创建新的namespace,并把本进程放到新namespace内。
https://man7.org/linux/man-pages/man1/unshare.1.html - setns, 把当前进程加入到某些指定的namespace。
https://man7.org/linux/man-pages/man2/setns.2.html
在分别讲每一个namespace之前,我们大致了解下内核代码的相关结构。
1 | // 内核中用struct task_struct结构表示进程,其中包含nsproxy字段,其中包含8个namespace中的7个,而另一个namespace则包含在real_cred和cred中。 |
当我们调用clone系统调用时,会新建task_struct,同时根据参数决定是否新建namespace。
当调用fork时,会新建task_struct,但不会新建namespace。
当调用unshare时,根据参数决定是否新建namespace,但不会新建task_struct。
当调用setns时,不会新建task_struct,也不会新建namespace。
这里特别解释一下unshare,作为一个系统调用,他把当前进程加入某些新的namespace,但是linux有个命令也叫unshare,这个命令的行为是unshare进程本身加入某些新的namespace,然后这个进程把自己切换成指定的执行体。比如我执行unshare -T bash
那么unshare进程会启动,然后把自己加入到一个新的time namespace(通过unshare系统调用),并直接启动执行体bash(通过execvp系统调用),也就是接下来这个进程变成unshare的身体,bash的灵魂。
知道了这几个场景后,下面介绍的时候可能只会介绍其中一种场景,比如大部分情况下clone和unshare对namespace的逻辑是一致的,所以会只讲clone或只讲unshare。再比如setns和fork不会新建namespace,因此相对逻辑少一点,就基本上很少讲到这两个调用。
好了下面分别讲每个namespace的原理。
uts namespace
演示
1 | # 主机中 |
可以看到主机的hostname跟那个进程的hostname是隔离的
注意以上修改hostname用hostnamectl的话不成立,这里不展开。
介绍
uts namespace设置hostname和nis domain的虚拟视图,nis(网络信息服务)介绍如下,但这已经是一项过时的技术,所以我们不去关注, 只需要关注hostname。
1 | https://docs.freebsd.org/doc/7.1-RELEASE/usr/share/doc/zh_CN/books/handbook/network-nis.html |
另外要说的是uts全称是UNIX Time-Sharing,这个名称跟时间无关,他的意思表达的是多用户的分时系统,所以目的是让多用户看到不同的信息,所以这里uts ns也就是让不同的进程看到不同的系统信息。
原理
由于nis我们不关注,因此这里只关注uts namespace对hostname的影响。
uts namespace的逻辑比较简单,在进程结构task_struct->nsproxy->uts_ns->name中保存了一个hostname等信息:
1 | // 进程结构 |
当前面指定的clone系统调用被执行时,将发生namespace是否新建的检查,如果没有指定CLONE_NEWUTS,那么子进程和父进程共享同一个结构,也就是子进程的task_struct->nsproxy->uts_ns指针与父进程相同,如果指定CLONE_NEWUTS参数,那么就会从父进程拷贝一份赋给子进程。后续gethostname和sethostname都是直接操作task_struct->nsproxy->uts_ns->name这个结构,因此可以做到每个进程有自己hostname。unshare的逻辑与clone相似,差别是unshare只新建nsproxy不新建task_struct。
1 | // 获取hostname的系统调用,将从task_struct->nsproxy->uts_ns->name结构中拷贝nodename结构。同样sethostname逻辑类似,不做列举。 |
再补充一点new_utsname中不止nodename,还包含其他字段,所有字段加起来就是uname命令能查看的全部信息。
1 | struct new_utsname { |
只是除了nodename(hostname)和domainname(nis)外并没有提供字段的设置方法,所以其他字段是没法修改的,所以子进程看到的其他字段跟父进程是一样的,整个主机看到的也都是一样的。
ipc namespace
ipc namespace主要负责隔离3个进程间通信的资源,一个是消息队列(Message queues),一个是共享内存(Share Memory),一个是信号量(Semaphore), 这些 IPC 机制的共同特点是 IPC 对象由文件系统路径名以外的机制标识。他们的实现隔离的方法也比较简单,跟uts ns类似,在进程中存储一份独立的ipc相关的资源,后续的系统调用都从进程的这个资源中进行操作。在创建进程的时候如果不指定CLONE_NEWPID参数则与父进程共享task_struct->nsproxy->ipc_ns,如果指定CLONE_NEWPID参数,则调用create_ipc_ns新建:
1 | static struct ipc_namespace *create_ipc_ns(struct user_namespace *user_ns, struct ipc_namespace *old_ns) |
后续相关的ipc操作都会操作namespace下的资源:
1 | SYSCALL_DEFINE3(semget, key_t, key, int, nsems, int, semflg) |
可以看到semget是从当前进程的ipc_ns(current->nsproxy->ipc_ns)中去get的,其他shmget和msgget也是一样的。
mnt namespace
演示
1 | # 创建临时目录xfs |
可以看到mount namespace中的mount不会影响主机的mount。
再来看一个例子:
1 | # 找到k8s中位于server2主机上的一个pod |
可以看到容器中的文件系统根主机的是不同的
介绍
mount namespace用于给进程提供一个独立的目录结构,也就是他看到的/xxx目录跟主机上的/xxx是不同的,甚至主机上可以不存在这个xxx目录。他采用CLONE_NEWNS标志位表示是否需要新建mount namespace, 这个namespace是linux最早引入的namespace,当时只有这一种namespace,所以直接把NS作为名字来代表他,沿用至今。
原理
当前面指定的clone系统调用被执行时,将发生namespace是否新建的检查,如果没有指定CLONE_NEWNS,那么子进程和父进程共享同一个结构,也就是子进程的task_struct->nsproxy->mnt_ns指针与父进程相同,所以子进程和父进程mount的是同样的节点和内容, 如果指定CLONE_NEWNS参数,那么会遍历父进程的每一个mount,全部拷贝一份给子进程,mount列表存储在进程的task_struct->nsproxy->mnt_ns结构中, 拷贝后子进程跟父进程看到的文件结构视图还是跟父进程一样的。只有在重新卸载和挂载不同文件系统后子进程才能看到不同的文件视图。
mount结构存储在struct mnt_namespace,并以两种组织方式存在,一个是树形结构,一个是链表结构,只是组织方式不同,用于不同场景。
1 | struct mnt_namespace { |
内核在拷贝mout列表时,采用深度遍历root字段进行拷贝的方式来生成新的root和list字段。
图片基于mnt_namespace的拷贝过程解读(copy_tree函数)
mount的挂载过程有点复杂,但是mount namespace提供视图隔离的过程却很简单。
当新进程创建了mount ns后,由于在task_struct->nsproxy->mnt_ns存储了进程独有的所有mount,所以进程可以独立的更改自己文件系统。(但是用户调用mount时,具体流程中是哪个函数关联了mnt ns我还是找不到,望知道的同学告知。)接下来我们来看如何让子进程看到不同的视图,我们已经已经知道了这需要一个重新挂载文件系统的过程,那么是用什么方法来挂载呢?
先补充一个知识,什么是根文件系统rootfs,有些地方把rootfs专用于指内核启动之后挂载的一个小型文件系统,内核启动后内存中是空的,这时候他会加载一个小型的文件系统到内存中,这个文件系统会挂载到根目录”/“,整个系统中包含启动所需的bin和lib,这个文件系统就叫rootfs,于是内核执行rootfs中的init进程,init进程于是找到磁盘中的文件系统,并执行系统调用把根目录切换到磁盘中的大文件系统,init重启自己,重启的就是磁盘中的init了。我们看linux系统中boot目录下的两个主要的文件,vmlinuz就是内核,initrd.img就是rootfs。至于内核跟rootfs为啥分开分步加载,是为了内核保持稳定,rootfs保持开放。
1 | ~$ ls /boot |
这种定义rootfs的方式比较狭义,还有一种广义的说法,rootfs就是挂载到”/“的文件系统,而其他所有的文件系统都是挂载到rootfs中的某个节点。从这个角度说rootfs就是一个进程看到的文件视图。我们可以采用这个个广义的说法来理解。
那么为了给进程一个全新的视图,我们的思路就很清楚了,1:创建进程携带创建CLONE_NEWNS参数,2:给子进程挂载rootfs,3:启动子进程。 这样甚至子进程的执行文件都可以来自于新的rootfs,非常的灵活。能实现这个功能的两个系统调用一个是chroot一个是pivot_root,他俩的内核代码分别是:
1 | // 把进程所在mnt ns的所有进程的rootfs设为new_root, 并且原root挂到新root的put_old下 |
对比来看突出一点是,pivot_root改变了整个mount ns下进程的root,而chroot是改变相同fs结构的进程的root。但是本质上都可以实现我们的功能。
还有个区别是chroot可以切换到任意路径,但是pivot_root要求新root得是独立的文件系统,也就是能够从原来的rootfs中卸载,同时还要求put_old得是新root下的某个目录,因为原root还得挂回新root的put_old下。你可能会觉的新root下挂个老的root不是很奇怪吗也不安全,所以实际中调用完pivot_root后往往会把put_old卸载,然后删除put_old目录。
pid namespace
演示
1 | # 在主机上查看进程1的stat。 |
可以看到两个进程看到的1号进程的信息是不同的。这里不是要讲procfs,而是想说,在pid namespace中,pid是独立分配的。
这里介绍一下上面unshare命令中的几个参数:
首先-p以及–fork是配合的,我们知道unshare默认是不会新建进程的,如果我们想通过-p来创建新的pid ns,虽然是新建成功了,但是此进程并不会真的加入到新的pid中,pid ns只能在进程新建时设置并起作用,所以我们这里通过–fork来新建一个进程, 默认新进程是${SHELL}。
接着-m和–mount-proc也是配合使用的,由于我们要查看/proc下的信息,而默认proc会继承父进程的proc,因此你只能看到原来的proc信息,为了看到新的pid ns下的proc信息,我们需要重新挂载procfs,于是我们指定–mount-proc来挂载新的procfs,但是我们不能在挂载新的procfs时候影响到主机的/proc目录,因此我们这个新进程得新建一个mount namespace来隔离与主机之间mount信息,因此我们指定-m参数。不过这里-m可以不指定,–mount-proc会隐含-m参数。
介绍
pid namespace用来给进程提供一个虚拟的pid视图,一个pid namespace内的进程id可以独立分配,与其他pid namespace的pid可以重复。
原理
内核中进程结构中与pid相关几个字段
1 | struct task_struct { |
其中pid字段的类型是pid_t,这就是一个整数,这个pid是内核空间用来管理这个进程的,也就是可以理解成不存在namespace的时候进程的原本的一个id。有多少namespace都不影响这个值。
thread_pid是一个pid的结构体,这个结构很关键:
1 | struct pid |
其中level代表这个进程位于第几层namespace,原始的进程都是第0层,如果你创建了一个进程,并且没有指明新建pid namespace,那么这个新进程的level还是第0层,也就是和父进程一样的值。如果这个进程指明了要新建pid namespace,那么level就会加1,比父进程的level大1. 如果level值越大,代表嵌套的pid namespace越深。由于进程在每个namespace层次中都具体不同的pid,因此numbers这里记录了每一层中进程在其中的pid和这一层的namespace指针。这里numbers虽然数组长度是1,但是他是结构的最后一个字段,因此在实际使用numbers的时候是直接溢出访问的,只要使用者自己控制好内存的安全,可以把numbers当成任意长度的数组。
接下来是nsproxy中的pid_namespace结构,pid_namespace中也有level字段,表示这个namespace位于第level层:
1 | struct pid_namespace { |
现在我们看到两个地方都有level字段,一个是thread_pid->level
,一个是nsproxy->pid_ns_for_children->level
,大多数时候,一个进程的thread_pid->level
和nsproxy->pid_ns_for_children->level
,这两个值应该是相同的,因此thread_pid->numbers[thread_pid->level].nr
和thread_pid->numbers[nsproxy->pid_ns_for_children->level].nr
大部分时候是相等的。不相等的场景是,我们用unshare -p
把当前进程带入一个新的pid ns,这时候nsproxy->pid_ns_for_children->level
会+1,而thread_pid->level
不会+1,这就是我们在演示中提到需要--fork
参数来新建进程,否则这个进程的thread_pid->level
不会增加,因此进程实际并没有加入新的pid namespace,echo $$
拿到的pid也依然是thread_pid->numbers[thread_pid->level].nr
没有变。
那么对一个进程来说,他自己看到的自己的pid是哪一层的呢?当我们在进程中调用getpid的系统调用的时候,通过追踪代码,他实际是通过thread_pid->numbers[thread_pid->level].nr拿到的,也就是拿到thread_pid结构中最深层namespace下的pid。
1 | SYSCALL_DEFINE0(getpid) |
还剩两个问题,一个是pid_namespace是在什么时候新建的呢?
跟前面的namespace一样,pid namespace也是在clone或者unshare的时候根据标志位CLONE_NEWPID来决定是跟父进程一样还是新建一个。
另一个问题是进程新建的时候要每一层namespace都分配一个pid,那么如何做到每一层单独分配的。可以通过这个函数看到:
1 | struct pid *alloc_pid(struct pid_namespace *ns, pid_t *set_tid, |
关键信息是在每个pid namespace结构中维护了一个id分配器。
net namespace
演示
1 | # 把当前进程加入一个新建的net namespace中 |
可以看到主机和某个net namespace中的进程是独立的,不一样。
介绍
net namespace可以让进程看到自己独有的网络资源,包括网络设备,IPv4 IPv6协议栈, 端口号,路由表, 防火墙等,比如每个net ns有自己的lo。
原理
跟其他namespace一样,net ns也是在创建进程的时候通过标志位CLONE_NEWNET来决定是继承父进程的net结构还是自己新建一个net结构。而我们所知道的lo设备就是在新建net结构后的初始化过程中创建出来的:
1 | //复用或者新建net结构 |
再比如我们创建socket的时候,也会绑定到当前进程的net ns:
1 | // 创建socket |
net namespace之间通信
另外关于net namespace的注意点就是关于如何跨net ns通信,内核提供了veth pair,这是一种类似进程中的pipe的虚拟设备对,一对设备包括两个虚拟网卡,一个放入ns1,一个放入ns2,那么往一边写入数据,另一边就可以收到数据,方向则是双向的。
图片来自linux 网络虚拟化: network namespace 简介
那么为了让多个ns之间彼此通信该怎么做呢,这时候需要再引入一个网桥设备,这个设备起到一个交换机的作用,每个ns创建后,配套创建一个veth pair对,把veth pair的一端放入ns,另一端放到网桥上,这样ns之间就彼此联通了。
图片来自linux 网络虚拟化: network namespace 简介
比如在我的主机上执行:
1 | ubuntu@server2:~$ bridge link |
这里可以看到在名叫weave的这个网桥上挂的所有虚拟网卡,而每一个都是veth pair的一端,这可以从他们的名字上看出id:xxx@yyy
,这个格式表示序号为id的本网卡和yyy是一个网卡对,yyy可能只是名字上部分匹配对端的网卡名,需要稍微辨别一下。比如9: vethwe-bridge@vethwe-datapath
表示本网卡id是9,对端网卡名接近vethwe-datapath。比如12: vethwepl4866066@if11
表示本网卡id是12,对端网卡名接近if11。
但是目标网卡不一定存在于我们主机的初始net ns中,可能在一个容器的ns中,我们可以通过下面这行脚本来找出这个对端在什么地方,以寻找if11为例:
1 | ubuntu@server2:~$ ip netns | cut -f 1 -d " " | xargs -i{} sh -c 'echo {} && sudo ip netns exec {} ip addr | grep 11' |
可以看到if11是在cni-e2665039-d33a-9f75-17df-5c8c90a4f4ec
这个net ns下,是id为11的网卡。但是我们查找vethwe-datapath却不能用这个脚本,因为vethwe-datapath直接在主ns下,ip netns
不会列出主net ns所以需要直接执行ip addr来查看。
1 | ubuntu@server2:~$ ip addr | grep veth |
time namespace
演示
1 | # 把当前进程放入新建的time namespace中,下面这些命令和参数后面都会解释 |
可以看到主机和处于time namespace的进程,拿到的uptime是有差异的,是独立的。
介绍
time namespace影响的是两个时间,CLOCK_MONOTONIC
和CLOCK_BOOTTIME
。CLOCK_BOOTTIME
表示系统启动到现在的时间,CLOCK_MONOTONIC
表示CLOCK_BOOTTIME
-系统暂停的时间
,也就是启动后系统实际运转的时间。
但time namespace不影响系统的实时时间。也就是你调用date命令不会有什么改变的。
原理
和其他ns一样,time ns也是在进程创建时候指定了CLONE_NEWTIME参数后从父进程拷贝一份。time_namespace的结构体如下:
1 | struct time_namespace { |
他起作用的方式是通过指定monotonic和boottime这两个offset字段,随后在系统返回相应的时间的时候叠加上offset值。以uptime为例(sudo cat /proc/uptime):
1 | static int uptime_proc_show(struct seq_file *m, void *v) |
可以看到uptime返回前会叠加ns_offsets->boottime值。
那么offset值如何配置呢,是通过修改/proc/$$/timens_offsets
文件来做到的。timens_offsets文件的格式第一列是offset类别,第二列是offset的秒数,第三列是offset的纳秒数。下面我们操作一下:
1 | # 用uptime命令来读取boottimne,比直接读/proc/uptime文件可读性好 |
bash –norc背后的玄机
如果不感兴趣,这段可以不看,问题不大。
这里有一个注意点,我们执行unshare创建bash的时候,需要传递参数–norc,而且在我的注释中也写到在改offset前不能读offset和uptime,否则不管是没传–norc还是提前读了offset,都会导致后续写/proc/$$/timens_offsets报错Permission denied.
1 | ubuntu@server2:~$ sudo unshare -T -- bash |
1 | ubuntu@server2:~$ sudo unshare -T -- bash --norc |
这是为什么呢,本质上都是要求在修改offset之前不能创建子进程,norc参数可以让bash不要执行bashrc等脚本,执行脚本就会产生fork系统调用创建子进程,而读了offset也会fork进程。在fork进程的流程中,time_namespace结构中的frozen_offsets字段会被设置上,导致无法更改offset。
1 | struct time_namespace { |
那么有个很奇怪的问题,为什么这里echo这个指令引起的fork没有导致Permission denied??????
答案是因为bash把echo命令内置了,它并不会fork进程,如果你使用/bin/echo来echo的话就也会导致Permission denied。
1 | https://edoras.sdsu.edu/doc/bash/abs/internal.html |
那么为什么内核会有这样一个设定呢,为什么修改offset之前fork一下就不能修改了呢?
1 | https://lore.kernel.org/lkml/20191112012724.250792-3-dima@arista.com/t/ |
根据这个讨论记录可以看出,设计的目的是确保这个offset修改只发生在time ns的首个进程启动之前,启动后就不修改了防止对进程的影响。
但是unshare不受此约束,不会去设置frozen_offsets。上面他们的讨论中有提到unshare,不过我不太能看懂是否这是特意留下的一种修改offset方式。我比较倾向于这是设计者特意留下的一种修改已经启动的进程的offset的方法。
进程启动后的修改我们知道可以通过timens_offsets文件修改,那么进程启动前怎么修改?是通过vdso配置的,我也不懂就不展开了,可以自己有兴趣去搜一下。
但是还有个问题,frozen_offsets实际是配在子进程上的,只会对子进程生效,怎么bash创建一个子进程执行脚本后,自己也不能修改offset了呢,我们来捋一下这个流程。
在进程中保存time ns的字段实际有两个:
1 | struct nsproxy { |
对于unshare流程来说,这两个字段中time_ns直接和父进程的值相同,time_ns_for_children则根据有没有设置标志位选择与父进程的time_ns_for_children一致或者自己新建。假设我们初始进程的结构是:
1 | struct nsproxy { |
我们可以大概的认为time_ns是父进程的ns,time_ns_for_children是本进程的ns,而二者的差异可以判断ns是否新建。
当我们执行sudo unshare -T – bash(不带norc)时,我们新建出来的bash进程(也就是unshare进程, 此时是unshare的身体,bash的灵魂)的结构是:
1 | struct nsproxy { |
可见unshare之后新进程的time_ns保留了父进程的ns,而time_ns_for_children则新建了。这时候unshare不会为我们做别的了,也不会设置frozen_offsets。
接着bash会fork子进程来执行脚本或者执行命令行,这个过程不会设置CLONE_NEWTIME的标志位。这时候新建出来的子进程的结构是:
1 | struct nsproxy { |
跟父进程保持不变,但是还没完,fork会比unshare多做一步,会执行一遍timens_on_fork检查:
1 | void timens_on_fork(struct nsproxy *nsproxy, struct task_struct *tsk) |
这里time_ns和time_ns_for_children不相同(N1 != N3),表示有新的time ns创建了,这时候就会把time_ns_for_children赋值给time_ns(time_ns也变成N3),同时设置上N3的frozen_offsets。于是这个子进程没法修改offset了,同时unshare出来的那个进程因为也是拥有N3,所以也没法修改offset了。这与我们的结果相符。
因此整个过程是,unshare导致了time_ns和time_ns_for_children的差异,然后在fork时子进程继承time_ns和time_ns_for_children,接着由于fork的frozen_offsets的特性,检测到time_ns和time_ns_for_children差异后,把time_ns_for_children设置上frozen_offsets。于是这两个进程都没法修改了。
cgroup namespace
演示
演示流程是:
1.查看当前进程的cgroup节点路径
2.新建cgroup namespace不变的子进程,验证cgroup节点路径默认继承
3.新建创建了新cgroup namespace的子进程,验证cgroup节点路径有变化
1 | ########################################################### |
你可能想说,你这一长串都讲了个啥呀?我再来个精简版:
1 | # 主机上 |
可以看到新的cgroup namespace中,进程看到的cgroup节点路径是/,提供了一个虚拟的cgroup路径视图。
介绍
cgroup namespace为进程提供一个虚拟的cgroup节点路径。
在整个系统中存在cgroup树,每个进程都会属于一棵树中的唯一一个节点,一个进程如果新建了cgroup namespace,那么这个进程会把当前所属的节点当做是根节点,而自己进程的节点路径就变成根/了。这个路径就是通过/proc/pid/cgroup文件展示的。
cgroup目前存在两个版本,v1和v2, v1会存在多个cgroup树,比如memory树,cpu树,等等,对应到/proc/pid/cgroup文件就会有多行; v2只有一棵树,所有控制都在一棵树上解决,对应到/proc/pid/cgroup文件就会只有一行。
在没有cgroup namespace的时候,所有进程都以cgroup树的根作为根,也就是把系统的cgroup树的根看做/,其余节点都是/xx/xx的形式。但是有了cgroup namespace后,每个进程都可以有自己对于根/的定义。比如系统的树形结构是/a/b/c/d/e,那么我可以给某个进程设置c节点作为根/,这时候用/..表示b节点,/../..表示a节点,/../../..表示系统根,/d表示d节点,/d/e表示e节点。
给cgroup路径虚拟化成/,有3个作用:
1.是让进程看不到外部的cgroup树结构,防止信息泄露;
2.让进程迁移更容易,因为都从/开始就可以在不同机器上保持一致;
3.让进程没法操作外部的cgroup树,因为会把进程看到的根节点/挂载到/sys/fs/cgroup,所以操作不了外部cgroup节点了;
原理
我们首先来看cgroup在系统中是如何表示如何维护的,各个结构之间的关系是怎么样的:
在cgroup v1中,每种资源可以有独立的cgroup树,因此整个系统中会有多个cgroup树,每一棵树可以负责多个资源。
树的每个节点叫做cgroup,树的根节点叫cgroup_root,cgroup_root本身也承担普通cgroup节点的作用,也就是根节点也同时是一个cgroup。每个cgroup内部包含根节点cgroup_root的指针。
每个cgroup下可以挂任意多个task,cgroup v1中不区分进程和线程,都叫task。
每个进程包含一个css_set结构,其中包含全部cgroup_subsys_state,一个cgroup_subsys_state内部指向一个cgroup。
在cgroup v2中,整个系统只有一个全局的cgroup树,所有控制器都通过这棵树来配置。
树的每个节点也叫cgroup,根节点也叫cgroup_root, 根节点同时也是一个cgroup,cgroup内部指向cgroup_root。
cgroup v2节点下支持下挂pid和tid,可以支持对进程和线程的不同控制。
每个进程包含一个css_set,一个css_set中包含一个cgroup。
v2和v1在内核中代码是混在一起的,中间通过一些标志性的字段的判断来区分是v2还是v1,比如通过判断cgroup的root等于全局唯一的cgrp_dfl_root来判断这是一个v2的cgroup节点。
而不管v1还是v2,体现cgroup namespace的就是这个cgroup的节点路径,路径的获取途径是/proc/pid/cgroup文件。我们来看这里的显示逻辑。
首先在代码结构中task_struc->cgroup
包含css_set结构,task_struct->nsproxy->cgroup_ns->root_cset
中也包含一个css_set结构,这两个字段的意义分别是当前进程所在cgroup节点集合(集合的意思是针对有多棵树的情况)以及当前进程虚拟根cgroup节点的集合。后者也等于父进程的task_struc->cgroup
。
1 | struct task_struct { |
打个比方,父节点的cgroup节点在/aa/bb/cc,那么父进程的task_struc->cgroup
就指向/aa/bb/cc节点,父进程的task_struct->nsproxy->cgroup_ns->root_cset
指向暂时不重要;接着父进程创建子进程,子进程的task_struc->cgroup
和task_struct->nsproxy->cgroup_ns->root_cset
都继承自父进程的task_struc->cgroup
,指向/aa/bb/cc节点。接着如果我们把子进程移到/aa/xx/yy下,那么子进程的task_struc->cgroup
指向/aa/xx/yy,而task_struct->nsproxy->cgroup_ns->root_cset
依旧指向/aa/bb/cc。也就是进程的虚拟根cgroup节点不会发生变动。如图:
看到这里我们先记下两个信息,一个是通过task_struc->cgroup
可以获取一个进程当前所属的cgroup节点,另一个通过task_struct->nsproxy->cgroup_ns->root_cset
可以获取当前进程虚拟根cgroup节点。
现在我们来看/proc/pid/cgroup的显示逻辑:
1 | int proc_cgroup_show(struct seq_file *m, struct pid_namespace *ns, |
可以看到/proc/[pid]/cgroup显示的路径计算方式是
1.拿到目标进程的当前所属cgroup节点
2.拿到本进程虚拟根节点的cgroup节点
3.计算从2到1的相对路径
所以/proc/[pid]/cgroup显示的路径是从本进程的虚拟cgroup根节点到目的进程当前cgroup的相对路径,如果1和2相同,相对路径就是/。
1 | //https://github.com/torvalds/linux/blob/v5.19-rc2/fs/kernfs/dir.c#L144 |
验证相对路径
这部分不感兴趣可以不看。这里会演示移动一个进程的cgroup后,/proc/[pid]/cgroup显示会如何变。
1 | # 启动一个busybox容器,不停打印当前的cgroup路径 |
可以看到cgroup路径是/,这是因为k8s中每个容器都会加入独立的cgroup namespace。
随后我们在容器所在主机修改掉进程的cgroup:
1 | > ps -ef | grep cgroup | grep -v grep |
完成这一步操作后,这个进程的cgroup就被移动到了指定路径下,这个进程的task_struc->cgroup
会随之改变,但是这个进程的current->nsproxy->cgroup_ns->root_cset
不会变,于是在进程内,它看到的/proc/$$/cgroup
将发生变化,它其中的路径是current->nsproxy->cgroup_ns->root_cset
到task_struc->cgroup
(这里即/jin
)的相对路径。
在busycat的日志中会看到内容改变了
1 | 1 |
符合预期。
重新挂载cgroup
在最开始的演示中,我们看到如果不重新挂载cgroup,那么我们依然可以从/sys/fs/cgroup目录下去看出当前进程真实的cgroup节点位置。可以拆穿当前cgroup在根/下的谎言。所以我们必须执行命令来重现挂载cgroup。
1 | root@server2:/home/ubuntu# umount /sys/fs/cgroup |
但是为了避免子进程挂载cgroup时对父进程产生影响,我们的子进程必须在创建cgroup namespace的同时创建一个mount namespace,这也是我们演示中执行的sudo unshare -Cm
中包含-m
参数的原因。
user namespace
演示
1 | # 读主机uid=1000 |
可以看到原先uid=1000,随后我们加入一个新的user namespace,发现uid变成65534了,随后我们在新窗口中写一个配置,然后发现刚才的uid又变成1了。
可以发现加入了user namespace的进程他内部的uid的变化跟主机的uid是不相关的。
介绍
user namespace隔离安全相关的属性,包括uid,gid,能力集等,一个用户在namespace外可能是普通用户,在ns内可以是root用户,因此在ns内可以具有最高权限。
原理
user_namespace没有放到task_struct下的nsproxy里面,因为他的使用比较特殊。
他放在task_struct.real_cred.user_ns,这是因为user namespace需要用来做进程的凭据,所以被放在在凭据相关的结构下。
注意task_struct下存在real_cred和cred两个字段,功能类似,real_cred定义该进程被其他对象操作的时候的上下文,cred定义该进程操作其他对象时候的上下文,后面讲解时不做区分。
1 | https://github.com/torvalds/linux/blob/master/include/linux/cred.h#L101 |
在real_cred结构下有3个主要部分:
一个是uid,代表该进程的真实uid,这也代表了该进程的真实权限,不会因为在子namespace中是root用户,他就可以操作外层的root权限的操作。
一个是几个cap_xxx字段,代表了这个进程拥有的能力,新usernamespace下的第一个进程拥有全部能力,所以对应的cap_xxx的值表示的是全部的能力,这个能力是指在新namespace下的能力。
一个是user_ns,主要完成gid和uid的映射。映射的意思是下层ns中的uid对应的是上层ns中的哪一个uid, 重点讲。
1 | struct user_namespace { |
在user_namespace的结构中,parent和level的意义跟上面pid namespace的一样,parent代表上一层级的namespace,level代表这个user namespace处在第几层。
而uid_map和gid_map代表id的映射。
1 | struct uid_gid_map { /* 64 bytes -- 1 cache line */ |
这个结构里nr_extents代表有几项映射,nr_extents<=UID_GID_MAP_MAX_BASE_EXTENTS
时,数据存到extent数组,大于extent时,数据存在forward-reverse之间,存到外部了。
每一项映射的格式是uid_gid_extent,这个结构的意思是把子ns中[first,first+count)的id区段映射到父ns中[lower_first+count)。这里有一个重要的信息需要注意,lower_first代表的是上一级的ns还是第0级的ns中的uid?我猜这里跟你的预想会有出入,这个lower_first是第0层的的uid,也就是ns中的uid直接映射到主机上的uid。我估计的理由是避免递归查找,如果指向的是上一级的uid,那么从第n层查找第0层需要不停查找每一层的映射规则,低效。而现在这样的设计只需要一次查找。同时还有个场景是计算NS-x下的uid对应另一个NX-y下的uid,任意两个ns。在当前设计下也只需要两次查找,一次从[NS-x]->[NS-0],另一次是[NS-0]->[NS-y]。也可以很好的完成需求,也就是说虽然user_namespace是多层嵌套的结构,但是uid-map是只有一层映射的,直接映射到第0层的uid。
了解了映射的规则,现在我们来看一下子ns中的进程如何获取到ns内部的uid
1 | SYSCALL_DEFINE0(getuid) |
从这里我们可以看到如果用户真实uid在nsmespace 的uid_map映射中找到映射,那么就根据公式返回ret_id=(real_id - extent->lower_first) + extent->first
。如果找不到映射就最终返回65534.
看到这里我们就可以看懂演示中的现象了,首先刚刚创建user namespace的时候,因为实际不存在uid_map映射,那么我们通过id命令查看uid的时候,返回了65534。接着我们向进程的uid_map文件写入一条映射,如此我们的uid就被映射成1了,再次执行id就返回1了。
uid_map文件
这部分不感兴趣可以不看,不影响。
我们上面看到配置uid map是通过向uid_map文件写入映射来实现的,那么这里会有一个问题。
假如我们有3层namespace,主机>namespaceA>namespaceB,这时候namespaceA中的进程尝试去配置namespaceB的uid map,于是他准备执行echo "id_b id_host 1" | sudo tee /proc/pid/uid_map
,那么这时候他就迷茫了,因为他怎么知道要映射到主机上的哪个id_host呢,他是在namespace中的,他并不能感知到主机的id真实值有哪些。
答案是他不需要知道,他只要执行echo "id_b id_a 1" | sudo tee /proc/pid/uid_map
就可以了,他只要把目标namespace的uid映射到自己的uid就可以了。然后uid_map文件背后的写入逻辑会把id_a转成对应的id_host。同样当我们cat /proc/pid/uid_map
的时候背后的读取逻辑也会把id_host转成id_a给我们显示,如果是在其他ns比如namespaceX中读那就会转成id_x。这部分逻辑的内核代码如下:
1 | static inline struct user_namespace *seq_user_ns(struct seq_file *seq) |
逻辑已经发在注释中标出来了。我们来做一个演示和验证,看看是不是在不同ns下读到的uid_map文件内容是不同的。
1 | #首先通过unshare创建两层新的user namespace,并以此打印出每一层的pid备用,并查看user namespace id证明在不同的ns中。这里unshare命令会带上-r参数,这样我们就不需要去手动写入uid_map映射了,自动帮我们写上了。 |
可以看到符合我们的结论,在第0层,lower_first值对应了第0层的uid,ubuntu用户的uid就是1000。第1层看到的lower_first值也是对应了第1层的uid=0。第2层就是ns自己这个ns内,那lower_first对应(2-1)层的uid=0。符合我们的预期。
参考
namespace API
cred.h
nsproxy.h
user_namespace.h
clone(2) — Linux manual page
unshare(1) — Linux manual page
setns(2) — Linux manual page
vdso(7) — Linux manual page
网络信息服务
network namespace 简介
time_namespaces(7) — Linux manual page
Internal Commands and Builtins
kernel: Introduce Time Namespace
mnt_namespace的拷贝过程解读
Linux Namespace系列user namespace
理解user namespace
user namespace internals
User Namespace 详解
Pid Namespace 原理与源码分析
Linux 容器化技术
IPC Namespace 详解
Cgroup 整体介绍
Cgroup - 从CPU资源隔离说起
浅谈 Cgroups V2
Linux的cgroup详细介绍
Organizing Processes and Threads
资源限制cgroup v1和cgroup v2的详细介绍