宿主文件系统
规定 virtio-fs 宿主文件系统设备的数据路径、FUSE 会话、导出形式、请求路径、节点与句柄、访问策略、宿主身份以及快照与恢复行为。
译自microsoft/nvx · doc/design/3.6-host-filesystem.md。技术细节以英文原文为准。
如图 3.4 所示,宿主文件系统设备通过 virtio-fs 与客户机共享实时的宿主文件。客户机内核的 FUSE 客户端把挂载标签上的系统调用转换为 FUSE 请求,这些请求经由客户机 RAM 中的 virtqueue 到达宿主文件系统服务器,即 VMM 内部的一个 FUSE 服务器。服务器在导出的宿主目录上(或在聚合的各个子项上)执行每个请求,并通过同一个队列返回应答。与块设备不同——块设备的后端只能看到块——服务器看到的是名称、属性和打开的文件:宿主拥有命名空间和数据,而客户机拥有它的 VFS 对象、权限检查和锁。客户机的写入直接到达宿主文件,宿主的变化在客户机的下一次请求时对其可见。
图 3.4. 宿主文件系统数据路径。
图中结构(自上而下排列,↔ 表示双向交互):
- 客户机应用(Guest application)
- ↔ 客户机 VFS 与 FUSE 客户端(Guest VFS and FUSE client),即标签为
microvm的 virtio-fs 挂载- ↔ 客户机 RAM 中的高优先级队列与请求队列(High-priority and request virtqueues in guest RAM),含 MMIO 通知与共享状态中断
- ↔ VMM virtio-fs 设备(VMM virtio-fs device),每个队列一个工作线程
- ↔ FUSE 会话(FUSE session),负责 INIT 协商与请求分派
- ↔ 宿主文件系统服务器(Host filesystem server),负责节点、句柄、访问策略和宿主身份
- ↔ 受限的宿主卷(Confined host volume),在导出根之下做不跟随链接的解析
- ↔ 导出的宿主目录(Exported host directory),或聚合的各个子项
- ↔ 受限的宿主卷(Confined host volume),在导出根之下做不跟随链接的解析
- ↔ 宿主文件系统服务器(Host filesystem server),负责节点、句柄、访问策略和宿主身份
- ↔ FUSE 会话(FUSE session),负责 INIT 协商与请求分派
- ↔ VMM virtio-fs 设备(VMM virtio-fs device),每个队列一个工作线程
- ↔ 客户机 RAM 中的高优先级队列与请求队列(High-priority and request virtqueues in guest RAM),含 MMIO 通知与共享状态中断
- ↔ 客户机 VFS 与 FUSE 客户端(Guest VFS and FUSE client),即标签为
3.6.1 设备与 FUSE 会话
该设备是一个 virtio-fs 设备,固定标签为 microvm,带一个高优先级队列和一个请求队列。它的配置空间只保存标签和请求队列数量。它提供带间接描述符和事件索引通知抑制的分离环,但绝不提供 packed 环或 DAX 共享内存窗口,因此文件数据始终经由队列传输。机器配置文件拒绝替代标签、额外队列、共享内存窗口、PCI 传输,以及启动后子项发生变化的聚合。
客户机用 INIT 打开 FUSE 会话,在此之前其他所有请求都失败。设备使用 FUSE 协议版本 7,并接受客户机次版本 27 及更高,但恢复要求已保存的会话协商出次版本 31 或更高。设备从一个固定集合中授予客户机所提供的能力:异步读取、并行目录操作、自动数据失效、kill 权限处理、异步直接 I/O、原子 O_TRUNC、大写入、扩展 INIT、自适应 READDIRPLUS,以及直接 I/O 文件的共享映射。它协商出 1 MiB 的最大写入和纳秒时间戳,绝不授予回写缓存、锁转发或 DAX 映射。
应答中的每个目录项和属性都带有为零的缓存生存期,客户机打开或创建的每个文件都使用直接 I/O。因此客户机在每次路径遍历时都重新查找每一个路径分量,在需要时获取属性,并把每一次读写都发送给宿主而不是其页缓存:宿主的变化在客户机的下一次请求时可见,而客户机的写入在写入完成时就已经到达宿主文件。共享映射是例外,因为它们的页缓存在客户机中,并通过普通写请求回写。零生存期还使客户机在没有任何使用者时立即丢弃名称及其 inode 并发送 FORGET,因此它只为正在使用的对象以及通往它们的目录保留节点,例如打开的文件、工作目录以及挂载点和绑定点。由于没有协商锁转发,客户机内核把 POSIX 和 BSD 锁保留在本地,宿主进程既看不到也不遵守它们。客户机内核在发送请求之前会用宿主报告的属性检查 Unix 权限,宿主随后再针对执行该操作的宿主身份检查该操作。
3.6.2 导出形式
如表 3.14 所示,该插槽采用三种形式之一。
表 3.14. 宿主文件系统导出形式。
| 形式 | 客户机视角 |
|---|---|
| 休眠 | 没有宿主服务器,也没有生效策略,但设备仍可被发现;会话可以打开,但文件系统请求以 ENOSYS 失败 |
| 目录导出 | 一个宿主目录作为文件系统根,带一个访问模式和一套路径策略 |
| 聚合 | 一个只读的合成根,模式为 0500 且归 root 所有,它列出具名子项;每个子项都是独立卷,拥有自己的访问模式和路径策略 |
休眠插槽使快照能够在恢复时获得导出。目录导出始终导出一个目录,因为客户机挂载的是设备的根。机器配置文件通过启动令牌向客户机通告已附加的导出:其客户机位置、标签、其模式(当导出或任一子项可写时为读写),以及对聚合而言的聚合标记。在冷启动时,客户机以该模式把标签挂载到该位置;对聚合,客户机代理随后按实时宿主共享所描述的把每个子项绑定到其目标。
聚合的根按声明顺序列出其子项,并以 EROFS 拒绝任何更改。子项是一个宿主目录或单个普通文件。由声明的种类(而不是宿主对象)决定是哪一种,VMM 拒绝另一种种类的宿主路径。文件子项只提供它自己的文件,就以子项本身的形式:它的卷是该文件的父目录,收窄到该文件的名称,而客户机永远无法到达该名称,因此客户机无法列出或更改该文件旁边的任何东西,对它下方的查找以 ENOTDIR 失败,而在名称被非普通文件占据期间,每个请求都以 EACCES 失败。文件子项不接受任何路径策略,因为其模式覆盖整个文件。
子项名称唯一,使用 [A-Za-z0-9._-],长度至多 64 字节。一个聚合至多容纳 256 个子项,策略路径至多 128 KiB,而且各子项的宿主根不得彼此相等或互相包含,因此没有子项能在另一套策略下到达别人的文件;文件子项可以与目录子项并列。各子项共享一个 FUSE 超级块,因此服务器把每个子项的键混入每个宿主 inode 编号中,使子项之间的 inode 编号冲突不太可能发生。子项之间的重命名以 EXDEV 失败。子项之间的硬链接在其目标为只读时以 EROFS 失败,否则以 EXDEV 失败。
3.6.3 请求路径
客户机在挂载点上的一次操作变成一个或多个 FUSE 请求:
- 系统调用到达客户机 VFS,其 FUSE 客户端发出所需的请求:每个路径分量一个
LOOKUP,属性一个GETATTR,描述符一个OPEN或CREATE,数据一个READ或WRITE。 - 客户端把每个请求放入客户机 RAM 中的一条描述符链。设备可读缓冲区保存 FUSE 头部,其中包含操作码、请求 ID、节点 ID 以及调用方的 UID、GID 和 PID,之后是请求的参数、名称和任何写入负载;设备可写缓冲区接收应答。
FORGET通知使用高优先级队列且没有应答缓冲区;其他所有请求使用请求队列。 - 客户端发布该描述符链,并且除非设备抑制了通知,否则通知队列,从而唤醒该队列的工作线程。
- 工作线程把请求从客户机内存中复制出来并解析其头部。在调用方所有权模式下,它承担调用方的宿主身份。随后 FUSE 会话把请求分派给服务器。
- 服务器把请求的节点 ID 解析为节点,或把其句柄 ID 解析为打开的宿主对象,并应用访问模式、访问策略和表界限。
- 受限的宿主卷在不跟随符号链接的情况下,在导出根之下执行该操作。Linux 宿主相对于一个被固定的父目录发出相应的系统调用;Windows 宿主通过 Windows 文件 API 模拟相同的 Linux 语义,但不存储 Linux 的所有者或模式元数据。
- 服务器在带有请求 ID 的输出头部之后编码结果或负的错误码,工作线程把它写入应答缓冲区。随后工作线程发布一个已用环条目,并且除非客户机抑制了通知,否则通过该插槽的共享状态字和边沿中断向客户机发出信号。
- 客户端按 ID 把应答与其请求匹配,并完成系统调用。
每个队列有一个工作线程,它一次只处理一个请求并执行到完成,然后才取下一个。因此请求队列按环顺序串行化客户机请求,尽管客户机可以一次提交许多个;而 FORGET 通知则在高优先级队列上独立进行。设备不实现 FUSE 中断,因此已提交的请求总会执行到完成。
READ 会填充一个至多 1 MiB 的宿主缓冲区并把字节复制到应答缓冲区;WRITE 则先把其负载从客户机内存复制出来,再写入宿主文件。对打开句柄的请求直接使用其宿主描述符,不再解析路径。如果一个请求的设备可读部分超过 1 MiB 写入上限加 4 KiB 的头部余量,或者其头部无法解析,该请求在没有任何应答的情况下完成;格式错误的参数则以 EIO 或某个具体错误失败。服务器未实现的操作(例如 FLUSH、FALLOCATE、LSEEK 或 COPY_FILE_RANGE)以 ENOSYS 失败;Linux 随后停止发送它,并退回到通用处理或把该操作报告为不支持。客户机的 syncfs 会被确认,但不会刷新宿主。
3.6.4 节点、句柄与列表
服务器用节点 ID 向客户机标识宿主对象。节点 1 是根:导出的目录或聚合的合成根。一个节点记录它的卷;客户机到达它所用的相对于卷的名称(别名);它的宿主 inode 编号和客户机可见的 inode 编号;以及一个查找计数。它不持有宿主描述符。对某个节点的每个请求首先再次从导出根出发遍历每个已记录的名称且不跟随链接,如果某个名称现在穿过符号链接则以 ELOOP 失败,如果策略把它隐藏则以 EACCES 失败,然后在不跟随最后一段的情况下对最后一段执行操作。
对同一宿主对象的重复查找返回同一个节点并添加新名称,因此硬链接共享一个节点。服务器按宿主 inode 编号匹配对象,对于 inode 编号不稳定的卷则按名称匹配。每个返回目录项的应答都会增加一次查找,FORGET 减去客户机报告的计数,计数归零时节点消失。重命名和删除会在每个节点中重写或移除受影响路径及其之下的已记录名称,交换式重命名则把它们互换。
OPEN、OPENDIR 和 CREATE 返回句柄。一个句柄拥有一个宿主描述符,它在打开时不跟随最后一段;每个指明该句柄的请求都使用它,并由 RELEASE 关闭。对句柄的第一次目录读取会把宿主目录枚举一次,得到一个至多 4,096 个条目、至多 64 KiB 名称的列表,其中不含被策略隐藏的条目;后续读取按页遍历该列表;列出更大的目录则以 E2BIG 失败。每个条目的 cookie 是它在列表中的位置,因此列表可以跨快照存活。READDIRPLUS 会再次查找每个列出的名称,如果它不再到达所列的对象就失败。
图 3.5 跟踪一个文件在读写目录导出中从创建到删除的完整过程。服务器在每个请求上都针对节点的访问权限检查写入,而不只是打开时。已完成的写入对宿主进程可见,但在客户机同步该文件之前不会被强制写入稳定存储。已删除的文件仍可通过其打开的句柄访问,其节点也会保留到客户机遗忘它为止。
图 3.5. 读写目录导出中的文件生命周期。
图中的四个参与者为客户机应用(Guest application)、客户机 FUSE 客户端(Guest FUSE client)、宿主文件系统服务器(Host filesystem server)和宿主文件系统(Host filesystem)。消息序列如下(→ 表示请求,⇢ 表示应答):
| 步骤 | 发送方 | 接收方 | 消息 |
|---|---|---|---|
| 1 | 客户机应用 | 客户机 FUSE 客户端 | 以 O_CREAT 和 O_RDWR 打开 out.log |
| 2 | 客户机 FUSE 客户端 | 宿主文件系统服务器 | LOOKUP(1, out.log) |
| 3 | 宿主文件系统服务器 | 宿主文件系统 | 在根之下做不跟随链接的查找 |
| 4 | 宿主文件系统服务器 | 客户机 FUSE 客户端 | ENOENT |
| 5 | 客户机 FUSE 客户端 | 宿主文件系统服务器 | CREATE(1, out.log, flags, mode) |
| 6 | 宿主文件系统服务器 | 宿主文件系统 | 在被固定的根目录中创建并打开 |
| 7 | 宿主文件系统服务器 | 客户机 FUSE 客户端 | 节点 M、句柄 H、直接 I/O |
| 8 | 客户机应用 | 客户机 FUSE 客户端 | 在偏移 0 处写入 1 MiB |
| 9 | 客户机 FUSE 客户端 | 宿主文件系统服务器 | WRITE(H, offset 0, 1 MiB) |
| 10 | 宿主文件系统服务器 | 宿主文件系统 | 通过 H 做定位写入 |
| 11 | 宿主文件系统服务器 | 客户机 FUSE 客户端 | 已写入 1 MiB |
| 12 | 客户机应用 | 客户机 FUSE 客户端 | 在偏移 0 处读取 4 KiB |
| 13 | 客户机 FUSE 客户端 | 宿主文件系统服务器 | READ(H, offset 0, 4 KiB) |
| 14 | 宿主文件系统服务器 | 宿主文件系统 | 通过 H 做定位读取 |
| 15 | 宿主文件系统服务器 | 客户机 FUSE 客户端 | 4 KiB 的数据 |
| 16 | 客户机应用 | 客户机 FUSE 客户端 | 解除链接 out.log |
| 17 | 客户机 FUSE 客户端 | 宿主文件系统服务器 | LOOKUP(1, out.log),随后 UNLINK(1, out.log) |
| 18 | 宿主文件系统服务器 | 宿主文件系统 | 在被固定的根目录中做不跟随链接的解除链接 |
| 19 | 宿主文件系统服务器 | 客户机 FUSE 客户端 | 成功,且 H 仍可到达该文件 |
| 20 | 客户机应用 | 客户机 FUSE 客户端 | 关闭 |
| 21 | 客户机 FUSE 客户端 | 宿主文件系统服务器 | RELEASE(H) |
| 22 | 宿主文件系统服务器 | 宿主文件系统 | 关闭 H 的描述符 |
| 23 | 客户机 FUSE 客户端 | 宿主文件系统服务器 | 在高优先级队列上发送 FORGET(M, n) |
| 24 | 宿主文件系统服务器 | 宿主文件系统服务器 | 丢弃节点 M |
服务器把自身状态限定为 4,096 个节点、4,096 个句柄、每节点 256 个名称、共 16,384 个名称、1 MiB 的名称和 4,096 字节的路径。它在改变宿主之前检查这些界限,因此会超出它们的操作以 ENOSPC 或 E2BIG 失败,而不会创建设备无法跟踪或保存的宿主状态。
3.6.5 约束
宿主根由其平台对象身份固定——Linux 上是设备号和 inode 编号,Windows 上是卷 ID 和文件 ID——并在导出打开时重新验证;聚合的每个子项的根也以同样方式固定。每个操作都限制在导出之内:宿主路径解析不跟随任何符号链接或重解析点,绝不离开根,也绝不跟随最后一段。在 Windows 宿主上,客户机创建的链接是路径解析绝不跟随的重解析点,祖先中属于链接或重解析点的一律被拒绝,而保留设备名以及标记为隐藏、系统、离线或按数据访问召回的对象以 EACCES 失败。读写导出允许客户机创建带有精确目标的符号链接,且只有客户机会解析这些目标;宿主绝不跟随它们,而只读导出拒绝它们。在 Linux 宿主上,重命名会透传其标志,因此客户机可以原子地交换两个名称;Windows 宿主则在改变任何东西之前以 EINVAL 拒绝每一个重命名标志。
3.6.6 访问策略
导出或子项的只读模式在任何宿主文件系统操作运行之前,就在宿主设备中拒绝更改。路径策略随后在导出之内收窄访问:
- 被拒绝路径隐藏完整的子树。被拒绝的根对象身份同样会阻断通过硬链接、联接点和绑定挂载的别名。当被拒绝路径内部仍保留允许路径时,被拒绝路径可以指定某个导出或子项的根,这会让该根只能被遍历。
- 允许路径重新暴露被拒绝路径的子树。包含某路径的最近策略路径决定其可见性,因此被拒绝路径和允许路径可以交替出现。通往允许路径途中的隐藏目录只能被遍历:客户机可以查找它,但只能列出通往允许路径的条目,而且它的对象身份只在它自己的路径上被接受。
- 可写路径(如果存在)是读写导出中客户机唯一可以修改的部分。每一次命名空间更改都会检查其目录以及它创建、删除或替换的条目,每一次对象更改都会检查客户机为该对象使用过的每一个名称。被拒绝的写入以
EROFS失败,从可写路径到只读对象的硬链接以EXDEV失败。
第二次客户机挂载不会改变该策略,而客户机挂载标志绝不是安全边界。
3.6.7 宿主身份
默认情况下,宿主以 VMM 的用户身份执行客户机操作。在仅 Linux 宿主可用的调用方所有权模式下,每个能够访问宿主文件的请求都以其 FUSE 头部中的 UID 和 GID 运行:队列工作线程仅为自己的线程承担该身份,不带任何附加组或有效能力,并在处理其他任何事情之前恢复自己的身份。会话协商、FORGET 和句柄释放照常运行。客户机的 UID 0 和 GID 0 被压缩(squash)到导出根的所有者和组,因此导出根不得是 UID 0 或 GID 0。无法承担其身份的请求以 EPERM 失败,而不是以 VMM 身份运行。
3.6.8 快照内容与捕获
导出的文件属于外部实时状态,不是快照的一部分:既不捕获它们的内容,也不捕获任何宿主描述符、句柄或缓存。快照转而保存队列两侧的协议状态。客户机的 FUSE 客户端状态(包括其挂载、节点 ID、查找计数和句柄)位于客户机 RAM 中,随内存一起被捕获。设备保存与之匹配的状态:
- 协商出的 FUSE 会话。
- 每个节点的 ID、卷、已记录的名称(包括符号链接的名称)、查找计数、客户机可见的 inode 编号,以及由设备号、inode 编号和文件类型构成的对象身份。
- 每个句柄的 ID、节点、打开标志、对象身份,以及带 cookie 的目录列表。
- 下一个节点 ID 和句柄 ID。
- 固定的设备形态、访问模式、所有权模式、根身份和访问策略;对聚合而言,则是每个子项的名称、种类、模式、根身份和策略。
设备状态相对于导出根命名对象,绝不记录宿主路径。快照的机器契约转而记录宿主一侧:客户机目标、访问模式、标签、队列数量、直接 I/O、缓存生存期、规范宿主路径、策略路径、所有权模式;对聚合而言,则是每个子项的名称、规范宿主路径、种类、模式、策略和根身份。它的附件清单把导出(带有实时重新验证的重连策略)绑定到根的身份;对聚合而言,则绑定到按顺序排列的子项名称与根身份的摘要,因此恢复既不会丢弃、增加,也不会重排任何子项。
捕获按以下步骤进行:
- 当捕获对宿主输入进行门控时,每个工作线程完成其当前请求、发布应答,并且不再接收任何请求。没有任何工作线程接收的描述符仍留在可用环上,位于客户机内存和已保存的队列进度中,等待被恢复的设备接收。
- 在处理器于边界处停止之后,队列随之停止,保存要求不留下任何在途请求。
- 服务器通过每个句柄的描述符检查它,并再次在不跟随链接的情况下解析导出根之下的每个已记录名称,检查一个节点的所有名称都到达同一个对象。只要设备仍在跟踪某个已无名称的节点(例如客户机保持打开的已删除文件),或者某个名称已消失、被隐藏、穿过符号链接,或到达与该节点其他名称不同的对象,捕获就会失败。当所有句柄的目录列表合计超过 16,384 个条目或 4 MiB 的名称时,捕获也会失败。
- 捕获不会刷新宿主文件。客户机在边界之前通过文件请求写入的数据已经在其中,除非客户机同步过,否则它们按宿主自己的时机到达稳定存储。客户机已弄脏但尚未回写的共享映射页则留在被捕获的内存中;只有带配对暂存盘的捕获才会先写回它们,因为客户机代理会同步那里的每个文件系统。
即使在调用方所有权模式下,捕获和恢复也以 VMM 的用户身份检查和重新打开对象,因此当客户机持有某个只有其调用方才能到达或重新打开的对象时,它们会失败。
3.6.9 恢复
恢复会在客户机可以使用会话之前重建设备一侧的会话:
- 在客户机运行之前,启动器再次提供该导出。它必须复现快照中的目标、模式、策略、所有权模式、规范宿主路径和根身份;对聚合而言,还必须是顺序相同、名称相同、种类相同、模式相同的子项。只有休眠插槽才可以改为获得一个新的导出。
- 在传输改变任何状态之前,设备校验已保存的状态:已知的模式版本、固定的设备形态、设备本会协商出的 FUSE 会话(次版本 31 或更高)、在界限之内的表、低于下一个 ID 的唯一 ID,以及只出现在导出或子项为读写且节点所有名称都可写的位置上的可写句柄。
- 当被恢复的设备首次启动时,在它服务任何请求之前,服务器用已保存的节点 ID 和查找计数重建节点表。它在不跟随链接的情况下解析所提供导出之下的每个已记录名称,并要求是同一个对象(按设备号、inode 编号和文件类型判断),且具有相同的客户机可见 inode 编号。
- 服务器以其已保存的打开标志重新打开每个句柄,但会丢弃
O_CREAT、O_EXCL和O_TRUNC,因此恢复绝不会创建或截断文件。它验证每个重新打开对象的身份和种类,并恢复句柄的目录列表。 - 服务器恢复协商出的 FUSE 会话,不做新的
INIT。
随后客户机继续使用其挂载、工作目录、打开的描述符、映射和目录列表,而无需重新挂载。由于它不缓存任何目录项或属性,也不缓存共享映射之外的文件数据,它的下一次请求看到的是恢复时刻的宿主状况:恢复只检查客户机持有的那些对象,它们的内容以及其他每一个名称都可能自捕获以来已经改变。共享映射的页是例外:它们随被捕获的内存一起返回,因此在客户机内核使其失效之前,它们持有的是捕获时刻的文件内容,而每个被恢复的客户机都会把捕获时已弄脏的页写回实时宿主文件。绑定恢复的是身份而非内容:导出的副本即使内容完全相同也无法通过恢复,同样地,被宿主软件用新文件重命名覆盖的持有文件也无法通过恢复。同一快照的每次恢复都附加相同的实时宿主对象;与配对暂存盘不同,导出不会被复制。原生文件描述符和 Windows 句柄绝不会被序列化。
3.6.10 休眠插槽与已保存状态版本
休眠捕获保存明确的未附加状态,没有会话、节点、句柄、根身份或策略。它要求客户机在该插槽上没有 FUSE 会话,因此该标签必须未被挂载;卸载会结束会话。休眠快照可以不带导出恢复,也可以绑定一个新的目录导出,但不能是聚合。恢复运行的客户机随后显式挂载该标签,因为它在启动时的挂载步骤已经执行过。不具备此能力的快照无法添加导出,而活跃状态也无法恢复到休眠插槽中。
捕获记录导出所需的已保存状态版本。调用方所有权、带允许路径或可写路径的目录导出的访问策略、聚合以及文件子项各自需要一个更新的版本,因此早于该特性的发布版会拒绝该快照,而不是恢复一个更弱的导出。