LimitNPROC 与 TasksMax:OpenVPN 单元到底在数什么
LimitNPROC 统计的是单个用户在整个系统中的进程数;TasksMax 限制的是单个单元的 cgroup。调高任何一项之前,先读取实际生效的值。
OpenVPN 启动失败时,人们往往同时收到一条资源相关的报错和一句善意的建议——把某个上限调高,而这个建议只有大约一半的情况下管用。这些失败背后其实是两个互不相干的计数器。一个是内核对真实用户 ID 所拥有的任务数量的限制,它在进程执行时被继承。另一个是 cgroup 预算,由服务管理器施加于一个单元及其 fork 出的一切。针对真正在运行的那个单元,把两者各自的实际生效值读出来,比凭猜测要省事。
两个计数器,两个问题
LimitNPROC 就是 systemd 命名下的 RLIMIT_NPROC,它的计量单位是真实用户 ID。该用户名下的每个进程都会计入同一个总数,包括由其他单元、登录会话或调度任务启动的工作。因此这个限制并不是每服务一份的配额,即便它被写在某个服务单元里也是如此;为每个客户端 fork 一个辅助进程的服务,会与该账户下运行的其他一切争抢同一份额度。同一个用户下繁忙的 cron 任务,就能把某个 VPN 单元推过它自己从未越过的界线。
TasksMax 的归属不同:它作用于该单元 cgroup 上的 pids 控制器,统计的是任务而不是进程。线程也算任务,所以一个看起来单线程、却带有工作线程池的守护进程,其消耗的预算可能一眼看去远大于它的进程数。EffectiveTasksMax 报告的是:在父级 slice 发话之后,cgroup 层级实际允许的额度。
为什么 root 例外会改变结论
内核不会把 RLIMIT_NPROC 施加到带有 CAP_SYS_RESOURCE 或 CAP_SYS_ADMIN 的进程上,所以在不放弃特权的情况下以 root 运行的单元,实际上不受该设置约束。正是这唯一的例外,解释了人们交换的那些相互矛盾的建议:调高 LimitNPROC 能修好已经放弃特权的部署,而对仍然保有特权的部署毫无作用。在把功劳或责任归给这项设置之前,先查看 User=,以及守护进程启动后是否会切换身份。一个保有特权的单元如果之后切换到无特权账户,就会在切换那一刻重新落回限制之下,因此故障的时间线同样值得一读。
出错的阶段能进一步缩小范围。执行准备阶段的拒绝、守护进程自己报告的 fork 错误,以及 cgroup 内部被拒绝的任务创建,会产生不同的 journal 行,而其中只有一种能靠更大的预算解决。
读实际生效的值,而不是 shell 里的值
交互式 shell 里的 ulimit -u 描述的是那个 shell。它并不描述服务管理器传给守护进程的是什么,也完全没有涉及 cgroup 记账。应当改为查询该单元,并且使用主机上真实存在的实例名,而不是指南里的示例。
systemctl show [email protected] -p User -p LimitNPROC -p TasksCurrent -p TasksMax -p EffectiveTasksMax
cat /proc/$(systemctl show -p MainPID --value [email protected])/limits | grep -i processes
在容器内部,答案还需要再读两处,因为 infinity 这样的内部值只描述来宾侧的视图。向宿主机所有者询问容器自身的 pids 上限,并确认内部的服务管理器究竟能否委派该控制器。嵌套单元无法持有父级 cgroup 从未授予的预算。
估算修复幅度,并在负载下验证
从工作负载而不是报错文字里取数。数一数守护进程为每个已连接客户端或每次辅助进程调用启动了多少进程,乘以部署预期承载的并发量,再加上管理会话的余量,然后把这个数字与两个实际生效值作比较。本地的 drop-in 覆盖能让改动可审查,并且在软件包升级后依然保留,而 直接编辑随包发布的单元文件会让改动藏起来,直到下一次更新把它替换掉。把算式写在覆盖文件旁边,因为下一个调高或调低它的人需要的是假设,而不是最终数字。
预算变更在执行时生效,所以仅仅重新加载并不会调整已在运行的进程。请按正常生命周期重启该单元,接受会话中断,然后在新实例上重新读取计数器。
sudo systemctl daemon-reload
sudo systemctl restart [email protected]
systemctl show [email protected] -p TasksCurrent -p EffectiveTasksMax
sudo journalctl -u [email protected] -b --no-pager | tail -n 20
真正证明修复生效的,是一个重启后的单元:它在承载预期负载的同时任务数低于两道上限,并且 journal 不再在同一阶段报告任务或 fork 耗尽。把改动前后的数字一并留存,这样下一次排查是从测量出发,而不是从同一条建议出发。如果计数仍然触到上限,那就是算式错了,而不是限制碰巧太小;这个区别值得在下一次重启之前搞清楚。