服务日志出现 Too many open files 时,真正需要确认的是哪个进程的哪一种资源已经触顶。它可能来自未关闭的文件或套接字,也可能是并发量、连接池与进程上限不匹配;如果内核返回的是 ENFILE 而不是 EMFILE,排查范围还会从单进程扩展到系统级打开文件表。只执行一次 ulimit -n、看到一个数字就提高上限,既不能证明根因,也可能只是把泄漏拖到更晚、让故障一次影响更多请求。
本文给出一条可审计的处理路径:先保存原始错误与进程身份,再读取正在运行进程的实际限制和描述符构成,通过趋势与同负载对照判断泄漏、正常峰值或配置错位,最后分别在应用、容器和 systemd 层实施最小变更。文末的 V2CE 记录来自隔离 Docker 网络中的受控实验,不代表对任何真实生产服务执行过修改。
一、适用范围、症状与先给结论
本文适用于 Linux 上由普通进程、systemd 单元或 Docker Compose 容器承载的服务。典型表象包括应用无法打开日志或配置文件、接受新连接失败、访问上游或数据库时报错、健康检查间歇失败,以及日志中出现 EMFILE、errno 24 或 Too many open files。文件描述符不只代表磁盘文件,还可能代表网络套接字、管道、eventfd、inotify、设备和匿名 inode,因此“磁盘还有空间”并不能排除这类故障。
内核为每个进程维护软上限与硬上限。软上限是当前执行约束,硬上限是非特权进程可提高软上限的天花板。RLIMIT_NOFILE 的定义是“可打开的最大文件描述符编号再加一”;open、pipe、dup 等操作超过它时会得到 EMFILE。因此首先要验证当前故障进程的 Max open files,不能拿登录 shell、另一容器或配置模板中的值代替运行态。
处理结论可以概括为三句:先证明是单进程 EMFILE,再找出描述符为何增长;优先修复生命周期、池大小和无界并发;只有正常峰值确实需要更多描述符时,才按容量模型提高上限,并在服务实际启动层验证生效。
二、先分清 EMFILE、ENFILE 与相似故障
EMFILE 表示调用进程已达到自己的打开文件描述符上限;ENFILE 表示系统范围内的打开文件数量达到内核限制。两者可能都被应用包装成“打开文件失败”,但修复层级不同。保存日志时应保留原始 errno、系统调用、PID、容器 ID、时间戳和失败对象,避免只留下经过框架翻译的一句中文报错。
还要排除三个常见混淆项:ENOSPC 是空间或 inode 不足,EACCES 是访问权限不足,连接超时则通常没有立即返回 EMFILE。如果日志只写了“connect failed”,可在短时受控窗口使用应用调试日志、运行时指标或系统调用跟踪确认 errno;对繁忙生产进程执行 strace -f 可能带来开销,应先评估影响并限制时间和范围。
不要因为进程此刻仍处于 running 或健康检查还能返回 200,就否定描述符耗尽。应用可能只在某条请求路径打开新文件,也可能捕获异常、释放本次已获取的描述符后继续提供基础健康接口。反过来,进程崩溃也不能单独证明是描述符问题,还要用同一时间线上的 errno 和限制证据闭环。
三、第一现场:读取实际限制、数量与构成
先定位真实服务 PID,再直接读取 /proc/<pid>/limits。Linux 从 2.6.24 起通过该文件暴露进程资源限制;prlimit 也可读取指定 PID。下面的命令以只读方式保存基线,执行时把变量替换成经过核对的目标 PID:
pid=12345
date -Is
grep 'Max open files' "/proc/$pid/limits"
printf 'open_fds='; find "/proc/$pid/fd" -mindepth 1 -maxdepth 1 -printf '.' 2>/dev/null | wc -c
sudo lsof -nP -p "$pid" > /tmp/service-${pid}-lsof.txt
sudo prlimit --pid "$pid" --nofile
一次计数只能说明采样瞬间,不能区分稳定高水位与泄漏。应以不会压垮主机的频率持续记录“已打开数量、软上限、请求量、连接数、错误率和发布事件”,重点看描述符数量是否在流量回落后仍单调增长。读取 /proc/<pid>/fd 本身也会短暂使用资源,极端接近上限时要保留应急余量,并优先依赖已经部署的进程指标。
随后按描述符目标分类,寻找增长来源。lsof 的 TYPE、NAME 和 FD 字段可以帮助识别普通文件、TCP 连接、管道及已删除但仍打开的文件。下面是离线分析已保存快照的示例,不会修改目标进程:
awk 'NR > 1 {count[$5]++} END {for (k in count) print count[k], k}' \
/tmp/service-12345-lsof.txt | sort -nr
grep ' deleted$' /tmp/service-12345-lsof.txt | head -50
grep -E ' TCP | UDP ' /tmp/service-12345-lsof.txt | head -50
如果普通日志文件持续积累,检查异常路径是否漏掉 close;如果大量 TCP socket 集中在同一上游,核对连接池、请求超时、keep-alive 和重试;如果出现大量 (deleted) 文件,说明目录项虽已删除,空间和描述符仍可能被进程持有。必须把类型分布与业务流量对齐,不能看到 socket 多就认定泄漏,因为高并发网络服务本来就会持有大量连接。
四、隔离复现与当前证据表
V2CE 实验使用同一个 digest 固定的 node:22.20.0-alpine 镜像、同一份应用代码和同一条 80 次 open 探测请求。实验组只把 nofile 软硬上限设为 64,对照组只把两者改为 256。两者探测前的基线计数都是 24;网络设为 internal,没有发布宿主机端口,根文件系统只读。这样的对照用于回答“仅改变进程上限时,相同工作负载是否从失败变为成功”,并不模拟某个生产系统的完整流量。
| 证据项 | 实验组:nofile 64 | 对照组:nofile 256 |
|---|---|---|
| 镜像与程序 | 同一镜像 ID、同一应用 | 同一镜像 ID、同一应用 |
| 探测目标 | 打开 80 个 /dev/null |
打开 80 个 /dev/null |
| 探测前计数 | 24 | 24 |
| 探测结果 | 打开 41 个后失败,HTTP 503 | 80 个全部成功,HTTP 200 |
| 错误字段 | code=EMFILE、errno=-24、syscall=open |
错误字段为空 |
| 探测后健康 | HTTP 200、进程内 PID 1、计数回到 24 | HTTP 200、进程内 PID 1、计数回到 24 |
| 容器状态 | running / healthy、restart=0、OOM=false | running / healthy、restart=0、OOM=false |
宿主机侧状态也给出了前后对照:实验组 PID 始终为 3761560,对照组 PID 始终为 3761576;两边镜像 ID 都是 sha256:735b621e3e541de2a851625c382db48f76185f79cbaa1c94adcc8a80fd849e54,重启次数均为 0,OOMKilled 均为 false。结束后验证结果为容器 0、网络 0、卷 0,具名实验网络 absent,说明隔离资源已清理。
这里不能把“基线 24 加打开 41”当成精确的 65 个长期占用,因为枚举 /proc/self/fd 的动作本身会短暂使用描述符,健康检查也存在采样时序。可审计的结论是:64/64 限制下,相同 80 次打开工作负载稳定返回了 EMFILE;仅改为 256/256 后 80 次全部成功。实验代码在 finally 中关闭本次已打开的描述符,所以探测后恢复健康且数量回到 24;这项结果本身不证明真实业务存在泄漏。
五、根因判断:泄漏、峰值还是配置错位
“触发上限”是直接原因,但仍不是最终根因。第一类是生命周期缺陷:异常分支、超时、取消操作或热重载后没有关闭文件、socket、watcher 或管道。其特征通常是相同流量下持有量阶梯式或单调上升,业务回落和垃圾回收后仍不下降,并且某一类目标持续累积。修复点应落在资源所有权和关闭路径,而不是只提高数字。
第二类是正常峰值超过容量。比如每个请求可能同时占用客户端连接、上游连接、日志文件和内部管道;工作进程数、最大并发与连接池上限叠加后,合理峰值就可能超过旧限制。这时要根据“固定基线 + 每请求最坏占用 × 允许并发 + 运维与健康检查余量”估算需求,再用压力测试和真实高峰指标校验,而不是照抄其他机器的 65535。
第三类是启动层配置错位。管理员在交互 shell 执行了 ulimit -n,但服务由 systemd 启动;Compose 文件已经修改,运行中的旧容器却没有重建;或者应用内部连接池总量高于容器的 nofile。这类故障常表现为配置文件看似正确、/proc/1/limits 却仍是旧值。应把运行态证据作为最终准绳。
如果多个不相关进程同时报 ENFILE,才进一步核对系统级文件表、fs.file-max、fs.file-nr 与全机趋势。不要为单进程 EMFILE 直接修改全局 sysctl;也不要把 fs.file-max 当成某个服务的 RLIMIT_NOFILE,两者不是同一层限制。
六、应用与进程层修复:先控制资源生命周期
优先审查所有 acquire/open/connect 对应的 release/close/destroy,尤其是异常、取消、超时和提前返回路径。语言运行时提供 try/finally、defer、上下文管理器或带生命周期的连接池时,应让释放动作与获取动作绑定。对于日志轮转、配置热加载、文件监听和临时文件,还要验证多次重载后旧句柄确实消失。
为连接池、并发任务、队列消费者、文件扫描和 watcher 设置可解释的上限,并在接近容量时实施背压或快速失败。重试必须有次数、退避和总时限;无界重试会同时放大连接与描述符占用。应用指标至少应包含当前打开描述符、上限、使用率、按类型的连接数、池等待数和拒绝数。告警既看比例,也看增长速度和剩余绝对余量。
只有确认业务峰值合理且程序没有持续泄漏后,才考虑提高进程软硬上限。新的上限要覆盖峰值和发布、健康检查、日志等运维余量,同时受宿主机内存、系统级文件表、上游容量和每连接成本约束。提高上限不是免费的:更多 socket 和缓冲区会消耗内存,更多并发也可能把瓶颈推向数据库或第三方服务。
应急阶段如果必须暂时扩容,需同时设置时间边界、负责人和回退条件,并继续修根因。单纯定时重启进程只能清空累积状态,会制造短暂恢复的假象;如果泄漏速率不变,下一次触顶仍会发生。
七、容器与 systemd 层:让配置在真正启动点生效
Docker Compose 可在服务的 ulimits 中设置 nofile 软硬值。变更后要以受控滚动方式重新创建容器,并从新容器 PID 1 的 /proc/1/limits 复核;只重载反向代理或只在宿主机 shell 执行 ulimit 不会改掉已创建容器的运行时限制。
services:
app:
image: registry.example/app@sha256:已验证的摘要
ulimits:
nofile:
soft: 8192
hard: 8192
# 新容器启动后进行运行态核对
docker compose exec -T app grep 'Max open files' /proc/1/limits
docker inspect --format '{{json .HostConfig.Ulimits}}' app-container
示例中的 8192 只是结构演示,不是推荐生产值。实际数值必须由该服务的基线、峰值、并发模型和宿主机预算计算。还要确认编排平台是否覆盖 Compose 配置、镜像中是否再启动子进程,以及应用看到的 PID 是否就是目标进程。
systemd 服务应使用 drop-in 管理 LimitNOFILE,避免直接改发行版单元文件。保存现状后创建覆盖项,执行配置校验与 daemon-reload,再在变更窗口滚动重启。资源限制由父进程继承并跨 execve 保留,因此关键是服务管理器创建进程时的值。
systemctl show my-app.service -p LimitNOFILE -p MainPID
systemctl cat my-app.service
sudo systemctl edit my-app.service
# 在编辑器中写入:
# [Service]
# LimitNOFILE=8192
sudo systemd-analyze verify my-app.service
sudo systemctl daemon-reload
sudo systemctl restart my-app.service
pid=$(systemctl show my-app.service -p MainPID --value)
grep 'Max open files' "/proc/$pid/limits"
同样,8192 只是示例。修改硬上限还可能受到权限和内核 fs.nr_open 天花板约束。不要给服务附加不必要的 CAP_SYS_RESOURCE 来绕过规划;优先由可信启动层设置所需限制,并保留最小权限。
八、验证门禁:证明恢复而不是暂时不报错
验证必须覆盖配置、行为、趋势和副作用。先确认新进程的软硬限制与批准值一致,再用可控流量复现原故障路径。成功标准不只是一次请求返回 200,还包括描述符数量在流量回落后回到稳定区间、EMFILE/ENFILE 不再增长、连接池等待和延迟未恶化、错误率与重启次数没有异常。
pid=$(systemctl show my-app.service -p MainPID --value)
grep 'Max open files' "/proc/$pid/limits"
printf 'open_fds='; ls -1 "/proc/$pid/fd" 2>/dev/null | wc -l
systemctl is-active my-app.service
journalctl -u my-app.service --since '15 minutes ago' --no-pager \
| grep -E 'EMFILE|ENFILE|Too many open files'
生产验证应覆盖至少一个具有代表性的高峰窗口,并把发布前后数据放在同一图表。若采取了应用修复,最好在相同压力下比较“每单位请求的描述符峰值”和流量结束后的回落速度;若只调整上限,则必须证明使用率有足够余量且没有把并发压力转移给数据库、缓存或宿主机。
健康检查自身也可能需要打开 socket 或文件。上限只剩极少余量时,健康探针可能失败并触发不必要重启;但把健康检查改成永远成功会掩盖故障。更好的做法是让探针反映核心依赖、设置合理超时,并把描述符使用率作为独立可观测指标。
九、回滚、安全边界与未证明事项
变更前保存应用版本、systemd 完整单元、Compose 渲染配置、运行态 limits、描述符基线与监控截图。应用修复应保留上一版镜像或制品;配置变更应保留原软硬值。若新版本出现错误率、延迟、内存或上游连接异常,按既定滚动流程恢复上一版和原配置,并再次读取运行态限制确认回滚真正生效。
降低上限前要确认当前打开数量已安全低于目标值并留有余量。Linux 允许把软上限降到当前用量以下,但后续新建描述符会失败,因此在高峰中直接恢复较低值可能立刻重现故障。必要时先摘流、排空连接,再逐实例回滚;不要对全站同时重启。
本次隔离实验只证明 nofile=64 时同一 80-open 探测触发 EMFILE,而 nofile=256 时成功,以及应用捕获错误并关闭描述符后仍然健康。它没有证明 256 适合生产,没有证明任何现网服务存在泄漏,也没有修改宿主机限制、现网 systemd 单元、生产容器或外部网络。实验中进程未重启、未 OOM,说明这个受控样本的 503 不能归因于崩溃或内存杀死;其他应用若未处理 EMFILE,表现可能不同。
如果无法读取目标进程、缺少故障时间线,或描述符类型包含敏感路径与连接信息,应先获得相应运维权限并按数据分级保存证据。不要把完整 lsof 输出直接发布到公开工单,其中可能包含用户名、文件路径、内部地址或已删除密钥文件的名称。