cgroup v2 用统一层次组织资源控制。一个工作负载的多个进程可以共享合计预算,限制还会沿层次继承,而不是只对某个特定 PID 生效。CPU 带宽、内存用量和任务数量由不同控制器管理,执行方式也不同。读取真实 cgroup 文件和失败计数器,比把所有配置上限都当作同一种“到点就停”更能解释实际行为。

坏掉的样子

cpu.max 按周期限制 CPU 带宽。控制组用完配额后,可运行工作可能被节流,但这不会预留一颗专用处理器,也不保证某个请求延迟。多个线程可以很快消耗共享配额。CPU 权重则影响竞争情况下的相对分配;混淆权重、配额和 CPU 亲和性,会让容量规划建立在错误预期上。

内存上限不会统一把大分配变成可由应用干净处理的错误。到达 memory.max 时,内核可以回收内存;无法降低用量时,可能触发控制组 OOM 机制。某些分配路径还有其他失败方式。memory.high 施加回收压力和节流,本身不会调用 OOM,因此不能把这两个设置解释成同义的硬终止阈值。

任务上限也包括线程,不只是监控面板显示的应用进程数量。达到 pids.max 后,即使 CPU 和内存看似充足,fork 或 clone 仍可能失败。层次限制意味着子组配置更大,也不能简单覆盖祖先更紧的预算。解释可见文件时,需要确认对应路径与控制器委派关系,不能只读取一个数值就断定完整有效上限。

快速验证

先取得固定镜像,再用 Python 和采用 cgroup v2 的 Docker 守护进程运行脚本。实测为 Docker 29.3.1、Linux 6.12.76、Python 3.11.16。所有限制只施加到可丢弃实验容器。脚本执行受限的小规模内存耗尽测试,清理自己的容器,不改变宿主全局 cgroup 设置,也不修改其他服务的预算:

import json,subprocess,uuid

BASE='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84'
def docker(*args,check=True):
    result=subprocess.run(['docker',*args],text=True,capture_output=True,timeout=120)
    if check:assert result.returncode==0,result.stderr
    return result
def inspect(name):return json.loads(docker('inspect',name).stdout)[0]

name='xbcatm-budgets-'+uuid.uuid4().hex[:10];containers=[name+'-limits',name+'-oom',name+'-killed']
limits_code='''from pathlib import Path
import errno,json,subprocess,time
root=Path('/sys/fs/cgroup')
assert (root/'cgroup.controllers').exists()
quota,period=(root/'cpu.max').read_text().split();assert int(quota)*2==int(period)
assert int((root/'memory.max').read_text())==64*1024*1024
assert int((root/'pids.max').read_text())==12
def stats():return dict(line.split() for line in (root/'cpu.stat').read_text().splitlines())
before=stats();end=time.monotonic()+2
while time.monotonic()<end:pass
after=stats();assert int(after['nr_throttled'])>int(before['nr_throttled'])
children=[]
try:
 while True:
  try:children.append(subprocess.Popen(['sleep','30']))
  except OSError as error:
   assert error.errno==errno.EAGAIN;break
 assert children and int((root/'pids.current').read_text())==12
 assert int(dict(line.split() for line in (root/'pids.events').read_text().splitlines())['max'])>0
 print(json.dumps({'cpu.max':[quota,period],'memory.max':(root/'memory.max').read_text().strip(),'pids.max':12,'children':len(children),'throttled_periods':int(after['nr_throttled'])-int(before['nr_throttled'])}),flush=True)
finally:
 for child in children:child.terminate()
 for child in children:child.wait()
'''
try:
    result=docker('run','--name',containers[0],'--network','none','--cpus','.5','--memory','64m','--memory-swap','64m','--pids-limit','12',BASE,'python','-c',limits_code)
    print(result.stdout.strip())
    oom=docker('run','--name',containers[1],'--network','none','--memory','64m','--memory-swap','64m',BASE,'python','-c','print("allocating",flush=True);data=bytearray(256*1024*1024)',check=False)
    state=inspect(containers[1])['State'];assert oom.returncode==137 and state['OOMKilled'] and state['ExitCode']==137
    docker('run','-d','--name',containers[2],'--network','none',BASE,'python','-c','import time;time.sleep(60)')
    docker('kill','--signal','KILL',containers[2]);docker('wait',containers[2])
    state=inspect(containers[2])['State'];assert state['ExitCode']==137 and not state['OOMKilled']
    print('a 64 MiB memory budget caused actual OOM termination; an explicit SIGKILL also returned 137 but OOMKilled was false')
finally:docker('rm','-f',*containers,check=False)

第一个容器读取到 cpu.max 为 50000 100000,memory.max 为 67,108,864 字节,pids.max 为十二。CPU 数值表示每一百毫秒周期可使用五十毫秒带宽。把 Docker 的内存加交换总上限设为等于内存上限,使实验不能通过交换空间扩大预算;内存测试规模也保持明确而有限。

两秒忙循环让 cpu.stat 中的 nr_throttled 增加。本次增加二十个周期,但断言只要求正增长。这证明实际发生过限制执行,不只是 Docker 接受了配置参数。它并不是基准测试,不能推出所有负载和调度环境都会出现完全相同的墙钟耗时或减速比例。计数器说明作用发生,不等于完整性能模型。

随后,进程持续创建休眠子进程,直到任务创建返回 EAGAIN。本次十一个子进程加上控制 Python 进程达到十二,pids 控制器的拒绝计数也增加。实验会终止并回收子进程。虽然这些子进程大部分时间都不消耗 CPU,仍然占据任务预算,因此“进程都在睡眠”不能解释为什么还应允许继续创建。

另一个容器获得 64 MiB 内存上限,并尝试初始化 256 MiB bytearray。它以状态 137 退出,Docker 同时记录 OOMKilled 为真。这验证了该分配在实测条件下确实触发内存限制失败,但没有声称所有分配都必须产生同一种异常或终止方式。实际失败语义仍与内核路径和应用行为有关。

对照容器被显式发送 SIGKILL,同样以 137 退出,但 OOMKilled 为假。因此,仅凭退出码无法确定 OOM。调查生产事故时,需要把运行时状态、内存事件和系统证据放在一起,尤其当多个监督组件都有权限终止工作负载时。相同最终状态可能来自不同原因,不能用一个数字代替根因定位。

按整个工作负载设限并观测

预算应覆盖应用工作进程及相关计费内存,而不只是某个进程的堆大小估计。页缓存和其他被统计的内存,也可能参与压力。为启动、依赖变化和恢复操作留出合理空间,再以有代表性的并发进行测量。上限过紧,可能先改变延迟或失败率,之后才出现明显崩溃,不能只看有没有 OOM。

同时监控当前用量、压力指标、控制器事件和应用行为。压力信号有助于发现持续竞争,但突发分配可能快于周期告警。监控应帮助解释失败和调整容量,不能承诺每次触限都能提前预知。即使拥有完整面板,也仍需要合理的限制与业务错误处理。

通过拥有工作负载的管理器修改策略,例如 Docker 或 systemd,并在修改后核对实际控制器值。容器内 cgroup 路径可能是命名空间视图,而不是宿主完整层次。正确诊断需要把这个视图、继承约束和真正采取动作的执行机制连接起来,才能知道该改的是配额、应用并发,还是其他祖先限制。

延伸阅读

来源