线上服务突然开始报 Too many open files,接口大面积 5xx、连接被拒——这是后端工程师几乎都会撞上的经典文件描述符耗尽事故。文件描述符(file descriptor,简称 fd)是 Linux 管理一切 I/O 的句柄,socket、文件、管道都占一个。一旦耗尽,新连接和日志都建不起来。本文用一个真实排障链路,讲清如何定位、如何根治,以及怎么在它发生前就监控到。
一、先搞清楚:什么是文件描述符
在 Linux 里,进程对内核资源的每一次访问都通过一个非负整数编号,这就是 fd。它不只是”打开的文件”——每一个 TCP 连接、每一段管道、每个被打开的日志文件、甚至 stdin/stdout 都占用一个 fd。所以一个高并发的 Web 服务,光是承载客户端连接就可能吃掉上万个 fd。
fd 有三层限制:进程级 ulimit -n、用户级、系统级 fs.file-max。任一触顶都会触发 “Too many open files”。理解这三层,是排障的第一步。
二、故障现场:这些报错就是信号
文件描述符耗尽不会悄悄发生,它会以几种典型形态暴露。Java 服务常见:
java.net.SocketException: Too many open files
at java.base/sun.nio.ch.Net.socket0(Native Method)
Caused by: java.io.IOException: Cannot run program "/bin/sh":
error=24, Too many open files
Nginx 则会频繁出现 worker_connections are not enough 或 accept() failed (24: Too many open files);Go/Python 服务会抛出 OSError: [Errno 24] EMFILE。只要看到错误码 24(EMFILE)或日志里反复出现 “Too many open files”,就可以直奔 fd 方向。
三、三步定位:到底是谁在吃 fd
别急着改配置,先量化。第一步看系统整体水位,/proc/sys/fs/file-nr 三个数分别是”已分配 / 已分配未用 / 系统上限”:
# 系统级已用 / 峰值 / 上限
cat /proc/sys/fs/file-nr
# 输出示例: 52480 0 65536 —— 已用 5 万,上限 6.5 万,逼近红线
# 进程级当前打开了多少 fd
ls /proc/<PID>/fd | wc -l
# 该进程的上限
cat /proc/<PID>/limits | grep "Max open files"
第二步用 lsof 看这个进程到底打开了什么。如果大量是 IPv4 类型的 socket,说明是连接没释放;如果是 REG 普通文件,说明文件流没关:
# 按类型统计进程打开的 fd
lsof -p <PID> | awk '{print $5}' | sort | uniq -c | sort -rn
# 只看没关闭的 TCP 连接(状态 ESTABLISHED / TIME_WAIT)
lsof -p <PID> -i | grep -E "ESTABLISHED|TIME_WAIT" | wc -l
# 哪些文件/连接数量最多
lsof -p <PID> -i | awk '{print $9}' | sort | uniq -c | sort -rn | head
第三步定位到具体代码路径。配合 Linux 性能调优 里的思路,用 ss -antp 看连接分布,往往能发现某个下游地址的连接数异常高。
更进一步,/proc/<PID>/fd 目录里的每一项都是指向真实资源的符号链接。用 ls -l /proc/<PID>/fd 能直接看到每个 fd 背后是什么——是 socket、管道,还是某个被遗忘的日志文件。当 fd 数量涨到成千上万时,这一步能在一秒内告诉你泄漏来源是”连不上的下游”还是”没关的文件”,排障方向完全不同。很多新手只盯着总数看,却不去翻这些符号链接,结果在错误的方向上折腾半天。
四、四大常见根因
fd 耗尽几乎都是”该关的没关”或”上限太低”。归纳起来就四类:
| 根因 | 表现 | 典型场景 |
|---|---|---|
| 连接池未正确归还 | 数据库连接 / Redis 连接只借不还会涨 | 漏写 close、异常分支跳过归还 |
| 文件 / 流未关闭 | REG 类型 fd 持续累加 | 没用 try-with-resources |
| 大量 TIME_WAIT | 短连接高频建连后卡在 TIME_WAIT | 客户端主动关闭、没复用连接 |
| 容器默认 limit 过低 | Docker 默认 1024,瞬间打满 | 容器里跑高并发服务 |
其中最隐蔽的是”连接池配置错误”——最大连接数设得比 ulimit 还大,一旦流量上来,连接申请永远成功、却永远凑不满,fd 被慢慢吃光。这一点和 OOMKilled 内存耗尽 是同一类”资源泄漏”思路,排障逻辑可以互相借鉴。
补充一个高频但最容易被误判的情况:TIME_WAIT 堆积。TCP 主动关闭连接的一方会进入 TIME_WAIT 状态(默认保持约 60 秒),若你的服务以客户端身份高频创建短连接——例如每次请求都新建一条 HTTP 连接去调下游——本地端口会被这些”已关闭但未释放”的连接占满,进而表现为”无法新建连接”,从现象上看和 fd 耗尽几乎一样。这类问题不能靠单纯调大 fd 上限解决,而要让连接复用(开启 HTTP Keep-Alive、使用连接池),或在内核层设置 net.ipv4.tcp_tw_reuse=1 允许 TIME_WAIT 端口被快速复用。先分清是”真 fd 泄漏”还是”TIME_WAIT 假耗尽”,才能对症下药。
五、临时止血:先把服务救回来
线上先恢复再排查。临时调高进程上限(需 root 或同用户):
# 当前 shell 及子进程临时调高(重启失效)
ulimit -n 65535
# 对已在运行、卡死的进程,找出来重启
# systemd 服务在单元文件里加 LimitNOFILE 后重启
systemctl restart your-service
若是 Nginx 类反向代理,还要同步调高 worker_rlimit_nofile 和 worker_connections,否则单 worker 仍会被卡住。临时措施只能争取时间,根因不除,几小时后还会复现。
六、系统级与进程级正确配置
要让配置永久生效,分三层下手。系统级调高内核上限:
# /etc/sysctl.conf
fs.file-max = 1000000
# 生效
sysctl -p
# /etc/security/limits.conf(用户级,需重新登录生效)
* soft nofile 65535
* hard nofile 65535
# systemd 服务单元(进程级,最稳妥)
# /etc/systemd/system/your-service.service
[Service]
LimitNOFILE=65535
注意:limits.conf 只对登录会话生效,由 systemd 拉起的服务必须写 LimitNOFILE,否则前面两项都救不了它。这是最常见的”明明改了 ulimit 还是报错”的原因。
七、容器与 Kubernetes 场景
容器是 fd 事故的重灾区——Docker 默认每个容器 ulimit -n 只有 1024,跑个高并发网关几分钟就满。用 Docker Compose 或 docker run 显式放大:
# docker run
docker run --ulimit nofile=65535:65535 your-image
# docker-compose.yml
services:
app:
image: your-image
ulimits:
nofile:
soft: 65535
hard: 65535
Kubernetes 里则要同时保证宿主机 fs.file-max 足够、容器 securityContext 不设过低限制,以及 sidecar/agent 不抢 fd。一个 Pod 里多个容器共享内核,但各自 ulimit 独立,需分别确认。
八、代码层根治:让泄漏无从发生
配置是防线,代码才是源头。所有”打开即用”的资源,强制用语言提供的自动关闭机制。Java 用 try-with-resources,Python 用 with,Go 用 defer f.Close():
// Java:任何 Closeable 都放进 try-with-resources
try (FileInputStream in = new FileInputStream(path);
BufferedReader reader = new BufferedReader(new InputStreamReader(in))) {
return reader.lines().count();
} // 无论是否异常,in/reader 都会自动关闭
// 连接池必须配置最大上限 < ulimit,并设回收
// HikariCP 示例
config.setMaximumPoolSize(50); // 远小于 65535
config.setConnectionTimeout(3000);
config.setIdleTimeout(600000); // 空闲连接及时回收
对高并发网络服务,复用连接(HTTP Keep-Alive、连接池)比”每请求一连接”重要得多。一个 WebSocket 长连接 服务若每个用户都常驻一条连接,fd 消耗会随在线人数线性增长,更要把上限和容量规划一起算进去。
九、提前发现:把 fd 纳入监控
等报错再处理就晚了。把 fd 用量做成指标,超过 70% 就告警。简单脚本即可定时采集:
# 每分钟采集关键进程 fd 占用,写入监控
PID=$(pgrep -f your-service)
USED=$(ls /proc/$PID/fd 2>/dev/null | wc -l)
LIMIT=$(grep "Max open files" /proc/$PID/limits | awk '{print $4}')
echo "fd_used=$USED fd_limit=$LIMIT" | curl -X POST http://prometheus-pushgateway/metrics/job/fd
配合 Grafana 画趋势线,你能在 fd 缓慢爬升的早期就定位到是哪个版本引入的泄漏,而不是半夜被告警叫醒。
十、真实案例复盘:一次网关 fd 雪崩
某次大促前压测,一台 Nginx 网关在 QPS 涨到约 8000 后开始零星返回 502,错误日志刷满 worker_connections are not enough。第一反应是调大 worker_connections,但报错依旧。用 ls /proc/$(cat /var/run/nginx.pid)/fd | wc -l 一查,单个 worker 的 fd 已逼近 1024——而这台机器跑在容器里,Docker 默认的 ulimit -n 恰好就是 1024。根因是容器没显式放大 fd 上限,worker 配置调得再大也突破不了进程级硬限制。
止血方案是给容器加 --ulimit nofile=65535:65535 后滚动重启,压测立刻稳定。复盘时进一步发现,上游某个 Java 服务也存在连接池”只借不还会涨”的隐患,只是被更大的 ulimit 暂时掩盖了。这正说明:调大上限只是把问题推后,真正的稳定性来自代码层的资源归还与连接复用。做容量规划时,应把”单实例最大并发连接数 × 安全系数”作为 ulimit 的设定依据,而不是拍脑袋给个 65535 了事。
十一、避坑清单
收成一份可执行清单:① 出现 EMFILE/错误码 24 立刻怀疑 fd 耗尽;② 用 /proc/sys/fs/file-nr 和 lsof -p 量化并定位泄漏点;③ 临时 ulimit -n 止血,但必须查根因;④ 永久配置走 fs.file-max + limits.conf + systemd LimitNOFILE 三层;⑤ 容器务必 --ulimit nofile 显式放大;⑥ 代码层全员 try-with-resources / defer,连接池上限要小于 ulimit;⑦ fd 用量接入监控,70% 即告警。把它当成和内存、磁盘同等重要的资源来对待,这类事故就能从”突发故障”变成”例行运维”。



