1. 为什么文件描述符和进程数能卡住整个服务
做Linux运维和后台开发的朋友,大概率都经历过这种场景:服务明明还在跑,日志却开始疯狂报 Too many open files,或者新请求打进来直接超时,进程数量卡在某个数字死活上不去。第一次遇到 ulimit -n 报错的人,往往第一反应是“磁盘满了”,但 df -h 一看还剩几十个G,这时候才意识到,问题出在操作系统对进程资源的管理上。
文件描述符(File Descriptor,简称FD)和进程数限制,本质上就是操作系统给每个用户、每个进程划定的一层“资源边界”。文件描述符是Linux内核用来标识进程打开文件、套接字、管道等资源的整数句柄,进程每打开一个文件、每建立一个网络连接,都要消耗一个FD;而进程数限制则决定了一个用户或系统最多能同时运行多少个进程/线程。两者一旦触顶,轻则服务拒绝新连接,重则整个应用直接崩溃,而且排查起来往往比磁盘满、内存不足这类问题要隐蔽得多。
这篇文章想把这两块内容一次讲透。不管你是刚接触Linux的新人,还是已经写过几年代码、偶尔要上线部署的开发者,甚至是背过不少Linux面试题但没真正实操过的求职者,都能从这里找到可以直接落地的排查方法和调优手段。我会从内核层面的设计逻辑讲起,再到用户态配置、systemd场景、容器场景,最后给一份完整的“排障速查表”,把我实际踩过的坑和验证过的方案一并放出来。
先说一个个人体会:很多人把
ulimit -n当成一条调优命令,改完就以为完事了。但实际上,文件描述符和进程数限制是层层叠加的,shell、PAM、systemd、容器runtime、cgroup、内核参数每一层都可能把你的设置“吞掉”。只改一层,大概率不生效。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心概念与设计思路拆解
2.1 文件描述符到底是什么
用生活里的场景类比,文件描述符就像你去图书馆借书时拿到的一个凭证号码。每次你借一本书(打开一个文件),图书馆就给你一个号码,你还书的时候把号码交回去,号码就释放了。操作系统也一样,进程要读写文件、收发网络数据,都得先向内核申请一个FD,之后所有对这个文件的操作(读、写、关闭)都通过这个数字ID来完成。
在Linux的进程视角里,标准输入、标准输出、标准错误分别占用FD 0、1、2,之后每打开一个新的文件或socket,就会分配下一个最小的可用FD编号。每个进程能持有的FD数量,由一个叫 RLIMIT_NOFILE 的资源限制来控制,这个限制又分为软限制(soft limit)和硬限制(hard limit)。软限制是内核强制执行的“当前上限”,普通进程一旦超过就会报错;硬限制则是软限制能调高的“天花板”,只有root用户能提升硬限制。
内核里每个进程还有一个 struct files_struct 来管理FD表,系统全局能打开的FD总数则受 fs.file-max 和 fs.nr_open 两个内核参数约束。所以排查FD不足时,不光要看进程级别的 ulimit -n,还要看整个系统的剩余FD额度,链条缺一环都容易误判。
2.2 进程数限制的历史包袱与现状
进程数限制的英文术语是 RLIMIT_NPROC,它限制的是“同一个用户(real user ID)能同时拥有的进程数”。注意这里说的是“进程数”,但Linux下线程本质上是“轻量级进程”(LWP),所以这个限制同时也约束了线程数量。这也是为什么Java应用、Nginx Worker进程、容器编排系统这类吃线程的应用,经常会莫名其妙卡在进程数上。
有一个历史原因是很多面试题会问到的点:为什么进程数限制是1024或4096这种“小数字”?老派Unix设计时,系统资源是按“单个用户能跑多少任务”来估算的,当时的物理内存和CPU核心数都非常有限,1024已经是很宽裕的配额。到了今天,一台128核、512G内存的服务器上,单机跑几千个线程是家常便饭,默认限制就成了瓶颈。
从操作系统设计的角度看,给进程数设限制主要出于两个目的:一是防止单个用户fork炸弹式地耗尽系统进程表,拖垮整个系统;二是为了多用户隔离,避免某个用户把共享资源全部占掉。理解了这个设计初衷,你就知道调大限制不是“越大越好”,而是要在“防误操作”和“满足业务”之间取一个平衡。
2.3 层层叠加的限制体系
很多人以为改一个地方就完事,实际上Linux的资源限制是一套叠加体系,至少包含以下几层:
- 内核全局层:
fs.file-max、fs.nr_open、kernel.pid_max、kernel.threads-max,这是整个系统的硬天花板。 - 用户/进程层:
/etc/security/limits.conf或/etc/security/limits.d/*.conf,由PAM模块在用户登录时加载,设置nofile和nproc。 - 进程自身层:进程可以通过
setrlimit()系统调用在硬限制范围内调整自己的软限制。很多高并发服务(如Redis、Nginx)在启动时就会主动提高自身的FD限制。 - systemd层:使用systemd管理的服务,
ulimit命令和limits.conf都不一定生效,必须在service unit里配置LimitNOFILE、LimitNPROC等指令。 - 容器/cgroup层:容器runtime(如Docker)会通过cgroup限制容器内进程的资源使用,即使宿主机限制很宽,容器内部也可能有独立的FD和进程数上限。
这五层经常互相影响,我见过太多“明明改了limits.conf,ulimit -n却还是1024”的案例,根因就是session被systemd接管,或者PAM配置没生效,配置文件压根没被加载。后面我会专门用一节来讲这个。
3. 查看这些限制的实用命令
3.1 查看进程级限制
最常用的是 ulimit 命令,它其实是shell内建命令,直接操控当前shell进程的资源限制:
bash复制# 查看当前shell的文件描述符限制,-n表示file descriptor数量
ulimit -n
# 查看当前shell的最大进程数限制,-u表示user processes
ulimit -u
# 查看软限制和硬限制
ulimit -Sn # 软限制(soft)
ulimit -Hn # 硬限制(hard)
ulimit -Su # 进程数的软限制
ulimit -Hu # 进程数的硬限制
软限制和硬限制的区别很关键。默认情况下,ulimit -n 显示的是软限制,比如1024,这意味着进程最多只能打开1024个FD。如果你有root权限,可以临时往高调:
bash复制# 软限制临时提到65535,硬限制不变
ulimit -n 65535
# 同时调整软硬限制
ulimit -n 65535 -H
注意,普通用户只能调低硬限制,不能调高。这个机制是为了防止普通用户绕过系统管理员设下的安全上限。
3.2 查看进程实际占用的FD
进程级限制和进程实际占用是两回事。一个进程限制很高,但可能只用了几个FD;反过来,限制很低却跑了一个高并发服务,就会迅速触顶。查看实际占用情况:
bash复制# 查看某个进程打开了多少个FD(注意:实际数量是这个数字减1,因为第一行是标题)
ls /proc/<pid>/fd | wc -l
# 查看某个PID所有FD的明细,包括指向哪个文件或socket
ls -l /proc/<pid>/fd
# 直接查看进程的所有线程数
ps -T -p <pid> | wc -l
# 统计某个用户总共开了多少进程,比较直观
ps -u username | wc -l
/proc/<pid>/fd 是Linux procfs 虚拟文件系统里的一个目录,里面每个符号链接代表一个FD,链接指向的就是这个FD关联的文件路径或socket类型。排查“某个进程FD是不是泄漏了”,最直接的方法就是隔几分钟执行一次上面的命令,看数字是不是只涨不降。
3.3 查看系统全局限制
系统层级的参数用 sysctl 查看:
bash复制# 系统全局可分配的文件句柄总数
sysctl fs.file-max
# 单个进程可分配的最大FD数(硬上限,一般比file-max小)
sysctl fs.nr_open
# 系统范围内进程PID的最大值
sysctl kernel.pid_max
# 系统全局线程数上限
sysctl kernel.threads-max
# 当前已分配的文件句柄、已分配但未使用的句柄数、最大句柄数
cat /proc/sys/fs/file-nr
file-nr 的输出有三列:已分配FD数、未使用FD数、最大FD数。第三列就是 fs.file-max。当你看到第一列已经非常接近第三列时,说明系统级FD已经告急,这时候光调进程的 ulimit 是没用的,必须调大 fs.file-max 或排查FD泄漏源。
4. 调整这些限制的完整实操流程
4.1 临时调整:测试与当次会话生效
上面提到的 ulimit 是临时方案,只对当前shell及其子进程生效,退出shell就没了。适合快速验证“把限制调大后问题是否消失”的场景。
比如你开发时启动了一个本地服务,一直报 Too many open files,可以这样快速验证:
bash复制# 开会话级限制调大到 65535
ulimit -n 65535
# 然后启动你的服务(同一终端里执行)
./your_server
如果服务启动后正常了,说明确实是FD限制太低触顶。但别急着改配置文件收工,这只是“临时让它跑起来”,真正的永久生效配置在下一小节。
4.2 永久调整:limits.conf 配置详解
永久调整用户级限制,核心文件是 /etc/security/limits.conf,或者在 /etc/security/limits.d/ 下创建一个 .conf 文件(发行版不同,推荐用 limits.d 下的独立文件,方便管理和维护)。配置格式是:
code复制<domain> <type> <item> <value>
domain:用户名、组名(用@前缀)、*(通配所有用户)。type:soft、hard,或-表示同时设置软硬限制。item:nofile(FD数)、nproc(进程数)、core(core文件大小)、stack、memlock等。value:具体的数值或unlimited。
实际配置示例:
bash复制# /etc/security/limits.d/90-app.conf
# 对app用户设置FD和进程数限制
app soft nofile 65535
app hard nofile 131072
app soft nproc 65535
app hard nproc 131072
# 对所有用户设置FD软限制为4096
* soft nofile 4096
配置完以后需要重新登录会话,或者重启对应的服务进程才能生效,修改不会自动作用到已登录的shell。一个常见的坑是:用 su - app 切用户之前,必须在登录前把limits.conf配好,否则切过去之后限制可能不生效。PAM是在登录认证阶段读取配置的,su - 不重新读取PAM session限制(取决于PAM配置),得用完整的登录流程(如ssh登录)或重启sshd。
注意:
nproc在旧版本内核和较新的Linux发行版上行为有些微妙差异。有些系统上,*通配的nproc限制不会作用于root用户,但作用于普通用户时,会在limits.conf的nproc和 systemd 的UserTasksMax等参数之间取最小值。如果设了很大的nproc还是不生效,建议检查cgroup的pids.max。
4.3 systemd服务单元怎么配置
当服务由systemd管理时(现在几乎全是),ulimit 命令改的是shell进程,不会影响systemd启动的服务。正确的做法是在service unit文件里设置:
ini复制# /etc/systemd/system/myapp.service
[Service]
User=app
# 设置FD数限制
LimitNOFILE=65535
# 限制进程数
LimitNPROC=65535
# 如果有需要还可以限制打开文件大小等
LimitFSIZE=infinity
修改完unit文件后执行:
bash复制systemctl daemon-reload
systemctl restart myapp
验证是否生效,用:
bash复制# 查看服务进程的最终限制
cat /proc/<pid>/limits
这个文件会列出进程所有的RLIMIT值,包括软硬限制。这是排查systemd服务限制是否生效最可靠的手段。
我曾经遇到过一种情况:明明在service里写了 LimitNOFILE=65535,查看 /proc/<pid>/limits 也显示正确,但程序起来之后跑到一定量级的连接数依然开始报错。最后发现是这个程序自己又调用了 setrlimit(),主动把限制降回去了。很多高并发服务框架(比如一些RPC框架、Go标准库默认行为)会在启动阶段自行设置RLIMIT,代码里写死了一个值,这时候必须去看程序源码或文档,确认它是否自己改了限制。
4.4 内核参数调整:真正的天花板
用户级限制调的再高,也突破不了系统级参数。比如 fs.file-max 是10万,你给用户配 nofile 100万,最终进程最多也只能开到10万。
修改内核参数有两种方式:
bash复制# 临时生效
sysctl -w fs.file-max=2000000
sysctl -w kernel.pid_max=4194304
# 永久生效:写入 /etc/sysctl.conf 或 /etc/sysctl.d/xx.conf
echo "fs.file-max=2000000" >> /etc/sysctl.d/99-filelimit.conf
echo "kernel.pid_max=4194304" >> /etc/sysctl.d/99-filelimit.conf
# 然后让配置生效(会输出当前实际生效值)
sysctl -p /etc/sysctl.d/99-filelimit.conf
kernel.pid_max 控制的是PID编号的上限。Linux的PID默认最大值是32768,也就是说系统同时最多存在32768个进程(PID会循环使用,所以不是严格限制)。高并发容器场景下,32768很容易触顶,调大到4194304甚至更大是常见操作。注意PID最大值不是越大越好,过大的pid_max会增加内核遍历任务的性能开销,推荐设一个业务峰值进程数两倍左右的值。
再看 fs.nr_open,它限制的是单个进程FD的绝对上限,默认值是1048576(即1024*1024)。如果你想把某个进程的 nofile 调到超过这个值,必须先调大 fs.nr_open,否则即使软硬限制都设了,打开FD时还是会被拒。
4.5 容器与cgroup场景的特殊考量
Docker容器内看到的限制和宿主机不完全一致,因为容器runtime会通过cgroup为容器设置独立的资源隔离。这里有两个关键点:
第一,运行容器时可以用 --ulimit 参数直接传入限制:
bash复制docker run --ulimit nofile=65535:65535 --ulimit nproc=65535:65535 myimage
或者在 docker-compose.yml 里配置:
yaml复制services:
app:
image: myimage
ulimits:
nofile:
soft: 65535
hard: 65535
nproc:
soft: 65535
hard: 65535
第二,cgroup v2 引入了 pids.max 限制。如果使用cgroup v2,容器内每个进程组能创建的进程/线程数量受 /sys/fs/cgroup/<path>/pids.max 控制。即使宿主机的 ulimit -u 很大,如果cgroup的pids控制器设置了上限,进程数照样卡住。检查cgroup pids限制的方式:
bash复制cat /sys/fs/cgroup/pids/pids.max
cat /sys/fs/cgroup/pids/pids.current
对Kubernetes场景,还可以通过Pod的 spec.containers[].resources.limits 间接影响进程数吗?实际上不会直接限制进程数,但节点级的 pids.max 会限制Pod内进程总数,kubelet默认给每个Pod设置的pids上限是 --pod-max-pids 参数控制的(默认-1表示不限制),但节点级仍然受系统限制。
5. 典型坑位与排查实录
5.1 ulimit -n 改了不生效的几种原因
这是最经典的问题,也是我帮人排查最多的一个点。总结下来大概有四类原因:
第一类是 同一个会话里改了以后没重开。ulimit命令只对当前shell进程生效,但如果你是在一个已登录很久的会话里改的,影响范围只限于这个shell。新开的终端如果是从sshd或者systemd-logind重新创建的会话,才会重新读取PAM配置。
第二类是 PAM模块根本没启用。 limits.conf 是由 pam_limits.so 模块读取的,如果 /etc/pam.d/common-session 或 /etc/pam.d/system-auth 里没有下面这行,配置了就等于没配:
bash复制session required pam_limits.so
有些精简版容器镜像或最小化系统,这个模块没有被启用,这是“配置了limits.conf不生效”的头号原因。
第三类是 systemd用户会话与limits.conf脱钩。在宿主机上通过systemd启动服务时,服务进程不经过PAM登录流程,所以 limits.conf 完全管不到它。要用 LimitNOFILE 来控制,或者也可以把服务改为 PAMName=login 配合 pam_limits.so 加载,但一般没人这么做,直接写Limit更干净。
第四类是 被其他配置文件覆盖。 limits.conf 和 /etc/security/limits.d/*.conf 同时存在时,limits.d 下的配置优先。如果发行版自带了 limits.d/xx-nproc.conf,它里面可能限制了某个用户组的nproc,你的自定义配置就被覆盖了。
5.2 高并发服务 FD 触顶的表现与定位
不同服务FD耗尽的表现差异很大:
- Nginx 会报
worker_connections are not enough或too many open files - Java 应用会抛
java.io.IOException: Too many open files - Redis 会报
Can't accept a client: too many open files - MySQL 会报
Too many open files
先确认当前FD使用量:
bash复制# 找出打开FD数量最高的Top 10进程
for pid in $(ls /proc | grep -E '^[0-9]+$'); do
count=$(ls /proc/$pid/fd 2>/dev/null | wc -l)
echo "$count $pid $(cat /proc/$pid/comm 2>/dev/null)"
done | sort -rn | head -10
这条命令在生产环境也能跑,注意别太频繁运行,因为遍历 /proc 会带来轻微的系统开销。
定位到进程后,进一步看这进程到底在疯狂开什么:
bash复制ls -l /proc/<pid>/fd | grep socket
如果看到大量socket,说明是网络连接泄漏或连接池配置过小。如果看到大量指向删除文件的fd,说明有进程一直在写日志但日志被logrotate滚动删除了,FD没有释放。如果看到大量管道(pipe),则可能是进程间通信逻辑有问题。这一步能帮你区分“限制太小”和“程序泄漏”,是两种完全不同的处理策略。
5.3 进程数触顶的现象与Log报错
进程数触顶时的现象往往更隐蔽。常见表现是: fork 系统调用报错 Cannot allocate memory,但 free 一看内存明明没满。这是经典的“进程数限制/线程数限制”被击穿,而不是内存耗尽。
对Java应用来说,Java线程是映射到操作系统线程的,一个线程就是一个进程实体,所以 nproc 限制直接决定了一个JVM进程能创建的最大线程数。当JVM尝试创建新线程但超过了用户进程数上限时,就会抛 java.lang.OutOfMemoryError: unable to create new native thread,误导性极强,让人以为是堆内存不够。
另一个特殊情况是:某些云服务器厂商的镜像会在 /etc/security/limits.d/ 里预置了一个 20-nproc.conf,比如把 nproc 限制设成 4096,很多不明所以的人怎么改都突破不了4096,查了半天才发现在 limits.d 里躺着一个“看不见的配置”。
5.4 动态修改限制与核对技巧
如果你在一个运行中的服务上改限制,不想重启进程,可以用 prlimit 命令动态调整:
bash复制# 查看某进程当前资源限制
prlimit --pid <pid>
# 动态修改进程的FD软硬限制
prlimit --pid <pid> --nofile=65535:65535
# 动态修改进程数限制
prlimit --pid <pid> --nproc=65535:65535
prlimit 是util-linux包提供的工具,比直接操作 /proc/<pid>/limits 更安全(虽然 echo 写limits文件理论上也能改,但容易改坏,不建议)。不过要注意,进程如果自己调用了 setrlimit 又降回去,那你动态改完也可能被覆盖,改完后再用 cat /proc/<pid>/limits 确认一下,这个习惯非常重要。
5.5 常见问题速查表
| 症状 | 可能原因 | 排查命令 | 解决方向 |
|---|---|---|---|
| 服务报 Too many open files | 进程nofile限制太小 | ulimit -n / cat /proc/<pid>/limits |
调大nofile,检查程序FD泄漏 |
| 改了limits.conf不生效 | PAM模块未启用或systemd绕过 | 检查/etc/pam.d/common-session |
启用pam_limits.so,或改用LimitNOFILE |
| systemd服务限制不生效 | 服务单元内未配置Limit | cat /proc/<pid>/limits |
添加LimitNOFILE/LimitNPROC,daemon-reload |
| fork: Cannot allocate memory 但内存充足 | nproc或cgroup pids限制触顶 | ulimit -u / cat /sys/fs/cgroup/pids/pids.max |
调大nproc或pids.max |
| JVM报 unable to create native thread | 线程数达到进程数上限 | ps -T -p <pid> | wc -l |
调大nproc、减少线程池大小 |
| 系统级FD耗尽 | fs.file-max过低或FD泄漏 | cat /proc/sys/fs/file-nr |
调大fs.file-max,定位泄漏源 |
| 单进程FD无法超过1048576 | fs.nr_open限制 | sysctl fs.nr_open |
调大fs.nr_open |
| 新用户登录后限制不对 | limits.d中与通配规则冲突 | cat /etc/security/limits.d/*.conf |
调整规则优先级或删除冲突配置 |
6. 调优经验:不同场景下的合理数值建议
6.1 常规开发机/虚拟机
对于个人开发机、虚拟机,默认值1024的FD限制其实常常够用,不够用了再往65535调没有压力。进程数限制建议别动,保持系统默认即可,因为开发机上跑进程数量级通常是几百到几千,触发不到限制。如果开发过程中遇到了 Too many open files,优先怀疑代码里有没有句柄泄漏。
6.2 生产Web应用/Nginx
Nginx作为单机高并发入口,官方建议将 worker_rlimit_nofile 设置为和 worker_connections * 2 同数量级。假如你配置了每个worker进程支持10000个连接,那么FD数至少要有 10000 * 2 = 20000(因为每个连接通常占用读和写两个FD,或者再加上缓存、日志等开销)。实际生产中,单机Nginx配 65535 的 nofile 是很常见的起步值,配合 worker_rlimit_nofile 65535 配置,再加上系统级 fs.file-max 同步调大,才能支撑几十万并发连接。
Nginx在master进程启动时,会读取 worker_rlimit_nofile 并调用setrlimit把worker进程的限制提高,所以这个配置经常能“绕过”systemd的限制,但作为一种兜底习惯,建议还是要同时设置service单元的 LimitNOFILE,防止Nginx主进程打开新配置时失败。
6.3 数据库/消息队列中间件
数据库和消息队列这类长连接应用,FD消耗主要来自客户端连接数量。以MySQL为例,单条连接至少占用一个FD,如果有连接池缓存,实际占用的FD会等于或略大于最大连接数。给这类服务的建议是:nofile 至少要设为“预估最大连接数 + 128 + 系统文件数”,否则连接池一打满,连本地日志都写不了。
进程数方面,数据库通常不会在单个进程内起大量线程,但如果你用的是线程池模型(如一些Java实现的中间件),就要按“每个连接一个线程”的老模型来算——虽然现代框架都是IO多路复用,线程数和连接数不再一一对应,但如果代码写得不讲究,线程数照样会失控。这种情况下,nproc建议设成 物理核数 * 100 或者一个保守的65535,具体看业务线程模型。
6.4 容器平台/高密度部署
容器场景的进程数和FD限制,核心是在“系统资源够用”和“单容器不拖垮宿主机”之间做取舍。我的建议是:
- 每个容器的
nofile按业务正常峰值再乘1.5倍的余量来设。 nproc不要盲目给unlimited,否则一个出bug的容器疯狂fork,会拖死整台宿主机。建议给容器设置一个明确的上限,比如4096或8192。- cgroup的
pids.max一定要关注,K8s环境要检查kubelet的--pod-max-pids参数和节点的默认cgroup设定。 - 宿主机层面
fs.file-max调大后,要配合sysctl fs.nr_open一起调,因为如果nr_open小于某个容器要求的nofile值,容器内进程依然会被卡住。
7. 最后再分享一点实际心得
做Linux资源限制相关的排障,最大的一个感触是:永远不要只盯着一层看。文件描述符和进程数限制,本质是一个从内核到systemd、再到PAM、再到cgroup的层层嵌套体系,每一层都有自己的默认值和保护机制。你在某一层把限制改得再大,只要上一层还有一堵墙,最终效果都是零。
我自己在排查一个线上Java应用线程数触顶问题时,花了整整半天时间,先改limits.conf,无效;又加了systemd的LimitNPROC,还是无效;最后心一横把 kernel.pid_max 调大,才注意到 pids.max 这个cgroup参数。那一刻才真正意识到,Linux资源限制不是一条命令能解决的,而是需要对整个体系有清晰的认知。
另一个非常实用的习惯是:每次修改完限制,一定要用 cat /proc/<实际pid>/limits 来验证,而且要在问题进程的PID上验证,而不是在shell里 ulimit -n 看。因为shell的进程和服务进程可能走的是完全不同的路径,shell里显示的和实际进程的数值不一致是常有的事。实践出真知,这行当里“看着有效”和“真的有效”之间,差的就是这一步验证的耐心。
如果你也想给自己办一次“资源限制大扫除”,可以按这份清单走一遍:先跑 ulimit -a 看看当前shell各项限制,再跑 cat /proc/sys/fs/file-nr 看系统级剩余FD,接着逐个检查重要服务的 /proc/<pid>/limits,最后对照速查表判断当前业务是否逼近了天花板。这套动作下来,绝大多数隐性隐患都能提前暴露。
