搬运并适配自国外社区真实案例。原文出处见文末。
TL;DR
症状是 kubectl logs -f <pod> 或 Grafana Alloy 启动时报 failed to create fsnotify watcher: too many open files。表面看是文件描述符耗尽,实际是 inotify 实例上限(fs.inotify.max_user_instances,默认 128)被占满 ------罪魁祸首往往是 RHEL 9 / cgroupv2 环境下 containerd 为每个容器创建一个 inotify 实例来监控 OOM 事件(containerd/containerd#10468)。ulimit -n 和 fs.file-max 都很高也拦不住,因为 inotify 有自己的独立计数,且耗尽时内核返回的 -EMFILE 与 fd 耗尽一字不差。解法:先数 inotify 实例(/proc/*/fd 里 readlink 出 anon_inode:inotify),确认是实例耗尽还是 watch 耗尽,再决定调大 max_user_instances 还是清理泄漏方。
现象
lua
$ kubectl logs -f my-pod
error: failed to create fsnotify watcher: too many open files
同一集群里,Grafana Alloy 的 Pod 反复 CrashLoopBackOff,日志:
ini
level=error msg="failed to evaluate nested import" config_id=import.git.cluster_metrics
err="imported node k8s_components failed to evaluate, too many open files"
这个报错不止出现在 kubectl logs 和 Alloy。社区里同一症状还有这些触发点:
| 工具 | 报错形态 | 出处 |
|---|---|---|
kubectl logs -f |
failed to create fsnotify watcher: too many open files |
ServerFault#1137211 |
k9s |
查看日志时同样报 fsnotify 错误 | derailed/k9s#1399 |
support-bundle(Replicated) |
failed to create fsnotify watcher |
Replicated 社区 |
| Grafana Alloy | failed to evaluate nested import ... too many open files |
alloy#1217 |
| kairos / 普通 K8s 节点 | kubectl logs 报错且伴随 Longhorn 同步失败 |
kairos#2071 |
共同点:这些工具都用 Go 的 fsnotify 库监听文件变化 。fsnotify 是 Go 生态最流行的跨平台文件监听库,kubectl logs -f、k9s、Grafana Alloy、Loki 的日志采集都依赖它。它把 Linux 的 inotify 包装成统一接口,一旦底层 inotify 资源耗尽,fsnotify 把内核的 EMFILE 原样抛出来,上层就打印这个"看似 fd 问题"的报错。
环境:Kubernetes 1.27+,节点 RHEL 9.4(默认 cgroupv2),containerd 作为容器运行时。ulimit -n 显示 65535+,/proc/sys/fs/file-max 甚至被设成了 signed long 最大值(看起来"无限"),但错误照旧。
排查过程
第一回合:常规检查------为什么没发现问题
bash
$ ulimit -n
65535 # 每进程 fd 上限:很高
$ cat /proc/sys/fs/file-max
9223372036854775807 # 系统 fd 上限:等于无限
$ cat /proc/sys/fs/inotify/max_user_watches
65536 # watch 上限:也不低
分析 :三个常规指标全部正常。如果错误信息是"too many open files",直觉指向 fd 耗尽------但 fd 上限这么高,进程却报"打不开",矛盾点出现了。这说明错误信息具有欺骗性:fsnotify(Go 的 inotify 封装库)在 inotify 相关资源耗尽时,统一返回 EMFILE,上层就把"failed to create fsnotify watcher"当成 fd 问题打印。
第二回合:发现关键线索------错误随机出现,且和节点绑定
观察规律:同一个 Alloy 配置,在 A 节点重启几次能起来,在 B 节点永远 CrashLoopBackOff。把 Pod 调度到新节点(RHEL 8)就正常。同一份代码,不同节点行为不同------根因必然在节点层,不在应用层。
对比新旧节点差异:
- 新节点:RHEL 9.4,cgroupv2(RHEL 9 起默认启用,需手动 opt-in 才能回 cgroupv1)
- 旧节点:RHEL 8,cgroupv1
第三回合:深入内核统计------数 inotify 实例
bash
# 全系统数 inotify 实例(每个实例对应一个 anon_inode:inotify)
$ for foo in /proc/*/fd/*; do readlink -f $foo; done | grep inotify | wc -l
120 # 新节点(RHEL 9.4),而 max_user_instances=128!
# 对比:旧节点(RHEL 8)只有 40~50 个
$ for foo in /proc/*/fd/*; do readlink -f $foo; done | grep inotify | wc -l
45
分析 :max_user_instances 是 per-user 上限(默认 128)。新节点上 inotify 实例已经用到 120/128,剩下 8 个名额------Alloy 要创建 6 个实例(6 个 import.file),任何一个拿不到就报 "too many open files"。这就解释了为什么"重启几次能好":重启时别的进程释放了几个实例,Alloy 侥幸抢到;过一会儿又被占满,下次启动又失败。
第四回合:定位根因------containerd 在 cgroupv2 下每容器一个 inotify
谁占掉了 120 个实例?进一步看每个进程的 inotify 数量:
bash
$ for p in /proc/[0-9]*; do
n=$(ls -l $p/fd 2>/dev/null | grep -c inotify)
[ "$n" -gt 0 ] && echo "$n $(basename $p) $(cat $p/comm 2>/dev/null)"
done | sort -rn | head
62 containerd
38 kubelet
6 alloy
...
线索指向 containerd。查 containerd 仓库发现已知 issue(containerd/containerd#10468):cgroupv2 环境下,containerd 的 cgroup 管理模块用 unix.InotifyInit() 为每个容器创建一个 inotify 实例来监听 OOM 事件 (cgroups/v2 的 manager.go 用 inotify 监听 memory.events 里的 OOM kill 信号;cgroupv1 则用 eventfd,不占 inotify 名额)。所以:
| 运行时 | cgroup 版本 | 每容器消耗 |
|---|---|---|
| containerd + cgroupv1 | v1 | eventfd,不占 inotify |
| containerd + cgroupv2 | v2 | 1 个 inotify 实例/容器 |
一个 60 容器的小集群,containerd 就吃掉 60+ 个实例,加上 kubelet(每个日志 tail 也要 inotify),128 的上限瞬间见底。根因确认:不是应用泄漏,是"containerd 的 cgroupv2 设计 + 默认上限 128"的系统性冲突。
根因:inotify 三上限的内核代码级解析
fsnotify 的错误传播链:为什么"fd 很多"却报"打不开"
先厘清调用链,才能理解为什么常规检查无效:
scss
kubectl logs -f / k9s / Alloy
│ 调用 Go 库 fsnotify
▼
fsnotify.NewWatcher()
│ Linux 后端调用 inotify_init1()
▼
内核 fs/notify/inotify/inotify_user.c
│ per-user 实例计数超限
▼
返回 -EMFILE ← 错误码与 fd 耗尽完全相同
│
▼
用户态打印 "failed to create fsnotify watcher: too many open files"
关键点:inotify_init1() 返回的 fd 本身是 一个 fd------所以如果进程的 fd 表真的满了,也会是同样的报错。但两种情况下排查方向完全不同:fd 表满要看 ulimit -n 和泄漏;inotify 实例满要看 /proc/sys/fs/inotify/*。先数 inotify,再谈 fd,是这个报错的第一原则。
Linux 的 inotify 有三个独立的 per-user 上限 ,全部定义在 fs/notify/inotify/inotify_user.c:
| sysctl | 默认值 | 内核定义 | 含义 |
|---|---|---|---|
fs.inotify.max_user_instances |
128 | INOTIFY_DEFAULT_MAX_INSTANCES |
每个真实用户 ID 最多创建的 inotify 实例 数(inotify_init 次数) |
fs.inotify.max_user_watches |
8192(发行版常调大到 65536+) | INOTIFY_DEFAULT_MAX_WATCHES |
每个用户所有实例的 watch 总数 (inotify_add_watch 次数) |
fs.inotify.max_queued_events |
16384 | INOTIFY_DEFAULT_MAX_QUEUED_EVENTS |
每个实例事件队列上限,溢出时丢事件并产生 IN_Q_OVERFLOW |
inotify_init1() 创建实例时,内核检查该用户的实例计数:
c
/* fs/notify/inotify/inotify_user.c(简化表示) */
static int inotify_new_fsnotify_group(struct user_struct *user)
{
struct fsnotify_group *group;
/* 超过 per-user 实例上限 → 返回 EMFILE */
if (atomic_inc_return(&user->inotify_devs) >
inotify_max_user_instances) {
atomic_dec(&user->inotify_devs);
return -EMFILE; /* ← 这就是 "too many open files" 的来源 */
}
...
}
注意错误码是 -EMFILE(EMFILE = "Too many open files"),所以用户态看到的报错和真正的 fd 耗尽一字不差,但内核路径完全不同:这里是 inotify 实例计数超限,不是 fd 表满。
inotify_add_watch() 同理,检查的是 watch 总数:
c
/* fs/notify/inotify/inotify_user.c(简化表示) */
static int inotify_add_to_idr(struct idr *idr, ...)
{
...
if (atomic_inc_return(&user->inotify_watches) >
inotify_max_user_watches) {
atomic_dec(&user->inotify_watches);
return -ENOSPC; /* 或 -ENOMEM,取决于路径 */
}
}
这就是为什么错误可能显示为 "no space left on device"(ENOSPC)而不是 "too many open files"(EMFILE)------watch 耗尽和实例耗尽的错误码不同,排查时必须分清是哪一种。
为什么"每个 watch 约 1KB"这个经验值有用
fs.inotify.max_user_watches 默认 8192 是有道理的:内核为每个 watch 维护一个 fsnotify_mark,含路径名、掩码、引用计数等,实测约消耗 0.5~1KB 内核内存(64 位系统偏高)。Razvan 在 ServerFault 上的回答给出了按内存预算等比计算的脚本------把总内存的 1/2 按默认比例分配到三个参数:
python
# 系统默认值
default_max_user_instances = 128
default_max_queued_events = 16384
default_max_user_watches = 8192
# 预算:总内存的 2GB(举例)
available_memory_kb = 2 * 1024 * 1024
total_weight = default_max_user_watches + \
default_max_user_watches + \
default_max_user_watches
memory_per_unit = available_memory_kb / total_weight
print("fs.inotify.max_user_watches =", int(memory_per_unit * default_max_user_watches))
print("fs.inotify.max_user_instances =", int(memory_per_unit * default_max_user_instances))
print("fs.inotify.max_queued_events =", int(memory_per_unit * default_max_queued_events))
containerd 为什么在 cgroupv2 下吃 inotify
containerd 的 cgroup 管理代码(github.com/containerd/cgroups)里,v1 和 v2 的 OOM 事件监听实现完全不同:
| 版本 | 监听 OOM 的机制 | 占用 inotify |
|---|---|---|
| cgroup v1 | eventfd + cgroup.event_control |
❌ 不占 |
| cgroup v2 | unix.InotifyInit() 监听 memory.events |
✅ 每容器 1 实例 |
代码位置:cgroups/v2/manager.go 的 manager.OOM() 方法用 unix.InotifyInit() 创建实例后 inotify_add_watch 监听 memory.events 文件,事件里出现 oom_kill 就上报。这个实例在容器存活期间一直持有,容器销毁才释放。
于是公式很简单:
markdown
节点 inotify 实例消耗 ≈ 容器数(containerd cgroupv2)
+ 日志 tail 数(kubelet/工具)
+ 各种文件监听(应用自身)
RHEL 9 / Rocky 9 / 所有 systemd 默认 cgroupv2 的发行版都会踩这个坑。Ubuntu 22.04+ 同样默认 cgroupv2,所以这不是 RHEL 专属问题,而是 cgroupv2 时代的普遍问题。
watch 维度:另一种容易被误判的耗尽
实例上限是"能创建几个监听器",watch 上限是"能监听几个文件/目录",两者独立。常见 watch 耗尽场景:
bash
# IDE/编辑器打开了整个 monorepo(几万文件)
# 每个文件一个 watch → 直接打爆 8192/65536 上限
$ cat /proc/sys/fs/inotify/max_user_watches
65536
$ ls /proc/<ide-pid>/fd | wc -l # fd 正常
$ grep -c inotify /proc/<ide-pid>/fdinfo/* 2>/dev/null | awk -F: '{s+=$2} END {print "watch 总数:", s}'
区别实例耗尽与 watch 耗尽的关键:
| 特征 | 实例耗尽(EMFILE) | watch 耗尽(ENOSPC) |
|---|---|---|
| 报错 | too many open files |
no space left on device 或 ENOSPC |
| 内核计数 | user->inotify_devs |
user->inotify_watches |
| 排查命令 | 数 anon_inode:inotify 数量 |
数 inotify fd 的 watch 总数 |
| 常见原因 | cgroupv2 + 容器多 | 编辑器/监控工具监听海量文件 |
解决方案
按优先级排列:
方案 1:调大 max_user_instances(对症,立即生效)
bash
# 立即生效
sudo sysctl -w fs.inotify.max_user_instances=1024
# 持久化
echo 'fs.inotify.max_user_instances = 1024' | sudo tee /etc/sysctl.d/99-inotify.conf
sudo sysctl -p /etc/sysctl.d/99-inotify.conf
经验值:每个容器 1 个实例 + 每个日志 tail 1 个实例,集群规模 × 2 再加 50% 余量。60 容器集群用 1024 很稳。内存代价可忽略(每个实例的队列 16KB 上限)。
快速估算表(按 cgroupv2 + containerd):
| 节点容器数 | 建议 max_user_instances | 说明 |
|---|---|---|
| ≤ 30 | 256 | 默认 128 就快见底,先翻倍 |
| 30~100 | 1024 | 覆盖容器 + 日志 tail + 应用余量 |
| 100~300 | 4096 | 大节点,同时注意 watch 上限 |
| 300+ | 8192+ | 建议专项容量评估,别拍脑袋 |
方案 2:调大 max_user_watches(如果错误码是 ENOSPC)
bash
sudo sysctl -w fs.inotify.max_user_watches=524288
echo 'fs.inotify.max_user_watches = 524288' | sudo tee -a /etc/sysctl.d/99-inotify.conf
注意:watch 消耗真实内存(约 1KB/个),524288 个 ≈ 512MB 内核内存,大集群按 容器数 × 文件数 估算,别盲目开满。
方案 3:应用层避开 inotify(如果改不了节点)
Grafana Alloy 的 import.file / local.file 支持 detector 参数,改成轮询:
hcl
import.file "cluster_metrics" {
filename = "..."
detector = "poll" // 不用 fsnotify,改定时重读
poll_frequency = "60s" // 配置文件本身很少变,60s 足够
}
适合"配置文件不常变"的场景,代价是变更生效最多延迟一个 poll 周期。
方案 4:根治 containerd 侧(社区进行中)
- containerd/containerd#11652 正在讨论在 containerd service 文件里提高
max_user_instances(尚未合入) - 短期可手动给 containerd 服务加
LimitNOFILE+ 提高实例上限(方案 1 等价) - 升级 containerd 到修复版本(如有)前,优先用方案 1
不建议 :把 max_user_instances 调到 20 亿(ServerFault 高赞回答的写法 sysctl -w fs.inotify.max_user_instances=2099999999)。那是"关掉上限"的粗暴做法,等于放弃内核保护,泄漏问题会被掩盖而非解决。
如何自检你是否中招
bash
# 一行命令:统计全系统 inotify 实例数,和上限对比
$ echo "当前实例: $(for foo in /proc/*/fd/*; do readlink -f $foo; done | grep -c inotify) / 上限: $(cat /proc/sys/fs/inotify/max_user_instances)"
# 看每个进程消耗了多少(定位泄漏方)
$ for p in /proc/[0-9]*; do n=$(ls -l $p/fd 2>/dev/null | grep -c inotify); [ "$n" -gt 0 ] && echo "$n $(cat $p/comm 2>/dev/null)"; done | sort -rn | head
# 配合持续监控(cron 每 5 分钟记录一次)
$ echo "$(date +%F\ %T) $(for foo in /proc/*/fd/*; do readlink -f $foo; done | grep -c inotify)" >> /var/log/inotify_count.log
# 如果错误信息是 ENOSPC(no space left),检查 watch 数:
$ cat /proc/sys/fs/inotify/max_user_watches
注意一个细节:readlink -f /proc/<pid>/fd/N 对 inotify fd 输出 anon_inode:inotify,grep inotify 就能精确计数,不会误统计普通文件 fd(普通文件显示真实路径)。如果某些进程把 inotify fd 关闭了但 watch 还挂着(极端泄漏场景),/proc/<pid>/fdinfo/N 里的 inotify wd: 行能精确列出每个 fd 的 watch 数------这是最细粒度的自检手段。
复盘:这个案例的排查方法论
回顾整个链条,最有价值的不是"调大 sysctl",而是分层定位的过程:
- 症状分层 :
too many open files只告诉你是"某种资源被限制",必须先枚举所有可能(fd / inotify 实例 / inotify watch / semaphore / epoll),再用命令逐一排除。本次靠"重启能好 + 节点绑定"两个特征,把范围从"应用泄漏"缩小到"节点级共享资源"。 - 跨版本对比:新旧节点(RHEL 8 vs 9)行为差异是黄金线索------直接指向 cgroupv2 这个变更点。遇到"升级后开始报错",优先查该版本的系统级默认值变更,而不是怀疑自己的代码。
- 数着看 :
/proc是 Linux 排障的最终真相。for foo in /proc/*/fd/*这个命令比任何监控工具都快------它直接告诉你"现在用了几个、谁在用"。
启示
- "too many open files" 不一定指 fd。inotify 实例耗尽、watch 耗尽、semaphore 耗尽都可能返回 EMFILE/ENOSPC,错误信息只告诉你"资源被限制",不告诉你是哪个资源------必须数着看。
- 升级操作系统 = 换一批新默认值。RHEL 9 启用 cgroupv2,看似无关的内核变更会改变 containerd 的资源消耗模式,升级后必须重新做容量评估。
- per-user 限制在容器世界里是全节点共享的 。容器内看到的
ulimit -n是进程级,但 inotify 上限按真实 uid 统计------所有 Pod 加起来一起占,单容器自查永远查不出问题。 - "重启能好"是资源争抢的典型信号。瞬时恢复 + 随机复发 = 有人在抢一个共享的有限资源,重启只是碰运气抢到,不是修复。
- 别用"调到最大"掩盖问题。把上限改成无限,下次泄漏时你会失去所有预警,等 OOM 才发现为时已晚。
- 错误信息是给用户看的,不是给排查者看的 。fsnotify 把
EMFILE翻译成"too many open files",内核在inotify_init1里返回-EMFILE只是因为它是"最接近"的错误码------语义上它是"实例数超限"。排障要穿透错误信息看系统调用,而不是在用户态文本上做文章。
进阶:什么时候这个问题会变成安全风险
inotify 上限耗尽不只是"看不了日志"这么简单。在生产环境里,它往往只是资源受限的前兆:
- 日志采集断了 → 审计/合规数据缺失,出事故无法追溯
- kubelet 无法创建 inotify → 配置热更新失效,配置漂移检测失灵
- 监控 Agent(Alloy/Node Exporter 的 watch 部分)挂掉 → 监控盲区
- 恶意场景:攻击者故意创建大量 inotify 实例,可让同类应用集体失能(资源耗尽型 DoS)------这也是为什么不要把上限调到 20 亿的原因之一
所以这个问题的正确处置顺序是:先调大上限止血 → 再定位谁在吃 → 最后评估是否需要限制单进程 。如果确认是 containerd 的系统性消耗,且集群会持续扩容,建议把 max_user_instances 的调优写进节点初始化模板(Terraform/Ansible),让新节点一出生就带正确配置,避免每加一台机器就复现一次排障。
原始出处:
- ServerFault #1137211: failed to create fsnotify watcher: too many open files ------ 问题描述 + inotify 三参数说明 + 按内存比例调整脚本
- Orkhan Huseynli: Troubleshooting "too many open files" Issue on Grafana Alloy Instances ------ RHEL 9.4/cgroupv2/containerd 根因链 + 排查命令
- containerd/containerd#10468 ------ cgroupv2 下 containerd 每容器一个 inotify 实例
- grafana/alloy#1217、kairos-io/kairos#2071、derailed/k9s#1399 ------ 同类报错的集群场景复现
- Linux man pages: inotify(7)、inotify_init(2)、inotify_add_watch(2)
- Linux 内核源码:
fs/notify/inotify/inotify_user.c(INOTIFY_DEFAULT_MAX_*常量与-EMFILE/-ENOSPC返回路径)
本文首发于 CSDN 专栏《运维漏洞指南》