Linux 基础-Namespace

Linux 基础系列之 Namespace

Namespace 介绍

Linux Namespace 是 Kernel 的一个功能,它可以隔离一系列的系统资源,比如 PID(Process ID)、User ID、Network 等。Namespace 可以在一些资源上,将进程隔离起来,这些资源包括进程树、网络接口、挂载点等。

比如,阿里云向外界出售自己的 tomcat 实例用来运行它们自己的应用,这些实例可能在一台服务器上。为了避免攻击者进入了别人的 tomcat 实例,修改或关闭了其中的某些资源,理论上我们可以限制不同用户的权限,让用户只能访问自己名下的 tomcat。但是,有些操作可能需要 root 权限,那又不可能给每个用户都授予 root 权限,也不可能给每个用户都提供一台全新的物理主机让他们互相隔离。因此,Linux Namespace 在这里就派上了用场。使用 Namespace 就可以做到 UID 级别的隔离,以 UID 为 n 的用户,虚拟化出来一个 Namespace,在这个 Namespace 里面,用户是具有 root 权限的。但是,在真实的物理机器上,他还是那个以 UID 为 n 的用户,这样就解决了用户之间隔离的问题。

你可能会想,为什么不用容器进行隔离呢?namspace 正是容器技术的重要组成部分之一。

除了 User Namespace,PID 也是可以被虚拟的。命名空间会建立系统的不同视图,从用户的角度来看,每一个命名空间应该像一台单独的 Linux 一样,有自己的 init 进程(PID 为 1)​,其他进程的 PID 依次递增,A 和 B 空间都有 PID 为 1 的 init 进程,子命名空间的进程映射到父命名空间的进程上,父命名空间可以知道每一个子命名空间的运行状态,而子命名空间与子命名空间之间是隔离的。从图 2.1 所示的 PID 映射关系图中可以看到,进程 3 在父命名空间中的 PID 为 3,但是在子命名空间内,它的 PID 就是 1。也就是说用户从子命名空间 A 内看进程 3 就像 init 进程一样,以为这个进程是自己的初始化进程,但是从整个 host 来看,它其实只是 3 号进程虚拟化出来的一个空间而已。

Namespace 的 API 主要使用如下 3 个系统调用:

  1. clone():创建新进程,根据系统调用参数来判断哪些类型的 Namespace 被创建,而且它们的子进程也会被包含到这些 Namespace 中
  2. unshare():将进程移出某个 Namespace
  3. setns():将进程加入到 Namespace 中

Namespace 类型

当前 Linux 一共实现了 6 种不同类型的 Namespace:

Namespace 类型 系统调用参数 支持的内核版本 描述
Mount (mnt) CLONE_NEWNS 2.4.19 用于隔离挂载点的视图,不同的命名空间可以拥有各自的文件系统挂载结构
UTS (uts) CLONE_NEWUTS 2.6.19 用于隔离主机名和域名,每个命名空间可以设置不同的主机名和域名
IPC (ipc) CLONE_NEWIPC 2.6.19 用于隔离进程间通信的资源(如信号量、消息队列、共享内存等)
PID (pid) CLONE_NEWPID 2.6.24 用于隔离进程 ID(PID)编号空间,每个命名空间可以有独立的 PID 编号
Network (net) CLONE_NEWNET 2.6.24 用于隔离网络资源(如网络接口、IP 地址、路由表、端口等)
User (user) CLONE_NEWUSER 3.8 用于隔离用户和组 ID,每个命名空间可以有独立的用户和权限控制机制

通过 ls -l /proc/self/ns 也可查看当前系统支持的 namespace 情况,同时也可以查看进程对应的命名空间。

1
2
3
4
5
6
7
8
total 0
lrwxrwxrwx 1 user user 0 Oct 15 10:00 cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 user user 0 Oct 15 10:00 ipc -> 'ipc:[4026531839]'
lrwxrwxrwx 1 user user 0 Oct 15 10:00 mnt -> 'mnt:[4026531840]'
lrwxrwxrwx 1 user user 0 Oct 15 10:00 net -> 'net:[4026531967]'
lrwxrwxrwx 1 user user 0 Oct 15 10:00 pid -> 'pid:[4026531836]'
lrwxrwxrwx 1 user user 0 Oct 15 10:00 user -> 'user:[4026531837]'
lrwxrwxrwx 1 user user 0 Oct 15 10:00 uts -> 'uts:[4026531838]'

这些 namespace 文件都是链接文件。链接文件的内容的格式为 xxx:[inode number]。其中的 xxx 为 namespace 的类型,inode number 则用来标识一个 namespace,我们也可以把它理解为 namespace 的 ID。如果两个进程的某个 namespace 文件指向同一个链接文件,说明其相关资源在同一个 namespace 中。

其次,在 /proc/[pid]/ns 里放置这些链接文件的另外一个作用是,一旦这些链接文件被打开,只要打开的文件描述符 (fd) 存在,那么就算该 namespace 下的所有进程都已结束,这个 namespace 也会一直存在,后续的进程还可以再加入进来。

UTS Namespace

UTS Namespace 主要用来隔离 nodename(系统的主机名,基本上等价于 hostname,可以通过 hostname 来查看或设置,作为网络中标识机器的名称,如在局域网中识别服务器) 和 domainname(用于标识主机所属的网络域,通常是 NIS 这种网络服务目录系统所用) 这两个系统标识。在 UTS Namespace 里面,每个 Namespace 允许有自己的 nodename 和 domainname。

IPC Namespace

IPC Namespace 用来隔离 System V IPCPOSIX message queues。它们都是进程间通信(IPC)的机制,允许不同的进程在同一台计算机上进行数据交换和同步。每一个 IPC Namespace 都有自己的 System V IPC 和 POSIX message queue。

System V IPC

System V IPC 是一组早期引入的 IPC 机制,包含信号量(semaphores)、共享内存(shared memory)和消息队列(message queues)。它们提供了一种在同一台计算机上运行的不同进程之间交换数据的方式:

  1. 信号量 (Semaphores):
    • 用于进程间的同步
    • 控制对共享资源的访问,防止多个进程同时修改同一资源
  2. 共享内存 (Shared Memory):
    • 允许多个进程访问同一块内存
    • 是最快的进程间通信机制,因为数据不需要通过内核进行复制
  3. 消息队列 (Message Queues):
    • 提供了一种基于消息的通信机制
    • 消息被放入队列中,其他进程可以从队列中读取消息

System V IPC 通常用于需要简单可靠的进程间通信机制的场合,如控制多进程环境中的资源共享和进程同步。

POSIX message queues

POSIX 消息队列是基于 POSIX 标准定义的消息队列,与 System V 的消息队列类似,但提供了一些增强功能:

  • 命名和基于文件系统:POSIX 消息队列是通过路径名命名的,类似于文件系统
  • 异步通知:可以为消息队列注册信号通知,在有新消息进入队列时通知进程
  • 更灵活的优先级控制:消息可以拥有优先级,高优先级消息会被优先处理
  • 更好地与线程结合:POSIX 标准对线程的支持更好,POSIX 消息队列更容易与多线程程序集成

POSIX 消息队列 在需要更复杂的消息传递机制时使用,尤其是在需要异步操作或者更细粒度的优先级管理的时候。

PID Namespace

PID Namespace 是用来隔离进程 ID 的。同样一个进程在不同的 PID Namespace 里可以拥有不同的 PID。在 docker container 里面使用 ps -ef 经常会发现,在容器内,前台运行的那个进程 PID 是 1,但是在容器外,会发现同样的进程却有不同的 PID,这就是 PID Namespace 的能力。

Mount Namespace

Mount Namespace 是 Linux 第一个实现的 Namespace 类型,当时人们没有意识到,以后还会有很多类型的 Namespace 加入 Linux 大家庭。因此,它的系统调用参数是 NEWNS(即 New Namespace)​

Mount Namespace 用来隔离各个进程看到的挂载点视图。在不同 Namespace 的进程中,看到的文件系统层次是不一样的。在 Mount Namespace 中调用 mount()umount() 仅仅只会影响当前 Namespace 内的文件系统,而对全局的文件系统是没有影响的,Docker volume 也是利用了这个特性。

比如我们可以用 mount -t proc proc /proc 在新的 Namespace 中把 /proc 挂进来,这样执行 ps -ef 时,就能看到当前 PID namespace 中的进程列表了,这样比较干净。

  1. -t proc:
    • 这里的 -t 选项指定文件系统的类型。
    • proc 是一个特殊的文件系统类型,它在 Linux 上主要用于提供内核和进程信息的接口。这种文件系统实际上是一个伪文件系统,不存在于磁盘中,而是由内核动态生成。
  2. proc: 这是设备名称。对于 proc 文件系统,设备名称和类型名称是相同的。它并不指向一个物理设备,而是告诉系统你要挂载的是 proc 类型的文件系统。
  3. /proc:
    • 这是挂载点,通常是一个目录。/proc 是一个标准的、预定义的挂载点,用于 proc 文件系统。
    • 挂载后,这个目录将提供运行系统进程的详细信息以及其他内核信息。它包括每个进程的信息、系统配置参数、内存状态等

但要注意,需要通过查看 /proc/self/mountinfo 来看挂载点是否为共享,若为共享,则隔离是失效的:

  1. 默认行为:当使用 clone()unshare() 创建新的 Mount Namespace 时,所有的挂载点通常继承自父命名空间,并且默认继承共享设置。如果父命名空间中的挂载点是共享的,新命名空间中对该挂载点的变更会反馈回父命名空间。
  2. 共享与私有挂载:
    • 默认情况下,Linux 文件系统的挂载行为是共享的(shared)。如果不主动将命名空间标记为私有的(使用 mount --make-private),则挂载操作可能会传播到其他命名空间中。
    • 共享挂载(Shared Mounts):Linux 支持共享挂载,一个挂载点的事件(例如新挂载或卸载)能够在一组共享的挂载点之间传播
    • 属性列表
      • shared (共享): 在该挂载点的任何操作都将传播到其他共享它的命名空间。
      • private (私有): 在该挂载点的操作不会传播到其他命名空间。这是独立的行为。
      • slave (从属): 它会接收来自主挂载点的传播,但不会将变化传播回去。
      • unbindable (不可绑定): 防止通过 bind 方式将其他挂载点绑定到这个挂载点。

这一点后面做实验可以看到差别。

User Namespace

User Namespace 比较复杂,涉及到内核的各种权限和机制。

User Namespace 主要是隔离用户的用户组 ID。也就是说,一个进程的 User ID 和 Group ID 在 User Namespace 内外可以是不同的。比较常用的场景是在宿主机上以一个非 root 用户运行创建一个 User Namespace,然后在 User Namespace 里面却映射成 root 用户。这意味着,这个进程在 User Namespace 里面有 root 权限,但是在 User Namespace 外面却没有 root 的权限。从 Linux Kernel 3.8 开始,非 root 进程也可以创建 User Namespace,并且此用户在 Namespace 里面可以被映射成 root,且在 Namespace 内有 root 权限。

默认情况下,Docker 容器运行时不会启用用户命名空间(User Namespace)。容器内的 root 用户会直接映射到宿主机上的 root 用户。容器内部的用户(如新增的普通用户)直接使用容器内的 /etc/passwd 文件,不涉及用户 ID 映射。如果需要启用用户命名空间,可以通过手动配置 Docker 的 userns-remap 功能。 启用 userns-remap 后,容器的 UID 会被映射到宿主机上的一个非 root UID 范围,这提供了更好的安全隔离。

在容器技术中,rootless 或者 userns-remap 容器才会使用 User Namespace(Docker rootless 模式、(userns-remap)[https://docs.docker.com/engine/security/userns-remap/])

在容器技术中,rootless 容器才会使用 User Namespace(如:Docker rootless 模式

还有 /proc/sys/user/max_user_namespaces 这个值必须不为 0,它控制了 Linux 系统中允许同时存在的 用户命名空间的最大数量,为 0 则完全禁用了用户命名空间,阻止任何用户命名空间的创建。

Network Namespace

Network Namespace 是用来隔离网络设备、IP 地址端口等网络栈的 Namespace。Network Namespace 可以让每个容器拥有自己独立的(虚拟的)网络设备,而且容器内的应用可以绑定到自己的端口,每个 Namespace 内的端口都不会互相冲突。在宿主机上搭建网桥后,就能很方便地实现容器之间的通信,而且不同容器上的应用可以使用相同的端口。

Namespace 实验

完整的脚本 demo 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
import signal
import os
import ctypes
from ctypes.util import find_library
import sys

# clone 系统调用的标志
CLONE_NEWUTS = 0x04000000
CLONE_NEWIPC = 0x08000000
CLONE_NEWPID = 0x20000000
CLONE_NEWNS = 0x00020000
CLONE_NEWUSER = 0x10000000
CLONE_NEWNET = 0x40000000

MS_REC = 0x0004000
MS_PRIVATE = 0x00020000

def write_file(path, content):
try:
with open(path, "w") as f:
f.write(content)
except Exception as e:
sys.exit(f"[Error] Failed to write mapping {content} to {path}: {e}")

def child_func():
# 修改 hostname
## 也可以在新的 shell 中通过 hostname -b xxx 来修改
new_hostname = b"sheep"
if libc.sethostname(new_hostname, len(new_hostname)) != 0:
sys.exit(f"[Error] mount failed: {os.strerror(ctypes.get_errno())}")

# 调用 mount() 系统调用设置挂载传播为私有
## 也可以在新的 shell 中通过 mount --make-private / 来修改
if libc.mount(None, ctypes.c_char_p(b"/"), None, MS_REC | MS_PRIVATE, None) != 0:
sys.exit(
f"[Error] mount make private failed: {os.strerror(ctypes.get_errno())}"
)

# 调用 mount() 系统调用给新的 namespace 创建属于自己的 /proc
# 也可以在新的 shell 中通过 mount -t proc proc /proc 来实现
if (
libc.mount(
ctypes.c_char_p(b"proc"),
ctypes.c_char_p(b"/proc"),
ctypes.c_char_p(b"proc"),
0,
None,
)
!= 0
):
sys.exit(f"[Error] mount /proc failed: {os.strerror(ctypes.get_errno())}")

write_file("/proc/self/setgroups", "deny\n")
write_file("/proc/self/uid_map", f"0 {origin_user_id} 1\n")
write_file("/proc/self/gid_map", f"0 {origin_user_id} 1\n")

os.execlp("bash", "bash") # 启动一个新的 bash shell

# 需要用到的系统调用
libc = ctypes.CDLL(
find_library("c"), use_errno=True
) # 通过 ldconfig -p | grep libc.so 来查找;use_errno 如果不置为 True 的话,一些异常信息会出现错误

STACK_SIZE = 1024 * 1024 # 子栈大小
stack = ctypes.create_string_buffer(STACK_SIZE)
origin_user_id = os.getuid()

# 调用 clone 系统调用创建子进程,并在新的 namespace 中执行
child_stack = ctypes.c_void_p(ctypes.addressof(stack) + STACK_SIZE)
pid = libc.clone(
ctypes.CFUNCTYPE(ctypes.c_int)(child_func),
child_stack,
CLONE_NEWUTS
| CLONE_NEWIPC
| CLONE_NEWPID
| CLONE_NEWNS
| CLONE_NEWUSER
| CLONE_NEWNET
| signal.SIGCHLD,
)

if pid == -1:
sys.exit("Failed to create new namespace")

# write_file(f"/proc/{pid}/setgroups", "deny\n")
# write_file(f"/proc/{pid}/uid_map", f"0 {origin_user_id} 1\n")
# write_file(f"/proc/{pid}/gid_map", f"0 {origin_user_id} 1\n")

os.waitpid(pid, 0) # 等待子进程完成

运行测试如下:

有蛮多细节可以研究一下的:

关于系统调用

clone 系统调用的标志,可以在 Linux 内核的头文件中找到,一般是在 /usr/include/linux/sched.h 中:

libc.so 是 C 标准库的共享库版本,用于提供许多与系统底层交互的基本功能,可以通过 ldconfig -p | grep libc.so 找到它的位置,或者直接用 find_library("c") 来获取。

demo 中调用 clone 系统调用创建子进程,并在新的 namespace 中执行。在 libc.clone 中我们传入需要的标志位,即可创建出符合要求的 namespace。

关于 Mount Namespace 的细节

代码中大部分 Namespace 的创建都比较好理解。

比较关键的是 libc.mount(None, ctypes.c_char_p(b"/"), None, MS_REC | MS_PRIVATE, None)。这里等价于 mount --make-private /。如果这里不进行私有化,则下一步进行:

1
2
3
4
5
6
7
8
libc.mount(
ctypes.c_char_p(b"proc"),
ctypes.c_char_p(b"/proc"),
ctypes.c_char_p(b"proc"),
0,
None,
)
# 或者 mount -t proc proc /proc

的时候,原 Namespace 的 /proc 就会异常:

通过一个新的 namespace 来执行 mount 的操作,为何会影响宿主机呢?就是上面提到的挂载共享传播的问题。

那如何查看哪些命名空间的属性是共享的呢?对于非 Mount Namespace 命名空间,如 UTS、网络、PID 等,其共享属性主要体现在隔离和资源的可见性上,即是对查询的限制,不是对修改的限制。

所以这里主要关注 Mount Namespace 的特性即可。可以通过 /proc/self/mountinfo 文件来检查:

每一行的格式大致如下

1
txt 42 31 8:1 / / rw,relatime shared:1 - ext4 /dev/sda1 rw,data=ordered
  • 在上面的例子中,shared:1 表示该挂载点是共享的,属于共享组 1
  • 共享组:挂载点可以是共享的、私有的或者是从属的。shared:X 表示该挂载点属于共享组 X。挂载操作会在同一共享组的所有成员间传播。

关于 User Namespace 的细节

在代码中的这三行:

1
2
3
write_file("/proc/self/setgroups", "deny\n")
write_file("/proc/self/uid_map", f"0 {origin_user_id} 1\n")
write_file("/proc/self/gid_map", f"0 {origin_user_id} 1\n")

这里重点是两个 map 文件的写入,通过这两个文件的写入来做用户映射。如果不映射父 user namespace 的 user ID 和 group ID 到子 user namespace 中来,当在新的 user namespace 中用 getuid()getgid() 获取 user id 和 group id 时,系统将返回文件 /proc/sys/kernel/overflowuid 中定义的 user ID 以及 /proc/sys/kernel/overflowgid 中定义的 group ID,它们的默认值都是 65534。也就是说如果没有指定映射关系的话,会默认映射到 ID 65534,一般是 nobody 这个用户。

关于 setgroups 的设置

理论上来说,映射用户之前,需要先进行 write_file(f"/proc/{pid}/setgroups", "deny\n") 否则后续 uid_mapgid_map 无写入权限。原因在于 /proc/[pid]/setgroups 控制着是否允许在 User Namespace 中使用 setgroups 系统调用,在创建 User Namespace 并在进行用户 ID 映射之前,必须设置为 deny 以禁止使用 setgroups,它会阻止在 User Namespace 中调用 setgroups,从而防止可能的权限提升。这是内核的防护机制。

这里有个细节,如果是在主进程里进行的映射,则不需要写 setgroups,也不会提示无权限写入。但如果是在子进程(即 child_func)中进行映射,则必须先写 setgroups。

关于 uid_map 与 gid_map 的写入

这两个文件的拥有者是创建新的 user namespace 的用户,即执行主进程的用户。

写入的内容主要是 ID-inside-ns ID-outside-ns length

  • ID-inside-ns:代表新 namespace 环境中的用户 id
  • ID-outside-ns:代表宿主环境中的用户 id
  • length:连续映射的长度

例如:

  1. 0 0 1 代表把新 namespace 中的 root 用户映射到宿主环境中的 root 用户
  2. 0 99 1 代表把新 namespace 中的 uid 为 99 的用户映射到宿主环境中的 root 用户
  3. 0 10 100 代表把新 namespace 中的 uid 为 10 到 110 的用户映射到宿主环境中 uid 为 0 到 100 的用户

通过 id user 就可以查看该用户的 uid 和 gid;反之,grep ":1000:" /etc/passwdgrep ":1000:" /etc/group 即可查看 uid/gid 为 1000 的用户名

写入的时候需要注意,不论是在主进程还是在子进程写入,ID-outside-ns 都必须是主进程用户对应的 uid,若违反,则在子进程中就会出现无写入权限的报错,而在主进程写的时候虽不报错,但实际上用户映射也不会生效。所以显然,创建 user namespace 不需要 root 权限。

同时,在主进程和在子进程中的对于 length 的处理还有一定差异。主进程中 length 可以指定任意长度,只要 ID-outside-ns 是主进程用户的 uid 即可。但在子进程中写入时,length 必须是 1,否则就会就会出现无写入权限的报错。

这里暂时不对此机制做过多深入的研究,目测和内核机制细节有关,较为复杂。

最后,两个 map 文件只能写一次数据,但可以一次写多条,并且最多只能 5 条。

在写这 3 个文件的时候,会检查这当前进程是否有 CAP_SETUIDCAP_SETGID 这两个权限。我的环境中 bash 默认有这两个的权限,所以不需要额外的设置。

capability 与 User namespace 的关系

Linux 下的每个 namespace,都有一个 user namespace 与之关联,这个 user namespace 就是创建相应 namespace 时进程所属的 user namespace,除了 user namespace 外,创建其它类型的 namespace 都需要 CAP_SYS_ADMIN 的 capability。当新的 user namespace 创建并映射好 uid、gid 了之后, 这个 user namespace 的第一个进程将拥有完整的所有 capabilities,意味着它就可以创建新的其它类型 namespace。

通过 cat /proc/$$/status | egrep 'Cap(Prm|Eff)' 即可查看,若为 0 值,则需要通过 sudo setcap cap_setgid,cap_setuid+ep /bin/bash 进行新增(-ep 则是去除)。

关于 capability 的详细介绍可以参考 这里,简单点说,原来的 Linux 就分 root 和非 root,很多操作只能 root 完成,比如修改一个文件的 owner,后来 Linux 将 root 的一些权限分解了,变成了各种 capability,只要拥有了相应的 capability,就能做相应的操作,不需要 root 账户的权限。

关于映射用户的权限

一句话总结就是:映射后的用户拥有绝大部分原用户的权限(有待进一步研究):

  1. 非 root 映射成 root,可以看到这里其实不是 root:
  2. root 映射成 root,可以看到这里有大部分的 root 权限

总结

最近下定决心开始研究云原生底层的各项能力与机制,容器相关技术是开始的第一步。之前简单接触过 Linux Namespace,最近稍微认真看了下没想到如此复杂...不过想想也不奇怪,各种 Namespace 本来也是随着内核升级不断引入的,加入的时候可能就一点点,日积月累,集腋成裘,后来者学习的时候的确难度比较大。本篇写作过程中也发现了很多之前没有注意到的技术细节,这个新的系列是一个大坑,不知道猴年马月能填完了。


希望这个新的系列能完结🙏


Linux 基础-Namespace
https://www.tr0y.wang/2025/03/03/linux-namespace/
作者
Tr0y
发布于
2025年3月3日
更新于
2025年3月18日
许可协议