shell 内建的 ulimit 改变的是该 shell 及其随后启动进程的资源限制。对于文件描述符,RLIMIT_NOFILE 包含当前生效的软值与硬性上界。当终端显示很宽松的数字,独立受管服务却仍然无法打开文件或接受连接时,关键往往在于这些值从哪里继承。只知道某个命令可以调大数字,并不足以证明改到了真正执行工作负载的进程。
坏掉的样子
软值通常约束新的描述符分配。普通进程可以在现有硬上限内提高软值,也可以降低硬值;提高硬值则需要相应权限,而且仍然受内核约束。因此,启动时继承的硬值太低,可能使应用无法设置自己打算使用的软值。不能只检查应用配置写了多少,还要核对它是否真正有能力应用那个数字,以及失败有没有被正确处理。
子进程修改自身限制,不会改变启动它的父进程。同样,在新终端里执行命令,也不会追溯配置已经由 systemd 启动的守护进程。服务实际父进程和启动配置决定其初始值;shell 配置文件只有在真实启动路径读取并应用时才有效。把交互终端的状态当成机器上所有进程的共同状态,会让排障从一开始就观察错对象。
Bash 的选项选择也需要注意。读取 ulimit -n 通常显示软值,但赋值时如果没有选择 -S 或 -H,会同时修改软硬两项。只想暂时降低软限制的操作者,可能顺带降低硬上限,随后无法再以普通权限恢复。应依据具体 shell 的文档理解语义,不能把几条长得相似的命令当成完全等价。
快速验证
本轮环境没有管理员切换数字身份的权限,因此未重新运行该启动流程;执行记录仍来自此前环境。
在装有 Bash 和 Python 的一次性 Linux 容器中,以管理员身份运行下面的脚本。它通过一个普通数字身份执行实验,管理员控制进程的限制保持不变。实测环境为 Linux 6.12.76、Bash 5.2.15、Python 3.11.2,没有修改任何既有账号配置或服务单元:
import resource,subprocess
before=resource.getrlimit(resource.RLIMIT_NOFILE)
assert before[0]>=64 and before[1]>=64
script=r'''
set -eu
ulimit -S -n 32
ulimit -H -n 64
python3 - <<'CHILD'
import resource
assert resource.getrlimit(resource.RLIMIT_NOFILE)==(32,64)
resource.setrlimit(resource.RLIMIT_NOFILE,(48,64))
assert resource.getrlimit(resource.RLIMIT_NOFILE)==(48,64)
print('first child inherits 32/64 and raises its own soft limit to 48')
CHILD
python3 - <<'CHILD'
import resource
assert resource.getrlimit(resource.RLIMIT_NOFILE)==(32,64)
print('second child still inherits shell values 32/64')
CHILD
if ulimit -S -n 65 2>/dev/null; then exit 11; fi
if ulimit -H -n 128 2>/dev/null; then exit 12; fi
(
ulimit -n 16
python3 - <<'CHILD'
import resource
assert resource.getrlimit(resource.RLIMIT_NOFILE)==(16,16)
print('Bash assignment without -S/-H changed both limits in this subshell')
CHILD
)
python3 - <<'CHILD'
import resource
assert resource.getrlimit(resource.RLIMIT_NOFILE)==(32,64)
print('parent shell limits survived child changes and rejected invalid increases')
CHILD
'''
# Run the experiment under an unprivileged numeric identity in this disposable container.
result=subprocess.run(['bash','--noprofile','--norc','-c',script],user=61001,group=61001,extra_groups=[],
env={'PATH':'/usr/bin:/bin','LC_ALL':'C'},capture_output=True,text=True,timeout=10)
assert result.returncode==0,(result.returncode,result.stderr)
assert resource.getrlimit(resource.RLIMIT_NOFILE)==before
print(result.stdout,end='')
print('administrator controller limits remain unchanged')
测试 shell 先明确设置软值三十二、硬值六十四。它启动的第一个 Python 子进程继承这组值,然后在硬值保持六十四的情况下,成功把自己的软值提高到四十八。这展示的是现有上界内允许的调整,不需要子进程拥有管理员权限,也不等于它可以绕过硬限制任意扩大资源范围。
同一个 shell 随后启动第二个 Python 子进程,后者仍然得到三十二和六十四。第一次修改只留在第一个子进程,没有反向传播到 shell,也没有影响 shell 后来启动的其他子进程。两次观察都读取真实资源限制接口,不是根据启动脚本文本推测,因此能清楚区分配置意图和当前进程实际继承的值。
普通测试 shell 尝试把软值设为超过硬值的六十五,以及把硬值提高到一百二十八,均被拒绝。脚本检查了命令的失败结果,不会隐藏错误后再假装新值已经应用,也没有借助 root 权限让本应失败的操作意外成功。权限上下文是这项实验的一部分,否则很容易测到与生产普通进程不同的行为。
在子 shell 中执行没有软硬选择参数的赋值,会把两项都变成十六。该子 shell 结束后,原测试 shell 的另一个子进程仍然看到三十二和六十四。这里分别验证了两条常被混淆的规则:一次赋值可以修改两种限制,但它仍然只作用在当前进程范围内,不会因为两项都变了,就拥有反向改写父进程的能力。
最后,管理员控制进程比较运行前后的限制,确认完全相同。所有降低和失败的调高,都发生在可丢弃的子进程中。实验验证继承和权限规则,并没有替生产服务器决定描述符预算,也没有声称每一种 shell 都具有完全相同的命令细节。实际部署仍应检查正在使用的解释器与启动方式。
配置并检查真实启动链
对于 systemd 服务,应在最终生效的单元设置中配置合适的 LimitNOFILE,启动策略改变后让替代进程按新策略运行。对于 shell 启动的应用,应在执行程序前通过实际入口设置需要的值。一次性终端命令不是持久配置,除非后续每次启动都有明确机制再次执行它;配置文件存在也不证明这条路径真的被经过。
应检查目标进程的限制,例如读取对应 /proc/PID/limits,并确认观察的是正确运行实例。读取控制 shell 的值回答的是另一个问题。容器运行时、用户管理器和中间包装程序都可能影响继承;当实际值与预期不同,应沿真实父子链定位,而不是反复在无关终端里调同一个数字。
预算应包含套接字、普通文件、管道、事件描述符和必要运维余量。提高上界本身不会创建描述符,但允许更多工作同时存活,可能增加内存和下游负载。因此除了数字,还应验证应用事件机制与资源所有权管理。只有工作负载能够正确使用更高额度时,调整限制才有实际价值,而不是把问题推迟到下一个更大的上限。
来源
- getrlimit(2),资源软硬限制、权限检查与继承,已核验 2026-09-12。
- GNU Bash builtins,ulimit 的软硬值选择与赋值行为,已核验 2026-09-12。
- systemd 252 execution environment,LimitNOFILE 与服务管理器执行限制,已核验 2026-09-12。