命名空间

本页目录

容器内显示 PID 1,却不代表宿主也看到 PID 1;新建挂载空间,也没有复制一份文件内容。namespace 为各类资源建立视图和权限边界,cgroup 则管理资源用量。

容器还需组合根文件系统、capability、seccomp 与安全策略,不存在一个包办全部隔离的 namespace 对象。

查阅目标阅读位置
对象范围与引用类型、创建与进入
身份和文件视图PID、User、Mount
可回收实验与其余边界隔离示例、网络与时间
容器组合多个 namespace,进程可以共享其中一部分 进程 A 独立 mount / PID 进程 B 另一组 mount / PID 同一个 network ns 共享端口与网络设备视图 namespace 成员关系逐种选择;不是一个不可拆分的“容器身份”。

各类 namespace 管什么

类型标志范围与边界
MountCLONE_NEWNS挂载树视图;文件内容可能仍共享,事件可能传播
PIDCLONE_NEWPIDPID 编号与可见层级;不是每个成员都变成 PID 1
UserCLONE_NEWUSERUID/GID 映射和 capability 作用域
NetworkCLONE_NEWNET网络设备、路由、协议栈状态和端口等
IPCCLONE_NEWIPCSysV IPC、POSIX 消息队列等
UTSCLONE_NEWUTShostname 和 NIS domainname;不是独立 DNS 系统
CgroupCLONE_NEWCGROUPcgroup 路径视图;不创建资源额度
TimeCLONE_NEWTIMEmonotonic/boottime 等时钟偏移;不虚拟化所有时钟

具体能力依赖内核配置,创建和进入也受权限限制。namespace 概览

创建、进入与持有引用

clone/clone3 可以让新任务进入新 namespace;unshare 为调用者解除某些共享关系;setns 进入由 fd 指定的已有 namespace。各类型有特殊规则,不能把三者视为完全等价的“更换容器”。

/proc/PID/ns 下的链接可用于比较成员关系,打开相应 fd 或保留 bind mount 可以维持 namespace 引用。进程退出不一定立刻销毁仍被持有的 namespace 对象。

nsproxy 保存多类 namespace 引用,但 user namespace 关联进程凭据,当前 PID namespace 又与 PID 层级有关;不能把 nsproxy 简化为包含一切的完整快照。pid_for_children 和 time_for_children 专门提示“当前所属”与“后续子进程所属”的区别。

PID namespace:编号是相对的

新 PID namespace 的第一个进程成为其中的 PID 1,后续成员有各自编号。同一任务在祖先 PID namespace 中还有其他 PID;祖先可看见后代,子 namespace 不借此获得查看全部祖先进程的能力。

unshare(CLONE_NEWPID) 不改变调用者自己的 PID namespace,而是影响之后创建的子进程;setns 对 PID namespace 也有对应的子进程语义。这解释了 unshare 命令通常需要 --fork 才能让执行的程序进入新 PID 视图。PID namespace

先创建一个子进程,再 unshare 并创建后续子进程。从两个观察者切换视图,比较同一个任务的身份与可见编号,而不是把 namespace 当成复制了一份进程。

正在呈现知识画面
unshare 以后,父进程的 PID 为什么没变?

unshare(CLONE_NEWPID) 改变 pid_for_children,不迁移调用者当前 PID namespace。同一子任务可在祖先和自身空间拥有不同 PID,并没有复制两个进程。

PID 1 具有特殊信号规则:namespace 内其他成员通常只能向它发送其已安装处理器的信号,SIGKILL/SIGSTOP 不能像普通进程一样在内部生效;来自祖先 namespace 的 SIGKILL/SIGSTOP 有特定例外。因此“内部不能 kill,但可以 SIGSTOP”是错误概括。

PID 1 退出时,内核会杀死该 namespace 中其余进程,后续向这个 namespace 创建进程也会受限制;对象仍可能被 fd 保持,不能等同于立即释放所有引用。

验证“当前空间没变,后续子进程变了”

下面用 Python 3.12+ 的 Linux os.unshare() 接口直接观察这两个成员关系。先从命令行创建一个临时 user namespace,让程序获得创建自己 PID namespace 所需的作用域内权限;程序保留外层 proc 挂载,便于父进程按外层 PID 检查子进程。

保存为 pid_view.py,用 unshare --user --map-root-user python3 pid_view.py 运行。程序只创建一个子进程,并用管道控制其退出时机。

import errno
import os

def ns_identity(path):
    info = os.stat(path)
    return info.st_dev, info.st_ino

before_pid = os.getpid()
before_ns = ns_identity("/proc/self/ns/pid")
os.unshare(os.CLONE_NEWPID)
assert os.getpid() == before_pid
assert ns_identity("/proc/self/ns/pid") == before_ns

read_fd, write_fd = os.pipe()
child = os.fork()
if child == 0:
    os.close(write_fd)
    try:
        assert os.getpid() == 1 and os.getppid() == 0
        assert os.read(read_fd, 1) == b"x"
    except BaseException:
        os._exit(1)
    os._exit(0)

os.close(read_fd)
try:
    child_ns = ns_identity(f"/proc/{child}/ns/pid")
    assert child_ns != before_ns
    assert ns_identity("/proc/self/ns/pid_for_children") == child_ns
    assert ns_identity("/proc/self/ns/pid") == before_ns
    assert os.write(write_fd, b"x") == 1
finally:
    os.close(write_fd)
    _, status = os.waitpid(child, 0)
assert os.waitstatus_to_exitcode(status) == 0
print(f"parent PID unchanged: {before_pid}; child seen outside: {child}; inside: 1")

# 新空间的 init 已退出,继续向该空间 fork 应失败。
try:
    unexpected = os.fork()
except OSError as error:
    assert error.errno == errno.ENOMEM
else:
    if unexpected == 0:
        os._exit(1)
    os.waitpid(unexpected, 0)
    raise AssertionError("已退出 init 的 PID namespace 不应接受新进程")

PID 数字随每次运行改变,真正要检查的是四个关系:父进程自己的编号未变;子进程在内部是 1;子进程看到外部父进程的 PPID 为 0;父进程的 pid_for_children 与子进程当前 PID namespace 相同。pid_for_children 在第一次创建子进程前还有特殊可见性,因此代码等到 fork 之后才比较它。

unshare(PID) 改变后续子进程空间,调用者留在原处 父进程:当前 PID namespace A getpid() 保持原编号 子进程:当前 PID namespace B 内部 PID=1,外部另有编号 fork 父的 pid_for_children → B;父的 ns/pid → A 子进程的父亲位于 B 之外,因此其 getppid() 返回 0;PID 1 退出后 B 不再接受 fork。

最后的 ENOMEM 在这个受控轨迹里来自 namespace init 已退出,不能一概解释为物理内存不足。实际程序仍应保留 errno 并结合前置操作诊断。实验不重新挂载 proc,所以它没有演示容器内 ps 的完整视图;需要该视图时再配合独立 mount namespace 和新的 proc 实例。PID namespace 的成员与 proc 语义

作为 PID 1 运行,还需承担信号与回收责任

孤儿由适当的 subreaper 或 namespace init 收养,负责方应 wait 回收。应用作为 PID 1 时,需要正确处理终止信号与子进程回收,不能只依赖普通交互式程序的默认行为。

User namespace:root 的意义取决于映射

在非初始 user namespace 内读取自己的 /proc/self/uid_map,若看到 0 1000 1,可按三个字段解释:

字段数值含义
内部起始 UID0新空间内的身份
父空间起始 UID1000对应外部身份
范围长度1仅映射这一个 UID

它不会自动创建其他用户映射,也不能把内部 root 简单换算成宿主 root。

内部 root 不等于宿主 nobody;也不是自动无法读取任何宿主文件。若挂载视图暴露了宿主文件,映射后的身份仍可能拥有普通访问权限。user namespace 内的 capability 主要对其权限作用域内资源生效,不会自动获得初始 user namespace 的全局权限。User namespace

多 UID/GID 映射涉及 subuid/subgid、映射助手和 setgroups 限制等。无特权 user namespace 是否可创建,还可能受发行版安全策略、LSM 和数量限制影响;kernel.unprivileged_userns_clone 不是所有 Linux 上都统一存在的开关。

嵌套映射要逐层合成,也要记录读者视角

设第一层 namespace 到初始 namespace 的范围为 0 100000 65536,第二层到第一层只映射 0 1000 1。那么第二层 UID 0 先映成第一层 UID 1000,再映成初始 namespace UID 101000;第一层自己的 UID 0 则对应初始 UID 100000。两层都叫 root,并不意味着它们代表同一个外部身份。

def mapped(uid, ranges):
    for inside, outside, count in ranges:
        if inside <= uid < inside + count:
            return outside + uid - inside
    return None

first_to_initial = [(0, 100000, 65536)]
second_to_first = [(0, 1000, 1)]
intermediate = mapped(0, second_to_first)
assert intermediate == 1000
assert mapped(intermediate, first_to_initial) == 101000
assert mapped(0, first_to_initial) == 100000
assert mapped(65535, first_to_initial) == 165535
assert mapped(65536, first_to_initial) is None
assert mapped(1, second_to_first) is None

模型里的 None 表示没有对应映射,不是 UID 0,也不等于自动拥有 nobody 的权限。内核在不同接口中可能用 overflow UID 显示无法表示的所有者,或者拒绝需要有效映射的操作;显示结果不能替代授权判断。上述范围也只是映射计算示例,实际创建它们仍需满足映射写入权限与父层可映射范围。

还有一个容易漏掉的细节:如果读取者与目标进程在同一个 user namespace,uid_map 第二列按目标的父 namespace 解释;如果两者在不同 user namespace,第二列按打开该文件的读取者的 namespace 解释。因此采集映射时应同时记录读取者的位置,不能把任意 /proc/PID/uid_map 的第二列都标成“宿主 UID”或“父 UID”。User namespace:UID/GID 映射的读取规则

Mount namespace:独立挂载表不等于独立文件内容

新空间通常从现有挂载视图复制而来,底层文件系统仍可能相同。共享子树的 shared/slave/private 等传播属性决定挂载事件如何跨视图传播;因此“一个空间 mount 永远不影响另一个”不成立。挂载 namespace

根目录隔离还需要合适的挂载和根切换设计。chroot 或新 mount namespace 本身都不会复制所有文件,也不会自动撤销已打开 fd 的访问能力。

一个不改宿主 hostname 的局部示例

以下命令在允许无特权 user namespace 的环境中创建 user、UTS、PID 和 mount 空间,子进程退出后结束实验:

unshare --user --map-root-user --uts --pid --fork --mount --mount-proc   sh -c 'hostname kb-demo; printf "pid=%s hostname=%s\n" "$$" "$(hostname)"; cat /proc/self/uid_map'

hostname 修改发生在新 UTS namespace;--mount-proc 为新 PID 视图挂载 proc,避免 ps 等工具仍读取外部 proc 实例。输出的 UID 映射取决于执行者,不能预设一定为宿主 1000。权限拒绝时先识别环境策略,不要为运行教学例子直接放宽整机安全配置。

网络与时间空间的常见误解

容器可以共享网络 namespace,甚至使用宿主网络,所以不是每个容器必有独立 netns。新网络空间也不自动得到可用外网,需要按方案配置设备、地址、路由或用户态网络后端。

time namespace 为相应 monotonic/boottime 时钟提供偏移,适合保持迁移后的时间视图;CLOCK_REALTIME 不因此获得每容器独立墙钟。创建后的子进程与偏移配置时机也有专门限制。Time namespace

进一步理解资源上限与委派,见 cgroup。