Linux 限制单个进程的 CPU 和内存:我把服务器上的 cpulimit 全换成了 systemd-run

🔑 关键词:systemd-run,cgroup v2,CPUQuota,限制进程CPU使用率,MemoryMax

📖 摘要:从一次凌晨 load 告警说起,把 nice、taskset、cpulimit、docker 和 systemd-run 摆在一起比,讲清 cgroup v2 下给单个进程封顶 CPU、内存、I/O 的写法,以及三个我实际踩过的坑。

Linux 限制单个进程的 CPU 和内存:我把服务器上的 cpulimit 全换成了 systemd-run

图片

凌晨两点被 load 告警叫起来这事,干运维的都经历过。这篇是我这两年处理「某个进程把机器吃干净」这类问题的记录,从 nice 一路讲到 cgroup v2,中间几个坑我替你先踩了。

先把场景说清楚

一台 Linux 服务器上跑着三五个服务,突然有个任务把 CPU 或者内存吃光了。这个任务可能是备份脚本、日志切割、数据压缩、模型推理,也可能是同事 SSH 上来跑的一个压测。你不想改它的代码,不想为它专门上 docker,也不想眼睁睁看着同机上别的服务被拖死。你要的东西只有一个:给它封顶。

我 2020 年之前一直拿 cpulimit 干这事,后来不用了。不是因为它坏,是因为 systemd 加 cgroup v2 已经把这件事在内核层面做完了,只是很多人还在装第三方工具。

出事那天

2023 年 11 月,我们一台 8 vCPU / 32G 的云主机,凌晨 2:07 触发 load 告警,load1 冲到 32。SSH 上去光标卡了七八秒才回显。top 一看,pigz -9 -p 8 八个线程把 CPU 全占了,旁边的 mysqld 连接数还在往上堆,应用侧已经开始报 1040 Too many connections。

流程本身没错:mysqldump | pigz -9 > /data/backup/xxx.sql.gz。错的是没给这条流水线设边界,它默认认为自己独占整台机器。

图片

后面我先用 cpulimit 顶了两周,再换成 systemd-run,一直用到现在。中间那些反复,就是下面这些内容。

nice 和 taskset 为什么不够用

nice -n 19 只是改了 CFS 的权重。机器上只有这一个重活的时候,CPU 是空的,nice 值再高它照样 100% 占用。nice 解决的是「多个进程抢 CPU 时谁先跑」,不是「一个进程最多能用多少」。它更管不了内存。

taskset -c 2,3 看着像限核,实际是把线程钉死在两个核上。如果程序开了 8 个线程全钉过来,就变成 8 个线程抢 2 个核,总吞吐更差,尾延迟抖动更大。内存你也控制不了。

这两个手段描述的是「优先级」和「亲和性」,不是「配额」。要的是配额。

cpulimit 到底坑在哪

图片

cpulimit 的原理是发 SIGSTOP 停一下,过一会儿再 SIGCONT 放行,用「停多久 / 放多久」的比例去近似目标占用率。

问题有三。第一,被 STOP 的进程如果正握着锁或者数据库事务,排在它后面等的那串进程会一起卡住。第二,它只管 CPU,内存超了照样 OOM,我在一台 32G 的机器上见过它把整台机器拖到 swap 狂读。第三,精度不行,我在 8 核机器上给它 -l 200,top 里经常能看到它蹿到 240% 以上,CPU 曲线是锯齿状的,多线程进程还有识别不全的情况。

它是 sysvinit 时代的补丁。在 cgroup v2 已经默认启用的发行版上继续用它,属于自己给自己找活干。

systemd-run:一条命令

# 阻塞当前终端,Ctrl-C 结束,stdout 直连
sudo systemd-run --scope --unit=backup-job \
  -p CPUQuota=200% \
  -p CPUWeight=20 \
  -p MemoryHigh=3G \
  -p MemoryMax=4G \
  -p MemorySwapMax=0 \
  -p "IOReadBandwidthMax=/dev/nvme0n1 200M" \
  -p "IOWriteBandwidthMax=/dev/nvme0n1 100M" \
  -p TasksMax=128 \
  /usr/local/bin/backup.sh

几个要点。--scope 会在你当前 shell 下挂一个 scope,stderr 和 stdout 直连,能看到输出;去掉 --scope 就变成后台的 transient service,用 systemctl status backup-job.service 和 journalctl -u backup-job.service 看。--scope 不走 shell,所以要用管道或者重定向,得自己包一层 sh -c '...'。单位名不能带点,--unit=backup-job 就行,后缀 systemd 会补。IO 那两个参数里的设备名用 lsblk 查,要写块设备节点,拿不准就别乱填。

参数逐个说

图片

参数 作用 误区
CPUQuota=200% 整个 cgroup 的 CPU 带宽上限,200% 等于 2 个核 不是每线程,8 个线程共享这 2 个核的额度
CPUWeight=20 有竞争时的相对权重,默认 100 机器空闲时它等于没设
MemoryHigh=3G 软上限,超了内核猛回收,变慢但不杀 它不是硬墙
MemoryMax=4G 硬上限,超了直接 OOM kill 杀的是整个 scope,不是某一个进程
MemorySwapMax=0 禁止使用 swap 不设的话内存压力会转成 I/O 抖动
IOReadBandwidthMax 读带宽上限 值里要带块设备名,中间空格隔开
IOWeight I/O 竞争权重,范围 1-10000 和带宽上限不是一回事
TasksMax=128 进程加线程数上限 防 fork bomb 比 ulimit 靠谱

怎么确认它真的生效了

stat -fc %T /sys/fs/cgroup
# cgroup2fs 就是 v2,tmpfs 就是 v1

systemd-cgtop

cat /sys/fs/cgroup/system.slice/backup-job.scope/cpu.max
# 200000 100000   → 200% / 100ms 周期

cat /sys/fs/cgroup/system.slice/backup-job.scope/memory.max
# 4294967296

cat /sys/fs/cgroup/system.slice/backup-job.scope/cpu.stat
# nr_periods / nr_throttled / throttled_usec

图片

如果 CPU 明显跑满,而 nr_throttled 一直是 0,只有两种可能:quota 设太宽,或者进程压根没进这个 cgroup。后者更常见,尤其是用 --scope 跑一个内部又自己去 fork 或者交给 systemd 托管的脚本。

我自己踩过的三个坑

第一个,MemoryMax 触发的是 SIGKILL,不是限速。我头一次把 MemoryMax 设成 2G 去跑一个峰值 2.8G 的 Java 进程,结果它大概每 40 秒被杀一次,journal 里只有孤单的一行 A process of this unit has been killed by the OOM killer.,我以为是脚本有 bug,查了俩小时。正确姿势是 MemoryHigh 设 2G、MemoryMax 设 3G,让它在软限上慢慢磨。

第二个,--scope 下 Ctrl-C 只保证信号发到最外层进程,所以管道命令一定要写成 sh -c 'mysqldump ... | pigz ...',不然后半截可能变成孤儿进程继续吃 CPU。

第三个,老系统上还是 cgroup v1,部分 I/O 参数会直接报 Failed to set unit properties: Invalid argument。RHEL 9、Debian 12、Ubuntu 22.04 之后基本都是 v2 默认,RHEL 8 和 Debian 11 大概率还是 v1,得加内核参数切。动手前先跑一遍 stat -fc %T /sys/fs/cgroup。

几种手段横向对比

图片

手段 限 CPU 限内存 限 I/O 精度 额外依赖
nice / renice 否 否 否 — 无
taskset 部分 否 否 低 util-linux
ulimit -v / -t 否 部分 否 中 shell 内置
cpulimit 是 否 否 低 第三方包
docker --cpus 是 是 是 高 容器运行时
systemd-run 是 是 是 高 systemd 240+

一个可能不太一样的看法

限制资源这件事,本质上不是「用什么工具」,而是「承认这台机器是共享的」。一台机器上跑五个任务,它们之间就该有层级。

我现在每台机器都会提前建几个 slice:critical.slice 的 CPUWeight 给 500,batch.slice 给 50 并且 MemoryMax 卡在物理内存的一半,debug.slice 直接 CPUQuota=50%。临时任务、排查用的压测、同事上线的脚本,一律扔进 debug.slice:

sudo systemd-run --slice=debug.slice --scope /usr/bin/stress-ng --cpu 8

这么做的成本几乎为零,不需要镜像,不需要网络命名空间,不用操心 PID 1 和信号转发。相比之下,为了「给一个本地脚本限个速」去拉镜像、写 Dockerfile、处理卷挂载权限和时区,性价比真的低。

反过来讲,如果任务需要文件系统隔离、依赖打包、多机调度,那还是 docker 和 k8s 的活。工具没好坏,只有合不合适。

🏷️ 标签: