Kubernetes 中的 Resource requests 和 limits

1. 概览

当在同一个集群上运行多个 Pod 时,你需要告诉 Kubernetes 每个容器需要多少 CPU 和内存。如果不声明,某个 service 可能会占用整个节点的资源,影响其他 service。如果声明不当,Pod 可能会频繁被 OOMKilled 或由于被 throttle 而运行缓慢。

本文解释了什么是 requests 和 limits,以及 Kubernetes 如何使用它们。本文还讨论了 QoS class,以及如何配置 Java/Spring Boot 应用以适配这些值。

2. Requests 和 limits 是什么

Pod 中的每个容器可以声明两种类型的值:

  • requests:容器被保证的最少资源量。调度器使用这个值来选择放置 Pod 的节点。
  • limits:容器被允许使用的最大值。Kubelet 和容器运行时在内核级别实施这个限制。

最常见的两种资源类型是 cpu 和 memory。还有 ephemeral-storage 和扩展资源如 GPU。

2.1. CPU 和内存的单位

CPU 按核心计算。1 表示一个 vCPU,500m(500 毫核)表示半个核心。内存按字节计算。Kubernetes 接受二进制后缀(Ki、Mi、Gi)和十进制后缀(K、M、G)。

新手容易混淆 512M 和 512Mi。为了清楚地看到差异,我们编写一个小类来转换这些值:

import java.util.Map;

public final class ResourceQuantity {

    private static final Map<String, Long> SUFFIXES = Map.of(
            "Ki", 1024L,
            "Mi", 1024L * 1024,
            "Gi", 1024L * 1024 * 1024,
            "K", 1_000L,
            "M", 1_000_000L,
            "G", 1_000_000_000L
    );

    private ResourceQuantity() {
    }

    public static long cpuToMillicores(String value) {
        if (value.endsWith("m")) {
            return Long.parseLong(value.substring(0, value.length() - 1));
        }
        return Math.round(Double.parseDouble(value) * 1000);
    }

    public static long memoryToBytes(String value) {
        for (String suffix : new String[]{"Ki", "Mi", "Gi", "K", "M", "G"}) {
            if (value.endsWith(suffix)) {
                String number = value.substring(0, value.length() - suffix.length());
                return Long.parseLong(number) * SUFFIXES.get(suffix);
            }
        }
        return Long.parseLong(value);
    }
}

cpuToMillicores 函数处理两种形式:带 m 后缀和如 1.5 的十进制数。memoryToBytes 函数在单字符后缀(M)之前检查两字符后缀(Mi),因为 "512Mi" 也以 "i" 结尾,不能混淆匹配。

用 JUnit 5 和 AssertJ 进行测试:

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class ResourceQuantityTest {

    @Test
    void givenMillicoreOrDecimal_whenConvert_thenReturnsMillicores() {
        assertThat(ResourceQuantity.cpuToMillicores("500m")).isEqualTo(500);
        assertThat(ResourceQuantity.cpuToMillicores("1.5")).isEqualTo(1500);
    }

    @Test
    void givenBinaryAndDecimalSuffix_whenConvert_thenValuesDiffer() {
        long mebibytes = ResourceQuantity.memoryToBytes("512Mi");
        long megabytes = ResourceQuantity.memoryToBytes("512M");

        assertThat(mebibytes).isEqualTo(536_870_912L);
        assertThat(megabytes).isEqualTo(512_000_000L);
        assertThat(mebibytes - megabytes).isEqualTo(24_870_912L);
    }
}

这两个值相差约 24 MB。对于接近限制的容器,这足以决定 Pod 是活着还是被杀死。

注意: 不要写 memory: 512m。字母 m 通常表示“毫”,所以这个值是 0.512 字节。Kubernetes 仍然会接受它,但错误很难被发现。

3. 在 Deployment 中声明

以下示例使用用 Spring Boot 编写的 order-service:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service
spec:
  replicas: 2
  selector:
    matchLabels:
      app: order-service
  template:
    metadata:
      labels:
        app: order-service
    spec:
      containers:
        - name: app
          image: nonla/order-service:1.4.0
          ports:
            - containerPort: 8080
          resources:
            requests:
              cpu: "500m"
              memory: "512Mi"
            limits:
              cpu: "1"
              memory: "768Mi"

各值的含义:

  • requests.cpu: 500m:调度器仅将 Pod 放在至少还有 0.5 个核心未被请求的节点上。
  • requests.memory: 512Mi:类似,但计算内存。
  • limits.cpu: "1":容器最多可以使用 1 个核心。超过这个限制就会被 throttle。
  • limits.memory: 768Mi:超过这个限制,容器会被内核杀死。

应用 manifest 并检查结果:

kubectl apply -f order-service.yaml
kubectl get pod -l app=order-service -o jsonpath='{.items[0].spec.containers[0].resources}'
{"limits":{"cpu":"1","memory":"768Mi"},"requests":{"cpu":"500m","memory":"512Mi"}}

4. 调度器如何使用 requests

调度器 不 查看节点的实际使用情况。它只是将节点上已有 Pod 的 requests 相加,然后与 allocatable 进行比较。你可以查看这些数字:

kubectl describe node worker-1 | grep -A 8 "Allocated resources"
Allocated resources:
  (Total limits may be over 100 percent, i.e., overcommitted.)
  Resource           Requests      Limits
  --------           --------      ------
  cpu                3200m (80%)   6 (150%)
  memory             5Gi (66%)     9Gi (118%)

结果表明两点:

  1. 总 requests 永远不会超过 100% 的可分配资源。如果没有节点有足够的空间,Pod 会处于 Pending 状态,事件为 Insufficient cpu 或 Insufficient memory。
  2. 总 limits 可以超过 100%。这称为 overcommit。当 Pod 不会同时使用到限制时,overcommit 是安全的。

结果:如果你设置 requests 过高,节点看起来“满了”,尽管实际 CPU 还是闲置的。如果设置过低,许多 Pod 会被挤到一个节点上并争夺资源。

5. 当容器超过限制时

CPU 和内存处理方式完全不同。

5.1. CPU:被 throttle

CPU 是 可压缩的 资源。当容器用完配额时,内核(通过 cgroup CFS)会暂停容器直到下一个周期,通常长度为 100ms。容器不会死亡,只是运行变慢。症状是 latency p99 急剧上升,而 kubectl top 看起来仍然正常。

kubectl top pod -l app=order-service
NAME                             CPU(cores)   MEMORY(bytes)
order-service-7d9f8c6b5-k2xqp    998m         610Mi
order-service-7d9f8c6b5-tm4wz    412m         590Mi

第一个 Pod 接近限制 1,所以几乎肯定被 throttle 了。

5.2. 内存:被杀死

内存是 不可压缩的 资源。当容器超过 limits.memory 时,内核 OOM killer 会立即终止进程:

kubectl describe pod order-service-7d9f8c6b5-k2xqp | grep -A 4 "Last State"
    Last State:     Terminated
      Reason:       OOMKilled
      Exit Code:    137
      Started:      Tue, 13 Oct 2026 09:12:03 +0700

退出代码 137 表示进程收到 SIGKILL (128 + 9)。应用没有机会运行 shutdown hook 或记录日志。

另一种情况:容器使用超过 requests.memory 但仍低于限制。当节点内存不足时,kubelet 会 evict 这些 Pod,优先于使用正确请求量的 Pod。

6. QoS class

根据你声明 requests 和 limits 的方式,Kubernetes 给每个 Pod 分配一个 QoS class。这个 class 决定当节点资源不足时被 evict 的顺序。

QoS class 条件 被 evict 的顺序
Guaranteed 所有容器的 requests == limits,同时包括 CPU 和内存 最后
Burstable 至少有一个 request 或 limit,但不满足 Guaranteed 条件 中间
BestEffort 没有容器声明任何内容 最先

上面的 Pod order-service 属于 Burstable class。你可以用以下命令检查:

kubectl get pod -l app=order-service -o jsonpath='{.items[*].status.qosClass}'
Burstable Burstable

对于像 payment-service 这样重要的 service,你可以设置 requests 等于 limits 使 Pod 属于 Guaranteed class:

resources:
  requests:
    cpu: "1"
    memory: "1Gi"
  limits:
    cpu: "1"
    memory: "1Gi"

如果只声明 limits 而留下 requests 空白,Kubernetes 会自动将 requests 设置为 limits。Pod 将变成 Guaranteed。

7. 容器中的 Java 应用

从 Java 10 开始,JVM 能够识别 cgroup。Java 21 同时支持 cgroup v1 和 v2。JVM 读取 limits.memory 来计算堆大小,读取 limits.cpu 来计算处理器数量。问题在于默认值:最大堆只有 25% 的内存限制。你的应用会浪费大部分请求的内存。

在 Spring Boot 中添加以下组件来记录 JVM 实际看到的内容:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

@Component
public class ContainerResourceLogger {

    private static final Logger log = LoggerFactory.getLogger(ContainerResourceLogger.class);

    @EventListener(ApplicationReadyEvent.class)
    public void logResources() {
        Runtime runtime = Runtime.getRuntime();
        long maxHeapMi = runtime.maxMemory() / (1024 * 1024);
        log.info("availableProcessors={}, maxHeap={}Mi",
                runtime.availableProcessors(), maxHeapMi);
    }
}

使用 768Mi 限制且没有额外配置,日志输出为:

INFO  ContainerResourceLogger : availableProcessors=1, maxHeap=192Mi

你应该使用 MaxRAMPercentage 调整堆的比例,而不是硬编码 -Xmx:

          env:
            - name: JAVA_TOOL_OPTIONS
              value: "-XX:MaxRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError"

重新部署后:

INFO  ContainerResourceLogger : availableProcessors=1, maxHeap=576Mi

剩余的 25% 用于 metaspace、thread stack、code cache、direct buffer 和 GC。不要将这个比例设置为 90% 或更高。堆还没满,但进程的总内存已经超过限制,容器会被 OOMKilled,但日志中没有 OutOfMemoryError。

注意: availableProcessors 从 CPU 限制 计算,然后向上舍入。500m 的限制会产生 1 个处理器。此时 JVM 通常会选择 SerialGC,而像 ForkJoinPool.commonPool() 这样的线程池只有 1 个 worker。如果应用需要大量并行线程,请重新检查这些值。

8. 为 namespace 设置默认值

为了避免 BestEffort Pod 进入集群,你可以使用 LimitRange 为每个 namespace 分配默认值:

apiVersion: v1
kind: LimitRange
metadata:
  name: default-resources
  namespace: shop
spec:
  limits:
    - type: Container
      defaultRequest:
        cpu: "200m"
        memory: "256Mi"
      default:
        cpu: "500m"
        memory: "512Mi"

任何未声明 resources 的容器在创建时将自动接收上述值。

结合 ResourceQuota 来限制整个 namespace 允许请求的总资源:

apiVersion: v1
kind: ResourceQuota
metadata:
  name: shop-quota
  namespace: shop
spec:
  hard:
    requests.cpu: "8"
    requests.memory: "16Gi"
    limits.memory: "24Gi"

当 namespace 对 CPU 或内存有配额时,任何缺少相应 requests/limits 的 Pod 都会被 API server 拒绝。这是应该与 LimitRange 配合的原因。

9. 常见错误和何时不应使用

  • 什么都不声明。 Pod 变成 BestEffort,最先被 evict,调度器没有数据进行分配。
  • 内存限制低于启动时的需求。 Spring Boot 通常在上下文初始化阶段使用最多的内存。通过实际负载周期用 kubectl top 进行测量,然后再确定数字。
  • 请求设置过高“以防万一”。 节点看起来满了虽然 CPU 还是闲置的,导致集群 autoscaler 添加不必要的节点,浪费金钱。
  • JVM 应用的 CPU 限制过紧。 JIT 编译器和 GC 与应用并行运行,所以启动时容易被 throttle。结果是启动缓慢,readiness probe 失败。
  • 将 -Xmx 设置为内存限制。 容器肯定会被 OOMKilled,因为 JVM 还会使用堆外内存。

注意: 许多团队选择为对延迟敏感的 service 不设置 CPU 限制,只设置 CPU 请求。容器可以使用节点的空闲 CPU 而不被 throttle,而请求仍然保证了最低值。这种方法不适合在多个团队共享的集群中使用,因为没有限制时一个故障的 service 可能会拖慢其他 service。相反,你 应该始终设置内存限制:没有内存限制,一个内存泄漏可能会导致整个节点内存不足。

10. 总结

在本文中,你了解了 requests 决定调度和保证的资源级别,而 limits 决定上限。超过 CPU 限制时容器被 throttle,超过内存限制时被 OOMKilled。你如何结合这两个值会产生 QoS class,而 QoS class 决定当节点资源不足时被 evict 的顺序。

对于 Java 应用,使用 MaxRAMPercentage 使堆按内存限制成比例,并为非堆部分保留足够的空间。最后,使用 LimitRange 和 ResourceQuota 确保整个 namespace 遵循相同的标准。最好的数字总是来自测量实际负载,而不是猜测。

kubernetesresource-managementjvmdevopsspring-boot