kubectl logs 看不了日志?fsnotify 报 too many open files 的真相是 inotify 实例耗尽

搬运并适配自国外社区真实案例。原文出处见文末。

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 -nfs.file-max 都很高也拦不住,因为 inotify 有自己的独立计数,且耗尽时内核返回的 -EMFILE 与 fd 耗尽一字不差。解法:先数 inotify 实例(/proc/*/fdreadlinkanon_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_instancesper-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/v2manager.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.gomanager.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 deviceENOSPC
内核计数 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:inotifygrep inotify 就能精确计数,不会误统计普通文件 fd(普通文件显示真实路径)。如果某些进程把 inotify fd 关闭了但 watch 还挂着(极端泄漏场景),/proc/<pid>/fdinfo/N 里的 inotify wd: 行能精确列出每个 fd 的 watch 数------这是最细粒度的自检手段。

复盘:这个案例的排查方法论

回顾整个链条,最有价值的不是"调大 sysctl",而是分层定位的过程:

  1. 症状分层too many open files 只告诉你是"某种资源被限制",必须先枚举所有可能(fd / inotify 实例 / inotify watch / semaphore / epoll),再用命令逐一排除。本次靠"重启能好 + 节点绑定"两个特征,把范围从"应用泄漏"缩小到"节点级共享资源"。
  2. 跨版本对比:新旧节点(RHEL 8 vs 9)行为差异是黄金线索------直接指向 cgroupv2 这个变更点。遇到"升级后开始报错",优先查该版本的系统级默认值变更,而不是怀疑自己的代码。
  3. 数着看/proc 是 Linux 排障的最终真相。for foo in /proc/*/fd/* 这个命令比任何监控工具都快------它直接告诉你"现在用了几个、谁在用"。

启示

  1. "too many open files" 不一定指 fd。inotify 实例耗尽、watch 耗尽、semaphore 耗尽都可能返回 EMFILE/ENOSPC,错误信息只告诉你"资源被限制",不告诉你是哪个资源------必须数着看。
  2. 升级操作系统 = 换一批新默认值。RHEL 9 启用 cgroupv2,看似无关的内核变更会改变 containerd 的资源消耗模式,升级后必须重新做容量评估。
  3. per-user 限制在容器世界里是全节点共享的 。容器内看到的 ulimit -n 是进程级,但 inotify 上限按真实 uid 统计------所有 Pod 加起来一起占,单容器自查永远查不出问题。
  4. "重启能好"是资源争抢的典型信号。瞬时恢复 + 随机复发 = 有人在抢一个共享的有限资源,重启只是碰运气抢到,不是修复。
  5. 别用"调到最大"掩盖问题。把上限改成无限,下次泄漏时你会失去所有预警,等 OOM 才发现为时已晚。
  6. 错误信息是给用户看的,不是给排查者看的 。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),让新节点一出生就带正确配置,避免每加一台机器就复现一次排障。


原始出处

本文首发于 CSDN 专栏《运维漏洞指南》

相关推荐
starrysky8105 天前
RuntimeError: dictionary changed size during iteration——多线程 Pydantic cached_property 竞态条件
angular.js
starrysky8105 天前
Agent 安全 #06:Agent 灰度发布与回滚 —— 上线新 Prompt 炸了生产,你敢回滚吗?
angular.js
starrysky8105 天前
CPU 70% idle 但 load average 84?D 状态进程不可中断睡眠的深度排查
angular.js
shmily麻瓜小菜鸡6 天前
浏览器在请求外部图片(403 Forbidden)问题
vue.js·vscode·echarts·angular.js
界面开发小八哥8 天前
界面控件DevExtreme v26.1新版亮点——支持Angular 22
前端·javascript·angular.js·devexpress·ui开发·devextreme
巴勒个啦12 天前
Vue 3.6 Vapor Mode 实战:我把一个 Vue3 项目的渲染性能提升了 4 倍
前端·angular.js
shmily麻瓜小菜鸡15 天前
在 Angular 项目中实现国际化(i18n)和翻译 使用ngx-translate
前端·javascript·angular.js
starrysky81020 天前
一个 JSON 解析错误,搞垮了整个进程池?——requests JSONDecodeError + BrokenProcessPool 排障实录
angular.js
starrysky81020 天前
小模型反而爆显存?Qwen2.5-VL 3B vs 7B 的 vLLM 显存真相——多模态 Profiling 机制深度拆解
angular.js