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 letramgeneralmente 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:
- Los
requeststotales nunca superan el 100% del allocatable. Si ningún node tiene espacio suficiente, el Pod permanece en estadoPendingcon un eventoInsufficient cpuoInsufficient memory. - Los
limitstotales 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:
availableProcessorsse calcula a partir del límite de CPU, redondeado hacia arriba. Un límite de500mresulta en 1 procesador. Entonces la JVM típicamente eligeSerialGC, y los thread pools comoForkJoinPool.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 topdurante 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
-Xmxigual 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.