ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

RK3588上ulimit不生效与Too many open files全链路排查

RK3588上ulimit不生效与Too many open files全链路排查

1. 板子跑着跑着报 "Too many open files":先把 ulimit 管的那几个开关认全

先说一个我在 RK3588 边缘盒子上遇到的真实场景:一台带四路网络摄像头接入 + NPU 推理 + 本地录像的板子,跑起来前两个小时一切正常,第二天早上日志里开始刷Too many open files,随后推流线程一个个退掉,进程还在,但业务已经死了。第一反应当然是去改/etc/security/limits.conf,把nofile从 1024 拉到 65535,reboot之后发现ulimit -n还是 1024。这个问题我在不同板子上重复遇到过好几次,每次卡住的点都不完全一样,所以这篇不讲"改哪个文件"这种一句话答案,而是把 RK3588 这类 ARM64 板卡上 ulimit 的生效链条完整拆一遍。

ulimit是 shell 的内建命令,它本身不"设置系统限制",只是在调用setrlimit(2)去修改当前进程的 rlimit 结构,这个结构会被 fork 出的子进程继承。也就是说,ulimit的作用域是"进程树",不是"系统"。理解这一点非常关键,因为绝大多数"改了不生效"的问题,本质都是"改的地方"和"出问题的进程"不在同一棵进程树上。

RK3588 这类板子常见于边缘计算盒子、NVR、车载终端、工控一体机,特点是要在有限的内存和单一 rootfs 上同时跑多媒体、网络、AI 推理几套东西。这些业务对资源限制的消耗类型完全不同,先看一张对照表,把常见资源项和它们在这类设备上的典型触发点对应起来:

资源项shell 键内核对应RK3588 上的典型触发场景
RLIMIT_NOFILE-nstruct rlimit的nofile多路 RTSP 拉流、大量 MQTT 长连接、文件句柄泄漏
RLIMIT_NPROC-u按 UID 统计的进程/线程数GStreamer pipeline 开大量线程、线程池未复用
RLIMIT_MEMLOCK-l可mlock的物理页NPU 推理池、实时音频、mlockall预分配
RLIMIT_STACK-s主线程栈大小大数组放栈上、深递归的解析代码
RLIMIT_CORE-ccore dump 文件大小崩溃现场保留、调试板子上的段错误
RLIMIT_RTPRIO-r实时优先级上限视频采集线程要设SCHED_FIFO

1.1 soft limit 与 hard limit:能自己抬,和只能求 root 抬

每个资源项在rlimit里是一对值:rlim_cur(soft)和rlim_max(hard)。普通进程只能把 soft 提高到不超过 hard 的值,也可以把 hard 往下降——但一旦降下去,在同一个进程树里就再也升不回来了,因为提升 hard 需要CAP_SYS_RESOURCE能力。这个设计是"不可逆降级",我踩过的最难受的一个坑就是在启动脚本里写了ulimit -Hn 4096做临时保护,然后在同一个 shell 里起服务,结果后面所有子进程的 hard limit 全被钉死在 4096,再改limits.conf也没用,必须重新登录或者重启服务单元。

所以日常操作里有一条铁律:要降 hard limit,一定要放在最末端、最局部的地方;要做全局调优,只动 soft,或者只在系统级配置里统一抬高 hard。

1.2 为什么 RK3588 上这类报错来得特别早

在 x86 服务器上,nofile的发行版默认值往往已经是 1024:524288 甚至更高,而很多板卡的 rootfs 镜像里,/etc/security/limits.conf还是空的模板,systemd的服务默认LimitNOFILE是 1024:4096(老版本)或 1024:524288(新版本,但只在部分配置下生效)。更麻烦的是嵌入式场景下 rootfs 常常是精简构建出来的,PAM 模块可能压根没装,limits.conf就是个摆设文件——写了也不报错,但一点作用都没有。

另一个放大器是业务模型:一台 RK3588 盒子接四路 4K 摄像头,每路 RTSP 拉流至少 2 个 socket(RTP + RTCP),加上解码器的 buffer 文件、录像的 mp4 分片、日志、Unix domain socket、epoll 注册的 fd,一路轻松吃掉 30~50 个 fd;再来个 NPU 推理服务对每路做 detour、一个 Web 管理面板、一个 MQTT 上报,几百个 fd 是起步价。1024 这个默认值在这种负载下,撑不过半天。

2. 限制是分层的:内核总量、PAM 会话、systemd 单元各管一段

很多人以为 ulimit 是"一层配置",实际上在 RK3588 这种 systemd + PAM 的 rootfs 上,至少有三层,而且它们的优先级和覆盖关系是从内到外层层收紧的。

2.1 内核层的三个总量开关决定了天花板

内核并不直接管"某个进程能开多少 fd",它管的是"系统总共能有多少个文件"。相关的 sysctl 有三个:

  • fs.file-max:整个系统能同时打开的文件数量上限(内核按 CPU 和内存自动算,一般是内存页数的 10% 左右)。这是全局总量,不是单进程上限。
  • fs.nr_open:单个进程能打开的文件描述符最大数量,默认 1048576。这是很多人的盲区——你把LimitNOFILE设成 2000000,setrlimit会直接失败返回EPERM,systemd 单元会起不来,日志里只给一句很含糊的 "Failed to set RLIMIT"。
  • kernel.pid_max和kernel.threads-max:进程和线程的总量约束,RLIMIT_NPROC的实际效果会被threads-max二次限制。

这三者是"物理天花板",上面两层的任何配置都只能在它们之下活动。所以调优顺序永远是先抬天花板,再配会话,最后配服务,反过来做会出现"配置写了但被静默截断"的现象。

2.2 PAM 这一路:只覆盖"登录会话",且必须装了 pam_limits

/etc/security/limits.conf和/etc/security/limits.d/*.conf不是内核读的,是pam_limits.so在会话建立时读的。也就是说:

  1. rootfs 里必须存在pam_limits.so;
  2. /etc/pam.d/里对应的服务(sshd、login、common-session)必须引用它;
  3. 只有通过"会话建立"这条路径启动的进程才会被应用。

嵌入式板子上最常见的翻车就是第 1 条——用精简方式构建出来的 rootfs 根本没有 PAM,/etc/pam.d目录都不全,limits.conf写满了也是白写。判断方法很简单:

# 看 pam_limits 是否存在 find /lib /usr/lib -name "pam_limits.so*" 2>/dev/null # 看 sshd 是否引用(Debian 系通常是 include common-session) grep -r "pam_limits" /etc/pam.d/

两条命令都没输出,说明这条路对你无效,直接跳到 systemd 层去配。

2.3 systemd 这一路:优先级最高,而且很多人不知道它存在

在 systemd 系统上,所有服务都是 systemd 的子进程,它们的初始 rlimit 继承自 systemd(PID 1),跟limits.conf完全没关系。systemd 里控制这个的地方有三处:

  • /etc/systemd/system.conf的DefaultLimitNOFILE=(作用于系统服务)
  • /etc/systemd/user.conf的DefaultLimitNOFILE=(作用于用户服务)
  • 单元文件里的LimitNOFILE=(覆盖上面两者,只作用于该单元)

三者的覆盖关系是"就近原则"。另外还有/etc/systemd/logind.conf里的UserTasksMax,它限制的是每个用户能创建的任务数(cgroup pids 控制器),跟RLIMIT_NPROC是两个东西,但报错现象很像,容易混。我见过一个 case:ulimit -u明明设成了 65535,但服务一开线程池就报 "Resource temporarily unavailable",最后查到是UserTasksMax卡在 4096。

配置位置生效对象是否需要重启常见失效原因
fs.nr_open/fs.file-max全系统sysctl -p即时生效没写进/etc/sysctl.d/,重启丢失
limits.conf/limits.dPAM 会话(ssh、login、su)重新登录没装 pam_limits、通配符不匹配 root
system.conf/user.conf所有 systemd 单元systemctl daemon-reexec只改了system.conf,用户服务不受影响
单元LimitNOFILE=单个服务systemctl daemon-reload+ restart单元被 drop-in 覆盖
容器--ulimit容器内进程重建容器宿主机 hard limit 不够

3. 改完不生效的七个断点:把排查链路走一遍

这一节是我自己的排查清单,顺序是从下往上,因为越靠下的越容易被忽略。每次遇到"改了没效果",按顺序走一遍,基本十分钟内能定位。

3.1 断点一:先确认你看的是"哪个进程"的限制

这是最容易犯的错。在终端里敲ulimit -n看到 65535,就以为配好了,但报错的进程根本不是这个终端起的。正确的做法是直接看目标进程:

# 看某个正在跑的进程的真实限制 cat /proc/$(pidof your_app)/limits # 输出长这样,注意 Max open files 那行 # Limit Soft Limit Hard Limit Units # Max open files 1024 4096 files

/proc/PID/limits是唯一可信的真相来源。同时可以看它当前开了多少:

ls /proc/$(pidof your_app)/fd | wc -l

如果这个数字已经贴着 Soft Limit,那没什么可怀疑的,就是撞上限了。

3.2 断点二:跑的是服务,改的是会话

这是最高频的原因。limits.conf只对登录会话生效,你的业务进程如果是systemd拉起来的,那它继承的是 systemd 的默认值。验证方法:

systemctl show your-app.service -p LimitNOFILE -p LimitNPROC -p LimitMEMLOCK

输出如果还是LimitNOFILE=1024:524288,说明你改的limits.conf对它无效,需要去写单元文件或者system.conf。

3.3 断点三:通配符*不包括 root

/etc/security/limits.conf里的*是个用户名通配,但 pam_limits 对 root 有特殊处理——*不匹配 root,必须显式写一行root。嵌入式板子上业务基本都跑在 root 下,所以这一条几乎必踩。正确的写法是:

* soft nofile 65535 * hard nofile 65535 root soft nofile 65535 root hard nofile 65535

顺手提一句,limits.conf的格式是三段式加一个可选的第四段,写成* - nofile 65535表示 soft 和 hard 一起设。很多人写* soft nofile 65535只设了 soft,然后发现 hard 还是 4096,后面想再抬就抬不上去了。

3.4 断点四:fs.nr_open把上限锁死了

如果你在system.conf里写了DefaultLimitNOFILE=2000000,而fs.nr_open还是默认的 1048576,那 systemd 会直接忽略或者截断,服务可能起不来。查一下:

sysctl fs.nr_open fs.file-max kernel.pid_max

经验值是fs.nr_open设成你需要的最大nofile的 1.5~2 倍,fs.file-max再高一个量级。比如目标nofile=65535,fs.nr_open=1048576完全够用,不需要动;目标nofile=1000000,那就得把fs.nr_open抬到 2000000。

3.5 断点五:登录方式不同,结果完全不同

同一台板子,SSH 登录进去ulimit -n是 65535,插串口终端登录进去还是 1024。原因很直接:SSH 走sshd,/etc/pam.d/sshd里 include 了common-session,pam_limits生效;串口getty走的是另一套 PAM 配置,甚至可能是 busybox 自带的简化登录,压根不走 PAM。

另一个变种是su和sudo。su -(带横杠)会重建完整登录会话,PAM 生效;su(不带横杠)在很多发行版上不重建会话,PAM 的 session 阶段可能被跳过。sudo则取决于/etc/pam.d/sudo的配置。所以"我用 sudo 试了是好的"这句话不能作为配置成功的证据。

3.6 断点六:systemd-logind 的用户级默认值

较新版本的 systemd 在logind.conf里提供了UserTasksMax,在user.conf里提供了DefaultLimitNOFILE。如果你的服务是 user unit(systemctl --user),那它读的是user.conf而不是system.conf。板子上跑桌面环境的场景下这个坑特别常见。

3.7 断点七:容器里是另一套继承链

板上用 Docker 跑业务的话,容器内进程的 rlimit 来自dockerd的继承,再叠加docker run --ulimit参数。默认情况下 Docker 会把nofile设成1024:524288(或者继承 daemon 的设置)。容器内改limits.conf是无效的,因为容器里通常没有完整的 PAM,就算有,进程也不是通过登录会话启动的。

# 容器运行时指定 docker run --ulimit nofile=65535:65535 --ulimit memlock=-1 your-image # 已经在跑的容器,看实际值 docker exec <container> cat /proc/1/limits

排查链路走完,一般会落在"服务层"或者"容器层"上,因为这两层是默认生效的。

4. RK3588 上把配置真正钉住的四步操作

知道原理之后,落地其实很快。我习惯按"量、内核、会话、服务"的顺序做,每一步都能单独验证,出问题好回滚。

4.1 第一步:先量,别拍脑袋定数字

先把现有负载的真实需求测出来,不然后面设多少都是猜。我一般这么做:

# 业务跑满负载后,看目标进程的实际 fd 消耗 PID=$(pidof your_app) ls /proc/$PID/fd | wc -l # 看 fd 的类型分布,判断是 socket 多还是文件多 ls -l /proc/$PID/fd 2>/dev/null | awk '{print $NF}' | sed 's/.*\[//;s/\]//' | sort | uniq -c | sort -rn | head # 看系统全局的文件使用情况 cat /proc/sys/fs/file-nr # 三个数字:已分配、已使用、上限

测出来的峰值乘以 2~3 作为目标值,是比较稳妥的做法。RK3588 的典型业务,我一般会落在nofile=65535这个量级——够用,又不至于让 fd 表占用太多内存。

4.2 第二步:抬内核总量,写进 sysctl.d

不要直接改/etc/sysctl.conf,写独立文件更好管理,也方便回滚:

cat > /etc/sysctl.d/99-rk3588-limits.conf <<'EOF' # 单进程 fd 硬天花板,必须 >= 任何 LimitNOFILE fs.nr_open = 1048576 # 系统全局文件句柄总量 fs.file-max = 2097152 # 进程/线程总量 kernel.pid_max = 4194304 # 高并发网络场景顺手带上 net.core.somaxconn = 1024 net.ipv4.tcp_max_syn_backlog = 2048 EOF sysctl --system

sysctl --system会按文件名顺序加载/etc/sysctl.d/下所有配置,99-前缀保证它排在发行版默认值之后。验证:

sysctl fs.nr_open fs.file-max kernel.pid_max

注意fs.nr_open改大不需要重启,但改小的时候如果有进程已经超过了新值,会失败,得先把进程收掉。

4.3 第三步:会话层,用 limits.d 而不是改 limits.conf

limits.conf是发行版模板,升级时可能被覆盖,我习惯放独立文件:

cat > /etc/security/limits.d/99-rk3588.conf <<'EOF' * soft nofile 65535 * hard nofile 65535 root soft nofile 65535 root hard nofile 65535 * soft nproc 32768 * hard nproc 32768 root soft memlock unlimited root hard memlock unlimited EOF

memlock unlimited这一条在 RK3588 上很值得加,NPU 推理框架和一些实时音频库会调mlockall预分配内存,默认 64KB 的 memlock 会直接失败。加完之后重新登录验证。

这里有个顺序细节:如果 hard 值只写在limits.d里,而 systemd 的默认 hard 是 4096,那么通过 SSH 登录进来时,pam_limits 想设 65535 的 hard 会被拒绝(因为它自己也被继承的 hard 卡着)。所以第三步之前,第四步的 systemd 全局值必须已经抬上去,两层要配套。

4.4 第四步:服务层,system.conf 加单元覆盖双保险

先改全局默认,让所有新起的单元都有足够的空间:

mkdir -p /etc/systemd/system.conf.d cat > /etc/systemd/system.conf.d/99-rk3588-limits.conf <<'EOF' [Manager] DefaultLimitNOFILE=65535:65535 DefaultLimitNPROC=32768:32768 DefaultLimitMEMLOCK=infinity EOF # 用户级服务也要配,否则 systemctl --user 起的服务还是老值 mkdir -p /etc/systemd/user.conf.d cat > /etc/systemd/user.conf.d/99-rk3588-limits.conf <<'EOF' [Manager] DefaultLimitNOFILE=65535:65535 EOF

然后针对个别重负载服务做单元级覆盖,用 drop-in 的方式而不是直接编辑原单元文件:

mkdir -p /etc/systemd/system/your-app.service.d cat > /etc/systemd/system/your-app.service.d/limits.conf <<'EOF' [Service] LimitNOFILE=65535 LimitNPROC=32768 LimitMEMLOCK=infinity LimitCORE=infinity EOF systemctl daemon-reload systemctl restart your-app

daemon-reexec和daemon-reload的区别值得记一下:改system.conf这类全局配置需要systemctl daemon-reexec让 PID 1 重新读配置;改单元文件只需要daemon-reload。两个都执行一遍最保险:

systemctl daemon-reexec systemctl daemon-reload

4.5 验证清单:四个地方全对才算真的成了

验证点命令期望结果
内核天花板sysctl fs.nr_open≥ 你设的最大 nofile
会话层重新 SSH 登录后ulimit -n65535
服务层systemctl show your-app -p LimitNOFILE65535
进程实际cat /proc/$(pidof your-app)/limitsMax open files 65535

四个都对,才算收工。只对前三个,说明服务重启没做或者被 drop-in 覆盖了。

5. NOFILE 之外的几个 limit:板子上更隐蔽的坑

调完nofile就完事的人不少,但 RK3588 这类板机上,还有几个限制会以完全不同的报错形式出现,看着不像 ulimit,实际上就是。

5.1 RLIMIT_MEMLOCK:NPU 推理和实时音频的隐形天花板

默认 64KB,这个值在服务器上基本没人管,因为业务很少mlock。但在 RK3588 上,NPU 驱动、某些推理 runtime、实时音频(ALSA 的memlock)、DPDK 风格的零拷贝网络都会用到。表现是:

mlock failed: Cannot allocate memory

或者推理框架初始化时报一段看不懂的 DMA 分配错误。这时候先查:

ulimit -l grep -i memlock /proc/$(pidof your_app)/limits

改成unlimited就行。需要注意的是memlock unlimited在部分内核配置下需要CAP_IPC_LOCK,root 默认有,普通用户没有。另外如果板子开了systemd的MemoryMax或者 cgroup v2 的memory.max,mlock的页也会计入,超了照样失败。

5.2 RLIMIT_NPROC:不是"进程数",是"任务数"

ulimit -u限制的是某个 UID 下的任务数(进程 + 线程)。这个很反直觉,因为 Linux 里线程也是 task。RK3588 是 4×A76 + 4×A55 的八核,业务代码里开个 32 线程的线程池很常见,几路视频一叠加,轻松上千个 task。默认 1024 或者 4096 的nproc很容易被打满。

更隐蔽的是,RLIMIT_NPROC对 root 用户其实是不生效的(内核里对CAP_SYS_RESOURCE或CAP_SYS_ADMIN有豁免),所以你会看到 root 跑的服务明明超了nproc也没报错,而切到普通用户就立刻失败。如果业务里有降权运行的子进程(这在安全加固过的镜像里很常见),这个坑就会出现。

还有一种情况是报错信息误导人:fork: Resource temporarily unavailable看着像内存不够,其实是nproc或者 cgroup 的pids.max打满了。查一下:

cat /sys/fs/cgroup/pids.max # cgroup v2 cat /sys/fs/cgroup/pids/pids.max # cgroup v1

5.3 RLIMIT_STACK:主线程栈和线程栈是两码事

ulimit -s设的是主线程栈,默认 8MB。pthread_create创建的子线程栈由pthread_attr_setstacksize决定,默认也是 8MB,但不受ulimit -s约束(实际上 glibc 会参考,但不是强约束)。板子上内存紧张的时候,很多团队会把ulimit -s调到 1024,省点虚拟内存,结果主线程里一个大数组或者一次深度递归的 JSON 解析直接段错误。

RK3588 上内存通常是 4/8/16GB,栈其实不缺那几 MB,除非你在跑几百个线程。真要省,去调线程栈大小,别动主线程栈。另外 8MB 的栈在嵌入式场景下对alloca和变长数组是很危险的,见到-Wvla警告建议直接开-Werror=vla掐掉。

5.4 RLIMIT_CORE:调试期一定要开

量产镜像经常把core设成 0 省空间,但调试阶段这是自断后路。板子上的崩溃现场本来就难复现,core关了等于什么都不剩。建议调试版镜像里:

ulimit -c unlimited echo "/var/crash/core.%e.%p" > /proc/sys/kernel/core_pattern

配合coredumpctl(systemd 系统)或者直接分析 core 文件,比看日志有用得多。注意core_pattern写到/var/crash时要确认那个分区够大,RK3588 的 eMMC 容量通常不大,一个几 GB 的 core 写下去可能把分区撑爆——这也是量产版关掉它的原因。

6. 调大之后的反噬:几个我真实踩过的坑

把限制调大是"看起来只有好处"的操作,实际上坑不少,而且往往在上线几周后才暴露。

6.1 硬上限的继承陷阱

前面提过一次,这里再强调。任何脚本里出现ulimit -Hn、ulimit -Hu这类降 hard 的写法,都要极其小心。我见过一个启动脚本为了"兼容老环境"先执行ulimit -Hn 4096,再exec业务进程,结果这棵进程树里的 hard limit 永远升不回去,后面怎么改配置都没用,只能重启。排查的时候看/proc/PID/limits的 Hard Limit 是 4096,Soft Limit 也是 4096,一眼就能看出来是被脚本钉死了。

6.2 fd 不是免费的:每个 fd 都占内核内存

nofile=65535意味着理论上一个进程可以开 6 万多个文件,内核会为每个打开的文件维护struct file(大概 200~300 字节),加上 inode、dentry 缓存。真跑到这个量级,几 MB 到几十 MB 的内核内存就没了。板子只有几个 G 内存的时候,这个开销是要算进去的。

更需要注意的是:fd limit 调大之后,fd 泄漏变得更隐蔽了。原来 1024 的时候,泄漏半天就报错,你能立刻发现;调到 65535 之后,泄漏可能要跑一周才暴露,而那时代码迭代了好几版,排查成本高得多。所以调大限制的同时,一定要加监控:

# 简单的 fd 使用率监控,超过 70% 告警 PID=$(pidof your_app) CUR=$(ls /proc/$PID/fd 2>/dev/null | wc -l) MAX=$(awk '/Max open files/{print $4}' /proc/$PID/limits) [ $((CUR * 10 / MAX)) -ge 7 ] && echo "fd warning: $CUR/$MAX"

这个脚本挂到 cron 或者塞进你的监控 agent 里,比事后 debug 便宜太多。

6.3 调大 nofile 反而触发了 select() 崩溃

这个坑很经典,值得单独说。select()的fd_set在内核和 glibc 里都有个FD_SETSIZE=1024的硬编码限制,如果你往select()里传了大于 1023 的 fd,行为是未定义的——在 ARM64 上通常是栈越界写,然后以极难定位的方式崩溃。

原来 fd limit 是 1024 的时候,fd 编号天然不会超过 1023,所以select()一直"没问题"。你把 limit 调到 65535 之后,程序跑得久了,fd 编号爬到 2000 多,这时候再调select()就炸了。表现是随机段错误或者数据错乱,backtrace 完全指不到业务代码。

解决办法只有两个:改用poll()或epoll,或者对大 fd 做重映射(不推荐,太脏)。RK3588 上跑多路网络的业务,本来就该用epoll,我一般建议在代码评审阶段就把select()直接列为禁用 API。

6.4 固化与回滚:别让配置只活在当前会话

最后说一个流程上的事。板子上调试时,ulimit -n 65535敲一下很方便,但重启就没。所有验证有效的改动,必须落到文件里:

  • 内核参数 →/etc/sysctl.d/99-*.conf
  • 会话参数 →/etc/security/limits.d/99-*.conf
  • 服务参数 →/etc/systemd/system.conf.d/99-*.conf+ 单元 drop-in

同时留一个回滚脚本,把这三个目录下的 99 文件删掉,systemctl daemon-reexec+sysctl --system,一分钟回到出厂状态。RK3588 的板子经常要发到现场,出问题时现场人员只能执行脚本,提前准备好这个比什么都值。

还有一个细节:如果板子用的是只读 rootfs(overlay 或者 squashfs + tmpfs),你改的这些文件重启就没了。这种情况一定要在构建镜像的阶段就写进去,而不是在板子上手改。我见过在现场改完limits.d开机就还原,排查了两天以为配置写错了,最后发现是 rootfs 只读。

配置这件事本身不难,难的是链路太长、生效点太多,每一层都有办法悄悄把你的设置吃掉。把/proc/PID/limits当成唯一的验收标准,剩下的都只是过程。

返回列表