Kubernetes 资源字段只有经过节点执行栈落实,才成为实际约束。在 Linux 上,kubelet 与容器运行时通过 cgroup 组织进程并配置资源控制器。最终形成的是一个层级,而不是简单贴在 Pod 上的单个数字。判断进程真正可以消耗多少资源之前,需要检查对应容器控制组及继承的约束,避免把清单意图直接等同于有效运行预算。

读取实际执行资源约束的控制器

在 cgroup v2 中,cpu.max 用配额与周期表示 CPU 带宽,单位是微秒。10000 100000 表示该组每个周期可使用相当于十分之一个 CPU 的时间。它不会把进程固定在一个核心上,也不会降低处理器时钟频率。组内如果存在多个可运行线程,就可能在同一周期中更快消耗完共同额度,然后等待下一段可用预算。

CPU weight 负责另一件事:在发生争用时,影响相应层级中有资格竞争的组之间的相对分配。Kubernetes 的 CPU 请求既参与调度,也可以影响这个权重。请求本身不是运行时硬上限,权重也不保证应用的墙钟延迟或独占核心。应把放置需求、相对份额和带宽上限分别理解,而不是都称为已经预留的 CPU。

内存控制器分别暴露限制与使用量。memory.max 描述控制组的硬内存边界,memory.current 报告当前记账使用。回收与 OOM 属于内存机制,不是 CPU 那样按周期进行的节流。应用堆大小也不能解释全部记账内存,调查限制问题时需要把运行时指标与控制器视图对应起来,关注额外进程、缓存和其他开销。

坏掉的样子

层级约束可能比某个本地文件显示的值更严格。子组没有本地 CPU 上限,仍可能受到祖先限制;容器的 cgroup 命名空间也可能隐藏部分层级。因此,不能看到本地 max 就推断可以无限使用整个节点。有效预算还取决于节点上下文与父级约束,容器内部可见的信息未必足以解释所有争用和节流。

多容器 Pod 可以分别配置容器限制。一个容器暂时没有用完额度,并不自动承诺另一个容器可以越过自己的上限。Pod 级资源功能涉及额外字段和版本条件,不能悄悄用它们替代每容器配置的含义。下面实验明确只使用容器 resources,并核对准入后的 Pod 没有配置 Pod 级 resources 字段,保持验证范围清楚。

即使整机 CPU 平均使用率不高,频繁节流仍可能解释应用延迟。反过来,单个节流计数也不能确定所有延迟来源。应查看相关时间窗口中的增量,结合负载需求与请求延迟分析,并记住祖先争用及其他资源也会造成等待。短暂合成循环只能建立执行机制的证据,不能替真实应用完成容量规划或延迟承诺。

控制器文件还依赖环境。cgroup v1 使用不同接口,CPU 管理策略和运行时配置也可能影响放置与执行细节。需要验证实际节点设置,不能复制一个路径就假定含义完全相同。本例要求 cgroup v2 接口,只读取控制器文件,没有修改节点的资源控制配置。运行条件不满足时,检查应明确失败,而不是输出伪造的成功结果。

快速验证

准备采用 cgroup v2 的临时 Linux Kubernetes 集群、kubectl,以及节点可用的 python:3.11-slim-bookworm 镜像。保存为 cgroup-enforcement.py,执行 python3 cgroup-enforcement.py KUBECONFIG_PATH CONTEXT,明确指定测试参数。账号需要创建、删除命名空间。实测服务端为 Kubernetes 1.34.11,使用 kind 的容器运行时配置。

import json
import subprocess
import sys
import time
from uuid import uuid4

assert len(sys.argv) == 3, 'usage: python3 example.py DISPOSABLE_KUBECONFIG CONTEXT'
base = ['kubectl', '--kubeconfig', sys.argv[1], '--context', sys.argv[2], '--request-timeout=30s']
image = 'python:3.11-slim-bookworm'


def k(*args, payload=None, expected=0):
    result = subprocess.run(base + list(args), input=json.dumps(payload) if payload is not None else None,
                            capture_output=True, text=True, timeout=150)
    if expected is not None:
        assert result.returncode == expected, result.stderr
    return result


def obj(namespace, kind, name=None, selector=None):
    args = ['get', kind, '-n', namespace, '-o', 'json']
    if name:
        args.append(name)
    if selector:
        args += ['-l', selector]
    return json.loads(k(*args).stdout)


def apply(namespace, data):
    return k('apply', '-n', namespace, '-f', '-', payload=data)


def wait_for(read, ready, seconds=120):
    until = time.monotonic() + seconds
    while time.monotonic() < until:
        value = read()
        if ready(value):
            return value
        time.sleep(0.5)
    raise AssertionError('expected Kubernetes condition did not arrive')


def pod_ready(pod):
    return any(c['type'] == 'Ready' and c['status'] == 'True'
               for c in pod.get('status', {}).get('conditions', []))


def spec(script='import time; time.sleep(600)'):
    return {'automountServiceAccountToken': False, 'terminationGracePeriodSeconds': 1,
            'containers': [{'name': 'app', 'image': image, 'imagePullPolicy': 'IfNotPresent',
                            'command': ['python3', '-c', script],
                            'resources': {'requests': {'cpu': '10m', 'memory': '24Mi'},
                                          'limits': {'cpu': '200m', 'memory': '96Mi'}}}]}

namespace = 'notes-cgroup-' + uuid4().hex[:8]
k('create', 'namespace', namespace)
try:
    podspec = spec()
    podspec['containers'] = []
    for name, request, limit, memory in [('small', '25m', '100m', '64Mi'), ('large', '50m', '200m', '96Mi')]:
        container = spec()['containers'][0]
        container['name'] = name
        container['resources'] = {'requests': {'cpu': request, 'memory': '24Mi'},
                                  'limits': {'cpu': limit, 'memory': memory}}
        podspec['containers'].append(container)
    apply(namespace, {'apiVersion': 'v1', 'kind': 'Pod', 'metadata': {'name': 'budgets'}, 'spec': podspec})
    wait_for(lambda: obj(namespace, 'pod', 'budgets'), pod_ready)
    snapshot = '''from pathlib import Path
import json,os
p=Path('/sys/fs/cgroup')
assert (p/'cgroup.controllers').exists(), 'this example requires cgroup v2'
print(json.dumps({'cpu_max':(p/'cpu.max').read_text().strip(),
                  'cpu_weight':int((p/'cpu.weight').read_text()),
                  'memory_max':int((p/'memory.max').read_text()),
                  'group_inode':os.stat(p).st_ino}))
'''
    observations = {}
    for name, expected_cpu, expected_memory in [('small', 0.1, 64), ('large', 0.2, 96)]:
        data = json.loads(k('exec', '-n', namespace, 'budgets', '-c', name, '--', 'python3', '-c', snapshot).stdout)
        quota, period = map(int, data['cpu_max'].split())
        assert quota / period == expected_cpu
        assert data['memory_max'] == expected_memory * 1024 * 1024
        observations[name] = data
    assert observations['small']['group_inode'] != observations['large']['group_inode']
    load = '''from pathlib import Path
import json,time
p=Path('/sys/fs/cgroup/cpu.stat')
def counters(): return {k:int(v) for k,v in (line.split() for line in p.read_text().splitlines())}
before=counters(); begin=time.monotonic(); cpu=time.process_time(); value=0
while time.monotonic()-begin < 3: value=(value+1)%1000003
after=counters()
print(json.dumps({'wall_seconds':time.monotonic()-begin,'process_cpu_seconds':time.process_time()-cpu,
                  'nr_throttled_delta':after['nr_throttled']-before['nr_throttled'],
                  'throttled_usec_delta':after['throttled_usec']-before['throttled_usec']}))
'''
    throttling = json.loads(k('exec', '-n', namespace, 'budgets', '-c', 'small', '--', 'python3', '-c', load).stdout)
    assert throttling['nr_throttled_delta'] > 0 and throttling['throttled_usec_delta'] > 0
    final = obj(namespace, 'pod', 'budgets')
    assert pod_ready(final) and all(c['restartCount'] == 0 for c in final['status']['containerStatuses'])
    assert 'resources' not in final['spec']
    print(json.dumps({'container_groups': observations, 'measured_throttling': throttling,
                      'container_restarts': 0, 'pod_level_resource_field_used': False}))
finally:
    k('delete', 'namespace', namespace, '--wait=true', '--timeout=90s')

两个容器位于同一 Pod,却暴露不同的控制组目录身份。较小容器的 cpu.max 为 10000 100000,memory.max 为 67108864;较大容器分别为 20000 100000 和 100663296。观察到的 CPU 权重是六与十一。这些是当前运行时映射结果,不是要求所有安装都必须重现的通用常量,代码也没有把权重数字当成可移植断言。

在较小容器中执行约三秒繁忙循环,节流周期增加三十次,节流时间约增加 2.707 秒。墙钟经过约 3.007 秒,进程实际 CPU 时间约为 0.298 秒。两个容器最终都保持就绪,重启次数为零。全部断言通过,命名空间随后清理,结果显示 CPU 约束确实影响了实际执行,而不是仅存在于清单。

实验验证不同容器的真实配置与 CPU 节流,没有测量争用工作负载之间的公平性,没有展示所有祖先配置,也没有测试 Pod 级资源分配。把底层控制器观测转化为运维结论时,需要保留这些边界,不能从一次成功节流推导全部共享资源保障。

来源