日期:2026-08-14
代码基线:Linux 6.6.114,涉及
fs/namespace.c、fs/mount.h、fs/pnode.h、fs/pnode.c、fs/namei.c本文从 mount namespace 的隔离边界出发,说明跨 namespace 挂载要解决什么问题,以及
open_tree(OPEN_TREE_CLONE)和move_mount()为什么能够把这件事做成。
1. 前言:什么是 namespace?它隔离了什么?
可以先把 Linux namespace 理解成一副“给进程看的眼镜”。进程仍然运行在同一个内核里,但戴上不同的眼镜后,看到的 PID、网络设备、主机名、用户映射和挂载树可以不同。namespace 不是虚拟机,也不是把所有内核对象复制一份;它是让进程在查找和操作某类资源时,先进入自己所属的上下文。本文只讨论 mount namespace。
在继续讲 mount namespace 之前,需要先说明“挂载树”是什么。它不是普通的目录树:目录树描述的是一个文件系统内部的 dentry 层级,而挂载树描述的是多个文件系统视图怎样通过 mountpoint 拼接在一起。例如根文件系统提供 /,procfs 挂在 /proc,tmpfs 挂在 /run,另一个磁盘文件系统或 bind view 挂在 /data,它们会组成下面这棵树:
root mount:根文件系统,挂载点 /
├── proc mount:procfs,挂载点 /proc
├── tmpfs mount:tmpfs,挂载点 /run
└── data mount:磁盘文件系统或 bind view,挂载点 /data
VFS 用 vfsmount 表示“一次被挂载出来的文件系统视图”。其中 mnt_sb 指向底层文件系统实例的 superblock,mnt_root 表示这个视图从哪个 dentry 开始。内核私有的 struct mount 再给它加上父 mount、挂载点和子 mount 列表,于是一个个 mount node 就能连成树:
/* include/linux/mount.h:一次文件系统视图 */
struct vfsmount {
struct dentry *mnt_root; /* 这个视图的根 dentry */
struct super_block *mnt_sb; /* 底层文件系统实例 */
int mnt_flags;
...
};
/* fs/mount.h:给 vfsmount 增加挂载拓扑关系 */
struct mount {
struct mount *mnt_parent; /* 父 mount */
struct dentry *mnt_mountpoint; /* 挂在父 mount 的哪个 dentry */
struct vfsmount mnt; /* 本 mount 的文件系统视图 */
struct list_head mnt_mounts; /* 直接子 mount */
struct list_head mnt_child; /* 挂入父 mount 的链表节点 */
struct mnt_namespace *mnt_ns; /* 属于哪个 mount namespace */
...
};
假设路径查找正在根 mount 中向下走,遇到 /proc 对应的 dentry 后,VFS 会根据“当前父 mount + /proc dentry”找到 proc mount,然后把当前 vfsmount 切换到 proc mount、把 dentry 切换到它的 mnt_root,再继续查找。这里切换的是 mount tree 的节点,不是把 procfs 的目录复制进根文件系统。
同一个 superblock 也可以对应多个 mount node。bind mount 就是典型例子:它不会创建新的底层文件系统,而是创建另一个 struct mount,让新的 mount node 复用原来的 mnt_sb,并从指定的 mnt_root 开始展示。因此后文说“克隆一棵 mount tree”,克隆的是挂载节点和拓扑关系,不是复制文件内容。
mount namespace 的隔离首先来自进程身上的两个指针。current->nsproxy->mnt_ns 表示进程当前属于哪一个 mount namespace,current->fs->{root,pwd} 决定绝对路径和相对路径从哪里开始。mount namespace 自己保存挂载树的根和 mount 列表,每个 struct mount 又用 mnt_ns 指回自己所属的 namespace。把这些关系简化后,大致是下面这样:
/* include/linux/nsproxy.h */
struct nsproxy {
...
struct mnt_namespace *mnt_ns;
...
};
/* fs/mount.h */
struct mnt_namespace {
struct ns_common ns;
struct mount * root;
struct list_head list;
struct user_namespace *user_ns;
u64 seq;
unsigned int mounts;
unsigned int pending_mounts;
};
这几个字段组合起来,形成了 mount namespace 的基本隔离模型:进程从自己的 root 进入一棵 mount tree,路径查找沿着这棵树前进;要修改某个 mount 时,内核再比较 mount->mnt_ns 和 current->nsproxy->mnt_ns,确认这个 mount 是否属于当前进程所在的 namespace。
setns(CLONE_NEWNS) 做的也不只是替换一个 namespace 编号。mntns_install() 会同时替换 nsproxy->mnt_ns,并在新 namespace 的挂载树中重新查找 /,把进程的 root 和 pwd 一起切过去:
/* fs/namespace.c: mntns_install() */
get_mnt_ns(mnt_ns);
old_mnt_ns = nsproxy->mnt_ns;
nsproxy->mnt_ns = mnt_ns;
/* 在新 mount namespace 中找到根路径 */
err = vfs_path_lookup(mnt_ns->root->mnt.mnt_root,
&mnt_ns->root->mnt,
"/", LOOKUP_DOWN, &root);
set_fs_pwd(fs, &root);
set_fs_root(fs, &root);
因此,进程从 namespace A 切到 B 后,再解析绝对路径 /data,起点已经变成 B 的 root。fs/namei.c 中,绝对路径会调用 nd_jump_root() 回到当前进程的 root;如果使用 dfd 做相对查找,起点则直接来自已经打开的 file->f_path:
/* fs/namei.c: path_init() */
/* 绝对路径:从 current->fs->root 开始 */
if (*s == '/' && !(flags & LOOKUP_IN_ROOT)) {
error = nd_jump_root(nd);
...
}
/* dfd 相对路径:从 fd 已经保存的 f_path 开始 */
struct fd f = fdget_raw(nd->dfd);
nd->path = f.file->f_path;
路径查找穿越 mountpoint 时,__lookup_mnt() 也不会拿路径字符串重新判断 namespace。它根据“当前父 vfsmount + 当前 dentry”在全局 mount hash 中寻找子 mount:
/* fs/namespace.c */
struct mount *__lookup_mnt(struct vfsmount *mnt,
struct dentry *dentry)
{
struct hlist_head *head = m_hash(mnt, dentry);
struct mount *p;
hlist_for_each_entry_rcu(p, head, mnt_hash)
if (&p->mnt_parent->mnt == mnt &&
p->mnt_mountpoint == dentry)
return p;
return NULL;
}
所以路径隔离并不是“每走一步都检查一次 namespace”,而是先让不同 namespace 的进程从不同的 root、不同的父 mount 出发。起点不同,后续用 __lookup_mnt() 找到的子 mount 自然也不同。这就是为什么同一个 /data 在 A 中可以进入 mount X,在 B 中却进入 mount Y。
只有路径视图不同还不够,内核还要阻止进程修改别人的挂载树。这个约束主要由 check_mnt() 完成,它直接比较待操作 mount 的 mnt_ns 和当前进程的 mnt_ns:
/* fs/namespace.c */
static inline int check_mnt(struct mount *mnt)
{
return mnt->mnt_ns == current->nsproxy->mnt_ns;
}
bind、move、umount、pivot_root 等操作走到关键位置时都会做类似检查。路径可能通过 fd 或 /proc/<pid>/root 指到别的树,但“能够找到”不等于“能够修改”;只要 mount 仍属于另一个 namespace,check_mnt() 就会把操作拦下来。
理解了上面的实现,再看 mount namespace 到底隔离了什么就比较清楚了。它首先隔离挂载拓扑,也就是哪些文件系统或目录挂在哪些路径上;其次隔离路径查找的起点,同一个绝对路径会从各自的 root 开始解析;最后隔离mount 操作的归属,进程通常只能修改当前 namespace 拥有的 mount。/proc/<pid>/mountinfo 展示的也是该进程所在 namespace 能看到的挂载关系。
图 1:mount namespace 让 A 和 B 从不同的挂载树解析同名路径;隔离的是挂载关系和路径视图,不是复制底层文件数据。
它没有隔离的是已经存在的 VFS 对象和底层文件数据。不同 namespace 中的 mount 可以共享同一个 superblock;打开的 fd 会继续持有原来的 struct file 和 f_path;struct mount、vfsmount 也不会因为调用者执行 setns 就被复制或失效。shared/slave peer 关系甚至可以主动把挂载事件传播到其他 namespace。
| 对象或行为 | 是否被 mount namespace 隔离 |
|---|---|
| 挂载拓扑:什么挂在什么位置 | 是 |
| 绝对路径从哪棵树的 root 开始 | 是 |
| mount 修改操作的归属 | 是,受 check_mnt() 等检查约束 |
| 底层文件数据、inode、page cache | 否 |
struct super_block | 否,不同 namespace 的 mount 可以共享 |
已打开 fd 的 file->f_path | 否,fd 继续持有原对象 |
struct mount / vfsmount 对象 | 否,不会因 setns 自动失效 |
| shared/slave peer 传播关系 | 不完全隔离,可以跨 namespace 传播 |
第一章最关键的结论是:mount namespace 隔离的是“从哪棵挂载树查路径、允许修改哪棵挂载树”,而不是把所有 VFS 对象封死在 namespace 里面。 路径受 namespace 影响,fd 已经持有的对象却不会因 namespace 切换而重新解析;后面的跨 namespace 操作正是利用了这条边界。
2. 为什么要做跨 namespace 操作?
最典型的场景来自容器运行时、沙箱管理器和系统服务。假设宿主侧的 namespace A 中已经准备好了 /data,现在要把它以 bind mount 的方式放进容器 namespace B,并在 B 中显示为 /mnt/data。类似需求还包括:把宿主机设备节点注入容器、把 secret 或配置目录挂入沙箱、把运行时动态创建的文件系统视图交给另一个 namespace,以及在切换 mount namespace 前先准备好一组挂载。
这里说的“跨 namespace”不是复制 /data 里的文件,也不是重新创建一次底层文件系统。我们真正想做的是:在 A 中选中一棵 mount tree,创建一个共享原 superblock 的 bind clone,然后把这个 clone 安装到 B 的挂载树中。A 中原来的 mount 必须继续存在,B 得到的是另一个 mount instance:
namespace A namespace B
/data ── bind clone ──► detached tree ── attach ──► /mnt/data
A 中原 mount 保留 B 中新增一个 mount instance
两者共享原 superblock 不复制文件数据
为什么不直接在 B 中访问 /proc/<pidA>/root/data 再做 bind?路径查找虽然可能通过 procfs 进入 A 的 root,但最终得到的 path.mnt 仍属于 A;旧 bind 的 __do_loopback() 会检查源 mount 是否属于当前 namespace,不属于就拒绝:
/* fs/namespace.c: __do_loopback() */
struct mount *old = real_mount(old_path->mnt);
if (!check_mnt(old) &&
old_path->dentry->d_op != &ns_dentry_operations)
return ERR_PTR(-EINVAL);
旧 mount(MS_BIND) 还有一个结构性限制:克隆源和挂到目标发生在同一次系统调用中。源和目标都必须属于调用者当前的 namespace,中间没有机会先在 A 中拿到源、切换到 B、再解析目标。这就是新 mount API 要解决的实际问题:把“准备源 mount”和“把它安装到目标”拆开,让 namespace 切换发生在两步之间。
跨 namespace 并不等于绕过权限。运行时通常需要在源 namespace 中有 mount 权限,能够进入目标 mount namespace,并在目标 namespace 中有安装 mount 的权限;LSM 也会在相应 hook 上继续检查。这个机制提供的是一条安全交付 mount 对象的路径,不是一条越权通道。
3. 做跨 namespace 操作,需要做哪些工作?
要把 A 中的 /data 安装到 B 的 /mnt/data,用户态需要完成五件事。第一,先保存 A 和 B 的 mount namespace fd,否则切换出去后可能无法回来。第二,在 A 中解析源路径并创建 bind clone,不能等进入 B 后再用普通绝对路径寻找 A 的源。第三,用 fd 持有 clone,让它在切换 namespace 后仍然可用。第四,通过 setns 进入 B,或者把 mount fd 通过 SCM_RIGHTS、pidfd_getfd 等方式交给 B 中的进程。第五,在 B 中解析目标路径,并把 fd 指向的 clone 安装进去。
整体顺序可以先写成下面这个用户态骨架。错误处理在示例中省略,但顺序不能颠倒:
int ns_a = open("/proc/<pidA>/ns/mnt", O_RDONLY | O_CLOEXEC);
int ns_b = open("/proc/<pidB>/ns/mnt", O_RDONLY | O_CLOEXEC);
/* 1. 在 A 中解析源,并取得 detached bind clone */
setns(ns_a, CLONE_NEWNS);
int tree_fd = open_tree(AT_FDCWD, "/data",
OPEN_TREE_CLONE |
AT_RECURSIVE |
OPEN_TREE_CLOEXEC);
/* 2. 切到 B;tree_fd 仍指向同一棵 detached tree */
setns(ns_b, CLONE_NEWNS);
/* 3. 目标 /mnt/data 按 B 的挂载树解析 */
move_mount(tree_fd, "",
AT_FDCWD, "/mnt/data",
MOVE_MOUNT_F_EMPTY_PATH);
close(tree_fd);
这里必须注意源参数的写法。move_mount(tree_fd, "", ..., MOVE_MOUNT_F_EMPTY_PATH) 表示直接使用 fd 本身指向的 mount root;如果写成以 / 开头的源路径,路径查找会跳回当前 namespace B 的 root,也就丢掉了跨 namespace 的意义。open_tree 的源路径在 A 中解析,move_mount 的目标路径在 B 中解析,两端不是在同一个路径世界里完成的。
仅仅用 open_tree 打开 A 中已有的 mounted path 还不够,跨 namespace 时必须带 OPEN_TREE_CLONE。不带 CLONE 的 fd 仍指向 A 中已经 attached 的 mount;切到 B 后,move_mount 会发现源有 parent,却不属于当前 namespace,于是 check_mnt(old) 失败。带 CLONE 后得到的是 detached tree,它的 mnt_parent == self,并被放入匿名 mount namespace,这才具备交给 B 的条件。
| 在 A 中准备的源 | 切到 B 后能否安装 | 原因 |
|---|---|---|
open_tree(),不带 CLONE | 不能 | 源仍是 A 中 attached mount |
open_tree(OPEN_TREE_CLONE) | 可以 | 源是匿名 ns 中的 detached bind clone |
fsmount() | 可以 | 源是匿名 ns 中新创建的 detached mount |
权限也要分别满足。open_tree(OPEN_TREE_CLONE) 和 move_mount 都会走 may_mount(),检查调用者是否对当前 mount namespace 的 user namespace 具有 CAP_SYS_ADMIN;setns 还会检查目标 user namespace 的 CAP_SYS_ADMIN,以及调用者自身 user namespace 的 CAP_SYS_ADMIN 和 CAP_SYS_CHROOT:
bool may_mount(void)
{
return ns_capable(current->nsproxy->mnt_ns->user_ns,
CAP_SYS_ADMIN);
}
因此,这套流程需要运行时对源、目标两端都具备相应授权。它能跨越的是挂载树的路径边界,不是安全边界。
4. open_tree 和 move_mount 做了什么事?
open_tree 负责准备源,move_mount 负责把源接到目标。它们之间用一个 O_PATH fd 传递 mount tree。前半段怎么准备源,决定最终语义是 bind、move 还是新 mount:
| 用户态组合 | 准备出来的源 | 最终语义 |
|---|---|---|
open_tree(OPEN_TREE_CLONE) + move_mount | 复用原 superblock 的 detached clone | mount --bind / --rbind |
open_tree()(无 CLONE)+ move_mount | 当前 namespace 中已有的 attached mount | mount --move |
fsopen + fsconfig + fsmount + move_mount | 基于 fs_context 创建的新 mount | 新文件系统 mount |
先看 open_tree。它在当前 mount namespace 中解析 dfd + filename;不带 OPEN_TREE_CLONE 时,只返回现有路径的 O_PATH fd。带 CLONE 时则调用 open_detached_copy(),先做 bind clone,再把 clone 放入匿名 mount namespace:
/* fs/namespace.c: open_tree() */
bool detached = flags & OPEN_TREE_CLONE;
if ((flags & (AT_RECURSIVE | OPEN_TREE_CLONE)) == AT_RECURSIVE)
return -EINVAL;
if (detached && !may_mount())
return -EPERM;
if (detached)
file = open_detached_copy(&path, flags & AT_RECURSIVE);
else
file = dentry_open(&path, O_PATH, current_cred());
OPEN_TREE_CLONE 走的不是文件系统的 get_tree 或 fill_super,而是与旧 bind mount 共用的 __do_loopback()。非递归 clone 调 clone_mnt(),递归 clone 调 copy_tree(),所以 OPEN_TREE_CLONE | AT_RECURSIVE 对应的就是 rbind:
/* fs/namespace.c: __do_loopback() */
if (recurse)
mnt = copy_tree(old, old_path->dentry,
CL_COPY_MNT_NS_FILE);
else
mnt = clone_mnt(old, old_path->dentry, 0);
clone_mnt() 最关键的几行说明它复用了原 superblock,只创建新的 mount instance。mnt_root 还可以是任意子 dentry,这也是 bind mount 的典型特征:
/* fs/namespace.c: clone_mnt() */
struct super_block *sb = old->mnt.mnt_sb;
atomic_inc(&sb->s_active);
mnt->mnt.mnt_sb = sb; /* 与源共享 superblock */
mnt->mnt.mnt_root = dget(root); /* 可以从子目录起步 */
mnt->mnt_mountpoint = mnt->mnt.mnt_root;
mnt->mnt_parent = mnt; /* detached:自己是自己的 parent */
图 2:open_tree(OPEN_TREE_CLONE) 做的是 bind clone,不是创建新的文件系统实例。
clone 不能让 mnt_ns 为空,因为 move_mount 要求源已经是一个合法 mounted object;但它又不能继续属于 A 的活跃挂载树。内核的解决办法是为它分配一个匿名 mount namespace,把整棵 tree 放进去,再用 O_PATH fd 持有:
/* fs/namespace.c: open_detached_copy() */
struct mnt_namespace *ns = alloc_mnt_ns(user_ns, true);
mnt = __do_loopback(path, recursive);
for (p = mnt; p; p = next_mnt(p, mnt)) {
p->mnt_ns = ns;
ns->mounts++;
}
ns->root = mnt;
mntget(&mnt->mnt);
file = dentry_open(path, O_PATH, current_cred());
file->f_mode |= FMODE_NEED_UNMOUNT;
匿名 mount namespace 没有对应任务,也不能被 setns 进入。它只是 detached tree 的临时容器:源已经满足 is_mounted(),但 mnt_parent == self;如果 fd 在安装前关闭,FMODE_NEED_UNMOUNT 会让 dissolve_on_fput() 拆掉这棵 tree。clone 还没暴露到文件系统层次中,因此可以在 attach 前调用 mount_setattr,包括设置 idmapped mount。
再看 move_mount。它先检查目标 mountpoint 是否属于调用者当前 namespace,然后区分源是 attached 还是 detached。attached 源必须也属于当前 namespace;detached 源则允许来自匿名 mount namespace:
/* fs/namespace.c: do_move_mount() */
old = real_mount(old_path->mnt);
p = real_mount(new_path->mnt);
attached = mnt_has_parent(old);
ns = old->mnt_ns;
/* 目标必须属于当前 namespace */
if (!check_mnt(p))
goto out;
/* 源必须是合法 mount */
if (!is_mounted(&old->mnt))
goto out;
/* attached 源必须属于当前 ns;detached 源必须来自匿名 ns */
if (!(attached ? check_mnt(old) : is_anon_ns(ns)))
goto out;
if (!path_mounted(old_path))
goto out;
if (attached)
flags |= MNT_TREE_MOVE;
err = attach_recursive_mnt(old, p, mp, flags);
这段不对称检查就是跨 namespace 能成立的关键。进程已经切到 B,所以目标通过 check_mnt(p);源不属于 B,但它是 detached 的,检查走 is_anon_ns(ns) 分支,也能通过。随后 attach_recursive_mnt(..., 0) 把 clone graft 到 B,commit_tree() 会把 tree 中 mount 的 mnt_ns 改成 B,最后释放匿名 namespace。如果源是 attached 的,函数则设置 MNT_TREE_MOVE,执行真正的 mount tree move。
图 3:源路径在 A 中解析,目标路径在 B 中解析;fd 在 setns 前后仍持有同一棵 detached tree。
把全过程合起来看,跨 namespace 并不是某个系统调用绕过了隔离,而是新 API 在隔离边界两边各完成了一半工作:
namespace A
open_tree(OPEN_TREE_CLONE, "/data")
├─ __do_loopback() 创建 bind clone
├─ clone 放入匿名 mount namespace
└─ O_PATH fd 持有 detached tree
setns(namespace B)
└─ root / pwd / current->mnt_ns 改为 B,fd 不变
namespace B
move_mount(fd, "", "/mnt/data", MOVE_MOUNT_F_EMPTY_PATH)
├─ 目标属于 B:check_mnt(target) 通过
├─ 源属于匿名 ns:is_anon_ns(source) 通过
└─ clone 接入 B,匿名 namespace 释放
因此最终结论是:open_tree(OPEN_TREE_CLONE) + move_mount 是一次被拆成两步的 bind mount。 open_tree 在 A 中把路径转换成 fd 持有的 detached bind clone,move_mount 在 B 中把这个 clone 接回目标路径。fd 负责把对象带过 namespace 切换点,匿名 mount namespace 提供中立的临时归属,move_mount 的不对称检查负责安全放行。A 中原 mount 不动,B 得到共享同一 superblock 的新 mount instance;不带 CLONE 时则只是 move,只有 fsmount + move_mount 才代表安装新创建的 mount。