☰
Linux文件描述符与进程数限制:从ulimit到cgroup的排查调优指南
2026/9/28 11:09:45 网站建设 项目流程

接手一台新服务器,我最先看的三样东西永远是:内存、磁盘、以及ulimit -n的输出。前面两个好理解,第三个很多人不重视,直到线上服务半夜报Too many open files,或者一个脚本怎么都 fork 不出新进程时,才会想起来“哦,当时要是把限制看清楚就好了”。这篇文章就围绕 Linux 下的文件描述符(File Descriptor,FD)和进程数限制展开,把这两块由浅到深讲透,包括底层机制、三层限制模型、排查思路和调优模板,适合后端开发、运维、SRE 以及对 Linux 参数感兴趣的朋友。

1. 先用大白话搞懂文件描述符:一个整数背后的三张表

1.1 fd 的本质:内核替你保管的“文件档案编号”

文件描述符在 Linux 里的本质其实就是一个非负整数,比如 0、1、2、3……,看起来轻飘飘的,但它背后挂着内核里三张互相引用的表。

  • 第一张是进程级的文件描述符表,每个进程一份,里面记录了这个进程打开了哪些文件、每个 fd 对应的文件指针位置、以及打开时的标志位(比如是否 close-on-exec)。
  • 第二张是系统级的打开文件表(open file description table),记录了文件当前的读写偏移量、访问模式(只读/只写/读写)等。
  • 第三张是inode 表,对应磁盘上真实的文件或设备。

你每次调用open()、socket()、accept()返回的整数,就是第一张表里的下标。真正操作文件时,内核通过这个下标找到第二张表里的记录,再通过第二张表找到 inode,一层层索引下去。你可以把它类比成图书馆的借书单:fd 是借书单上的编号,打开文件表是书库的登记册,inode 是书架上那本实体书。用户空间拿到的永远只是编号,摸不到实体,但又不是纯粹的数字游戏。

1.2 为什么 0、1、2 是约定俗成的?

每个进程启动时,内核会默认分配三个 fd:0 代表标准输入(stdin),1 代表标准输出(stdout),2 代表标准错误(stderr)。这不是“硬性规定”,而是 POSIX 约定,几乎所有 shell、C 库都遵循这个约定,所以你的程序才能理所当然地用printf往 1 号 fd 上写,用perror往 2 号 fd 上写。

理解了这一点,很多坑就能想明白了。比如你在 shell 里执行command > log.txt 2>&1,本质是做两次重定向:先把 1 号 fd 指向 log.txt,再把 2 号 fd 复制为 1 号 fd 的副本,于是 stdout 和 stderr 都进了同一个文件。如果写成command 2>&1 > log.txt,顺序反了,stderr 会先复制到旧的 stdout,也就是终端,然后 stdout 才重定向到文件,最终 stderr 仍然打到屏幕上。

1.3 报错信息里的门道:到底是谁不够用了?

当你看到Too many open files时,最常见的情况是进程已经达到了单进程文件描述符上限(RLIMIT_NOFILE),但也有可能系统级的fs.file-max已经用完,或者 inode 数量耗尽。同样是这个报错,根因可能完全不同。

还有一类容易被忽略的:accept()返回EMFILE时,很多新手第一反应是“连接太多了”,其实不一定。连接数只是 fd 消耗的一部分,高并发场景下每进来一个 TCP 连接就多占用一个 fd,但 fd 还可能被日志文件、socketpair、定时器 fd、eventfd 占着。如果一个服务有 10000 个正常连接,但因为日志轮转配置有问题,产生了大量未关闭的文件句柄,fd 照样会涨到上限。

1.4 怎么观察一个进程到底开了多少 fd?

最简单的入口是/proc/<pid>/fd/目录。这个目录下列出的是进程所有已打开的 fd 的符号链接,比如ls -l /proc/1234/fd会看到类似0 -> /dev/null、1 -> /var/log/app.log这样的输出。如果看到大量指向已删除文件的链接(文件名后面带(deleted)),那基本可以判定是文件没关干净,正在泄露 fd。

另一个常用命令是lsof -p <pid>,输出更友好,能看到 fd 对应的是 socket、普通文件还是字符设备。但注意lsof在部分精简容器里没有安装,想纯手工检测,用/proc/<pid>/fd配合ls -l就行。

提示:/proc是虚拟文件系统,读这些文件不产生实际磁盘 I/O,也没有权限时用sudo补一下即可。这个目录本身就是排查 fd 问题的第一现场。

2. 限制从哪来:ulimit、systemd、内核三层模型

2.1 ulimit -n 的软限制与硬限制

Linux 的 fd 限制写在进程的 rlimit 里,分软限制(soft)和硬限制(hard)。软限制是“当前生效的值”,内核会在超过它时报错;硬限制是“软限制能涨到的天花板”。普通用户可以把自己的软限制往高调到不超过硬限制,但往上调硬限制需要CAP_SYS_RESOURCE权限,也就是通常说的 root 或特权容器。

你执行ulimit -n看到的是软限制,执行ulimit -Hn看到的是硬限制。如果软硬不一样,而你的程序需要更多 fd,就要先确认硬限制够不够。很多生产环境里的 Java 服务因为启动时被 systemd 限制了硬限制,即使启动脚本里写了ulimit -n 65535也会失败,因为普通用户不能越过硬限制。

修改方式有几种:

  • ulimit -n 65535:仅对当前 shell 及其子进程生效,重新登录就失效。
  • /etc/security/limits.conf:针对登录会话生效,配置* soft nofile 65535这类规则。
  • systemd 服务单元:通过LimitNOFILE=字段指定,这只对服务生效,跟登录会话是两套体系。

从 RHEL/CentOS 7、Ubuntu 16.04 之后,主流系统的默认会话限制基本是 1024 的软限制、4096 的硬限制,或者直接 1024/1024。这个数值对日常 shell 操作够用,但如果跑数据库、消息队列这类连接密集服务,远远不够。

2.2 为什么改了 limits.conf 不生效?

这是排障时最常踩的坑。/etc/security/limits.conf只对通过 PAM 登录的会话生效,也就是你 ssh 进去之后的 shell。但如果你通过 systemd 启动服务,systemd 会在启动进程前直接设置 rlimit,根本不读limits.conf,而是读服务单元文件里的LimitNOFILE。

所以你会看到一种诡异现象:手动在终端启动应用,ulimit -n显示 65535,一切正常;但当/etc/systemd/system/myapp.service里的LimitNOFILE没设置时,服务继承的是 systemd 默认值(通常是 1024),应用一启动就报 fd 不够。排查时不要只看limits.conf,一定要看服务单元文件。

修改 systemd 服务的方式很简单:

[Service] LimitNOFILE=65535 LimitNPROC=65535

改完执行systemctl daemon-reload && systemctl restart myapp。如果想确认生效没有,可以:

cat /proc/<pid>/limits

其中Max open files那一行会显示实际的软硬限制。这是最权威的验证方式,比在各种配置文件里猜来猜去靠谱得多。

2.3 内核层:fs.file-max 与 file-nr

除了单进程限制,系统还有一个全局限制,就是内核参数fs.file-max,它代表整个系统能打开的 FD 总数。查看当前全局已打开的 fd 数量,看/proc/sys/fs/file-nr,三个数字分别代表:

  • 已分配 fd 数量
  • 未使用但已分配的 fd 数量(历史上我见过这个数字为 0 的注释,实际上它是“已分配但未使用”的存量)
  • 系统上限,也就是fs.file-max

如果你的file-nr的第一个数字持续逼近第三个数字,说明系统层面 fd 已经快耗尽了。修改方式是:

sysctl -w fs.file-max=1000000 echo 'fs.file-max=1000000' >> /etc/sysctl.conf

注意还有一个参数叫fs.nr_open,它是单个进程可分配 fd 的硬上限,默认 1048576(约 100 万)。RLIMIT_NOFILE的硬限制不能超过nr_open,否则内核会直接报EFAULT或“数值超出允许范围”。这个参数一般不用动,但如果你想把某个进程的 fd 调到几百万,得先确认nr_open够大。

三层模型可以用一张表概括:

层级关键位置作用范围常见修改方式
单进程 rlimitulimit -n/ 进程 rlimit单个进程limits.conf、systemdLimitNOFILE
全局 fd 上限/proc/sys/fs/file-max整个系统sysctl -w fs.file-max
单进程上限天花板/proc/sys/fs/nr_open单进程硬限制上限sysctl -w fs.nr_open

3. 进程数限制:nproc、线程与 cgroup 的演进

3.1 ulimit -u 与 TasksMax

进程数限制比 fd 限制更隐蔽,但同样致命。当你敲fork()却收到Resource temporarily unavailable,或者Unable to fork,大概率是进程数限制到了。

查看当前用户的进程数软硬限制用:

ulimit -u ulimit -Hu

这个限制只对普通用户生效,root 默认不受ulimit -u限制,但仍然受 cgroup 的 pids 限制。这也是为什么容器里动不动就报Cannot fork,因为容器平台往往通过 cgroup 限制了 pids.max,跟传统 ulimit 体系不直接相关。

系统级参数对应的还有内核参数kernel.threads-max,限制整个系统所有线程的数量。不过实际场景中,cgroup 的pids控制器才是最常见、最直观的限制来源。

3.2 线程也算进程?理解 Linux 的 PID/TID

很多刚从 Windows 转过来的开发者会困惑:为什么我一个 JVM 应用有几百个线程,ps -eLf | wc -l能数出几百行?

Linux 里线程和进程不是同一层概念。线程在用户态表现为同一个 PID 下的多个 TID(Thread ID),但在内核里每个线程都是一个可调度的任务(task),都有自己的 task_struct,也都占用一个 pid 编号。所以在传统的kernel.threads-max和 cgroup pids 计数器里,线程就是“进程”,一个线程算一个 pids 单位。

这就导致一个很现实的问题:一个 8GB 堆的 Java 服务,JVM 线程池 + GC 线程 + 异步日志线程加起来可能有几百个,如果再让 JVM 跑一些 sidecar 进程或多次 fork,进程数配额很容易被打满。看起来每个进程都没做错什么,整体配额却先到顶了。

3.3 cgroup 的 pids.max:容器环境下的隐藏枷锁

如果你在容器平台(Kubernetes、Docker 等都支持)里跑服务,cgroup的 pids 控制才是真正生效的那一层。它可以在cgroup路径下看到:

cat /sys/fs/cgroup/pids/pids.max cat /sys/fs/cgroup/pids/pids.current

在 Kubernetes 场景下,kubelet 默认会根据spec.containers[].resources.pidsLimit给容器设置 pids 限制,如果没显式配置,很多平台会默认给个配额。我见过最离谱的一次,某个 Java 服务在单 Pod 里 pids.max 被设成了 1024,JVM 一启动就占去几百,稍微有点线程池就立刻Cannot fork。那一次排查了一个下午,最后发现不是代码问题,而是一个没写进 YAML 的默认策略。

修改容器的 pids 限制,在 Kubernetes 里是:

resources: pidsLimit: 1000

Docker 命令行对应--pids-limit。如果是在裸机上直接改 cgroup,可以用:

echo 1000 > /sys/fs/cgroup/pids/pids.max

注意:cgroup v2 的路径通常是/sys/fs/cgroup/pids.max,不同发行版路径略有差异,不要照抄。先用find /sys/fs/cgroup -name 'pids.max' 2>/dev/null找一下最直接。

4. 一次线上“Too many open files”的完整排查复盘

4.1 现象:请求失败、日志刷错误、监控曲线异常

今年早些时候我处理过一个真实案例。某后端服务在压测时突然大量报错,日志里反复出现java.net.SocketException: Too many open files,同时进程的 CPU 飙升,但请求成功率掉到 50% 以下。第一反应当然是加 fd 上限,但加完之后发现只是把报警延后了几个小时,问题依然存在。

这就意味着:不是“上限不够”,而是“用量失控”。如果只是上限不够,调高之后应该能安稳一段时间,但很快又逼近新上限,说明有 fd 在持续增长,没被释放。

4.2 排查链路:从 lsof、ss 到 /proc/pid/fd

我的排查顺序是这样的:

# 1. 找到目标进程 ps aux | grep app.jar # 2. 查看当前 fd 统计 ls /proc/<pid>/fd | wc -l # 3. 按类型分组看 fd 分布 ls -l /proc/<pid>/fd | awk '{print $NF}' | sed 's/.*\.//' | sort | uniq -c | sort -rn # 4. 看 TCP 连接状态 ss -tanp | grep <pid>

第 3 步最关键。打开/proc/<pid>/fd后,看到的都是符号链接,<filename> (deleted)自然代表文件句柄没释放,socket:开头则代表网络连接。我那次统计出的结果是:普通 TCP 连接大概 2000 多个,但socket:总数却有 8000 多,说明存在大量已经不在正常连接列表里的 socket fd。

为了找到这些 fd 对应的对端,我用的是:

awk '/^socket:/ {print $1}' /proc/<pid>/fdinfo/*

但fdinfo里只有ino信息,需要再配合ss -tm或lsof -i才能反查到远端地址。实际操作上最便捷的还是直接抽样看几个 socket fd 对应的具体 TCP 连接:

for fd in $(ls /proc/<pid>/fd | sort -n); do if [ -L /proc/<pid>/fd/$fd ]; then target=$(readlink /proc/<pid>/fd/$fd) echo "$fd -> $target" fi done | grep socket | head -50

最后发现大量连接处于TIME_WAIT状态的残留 socket 没有真正从进程中移除,加上应用层没有正确关闭连接,fd 被反复占用。

4.3 根因:连接泄漏与 fd 泄露同时发生

继续深挖后发现,问题出在客户端的连接池配置。服务端应用关闭连接时,只是做了io.cleanUp()一类的清理,没有调用底层 socket 的 close;而客户端那边的新连接不断建立,旧的却不释放,导致 socket fd 被一次又一次地复制、遗留。再加上压测期间 QPS 很高,泄漏速率远大于释放速率,fd 数量像滚雪球一样涨。

这类问题的核心规律是:fd 持续上涨 + 不回落,无论上限调多高都只是延迟爆炸时间。如果不查根因、只调上限,唯一的结果是让系统晚一点宕机,宕机时规模更大。

4.4 修复:改代码、调参数、加监控三管齐下

修复方案分三层:

  • 代码层:确保所有 socket 和流都走 try-with-resources 或 finally close,杜绝半关不关的情况。
  • 参数层:把net.ipv4.tcp_tw_reuse和net.ipv4.tcp_fin_timeout调到合适值,让 TIME_WAIT 里的连接尽快回收。但注意,tcp_tw_reuse只对主动连接方生效,有一定副作用,配置前先理解你的服务是连接发起方还是接受方。
  • 监控层:把/proc/<pid>/fd数量、ss -s的连接状态统计接入监控,一旦 fd 在正常业务之外持续增长超过 N% 就报警。这一步是防止“复发”的核心。

那次调完之后,fd 稳定在 3000 左右,压测 QPS 再涨也不会有问题。

5. 调优不是拍脑袋:量化指标、配置模板与长期治理

5.1 给服务定 FD 数量级

很多人在limits.conf里写 65535、131072,纯粹是“看到别人这么写”。真实的 fd 需求量应该由你的业务性质决定:

  • 连接密集型服务(如网关、长连接服务):FD 数 ≈ 最大并发连接数 + 日志/内部内存映射文件数 + 线程池内部 fd。一个真实连接因为内核 socket 缓冲区、TCP 控制块等还要额外占内存,每个连接按 2~3 个 fd 估算比较安全。
  • 常规 Web 服务(如 Nginx + PHP-FPM):Nginx 单 worker 的 fd 数 ≈ 每个 worker 的连接数,PHP-FPM 则主要吃进程数限制而不是 fd。
  • Java 服务:Java NIO 会占用 fd 作为 selector 的管道,Netty 的 EventLoop 也会消耗 fd,估算时在并发连接数基础上再留 10%~20% 余量。

进程数(nproc)的设定稍微特殊一点。对 JVM 来说,进程数需求主要为:JVM 内部线程(GC 线程、编译器线程、JMX 线程等)+ 应用线程池 + 少量外部进程。保守起见,单容器内给 512 或 1024,对多数不是重度线程密集型的 Java 应用够用;但如果你跑的是 Tomcat 的 maxThreads=800 + Netty + 各种 scheduler,1024 可能立刻吃紧。

5.2 一套可复制的配置模板

以下是我在 Linux 服务器上常用的基线配置,适合大多数生产环境:

/etc/security/limits.conf:

* soft nofile 65535 * hard nofile 65535 * soft nproc 65535 * hard nproc 65535 root soft nofile 65535 root hard nofile 65535 root soft nproc unlimited root hard nproc unlimited

/etc/sysctl.conf增加:

fs.file-max = 2097152 fs.nr_open = 2097152 net.ipv4.ip_local_port_range = 1024 65000 net.ipv4.tcp_fin_timeout = 30 net.ipv4.tcp_tw_reuse = 1

如果是 systemd 服务,在 service 单元里写:

[Service] LimitNOFILE=1048575 LimitNPROC=65535

LimitNOFILE我一般不会直接给infinity。虽然 systemd 支持infinity这种写法,但它等同于直接把硬限制拉到内核允许的最大值,一旦业务代码有 fd 泄漏,服务会拖到整个系统资源的墙角才被 OOM 或自动重启拦截。给一个明确的上限,反而能让监控更早发现问题。

5.3 治理建议:把 FD 当一等公民监控

长期实践下来,我最大的体会是:文件描述符和进程数限制不能当成“出了问题再调”的参数,而应该是上线前的验收项。

每个新服务部署前,至少检查四件事:

  1. cat /proc/<pid>/limits确认软硬限制符合预期。
  2. 压测时记录 fd 峰值,按峰值 1.5 倍预留上限。
  3. 观察 24 小时内的 fd 走势,正常业务曲线应该有波峰波谷,如果是一根持续向上的直线,直接视为 fd 泄漏报警。
  4. 对容器环境,确认 cgroup 的pids.max大于进程数峰值。

另外,面试考点里也爱出“fd 耗尽会导致什么问题”“软限制和硬限制的区别”“ulimit -n 和 fs.file-max 有什么关系”之类的题。如果你在准备运维或后端面试,把上面几节内容串起来,基本能答得比讲 PPT 的人更有实操感。

提示:有不少服务端框架自带连接池和线程池监控,比如 Java 的Tomcat Connector metrics、Netty 的EventExecutorGroup指标,可以先把这些指标接到监控平台,比直接读/proc更省心。

根据我自己的习惯,每台机器装机后第一件事就是写一个启动巡检脚本,自动检查fs.file-max、ulimit -n、limits.conf、sysctl.conf的一致性,输出一张表格。这不是洁癖,而是这类问题通常在业务跑起来之前是最容易修的,一旦上了生产再去动 rlimit,既要滚动重启进程,又要小心翼翼不打断流量,成本完全是两个量级。把这套检查做成自动化,后面能省下无数个熬夜排查的夜晚。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询