ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

FDE排查指南:文件描述符耗尽的原因、定位与预防

FDE排查指南:文件描述符耗尽的原因、定位与预防 先提醒一件事如果你看到某个服务在运行一段时间后突然上报too many open files或者应用日志里出现 “Too many open files” 异常先别急着怀疑程序代码坏了大概率是 FDE。FDE 不是新框架也不是某个厂商的专有名词它就是 File Descriptor Exhaustion翻译成中文就是“文件描述符耗尽”。很多人第一次听到“FDE 又不够了”会以为是个组件或存储空间实际上它说的是进程能打开的文件句柄数量到了上限后面的新连接、新文件、新线程都只能失败。这类问题在后端开发、SRE、测试环境维护里非常常见尤其是接入网关、消息推送、API 服务、数据库连接池这类高并发进程。我平时在团队里也会开玩笑说“又要当 FDE 工程师了”但严格讲这不算一个岗位而是排查“文件描述符耗尽”的一整套分析能力。这篇文章我会按自己的排查顺序把 FDE 为什么会出现、怎么定位、怎么改、怎么预防讲清楚。内容偏向 Linux 环境适合后端开发、运维和做工具链的同事参考。1. 先搞懂 FDE 为什么会出现以及它到底缺的是什么1.1 FDE 不是“磁盘不够”也不是“内存不够”FDE 的核心是 Linux 系统中“文件描述符”这种资源不够了。Linux 有一个设计哲学一切皆文件。普通文件是文件网络连接 socket 是文件管道是文件事件通知用的 eventfd、epoll fd 也占用文件描述符。每个进程都会维护一张文件描述符表这张表的大小同时受用户态限制和内核配置影响。当进程里已经打开的 fd 数量达到上限再有新的 socket 连接、新的文件读取、新的管道创建内核就会拒绝并返回EMFILE在应用日志里通常显示为java.io.IOException: Too many open filessocket: Too many open filesopen() failed with errno 24accept failed: Too many open files这些报错本质上都是同一个问题进程的 fd 表满了。注意这时候系统的磁盘可能还很充裕内存也可能够用CPU 也不高但服务就是无法接受新连接。这就是 FDE 最容易被误判的原因。1.2 正常的服务为什么会把 fd 用完大多数正常工作的服务fd 数量会稳定在一个区间。例如一个 HTTP 服务刚启动时可能只打开几十个 fd运行过程中随着连接并发、日志文件、连接池、临时文件的增多fd 数量慢慢上涨。如果代码里对连接、文件、流对象的生命周期处理得当fd 数量会在某个水位附近波动不会持续增长。真正出问题的是“只增不减”。常见情况有几类网络连接只建立不关闭短连接被当成长连接使用连接池里的连接没有复用每次请求都新建连接文件流、输入流、输出流使用了但没有 close日志框架反复打开同一个日志文件却不释放订阅上报、消息推送、WebSocket 连接断开后没有清理临时文件不断创建但从不删除。这些情况叠加后fd 数量会像内存泄漏一样持续上涨直到触顶。所以 FDE 本质是一种资源泄漏问题只是泄漏的是“文件描述符”而不是内存。排查 FDE 不能只盯着一瞬间的 fd 数值要看它的增长曲线。如果启动后 5 分钟就冲到上限和运行 3 天之后慢慢触顶根因方向完全不同。2. 先确认当前限制内核、用户、进程三层都不能少2.1 三个常用命令先看当前值和上限我接到 FDE 报警后的第一步不是翻代码而是看这个进程到底能打开多少 fd已经打开了多少。三条命令就够了# 查看当前 shell 能打开的最大文件描述符数量 ulimit -n# 查看指定进程的实际限制 cat /proc/PID/limits# 查看进程当前已打开的 fd 数量 ls /proc/PID/fd | wc -lulimit -n只是查看当前终端会话的限制很多新手在这里容易误判。如果你在 shell 里执行ulimit -n返回 1024然后 ssh 到服务器上执行返回 65535两次结果不一致是正常的因为登录会话、systemd 服务、容器 runtime 都可能设置不同的 ulimit。/proc/PID/limits里有一个Max open files那一行才是目标进程实际生效的限制。比如你看到Soft Limit是 65535Hard Limit是 65535说明进程上限就是 65535。如果当前 fd 数量已经接近这个数问题基本就锁定在 FDE。内核层还可以看两个参数# 整个系统能打开的最大 fd 数 cat /proc/sys/fs/file-max# 系统当前已分配 fd 数量 cat /proc/sys/fs/file-nr如果进程级限制还有余量但系统级 file-nr 已经逼近 file-max那说明不是单个进程的问题而是整个系统上运行的进程太多或某个容器把系统级资源吃满了。这种场景在共享 Kubernetes 节点、容器混部环境下容易出现。2.2 修改 ulimit 只解决一半问题要区分四种生效范围先不要急着改上限一定要先判断“修改哪里有效”。不同启动方式下生效位置不一样。层级配置入口生效范围内核全局/proc/sys/fs/file-max整个操作系统用户登录会话/etc/security/limits.conf通过 PAM 登录的会话systemd 服务Service 配置中的LimitNOFILE由 systemd 托管的服务容器 runtimeDocker--ulimit nofile容器内进程当前 Shellulimit -n命令当前 shell 及其子进程如果服务是直接用systemctl start启动的在/etc/security/limits.conf里调大nofile不一定生效。systemd 服务默认会用自己的限制需要单独配置[Service] LimitNOFILE1048576配置完后执行systemctl daemon-reload再重启服务然后用cat /proc/PID/limits确认。如果是容器部署Docker 的--ulimit参数会覆盖宿主机 limits.conf。docker-compose 里可以这样写services: app: ulimits: nofile: soft: 65535 hard: 65535注意在容器里执行ulimit -n时看到的通常是容器 runtime 传入的限制不是宿主机的全局限制。所以排查容器内 FDE 时先看cat /proc/1/limits再看业务进程的/proc/PID/limits。3. 定位 FDE 增长的代码路径和连接来源3.1 用 /proc 和 lsof 找到进程内真实占用的 fd确认进程触顶后下一步是看这些 fd 到底指向什么。我一般会按这个顺序操作# 看进程当前打开了多少 fd ls /proc/PID/fd | wc -l# 看这些 fd 指向什么文件或连接 ls -l /proc/PID/fd | head -50# 看每种 fd 类型的数量分布 lsof -p PID | awk {print $5} | sort | uniq -c | sort -nrls -l /proc/PID/fd里的输出如果大量是socket:[inode编号]说明 fd 被网络连接占用如果大量是/var/log/xxx.log说明日志文件句柄很多如果大量是/tmp/xxx说明临时文件没清理如果大量是pipe:[编号]或eventfd说明进程内部调度和线程通信的 fd 占了不少。这一步能快速把问题分类连接泄漏、文件泄漏、管道泄漏、还是日志句柄堆积。很多人一看到too many open files就以为是文件打开多了实际上生产环境里大部分 FDE 都是网络连接泄漏。3.2 从连接状态反推是连接泄漏还是文件句柄泄漏看到大量 socket 之后还要区分这些连接处于什么状态。使用lsof -p PID | grep TCP或者ss -tnp看端口和连接状态# 查看进程监听的端口以及连接数 ss -tnp | grep PID# 按连接状态统计 ss -tn | awk {print $1} | sort | uniq -c如果ESTABLISHED状态很多说明当前还有大量存活连接如果TIME_WAIT很多说明短连接频繁创建和释放如果CLOSE_WAIT很多说明对端已经关闭连接但本进程没有正确调用 close这是非常典型的连接泄漏。CLOSE_WAIT 堆积到一定程度会导致 fd 被半关闭的连接长期占着。这时候调大ulimit -n只是把爆发时间往后推代码不修复只是晚一点出问题。如果是执行完接口测试后 fd 数量不下降重点检查测试工具的 keep-alive 配置、HTTP client 的连接复用策略以及服务端是否正确处理了连接关闭事件。3.3 高频泄漏场景连接池、日志、临时文件、事件循环根据我遇到过的案例FDE 高发场景大概集中在四个地方第一是连接池配置错误。比如 Java 的HttpClient、Go 的http.Client、Python 的requests.Session如果没有设置maxIdleConnsPerHost或者连接池生命周期太长每次请求都新建连接fd 就会持续上涨。第二是日志系统。有的日志框架对每个日志文件单独打开一个 fd如果日志轮转时没有正确关闭旧文件就会出现“日志文件句柄堆积”。这种情况在日志切割后尤其明显fd 数量只增不减。第三是临时文件。下载临时文件、导出报表、上传分片如果finally里只删了文件路径但没有关闭 FileInputStream / FileOutputStreamfd 会一直占用直到进程退出。第四是事件循环和线程模型。在一些 C 或者 Go 服务里每来一个请求创建一个epoll fd或eventfd但在异常分支里忘记 close。这类问题看代码很难一眼发现需要结合 fd 增长时间和业务请求量做关联分析。我个人的习惯是如果能找到“fd 激增时间段”就去看这个时间段内的错误日志、报警记录、发布记录、上游流量变化这比闷头读代码效率高很多。不要一上来就改并发参数。先用ls -l /proc/PID/fd看 fd 指向哪里再决定是修连接池、修日志轮转还是修临时文件管理。4. 修复 FDE 的常用方法调大上限与代码修复要同时做4.1 按环境调大 nofile并让 systemd 和容器配置生效对于临时恢复调大限制是最直接的手段。但要注意进程启动时限制已经生效运行时修改 limits.conf 不会立刻作用到已经运行中的进程需要重启服务才能加载新配置。systemd 服务配置示例[Service] LimitNOFILE1048576修改后执行systemctl daemon-reload systemctl restart your-service cat /proc/PID/limitsDocker 启动示例docker run --ulimit nofile1048576:1048576 your-image如果是普通用户进程可以通过 PAM 方式修改echo * soft nofile 65535 /etc/security/limits.conf echo * hard nofile 65535 /etc/security/limits.conf这里有一个容易忽略的点如果服务是被 supervisord、systemd、k8s 管理的单纯改/etc/security/limits.conf不一定生效。supervisord 需要在[supervisord]段配置minfds65535k8s 需要在容器 spec 里设置resources以及对应的 ulimits 参数。修改完以后一定要用/proc/PID/limits验证不能只看配置文件。4.2 代码层怎么控制 fd 数量调整制只是“扩容”代码层修复才是“止血”。下面按语言总结常见做法。Go 语言的http.Client默认没有连接数限制但Transport会缓存空闲连接。一个最容易踩的坑是每次请求都创建新的http.Client这样连接池形同虚设。更合理的做法是复用同一个 client并显式配置 MaxIdleConns 和 MaxIdleConnsPerHosttransport : http.Transport{ MaxIdleConns: 100, MaxIdleConnsPerHost: 20, IdleConnTimeout: 90 * time.Second, } client : http.Client{Transport: transport}Java 里推荐用 try-with-resources 管理流对象确保close()一定执行try (BufferedReader reader Files.newBufferedReader(path)) { // 具体业务逻辑 }Python 里使用with语句with open(/tmp/test.log, w) as f: f.write(hello)如果是数据库连接池除了检查最大连接数还要设置空闲连接回收时间。以 MySQL 连接池为例很多连接池默认空闲连接不会主动断开长时间运行后 fd 会被空闲连接占掉必须设置连接空闲回收周期。4.3 不要只靠调大上限要先压测验证调大上限能暂时缓解但真正的验证方式是压测。我建议调完以后做一次小流量验证再逐步放大并发同时持续观察 fd 增长趋势。压测观察点有三个并发升高时fd 数量是否线性增长压测停止后fd 数量是否能回落到基线连续多次压测后fd 峰值是否逐次升高。如果压测停止后 fd 数量不回落说明泄漏点仍然存在如果峰值逐次升高说明连接池或长连接管理还有问题。这个时候不要继续调大上限要继续定位泄漏点。5. 建立 FDE 监控和预防机制5.1 用监控盯住 fd 使用率的增长趋势FDE 最好的处理方式是提前发现而不是等到报错。监控项最直接的是每个进程的 fd 使用率。如果使用 Prometheus配合 node_exporter 可以采集到process_fds_open进程当前打开的 fd 数process_fds_max进程最大 fd 数限制也可以直接用脚本采集周期建议 30 秒或 1 分钟cat /proc/$(pidof your-service)/fd | wc -l把fd_open / fd_max计算成使用率按服务维度展示。这里我建议同时记录一个额外指标“连接状态分布”。因为很多 FDE 早期只表现为 CLOSE_WAIT 或 TIME_WAIT 上涨通过连接状态可以提前发现比 fd 使用率更早暴露问题。5.2 告警阈值不能只看瞬时值告警阈值不要设置得太保守。有的服务刚启动时连接池预热就占用大量 fd瞬时使用率可能直接到 70%但这不代表马上会挂。更科学的做法是看增长速率例如使用率 15 分钟增长超过 20%触发 warning使用率已超过 80%且持续增长 30 分钟触发 critical使用率达到 95%立即告警并通知值班人员。同时务必区分“连接数峰值波动”和“fd 数值持续上涨”。如果每次大促流量上涨时 fd 涨到 70%流量回落后降到 40%这种属于正常波动。如果流量不变、fd 还在涨那才需要认真排查。5.3 发布检查和回归测试预防 FDE 的最好时机是发布之前。我建议在发布检查清单中加入以下几条本次上线是否涉及网络连接池、日志文件、临时文件管理的代码变更是否增加新的外部依赖、新的中间件客户端客户端连接是否设置超时、空闲回收服务重启后 fd 数量是否能恢复到正常水位。如果有条件在灰度环境跑一遍接口回归测试然后观察 1 到 2 小时的 fd 曲线重点看是否有持续上升。这一步不复杂但能避免很多线上故障。6. 我踩过的 FDE 坑和排查顺序总结6.1 三个容易误判的真实案例第一个案例某个 Java 服务每天凌晨 fd 数量涨到 1024应用开始大量抛 Too many open files。一开始大家以为是系统配置太低直接把 ulimit 调到 65535确实撑过了一周。结果一周后服务 fd 又涨到 65000逼着团队去查代码最后发现是一个定时任务遍历目录时每个文件都创建了 FileInputStream 但没关闭。那次事故之后我悟到一个道理调大 ulimit 只是急救不是治疗。第二个案例Nginx 偶尔在高峰期报worker_connections are not enough很多人以为是worker_connections配置不够结果线程调大后问题依旧。最后看/proc/PID/fd才发现是 access_log 做了按小时切割但切割工具重新打开文件时旧的 fd 没有完全释放。这种日志型 FDE 在白天流量上升时尤其明显因为日志写入量大日志文件切换频率高。第三个案例容器里跑了一个 Python 服务ulimit -n显示 1024怎么改limits.conf都不生效。原因很直接容器 runtime 默认 ulimits 覆盖了宿主机配置。解决方法是直接在容器编排层设置ulimits.nofile并重启容器。6.2 通用排查顺序清单如果现在线上正在发生 FDE我建议按这个顺序处理先看进程当前 fd 数量和进程限制cat /proc/PID/limitsls /proc/PID/fd | wc -l再看 fd 指向类型是 socket、普通文件、管道还是 eventfd看连接状态分布CLOSE_WAIT、ESTABLISHED、TIME_WAIT 各多少结合最近 1 小时发布记录、流量变化、报错日志找增长拐点临时调大 limit 恢复服务同时保留现场不要急着重启修复代码层泄漏点并用压测验证 fd 曲线是否回归正常。这套顺序可以在十分钟内完成初步定位避免陷入“调大上限、重启、又触顶”的循环。6.3 一个更重要的经验上限不是越大越好最后说一个容易忽略的问题ulimit 和 fd 使用率不是越大越好。每个 fd 都对应内核里的数据结构会占用内存和系统资源。如果直接把所有服务的 nofile 都调到 100 万等于给每个进程开了很大的资源配额遇到泄漏时不会立刻报错但系统级 file-max 可能提前被打满反而更难定位。更合理的做法是普通后台服务单进程设置 65535 左右高并发网关、接入层设置 1048576 以内开发测试环境可以更低比如 32768每个服务单独设置不要全局“一刀切”。FDE 这类问题处理过一次之后就会发现真正难的不是修改 ulimit而是找到为什么会把 fd 用满。资源限制只是最后一道防线代码质量、连接管理、日志轮转、临时文件清理才是长期需要维护的东西。希望这篇排查思路能帮你在下次遇到“FDE 又不够了”的时候少走几步弯路。
返回列表