用 cgroup v2 给批处理任务加上内存与 CPU 硬限额
写数据迁移、日志归档或批量转码这类任务时,最怕的不是跑得慢,而是把同一台机器上的其他服务一起拖死。很多人的第一反应是加一句 ulimit -v,但真正上线后会发现它要么完全拦不住,要么在任务刚开始时就报 MemoryError。原因在于工具选错了层级。
ulimit 管不住的是什么
ulimit 设置的是进程的 rlimit,其中 RLIMIT_AS 限制的是单个进程的虚拟地址空间。对 Python、JVM 这类运行时来说,虚拟地址空间远大于真实驻留内存:解释器会预留大块地址区间,glibc 的每个 arena 也各自预留。结果就是限额设小了任务起不来,设大了形同虚设。
更根本的问题是,rlimit 以进程为单位,无法表达“这一整批进程加起来不超过 2G”。主进程 fork 出十个 worker,每个都在自己的额度里老老实实跑,合起来照样能把内存吃光。而 cgroup v2 的视角是进程树:一个 cgroup 里的所有进程和子 cgroup 共享同一份额度,这正是批处理任务需要的语义。
先确认内核真的在用 cgroup v2
一条命令即可判断挂载类型:
stat -fc %T /sys/fs/cgroup
输出 cgroup2fs 说明是纯 v2;如果输出 tmpfs,则可能是 v1 或混合模式,在混合模式下 v2 的挂载点通常在 /sys/fs/cgroup/unified。后面所有路径都要相应调整,否则写进去的限额不会生效,也不会报错,这是最容易踩空的一步。
最省事的做法:用 systemd-run 起一个受限 scope
如果机器上有 systemd,不必手工创建目录,直接让任务跑在一个临时 scope 里:
systemd-run --user --scope \
-p MemoryMax=2G -p MemorySwapMax=0 \
-p CPUQuota=200% -p TasksMax=256 \
python3 ./etl.py --input ./raw --output ./out
--scope 让命令留在当前终端会话中同步运行,而不是变成一个后台服务;命令退出后 scope 自动消失,限额也随之回收,非常适合放进 Makefile 或 CI 脚本里。--user 依赖 systemd 用户实例,需要 memory、cpu、pids 控制器已委派给用户(主流发行版的 user@.service 默认已开启 Delegate=yes)。如果报 Failed to start transient scope unit,先检查这一点,而不是怀疑参数写错。
没有 systemd 时直接操作 cgroupfs
在容器或精简系统里,通常只能手动操作。以 root 身份创建目录即可得到一个 cgroup,然后把进程写进 cgroup.procs:
CG=/sys/fs/cgroup/etl-$$
mkdir -p $CG
echo 2G > $CG/memory.max
echo 0 > $CG/memory.swap.max
echo 200000 100000 > $CG/cpu.max
echo 256 > $CG/pids.max
echo $$ > $CG/cgroup.procs
把当前 shell 自己迁进去之后,它后续 fork 出来的所有子进程都会自动继承这个 cgroup,这是比逐个迁移进程更可靠的做法。注意 v2 有一条“无内部进程”规则:一个 cgroup 一旦有了子 cgroup,就不能再直接放进程进去。因此不要一边往 etl-1234 里塞进程,一边又想在它下面建子目录。
真正决定行为的几个参数
- memory.max:硬上限。超过后内核会先尝试回收,回收不动就触发 OOM killer,杀掉这个 cgroup 里的某个进程,通常是占用最多的那个。
- memory.high:软上限。超过后进程被强烈节流,内存分配变慢并伴随回收,但不会被杀。适合先观察一段时间再收紧。
- memory.swap.max:设为 0 才能真正封死内存。否则匿名页被换到磁盘,
memory.max迟迟不触发,看起来像是限额失效。 - cpu.max:两个数字,单位微秒,格式是“配额 周期”。
200000 100000表示每 100ms 最多用 200ms CPU,即两核,等价于CPUQuota=200%。 - pids.max:限制任务数,防止批处理脚本因为一个 bug 无限 fork。
排查时最该看的不是 memory.current 而是 memory.events,其中的 oom_kill 计数会明确告诉你“这个任务是被限额杀掉的”,而不是自己抛了异常。oom 计数则表示触发过 OOM 但未必杀了进程。
一个常见误判:memory.current包含文件页缓存,读大文件时会迅速上涨,但那部分是可回收的,并不等于会 OOM。观察峰值时配合memory.stat里的anon字段一起看更准确。
在 Python 里读取自己的额度
批处理脚本可以主动感知限额,在接近上限时先做一次 flush 和回收,减少被直接杀掉导致中间结果丢失的概率。/proc/self/cgroup 中的 0::/etl-1234 一行给出了自己在 v2 层级中的相对路径:
from pathlib import Path
def my_cgroup():
for line in Path('/proc/self/cgroup').read_text().splitlines():
if line.startswith('0::'):
return Path('/sys/fs/cgroup') / line[3:].lstrip('/')
raise RuntimeError('cgroup v2 not found')
def read_int(path):
try:
return int(path.read_text().strip())
except (OSError, ValueError):
return None
cg = my_cgroup()
print('memory.current =', read_int(cg / 'memory.current'))
print('memory.max =', read_int(cg / 'memory.max'))
注意未设上限时 memory.max 的内容是字符串 max,int() 会抛 ValueError,所以必须包在 try 里,上面的 read_int 正是为此存在。
在这个基础上,可以在主循环里做主动降压:
import gc
limit = read_int(cg / 'memory.max')
if limit:
for i, row in enumerate(stream, 1):
handle(row)
if i % 5000 == 0 and (read_int(cg / 'memory.current') or 0) > 0.8 * limit:
flush()
gc.collect()
要清醒地认识到这只是缓解手段。Python 的内存碎片和解释器自身的缓存会让已占用的内存不必然归还给内核,真正兜底的仍然是 memory.max。
清理与几个容易踩的坑
手工创建的 cgroup 目录不会随进程退出自动消失,进程结束后需要显式删除,否则下次同名创建会失败。删除的条件是该 cgroup 内没有进程、也没有存活的子 cgroup:
sleep 0.2
rmdir $CG 2>/dev/null || echo still busy: $(cat $CG/cgroup.procs)
- 在容器里看到的
/sys/fs/cgroup往往已经是某个父 cgroup 的子集,父级可能还有更小的限制,实际生效的永远是最紧的那一个。 - 只设
memory.max而不设memory.swap.max时,机器上有 swap 就会把压力转移到磁盘,表现为“限额好像没生效”,同时任务慢得莫名其妙。 CPUQuota对 IO 密集型任务帮助有限,那类场景要靠io.max,并且需要目标块设备使用的调度器支持。- 限额值不要拍脑袋。先用较宽松的
memory.high跑一轮,用watch -n1 cat $CG/memory.current观察峰值,再据此设定memory.max,误杀会少很多。
- 点赞
- 收藏
- 关注作者
评论(0)