requests 和 limits 到底在管什么
requests 是调度器用来挑节点的依据,也是 QoS 和驱逐排序的依据;limits 是运行时内核真正执行的硬边界。两者的语义完全不同,混填是生产事故的常见起点。
CPU 是可压缩资源,超过 limit 不会杀进程,只会被 CFS 节流,表现为延迟毛刺;内存不可压缩,超过 limit 直接 OOMKill,进程连堆栈都来不及打。所以 CPU limit 在延迟敏感的服务上经常干脆不设,让节点在空闲时把算力借出来;内存 limit 必须设,而且要设得比堆大一圈。
很多团队遇到的“压测时 P99 突然跳到 200ms 又自己恢复”,八成是 CPU 被节流,而不是代码变慢。判断方法很简单:看容器指标里的 container_cpu_cfs_throttled_periods_total 有没有持续增长。
QoS 三档与驱逐顺序
Kubernetes 按 requests 与 limits 的关系把 Pod 分成三档:
| QoS | 判定条件 | 节点压力下的命运 |
|---|---|---|
| Guaranteed | 每个容器都设了 requests 且等于 limits(CPU 和内存都要) | 最后才被驱逐 |
| Burstable | 设了 requests,但不满足 Guaranteed | 按超出 requests 的程度排序驱逐 |
| BestEffort | 一个都没设 | 第一个被驱逐 |
节点内存紧张时,kubelet 先杀 BestEffort,再按“实际用量超出 requests 的幅度”杀 Burstable,最后才动 Guaranteed。这意味着:一个 requests 写 512Mi、实际吃 3Gi 的 Java 服务,在节点压力下比谁都危险,因为它超出 requests 的倍数最大。
Java 进程在容器里的内存账
JDK 21 默认开启容器感知,-Xmx 不写时会按 MaxRAMPercentage(默认 25%)从 limit 算堆。极限情况下,2Gi 的容器只拿到 512Mi 堆,剩下 1.5Gi 里除了元空间、线程栈、直接内存这些固定开销,大部分是闲置的——既不能当堆用,也不会自动借给别的实例。
容器内存的真实构成是:堆 + Metaspace + 代码缓存 + 线程栈(默认 1Mi × 线程数)+ 直接内存(Netty、NIO)+ GC 自身的辅助结构 + JVM 与本地库的固定开销。经验值是堆占 limit 的 60%–75%,留给非堆 25% 以上。线程数高的服务要往 60% 靠,因为每多 200 个线程就多 200Mi 的栈。
还有一处常被忽略的账:CPU 核数。JVM 会按容器的 CPU 限制推导 Runtime.availableProcessors(),并据此决定 GC 线程数、ForkJoinPool 并行度、连接池默认大小。只写 requests 不写 limits 时,JVM 可能看到整台机器的核数,开出一堆线程去抢 1 核的配额,于是 CPU 节流和上下文切换一起出现。这种情况建议显式加 -XX:ActiveProcessorCount=2,让并发度与真实配额对齐。
一份可用的 Deployment 片段
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-api
namespace: shop-prod
spec:
replicas: 6
selector:
matchLabels:
app: order-api
template:
metadata:
labels:
app: order-api
spec:
containers:
- name: app
image: registry.example.com/shop/order-api:2026.01.3
resources:
requests:
cpu: "1"
memory: 2Gi
limits:
memory: 2Gi
env:
- name: JAVA_TOOL_OPTIONS
value: >-
-XX:MaxRAMPercentage=70
-XX:InitialRAMPercentage=70
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:+ExitOnOutOfMemoryError
-XX:NativeMemoryTracking=summary
startupProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
periodSeconds: 5
failureThreshold: 30
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
periodSeconds: 5
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
periodSeconds: 10
failureThreshold: 3
注意这里没写 CPU limit:requests.cpu 保证调度,limit 留空避免节流。要设的话,至少给到 requests 的 2 倍,否则 GC 线程和 IO 线程一抢就超。
排查步骤
Pod 被杀的现场通常只剩一个退出码,按下面顺序还原:
# 1. 看状态与退出码,137 = 被 SIGKILL,OOMKilled 一般也是 137
kubectl describe pod -n shop-prod order-api-7d9f8c6b5-4xk2p | grep -A6 'Last State'
# 2. 看 QoS 档位
kubectl get pod -n shop-prod order-api-7d9f8c6b5-4xk2p \
-o jsonpath='{.status.qosClass}{"\n"}'
# 3. 看节点内存压力与驱逐事件
kubectl get events -n shop-prod --field-selector reason=Evicted --sort-by=.lastTimestamp
# 4. 进容器核对 JVM 实际拿到的堆
kubectl exec -n shop-prod deploy/order-api -- jcmd 1 VM.flags | tr ' ' '\n' | grep -i maxheap
如果 OOMKilled 且退出码 137、但堆日志里没有任何 OutOfMemoryError,说明超的往往不是堆,而是元空间、直接内存或者线程栈。这时用 NativeMemoryTracking=summary 加 jcmd 1 VM.native_memory summary 看各分类占用,比盲目调大 limit 有效。
常见坑
requests 写得比实际用量小一个数量级。 调度器以为节点很空,把十几个 Pod 塞满一台机器,结果每个都在抢 CPU、都在被驱逐,集群看着健康,服务全是毛刺。
内存 limit 等于堆大小。 只算了 -Xmx,没算元空间和线程栈,跑一段时间类加载变多就 OOMKilled,重启后又能撑几天,形成周期性的“莫名重启”。
用 MaxRAMPercentage 又同时写 -Xmx。 两个都写时 -Xmx 优先,容器 limit 改了堆却不变,扩容完全无效,这是配置漂移的重灾区。
CPU limit 设得刚好等于 requests。 启动阶段 JIT 编译和类加载的瞬时占用会超过 1 核,被节流后启动变慢,配合过短的 startupProbe 直接变成 CrashLoopBackOff。
评论