virtiofs ENFILE 问题排查手册
六月 13, 2026 更新: 九月 18, 2026 [虚拟化与容器] #调试 #qemu #libvirt #Rustvirtiofs 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):
src/passthrough/guest_fd_limit.rs:72// Since this error is likely returned to the guest (and not logged), prefer an // error with a reasonable errno number over a useful error message. io::Error::from_raw_os_error(libc::ENFILE)src/main.rs:51/791-793— 池子上限计算src/limits.rs:50-64—--rlimit-nofile默认取min(1_000_000, nr_open);若继承的软限已 ≥ 1M 则沿用继承值
旁证(为什么排除宿主 file-max):
- 宿主
dmesg/journalctl中没有任何VFS: file-max limit reached警告 (环形缓冲覆盖了整个故障窗口,可信) - 6/13 实测宿主
file-nr = 1,015,480≈ 999,390(池子)+ 系统其他 fd,严丝合缝 - 故障期间宿主
file-max = INT64_MAX——按内核源码(fs/file_table.c的alloc_empty_file),该值下比较nr_files >= max_files永远为假,不可能返回 ENFILE - 症状"持续不恢复"符合句柄滞留模型:恢复只能靠 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 挂载
find/grep -rl大范围扫描会快速耗尽池子(每个 touched inode 都占一个槽)- 大扫描尽量在宿主侧直接执行,或限制范围(
-prune掉target/debug、node_modules等)
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 fd | 999,410(池满→ENFILE) | 22(几乎不涨) |
宿主 file-nr | 1,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?几个正确性/可移植性代价:
- 句柄不 pin 住 inode(最大的坑):O_PATH fd 持有 inode 引用,文件被 unlink 后 fd 仍有效、inode 延迟回收;file handle 只是一段「inode 号+代次+文件系统 id」字节、不持引用,文件删除后
open_by_handle_at可能返回ESTALE,且 inode 号+代次理论上可复用。所以prefer对删不掉/无句柄的文件仍要回退 O_PATH fd。 open_by_handle_at需要CAP_DAC_READ_SEARCH:按 inode 直接打开、绕过路径权限检查,是特权操作;virtiofsd 以 root 跑才有此能力,若未来降权/沙箱即失效。- 文件系统支持不普遍:不少文件系统不支持 file handle(
name_to_handle_at返回EOPNOTSUPP),如部分 FUSE/网络文件系统、overlayfs 某些场景,默认开启会直接坏掉。 - 两套路径都要维护:
prefer是「能用就用、不能则回退」,O_PATH fd 逻辑一点不能删,复杂度不减反增。 - 迁移/快照序列化更难:句柄是 opaque 数字、跨主机(不同文件系统实例)无效,需额外重建路径(上文日志里的
preserialization::proc_paths告警即此类)。
一句话:O_PATH fd = 简单、永远正确、费 fd 槽;file handle = 省 fd、但有删除/复用/可移植性边角。所以 never 是保守默认,prefer 留给确定支持的场景显式开启——本机(共享目录固定在 ext4、virtiofsd 以 root 跑)恰好覆盖了这些边角。
触发源更正
实测复现的触发是 du -sh */ 这类对挂载树的递归遍历。此前怀疑的两者其实都不是主要触发源:
ncdu -x /home/peter/:-x不跨文件系统,根本没进 virtiofs 挂载;localsearch-3(GNOME tracker 文件索引器,tracker-miners 更名):默认index-single-directories=$HOME只索引单层不递归,实测仅 88 个文件。
2026-09-18 复发:修复是「每台宿主机 + 每个域」各一份
同一现象在 peter.xiao.com 的 VM(ubuntu.code)上复发,排查结论: 2026-08-13 的修复只落在 192.168.7.99(debian13 域所在宿主)上,本机从未打过补丁。 virtiofs 修复不会跨机器/跨域生效——wrapper 与 AppArmor 是宿主级,
<binary>与<openfiles>是域级。
现场与判定
- VM 内
git status报warning: 不能访问 '~/.my_gitconfig': 系统中打开的文件过多,随后fatal: 文件 /home/peter/.gitconfig 中错误的配置行 9- 该行号是 git 的误导性报错:include 文件打不开时,git 会把外层
.gitconfig里path =所在行报成 bad config line(用「目录当 include 目标」本地复现确认)。文件本身没坏,第 9 行正是path = ~/.my_gitconfig。
- 该行号是 git 的误导性报错:include 文件打不开时,git 会把外层
- 判定同本文首节:宿主 virtiofsd worker fd = 999,411(默认池 999,390,池满);宿主
file-nr = 1,026,010;dmesg无VFS: file-max limit reached(排除宿主 file-max 墙)。 - 三项修复全都不在:wrapper 文件不存在;
/etc/apparmor.d/local/usr.sbin.libvirtd为空(mtime 2025-10);域 XML 是默认<binary path='/usr/libexec/virtiofsd'/>。
补打修复(同 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 fd | 999,411(池满) | 22 |
--rlimit-nofile | 默认(池 999,390) | 4,194,304 |
宿主 file-nr | 1,026,010 | ~26,000 |
VM 内全树 find | 报 ENFILE | 299,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 源码核实):
- 内核 fd table 扩容(
fs/file.c)的硬上限是sysctl_nr_open(即fs.nr_open), 超了返回 EMFILE(24),整个fs/file.c中根本不存在files_stat.max_files的引用—— 扩容与file-max零关系 file-max唯一出现在fs/file_table.c的alloc_empty_file():nr_files >= max_files才返回 ENFILE(23)。INT64_MAX下该比较永远为假, 恰恰不可能产生 ENFILE- 当时"修复"(把 file-max 设为 2097152)与根因无关;真正让系统恢复的是 扫描结束 / 缓存逐出 / virtiofsd 重启等并发因素
- 复发时宿主 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
→ 修复按「宿主 + 域」各一份,多机多域环境逐台逐域核对