凌晨两点多,某个跑着业务服务的机器开始大量报Too many open files,业务侧只是零星超时,日志里全是打不开配置文件、建不了连接的错误。值班的人第一反应基本都是同一句:ulimit -n调过了啊,怎么还报这个。我这些年见过的类似故障,十个里面有七八个不是"没调",而是"调在了不该调的地方",或者"调了但被上层覆盖掉了"。ulimit这个东西表面上就几个字母,真正的坑全藏在配置的加载路径和生效边界里。
这篇就聊两个最典型、也最容易反复踩的坑:一是登录用户的 limits 配置为什么看起来生效了、实际没落到你想要的进程上;二是常驻服务进程为什么完全不认/etc/security/limits.conf。把这两条捋清楚,剩下的什么nr_open、容器继承、应用层二次限制,都是顺着链条往下推的自然结果。内容偏运维和排障,需要你手上有 root 权限、能重启服务,最好还能临时起一个测试服务来验证。刚接触 Linux 的同学也能看,我会把每条命令在干什么讲清楚。
1. 第一个坑:limits.conf 改完,重新登录回来还是 1024
1.1 先分清三条命令看到的三个数字
很多人排查的第一步就跑偏了,因为ulimit相关的命令有好几个口径,看到哪个数就以为是哪个数。先把这三个彻底分开:
ulimit -Sn:当前 shell 的软限制,进程实际能用的上限就是这个值,超过就报Too many open files。ulimit -Hn:当前 shell 的硬限制,是软限制的天花板。非 root 用户只能把软限制往上顶到硬限制,不能超过硬限制。cat /proc/<pid>/limits:某个具体进程实际拿到的软硬限制。这个才是唯一有意义的最终答案。
举个常见的场景:你在 shell 里敲ulimit -n,返回 1024;打开/etc/security/limits.conf一看,明明写着* soft nofile 65536。这时候别急着怀疑文件格式,先在同一个 shell 里跑一遍:
ulimit -Sn ulimit -Hn cat /proc/$$/limits | grep -i "open files"$$是当前 shell 的 PID。如果/proc/$$/limits里Max open files的软限制仍然是 1024,那说明当前这个会话根本没有加载到你写的那行配置,而不是配置写错了值。这两个是完全不同的问题,处理方向也完全不同。
还有一种更迷惑的情况:在 shell 里手动执行ulimit -n 65536是成功的,但退出重登又回到 1024。这说明硬限制本身够大,只是配置没有被会话读取。如果手动执行就报cannot modify limit: Operation not permitted,那是硬限制本身卡住了,属于第三章要讲的层级问题。
提示:判断"配置有没有被读取"和"配置值是否正确",永远先看
/proc/<pid>/limits,不要看ulimit -n。前者是既成事实,后者只是当前 shell 的视图,而且在sudo、su、ssh之间会跳变。
1.2 通配符*和 root 之间那道隐形的墙
这是第一个坑里最经典的翻车点。limits.conf的格式是四列:<域> <类型> <条目> <值>,域可以写用户名、组名(@group)、通配符*。很多人图省事,只写一行:
* soft nofile 65536 * hard nofile 65536然后拿 root 去验证,发现 root 的ulimit -n还是 1024,于是判定"配置没生效",转头去折腾 PAM,折腾半天。
问题在于,*这个通配符在多数发行版的pam_limits实现里并不会自动套到 root 头上。root 需要单独写一行显式配置。这个设计初衷是防止一条通配规则把 root 的限制也放宽,但从排障角度看,它制造了大量误判。正确的写法是把 root 显式列出来:
* soft nofile 65536 * hard nofile 65536 root soft nofile 65536 root hard nofile 65536除了 root 之外,还有两类用户容易被漏掉:一类是服务专用的系统账号(比如应用自己创建的运行用户),它们不走登录流程,用su -切过去测才有意义;另一类是被nologin标记的账号,它们连交互会话都没有,只能靠 systemd 那边的配置,属于第二个坑的范畴。
顺带说一个隐蔽的坑:limits.d目录里的文件是按文件名排序后依次读取的,同域同条目的配置,后读到的会覆盖先读到的。某些发行版自带一个20-nproc.conf之类的文件,里面写着* soft nproc 4096。你如果在limits.conf里写了nproc,可能被它盖掉;反过来你新建一个99-xxx.conf,就能排在后面。文件名前缀用数字不是装饰,是顺序控制。
1.3 pam_limits 到底是在什么时候读文件的
/etc/security/limits.conf本身不是内核配置文件,它不会被内核自动读取,真正读它的是pam_limits.so这个 PAM 模块。也就是说,只有走过 PAM 会话建立流程的登录方式,才会应用这份配置。
链路大致是这样:用户通过某种方式登录(login、sshd、su、sudo -i等),对应的 PAM 服务文件(比如/etc/pam.d/sshd、/etc/pam.d/login、/etc/pam.d/su)里必须有一行:
session required pam_limits.so而且对于远程登录,sshd的配置里必须有UsePAM yes。如果这一项被关掉,sshd就不走 PAM 会话,limits.conf里的配置对通过 SSH 登录的会话完全无效,你会发现无论怎么改都没反应。
验证方式很直接,改完之后用同一账号重新建立会话,然后看/proc/$$/limits。注意是重新建立会话,不是source /etc/profile,也不是在当前 shell 里执行bash。pam_limits是在会话建立那一刻一次性写入进程限制的,后续任何 shell 层的操作都改变不了它,只能通过ulimit命令在当前 shell 里临时改。
这里还能延伸出一个常见误区:把ulimit -n 65536写进/etc/profile或者/etc/bashrc。这种做法只在交互式登录 shell 里生效,而且是在 PAM 已经设完之后再覆盖一次,对非登录 shell、对ssh host cmd这种非交互执行、对服务进程统统无效。它能"看起来管用",纯粹是因为你恰好是在登录 shell 里敲的ulimit -n。
1.4 两分钟定位:现象与命令的对照表
把上面的信息压缩成一张表,遇到"改了不生效"先按这张表走一遍,基本能省掉大量试错时间。
| 现象 | 优先检查的命令 | 大概率结论 |
|---|---|---|
重登后ulimit -Sn仍是 1024 | cat /proc/$$/limits | 会话没加载 PAM limits |
| root 不生效、普通用户生效 | grep -v '^#' /etc/security/limits.d/*.conf | *没覆盖 root,需显式写 |
手动ulimit -n成功但重登失效 | grep pam_limits /etc/pam.d/* | 配置文件没被 PAM 读取 |
手动ulimit -n直接报错 | sysctl fs.nr_open、ulimit -Hn | 撞到硬限制或内核上限 |
| 某用户生效某用户不生效 | id <user>、su - <user>后复测 | 用户域配置或组配置缺失 |
这里要强调一句:排查顺序一定是"先确认目标进程实际拿到多少,再倒推是哪一环没生效"。反过来做,就会陷入不停地改文件、不停地重登、不停地怀疑人生的循环。
2. 第二个坑:服务进程根本不认识 limits.conf
2.1 systemd 的启动路径绕开了 PAM
第一个坑解决之后,很多人的下一步是把所有用户的软硬限制都调到 65536,然后重启业务服务,发现服务进程的Max open files还是 1024。这时候就开始怀疑人生了:用户明明配了,为什么进程拿不到。
原因很干脆:由 systemd 启动的服务,不走 PAM 会话流程。systemd 是 PID 1,它fork+exec服务进程的时候,直接按 unit 里定义的资源限制来设置,中间没有login、没有sshd、也没有pam_limits.so参与。所以/etc/security/limits.conf对这些服务来说就是一张废纸,写得再漂亮也没人读。
这个坑之所以难缠,是因为它表现得"像生效了又像没生效":你用同一个账号su -过去手动跑一遍程序,限制是对的;一交给 systemd 拉起,限制就回去了。差别就在于前者走了 PAM,后者没走。
要验证这一点很简单,随便找一个 systemd 管理的服务:
systemctl show <服务名> -p LimitNOFILE输出大概是LimitNOFILE=1024:524288这种形式,冒号前面是软限制,后面是硬限制。这个值是从 unit 文件、drop-in 覆盖文件、以及 systemd 全局默认值里合并出来的,跟limits.conf一点关系都没有。
2.2 LimitNOFILE 的三层覆盖与优先级
systemd 环境下调整句柄数,有三个可以下手的地方,优先级从低到高:
- 全局默认值:
/etc/systemd/system.conf里的DefaultLimitNOFILE=。改这个影响所有没显式声明限制的服务,属于"一刀切"的做法,方便但副作用大。 - unit 文件里的
LimitNOFILE=:写在[Service]段里。注意很多发行版自带的 unit 文件里本来就有这一行,值可能是个偏小的数,这种情况下你在别处怎么调都会被它压住。 - drop-in 覆盖文件:
/etc/systemd/system/<服务名>.service.d/*.conf。这是最推荐的方式,不改动发行版自带的 unit 文件,升级时也不会被覆盖。
用systemctl edit <服务名>会自动帮你创建 drop-in 文件并打开编辑器,写入内容:
[Service] LimitNOFILE=65536这里有个细节值得说清楚:LimitNOFILE只写一个值时,systemd 会把软限制和硬限制都设成这个值。如果你想软硬不同,得写成LimitNOFILE=65536:1048576这种冒号分隔的形式。想用系统最大值,可以写LimitNOFILE=infinity,较新版本的 systemd 会把它解析成内核的fs.nr_open值。
改完之后必须做两件事,少一件都白干:
systemctl daemon-reload systemctl restart <服务名>daemon-reload是让 systemd 重新读 unit 和 drop-in 配置,restart是让新配置真正落到进程上。只做前者,进程还是老样子;只做后者,systemd 用的还是老配置。很多人卡在这一步,改完看一眼没变化就放弃了。
另外还有一种情况:有些服务的启动脚本是Type=forking,主进程是脚本fork出来的子进程,而脚本自己又调了ulimit -n。这时候进程最终的限制取决于脚本里那次ulimit和 systemd 设置的硬限制谁更小,排查时得把启动脚本也翻一遍。
2.3 改完没生效的三种典型情况
我在实际排障里遇到过三类"明明配了却没落地"的情况,列出来供对照。
第一类是改错了 unit 名。同一台机器上可能有xxx.service和xxx@.service这种模板实例,你要改的是实际在跑的那个实例名。用systemctl status <名字>确认一下,别对着模板文件改了半天,跑的是另一个。
第二类是被更高优先级的 drop-in 覆盖。drop-in 目录里的文件是按文件名排序加载的,后面的覆盖前面的。如果你新建了10-override.conf,而系统里已经有一个50-vendor.conf也设了LimitNOFILE,那你的配置会被后者盖掉。文件名前缀用大一点的数字,比如99-,比较稳妥。
第三类是服务自己的配置又压了一层。这一点在第三章和第四章会展开,简单说就是应用本身还有一份句柄数配置,比如 Web 服务器、数据库、缓存服务都有自己的参数。systemd 给的 65536 只是"允许你用这么多",应用层如果只肯用 1024,那就是应用层的问题了。
2.4 从 /proc 反推配置是否真的落地
不管中间经过多少层,最终答案只有一个地方能看:进程的/proc/<pid>/limits。
pid=$(systemctl show <服务名> -p MainPID --value) grep -i "open files" /proc/$pid/limits输出里Max open files那一行的两个数字,就是软限制和硬限制。如果这里还是 1024,那前面的配置一定没生效,别再看别的了。如果这里已经是 65536,但应用还在报Too many open files,说明问题不在系统层,往下走。
顺便提一个观测手段:ls /proc/<pid>/fd | wc -l能数出当前进程打开了多少个文件描述符。这个数在接近软限制的时候就要开始关注了,别等到报错。需要注意的是这个目录的读取需要相应权限,普通用户看别人进程的 fd 目录可能会被拒绝。
3. 天花板在哪:nr_open、file-max 与硬限制的层级关系
3.1 从 ulimit 到内核的四层上限
很多人以为ulimit -n想设多大就设多大,实际上它上面还压着好几层。从上往下捋,大致是这么个结构:
- 第一层,进程级软限制:就是
ulimit -Sn,进程实际可用的句柄数。 - 第二层,进程级硬限制:
ulimit -Hn,软限制不能超过它。非 root 用户只能降不能升。 - 第三层,内核单进程上限:
fs.nr_open,这是内核允许单个进程设置的硬限制最大值,默认通常是 1048576。任何进程的硬限制都不可能超过它。 - 第四层,系统级总量上限:
fs.file-max,整台机器所有进程加起来能打开的文件总数。这一层通常很大,一般不会成为瓶颈,但在连接数极高的机器上值得看一眼。
对应的查看命令:
ulimit -Sn ulimit -Hn sysctl fs.nr_open sysctl fs.file-max cat /proc/sys/fs/file-nr最后那个file-nr会输出三个数,第一个是当前已分配的文件句柄数,第三个是系统上限。日常巡检的时候瞄一眼第一个数,能大致判断机器整体压力。
理解了这四层,很多事情就说得通了。比如你在limits.conf里写了nofile 2000000,超过了fs.nr_open的 1048576,那这条配置要么被静默截断,要么在设置时报错,取决于具体场景。稳妥的做法是把目标值控制在fs.nr_open以内,很多生产环境会反过来先把fs.nr_open调大,再设置进程限制,顺序不能反。
3.2 cannot modify limit 的两种报错来源
ulimit -n 65536报cannot modify limit: Operation not permitted,这个错误至少有两种完全不同的成因,处理方式也不一样。
第一种是当前用户不是 root,且目标值超过了硬限制。普通用户只能把软限制往下调,或者调到不超过硬限制的位置。想突破硬限制,要么让 root 先把硬限制调上去,要么在limits.conf里把 hard 值配好再重新登录。
第二种是目标值超过了fs.nr_open。即便你是 root,硬限制也不能突破内核这一层。这时候需要先改内核参数:
sysctl -w fs.nr_open=2097152要持久化的话写进/etc/sysctl.d/下的配置文件。但要注意,nr_open调大之后,还得确认fs.file-max也够大,否则只是把单进程的天花板抬高了,系统总量还是卡着。
还有一种不太常见但会遇到的:某些受限环境(比如被 seccomp 或者安全策略约束的进程)里,setrlimit调用会直接被拒绝,报错信息可能更含糊。这种就不属于配置问题了,得看运行环境本身的约束。
注意:调
fs.nr_open属于内核参数变更,影响范围是整机。在共享机器上动手之前,先确认没有别的业务依赖原来的值,改完观察一段时间内存和句柄使用情况。
3.3 容器里被截断的继承链
容器环境下这个链条会多出一截,也更容易出问题。容器内进程的限制,主要来自三个地方:容器运行时启动时给 init 进程设的限制、容器内 systemd 或者启动脚本的设置、以及应用自己的配置。
以常见的容器运行时为例,容器默认会继承宿主机上运行时守护进程的限制。如果守护进程本身是由 systemd 拉起的,那它的限制又来自DefaultLimitNOFILE。这就形成了一条很长的继承链:systemd 默认值 → 运行时守护进程 → 容器 init → 应用进程。链上任何一环偏小,最后都会体现在应用上。
显式指定的方式是在启动参数里写:
--ulimit nofile=65536:65536进容器之后,第一件事还是看/proc/1/limits,确认 init 进程拿到的到底是多少。有些环境里容器内还会跑一个精简的 init 或者启动脚本,脚本自己又调了一次ulimit,那就得连脚本一起看。
还有一个容易忽略的点:容器内如果跑了完整的 systemd,那么容器内的limits.conf和DefaultLimitNOFILE都会起作用,跟宿主机是两套独立的配置。排查的时候一定要先确认自己登进去的是哪一层,别拿宿主机的配置去解释容器内的现象。
4. 应用层还有一道门:应用自己也会压一遍句柄数
4.1 几类常见服务各自的句柄口径
系统层调到 65536 之后,还有一层经常被忽略:应用自己也有句柄相关配置,而且它们的取值逻辑各不相同。
Web 服务器类通常会有一个"每个工作进程能开多少句柄"的参数,以及一个"每个进程允许多少并发连接"的参数。这两个是乘法关系,前者必须大于后者的两倍左右(因为每个连接在服务端和客户端方向各占一个句柄,加上监听套接字和日志文件)。如果只调了连接数没调句柄数,进程跑到一半就会开始拒绝新连接。
数据库类一般是"最大连接数 + 表缓存相关参数"共同决定所需句柄,而且它会有一个自己的上限参数。这个参数在启动时取的是配置值和系统允许值之间的较小者,所以经常出现"配置文件里写了很大,实际运行值很小"的情况。查实际生效值要看运行时的状态变量,不能只看配置文件。
缓存类服务通常有一个最大客户端数参数,启动时会跟系统限制做比较,取合适的值。这类服务的特点是句柄需求跟连接数基本一比一,比较好估算。
排查这类问题的通用思路是:先看系统层给的额度,再看应用层配的额度,两者取小。应用层的实际值一般能通过服务自身的状态查询命令拿到,比猜配置文件的字面值靠谱得多。
4.2 句柄数不是越大越好
有一种倾向是"反正调大没坏处,直接拉到最大"。实际经验是,这么做在多数场景下没问题,但在两种情况下会带来麻烦。
第一种是掩盖真实的泄漏。句柄数设成一百万,进程慢慢泄漏,你可能几周都不会收到告警,等发现的时候已经是几百万个句柄压在那,内存和遍历开销都上来了。合理的做法是把限制设成"业务峰值的一点五到两倍",让泄漏能在可接受的时间内暴露出来。
第二种是放大故障影响面。句柄上限高意味着单个进程可以吃掉更多系统资源,一旦某个服务出问题疯狂建连接,会更快地把整机资源拖垮,而不是自己先挂掉。在多服务混布的机器上,这层隔离其实是有价值的。
我的习惯是分环境区别对待:开发测试环境给一个宽松但有限的值,方便调试;生产环境按压测出来的峰值上浮,同时配上句柄使用率的监控告警,比如超过软限制的 70% 就预警。这样既不误伤正常业务,也不至于让问题藏太久。
5. 我现在的落地做法:一套可复制的配置基线与自检清单
5.1 交互式用户的配置模板
登录用户的配置我一般集中放在一个独立的 drop-in 文件里,避免跟发行版自带的文件打架,也方便整体迁移:
# /etc/security/limits.d/99-app-limits.conf * soft nofile 65536 * hard nofile 262144 root soft nofile 65536 root hard nofile 262144 appuser soft nofile 131072 appuser hard nofile 262144几个取值上的考虑:软限制设成业务实际峰值之上一点,硬限制留出空间方便临时上调;普通用户和 root 都显式写,不依赖通配符;对特定业务账号单独放宽。软硬分离的好处是,运维在紧急情况下可以ulimit -n临时往上顶,不用改配置重登。
写完之后的验证流程固定为三步:开一个全新会话、执行ulimit -Sn和ulimit -Hn、再看/proc/$$/limits确认软硬都落到预期值。三步都对,才算这条配置真的通了。
5.2 常驻服务与容器的配置模板
服务侧我倾向于用 drop-in 而不是改 unit 文件,理由前面说过,升级不丢配置。以某个业务服务为例:
# /etc/systemd/system/app-server.service.d/99-limits.conf [Service] LimitNOFILE=131072:262144全局默认值我会单独设一次,作为兜底:
# /etc/systemd/system.conf DefaultLimitNOFILE=65536:262144这样即使某个服务的 unit 里没写,也不会掉到 1024 那个老默认值上。改完全局默认值之后需要daemon-reload,并且已经运行的服务不会自动更新,得逐个重启。
容器侧在启动参数里显式声明,不要依赖继承:
--ulimit nofile=131072:262144然后在容器内用/proc/1/limits确认,再看应用进程的/proc/<pid>/limits。两层都确认过,才算配置完整。
5.3 上线前跑一遍的自检脚本
我把上面这些检查串成了一个脚本,每次调整完环境跑一遍,比人肉核对靠谱:
#!/usr/bin/env bash echo "== 内核层 ==" echo "fs.nr_open = $(sysctl -n fs.nr_open)" echo "fs.file-max = $(sysctl -n fs.file-max)" echo "已分配/上限 = $(awk '{print $1"/"$3}' /proc/sys/fs/file-nr)" echo "== 当前 shell ==" echo "soft = $(ulimit -Sn) hard = $(ulimit -Hn)" echo "== 服务进程 ==" for svc in "$@"; do pid=$(systemctl show "$svc" -p MainPID --value) if [ "$pid" = "0" ]; then echo "$svc: 未运行" continue fi printf "%s(pid=%s): " "$svc" "$pid" awk '/Max open files/{print "soft="$4" hard="$5}' "/proc/$pid/limits" echo " 当前 fd 数: $(ls /proc/$pid/fd 2>/dev/null | wc -l)" done用法就是./check.sh 服务名1 服务名2。脚本不长,但把四层信息一次拉齐了,比在现场一条条敲命令快得多。里面的awk字段位置取决于内核输出格式,跑之前先手工cat一份/proc/<pid>/limits看看列对齐情况,不同内核版本可能略有差异。
5.4 观测:别等报错才知道句柄快满了
配置调好只是起点,真正能减少故障的是观测。我会盯三个指标:单进程 fd 使用量与软限制的比值、整机file-nr的已使用量、以及Too many open files这类错误在日志里出现的频次。
前两个做阈值告警,第三个做趋势观察。如果某个服务的 fd 使用量曲线是缓慢单调上升、从不回落,那基本可以判定有句柄泄漏,这时候即使没到阈值也该去查了。常见来源包括连接池只取不还、异常分支里没有关闭文件、日志轮转时句柄没重新打开。这类问题和ulimit配置无关,但往往是通过调大ulimit把症状压下去了,结果把根因埋得更深。
最后分享一个小技巧:排查句柄问题时,/proc/<pid>/fd配合ls -l能直接看到每个句柄指向什么,是被删掉的日志文件、是某个套接字、还是重复打开的配置文件,一眼就能分辨。这个信息比任何统计数字都直观,我在定位泄漏时基本第一步就跑它。