Resource requests y limits en Kubernetes

1. Descripción general

Cuando ejecutas múltiples Pods en el mismo cluster, necesitas indicarle a Kubernetes cuánta CPU y memory necesita cada container. Si no lo declaras, un servicio podría ocupar todos los recursos del node e impactar a otros servicios. Si lo declaras incorrectamente, el Pod podría ser OOMKilled continuamente o ejecutarse lentamente porque está siendo throttled.

Este artículo explica qué son requests y limits, y cómo los utiliza Kubernetes. También trata sobre la clase QoS y cómo configurar aplicaciones Java/Spring Boot para que sean compatibles con estos valores.

2. ¿Qué son requests y limits?

Cada container en un Pod puede declarar dos tipos de valores:

  • requests: la cantidad mínima de recursos que se garantiza al container. El scheduler usa este valor para elegir el node donde colocar el Pod.
  • limits: el máximo que el container está permitido usar. Kubelet y el container runtime aplican este límite a nivel de kernel.

Los dos tipos de recursos más comunes son cpu y memory. También existen ephemeral-storage y extended resources como GPU.

2.1. Unidades de CPU y memory

CPU se mide en cores. 1 significa un vCPU, mientras que 500m (500 millicores) significa medio core. Memory se mide en bytes. Kubernetes acepta sufijos binarios (Ki, Mi, Gi) y decimales (K, M, G).

Los principiantes confunden a menudo 512M con 512Mi. Para ver claramente la diferencia, escribamos una clase pequeña que convierta estos valores:

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);
    }
}

El método cpuToMillicores maneja dos formatos: con sufijo m y números decimales como 1.5. El método memoryToBytes verifica los sufijos de dos caracteres (Mi) antes que los de uno (M), porque "512Mi" también termina en "i" y no debe confundirse.

Test con JUnit 5 y 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);
    }
}

Los dos valores difieren aproximadamente 24 MB. Para un container cerca del límite, eso es suficiente para decidir si el Pod sobrevive o es eliminado.

Nota: No escribas memory: 512m. La letra m generalmente significa “milli”, así que este valor es 0,512 bytes. Kubernetes aún lo acepta, y el error es muy difícil de detectar.

3. Declaración en un Deployment

El siguiente ejemplo usa un order-service escrito en Spring Boot:

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"

El significado de cada valor:

  • requests.cpu: 500m: el scheduler solo coloca el Pod en un node que tenga al menos 0,5 cores sin usar solicitados.
  • requests.memory: 512Mi: lo mismo, pero para memory.
  • limits.cpu: "1": el container puede usar como máximo 1 core. Si lo supera, será throttled.
  • limits.memory: 768Mi: si lo supera, el kernel mata el container.

Aplica el manifest y verifica el resultado:

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. Cómo el scheduler usa los requests

El scheduler no mira el uso actual del node. Solo suma los requests totales de los Pods que ya están en el node y lo compara con el allocatable. Puedes ver este número:

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%)

El resultado muestra dos cosas:

  1. Los requests totales nunca superan el 100% del allocatable. Si ningún node tiene espacio suficiente, el Pod permanece en estado Pending con un evento Insufficient cpu o Insufficient memory.
  2. Los limits totales pueden superar el 100%. Esto se llama overcommit. El overcommit es seguro cuando los Pods no usan sus límites al mismo tiempo.

Consecuencia: si estableces requests muy alto, el node parece “lleno” aunque la CPU esté realmente disponible. Si lo estableces muy bajo, muchos Pods se amontonan en un node y compiten por recursos.

5. Cuando un container supera el límite

CPU y memory se manejan de dos formas muy diferentes.

5.1. CPU: es throttled

CPU es un recurso comprimible. Cuando el container agota su cuota, el kernel (a través de cgroup CFS) pausa el container hasta el siguiente ciclo, generalmente de 100ms. El container no muere, solo se ejecuta más lentamente. El síntoma es que la latencia p99 se dispara mientras kubectl top se ve normal.

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

El primer Pod está cerca del límite de 1, así que casi seguramente está siendo throttled.

5.2. Memory: es killed

Memory es un recurso no comprimible. Cuando el container supera limits.memory, el OOM killer del kernel termina el proceso inmediatamente:

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

El código de salida 137 significa que el proceso recibió SIGKILL (128 + 9). La aplicación no tiene oportunidad de ejecutar shutdown hooks o escribir logs.

Otro caso: el container usa más que requests.memory pero menos que el límite. Cuando el node tiene falta de memory, kubelet evictará estos Pods antes que aquellos que usan exactamente lo que solicitaron.

6. Clase QoS

Basándose en cómo declares requests y limits, Kubernetes asigna a cada Pod una clase QoS. Esta clase determina el orden de evicción cuando el node tiene falta de recursos.

QoS class Condición Orden de evicción
Guaranteed Todos los containers tienen requests == limits para CPU y memory Último
Burstable Al menos un request o limit, pero no cumple Guaranteed En medio
BestEffort Ningún container declara nada Primero

El Pod order-service anterior pertenece a la clase Burstable. Lo verificas con:

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

Para un servicio importante como payment-service, puedes establecer requests iguales a limits para que el Pod pertenezca a la clase Guaranteed:

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

Si solo declaras limits pero dejas requests vacío, Kubernetes asigna automáticamente requests iguales a limits. El Pod también se convierte en Guaranteed.

7. Aplicación Java en un container

Desde Java 10, la JVM reconoce cgroups. Java 21 soporta tanto cgroup v1 como v2. La JVM lee limits.memory para calcular el tamaño del heap, y lee limits.cpu para calcular el número de procesadores. El problema está en los valores por defecto: el heap máximo es solo 25% del límite de memory. Tu aplicación desperdicia la mayoría de la memory que solicitó.

Agrega el siguiente componente a Spring Boot para registrar lo que realmente ve la 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);
    }
}

Con el límite 768Mi y sin configuración adicional, el log muestra:

INFO  ContainerResourceLogger : availableProcessors=1, maxHeap=192Mi

Deberías ajustar la proporción del heap usando MaxRAMPercentage en lugar de hardcodear -Xmx:

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

Después de redeplegar:

INFO  ContainerResourceLogger : availableProcessors=1, maxHeap=576Mi

El 25% restante se dedica a metaspace, thread stack, code cache, direct buffer y GC. No establecerás esta proporción al 90% o superior. Aunque el heap no esté lleno, la memoria total del proceso habrá superado el límite, y el container será OOMKilled sin ningún OutOfMemoryError en el log.

Nota: availableProcessors se calcula a partir del límite de CPU, redondeado hacia arriba. Un límite de 500m resulta en 1 procesador. Entonces la JVM típicamente elige SerialGC, y los thread pools como ForkJoinPool.commonPool() solo tienen 1 worker. Si tu aplicación necesita muchos threads en paralelo, verifica estos valores de nuevo.

8. Establecer valores por defecto para un namespace

Para evitar que los Pods BestEffort entren en el cluster, puedes usar LimitRange para asignar valores por defecto por 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"

Cualquier container que no declare resources recibirá automáticamente estos valores cuando se cree.

Combínalo además con ResourceQuota para limitar los recursos totales que todo el namespace puede solicitar:

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

Cuando el namespace tiene quota para CPU o memory, el API server rechazará cualquier Pod que no tenga requests/limits correspondientes. Esta es la razón por la que debes usar LimitRange.

9. Errores comunes y cuándo no usarlos

  • No declarar nada. El Pod se convierte en BestEffort, será evictado primero, y el scheduler no tiene datos para distribuir.
  • Límite de memory menor que la necesidad al arrancar. Spring Boot generalmente usa más memory durante la fase de inicialización de contexto. Mide con kubectl top durante un ciclo de carga real antes de fijar el número.
  • Requests muy alto “por si acaso”. El node parece lleno aunque la CPU esté disponible, lo que causa que el cluster autoscaler agregue nodos innecesarios y cueste dinero.
  • Límite de CPU muy ajustado para aplicación JVM. El compilador JIT y GC corren en paralelo con la aplicación, así que se throttle fácilmente al arrancar. El resultado es un startup lento y fallos en readiness probes.
  • Establecer -Xmx igual al límite de memory. El container definitivamente será OOMKilled, porque la JVM usa memory fuera del heap.

Nota: Muchos equipos eligen no establecer límite de CPU para servicios sensibles a latencia, solo establecen request de CPU. El container entonces puede usar la CPU disponible del node sin ser throttled, mientras que el request aún garantiza un mínimo. Este enfoque no es adecuado para clusters compartidos entre múltiples equipos, porque sin límites, un servicio con error puede ralentizar todos los demás. Por el contrario, siempre deberías establecer un límite de memory: sin límite de memory, una memory leak podría llevar todo el node a una situación de falta de memory.

10. Conclusión

En este artículo, has visto cómo requests determina scheduling y el nivel de recursos garantizado, mientras que limits establece el techo. Superar el límite de CPU causa throttling del container, y superar el límite de memory causa OOMKilled. La forma en que combinas estos dos valores crea la clase QoS, y la clase QoS determina el orden de evicción cuando el node tiene falta de recursos.

Para aplicaciones Java, usa MaxRAMPercentage para que el heap escale con el límite de memory, y deja suficiente espacio para la parte non-heap. Finalmente, usa LimitRange y ResourceQuota para que todo el namespace siga el mismo estándar. Los mejores números siempre vienen de medir cargas reales, no de adivinanzas.

kubernetesresource-managementjvmdevopsspring-boot