
gVisor inotify 实现解析vfs 层数据模型、锁序设计与文件系统适配【免费下载链接】gvisorApplication Kernel for Containers项目地址: https://gitcode.com/GitHub_Trending/gv/gvisor本文围绕 gVisor 仓库内的设计文档 pkg/sentry/vfs/g3doc/inotify.md 展开深入讲解 gVisor Sentry 中 inotify 机制的实现细节inotify 实例、watch 集合与事件对象三大核心数据结构、防止死锁的三把锁及其加锁顺序、不同文件系统tmpfs、kernfs/anonfs、gofer如何承载 watch 目标以及 IN_EXCL_UNLINK 与 IN_ONESHOT 两个特殊 watch 选项的语义与实现代价。读完后你将能够对照 pkg/sentry/vfs/inotify.go 与 pkg/sentry/syscalls/linux/sys_inotify.go 的源码理解容器内应用如文件监视器、构建工具在 gVisor 沙箱中使用 inotify API 时的完整调用链路。Inotify 是 Linux 中用于监视文件系统事件的机制参见 inotify(7)一个 inotify 实例可以监视文件和目录的修改、创建/删除等操作。inotify API 由若干系统调用组成——inotify_init/inotify_init1创建实例inotify_add_watch/inotify_rm_watch添加/移除 watch。事件可以从 Sentry 的多个层次产生包括系统调用层、vfs 层、进程 fd 表以及各个文件系统实现内部。核心数据模型vfs.Inotify、Watches、Watch 与 EventgVisor 的 inotify 数据结构全部实现在 vfs 包中定义于 pkg/sentry/vfs/inotify.go。整体可以概括为三层关系inotify 实例谁接收事件→ watch对某个目标的一次监视→ watch 集合某个文件上所有 watch 的归集。vfs.Inotify实例即一个文件描述符实现inotify 实例由vfs.Inotify对象表示它实现了vfs.FileDescriptionImpl接口源码中通过var _ FileDescriptionImpl (*Inotify)(nil)静态断言。与 Linux 一致inotify fd 由一个伪文件系统anonfs承载在 pkg/sentry/vfs/inotify.go 的NewInotifyFD中可以看到创建一个 inotify fd 时会调用vfsObj.NewAnonVirtualDentry(fmt.Sprintf([inotifyfd:%d], id))把一个名为[inotifyfd:唯一ID]的虚拟 dentry 绑定到该文件描述符上。Inotify结构体pkg/sentry/vfs/inotify.go的关键字段值得注意id uint64实例唯一标识。注释说明不复用 inotify fd 的原因——fd 可以被 dup同一个实例可能对应多个 fd而Watches集合中的 watch 正是以这个 id 为键索引的。events eventList/evMu待投递事件队列及专用锁后文“锁序”一节详述。watches map[int32]*Watchwatch descriptorwd到 watch 对象的映射wd 从 1 开始编号nextWatchMinusOne字段与 Linux 行为一致。queue waiter.Queue用于在实例变为可读时通知等待者epoll/poll/select。实例对外暴露的行为也与 Linux 语义对齐PRead/PWrite返回ESPIPEWrite返回EBADFRead从事件队列反序列化struct inotify_event到用户缓冲区pkg/sentry/vfs/inotify.go队列为空时返回ErrWouldBlockIoctl支持FIONREAD返回待读事件的字节总数pkg/sentry/vfs/inotify.goEpollable()返回 true使 inotify fd 可以加入 epoll。vfs.Watches一个文件上的所有 watch一个文件即 watch 目标上的全部 watch 存放在一个vfs.Watches集合中定义见 pkg/sentry/vfs/inotify.go。每个 watch 属于不同的 inotify 实例同一实例对同一目标至多一个 watch集合内部用map[uint64]*Watch按所有者 inotify 实例的 id 索引。Watches.Add中还有一条硬性检查若同一 owner 再次被加入会直接 panic保证“一实例一目标一 watch”的不变量。硬链接指向同一文件的多个 dentry以及指向同一文件的多个文件描述符共享同一个vfs.Watchesgofer 文件系统是例外见后文。目标上发生活动时其Watches集合就会在成员 watch 所属的 inotify 实例上生成通知。Watches的关键方法Lookup(id)/Add(watch)/Remove(id)增删查 watch均要求调用方已持有对应 inotify 实例的锁Precondition注释明确说明Notify(...)向集合中所有匹配的 watch 分发一个事件返回是否需要清理 one-shot watchpkg/sentry/vfs/inotify.goHandleDeletion()目标被销毁时清空集合、把每个 watch 从各自的 inotify 实例上摘除并为其生成IN_IGNORED事件分发前先Notify一次IN_DELETE_SELFpkg/sentry/vfs/inotify.go。vfs.Watch一次具体的监视单个 watch 由vfs.Watch表示pkg/sentry/vfs/inotify.go字段包括owner *Inotify所属实例创建后不可变wd int32实例内唯一的 watch descriptor创建后不可变target *Dentrywatch 目标的 dentry创建后不可变mask atomicbitops.Uint32监视的事件掩码原子存储以支持IN_MASK_ADD的并发更新expired atomicbitops.Int32one-shot watch 已投递过事件、等待移除的标志。watch 同时被两个地方持有所有者vfs.Inotifyi.watches[wd]和目标上的vfs.Watches集合ws.Add(w)见 pkg/sentry/vfs/inotify.go 的newWatchLocked。这种双向持有正是复杂锁序的来源。当 watch 在其目标上被通知到事件时它通过owner.queueEvent(...)把事件排入所属实例的队列等待用户 read 投递。Watch.Notifypkg/sentry/vfs/inotify.go还体现了两个 Linux 细节一是事件掩码匹配时先并入“不可屏蔽的控制位”^uint32(0) ^ linux.IN_ALL_EVENTS再与事件按位与保证 IN_ISDIR、IN_QEVENT 等控制位一定出现在投递的 mask 里二是若 mask 含IN_ONESHOT成功排队事件后置expired并返回 true触发后续清理。vfs.Event待投递事件的序列化形式vfs.Eventpkg/sentry/vfs/inotify.go是一个简单结构体封装了 Linuxstruct inotify_event的全部字段wd、mask、cookie以及可选的、按 16 字节inotifyEventBaseSize必须为 2 的幂向上取整填充的name。它由Watches生成、转发给各 watch 的所有者最终在 read(2) 系统调用期间被序列化到用户缓冲区Event.CopyTo先用一个预分配的 scratch buffer 写出 16 字节头部wd/mask/cookie/len再写出 name 段pkg/sentry/vfs/inotify.go。两个值得注意的实现细节事件合并coalescingInotify.queueEvent在入队前检查队尾事件若与新事件完全相同wd、mask、cookie、len、name 均一致见Event.equals则直接丢弃新事件不重复通知等待者——这与 Linux 的合并行为一致pkg/sentry/vfs/inotify.go。队列消费即出队Read中只要缓冲区够放当前事件就先i.events.Remove(event)再拷贝即使拷贝失败事件也已出队注释明确说明这是为了模拟 Linux“只要有足够空间就出队”的行为。此外pkg/sentry/vfs/inotify.go 提供了两个 vfs 层的辅助函数InotifyRemoveChild删除时先通知子目标IN_ATTRIBunlinkedtrue再通知父目录IN_DELETE和InotifyRename用同一 cookie 向旧/新父目录发IN_MOVED_FROM/IN_MOVED_TO再向自身发不带 cookie 的IN_MOVE_SELF。系统调用层inotify 四件套的落地系统调用入口在 pkg/sentry/syscalls/linux/sys_inotify.go四个系统调用分别对应inotify_init/inotify_init1InotifyInit1校验 flags 只能是IN_NONBLOCK | IN_CLOEXECallFlags其余位返回EINVAL然后调用vfs.NewInotifyFD创建实例并经t.NewFDFrom(0, ino, ...)分配 fdIN_CLOEXEC转换为CloseOnExec。InotifyInit只是把参数置 0 后转发。inotify_add_watchInotifyAddWatch先校验 mask 与linux.ALL_INOTIFY_BITS按位与非零否则EINVAL随后根据IN_DONT_FOLLOW选择是否跟随末端符号链接根据IN_ONLYDIR设置path.Dir true再通过getTaskPathOperationGetDentryAt解析路径得到目标 dentry最后调用ino.AddWatch(d.Dentry(), mask)返回 wdpkg/sentry/syscalls/linux/sys_inotify.go。inotify_rm_watch经fdToInotify非 inotify fd 返回EINVAL无效 fd 返回EBADF拿到实例后调用ino.RmWatch(t, wd)。Inotify.AddWatchpkg/sentry/vfs/inotify.go完整呈现了文档中“一个实例对一个目标只有一个 watch”的规则持i.mu后先在目标Watches里Lookup(i.id)若已存在则按IN_MASK_ADD语义置位则与原 mask 做或否则直接替换更新 mask 并复用原 wd不存在才newWatchLocked创建新 watch。RmWatchpkg/sentry/vfs/inotify.go从实例的watchesmap 删除后再从目标Watches中Remove若目标上剩余 watch 数为 0则在释放i.mu之后调用w.target.OnZeroWatches(ctx)保持锁序见下节最后向实例队列投递一条IN_IGNORED事件。实例销毁走Releasepkg/sentry/vfs/inotify.go持i.mu遍历所有 watch逐个从目标集合Remove记录那些剩余 watch 归零的目标 dentry解锁后统一调用它们的OnZeroWatches。注释特别说明了持锁的原因——避免与Watches.HandleDeletion并发回调产生竞态。锁序Inotify.mu - Watches.mu - Inotify.evMuinotify 实现涉及三把锁对应文档“Lock Ordering”一节锁保护对象Inotify.mu实例锁保护watchesmap 与nextWatchMinusOneInotify.evMu事件队列专用锁只保护events列表及 scratch bufferWatches.muwatch 集合锁保护目标上的 watch 映射正确的加锁顺序是Inotify.mu - Watches.mu - Inotify.evMu。为什么必须为事件队列单设一把锁源码注释pkg/sentry/vfs/inotify.go与文档给出的是同一个论证如果直接用Inotify.mu保护队列系统中会同时出现两种加锁路径——添加 watch先拿Inotify.muAddWatch再加目标集合的Watches.muws.Lookup/ws.Add生成事件先持有Watches.muWatches.Notify遍历各 watch再回调通知 watch 的所有者、需要拿Inotify.mu例如cleanupExpiredWatches中调用watch.owner.RmWatch就要拿 owner 的i.mu见 pkg/sentry/vfs/inotify.go。两条路径的加锁方向相反将构成死锁。把“通知所有者”的路径改成只拿Inotify.evMuqueueEvent锁序即变为严格单向。这也解释了Watches.Notify为什么在持有w.mu.RLock时只能调用watch.Notify内部仅触碰原子量与evMu而不能直接操作 owner 的 watch 表。另外两处“延迟到解锁之后”的调用同样服务于该锁序RmWatch/Release在放下i.mu后才调用OnZeroWatches——因为OnZeroWatches可能获取文件系统实现自己的锁如 gofer 的renameMu接口注释明确要求调用方不得持有任何 inotify 锁pkg/sentry/vfs/dentry.go。至于 inotify 三把锁在整个文件系统锁体系中的位置文档指向 vfs 包的包级注释vfs 层的Watches、Dentry等类型均可在 pkg/sentry/vfs 目录下找到读者可结合vfs.go的包注释核对整体顺序。不同文件系统实现中的 watch 目标在 Linux 中watch 驻留在 vfs 层的 inode 上因此同一文件的所有硬链接与文件描述符共享同一 watch 集合。gVisor 的 Sentry 中并没有跨文件系统类型的公共 inode 结构有些文件系统甚至没有 inode 概念所以必须把 inotify 支持“穿针引线”地接入每个具体文件系统实现。文档按三类讨论Tmpfswatch 驻留在 inode 上对有 inode 的文件系统如 tmpfs设计与 Linux 类似watch 就挂在 inode 上。从源码看tmpfs 的每个 inode 持有自己的vfs.WatchesInotifyWithParent先通知父目录再通知自身且对目录 dentry 自动补上IN_ISDIR标志OnZeroWatches是空实现因为 tmpfs dentry 的生命周期不受 watch 影响pkg/sentry/fsimpl/tmpfs/tmpfs.go。伪文件系统kernfs / anonfs 暂不实现在 Linux 中由于 inotify 实现在 vfs 层构建在 kernfs 之上的伪文件系统被动地支持 inotify。但 watch 只能跟踪显式的文件系统操作read/write、open/close、mknod 等所以对/proc/self/fd这类目标fd 的新增/移除并不会产生事件。按文档的说法gVisor 目前在 kernfs 与 anonfs 中未实现 inotify——“似乎用处不大”。从源码结构看anonfs 的InotifyWithParent是空操作、OnZeroWatches为空实现pkg/sentry/vfs/anonfs.gokernfs 同样提供了空/透传的实现pkg/sentry/fsimpl/kernfs/kernfs.go与文档描述一致。Gofer 文件系统最复杂的一例gofer 文件系统Sentry 与 Gofer 之间通过 9P 协议交互的根文件系统代码位于 pkg/sentry/fsimpl/gofer有若干特性使 inotify 支持尤为困难文档逐条给出了“问题 方案”源码可在 gofer 的 dentry 生命周期管理中找到对应实现1. 没有 inode。文件由一个持有未打开的 p9 文件可能还有一个打开的 FID的 dentry 表示Sentry 通过它与 gofer 交互。方案watch 只能挂在 dentry 上就 inotify 而言假设每个 dentry 对应唯一 inode。这在存在硬链接时可能导致意外行为多个 dentry 本应共享 watch 集合而且由下一点可知我们无法确证两个 dentry 是否指向同一文件。2. Sentry 无法总是感知远端硬链接。没有任何机制能确认远端文件系统上的两个文件是否链接到同一 inode——QID 与 inode 并不总是一一对应。远端硬链接必然打破“dentry 与 inode 1:1”的假设。方案这是 gofer 文件系统的一般性问题不只是 inotify 的问题只能接受它。3. dentry 会被缓存之后可能被驱逐evict。dentry 生命周期与文件生命周期不对应gofer fs 并非全内存dentry 不存在不代表文件不存在dentry 引用计数归零时会被放入缓存以备同一文件路径再次被访问但缓存的 dentry 可能被 LRU 驱逐下次访问该路径会创建新 dentry——已存在的 watch 将丢失。方案dentry 引用归零时若它仍有 watch 则不缓存它从而避免被驱逐/销毁if d.inode.watches.Size() 0时仅从缓存 LRU 移除而不驱逐见 pkg/sentry/fsimpl/gofer/gofer.go。注意若 dentry 已被删除或失效d.vfsd.IsDead()仍应连同其 watch 一起销毁pkg/sentry/fsimpl/gofer/gofer.go。另外当一个 dentry 的最后一个 watch 被移除时若其引用计数也为 0则把它放入缓存这样不再需要时它最终可以被逐出内存——这正是OnZeroWatches的用途gofer 的实现就是一句d.checkCachingLocked(ctx, false)pkg/sentry/fsimpl/gofer/gofer.go。4. dentry 可能失效invalidated。远端文件中途发生变化时dentry 下次被使用时会被失效并替换为新 dentry此时旧 dentry 上的 watch 该如何处理并不明确。方案失效发生时静默销毁 watch——我们无法确切知道发生了什么、何时发生。Linux 上 NFS 文件的 inotify 很可能行为类似因为 inotify 实现在 vfs 层、不感知远端文件系统的复杂性。文档还专门论证了为什么“不”在失效时发一个事件例如 delete 事件理由有三其一无法区分远端文件是被移动、被删除还是其他原因失效而这些情形应产生不同事件且只有真正删除才应销毁 watch其二检测底层文件变化的机制是查看 gofer 是否给出新 QID这会产生误报例如服务器关闭后重新打开同一文件也可能得到新 QID其三事件时间与文件实际修改时间可能完全不同——dentry 不会在底层文件变化时立刻收到通知只有在沙箱内再次访问该文件、触发失效检查时才发现问题此时再发通知会让用户对随后的 read/write 等操作产生错误预期。更根本的一点是Linux 上 inotify 即使在本地文件系统中也可能丢失事件为不拖垮文件系统性能而做的取舍在 NFS 上更是因类似原因而丢失因此“静默”比“发出错误通知”更可取。5. 远端文件系统可能有外部使用者。我们只能跟踪沙箱内对文件执行的操作这在外置使用者出现时是不完整的动作集合。方案在不使用InteropModeExclusive而使用 inotify 时可以返回错误或只给警告。虽然行为不完整但 VFS1 在文件系统共享时允许这样做Linux 对远端文件系统也如此inotify 位于 vfs 层。gofer 的事件通知路径同样值得看一眼InotifyWithParent在读锁保护下先通知父目录、再通知自身并把d.isDeleted()作为unlinked参数传入Watches.Notify这正是IN_EXCL_UNLINK过滤所需的“目标是否已被 unlink”信息pkg/sentry/fsimpl/gofer/gofer.go。Dentry 接口让任何文件系统都能参与 inotify对于必须发生在 vfs 层之上的事件gVisor 在DentryImpl接口pkg/sentry/vfs/dentry.go中提供了三个方法使任意FilesystemImpl都能与目标交互InotifyWithParent()同时在该 dentry 自身的 watch 集合与其父目录的 watch 集合上生成事件父目录先被通知随后是该 dentry注释还说明目录 dentry 会自动追加IN_ISDIR标志且事件是否真正送达取决于各 watch 的 mask。tmpfs、overlay、erofs、kernfs、gofer、anonfs 都实现或空实现了它分别见 pkg/sentry/fsimpl/tmpfs/tmpfs.go、pkg/sentry/fsimpl/overlay/overlay.go、pkg/sentry/fsimpl/erofs/erofs.go、pkg/sentry/vfs/anonfs.go。Watches()返回该 dentry 所代表目标的 watch 集合用于访问和修改目标上的 watch。接口语义上硬链接到同一底层文件的 dentry 共享同一 watch 集合调用方无需持有 dentry 引用。OnZeroWatches()当一个 dentry 上的最后一个 watch 被移除时执行清理。gofer 必须用它来允许“已无 watch 的被监视 dentry 重新进入缓存”大多数实现可以直接什么都不做如 tmpfs、kernfs、anonfs 的空实现。再次强调OnZeroWatches()必须在所有 inotify 锁释放之后调用因为它可能获取FilesystemImpl特有的锁否则会破坏全局锁序。IN_EXCL_UNLINK为已 unlink 路径屏蔽事件inotify_add_watch(2)的 mask 中可以设置若干选项位其中IN_EXCL_UNLINK需要每个文件系统提供额外支持带有该位的 watch当其目标对应一个已被 unlink 的路径时不再为它生成事件。例如对foo/bar打开 fd 之后foo/bar被 unlink那么此后对该 fd 的 read/write 等操作都会被带IN_EXCL_UNLINK的foo或foo/bar上的 watch 忽略。这要求每个DentryImpl自行记录自己是否已被 unlink以便判断事件是否应投递给IN_EXCL_UNLINK的 watch。源码中Watch.ExcludeUnlinked()就是检查 mask 里的linux.IN_EXCL_UNLINK位pkg/sentry/vfs/inotify.go分发点在Watches.Notify当事件来自已 unlink 的子目标unlinked true且是路径事件et PathEvent时带该位的 watch 被跳过pkg/sentry/vfs/inotify.go。vfs 层的InotifyRemoveChild/InotifyRename辅助函数正是负责传入unlinked标志的地方pkg/sentry/vfs/inotify.go。EventType的PathEvent/InodeEvent区分对应 Linux 的FSNOTIFY_EVENT_PATH/FSNOTIFY_EVENT_INODE也在此发挥作用inode 事件不受IN_EXCL_UNLINK过滤。IN_ONESHOT一次性 watch 的过期与清理one-shot watch 在生成一个事件后即过期。当事件发生时所有成功产生事件的 one-shot watch 都会被移除。文档特别提醒受锁序约束one-shot watch 的管理可能相当昂贵。源码印证了这一点pkg/sentry/vfs/inotify.goWatches.Notify持有w.mu的读锁遍历 watchWatch.Notify对 one-shot watch 成功入队后置expired1并报告hasExpired放下读锁后调用cleanupExpiredWatches——它先用读锁把已过期 watch 收集到局部切片因为锁序禁止在持有Watches.mu时再拿各 owner 的Inotify.mu再逐个调用watch.owner.RmWatch(ctx, watch.wd)RmWatch会再次遍历、从目标集合Remove并投递IN_IGNORED。也就是说一个 one-shot watch 的回收要经历“原子标记 - 集合层收集 - 逐实例加锁移除”的多阶段流程这正是文档所说Watches.Notify()背后代价的来源。Watch.Notify开头的expired检查则处理了边界情形在 one-shot watch 尚未真正被移除之前第二个事件又到达了目标此时直接返回 false不再重复入队。小结这篇设计文档勾勒出的 gVisor inotify 实现其工程核心有三点其一用Inotify/Watches/Watch/Event四个结构在 vfs 层复现了 Linux 的 inotify 对象模型并通过DentryImpl的三个方法把 watch 目标下沉到各文件系统实现其二用“实例锁 / 集合锁 / 事件队列锁”三把锁和严格的单向锁序Inotify.mu - Watches.mu - Inotify.evMu换取无死锁的并发通知路径代价是一系列“先放锁再回调”的延迟调用OnZeroWatches、cleanupExpiredWatches、HandleDeletion其三在 gofer 这类无 inode、带 dentry 缓存与远端失效语义的文件系统上用“watch 钉住 dentry 不缓存、失效时静默丢弃 watch”等务实策略在行为不完整与引入错误事件之间选择了前者与 Linux 在 NFS 上 inotify 本身有损的既有事实保持一致。若要在沙箱内依赖 inotify 的应用构建缓存、文件监视等出现“事件缺失”类问题gofer 一节的五条限制清单是最直接的排查依据。【免费下载链接】gvisorApplication Kernel for Containers项目地址: https://gitcode.com/GitHub_Trending/gv/gvisor创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考