跳到主要内容

客机文件系统:virtio-fs 后端、写时复制与主机穿透

30 秒导读: 微VM 里跑的程序读写文件时,内核发出的每一个 FUSE 请求,都由主机上一个普通用户态进程接住并回答。这个进程实现了三种"文件系统后端":纯内存的 memfs、直穿主机目录的 passthroughfs、以及把两层叠起来做写时复制的 dualfs。本章讲这三层怎么分工、写层(可写上层)怎么在只读底层之上凭空长出来,以及主机进程如何在不动主机真实 inode 的前提下,把 uid/gid/权限/配额都"演"给客机看。

本章是运行期视角:VM 已经起来了,客机正在挂载、读写文件。只读镜像本身的字节布局(EROFS/OCI 层)是构建期的事,见 03-image-rootfs;客机通过协议帧发起的远程文件操作(host 端主动读写沙箱里的文件)走的是另一条通道,见 02-protocol-relay


1. 这是什么(零基础也能懂)

一句话定义: 客机的文件系统不是一块真磁盘,而是主机进程用代码"现编"的答案——客机内核每次 open/read/readdir,都通过 virtio-fs 这条虚拟总线问到主机进程头上,主机进程查自己的数据结构、必要时读主机真实文件,再把结果回给客机。

为什么要这样做? 因为 microsandbox 的目标是"启动快、隔离强、还能和主机共享目录"。如果给每个沙箱都格式化一块虚拟磁盘,又慢又浪费。用 virtio-fs 直接把主机的一个目录"投影"进客机,既省事又能双向共享。但直接投影有两个麻烦:

  • 底层是只读的(OCI 根文件系统镜像不能被沙箱改坏)——那客机怎么写文件?
  • 主机进程不是 root——它没法 chown、没法造设备节点、没法给符号链接设 xattr,可客机里的程序偏偏要求这些能力都正常。

microsandbox 在这一层的全部精巧,都是在回答这两个问题。

一句话直觉/类比: 把它想成一个尽职的前台代接员。客机打电话来问"3 号文件谁是主人?",前台不敢冒充主人的名义改户口(主机 root 权限),就掏出一个小本子(xattr 里的 20 字节 override),照本子念一个"虚拟户主"给你听。你要改文件?底层文件是只读的,前台就先把它复印一份放到可写的上层,让你改复印件——这就是写时复制(copy-on-write,COW,只有真要写时才复制,读的时候大家共享同一份)。

它能做什么:

  • 把主机某个目录直穿给客机读写(passthroughfs)。
  • 在只读镜像之上叠一个可写层,实现 COW,客机改动只落在上层(dualfs)。
  • 提供一个纯内存的临时文件系统(memfs)。
  • 在主机进程不特权的前提下,把 uid/gid/mode/设备号、以及"这个文件属于谁"都虚拟化给客机看,而不真的动主机 inode。
  • 给可写层设一个字节预算(quota),防止客机把主机磁盘写爆。

2. 顶层全景(它大概怎么转)

2.1 一次客机读文件,请求走到哪

客机(microVM) 主机进程(microsandbox runtime)
┌────────────────────┐ ┌───────────────────────────────────┐
│ 应用 read() │ │ DynFileSystem 后端 │
│ │ │ virtio-fs │ ├─ PassthroughFs 直穿主机目录 │
│ ▼ │ (FUSE over │ ├─ DualFs 只读+可写叠层 │
│ 客机内核 FUSE ────┼── virtio 总线)─┼─▶ └─ MemFs 纯内存 │
│ │ │ │ │ │
│ ▼ │ │ ▼ 必要时 │
│ 收到 stat/数据 ◀───┼─────────────────┼── 读主机真实文件 / 查内存表 │
└────────────────────┘ └───────────────────────────────────┘

怎么读这张图:客机内核把文件操作打成 FUSE 请求,经 virtio-fs 传给主机进程;主机进程选中一个后端,后端要么查自己的内存表(memfs),要么真去 openat/read 主机上的文件(passthroughfs),要么在两层之间做决策(dualfs)。主机内核对这一切一无所知——它只看到一个普通进程在读它自己有权读的文件。

2.2 三种后端各干什么

后端一句话职责存储在哪代码位置
MemFs纯内存文件系统,进程退出即消失全在进程内存crates/filesystem/lib/backends/memfs/*
PassthroughFs把主机一个目录直穿给客机,带 stat 虚拟化主机真实目录crates/filesystem/lib/backends/passthroughfs/{unix,windows}/*
DualFs组合两个子后端,用可编程策略实现 COW / 叠加两个子后端各存一部分crates/filesystem/lib/backends/dualfs/*

三者都实现同一个 trait DynFileSystem(定义在 crates/filesystem/lib/lib.rs:45 一带 re-export),所以对上层(virtio-fs 的接线代码)完全可替换。backends/mod.rs:6-12 就是这个分工的入口:

// crates/filesystem/lib/backends/mod.rs:6
#[cfg(unix)]
pub mod dualfs;
#[cfg(unix)]
pub mod memfs;
pub mod passthroughfs;

注意 dualfs/memfs 只在 Unix 编译——因为 COW 与内存后端目前只服务 Linux/macOS 主机;passthroughfs 跨平台(unix/windows/ 两套实现)。

2.3 关键角色:DualFs 自己不存任何数据

这是理解本章的钥匙。DualFs 是个编排者,它不拥有任何文件字节;所有数据都在两个子后端里(types.rs:56-57 注释:"The core owns the guest identity; child backends own the storage")。DualFs 只维护"客机看到的身份"——哪个客机 inode 现在由哪个子后端支撑。

// crates/filesystem/lib/backends/dualfs/mod.rs:60
pub struct DualFs {
backend_a: Box<dyn DynFileSystem>, // 通常是可写层
backend_b: Box<dyn DynFileSystem>, // 通常是只读底层
policy: Arc<dyn DualDispatchPolicy>, // 每个操作读走哪层、写落哪层
hooks: Vec<Arc<dyn DualDispatchHook>>,
state: DualState, // 全部客机命名空间状态
...
}

约定俗成: backend_a可写上层,backend_b只读底层。默认策略的名字就把这点写死了——ReadBackendBWriteBackendA(读走 B、写落 A)。


3. dualfs 的写时复制核心

这是本章工程含量最高的部分。先建立直觉,再看策略如何决策,最后看"物化"(materialize)如何把只读文件搬到可写层。

3.1 客机 inode 的五种"背书状态"

DualFs 给每个客机可见对象记一个 NodeState,表示"这东西现在由哪层撑着"。这是 COW 的核心账本。

// crates/filesystem/lib/backends/dualfs/types.rs:87
pub enum NodeState {
Root { backend_a_root, backend_b_root }, // 根目录,两层都有
BackendA { backend_a_inode, former_backend_b_inode }, // 只在 A(可能是从 B 物化来的)
BackendB { backend_b_inode, former_backend_a_inode }, // 只在 B(只读底层原件)
MergedDir { backend_a_inode, backend_b_inode }, // 目录:两层都存在,readdir 要合并
Init, // 虚拟的 init.krun 二进制
}

用一句话记住这五种:

状态含义触发写会怎样
BackendB只读底层的原件,尚未被改过写前物化到 A
BackendA已在可写层(新建的,或物化后的)直接写 A
MergedDir目录同时在两层可见readdir 合并两层
Root根,永远两层都有
Init注入的 init.krun,inode=2拒绝(EACCES)

former_backend_b_inode 这个字段很妙:一个文件从 B 物化到 A 后,它记住自己原来在 B 的 inode,以便 forget 时把两层的 pin 都释放干净(见 lookup.rs:816forget_one)。

3.2 策略:每个操作读走哪层、写落哪层

DualFs 把"路由决策"抽成一个 trait,每个 FUSE 操作前调用一次,必须快、不做 I/O:

// crates/filesystem/lib/backends/dualfs/policy.rs:18
pub trait DualDispatchPolicy: Send + Sync {
fn plan(&self, req: &RequestCtx, view: &DualNamespaceView, hints: &HintBag)
-> io::Result<DualDispatchPlan>;
}

plan() 返回一个 DualDispatchPlan 枚举(policy.rs:107-140),这是决策的"指令集":

Plan 变体含义
UseBackendA / UseBackendB操作直接送某一层
MergeLookup { precedence }lookup 按优先级试两层,先命中先返回
MergeReaddir { precedence }readdir 合并两层目录项
MaterializeToBackendThen { source, target, then }先物化再执行——COW 的关键
TryBackendAThenBackendB { fallback_on }先 A,遇指定 errno 回退 B
Synthetic / Deny不碰后端,直接合成响应 / 拒绝

默认策略 ReadBackendBWriteBackendA 把 COW 语义写得一清二楚(policies/read_backend_b_write_backend_a.rs:27):

// crates/filesystem/lib/backends/dualfs/policies/read_backend_b_write_backend_a.rs:39
OpKind::Open => {
let is_write = (req.flags as i32) & (O_WRONLY | O_RDWR | O_TRUNC) != 0;
if is_write {
match &req.node_state {
NodeState::BackendB { .. } => // 只读底层的文件,要写它
MaterializeToBackendThen { source: B, target: A, .. }, // → 先复印到 A
NodeState::BackendA | MergedDir | Root => UseBackendA { .. }, // 已在可写层,直接写
NodeState::Init => Deny { errno: EACCES }, // init.krun 不许写
}
} else { /* 只读打开:B 的读 B,其余读 A */ }
}

读完这段,COW 的机制就通透了:只读底层的文件,只有在被以写意图打开(或 setattr/setxattr)时,才触发物化;纯读永远不复制。 创建/mkdir/unlink/rename 等改命名空间的操作,一律落 A(read_backend_b_write_backend_a.rs:78-87)。

四个内置策略,对应四种叠加语义:

策略lookup用途直觉
ReadBackendBWriteBackendA合并,B 优先落 A,B 上的先物化默认 COW:只读镜像 + 可写上层
BackendAFallbackToBackendBRead合并,A 优先一律落 AA 是主,B 只兜底读
MergeReadsBackendAPrecedence合并,A 赢平局落 A两层都可见,A 覆盖 B
BackendAOnly只查 A落 AB 永不参与(退化成单层)

3.3 物化(materialize):把只读文件复印到可写层

当策略说 MaterializeToBackendThen 时,file_ops.rs 的 open 分支先调 do_materialize,再在目标层打开(file_ops.rsdo_open 的 dispatch 段)。物化是整个 COW 的"复印动作":

物化一个 B 层文件到 A 层(materialize.rs:20 do_materialize)
────────────────────────────────────────────────────────
① 加 copy_up_lock,双重检查(可能别的线程已物化) :38-46
② ensure_ancestors:确保所有父目录在 A 层已存在 :57 → 递归 promote_directory_to_merged
③ 在 A 的隐藏 staging 目录里 create 一个 tmp 文件 :389
④ 按 copy_chunk_size(默认 1 MiB)分块把 B 的数据流到 A :401-446
⑤ 复制 xattr、时间戳、属主 :459-463
⑥ rename:把 tmp 从 staging 原子搬到最终位置 :466
⑦ NodeState 从 BackendB 翻成 BackendA,记住 former_b :112-125
⑧ 释放对 B 原件的 pin(forget) :135

几处值得记住的设计:

  • 目录不走物化,走"提升"(promotion)。 目录被 promote_directory_to_merged(materialize.rs:159)在 A 层 mkdir 一个对应目录,状态变 MergedDir——从此两层都有它,readdir 时合并。物化只处理普通文件/符号链接/特殊文件(materialize.rs:106-109 对目录直接返回 EISDIR)。
  • 祖先必须先在目标层就位。 你要在 a/b/c.txtc.txt,那 ab 必须先在 A 层被 mkdir 出来,否则没地方放复印件。ensure_ancestors(materialize.rs:260)从叶子往上收集祖先,再自根向叶依次提升。
  • 先在 staging、再 rename 安装。 复印过程中的半成品放在隐藏目录 .dualfs_staging(mod.rs:52),copy 完再 rename 到位——保证别的观察者永远看不到"写了一半"的文件。
  • 并发安全靠每节点一把锁。 GuestNode.copy_up_lock(types.rs:81)串行化同一个节点的物化;进锁后再查一次状态,避免两个线程各复印一份。

数据搬运用了一个巧妙的中转:StagingWriter/StagingReader(materialize.rs:584-660)借 init_file 当临时缓冲,用 pread/pwrite 在源 fd 和目标 fd 间倒腾,不经过大块内存分配。

3.4 删除的 COW:whiteout(白障)与 opaque(不透明)

只读底层的文件删不掉(B 是只读),怎么让客机觉得"删除成功"?答案是在上层记一个墓碑

// crates/filesystem/lib/backends/dualfs/remove_ops.rs:72
// 如果孩子在另一层还可见,就打一个 whiteout 遮住它
if child_node.state.read().unwrap().backend_inode(other).is_some()
&& !is_opaque_against(&fs.state, parent, other)
{
fs.state.whiteouts.write().unwrap()
.insert((parent, name_bytes.to_vec(), other));
}
  • whiteout = (父inode, 名字, 被遮的层) 三元组(types.rs:184-185)。lookup 时若命中 whiteout,就当"不存在"跳过那一层(lookup.rs:413is_whited_out 检查)。
  • opaque = 整个目录对某一层"不透明"(types.rs:187-188),用于"上层新建的目录应完全遮住下层同名目录"的场景。

这套 whiteout/opaque 是 overlay 类文件系统的经典手法,dualfs 把它做进了内存状态表,而不是像 OverlayFS 那样在磁盘上落特殊文件。

3.5 lookup:合并两层、分配客机 inode

do_lookup(lookup.rs:35)是所有路径解析的入口,流水线是:处理保留名(init.krun)→ 校验名字 → 查 dentry 缓存 → 跑 hook → policy.plan() → 按 plan 分派。

合并 lookup(execute_merge_lookup,lookup.rs:397)按优先级先试一层:命中就 register,遇 ENOENT 再试另一层,途中还要尊重 whiteout/opaque。register(lookup.rs:573)负责给新发现的子后端 inode 分配一个稳定的客机 inode,并登记进四张表:nodesdentriesalias_index、以及该层的 inode_map(硬链接去重)。

硬链接去重是这里的细节:同一个子后端 inode 若已有客机 inode(inode_map 命中),就复用,不再分配新号,并立刻把这次 lookup 多拿的 pin forget 掉(lookup.rs:606-615)。

3.6 hooks:不做文件活,只影响决策

DualDispatchHook(hooks.rs:23)是一串观察/干预点,穿在 lookup/open 等流水线上:

阶段能干什么
before_resolve / after_resolve解析前后,可 Deny 或短路
before_plan给策略塞 Hint(如"这次偏好某层")
after_plan / before_dispatch看到计划/最后一次拦截,可短路成 SyntheticResponse
after_dispatch / after_commit / after_response纯观察(fire-and-forget)

决策类 hook 通过 HookDecision(hooks.rs:75)返回 Continue/Deny/AddHint/ShortCircuitrun_decision_hooks(hooks.rs:215)把 AddHint 就地收进 HintBag,其余往上抛。这让"给某子树钉住某一层""对某些路径直接返回合成结果"这类策略,不必改核心代码。

create_ops / remove_ops / xattr_ops 都复用这套流水线:create 落 A 并登记 dentry;xattr 的 get/list 走"直接分派"(xattr_ops.rs:29resolve_active_backend_inode 从节点状态直接算出该问哪层),而 setxattr 对 B 层文件会触发物化(策略里 Setxattr => Materialize)。


4. passthroughfs:直穿主机目录

PassthroughFs 是运行时实际用得最多的后端(rootfs 跳板、/.msb 运行时通道、用户 --mount 都是它)。它把主机一个目录直接映射给客机,核心难点全在"主机进程不特权"上。

4.1 结构与配置

// crates/filesystem/lib/backends/passthroughfs/unix/mod.rs:164
pub struct PassthroughFs {
cfg: PassthroughConfig,
root_fd: File, // 打开的根目录 fd,一切 openat 以它为锚
inodes: RwLock<MultikeyBTreeMap<...>>,// 双键:FUSE inode ↔ 主机 (ino,dev,mnt_id)
...
quota: Option<DirQuota>, // 可选写配额
}

PassthroughConfig(mod.rs:112-158)是这层的策略旋钮:

字段作用
root_dir要直穿的主机目录
stat_virtualizationuid/gid/mode 怎么虚拟化(见 4.2)
host_permissions客机改权限要不要真的写主机 inode(见 4.3)
readonly主机侧防御:拒绝一切写操作(只读挂载的纵深防御)
inject_init是否在根暴露合成的 init.krun
bind_identity_map属主身份映射(见 5.2)
quota_bytes可写子树的字节预算(见 5.3)

readonly 的注释点出了纵深防御思路(mod.rs:128-133):客机挂载本身用 MS_RDONLY,但特权客机进程可能尝试重挂,所以主机后端必须自己也拒绝写——两道锁。

打开根目录用 O_DIRECTORY|O_CLOEXEC,之后所有路径解析都以 root_fd 为锚做 openat(Linux 上还探测 openat2+RESOLVE_BENEATH,防止符号链接逃出子树,mod.rs:192-194),这是防止路径穿越到子树之外的基础。

4.2 stat 虚拟化:20 字节 xattr 演一份元数据

主机进程是普通用户,不能 chown、不能造设备节点、不能给符号链接设 xattr。解决办法:把客机想要的 uid/gid/mode/rdev 打包成一个 20 字节二进制 xattr user.msb.override_stat,贴在主机文件上;客机每次 stat,后端读真实 stat 后再用这个 override 覆盖上去。

// crates/filesystem/lib/backends/shared/stat_override.rs:56
#[repr(C, packed)]
pub(crate) struct OverrideStat {
version: u8, _pad: [u8;3],
uid: u32, gid: u32, mode: u32, rdev: u32, // 共 20 字节
}

patched_stat(stat_override.rs:130)是应用点:xattr 存在就覆盖 uid/gid/mode(含 S_IFMT 文件类型位),块/字符设备再覆盖 rdev。三种策略(mod.rs:66-88 StatVirtualization):

策略语义失败模式
Strict挂载时就探测 xattr 支持,不支持直接挂载失败读到损坏 override → EIO
Relaxed有 override 就用,主机不支持 xattr 也容忍损坏值仍 EIO
Off完全不读 override,客机看主机真实元数据需要 xattr 虚拟化的操作(mknod 特殊类型、chown)明确报错

读写单次 memcpy,无文本解析;版本字节不认识就硬失败 EIO(stat_override.rs:250)。Linux 符号链接是个例外——真符号链接存不了 user.* xattr,所以对真链接跳过 override 读(stat_override.rs:142-146)。

4.3 host_permissions:客机 chmod 要不要落主机

独立于 stat 虚拟化的另一个旋钮:客机改权限位,是只记在 override 里,还是真的 fchmod 主机 inode?

// crates/filesystem/lib/backends/passthroughfs/unix/mod.rs:96
pub enum HostPermissions {
Private, // 客机权限变更只进 override;主机 inode 保守权限(文件 owner-rw、目录 owner-rwx)
Mirror, // 把普通 0o777 位镜像到主机 inode
}

Mirror 的实现(host_mode.rs:49 fchmod_mirror)只镜像 0o777 位,并且永远叠一个 owner floor(文件至少 0o600、目录至少 0o700,host_mode.rs:26-29),保证主机进程始终能访问自己的文件;setuid/setgid/文件类型位一律剥掉,只有普通文件和目录够格(mirror_eligible_type,host_mode.rs:41)。

还有个安全细节:Off 模式下没有 override 层兜底,truncate/write 时的 setuid/setgid 清除必须直接打主机 inode(host_strip_priv_bits,host_mode.rs:90),否则客机可以写一个 setuid 主机二进制而不清特权位——这是 HANDLE_KILLPRIV_V2 语义的底线。


5. 运行期如何选后端并接进 VM

前面讲的是后端"能力";这一节讲运行时(crates/runtime/lib/vm.rs)怎么把它们接到虚拟机上。接线的统一手法是:构造一个后端 → 用 VM builder 的 .fs() 把它注册成一个 virtio-fs 设备,带一个 tag(客机按 tag 挂载)。

// crates/runtime/lib/vm.rs:1057 (根文件系统跳板)
let backend = bind_rootfs_backend(rootfs_path)?;
builder = builder.fs(move |fs| fs.tag("/dev/root").custom(Box::new(backend)));

5.1 三类挂载点

挂载tag后端构造特点
根文件系统(直穿)/dev/rootbind_rootfs_backend(vm.rs:1329)直穿一个主机 rootfs 目录
根文件系统(块设备叠加)/dev/root空目录跳板 PassthroughFs(vm.rs:1064/1119)virtio-fs 只当跳板,真数据在 VMDK+ext4 块设备(见 03)
运行时控制通道msb_runtimePassthroughFs,inject_init:false,带配额(vm.rs:1150)host↔guest 控制面:主机写脚本/TLS 证书,读 heartbeat
用户卷 --mount自定义PassthroughFs,解析用户选项(vm.rs:1163)应用 stat_virtualization / host_permissions / readonly / quota

运行时通道用 virtio-fs 而不是块设备,正是因为它是双向控制面:主机端要能写进脚本、读出心跳(vm.rs:1143-1149 注释),块设备做不到这种"主机也在同时读写"的共享。它的 tag 是 msb_runtime(protocol/lib/lib.rs:82),配额固定 16 MiB(RUNTIME_FS_QUOTA_BYTES,protocol/lib/lib.rs:92),不可由用户配置——因为合法用途只有 ~1 KiB 心跳,其余全是滥用余量。

5.2 身份映射(bind identity map):共享目录里"谁是主人"

用户 --mount 一个主机目录进沙箱时,里面的文件属主是主机 uid。直接给客机看主机 uid 会泄露信息、也不合客机的用户视角。BindIdentityMap(stat_override.rs:74)在没有 per-file override 时兜底映射属主:

// crates/filesystem/lib/backends/shared/stat_override.rs:111
pub fn apply(&self, st: &mut stat64) {
if st.st_uid == self.host_owner_uid {
st.st_uid = self.guest_uid; st.st_gid = self.guest_gid; // 我创建的文件 → 沙箱用户
} else {
st.st_uid = self.overflow_uid; st.st_gid = self.overflow_gid; // 别人的文件 → nobody(65534)
}
}

它是个 Arc<OnceLock<BindIdentityMap>> 句柄(stat_override.rs:92),因为客机用户要等 VM 起来后才解析出来,所以先把空句柄发给所有需要它的 passthrough 挂载,晚点再一次性填入。bind_identity_map_for_mount(vm.rs:1379)负责按 stat_virtualization 决定要不要给某个挂载分配句柄(Off 模式不给)。

5.3 配额:别让共享通道写爆主机盘

DirQuota(quota.rs:66)给一个 passthrough 子树设字节预算。它的账法很讲究(quota.rs:8-41 大段注释):

  • 只算 delta,不算绝对大小。 基线(baseline)是"客机第一次写之前子树已有的字节",永不计入客机额度。所以在一个已有 5 GiB 的目录上设 1 GiB 配额,意思是"客机还能加 1 GiB",不是"立刻满"。
  • 基线惰性求值。 不在挂载时走目录(那会拖慢 VM 启动),而在第一次写意图操作时才快照一次(ensure_baseline,quota.rs:115),读-only 工作负载永远不触发。
  • 乐观计数、越界才重算。 charge(quota.rs:130)在热路径上只做一次原子加;只有当乐观值撞到上限,才回退去走一遍真实子树大小(subtree_size,quota.rs:189)校正——因为删除/覆盖不做增量跟踪,乐观值会漂移,但只在真正要拒绝的那一刻才需要精确。
  • 按逻辑大小(st_size)算,不按已分配块——对"防写爆"型配额是保守的(稀疏文件按表观大小计,堵住客机日后填充的空间)。

quota_statvfs(quota.rs:221)还把配额伪装成文件系统大小回给客机 df,让 df 显示的是这个挂载的真实约束,而不是主机盘那个大得没意义的数字。


6. 安全要点小结

这一层的安全属性,几乎都来自"主机进程不特权 + 一切虚拟化"这条主线:

威胁防御代码锚点
主机进程被诱导冒充 root 改户口不 chown 主机 inode,改虚拟到 20 字节 xattrstat_override.rs:130 patched_stat
共享目录泄露主机真实属主身份映射把主机 uid 折成 guest/overflowstat_override.rs:111 apply
客机把只读镜像写坏COW:写前物化到可写层,底层永不变read_backend_b_write_backend_a.rs:44
客机重挂只读卷绕过只读主机后端自己再拒写(纵深)mod.rs:128-133 readonly
客机写爆主机磁盘子树字节配额,越界 ENOSPCquota.rs:130 charge
符号链接逃出子树openat2+RESOLVE_BENEATH,以 root_fd 为锚mod.rs:192 has_openat2
写 setuid 主机二进制不清特权位Off 模式直接剥主机 inode 特权位host_mode.rs:90 host_strip_priv_bits
客机改坏注入的 init 二进制init.krun(inode=2)拒写/拒删read_backend_b_write_backend_a.rs:57;remove_ops.rs:31

一句话:客机看到的每一个"我是 root、我改了权限、我删了文件、我改了只读镜像",在主机侧要么是一次 xattr 覆盖、要么是一条内存里的 whiteout、要么是一份复印件——主机真实文件与主机内核的信任边界从未被触碰。


7. 巧妙之处(可带走的技术)

  • 后端即策略,策略即数据。 dualfs 把"读走哪层/写落哪层"抽成 DualDispatchPlan 枚举 + DualDispatchPolicy trait(policy.rs:18-140),换一种叠加语义只需换一个策略对象,核心分派代码零改动。四个内置策略(policies/*)就是四种 overlay 语义。

  • 物化经 staging + rename 安装,观察者永不见半成品。 复印在隐藏目录里做,rename 原子上位(materialize.rs:389,466);数据搬运借 init_file 当中转 buffer 用 pread/pwrite(materialize.rs:584-660),不吃大内存。

  • 20 字节二进制 xattr 而非文本。 OverrideStat#[repr(C, packed)],读写单次 memcpy,版本字节做前向兼容,损坏即 EIO(stat_override.rs:56,250)——把"不特权进程模拟特权元数据"做成了 O(1) 的贴纸。

  • 配额的乐观计数 + 边界重算。 热路径一次原子加,只在撞上限时才走一遍真实目录校正漂移(quota.rs:130-152);churning 写(如每秒重写心跳文件)因此不会把预算单调耗干。

  • 基线惰性快照。 不把目录遍历放在 VM 启动路径,而放在"第一次写意图"那一刻(quota.rs:115),纯读负载零开销。

  • whiteout/opaque 全在内存。 删除只读底层文件,用内存状态表记墓碑(remove_ops.rs:72),不在磁盘落特殊文件,比 OverlayFS 更轻。


8. 边界与局限(诚实)

  • dualfs 目前只在 Unix 编译(backends/mod.rs:6-11);Windows 只有 passthrough。
  • 在本 commit,DualFs 是一套自洽的 COW 组合库,但运行时 rootfs 的 COW 走的是块设备叠加(VMDK+ext4),不是 dualfs——vm.rs 里 rootfs 用 passthrough 当跳板 + 块设备实现 COW(见 03)。dualfs 是 filesystem crate 对外导出的能力(lib.rs:33-34),运行时接线未把它设为默认 rootfs 后端。
  • 配额按逻辑 st_size 计,不反映实际块占用;稀疏文件会被高估(设计上刻意保守)。
  • 物化整份复制文件,不做块级共享——一个大文件即使只改一个字节,也会被整份复印到可写层(materialize_regular_file,materialize.rs:361)。
  • stat 虚拟化依赖主机 user.* xattr;Strict 在不支持 xattr 的主机文件系统上直接挂载失败(mod.rs:272-280)。
  • 本章不覆盖:只读镜像的字节布局(→03,构建期);协议里的 FsRequest 远程文件通道(→02)。

9. 代码地图(导航索引)

主题文件路径关键符号
三后端入口crates/filesystem/lib/backends/mod.rsdualfs / memfs / passthroughfs
DualFs 编排者结构与 FUSE 分派crates/filesystem/lib/backends/dualfs/mod.rsDualFsinitSTAGING_DIR_NAME
客机 inode 背书状态crates/filesystem/lib/backends/dualfs/types.rsNodeStateDualStateGuestNodebackend_inode
分派策略与计划枚举crates/filesystem/lib/backends/dualfs/policy.rsDualDispatchPolicyDualDispatchPlanOpKind
默认 COW 策略crates/filesystem/lib/backends/dualfs/policies/read_backend_b_write_backend_a.rsReadBackendBWriteBackendA
其余内置策略crates/filesystem/lib/backends/dualfs/policies/{backend_a_only,backend_a_fallback_to_backend_b,merge_reads}.rsBackendAOnlyBackendAFallbackToBackendBReadMergeReadsBackendAPrecedence
物化(写时复制核心)crates/filesystem/lib/backends/dualfs/materialize.rsdo_materializepromote_directory_to_mergedensure_ancestorsmaterialize_regular_file
lookup / inode 分配 / forgetcrates/filesystem/lib/backends/dualfs/lookup.rsdo_lookupexecute_merge_lookupregisterforget_one
删除与 whiteoutcrates/filesystem/lib/backends/dualfs/remove_ops.rsdo_unlinkwhiteouts
生命周期 hookcrates/filesystem/lib/backends/dualfs/hooks.rsDualDispatchHookHookDecisionrun_decision_hooks
直穿后端结构与配置crates/filesystem/lib/backends/passthroughfs/unix/mod.rsPassthroughFsPassthroughConfigStatVirtualizationHostPermissions
host 权限镜像 / 特权位剥离crates/filesystem/lib/backends/passthroughfs/unix/host_mode.rsfchmod_mirrorhost_strip_priv_bitsmirror_eligible_type
stat 虚拟化 / 身份映射crates/filesystem/lib/backends/shared/stat_override.rsOverrideStatpatched_statBindIdentityMapprobe_xattr_support
子树写配额crates/filesystem/lib/backends/passthroughfs/quota.rsDirQuotachargesubtree_sizequota_statvfs
运行时接线到 VMcrates/runtime/lib/vm.rsbind_rootfs_backendbind_identity_map_for_mountPassthroughConfig 构造
内嵌 agentd 二进制crates/filesystem/lib/agentd.rsAGENTD_BYTES
纯内存后端crates/filesystem/lib/backends/memfs/mod.rsMemFs(inode/file_ops/dir_ops)

相邻章节:index · 01-lifecycle · 02-protocol-relay · 03-image-rootfs · 05-network-security