virtiofs ENFILE 问题排查手册

六月 13, 2026 更新: 九月 18, 2026 [虚拟化与容器] #调试 #qemu #libvirt #Rust

virtiofs ENFILE 问题排查手册

2026-08-07 重要更正

本文 2026-06-13 首发时把根因错误归为「fs.file-max = INT64_MAX 触发内核 fd table 整数溢出」。 2026-08-07 故障复发后,对照 virtiofsd 1.13.2 源码逐行核实,该结论是错的: ENFILE 来自 virtiofsd 内部的 guest fd 信号量耗尽(src/passthrough/guest_fd_limit.rs), 与宿主内核 file-max 无关。 原文的错误分析没有删除,压缩保留在文末「曾经的错误结论(勿再犯)」一节,防止误信或重复踩坑。

2026-09-18 补充: 修复(wrapper + <openfiles>)是每台宿主机、每个 virtiofs 域各一份, 在一台机器上做过不代表其他机器生效;本机 ubuntu.code 就因此复发过一次(见「2026-09-18 复发」节)。

问题现象

VM 内对 virtiofs 挂载点执行文件操作时报错:

ls: 无法打开目录 '.': 系统中打开的文件过多
# 对应系统错误: ENFILE (Too many open files in system)

特点:整棵挂载树所有读写(cat / ls / 新建文件 / python os.listdir)全部报错, 本地磁盘正常;持续存在、不会自动恢复(除非触发句柄释放)。


排查步骤

1. 系统级文件句柄状态

cat /proc/sys/fs/file-nr
# 输出格式: <已分配数量> <空闲数量> <最大限制>
# 注意: 现代内核(≥2.6.27)第 2 列恒为 0,不代表文件表耗尽!

cat /proc/sys/fs/file-max
cat /proc/sys/fs/nr_open

2. 定位 virtiofsd 进程

pgrep -a virtiofsd

ps -o pid,ppid,user,etime,rss,args -p $(pgrep -d',' virtiofsd)
# rss: 内存占用  etime: 运行时长
# 注意: virtiofsd RSS 很大(几十 GB)是 vhost-user 映射了 guest RAM,属正常现象

3. 检查 virtiofsd fd 数量(核心指标)

sudo ls /proc/<PID>/fd | wc -l
# 对比 guest fd 池上限: rlimit_nofile - 610(默认 = 1,000,000 - 610 = 999,390)
# 接近上限 ≈ 池子即将耗尽,就是 ENFILE 的前兆

4. 查看 fd 类型分布

sudo ls -la /proc/<PID>/fd/ | awk '{
  target=$NF
  if (target ~ /^socket:/)       type="socket"
  else if (target ~ /^pipe:/)    type="pipe"
  else if (target ~ /^anon_inode:/) type="anon_inode"
  else if (target ~ /\(deleted\)$/) type="deleted_file"
  else if (target ~ /^\//)       type="regular_file"
  else if (target ~ /^\/dev\//)  type="dev"
  else                           type="other"
  count[type]++
} END { for(t in count) print count[t], t }' | sort -rn

5. 查看 fd 指向哪些目录

sudo ls -la /proc/<PID>/fd/ | awk '{print $NF}' | \
  sed 's|/home/peter/project||' | \
  awk -F'/' '{print $1"/"$2"/"$3"/"$4}' | \
  sort | uniq -c | sort -rn | head -30
# virtiofsd 的 fd 目标路径是相对共享目录的 guest 视角路径(如 /agent-code/... 对应宿主 /home/peter/project/agent-code/...)

6. 采样查看 fd 具体内容

# 前 40 个
sudo ls -la /proc/<PID>/fd/ | head -40

# 中间段
sudo ls -la /proc/<PID>/fd/ | sed -n '5000,5020p'

# 末尾
sudo ls -la /proc/<PID>/fd/ | tail -20

7. 检查 virtiofsd 资源限制

for pid in <PID_LIST>; do
  echo "=== PID $pid ==="
  cat /proc/$pid/limits | grep -E "open files|processes"
  cat /proc/$pid/status | grep -E "VmRSS|VmSize|Threads"
done

8. 检查宿主内核是否真的撞过 file-max

# 环形缓冲覆盖故障窗口的前提下,出现该警告 = 宿主 file-max 真被撞到(另一种墙)
sudo dmesg | grep -i "file-max"

9. 全系统 fd 统计

total=0
for p in /proc/[0-9]*/fd/; do
  total=$((total + $(ls "$p" 2>/dev/null | wc -l)))
done
echo "用户可见: $total"
echo "系统分配: $(awk '{print $1}' /proc/sys/fs/file-nr)"
echo "差值(root): $(( $(awk '{print $1}' /proc/sys/fs/file-nr) - total ))"

根因分析(2026-08-07 更正后结论)

真正根因:virtiofsd 的 guest fd 池耗尽

virtiofsd 1.13(Rust 版)为了确保自己内部总有 fd 可用,给 guest 分配 fd 时 走一个信号量(GuestFdSemaphore),上限是:

guest_fd_limit = RLIMIT_NOFILE - internal_fd_reserve
               = 1,000,000 - (509 + 100 + 1)   // MAX_MEM_SLOTS + 100 + thread_pool_size
               = 999,390

触发链:

guest 内 find / grep -rl 递归扫描数十万文件
  → 每个 lookup/open/readdir 的 inode,virtiofsd 通过 GuestFdSemaphore::allocate()
    分配一个 O_PATH fd(src/passthrough/mod.rs:880/961/1281/1782)
  → 这些句柄在 guest dcache 驱逐 inode(FUSE_FORGET)之前不释放
  → 扫描几十万文件后池子归零
  → 池空后任何 virtiofs 操作(哪怕 cat 已缓存文件)都拿不到新 fd
  → allocate() 返回 ENFILE(guest_fd_limit.rs:72)
  → guest 所有 virtiofs 系统调用报 ENFILE,整棵挂载树不可用

关键源码(virtiofsd 1.13.2):

旁证(为什么排除宿主 file-max):

  1. 宿主 dmesg / journalctl 中没有任何 VFS: file-max limit reached 警告 (环形缓冲覆盖了整个故障窗口,可信)
  2. 6/13 实测宿主 file-nr = 1,015,480 ≈ 999,390(池子)+ 系统其他 fd,严丝合缝
  3. 故障期间宿主 file-max = INT64_MAX——按内核源码(fs/file_table.c 的 alloc_empty_file),该值下比较 nr_files >= max_files 永远为假,不可能返回 ENFILE
  4. 症状"持续不恢复"符合句柄滞留模型:恢复只能靠 guest 缓存逐出(FUSE_FORGET) 或重启 virtiofsd

判断标准(更正版)

症状结论
virtiofsd fd 数接近 rlimit(默认 999,390 池)guest fd 池即将/已经耗尽 ← 核心指标
宿主 dmesg 有 VFS: file-max limit reached宿主 file-max 真被撞到(另一种、更罕见的墙)
virtiofsd fd 99% 为 O_PATH 常规文件guest 在遍历文件系统
fd 集中在 target/debug、node_modules、build/cache构建工具 / IDE / 搜索扫描
file-max = INT64_MAX无碍(曾误判为根因,见文末)
file-nr 第 2 列 = 0无意义(现代内核恒为 0)
virtiofsd RSS 数十 GB正常(guest RAM 映射),非泄漏

修复方案

1. 扩大 guest fd 池(治本)

virtiofsd 由 libvirtd 派生,继承其 soft rlimit。把 libvirtd 的系统服务限额调大即可 (virtiofsd 启动时若继承的软限 ≥ 1M 会直接沿用,见 limits.rs:60-63):

sudo systemctl edit libvirtd
# [Service]
# LimitNOFILE=4194304
sudo systemctl restart libvirtd   # 会断开所有 VM 的 virtiofs,需重启域

池子变成 ≈ 4,194,304 - 610。注意与宿主 file-max 配平: 若 file-max 设为真实值,必须 file-max ≥ 池子上限 + 余量(如 file-max=8M / 池子=4M), 否则宿主内核会变成更矮的墙(届时 dmesg 会真的出现 VFS 警告)。

宿主内存成本(只有池子实际用满时才发生,设上限本身零成本):

资源4M 全占用量
内核 slab(struct file,本机实测 192B/个)≈ 768 MB
virtiofsd 进程 fd 表(8B/项)≈ 32 MB

2. 根本降耗:改用 file handle 代替 O_PATH fd(推荐)

# virtiofsd 1.13+ 支持 --inode-file-handles=prefer
# 用 name_to_handle_at / open_by_handle_at 引用 inode,句柄不再消耗 fd
/usr/libexec/virtiofsd --inode-file-handles=prefer --shared-dir /home/peter/project ...
# 要求宿主文件系统支持 file handle(ext4 支持;virtiofsd 以 root 运行,具备所需能力)
# 注意: 当前 libvirt 的 <binary> XML 元素可能不暴露该参数,需验证或走 hook

3. VM 内加速句柄释放(治标)

句柄在 guest dcache 驱逐 inode(FUSE_FORGET)时才释放,而 dcache 驱逐只由内存压力 驱动、没有时间老化——guest 内存充足时(本机实测 26GB 内存、空闲 12GB),扫描留下的 近百万 dentry 会永久驻留,FUSE_FORGET 一个都不发,故障"持续不恢复"是符合设计的必然。

所以真正有效的主动手段是定时 drop_caches;vfs_cache_pressure 只是回收发生时的 权重系数,没有内存压力时它什么都不做,仅作可选加速:

# 故障中立即恢复(主动杠杆,几秒内恢复)
sudo sh -c 'echo 3 > /proc/sys/vm/drop_caches'

# 长期: 定时执行 drop_caches,把故障窗口压到 ≤ 定时周期
cat > /usr/local/bin/manage_virtiofs_cache.sh << 'EOF'
#!/bin/bash
CACHED_MB=$(free -m | awk '/^Mem:/ {print $6}')
if [ $CACHED_MB -gt 4096 ]; then
    sync
    echo 3 > /proc/sys/vm/drop_caches
fi
EOF
chmod +x /usr/local/bin/manage_virtiofs_cache.sh
# crontab: */30 * * * * /usr/local/bin/manage_virtiofs_cache.sh

# 可选加速(非必须): 让碰巧发生的自然回收优先清 dentry 而非 page cache
echo 1000 > /proc/sys/vm/vfs_cache_pressure
echo 1000 > /proc/sys/vm/dirty_writeback_centisecs

# 持久化
cat >> /etc/sysctl.conf << EOF
vm.vfs_cache_pressure=1000
vm.dirty_writeback_centisecs=1000
EOF

注意:vfs_cache_pressure 不是定时器,没有内存压力时它不会触发任何回收, 单独设置它无法解决句柄滞留;真正起作用的是定时 drop_caches。

4. 避免大范围递归扫描 virtiofs 挂载

5. 应急恢复

# guest 内立即 drop_caches(最快,几秒内恢复)
sudo sh -c 'echo 3 > /proc/sys/vm/drop_caches'

# 或重启 virtiofsd / 重启 VM(挂载会短暂断开)
# 注意: kill -HUP 对 Rust 版 virtiofsd 不一定有效,可靠做法是重启域

2026-08-13 实施与验证记录

前面的「修复方案」只是建议;本节记录 2026-08-13 实际落地方式、踩到的坑与压测结果。

实际方案(A 扩池 + B file handle 双保险)

A. 用 <openfiles max> 扩池——libvirt 11.3 原生支持,比上文改 libvirtd LimitNOFILE 更精准(只影响本域):

<!-- debian13.xml 的 <filesystem> 段 -->
<binary path='/usr/local/libexec/virtiofsd-inode-handles' xattr='on'>
  <openfiles max='4194304'/>
</binary>

<openfiles max='N'/> 被 libvirt 映射为 --rlimit-nofile=N(libvirt_driver_qemu.so 里有 --rlimit-nofile=%llu)。池上限从 999,390 提到 ≈ 4,193,694,作兜底。

B. --inode-file-handles=prefer 经 wrapper 传入——libvirt 的 <binary> 没有 inode-file-handles 元素(schema 查无),只能包一层:

# /usr/local/libexec/virtiofsd-inode-handles(root:root 0755)
#!/bin/bash
exec /usr/libexec/virtiofsd --inode-file-handles=prefer "$@"

坑:libvirtd 的 AppArmor profile 拒绝执行 wrapper

第一次 virsh start 直接失败:

error: 内部错误:子进程(/usr/local/libexec/virtiofsd-inode-handles --print-capabilities)意外 退出状态 126: ... 无法执行二进制文件 ...: 权限不够

dmesg 定位到 apparmor="DENIED" operation="exec" profile="libvirtd" name="/usr/local/libexec/virtiofsd-inode-handles"。 libvirtd 的 profile 默认只白名单 /usr/{lib,lib64,lib/qemu,libexec}/virtiofsd PUx,新路径被拒。

解法写在 site 级 override(随 libvirtd 升级不丢):

echo '/usr/local/libexec/virtiofsd-inode-handles PUx,' >> /etc/apparmor.d/local/usr.sbin.libvirtd
apparmor_parser -r /etc/apparmor.d/usr.sbin.libvirtd

验证结果(全树 find 压测对比)

指标修复前修复后
virtiofsd worker fd999,410(池满→ENFILE)22(几乎不涨)
宿主 file-nr1,023,072~24,200(持平)
guest find 全树报 ENFILE正常(agent-code 152,380 文件)

为什么 file handle 能消除 fd 占用

默认 never 模式,virtiofsd 用 O_PATH fd 去「记住」guest 触碰过的每个 inode:O_PATH fd 虽不读内容,但照样占 fd 表一个槽位、计入 rlimit_nofile(默认 1M)。扫 N 个文件就占 N 个 fd,池子被吃光。

prefer 模式改用内核 file handle 接口:name_to_handle_at 给 inode 生成「文件句柄」(一段内存字节,含 inode 号+代次+文件系统 id),需要时 open_by_handle_at 转回临时 fd。句柄只占内存、不占 fd 槽——「记住一百万 inode」从消耗 fd 变成消耗内存,fd 池不再是瓶颈。

为什么默认不开启(file handle 的缺点)

既然省 fd,virtiofsd 为何默认 never 而不是 prefer?几个正确性/可移植性代价:

  1. 句柄不 pin 住 inode(最大的坑):O_PATH fd 持有 inode 引用,文件被 unlink 后 fd 仍有效、inode 延迟回收;file handle 只是一段「inode 号+代次+文件系统 id」字节、不持引用,文件删除后 open_by_handle_at 可能返回 ESTALE,且 inode 号+代次理论上可复用。所以 prefer 对删不掉/无句柄的文件仍要回退 O_PATH fd。
  2. open_by_handle_at 需要 CAP_DAC_READ_SEARCH:按 inode 直接打开、绕过路径权限检查,是特权操作;virtiofsd 以 root 跑才有此能力,若未来降权/沙箱即失效。
  3. 文件系统支持不普遍:不少文件系统不支持 file handle(name_to_handle_at 返回 EOPNOTSUPP),如部分 FUSE/网络文件系统、overlayfs 某些场景,默认开启会直接坏掉。
  4. 两套路径都要维护:prefer 是「能用就用、不能则回退」,O_PATH fd 逻辑一点不能删,复杂度不减反增。
  5. 迁移/快照序列化更难:句柄是 opaque 数字、跨主机(不同文件系统实例)无效,需额外重建路径(上文日志里的 preserialization::proc_paths 告警即此类)。

一句话:O_PATH fd = 简单、永远正确、费 fd 槽;file handle = 省 fd、但有删除/复用/可移植性边角。所以 never 是保守默认,prefer 留给确定支持的场景显式开启——本机(共享目录固定在 ext4、virtiofsd 以 root 跑)恰好覆盖了这些边角。

触发源更正

实测复现的触发是 du -sh */ 这类对挂载树的递归遍历。此前怀疑的两者其实都不是主要触发源:


2026-09-18 复发:修复是「每台宿主机 + 每个域」各一份

同一现象在 peter.xiao.com 的 VM(ubuntu.code)上复发,排查结论: 2026-08-13 的修复只落在 192.168.7.99(debian13 域所在宿主)上,本机从未打过补丁。 virtiofs 修复不会跨机器/跨域生效——wrapper 与 AppArmor 是宿主级,<binary> 与 <openfiles> 是域级。

现场与判定

补打修复(同 2026-08-13 方案)

# 1) wrapper(宿主级,root:root 0755)
cat > /usr/local/libexec/virtiofsd-inode-handles << 'EOF'
#!/bin/bash
exec /usr/libexec/virtiofsd --inode-file-handles=prefer "$@"
EOF
chmod 0755 /usr/local/libexec/virtiofsd-inode-handles

# 2) AppArmor local override(宿主级)
echo '/usr/local/libexec/virtiofsd-inode-handles PUx,' >> /etc/apparmor.d/local/usr.sbin.libvirtd
apparmor_parser -r /etc/apparmor.d/usr.sbin.libvirtd

# 3) 域 XML(域级;改完 dumpxml --inactive 检查再 define,或直接用 virsh edit)
#    <binary path='/usr/local/libexec/virtiofsd-inode-handles' xattr='on'>
#      <openfiles max='4194304'/>
#    </binary>
# 4) 重启域才生效(virtiofsd 与 qemu 的连接在域启动时建立)

验证

指标修复前修复后
virtiofsd worker fd999,411(池满)22
--rlimit-nofile默认(池 999,390)4,194,304
宿主 file-nr1,026,010~26,000
VM 内全树 find报 ENFILE299,381 文件 0 错误,扫描后 file-nr 几乎不涨

新坑:域 XML 里的 spice socket 路径被硬编码,重启即挂

重启域时启动失败:

warning: Spice: ...do_spice_init: Failed to open SPICE sockets
failed to initialize spice server
内部错误:连接监控的过程中进程退出: bind: No such file or directory

根因是图形设备里存了运行时生成的 socket 路径:

<listen type='socket' socket='/var/lib/libvirt/qemu/domain-4-ubuntu.code/spice.sock'/>

该路径按「当前域 ID」生成,domain-<id>-<name>/ 目录每次启动重建;XML 写死上次的 domain-4 后,重启拿到新 ID,旧 ID 的目录不存在 → qemu bind() 报 ENOENT。

修正:删掉 socket 属性即可(schema 里它对 type='socket' 是 optional,libvirt 会按当前域 ID 自动生成):

<listen type='socket'/>

教训:改运行中域的 XML 用 virsh dumpxml --inactive(或 virsh edit),不要用 dumpxml 的 live 输出 define——live XML 会携带运行时填充的路径等信息,define 后就被写死。本次修复时即用了 live dump(原 XML 此前也被改写过,无法完全归因),踩中此坑。


曾经的错误结论(保留备查,勿再犯)

以下为 2026-06-13 初版的错误分析。保留它不是为了参考,而是为了记住为什么错。

错误结论: fs.file-max 被设为 9223372036854775807(INT64_MAX)导致内核 fd table 扩容公式 n = last * 2 + 1 整数溢出 → kvmalloc 分配巨大数组失败 → 返回 ENFILE。

为什么错(2026-08-07 对照内核 6.12 源码核实):

  1. 内核 fd table 扩容(fs/file.c)的硬上限是 sysctl_nr_open(即 fs.nr_open), 超了返回 EMFILE(24),整个 fs/file.c 中根本不存在 files_stat.max_files 的引用—— 扩容与 file-max 零关系
  2. file-max 唯一出现在 fs/file_table.c 的 alloc_empty_file(): nr_files >= max_files 才返回 ENFILE(23)。INT64_MAX 下该比较永远为假, 恰恰不可能产生 ENFILE
  3. 当时"修复"(把 file-max 设为 2097152)与根因无关;真正让系统恢复的是 扫描结束 / 缓存逐出 / virtiofsd 重启等并发因素
  4. 复发时宿主 dmesg 无 VFS 警告,最终在 virtiofsd 1.13.2 源码 guest_fd_limit.rs:72 找到真正的 ENFILE 出处

教训: ① 症状(ENFILE)不一定来自最显眼的配置(file-max),先找 ENFILE 的 唯一内核出口,再顺着代码追;② 排查"大值参数"时要先确认内核里它到底被谁引用; ③ 实测 dmesg 环形缓冲覆盖窗口内的"无警告"是有效证据。


核心排查逻辑

guest 内 virtiofs 操作报 ENFILE
  → 宿主 dmesg 有无 "VFS: file-max limit reached"?(区分两种墙)
  → ps 找 virtiofsd → sudo ls /proc/<pid>/fd | wc -l
  → 对比 guest fd 池上限(rlimit - 610,默认 999,390)
  → 池满 → 看 fd 指向(O_PATH、guest 相对路径)→ 定位 guest 内扫描进程
  → guest 内 drop_caches(主动)/ 定时 cron 防复发
  → 根治: 扩池子(libvirtd LimitNOFILE)/ --inode-file-handles=prefer
  → 修复过又复发?逐项核对当前宿主机的修复三件套(wrapper / AppArmor override /
    域 XML 的 <binary>+<openfiles>),并确认 pgrep -a virtiofsd 带 --inode-file-handles=prefer
  → 修复按「宿主 + 域」各一份,多机多域环境逐台逐域核对