🤖
AI审核中

Too many open files排障实战

云原生与运维 23分钟 108浏览 0评论

一、报错的是文件,耗尽的却不一定是文件

假设一个服务运行了几天,突然开始出现异常:

java.io.FileNotFoundException: ... (Too many open files)

与此同时,部分接口请求失败,新的数据库连接无法建立,连原本正常的文件读取也开始报错。

查看监控,CPU 不高,内存尚有余量,磁盘也没有写满。

这时,很多人的第一反应是执行:

ulimit -n 65536

然后重启服务。

问题可能暂时消失,但几个小时或者几天之后,又以同样的方式出现。

这里至少存在两个需要分别回答的问题:

服务实际受什么限制?服务为什么会消耗这么多文件描述符?

前者属于配置和容量问题,后者属于资源使用和生命周期问题。只回答其中一个,往往无法完成真正的修复。

1. 文件描述符不只对应磁盘文件

文件描述符,通常简称 FD,是进程访问某些内核资源时使用的整数标识。

除了普通文件,套接字、管道,以及 epoll、eventfd、inotify 等机制创建的描述符,也会出现在进程的 FD 表中。因此,即使业务代码几乎不读写磁盘文件,一个网络服务依然可能耗尽文件描述符。Linux 的 /proc/<pid>/fd 可以展示这些资源的对应关系。

所以,“Too many open files”不能简单翻译成“磁盘上的文件太多”。

更有用的理解是:

某个资源创建操作,已经无法取得它需要的描述符或相关内核资源。

2. 抛出异常的位置,不一定是泄漏发生的位置

假设一个模块持续打开文件而不关闭,另一个模块只是正常创建数据库连接。

当整个进程的可用 FD 被耗尽时,后者也可能创建失败。

因此,异常堆栈经常只能告诉我们:

谁在资源耗尽之后,尝试申请了下一份资源。

它不一定直接告诉我们:

谁在此前一直占用资源而没有释放。

排查时,应当把“失败调用的位置”和“资源持续增长的来源”分开调查。

二、先分清限制层级,再决定查什么

Linux 中与这类问题有关的限制,并不是同一个开关。

限制或配置 主要作用 排查时需要回答的问题
RLIMIT_NOFILE 软限制 当前进程实际执行的 FD 限制 真正报错的进程现在受到什么限制?
RLIMIT_NOFILE 硬限制 约束软限制可以提高到什么程度 进程是否具备提高软限制的空间?
fs.nr_open 约束进程 FD 硬限制可以设置到的内核上界 为什么提高硬限制会失败?
fs.file-max 系统级文件句柄分配上限 是否出现了系统范围的资源耗尽?

软限制不能超过硬限制;提高硬限制涉及权限约束。严格来说,RLIMIT_NOFILE 规定的是可分配 FD 编号范围的上界,而不是一个独立的“业务连接数配额”。

fs.nr_openfs.file-max 也不能混为一谈:前者关联单个进程能够设置的限制,后者关联系统级文件句柄分配。把 fs.file-max 调得很大,并不会自动提高某个服务进程的软限制。

对于普通的文件打开操作,EMFILE 通常指向进程级限制,ENFILE 则指向系统级限制。但具体系统调用可能还有额外的配额语义,不能只看错误字符串就下结论。

可以按下面的路径展开:

graph LR
    A["文件或连接创建失败"] --> B{"确认调用位置与错误码"}
    B -->|"普通调用的 EMFILE"| C["核对进程 FD 与软限制"]
    B -->|"ENFILE"| D["核对系统与调用专属配额"]
    B -->|"inotify 相关"| E["核对实例与 watch 配额"]
    C --> F["分类采样并观察增长趋势"]
    D --> F
    E --> F
    F --> G{"容量不足还是资源未释放"}
    G -->|"合理峰值"| H["调整容量和启动配置"]
    G -->|"持续残留"| I["修复资源生命周期"]
    H --> J["固定负载与故障路径回归"]
    I --> J

这个流程的重点不是命令数量,而是:

先确定限制属于谁,再确定增长来自哪里。

三、第一步:读取真正报错进程的限制

1. 不要用当前终端代表线上进程

在 SSH 终端中执行:

ulimit -Sn
ulimit -Hn

看到的是当前 Shell 的软限制和硬限制。

子进程会继承父进程的资源限制,但修改当前 Shell 的限制,不会自动修改另一个已经运行的服务进程。systemd 启动的服务,也不是由当前 SSH Shell 直接创建的。

因此,排查入口应当是实际报错的 PID。

下面以 12345 为示例,执行时替换成业务进程的真实 PID:

PID=12345

ps -p "$PID" -o pid,ppid,lstart,args

sudo grep 'Max open files' "/proc/$PID/limits"

/proc/<pid>/limits 会展示该进程的资源软限制、硬限制及对应单位。

假设查到软限制为 4096,硬限制为 524288

这说明当前进程首先受到的是 4096 的软限制,而不是因为硬限制很高,就已经能够使用几十万个 FD。

如果服务存在多个 worker,需要逐个核对实际处理请求的进程。不要只查看 master、启动脚本或者容器入口进程,就默认它们代表全部业务进程。

2. 同时观察总量和资源类型

单次采样只能描述一个时刻,连续采样才能帮助判断趋势。

下面的脚本每隔五秒采样一次,共采样十二次。它会统计 FD 总量,并粗略区分 socket、pipe、匿名内核对象以及文件或设备路径。

命令需要 Linux、Python 3,以及读取目标进程信息的权限:

PID=12345

sudo python3 - "$PID" <<'PY'
import collections
import os
import sys
import time

pid = int(sys.argv[1])
fd_dir = f"/proc/{pid}/fd"

for sample in range(12):
    try:
        names = os.listdir(fd_dir)
    except OSError as exc:
        raise SystemExit(f"Cannot inspect PID {pid}: {exc}")

    counts = collections.Counter()

    for name in names:
        try:
            target = os.readlink(f"{fd_dir}/{name}")
        except FileNotFoundError:
            counts["vanished"] += 1
            continue
        except PermissionError as exc:
            raise SystemExit(f"Permission denied: {exc}")

        if target.startswith("socket:["):
            kind = "socket"
        elif target.startswith("pipe:["):
            kind = "pipe"
        elif target.startswith("anon_inode:"):
            kind = target
        else:
            kind = "path/device"

        counts[kind] += 1

    print(
        time.strftime("%Y-%m-%d %H:%M:%S"),
        f"pid={pid}",
        f"total={len(names)}",
        dict(counts),
        flush=True,
    )

    if sample != 11:
        time.sleep(5)
PY

这里利用的是 /proc/<pid>/fd 中的符号链接信息:socket 和 pipe 通常带有类型及 inode 标识,一些内核对象则显示为 anon_inode:...

需要注意,采样不是原子快照。

枚举目录之后,某个 FD 可能已经关闭,所以脚本将这类条目标记为 vanished。这表示采样期间发生了变化,不代表发现了泄漏。

同样,权限不足或者进程已经退出,也不能被解释成“FD 数量为零”。

四、第二步:用增长趋势区分容量问题和泄漏问题

1. FD 数量高,不等于存在泄漏

假设服务启动时只有几百个 FD,流量上升后增长到两千多个,随后长期稳定。

这种曲线可能只是连接池、长连接和其他常驻资源逐步完成初始化。

但如果在相同负载下,每完成一批请求,FD 基线就抬高一截;业务空闲并经过预期回收窗口后,资源仍然持续残留,就值得进一步调查。

这里可以建立一个简单模型:

当前 FD 数量 = 稳定常驻资源 + 正在使用的资源 + 暂未回收资源 + 非预期残留资源。

总量指标把这些部分混在一起,而排查工作就是逐步把它们拆开。

因此,比“现在有多少个 FD”更重要的问题是:

在负载基本相同的情况下,为什么下一轮结束后的资源基线,比上一轮更高?

2. 使用 lsof 查看资源归属

查看目标进程打开的资源:

sudo lsof -nP -p "$PID"

但不要直接把 lsof 输出行数当成 FD 数量。

它的输出还可能包含当前工作目录 cwd、程序映像 txt、内存映射 mem 等条目,这些并不是同一种数字 FD 记录。因此,lsof -p <PID> | wc -l 不适合直接用来判断进程是否接近 RLIMIT_NOFILE

更合适的分工是:

使用 /proc/<pid>/fd 观察数量,使用 lsof 理解资源指向。

3. 普通文件持续增长,检查资源关闭边界

如果增长主要集中在 path/device,可以查看是否反复出现相同文件或相同目录下的文件。

调查时,重点放在资源的整个生命周期上:正常完成时是否关闭,读取失败时是否关闭,提前返回时是否关闭,任务取消时又由谁负责关闭。

不要只搜索有没有调用 close()

真正需要确认的是:

每一次成功创建资源,是否都存在可靠且可达的释放路径。

4. socket 持续增长,先确认连接去了哪里

只查看目标进程的 TCP 资源:

sudo lsof -nP -a -p "$PID" -iTCP

这里的 -a 用于将进程条件和网络条件组合为交集,避免查询范围偏离目标进程。

取得连接信息后,再结合客户端配置与业务调用调查:

连接是否集中指向某个下游?连接池是否有明确上限?是否每个请求都创建一个新的客户端?异常或取消后,资源是否还被持有?

这些是调查方向,而不是仅凭 socket 数量就能成立的结论。

如果增长集中在某类匿名内核对象,也可以沿用同样的方法:把对象数量的变化,与客户端、监听器、事件循环等组件的创建和销毁行为进行关联。

五、第三步:进程没有接近上限,也不能直接结束排查

1. 系统级文件句柄是否耗尽

检查系统相关参数:

sysctl fs.file-nr fs.file-max fs.nr_open

fs.file-nr 的三个值分别表示已分配文件句柄数量、已分配但未使用的数量,以及最大值。在 Linux 2.6 及之后的实现中,第二项报告为零,不应把它误读成“系统没有空闲容量”。

必要时还可以查看当前启动周期的内核日志:

sudo journalctl -k -b --no-pager | grep -F 'file-max'

内核文档给出了触及系统上限时可能出现的 VFS: file-max limit ... reached 信息。但没有查到这条日志,不足以单独证明系统级限制没有参与故障。

另外,系统文件句柄数量也不等于所有进程 FD 数量的简单相加。多个描述符可以引用同一个打开文件描述,例如通过复制描述符形成共享引用。

2. inotify 的 EMFILE,可能不是 nofile 太小

这是一个很容易误判的分支。

inotify_init() 返回 EMFILE,既可能因为进程 FD 已经达到上限,也可能因为用户的 inotify 实例数量达到上限。

可以检查:

sysctl fs.inotify.max_user_instances
sysctl fs.inotify.max_user_watches

如果失败的是 inotify_add_watch(),返回 ENOSPC,应优先核对 watch 配额和相关内核资源,而不是直接把它当成磁盘空间不足。

还要区分实例和 watch:一个 inotify 实例可以持有多个监视项,watch 数量并不等于进程 FD 数量。

3. ENFILE 也要结合具体调用解释

对于 pipe()pipe2()ENFILE 除了可能表示系统级文件数量限制,还可能与用户可分配的管道内存硬限制有关。

所以,排障记录中最好同时保留错误码、失败调用和进程信息。

错误字符串是入口,不是根因分类器。

六、为什么配置改了,服务却没有真正生效

1. systemd:修改服务的启动配置

对于 systemd 管理的服务,可以先查看管理器中的配置:

systemctl show myapp.service \
  -p MainPID \
  -p LimitNOFILE \
  -p LimitNOFILESoft

如果经过容量评估,计划将软限制设置为 65536,并保留 524288 的硬限制,可以编辑服务的 drop-in:

sudo systemctl edit myapp.service

加入:

[Service]
LimitNOFILE=65536:524288

这里使用的是“软限制:硬限制”形式。这两个数字只是配置示例,不是适用于所有服务的通用值。应结合现有硬限制和内核上界核对,避免无意降低或不合理提高限制。

下面的重启会影响服务,应在完成流量摘除或安排维护窗口后执行:

sudo systemctl daemon-reload
sudo systemctl restart myapp.service

确认服务启动成功、主进程 PID 非零后,再读取新进程的实际限制:

PID=$(systemctl show -p MainPID --value myapp.service)

sudo grep 'Max open files' "/proc/$PID/limits"

如果 MainPID 对应的是包装进程,仍需继续找到真正的业务进程。

验证标准不应停留在“配置文件已经保存”,而应落实到:

新启动的业务进程,实际读到了预期限制。

提高软限制还有兼容性前提。systemd 官方文档特别提醒:Linux 上受传统 select() 限制的程序不能正常处理大于 1023 的 FD。提高上限之前,应确认应用及相关原生依赖能够处理高编号描述符。

2. Docker Compose:修改运行时配置,并重建容器

对于 Compose,在现有服务下加入 ulimits,同时保留原来的镜像、环境变量、卷和端口配置:

services:
  app:
    # 保留现有 image、environment、volumes 等配置
    ulimits:
      nofile:
        soft: 65536
        hard: 524288

服务级 ulimits 用于覆盖容器运行时的默认限制。不要把它与镜像构建阶段的资源限制混为一谈。

完成配置检查后,在允许中断或已经完成流量切换的前提下重建目标服务:

docker compose config

docker compose up -d --no-deps --force-recreate app

docker compose up 可以根据配置变化重建容器;--force-recreate 则显式要求重建。仅仅保存 YAML,并不会改变已经运行的容器。

下面假设服务只有一个容器实例,镜像中存在 cat,并且业务进程就是容器内的 PID 1:

CID=$(docker compose ps -q app)

docker exec "$CID" cat /proc/1/limits

如果业务进程不是 PID 1,应替换为它在容器内的实际 PID。多个实例则需要分别核对。

无论是 systemd 还是 Docker,最终都要回到同一个原则:

配置意图需要通过运行中进程的实际状态来验证。

七、做一个受控实验:不把整台服务器拖进故障

理解 FD 耗尽,不需要把系统全局限制调低,也不需要创建几十万个连接。

下面的实验仅作用于新启动的 Python 进程:将它自身的软限制设置为不超过 128,反复打开 /dev/null,捕获 EMFILE,随后关闭资源。

Python 的 resource 模块可以读取和设置当前进程的资源限制。以下示例面向 Linux 和 Python 3.9 及以上版本。(Python documentation)

保存为 fd_demo.py

import argparse
import errno
import os
import resource
import time


def main() -> None:
    parser = argparse.ArgumentParser(
        description="Bounded Linux FD exhaustion demo"
    )
    parser.add_argument("--hold", type=float, default=15.0)
    args = parser.parse_args()

    if not 0 <= args.hold <= 30:
        parser.error("--hold must be between 0 and 30 seconds")

    original = resource.getrlimit(resource.RLIMIT_NOFILE)
    soft, hard = original

    limit = (
        128
        if soft == resource.RLIM_INFINITY
        else min(soft, 128)
    )

    if limit < 16:
        raise SystemExit("Current limit is too small for this demo")

    opened: list[int] = []
    resource.setrlimit(resource.RLIMIT_NOFILE, (limit, hard))

    try:
        print(
            f"PID={os.getpid()}, soft_limit={limit}",
            flush=True,
        )

        while True:
            try:
                opened.append(
                    os.open("/dev/null", os.O_RDONLY)
                )
            except OSError as exc:
                if exc.errno != errno.EMFILE:
                    raise

                print(
                    f"EMFILE reached; newly_opened={len(opened)}",
                    flush=True,
                )
                break

        time.sleep(args.hold)

        while opened:
            os.close(opened.pop())

        # 仍然保持相同的软限制,验证释放资源后能否恢复。
        probe = os.open("/dev/null", os.O_RDONLY)
        os.close(probe)

        print(
            f"Released descriptors; "
            f"open() works at the same limit={limit}",
            flush=True,
        )
    finally:
        for fd in opened:
            os.close(fd)

        resource.setrlimit(
            resource.RLIMIT_NOFILE,
            original,
        )


if __name__ == "__main__":
    main()

执行:

python3 fd_demo.py --hold 15

程序会输出自身 PID,可以在另一个终端使用前面的采样方法观察。

在本文的隔离验证中,进程新打开了 125 个 FD,加上启动时已有的 3 个,触达 128 的软限制;外部采样看到的 FD 总数为 128,最高编号为 127

释放这些资源后,程序在仍然保持 128 软限制的情况下,再次执行 open() 成功。

不同运行环境下,进程启动时已有的 FD 数量可能不同,因此新增数量不必恰好是 125

这个实验说明:

资源耗尽之后,提高上限不是恢复能力的唯一方式。正确释放已经占用的资源,同样可以让后续申请恢复。

它也解释了为什么“重启之后正常”不能证明问题只是配置太小。重启会改变资源占用状态,而真正需要调查的是此前为何持续占用。

八、Java 实战:执行完 Stream,不代表关闭了文件

对于 Java 服务,下面这种写法很容易被忽略:

public static long countErrors(Path path) throws IOException {
    return Files.lines(path, StandardCharsets.UTF_8)
            .filter(line -> line.contains("ERROR"))
            .count();
}

代码完成了读取,也执行了终止操作 count(),但没有显式关闭由 Files.lines() 返回的资源型流。

Java 官方 API 明确说明:Files.lines() 返回的流持有打开的文件,关闭流才会关闭文件,应当放在 try-with-resources 或类似的资源管理结构中。Files.list()Files.walk() 等返回的资源型流,也有相应关闭要求。

可以改成下面的完整实现:

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.stream.Stream;

public final class ErrorLineCounter {

    private ErrorLineCounter() {
    }

    public static long countErrors(Path path) throws IOException {
        try (Stream<String> lines =
                     Files.lines(path, StandardCharsets.UTF_8)) {

            return lines
                    .filter(line -> line.contains("ERROR"))
                    .count();
        }
    }

    public static void main(String[] args) throws IOException {
        if (args.length != 1) {
            throw new IllegalArgumentException(
                    "Usage: java ErrorLineCounter <log-file>"
            );
        }

        System.out.println(
                countErrors(Path.of(args[0]))
        );
    }
}

使用 JDK 17 或更高版本编译运行:

javac ErrorLineCounter.java

java ErrorLineCounter ./app.log

try-with-resources 会在离开语句块时关闭声明的资源,包括执行过程中发生异常的情况。它的价值不是省掉几行 finally,而是让资源所有权与释放边界直接体现在代码结构中。

在本文的隔离验证中,修复示例预热后连续调用一万次,观测到的 FD 计数前后均为 6

这证明了这个小示例在该测试条件下没有持续累积 FD,但不能代替真实服务的完整验证。

真实应用还需要覆盖读取失败、处理中断、任务取消和提前返回等路径。资源型对象一旦跨方法、跨线程传递,也需要明确:究竟由创建者关闭,还是将关闭责任移交给调用者。

资源可以转交,责任不能模糊。

九、最后一步:建立容量预算,而不是寻找一个万能上限

1. 用资源模型解释上限

一个服务进程的 FD 预算,可以从下面几个部分估算:

FD 预算 ≈ 入站连接 + 出站连接 + 文件资源 + 管道及事件资源 + 运维预留空间。

这是容量规划模型,不是内核计数公式。

实际估算时,应使用真实的连接数量和资源使用方式,而不是把请求 QPS 直接换算成 FD 数量。然后通过峰值测试,验证模型有没有漏项。

提高上限之前,至少应能解释:当前峰值主要来自什么资源,增长是否有边界,以及提高后会给进程和系统留下多少余量。

2. 同时看使用率与净增长速度

假设某进程当前使用 18000 个 FD,软限制为 65536,在稳定业务负载下,每分钟净增加 90 个。

在“增长速度保持不变”的简化假设下,距离触及上限大约还有:

剩余时间 ≈(65536 − 18000)÷ 90 ≈ 528 分钟,约 8.8 小时。

这不是可靠的故障时间预测,因为流量和回收行为都可能变化。

但它可以提醒我们:

即使当前使用率不高,持续的正向净增长也值得告警。

监控不应只看使用率,还应观察稳定负载下的增长趋势,以及业务进入低谷、经过预期回收窗口后,资源是否仍持续残留。

告警阈值需要按服务特点确定,不能把某个百分比当成统一标准。

3. 用回归结果证明修复有效

建议至少覆盖下面几类验证:

验证场景 应观察的结果
固定负载持续运行 预热之后,FD 进入有界波动,而非持续抬升
多轮相同批次任务 每轮结束后的资源基线不持续累积
下游失败、读取异常、任务取消 资源能够沿故障路径释放
服务重启或容器重建 新业务进程实际继承预期限制

这里的“回落”不等于必须回到刚启动时的数量。

连接池和其他常驻组件可能保留合理资源。更准确的验收标准是:

资源占用存在能够解释、能够验证的稳定边界,而不是随着运行时间无限累积。

十、总结:调大上限之前,先解释资源去了哪里

“Too many open files”看起来像一个系统参数问题,实际排查却必须同时跨越进程限制、内核配额、部署方式和应用资源生命周期。

只查看 ulimit -n,可能看错对象;只统计 lsof 行数,可能看错指标;只根据 EMFILE 调参数,可能忽略 inotify 的专属配额;只通过重启恢复服务,则可能抹掉最有价值的现场。

更可靠的做法,是让证据串起来:

确认真正失败的调用,读取实际业务 PID 的限制,观察 FD 的类型和增长趋势,再决定是调整容量,还是修复释放路径。

最终,判断问题是否解决,不是看上限有没有从几千变成几万,也不是看重启之后是否暂时正常。

而是看相同负载和相同故障条件下,资源是否仍然持续增长,是否能够正确释放,以及运行中的进程是否真正使用了预期配置。

调大限制可以为排障争取空间,但解释清楚资源的去向,才能让系统长期稳定。

0 条评论
如果你觉得文章对你有帮助,那就请作者喝杯咖啡吧☕
微信
支付宝
  0 条评论