Linux 出现 Too many open files:区分进程上限、系统上限与描述符泄漏

54 次浏览5 条回复

适用于 Linux 服务报 Too many open files、新连接失败或无法打开文件的场景。命令基线为 Linux 5.4+、systemd 245+、procps-ng 3.3+;prlimit 来自 util-linux,lsofstrace 为可选工具。读取其他用户进程的 /proc、套接字归属和跟踪系统调用通常需要 root。示例单元 app.service 与 PID 必须替换为实际值。先保留故障窗口,不要一开始就放大限制或重启服务。

1. 先保留错误原文并区分 EMFILE 与 ENFILE

date -u
uname -r
systemctl --version | head -n 1
systemctl status app.service --no-pager -l
journalctl -u app.service --since '-15 min' --no-pager -o short-iso
journalctl -k --since '-15 min' --no-pager -o short-iso \
  | grep -Ei 'too many open files|file-max|ENFILE|EMFILE'

EMFILE 表示调用进程触及自己的文件描述符上限,ENFILE 表示系统范围的打开文件表耗尽;日志里的通用文案可能没有保留 errno,不能仅凭一句 Too many open files 判断是哪一层。若应用日志不显示 errno,可在获准的短窗口内用运行时诊断或 strace 观察失败调用;附加跟踪会影响时序,不应长时间留在线上。

文件描述符不只代表普通文件,也包括套接字、管道、epoll、inotify、eventfd 等内核对象。因此磁盘空间正常不能排除描述符耗尽。

2. 固定实际进程与生效限制

PID=$(systemctl show app.service -p MainPID --value)
printf 'PID=%s\n' "$PID"
ps -o pid,ppid,user,lstart,etime,cmd -p "$PID"
cat "/proc/$PID/limits"
prlimit --pid "$PID" --nofile
systemctl show app.service \
  -p LimitNOFILE -p LimitNOFILESoft -p MainPID -p ControlGroup
systemctl show app.service -p FragmentPath -p DropInPaths
systemctl cat app.service

/proc/PID/limitsprlimit 显示的运行中进程值为准。shell 中的 ulimit -n 只说明当前 shell,不代表 systemd 已启动服务的限制。单元文件里写了 LimitNOFILE= 也不等于当前进程已经采用该值:服务可能尚未重启,或实际工作进程由其他管理器、容器入口或包装脚本创建。

还要确认 PID 是否在故障期间发生过变化;拿新进程的低占用解释旧进程的失败会丢失关键证据。多 worker 服务的限制按进程生效,应分别检查工作进程,不能只看主进程。

3. 统计占用并判断是否持续增长

单次计数可先建立上限占用比例:

find "/proc/$PID/fd" -mindepth 1 -maxdepth 1 -printf '.' | wc -c
cat "/proc/$PID/limits" | grep -F 'Max open files'

/proc/PID/fd 在程序运行时不断变化,计数只能视为采样值。为了识别泄漏,应在不跨越进程重启的前提下按固定间隔采样,并与请求量、连接数和错误时间对齐:

while kill -0 "$PID" 2>/dev/null; do
  printf '%s ' "$(date -u +%FT%TZ)"
  find "/proc/$PID/fd" -mindepth 1 -maxdepth 1 -printf '.' | wc -c
  sleep 10
done

连续上升且负载回落后不下降,才更像泄漏;高并发时短暂升高并随后回落可能是正常峰值。采样间隔与持续时间应覆盖原告警周期,避免用两三个点拟合趋势。

4. 找出主要描述符类型与归属

lsof 可做只读快照:

sudo lsof -nP -p "$PID"
sudo lsof -nP -p "$PID" | awk 'NR > 1 {print $5}' | sort | uniq -c | sort -nr
ss -tanp
ss -uanp

第二条按 lsof 的 TYPE 列做粗略聚合,执行前先确认本机输出列格式;不要把它当成跨版本稳定接口。重点区分大量 REGFIFOIPv4/IPv6anon_inode,并结合应用行为继续定位:

  • 大量处于相同远端或相同状态的 socket:检查连接池上限、连接关闭路径、超时与重试放大。
  • 大量指向同类路径的普通文件:检查文件轮转、异常分支与打开后未关闭。
  • 大量 pipeeventpollinotify:检查子进程、事件循环或目录监听器是否被重复创建。
  • (deleted) 的文件:说明路径已删除但仍被进程持有;它会占用描述符,也可能继续占用磁盘块。

lsof 本身是瞬时视图,而且遍历大型进程可能有开销。需要精确归因时,应优先使用应用运行时自带的连接池、句柄或资源诊断指标,并只在受控窗口补充系统调用跟踪。

5. 检查系统范围容量

sysctl fs.file-max
cat /proc/sys/fs/file-nr

/proc/sys/fs/file-nr 第一列是已分配文件句柄数,第三列是系统上限;第二列的具体表现与内核实现有关,不应单独作为告警依据。第一列接近第三列且多个无关进程同时失败,才支持系统范围耗尽分支。若只有一个服务接近自身 soft limit,而系统仍有充足余量,应回到该进程的限制和占用来源。

不要把系统范围句柄数与某个进程 /proc/PID/fd 的数量直接相加或要求一一对应;内核对象共享与计数口径不同。

6. 修复与闭环验证

若确认是泄漏,应先修复创建与释放不对称、连接池无界或重试堆积。提高 LimitNOFILE= 只能增加缓冲时间,还会放大服务对内存、端口和下游连接的占用;调整前应估算峰值并检查依赖容量。若确需修改 systemd 单元,先确认配置来源,使用 drop-in 保存变更,并在维护窗口执行 daemon-reload 与受控重启。

变更后至少同时验证:

PID=$(systemctl show app.service -p MainPID --value)
prlimit --pid "$PID" --nofile
find "/proc/$PID/fd" -mindepth 1 -maxdepth 1 -printf '.' | wc -c
cat /proc/sys/fs/file-nr
journalctl -u app.service --since '-10 min' --no-pager -o short-iso

闭环标准不是“服务能启动”,而是运行中进程的限制符合预期、描述符数量在代表性负载下达到稳定平台并保留余量、原入口不再出现打开文件或建连失败,且系统范围句柄数没有持续逼近上限。

可以再补一个多进程服务的取证入口:只看 MainPID 可能漏掉真正耗尽上限的 worker。若主机使用 cgroup v2、服务由 systemd 管理,且当前用户有权读取目标进程的 /proc,可按单元的 cgroup 枚举进程并按 FD 数排序:

CG=$(systemctl show app.service -p ControlGroup --value)
PROCS="/sys/fs/cgroup${CG}/cgroup.procs"
test -r "$PROCS" || { printf 'cannot read %s\n' "$PROCS" >&2; exit 1; }

while read -r pid; do
  test -d "/proc/$pid/fd" || continue
  count=$(find "/proc/$pid/fd" -mindepth 1 -maxdepth 1 -printf '.' 2>/dev/null | wc -c)
  comm=$(cat "/proc/$pid/comm" 2>/dev/null || printf '?')
  printf '%s\t%s\t%s\n' "$pid" "$count" "$comm"
done < "$PROCS" | sort -k2,2nr

cgroup.procs 是瞬时集合,worker 退出或重建会造成 /proc/$pid 消失,因此脚本需要容忍竞态;容器内执行时还要确认看到的是同一 PID 与 cgroup 命名空间。对排名靠前的 PID 分别检查 /proc/PID/limits 和 FD 类型,并重复采样。这样可以区分“单个 worker 泄漏”和“worker 数量增加导致总量上升”,也能避免用主进程的低占用误判整个服务。cgroup v1 的路径与控制器布局不同,不能直接套用上述路径。

再补一个提高 LimitNOFILE= 前容易忽略的兼容性边界:Linux 上使用 select(2) / pselect(2) 的程序受 fd_setFD_SETSIZE 限制,通常只能安全表示 0–1023 的描述符。把 soft limit 提到 1024 以上后,进程可能拿到更大的 FD;若应用或某个依赖仍把它交给 FD_SET(),结果不是简单地“容量变大”,而可能出现漏监听或未定义行为。使用 pollepoll 的主事件循环也不能证明所有库路径都不调用 select

变更前可先核对运行时和依赖文档,并在获准的短窗口做调用采样:

PID=$(systemctl show app.service -p MainPID --value)
sudo strace -ff -p "$PID" \
  -e trace=select,pselect6,poll,ppoll,epoll_wait \
  -tt -o /tmp/app-fdwait

附加跟踪有性能扰动,应限时执行;一次采样没看到 select 也不能证明所有异常分支都不用它。更可靠的闭环是在预发布或维护窗口施加代表性并发,让进程实际分配到大于 1023 的 FD:

find "/proc/$PID/fd" -mindepth 1 -maxdepth 1 -printf '%f\n' \
  | sort -n | tail

随后验证连接建立、超时、重连和异常清理路径,并检查日志中是否出现事件循环或无效描述符错误。若兼容性未确认,优先修复泄漏或限制无界并发,不要只靠提高 soft limit 把故障点后移。

还应把 fs.nr_open 纳入上限分层。对 Linux 5.4+,它不是当前占用量,也不是系统文件表容量,而是单个进程 RLIMIT_NOFILE 可设置到的内核上界;fs.file-max 才是系统范围文件句柄上限。排查时可并列记录:

PID=$(systemctl show app.service -p MainPID --value)
prlimit --pid "$PID" --nofile
sysctl fs.nr_open fs.file-max
cat /proc/sys/fs/file-nr

因此即使 systemd drop-in 写了很大的 LimitNOFILE=,也要以新进程的 /proc/$PID/limits 为准。请求的 hard limit 超过 fs.nr_open 时,setrlimit(2) 会失败;用户级 systemd 管理器还受管理器自身 hard limit 与权限约束,不能假设配置文件中的数字一定生效。

若确实需要越过 fs.nr_open,修改它属于主机级内核参数变更,需要 root,并应先核对同机服务、内存预算及前面提到的 select(2) 兼容性。变更后在维护窗口重启目标服务,再重新读取 prlimit/proc/$PID/limits,同时观察 FD 数量是否在代表性负载下稳定;不要用 fs.nr_open 的增大代替泄漏修复。

还可以补一个容易被 EMFILE 误导的分支:若失败调用来自 inotify,EMFILE 除了表示进程触及 RLIMIT_NOFILE,也可能表示真实用户 ID 已达到 fs.inotify.max_user_instances;而 inotify_add_watch(2) 在达到 fs.inotify.max_user_watches 时通常返回 ENOSPC。因此文件监听器报错时,不能只比较 /proc/$PID/fd 数量与 nofile 上限。

在 Linux 5.4+、有权读取目标进程 /proc 的前提下,可先记录:

PID=$(systemctl show app.service -p MainPID --value)
prlimit --pid "$PID" --nofile
sysctl fs.inotify.max_user_instances \
       fs.inotify.max_user_watches \
       fs.inotify.max_queued_events

find "/proc/$PID/fd" -maxdepth 1 -type l -lname 'anon_inode:inotify' -printf '%f\n'
awk '/^inotify/ { watches++ } END { print watches + 0 }' /proc/$PID/fdinfo/*

第一条 find 粗看该进程的 inotify 实例,fdinfo 中以 inotify 开头的行可粗算该进程登记的 watch 数;进程活动期间两次读取存在竞态,权限不足也会少计。更重要的是,这两个 sysctl 按真实用户 ID 汇总,单看一个 PID 不能证明用户级配额尚有余量,还要检查同一账号下的其他监听进程。

闭环时应先确认程序是否重复创建 watcher、是否在目录移除或重载后释放实例,再考虑调整 sysctl。若只扩大 LimitNOFILE=,inotify 的独立配额不会随之增加;若只提高 watch 配额,也无法修复 watcher 生命周期泄漏。

再补一个 /proc/PID/fd 数量看似仍有余量、却收到 EMFILE 的少见分支:从 Linux 4.5 起,RLIMIT_NOFILE 还限制无特权进程通过 UNIX 域套接字以 SCM_RIGHTS 传递的“在途”文件描述符数量。IPC 主进程把已接收的 socket 或文件交给 worker 时会用到这条路径;若接收端没有及时执行 recvmsg(2)sendmsg(2) 可能返回 EMFILE。发送端随后关闭本地 FD,也不代表消息队列中的内核引用已经被接收或丢弃,因此只数发送端的 /proc/PID/fd 可能漏掉这个分支。

前提是确认应用确实使用 UNIX socket 传递 FD,并锁定实际发送进程。Linux 5.4+、strace 5.x 环境可在获准的短窗口取证:

PID=<实际发送进程PID>
prlimit --pid "$PID" --nofile
sudo ss -xapn

sudo timeout -s INT 30s strace -ff -tt -yy -s 1 \
  -e trace=sendmsg,recvmsg,close -p "$PID" \
  -o /var/tmp/app-scm-rights
sudo grep -E 'SCM_RIGHTS|= -1 EMFILE' /var/tmp/app-scm-rights*

附加跟踪需要 ptrace 权限并有性能与信息暴露风险,输出文件应按故障材料控制权限;多 worker 服务要附加到真正调用 sendmsg 的进程。ss -xapn 的队列只能提供相关性,不能直接等同于在途 FD 数。比较可靠的判断是:失败调用明确为携带 SCM_RIGHTSsendmsg,同时进程自身 FD 数未逼近 soft limit,并能看到接收端停滞、阻塞或未消费消息。

修复应落在接收端持续排空、传递队列背压以及 worker 异常退出后的清理;提高 LimitNOFILE= 只会扩大缓冲窗口。变更后应在代表性并发下同时验证 sendmsg 不再返回 EMFILE、UNIX socket 队列能够回落,并确认接收端拿到的 FD 会按生命周期关闭。