进程的出生配置:软限制与硬限制
第 38 篇讲过文件描述符是一切 IO 的通行证;这一篇讲管住通行证发放量的那套机制——rlimits。内核为每个进程维护一张资源限额表,随进程出生而继承、随 fork 传给后代、execve 不改写,除非有人显式调用 setrlimit 系接口调整。每一项限额都有两个数值:软限制是当前生效的天花板,超了通常报错或收信号;硬限制是软限制能上调到的天花板。非特权进程可以把软限制升到硬限制、也可以把硬限制往下压(压低不可逆,给自己断后路),但不能抬高硬限制;root 则可以随意。特殊值无限表示不设限,core 与文件大小类限额常用它。
这张表的第一个特性是「出生决定」。你今天在终端里敲 ulimit 改的只是当前 shell 及其后代的表,已经运行的服务一无所知——这就是「我在终端里改好了 ulimit,服务还是报错」的完整解释:服务进程的表是它被启动那一刻从父进程继承来的,与你的终端毫无关系。理解这一点,排查所有限额问题的第一动作就有了:直接看目标进程自己的表,cat 出 /proc 目录下对应进程号的 limits 文件,一翻便知,不用猜。
第二个特性是「入口决定」。一个进程的限额表来自启动它的那一串进程链:登录会话由 PAM 模块按配置初始化,systemd 服务由单元配置初始化,容器里的初号进程由容器运行时初始化。同一个程序,SSH 手动跑、systemd 跑、容器跑,三张限额表可能天差地别——「本地没事线上炸」的限额版本,几乎都是入口不同导致的。下一节沿着继承链把各入口的配置点逐一过一遍。
软硬双层的设计意图也值得品味。软限制是「日常水位线」,应用触及时得到的是可预期的报错——程序能捕获、能降级、能告警;硬限制是「不可逾越墙」,普通进程碰不到改不动,只有特权方能动。这套双轨让配额治理有了协商空间:运维把硬限制放高、软限制放日常值,应用自主在软限内弹性伸缩,需要扩容时走流程把软限抬上去——限流的语义完整性,比一刀切的上限高明得多。现代配额系统(连接池、并发框架)沿用同样的双层结构,源头正在这里。
常用限额清单
RLIMIT_NOFILE 是出镜率最高的一项:进程能打开的文件描述符数量上限,覆盖文件、套接字、管道、事件通知等一切 fd。传统默认软限制常见 1024,现代发行版多已放宽到几十万甚至百万量级,但「默认 1024」仍是大量旧文档、旧镜像与内网环境的现实。要区分一个近亲:select 调用的 fd 集合上限是编译期常量 1024,与 rlimit 无关——这就是第 5 篇 epoll 取代 select 的原因之一,fd 多的服务用 select,限制先撞在常量上。
RLIMIT_NPROC 限制单个真实用户名下可创建的进程数(root 豁免),fork 在额度耗尽时报资源暂时不可用。注意它是按用户而非按进程组或 cgroup 计数的:同一个服务账号跑的十个实例共享额度,互相挤兑。RLIMIT_CORE 控制 core 文件最大尺寸,是第 53 篇尸检链路的第一道闸门,为零即禁止转储。RLIMIT_STACK 默认 8MB 上下,既约束主线程栈深(递归过深的段错误常与此相关),也是 pthread 新线程的默认栈大小来源——线程开几百个的服务,把 stack 调大时要乘上线程数想想虚拟内存账本。
其余常用项按场景点名。RLIMIT_MEMLOCK 限制可锁定在物理内存里的字节数,默认很小(64KB 量级),DPDK 数据面、依赖大页锁定的数据库要显式调大。RLIMIT_CPU 超过累计 CPU 秒数后收终止信号,是给失控计算兜底的传统手段。RLIMIT_FSIZE 限制单文件写入尺寸,超限收信号,防日志炸盘的老办法。RLIMIT_AS 与 RLIMIT_DATA 限制地址空间总量,64 位时代多用于防御性兜底;容器语义下资源上限的主力已让位给 cgroup(第 8 篇),rlimit 管「单个进程视角的配额」,cgroup 管「一组进程的资源池」,两层机制互补而非替代。
nofile 的两个易混细节值得单独澄清。第一,上限是「进程同时打开的 fd 总数」,不是「每秒新建数」——高频短连接场景撞顶的表象是新建失败,本质是存量未释放(泄漏)或关闭延迟(第 7 篇讲过的 TIME_WAIT 期连接占用),调大上限只是止痛,泄漏治理才是治病。第二,单进程上限与系统级上限是两回事:内核里另有一个全局 fd 总量参数,进程撞自己的限额时全局可能还很空闲,两层各自监控、各自告警。分清这两组概念,nofile 类故障的排查路径立刻清晰。
还有两项容易被忽视的成员值得点名。排队信号数量限额是信号风暴防御的一环;消息队列内存限额防止单个用户用消息队列把内核内存吃穿;实时优先级限额防止普通用户进程抢占实时调度权益。它们平时安静,出事时都是「莫名其妙的全局抖动」类故障的候选嫌疑人。全面体检一台机器的限额表,远不止 nofile 一项,全清单随时可从手册查到,这里建立的是「限额面很宽」的意识。
继承链:PAM、systemd 与容器
登录入口的配置点在 PAM:pam_limits 模块读取 /etc/security/limits.conf 与 limits.d 目录,按「域 类型 限额项 数值」四列格式生效——域可以是用户名或组名,类型写 soft、hard 或两者都设的通配。改完重新登录生效,对已在运行的会话无效。这是传统虚拟机时代的主流入口,今天仍大量存在于跳板机与批处理环境。
systemd 时代的真正主角是单元配置。每个服务的限额由单元文件里的 Limit 系列(LimitNOFILE、LimitNPROC、LimitCORE 等)逐项设定,未设定的项继承管理器默认(DefaultLimit 系列,也可全局配置)。由此产生经典怪象:同一个服务,systemd 启动时 nofile 是一个值,SSH 登进去手动拉起又是另一个值——两入口、两配置源、两张表。排查纪律由此确立:一切以 /proc 进程号 limits 为准绳,单元配置与会话配置分头核对,别混为一谈。
容器又添一层。容器内初号进程的限额由运行时在创建时写入:docker 有 --ulimit 参数,Kubernetes 生态经由各层传递与注入,不同版本的默认值差异不小——有的继承宿主机,有的统一设成很大的值,还有的软限制 1024 而硬限制无限这种拧巴组合。容器内应用最终拿到什么,取决于运行时、编排层、镜像内启动脚本三方的接力,任何一环都可能覆盖上游。所以容器时代的限额排查更依赖最终事实:进容器看目标进程的 limits 文件,倒推配置在哪一层被改写。
继承链还可以拉一条完整的时间线,把一次服务启动的限额来源串起来看:宿主机启动时 systemd 管理器按全局默认初始化自己的限额表;服务单元按 Limit 系列定制后拉起服务进程;服务进程派生工作线程与子进程,限额原样传递;若服务容器化,运行时在创建容器初号进程时按运行时配置再写一遍。链条上每一环都有覆盖上游的能力,最终生效的值是最后一环的笔迹。这条时间线的排查价值在于「顺藤摸瓜」:从进程的 limits 文件出发,逐环节核对配置源,覆盖点必然现形。
cron 与定时任务的限额是个隐蔽角落。定时任务由 cron 守护进程代为拉起,继承的是守护进程自身的限额表——往往比交互登录小得多,fd 上限可能还停留在传统默认;同样的脚本手动跑没问题、进了 cron 就报错,是限额世界的经典悬案。sudo 也有类似的表重置行为,权限切换时限额未必跟随。解法是让任务脚本显式自保:入口处检查并按需调整限额(若权限允许),或在对应入口的配置体系里显式声明,别依赖继承的巧合。
经典陷阱与排查手段
陷阱一:Java 的 too many open files。服务在 systemd 下跑,运维在 limits.conf 里改的配置对它无效;连接池、网络队列一扩容,fd 顶穿软限制。正确动作:改单元文件 LimitNOFILE,重启服务,用进程 limits 文件确认生效。nginx 有专属名字的同款配置 worker_rlimit_nofile,逻辑相同——先改 worker 配置再改系统层。
陷阱二:容器里 nofile 生效链。kubelet 与容器运行时的默认值、编排清单里的注入项、镜像入口脚本里的 ulimit 命令,三层覆盖关系容易理不清;尤其是「软限制 1024、硬限制无限」的组合,应用若只读软限制就吃亏。排查套路固定:进容器看进程 limits,逐层核对运行时参数与镜像脚本,找到覆盖点改在哪一层。陷阱三:NPROC 与 fork 失败。高并发服务 fork worker 报资源暂时不可用,第一反应查 nofile 没用,要看 NPROC 与同 uid 实例数——共享账号多实例部署是高发场景。
陷阱四:stack 调大的连锁反应。为了深递归把 stack 调到几百 MB,单看主线程没事,线程池一开几百个,每线程按默认栈预留,虚拟地址空间与内存承诺一起飙升,与第 51 篇的 overcommit 严格模式撞车,分配失败出现在完全不相干的代码路径。教训:stack 类限额的调整要乘上线程数算总账。动态调整的最后手段是 prlimit 命令:指定进程号直接改运行中进程的限额表(如把 nofile 调到 65536),立即生效、无需重启——应急扩容的利器,但改动只活到进程重启为止,根因配置仍要回单元文件或运行时参数里落实。
陷阱五:限额调整后的验证缺失。改了配置没重新登录、改了单元文件没重启服务、改了容器参数没重建容器——三处都是「配置已写、进程未感知」的典型现场。验证纪律是唯一的解药:任何限额变更必须以目标进程 limits 文件里的新值为准才算完成,配置文件的修改只是过程。把这条纪律写成变更流程的验收条款,限额类工单的返工率会显著下降。
顺手补一个正向用法:用进程数限额防 fork 炸弹。给普通登录用户设一个合理的进程数上限,失控的递归 fork 会在额度处撞墙,损失被限定在单个会话;配合 CPU 与内存类 cgroup 限额,多用户主机的相互干扰就有了三道隔离。限额不只是性能参数,更是多租户环境的防火墙——这层认知让运维对限额的态度从「故障后排查」前移到「上线前布防」。
再留一个通用的容量公式在工具箱里:预估并发连接数乘以安全系数,加上内部预留,反推 nofile 的软硬值与监控阈值。不同运行时的表现形态各异——Redis 的连接上限受 nofile 硬性封顶(自身还预留内部 fd),Java 的客户端连接池、各种语言的网络框架同理——根子都是同一张限额表。公式朴素,但能挡住九成的「上线三天就撞顶」事故。
小结
rlimits 是进程自带的出生配置:软限制管当前生效,硬限制管上调空间,出生时继承自启动链,运行中可用 setrlimit 与 prlimit 调整。它的两个决定性特性——出生决定与入口决定——解释了绝大多数「改了没生效」的限额事故:表属于进程,配置属于入口,两者隔着一条继承链。
常用限额各有地盘:nofile 管通行证数量、nproc 管同账号进程数、core 管尸检闸门、stack 管栈深与线程默认栈、memlock 管锁内存,与 cgroup 形成「单进程配额加资源池」的双层治理。继承链上,PAM 管登录、systemd 管服务、运行时管容器,同一程序三种入口三张表。
排查的肌肉记忆就一条:看目标进程自己的 limits 文件,倒推入口配置,改对层、重启、再验证。把这条纪律刻进团队习惯,too many open files 之类的经典事故就从玄学降级成三分钟的路障清理。
更进一步的方向是把限额治理产品化:把各类服务的标准限额做成基线配置——数据库的 fd 数与锁内存、计算型服务的栈与进程数,各有各的配方——新服务部署时自动套用,偏离基线走评审。基线化的意义与本系列的 THP 检查项、core 演练一脉相承:把散落在个人经验里的知识固化成组织资产,让每一台新机器都出生在正确的配置里,而不是下一次事故的排查现场。
读者留言
COMMENTS 暂无还没有留言,来说第一句?