Kubernetes 调度判断的是 Pod 在资源请求和其他放置约束下是否适合某个节点,不是简单查看当前 CPU 曲线,然后把任务塞进看起来最空闲的位置。请求为未来需求提供记账输入,实际使用率则是另一种观测。决定一个表面空闲的集群是否有能力容纳特定 Pod 之前,需要同时理解两者,不能让其中一个指标替代全部容量判断。

以准入后的真实配置为依据

对于普通应用容器,调度器在判断节点是否容纳 Pod 时,会计算它们的请求。初始化需求、Pod 额外开销以及受支持的 Pod 级资源配置,也可能影响有效请求。最终经过准入的 Pod 才是实际输入,包括默认值和修改结果,而不只是操作者记得提交的源文件。排查时应读取保存下来的对象,确认控制面实际看到什么。

Node allocatable 也不同于硬件总容量,它表示按照节点配置可以提供给 Pod 的资源量。其他工作负载即使暂时空闲,其资源请求仍然占用调度记账中的空间。不过,请求并不表示应用已经实际分配了同样多的物理内存,也不表示独占相应数量的 CPU 核心。放置记账、实时消耗和独占策略属于不同机制,不能混用“预留”一词解释。

限制通过执行栈约束运行中的工作负载,通常不会在调度检查时替代已经显式填写的较小请求。因此,一个容器的 CPU limit 可以大于单个节点 CPU 容量,同时仍然凭较小 request 完成调度。这个被接受的限制不会制造额外处理器,也不承诺进程一定能够达到填写的上限;它只是配置了允许使用的边界。

坏掉的样子

Pending 可能源于请求资源不足,也可能由污点、亲和性、存储、拓扑或其他条件阻塞。改变资源值之前,应查看 PodScheduled 条件和调度事件。降低请求无法修复不匹配的节点选择器或不可用的卷;如果原请求本来真实,盲目降低还可能把调度失败转化成运行期争用,并没有建立合理容量方案。

默认值让省略字段尤其容易误读。填写 limit 而不填 request,且没有准入机制提供另一默认请求时,Kubernetes 会把该资源限制复制为请求。因此,只设置一个很大的 limit,也可能形成很大的调度需求。应查看实际对象中的两个映射,并考虑 LimitRange 的影响,再解释放置失败,不能假定省略请求就等于完全没有记账成本。

API 还会拒绝请求大于对应限制的配置。这属于准入校验失败,不是调度器在判断集群容量。区分这两个阶段,可以解释为什么一份清单根本无法创建 Pod,而另一份会创建可检查的对象,只是暂时没有分配节点。错误发生位置不同,修复所需证据也不同,不应统一归因于资源不足。

QoS 也不是免疫承诺。在传统每容器规则下,Guaranteed 要求所有相关容器同时具有 CPU 和内存请求与限制,并且分别相等。只让内存相等并不足够。即使类别为 Guaranteed,也可能遇到资源失败,不能用类别代替优先级、容量规划、应用正确性或实际节点压力分析。选择请求应考虑负载证据和故障代价,而不是追求某个标签。

快速验证

准备至少包含一个 Ready、没有污点工作节点的临时 Kubernetes 集群,确保 kubectl 可用,节点已有 python:3.11-slim-bookworm 镜像。保存为 request-placement.py,执行 python3 request-placement.py KUBECONFIG_PATH CONTEXT,明确指定测试参数。账号需要创建、删除命名空间。实测服务端为 Kubernetes 1.34.11,所选节点报告可分配十个 CPU。

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'}}}]}

from decimal import Decimal, ROUND_CEILING

def cpu_quantity(value):
    return Decimal(value[:-1]) / 1000 if value.endswith('m') else Decimal(value)

namespace = 'notes-requests-' + uuid4().hex[:8]
k('create', 'namespace', namespace)
try:
    workers = [n for n in obj(namespace, 'nodes')['items'] if
               not n['spec'].get('unschedulable') and not n['spec'].get('taints')
               and any(c['type'] == 'Ready' and c['status'] == 'True' for c in n['status']['conditions'])]
    assert workers, 'a Ready worker without taints is required'
    worker = workers[0]
    allocatable = cpu_quantity(worker['status']['allocatable']['cpu'])
    too_large = str(int(allocatable.to_integral_value(rounding=ROUND_CEILING)) + 1)
    for name, request in [('burst', '10m'), ('oversized', too_large)]:
        podspec = spec()
        podspec['nodeSelector'] = {'kubernetes.io/hostname': worker['metadata']['labels']['kubernetes.io/hostname']}
        podspec['containers'][0]['resources'] = {
            'requests': {'cpu': request, 'memory': '24Mi'}, 'limits': {'cpu': too_large, 'memory': '96Mi'}}
        apply(namespace, {'apiVersion': 'v1', 'kind': 'Pod', 'metadata': {'name': name}, 'spec': podspec})
    burst = wait_for(lambda: obj(namespace, 'pod', 'burst'), pod_ready)
    assert burst['spec']['nodeName'] == worker['metadata']['name']
    def insufficient(p):
        return any(c['type'] == 'PodScheduled' and c['status'] == 'False'
                   and c.get('reason') == 'Unschedulable' and 'Insufficient cpu' in c.get('message', '')
                   for c in p.get('status', {}).get('conditions', []))
    pending = wait_for(lambda: obj(namespace, 'pod', 'oversized'), insufficient)
    assert pending['status']['phase'] == 'Pending' and not pending['spec'].get('nodeName')
    only_limit = spec()
    only_limit['containers'][0]['resources'] = {'limits': {'cpu': '200m', 'memory': '64Mi'}}
    apply(namespace, {'apiVersion': 'v1', 'kind': 'Pod', 'metadata': {'name': 'defaulted'}, 'spec': only_limit})
    defaulted = wait_for(lambda: obj(namespace, 'pod', 'defaulted'), pod_ready)
    resources = defaulted['spec']['containers'][0]['resources']
    assert resources['requests'] == resources['limits'] == {'cpu': '200m', 'memory': '64Mi'}
    assert defaulted['status']['qosClass'] == 'Guaranteed'
    invalid = spec()
    invalid['containers'][0]['resources'] = {'requests': {'cpu': '200m'}, 'limits': {'cpu': '100m'}}
    denied = k('create', '-n', namespace, '-f', '-', payload={
        'apiVersion': 'v1', 'kind': 'Pod', 'metadata': {'name': 'invalid'}, 'spec': invalid}, expected=None)
    assert denied.returncode != 0 and 'must be less than or equal to cpu limit' in denied.stderr
    print(json.dumps({'node_allocatable_cpu': str(allocatable), 'limit_cpu': too_large,
                      'small_request_with_large_limit_scheduled': True,
                      'larger_than_node_request_pending': True,
                      'limit_only_defaulted_resources': resources,
                      'defaulted_qos': defaulted['status']['qosClass'],
                      'request_above_limit_rejected': True}))
finally:
    k('delete', 'namespace', namespace, '--wait=true', '--timeout=90s')

程序用 nodeSelector 指向该工作节点,仍让调度器负责实际分配。两个对照 Pod 的 CPU limit 都是十一核。第一个只请求十毫核,最终在指定节点 Ready;第二个请求十一核,保持 Pending,没有 nodeName,PodScheduled 报告 Unschedulable,并包含 Insufficient cpu。两者的程序都只空闲等待,没有真的尝试消耗十一核。

第三个 Pod 只填写 200m CPU 与 64Mi 内存限制。存储对象中出现完全相同的请求,实际 QoS 类别为 Guaranteed。第四个尝试请求 200m,却限制为 100m,被 API 拒绝。全部断言通过,命名空间随后删除。这些真实对象分别展示了成功放置、调度拒绝、默认填充和结构校验,而不是只计算一张假想容量表。

实验没有测量真实峰值容量、资源管理器独占行为或所有调度插件。生产请求需要从工作负载证据出发,把实际准入需求与使用量共同审阅,不能将一张空闲曲线或熟悉的请求比例当成放置保证,也不能把 kind 节点报告的容量直接当作生产机器基准。

来源