containerd 还在运行,节点却 NotReady:一次 inotify 限额故障
2026-09-26 晚上,检查 Kubernetes 集群中的异常 Pod,发现一台节点已经 NotReady 了十几个小时。到准备恢复时,集群里有 580 个 Pod 对象,其中 285 个带着删除时间戳,全部集中在这台节点上。
登录宿主机后,containerd 和 kubelet 的 systemd 状态却都是 active (running)。节点没有重启,内存和根盘也没有耗尽。继续检查才发现,containerd 主进程虽然活着,提供给 kubelet 的 CRI 插件并没有成功启动。
关键错误是:
failed to create fsnotify watcher: too many open files最终定位到这台机器仍然保留着 128 的 inotify 实例限额。提高限额、恢复 CRI 后,节点重新 Ready,积压 Pod 才开始正常清理和重建。
这篇记录对应 Ubuntu 24.04.4、Linux 6.8、Kubernetes 1.33.1、containerd 2.1.1,时间均为北京时间。以下用 node-a 代指故障节点,省略实际地址、备份目录及凭据;命令中的目标节点和运行时路径需要按自己的环境核对。
先看节点,别从几百个 Pod 逐个查起
最初看到的是一大批 Pending、Terminating,以及少量 CrashLoopBackOff。如果只按 Pod 逐个处理,很容易把问题拆成镜像、网络、挂载和应用启动等许多小故障。
把异常对象按节点归类后,范围就清楚了:绝大部分删除积压都在 node-a。该节点的 Ready 条件从当天 06:47:46 起变为 False,原因包括:
KubeletNotReadycontainer runtime is downPLEG is not healthy这里的 Terminating 是 kubectl 展示的状态,不是 Pod 的独立 phase。只筛选 status.phase != Running 会漏掉一批已经进入删除流程、API 里却还保留旧 Running 状态的对象。
这台机器同时承担控制面、业务和 Ceph 存储职责。检查时,Kubernetes API 的 /readyz 正常,etcd 三成员也健康;存储已经受到影响,Ceph 只有 12/18 OSD up、13/18 in、2/3 MON 在 quorum,约 30.66% 的对象副本处于 degraded。
API 可访问、etcd 健康,只能说明这些检查覆盖的部分正常。它们没有证明故障节点上的 kubelet 还能管理容器。
systemd 的 active 没有覆盖 CRI 插件状态
在故障节点上,先检查服务和日志:
systemctl status containerd kubelet --no-pagerjournalctl -u containerd -u kubelet --since '-30 min' --no-pagercontainerd 主进程确实在运行,但 kubelet 持续报:
rpc error: code = Unimplemented desc =unknown service runtime.v1.RuntimeService这个错误说明当前连接到的服务没有提供所请求的 CRI 接口。它还不能单独证明是哪一个原因:端点配置、插件加载、版本兼容性都可能需要检查。
继续回查 containerd 当天启动时的日志,找到了更直接的证据:
level=warning msg="failed to load plugin"error="failed to create CRI service:failed to create cni conf monitor for default:failed to create fsnotify watcher: too many open files"id=io.containerd.grpc.v1.cri这几层错误把调用路径串了起来:CRI 服务初始化需要建立 CNI 配置监控,创建 fsnotify watcher 失败,导致 CRI 插件加载失败。containerd 的其他部分仍然存活,所以 systemd 没有把整个服务判为退出。
再用运行时自己的接口核验,而不是只看进程:
# 按实际部署确认 socket 和工具路径。crictl --runtime-endpoint unix:///run/containerd/containerd.sock versionctr plugins ls当时 crictl version 同样返回 unknown service runtime.v1.RuntimeService。日志与接口检查相互印证,问题已经缩小到了节点上的 CRI 初始化。
Too many open files 不一定是 ulimit 太小
看到这条错误,直觉上很容易先去提高 ulimit -n。但 inotify_init() 返回 EMFILE 至少有两种含义:用户的 inotify 实例配额耗尽,或者进程的文件描述符上限耗尽。系统级文件表耗尽则对应 ENFILE。Linux inotify_init(2)
我先把几组限制分开检查:
sysctl fs.inotify.max_user_instances \ fs.inotify.max_user_watches \ fs.inotify.max_queued_events
systemctl show containerd \ -p MainPID -p LimitNOFILE -p LimitNOFILESoft
runtime_pid=$(systemctl show containerd -p MainPID --value)test "$runtime_pid" -gt 0sudo grep '^Max open files' "/proc/$runtime_pid/limits"sudo find "/proc/$runtime_pid/fd" -maxdepth 1 -type l | wc -l
cat /proc/sys/fs/file-nr得到的关键数据如下:
| 检查项 | 当时结果 | 能说明什么 |
|---|---|---|
max_user_instances | 128 | 用户可创建的 inotify 实例限额很低 |
max_user_watches | 1048576 | 监控项限额很高,但它不能替代实例限额 |
| containerd 已打开的普通 FD | 768 | 距离进程上限很远 |
| containerd FD 软、硬上限 | 均为 1048576 | 当前证据不支持“进程普通 FD 用尽” |
| root 进程持有的 inotify FD 引用数 | 约 127 | 已接近 128,值得优先验证实例配额 |
inotify 的实例数、监控项数和事件队列容量是不同维度。max_user_instances 按真实用户 ID 限制可创建的实例数量;同一用户下的多个进程会共同消耗这项配额。一个实例又可以包含许多 watch。Linux inotify(7)
所以,不能把 1048576 个 watches 理解为“还能创建一百万个 watcher 实例”,也不能只看某个进程排在榜首、持有几个 inotify FD,就认为它发生了泄漏。
127 是 /proc 扫描得到的 FD 引用数近似值,不是内核去重后的精确实例计数。 FD 可以被复制、继承,进程也会在扫描期间退出;按进程当前 UID 分组同样有统计边界。仅凭 127 接近 128,还不能证明报错瞬间恰好用满了全部配额。
但结合启动日志、普通 FD 的余量,以及后续提高配额后 CRI 成功恢复,已经足以支持优先处理 inotify 实例配额不足。没有必要同时把 watches、队列和普通文件句柄上限全部拉高。
节点故障为什么会积压出这么多 Pod
运行时失效后,kubelet 不能正常查询、创建和结束容器,节点进入 NotReady。控制器继续处理驱逐与替代实例,旧 Pod 的清理却无法完成。
其中还有一个会放大数量的配置:某个 Deployment 直接写了固定节点名。
spec: template: spec: nodeName: node-anodeName 会绕过调度器,把新 Pod 直接绑定到指定节点;它不会因为节点上有 NoSchedule 就自动选择另一台机器。与此同时,NoExecute 仍会影响未获得相应容忍的 Pod。Kubernetes 污点与容忍
现场表现为:替代 Pod 不断绑定到同一台故障节点,随后进入驱逐和删除流程,旧对象又无法及时清理。数量很大,并不表示已经有几百份应用进程实际跑起来。
几个使用 RWO 卷的服务也卡在新节点的 ContainerCreating。检查发现,原 VolumeAttachment 仍显示卷附着在旧节点;新节点的 kubelet 持续报告卷未挂载、等待超时。
这些现象让恢复顺序很明确:先恢复故障节点管理容器的能力,再观察旧实例退出、卷释放和新实例启动。直接强制删除 API 对象,并不能替代这些过程。
先改限额,再恢复已经失败的 CRI
开始变更前,重新检查了 Kubernetes、etcd 和 Ceph,并创建、校验了新的 etcd 快照,保存 Pod、PVC、虚机和挂载对象的身份基线。etcd 快照保护的是 API 状态,不等于业务数据库或 PV 数据备份。
这次只对故障节点把实例限额从 128 提高到 8192:
# 生产变更:仅在已经确认需要上调的目标节点执行。# 已有更高值时保留原值,避免照抄后反而调低。current_limit=$(sysctl -n fs.inotify.max_user_instances)if [ "$current_limit" -lt 8192 ]; then sudo sysctl -w fs.inotify.max_user_instances=8192fi8192 是本集群采用的运维基线,不是 Kubernetes 对所有机器的固定要求。提高上限也不能排除应用泄漏;如果实际占用继续无界增长,仍要追查持有者。
接下来有一个容易混淆的地方:运行时修改 sysctl,不需要靠重启服务器来应用;但已经加载失败的 CRI 插件,还需要恢复。
我核对了 containerd 的 systemd 单元,实际配置是 KillMode=process。该模式停止服务时针对主进程,不会由 systemd 按 control-group 模式一并终止整个组;containerd 2.1.1 上游单元也使用这一设置。systemd 说明、containerd 单元
这不是“重启一定没有业务影响”的保证。CRI 恢复后,kubelet 还会继续执行积压的删除与重建,这本身就可能导致实例变化和短暂中断。
完成影响评估后,只重启了这台机器的 containerd,没有重启宿主机、etcd 或 kubelet,也没有对另外两台节点执行相同操作。
# 以下是本次故障节点的恢复步骤,不是日常巡检命令。systemctl show containerd -p KillMode -p MainPID -p ActiveStatesudo systemctl restart containerd
crictl --runtime-endpoint unix:///run/containerd/containerd.sock versionctr plugins ls恢复后,crictl 能正确返回 containerd 2.1.1 和 CRI v1,images、runtime、cri 三个相关插件均为 ok。这次没有删除 containerd 数据目录,也没有通过移除 Pod finalizer 或批量强制删除来清理现场。
Ready 恢复之后,还要看哪些东西
关键时间点如下:
| 时间 | 观察到的变化 |
|---|---|
| 09-26 06:47:26 | containerd 启动日志记录 fsnotify watcher 创建失败 |
| 09-26 06:47:46 | 节点进入 NotReady |
| 09-26 23:19:56 | 故障节点实例限额提高到 8192 |
| 09-26 23:19:57 | 单独重启 containerd |
| 09-26 23:20:12 | CRI 接口和相关插件检查通过 |
| 09-26 23:20:43 | 三节点 Ready,第三个 MON 回到仲裁 |
| 09-26 23:45 | Terminating 清零,Ceph 无 degraded/undersized PG,仍在回填 |
| 09-27 01:17 | 三节点运行值和持久配置文件名复核完成 |
到 23:45,原来 285 个带删除时间戳的 Pod 已由控制器正常处理。原有 133 个 PVC 的 UID/PV 绑定保持,10 个虚机实例的身份和 Running/Ready 状态保持。
Ceph 恢复到 18 OSD up/in、3 MON quorum,当时 679 个 PG 为 active+clean,66 个仍在 active+remapped+backfilling。没有把“副本降级消失”写成“已经全部 clean”。
恢复过程也有需要留下的偏差:两个 OSD 新增心跳超时 crash,另一个 OSD 出现一次 OOMKilled 后自动恢复。四个 HDD OSD 启动时执行了 BlueStore allocator 恢复,花费了额外时间。这些记录没有被归档来消除告警,也没有为了让界面变绿而反复重启 OSD。
应用侧仍需要分别验收。有的服务随卷释放自行恢复;有的服务在数据库暂时不可达时初始化队列失败,即使后来数据库恢复,队列也不会自动重试,需要在依赖健康后单独恢复。另有镜像下载和业务策略依赖故障,不能都归结为同一个 sysctl 参数。
因此,这个窗口的结论是 inotify / CRI 主故障已经恢复,不是所有业务问题都被一并解决。后续数据库专项检查还发现了 Ready 状态没有覆盖的复制问题,另记在《Pod 都是 Ready,PostgreSQL 却没有主备复制》中;那部分还需要独立的数据库证据和恢复操作。
最后补齐的是三台机器的配置一致性
临时调整生效后,持久化不能省略。最终统一使用:
/etc/sysctl.d/99-inotify.conf本次需要保存的目标参数为:
fs.inotify.max_user_instances = 8192编辑时保留文件中原有的其他配置,检查其他 sysctl 文件是否重复设置了同一项。sysctl.d 的文件优先级与排序会影响最终生效值,配置文件存在本身还不够。systemd sysctl.d 规则
这里也补上了一个重要的现场事实:另外两台节点原本就已经是 8192,只有故障节点仍为 128。修复后,这台机器最初使用的文件名与另外两台不同,随后按统一约定改成 99-inotify.conf;改名保留文件内容,没有为此重启服务。
三节点复核结果是:
| 参数 | 三台节点运行值 |
|---|---|
fs.inotify.max_user_instances | 8192 |
fs.inotify.max_user_watches | 1048576 |
fs.inotify.max_queued_events | 16384 |
这表示运行值已对齐,标准持久文件也都存在;不表示三份文件逐字相同。修复节点的文件只显式保存 instances,另外两台原文件还包含其他 inotify 参数。本次没有为了验证开机加载而重启生产节点。
后续再做节点初始化、kubeasz 重配或系统升级时,需要同时检查运行值和落盘配置。对于这类承担控制面、业务和存储的混合节点,一台机器遗漏的小配额,也足以让容器管理、驱逐、挂载和存储恢复连在一起。
这次排障最有用的判断,是把三件事分开核验:进程是否存活、CRI 是否可用、业务是否真的恢复。 active、Ready 和一次健康接口成功,各自都有明确的范围。