Kubernetes 的资源 request 与 limit 属于不同决策。请求参与调度和资源共享,限制则配置运行时及操作系统支持的执行约束。把 limit 当成调度器预留的容量,会同时混淆这两个机制。有效的资源策略需要说明放置需求、可以接受的争用,以及运行中的工作负载到达边界后应该发生什么,不能只在清单上填写一组看起来合理的数字。
CPU 与内存具有不同失败方式
在典型 Linux 环境中,CPU 限制会形成带宽约束,繁忙控制组可能被节流,而进程仍然存活。内存限制依赖内存记账和回收;受约束的分配无法满足时,可能触发控制组 OOM 处理。内核不会像推迟 CPU 使用那样,把所有超限内存申请都温和地放慢。两种资源因此需要不同的容量指标和故障判断方式。
内存执行机制具有反应性,不能描述成恰好达到某个字节就调用应用回调,也不能保证使用量绝对不会短暂超过配置值。应用可能收到分配失败,OOM 处理也可能终止进程。应先观察真实终止原因、容器状态和可用的内存事件,再给故障命名,避免仅因为配置了内存限制,就把所有异常退出都解释成越界。
限制覆盖的不只是应用打算保存的业务数据。运行时开销、其他进程,以及被记账的缓存或其他内存,都可能占用控制组额度。容器限制为六十四 MiB,并不意味着它能在已有开销之外,再安全创建同样大小的数据结构。内存支持的卷也可能贡献内存消耗,表现为文件存储并不会让这部分成本消失。
坏掉的样子
退出码 137 对应常见的 SIGKILL 终止表示,本身不足以证明 OOM。应与容器的 OOMKilled 原因以及相关运行时或内核证据结合。节点压力驱逐又是另一种机制,需要检查 Pod 级状态和驱逐决策,不能把一个容器超过自身控制组预算,与 kubelet 为缓解节点压力而移除 Pod 混成同一个过程。
终止后发生什么,由重启策略继续决定。采用 Always 的泄漏程序可能不断重启,而根因始终存在。采用 Never 时,已终止容器保持终止,Pod 可以进入失败状态。限制只约束一种资源维度,既不会修复应用,也不保证邻居在失败发生前完全没有经历争用。是否影响业务延迟,还需要对应观测,不能由限制字段直接推断。
默认值同样重要。如果某项资源填写了 limit,却没有 request,也没有准入机制提供不同默认值,Kubernetes 会把该限制复制为请求。应读取准入后的真实对象,不能认为省略 request 就等于零。仅让内存请求与限制相等,也不足以满足传统 Guaranteed QoS 条件,还需要考虑 CPU 设置及所有相关容器。类别名称也不等于不会失败。
内存应使用明确的 Mi、Gi 等单位,小写 m 表示毫单位,不是兆字节。选择限制需要结合真实工作集与突发量,包含启动过程、并发任务和运行时开销。照抄固定比例或某个分位数,并不能替所有应用决定可接受的 OOM 行为;有状态进程与可重试短任务的恢复代价可能完全不同。
快速验证
本轮没有重跑原样保留的集群实验;以下 Kubernetes 1.34.11 观察属于历史记录。
准备使用 cgroup v2 的临时 Linux Kubernetes 集群、kubectl,以及节点可用的 python:3.11-slim-bookworm 镜像。保存为 memory-limit.py,执行 python3 memory-limit.py KUBECONFIG_PATH CONTEXT,明确指定测试参数。账号需要创建、删除命名空间。实测服务端为 Kubernetes 1.34.11,分配程序最多尝试一百二十八 MiB 载荷,其所在容器的内存限制为六十四 MiB。
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-memory-' + uuid4().hex[:8]
k('create', 'namespace', namespace)
try:
allocator = '''from pathlib import Path
import json,time
group=Path('/sys/fs/cgroup')
print(json.dumps({'memory_max':(group/'memory.max').read_text().strip(),
'initial_memory_current':int((group/'memory.current').read_text())}),flush=True)
while not Path('/tmp/go').exists(): time.sleep(0.1)
chunks=[]
for i in range(16):
chunks.append(bytearray(8*1024*1024))
print('allocated_chunks='+str(len(chunks)),flush=True)
time.sleep(0.05)
raise RuntimeError('bounded allocation unexpectedly escaped the expected limit')
'''
constrained = spec(allocator)
constrained['restartPolicy'] = 'Never'
constrained['containers'][0]['resources'] = {
'requests': {'cpu': '10m', 'memory': '32Mi'}, 'limits': {'cpu': '200m', 'memory': '64Mi'}}
for name, podspec in [('limited', constrained), ('neighbor', spec())]:
apply(namespace, {'apiVersion': 'v1', 'kind': 'Pod', 'metadata': {'name': name}, 'spec': podspec})
wait_for(lambda name=name: obj(namespace, 'pod', name), pod_ready)
first_log = wait_for(lambda: k('logs', '-n', namespace, 'limited').stdout.strip(), bool)
before = json.loads(first_log)
assert before['memory_max'] == str(64 * 1024 * 1024)
k('exec', '-n', namespace, 'limited', '--', 'python3', '-c',
'from pathlib import Path; Path("/tmp/go").touch()')
failed = wait_for(lambda: obj(namespace, 'pod', 'limited'), lambda p: p.get('status', {}).get('phase') == 'Failed')
status = failed['status']['containerStatuses'][0]
terminated = status['state']['terminated']
assert terminated['reason'] == 'OOMKilled' and terminated['exitCode'] == 137
assert status['restartCount'] == 0
assert failed['status'].get('reason') != 'Evicted'
neighbor = obj(namespace, 'pod', 'neighbor')
assert pod_ready(neighbor) and neighbor['status']['containerStatuses'][0]['restartCount'] == 0
output = k('logs', '-n', namespace, 'limited').stdout.splitlines()
chunks = [int(line.split('=')[1]) for line in output if line.startswith('allocated_chunks=')]
assert chunks and max(chunks) < 16
print(json.dumps({'before': before, 'last_reported_allocated_chunks': max(chunks),
'termination_reason': terminated['reason'], 'exit_code': terminated['exitCode'],
'container_restarts_with_never_policy': 0,
'pod_was_not_evicted': True, 'neighbor_still_ready': True}))
finally:
k('delete', 'namespace', namespace, '--wait=true', '--timeout=90s')
第一条日志记录 memory.max 为 67108864 字节。放行文件出现后,原容器进程按每块八 MiB 持续分配。本次运行在终止前报告了六块完成的分配。容器状态明确显示 OOMKilled 和退出码 137,Pod 阶段为 Failed,而不是 Evicted。由于使用 Never 策略,重启次数保持为零,这些信息共同确定了此次失败的性质。
另一个空闲测试 Pod 在最终观察时仍然 Ready,且没有重启。全部断言通过,命名空间随后清理。实验验证一次受约束 OOM 及其生命周期结果,没有测量邻居延迟,也没有建立完整节点隔离。应利用这些失败证据诊断和调整工作负载,不能把清单中存在 limit 直接当成完整可用性保证,更不能忽略真实业务恢复成本。
延伸阅读
来源
- Kubernetes: Resource Management,请求、限制、默认值与 Linux 执行机制,已核验 2026-10-15。
- Kubernetes: Assign Memory Resources,内存限制与实际 OOM 终止,已核验 2026-10-15。
- Linux: Control Group v2,memory.max、回收与控制组 OOM 行为,已核验 2026-10-15。