返回全部文章
KERNEL API / 05Linux 内核 · Mount API

open_tree + move_mount:为什么能够跨 mount namespace?

从挂载树和 mount namespace 的隔离边界出发,解释 detached bind clone、匿名 mount namespace 与 fd 如何协作完成跨 namespace 挂载。

日期: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,它们会组成下面这棵树:

text
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 就能连成树:

c
/* 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。把这些关系简化后,大致是下面这样:

c
/* 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 一起切过去:

c
/* 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:

c
/* 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:

c
/* 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:

c
/* 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 能看到的挂载关系。

Mount namespace 视图隔离:同名路径在不同 namespace 中进入不同 mount

图 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:

text
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,不属于就拒绝:

c
/* 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 安装进去。

整体顺序可以先写成下面这个用户态骨架。错误处理在示例中省略,但顺序不能颠倒:

c
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:

c
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 clonemount --bind / --rbind
open_tree()(无 CLONE)+ move_mount当前 namespace 中已有的 attached mountmount --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:

c
/* 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:

c
/* 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 的典型特征:

c
/* 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 */

open_tree OPEN_TREE_CLONE 创建 detached bind clone

图 2:open_tree(OPEN_TREE_CLONE) 做的是 bind clone,不是创建新的文件系统实例。

clone 不能让 mnt_ns 为空,因为 move_mount 要求源已经是一个合法 mounted object;但它又不能继续属于 A 的活跃挂载树。内核的解决办法是为它分配一个匿名 mount namespace,把整棵 tree 放进去,再用 O_PATH fd 持有:

c
/* 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:

c
/* 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。

open_tree 加 move_mount 跨 mount namespace 的完整时序

图 3:源路径在 A 中解析,目标路径在 B 中解析;fd 在 setns 前后仍持有同一棵 detached tree。

把全过程合起来看,跨 namespace 并不是某个系统调用绕过了隔离,而是新 API 在隔离边界两边各完成了一半工作:

text
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。