Linux隔离与调度「Namespace」
2020-03-17 / modified at 2022-04-04 / 2.6k words / 11 mins
️This article has been over 2 years since the last update.

Namespace主要是通过CLONE_FLAG实现资源隔离。其实无论是Docker还是其它容器,它底层的隔离实现是内核早就有的功能。在内核中,通过ns_proxy实现。

Namespaces is a form of lightweight process virtualization, and it provides resource isolation.

如下是其结构体

1
2
3
4
5
6
7
8
9
10
11
// 进程的结构体
struct task_struct{
...
struct nsproxy *nsproxy;
}
// ns的结构体
struct nsproxy{
atomic_t count;
struct uts_namespace *uts_ns;
// 后续还有 ipc, mnt,pid,user,net
}

在Docker等实现的runc的底层中,结构体调用如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// runc/libcontainer/container_linux.go:496
func NamespaceTypes() []NamespaceType {
return []NamespaceType{
NEWUSER, // Keep user NS always first, don't move it.
NEWIPC,
NEWUTS,
NEWNET,
NEWPID,
NEWNS,
NEWCGROUP,
}
}

type Namespace struct {
Type NamespaceType `json:"type"`
Path string `json:"path"`
}

如果想了解NS的内部实现,那么需要分析内部相关数据结构,本文主要只介绍使用场景。

本文是通过临床实验来验证出结论,这个证明过程是不科学严谨的,仅供参考。

进程(Processs)的基本介绍

什么是进程?

进程是内核调度与隔离的最小单位,它是工作负载的结构体task_struct,在内核中被维护在PID表中。

什么是 ProcFS?

/proc是一种虚拟文件系统(VFS),支持用户态通过标准文件I/O通信方式实现对内核态进程(与其它系统信息)的函数调用(Translating a file read to an internal kernel method)。它只是一种IO通信形式,它与Web请求JSP渲染返回HTML并没有本质的区别。它在Linux中被更高级命令封装: top, ps, free, pmap。我个人认为这种“万物皆文本”并不是一个好设计,导致物理文件的mount/chroot全部需要配套定制补丁。

参考链接: https://ops.tips/blog/what-is-slash-proc/ 此文章的作者在concourse-ci工作

NS相关系统调用

详情可以参考这里:https://lwn.net/Articles/531381/

  • setns: enter a ns
  • clone: fork and leave
  • unshare: Leaving a namespace

Mount命名空间隔离

首先,我们先回忆下C的mount调用方式

1
2
3
4
5
6
#include <sys/mount.h>
// eg: 挂载物理硬盘
// mount("/dev/sdaxx","/mnt/d/","ext3","flag",NULL)
int mount(const char *source, const char *target,
const char *filesystemtype, unsigned long mountflags,
const void *data);

接下来是mount命名空间隔离的实现,我们以unshare为例

1
2
3
4
5
# 使用debug模式分析
strace -f -s 100 -o 2.log unshare --mount bash
# 创建一个临时文件,并写入一些信息
mkdir -p tmp && mount -t tmpfs tmpfs tmp && mount -t tmpfs && echo 123 > tmp/log.txt
# 打开另一个终端 cd tmp,可以发现里面啥也没有

这样就初步实现了“私密空间”的隔离,其它应用甚至root也没法看到里面存储的东西,很多密钥通过此方案作为安全措施之一。

内部调用如下

1
2
3
4
5
6
# 与父进程分离NS
25208 unshare(CLONE_NEWNS) = 0
# 递归调用将根目录设置为私有的,其中none这个是固定参数,不要理解为umount了
# recursively/rɪˈkɝsɪv/ change the propagation /ˌprɑpǝˈɡеʃǝn/ type to private
25208 mount("none", "/", NULL, MS_REC|MS_PRIVATE, NULL) = 0
# 其它操作

Runc的的加载容器镜像的实现

在容器项目中,一般是先下载镜像,再单独挂载。我们以启动runc容器为例

1
2
3
4
5
6
7
mkdir /mycontainer
cd /mycontainer
mkdir rootfs
docker export $(docker create busybox) | tar -C rootfs -xvf -
runc spec
# 运行
strace -e trace=mount,unshare,clone,chroot,pivot_root -f -s 100 -o 1.log runc run my

内部调用

1
2
3
4
5
6
7
8
9
10
11
12
# 与父进程分离NS
26276 unshare(CLONE_NEWNS|CLONE_NEWUTS|CLONE_NEWIPC|CLONE_NEWPID|CLONE_NEWNET) = 0
# 子进程开始工作
# 已经抛弃chroot了
26278 pivot_root(".", ".") = 0
# 递归挂载根目录为当前目录
26278 mount("", ".", 0xc00015acc2, MS_REC|MS_SLAVE, NULL) = 0
26278 mount("/", "/", 0xc00015ad50, MS_RDONLY|MS_REMOUNT|MS_BIND|MS_REC, NULL) = 0
# 挂载已经unshare的proc,下文再接着讲
26278 mount("proc", "/mycontainer/rootfs/proc", "proc", 0, NULL) = 0
# 后续启动子进程
...

上述的“私密空间”,虽然普通用户看不到,但是root仍然可以用nsenter进入

关于AutoFS的特殊场景

我在某个场景中,需要挂载存储NFS服务,该场景下使用了AutoFS(自动新建与移除挂载点)实现自动挂载,如果开启了NS的隔离,将发现所有任务的 /proc/self/mount下没有自动挂载,而且报错“Too many levels of symbolic links.”,原因如下

1
2
3
4
5
init-- Autofs
|
|- ContainerHost -(CLONE_NEWNS)-> Container -> can NOT access NFS
|
|- NormalProcess -(CLONE_FS)-> can acess NFS resources

普通程序fork出的子进程由于使用CLONE_FS共享FS挂载,当应用请求某个路径时,内核将拦截请求并挂载NFS资源,实现自动挂载。

而容器类的子进程只能获取到快照,无法实时更新Namespace,导致AutoFS挂载失败。

解决方法:关闭CLONE_NEWNS或者将AutoFS(nsenter)加入同一个NS即可(相当暴力了)。

关于Docker与NFS

在硬件存储NFS的LDAP安全配置中,root用户大部分默认被看作nobody,所以在Docker内部可能无权限挂载NFS盘,建议先在本地挂载。值得注意的是,NFS(块存储)一般来说是很昂贵的,主要是高性能场景(比如DB/Git)才使用的,如果只是附件直接用S3/WebDAV就ok了,甚至使用用户态的fuse挂载也可以。

Docker执行挂载/解压的安全问题(SUID owned by root)

  • setuid: 有一种 chmod+s or chmod 4755 来配置setuid的方案,只需要容器挂载一个临时盘甚至直接改/etc,并用root配置setuid,在外部就可以作为root跑了。详见POC or 另外问题。这个比较冷门(通常被加固过了),但是部分调度器软件仍然有大量使用而且暴露了API,它甚至创建比较干净的thread tree,而不需要再次fork。
  • setcap: 实现了分割root用户的特权,但是注意看起来是普通用户的镜像实际上拿到了root

PID隔离

通过CLONE_NEWPIDCLONE_NEWNS实现了PID隔离,并防止访问上级的/proc

其中

  • CLONE_NEWPID: 导致从1开始重新生成PID,可以通过/proc/self/ns/pid查看
  • CLONE_NEWNS: which causes the caller’s mount namespace to obtain a private copy of the namespace that it was previously sharing with other processes, so that future mounts and unmounts by the caller are invisible to other processes (except child processes that the caller subsequently creates) and vice versa.

实例如下

1
2
strace -f -s 100 -o 2.log unshare --fork --pid --mount-proc sh
# 接着进入我们启动的sh,可以通过ps查看当前只有一个进程了(显示为1)

内部系统调用如下

1
2
3
4
5
6
7
8
9
10
11
12
# 配置clone参数
25089 unshare(CLONE_NEWNS|CLONE_NEWPID) = 0
25089 clone(child_stack=0, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7fcbc7efea10) = 25090
# 子进程被启动
# 将/递归设置为私有
25090 mount("none", "/", NULL, MS_REC|MS_PRIVATE, NULL <unfinished ...>
25089 wait4(25090, <unfinished ...>
25090 <... mount resumed> ) = 0
# 先私有后重新挂载,我认为下面的第一行是用不上的,因为上面已经递归配置过了
25090 mount("none", "/proc", NULL, MS_REC|MS_PRIVATE, NULL) = 0
25090 mount("proc", "/proc", "proc", MS_NOSUID|MS_NODEV|MS_NOEXEC, NULL) = 0
# 后续就是启动Shell了

用户隔离

用户命名空间CLONE_NEWUSER:主要实现了UID/GID隔离,维护了进程与文件的打开数的统计/Mapping

当前Docker等容器的限制

在默认情况下,CentOS是没有打开用户Mapping配置的,这时unshare是无法使用的,而在docker/runc侧,直接硬编码setuid/gid就把这个事情给搞定了,这个是不算隔离映射。

1
2
3
4
task_struct{
// 进程保存的用户信息
struct cred *cred;
}

你在容器外用ps -u查询可以发现运行账户就是真实的root用户,里面的root虽然被namespace限制,但是有各种安全问题,它被提权后是可以成为真root的。同时这种方案在使用挂载时引发了一系列的读写权限问题。

TTY安全问题

主流容器在(比如Postgres)进行动态配置一般采用su-exec/gosu等项目,这种是不带fork的直接执行execvp,可以避免tty泄漏与root权限过大的问题。

1
2
3
4
5
# 切换用户执行命令
# 内部只有setuid/setgid/execvp、而没有fork调用
./su-exec uid:gid pstree -up
# 需要加载bashrc的场景
./su-exec uid:gid bash -ic 'pstree -up'

千万不要去折腾su,sudo等命令,它会给你耗死在文档细节中的,还不如几个系统调用使用的明确。

如何实现“fake root”(井の中のroot)?

这个也是比较新的功能,详见man文档,需要升级到CentOS7以上,它通过LD_PRELOAD加载libfakeroot-sysv.so覆盖了常见系统调用,我们以rootless runc为例

1
2
3
4
5
6
7
useradd test
su test
id
# => 1001, 1001 普通用户
runc spec --rootless
strace -f -s 100 -o 2.log runc --root ~/runc run mycontainerid
# 命名不是root却启动了一个假的root

内部调用如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 创建子进程
14118 clone( <unfinished ...>
# 子进程不共享USER NS
14120 unshare(CLONE_NEWUSER) = 0
# 父进程配置Mapping
# permanently disable setgroups(2) in a user namespace
14118 open("/proc/14120/setgroups", O_RDWR) = 7
14118 write(7, "deny", 4) = 4
# map root to parent "test" uid
14118 open("/proc/14120/uid_map", O_RDWR) = 7
14118 write(7, "0 1001 1\n\0", 10) = 10
# map root to parent "test" gid
14118 open("/proc/14120/gid_map", O_RDWR) = 7
14118 write(7, "0 1001 1\n\0", 10) = 10
14120 setresuid(0, 0, 0 <unfinished ...>
...
14120 unshare(CLONE_NEWNS|CLONE_NEWUTS|CLONE_NEWIPC|CLONE_NEWPID) = 0

在外部用真正的root可以看到如下Mapping

1
2
3
4
cat /proc/14120/uid_map 
# 0 1001 1
cat /proc/14120/gid_map
# 0 1001 1

上述方法需要比较新的内核,CentOS需要7.7以上才稳定。

同时也要注意,在Singularity容器的介绍中,Most network filesystems (NFS/Lustre/GPFS etc.) do not support this uid/gid mapping in a user namespace.

网络隔离

网络隔离方案一般不会是单机版方案,主流方案是启动一个虚拟网卡/进程来实现层三(比如Openstack的Neutron)或者层四交换。

更多这里就不介绍了,可以参考 https://sookocheff.com/post/kubernetes/understanding-kubernetes-networking-model/

总结

  • 理解难度从高到低依次如下:proc,namespace,cgroup。procfs的遗留设计带来了很多额外的负担
  • 尽可能通过strace分析,而不是陷入源码/Shell中,碰上Shell就把你时间耗没了
  • 使用docker(root daemon)基本上到处都是漏洞,全部看作root来管理吧,可以考虑使用
    • 在计算等场景,使用加固后的OS比如EulerOS,加固过的Singularity
    • 禁用挂载/FS/allow_caps/namespace等权限后的docker,参考这里
    • 供应链上的docker trust sign

我更推荐看runc规格文档或者Redhat使用手册,而不是把时间耗在“docker等价于几条shell命令,shell命令的C源码实现”等事情中。