ElIauk

K8s 生产实践:资源限制与 QoS

requests 和 limits 到底在管什么 requests 是调度器用来挑节点的依据,也是 QoS 和驱逐排序的依据;limits 是运行时内核真正执行的硬边界。两者的语义完全不同,混填是生产事故的常见起点。 CPU 是可压缩资源,超过 limit 不会杀进程,只会被 CFS 节流,表现为延

评论
K8s 生产实践:资源限制与 QoS

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。

参考

评论

推荐阅读

订阅邮件

订阅更新,及时收到最新文章。

作者