Resource requests et limits dans Kubernetes
1. Aperçu
Quand vous exécutez plusieurs Pod sur le même cluster, vous devez dire à Kubernetes combien de CPU et de mémoire chaque container consomme. Sans les déclarer, un service peut monopoliser les ressources du nœud et impacter les autres. Si vous les déclarez mal, le Pod peut être OOMKilled en continu ou s’exécuter lentement à cause du throttling.
Cet article explique ce que sont requests et limits et comment Kubernetes les utilise. Il couvre aussi la classe QoS et comment configurer une application Java/Spring Boot pour qu’elle soit cohérente avec ces valeurs.
2. Qu’est-ce que les requests et les limits
Chaque container dans un Pod peut déclarer deux types de valeurs :
- requests : la quantité minimale de ressources garantie au container. Le scheduler utilise cette valeur pour choisir le nœud où placer le Pod.
- limits : le plafond maximal que le container peut utiliser. Kubelet et le container runtime appliquent cette limite au niveau du kernel.
Les deux ressources les plus courantes sont cpu et memory. Il existe aussi ephemeral-storage et les ressources étendues comme le GPU.
2.1. Unités de CPU et de mémoire
CPU se mesure en cores. 1 signifie un vCPU, tandis que 500m (500 millicores) signifie un demi-core. La mémoire se mesure en octets. Kubernetes accepte les suffixes binaires (Ki, Mi, Gi) et décimaux (K, M, G).
Les débutants confondent souvent 512M et 512Mi. Pour bien comprendre la différence, voici une petite classe de conversion :
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);
}
}
La méthode cpuToMillicores gère deux formats : avec le suffixe m et les nombres décimaux comme 1.5. La méthode memoryToBytes vérifie les suffixes à deux caractères (Mi) avant les suffixes à un caractère (M), car 512Mi se termine aussi par i et ne doit pas être confondu.
Testez avec JUnit 5 et 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);
}
}
Les deux valeurs diffèrent d’environ 24 MB. Pour un container proche de sa limite, cette différence peut faire la différence entre un Pod qui survit et un Pod qui se fait tuer.
Note : N’écrivez pas
memory: 512m. La lettremsignifie généralement « milli », donc cette valeur est 0,512 octet. Kubernetes l’accepte quand même, et l’erreur est très difficile à détecter.
3. Déclaration dans un Deployment
Voici un exemple avec un order-service écrit 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"
Voici ce que chaque valeur signifie :
requests.cpu: 500m: le scheduler ne place le Pod que sur des nœuds ayant au moins 0,5 core disponible non demandé.requests.memory: 512Mi: similaire, mais pour la mémoire.limits.cpu: "1": le container peut utiliser au maximum 1 core. Au-delà, il est limité (throttled).limits.memory: 768Mi: au-delà, le kernel tue le container.
Appliquez le manifest et vérifiez le résultat :
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. Comment le scheduler utilise les requests
Le scheduler ne regarde pas l’utilisation réelle des nœuds. Il fait juste la somme des requests de tous les Pod du nœud et la compare avec l’allocatable. Vous pouvez voir ces chiffres avec :
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%)
Le résultat montre deux choses :
- La somme des requests ne dépasse jamais 100% de l’allocatable. Si aucun nœud n’a assez de place, le Pod reste à l’état
Pendingavec un événementInsufficient cpuouInsufficient memory. - La somme des limits peut dépasser 100%. C’est ce qu’on appelle l’overcommit. L’overcommit est sûr quand les Pod n’atteignent pas leurs limites au même moment.
Conséquence : si vous fixez les requests trop haut, le nœud semble « plein » même si le CPU réel est inactif. Si vous les fixez trop bas, de nombreux Pod se retrouvent sur le même nœud et rivalisent pour les ressources.
5. Quand un container dépasse sa limite
CPU et mémoire sont gérés de deux façons très différentes.
5.1. CPU : throttling
CPU est une ressource compressible. Quand le container épuise son quota, le kernel (via cgroup CFS) met en pause le container jusqu’au cycle suivant, généralement 100 ms. Le container ne meurt pas, il s’exécute juste plus lentement. Le symptôme est une augmentation de la latence p99 tandis que kubectl top semble 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
Le premier Pod approche sa limite de 1, donc il est presque certainement limité.
5.2. Mémoire : terminaison
La mémoire est une ressource incompressible. Quand le container dépasse limits.memory, le OOM killer du kernel termine le processus immédiatement :
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
Le code de sortie 137 signifie que le processus a reçu SIGKILL (128 + 9). L’application n’a pas la chance d’exécuter un shutdown hook ou d’écrire dans les logs.
Un autre cas : le container utilise plus que requests.memory mais reste sous la limite. Quand le nœud manque de mémoire, kubelet évincera ces Pod avant ceux qui utilisent exactement ce qu’ils ont demandé.
6. Classe QoS
En fonction de la façon dont vous déclarez les requests et limits, Kubernetes attribue à chaque Pod une classe QoS. Cette classe détermine l’ordre d’éviction quand le nœud manque de ressources.
| Classe QoS | Condition | Ordre d’éviction |
|---|---|---|
Guaranteed |
Tous les containers ont requests == limits pour CPU et mémoire |
Dernier |
Burstable |
Au moins une request ou une limite, mais ne satisfait pas Guaranteed | Au milieu |
BestEffort |
Aucun container ne déclare rien | Premier |
Le Pod order-service ci-dessus appartient à la classe Burstable. Vous pouvez vérifier cela avec :
kubectl get pod -l app=order-service -o jsonpath='{.items[*].status.qosClass}'
Burstable Burstable
Pour un service critique comme payment-service, vous pouvez définir requests égaux à limits pour que le Pod appartienne à la classe Guaranteed :
resources:
requests:
cpu: "1"
memory: "1Gi"
limits:
cpu: "1"
memory: "1Gi"
Si vous déclarez seulement les limits et laissez les requests vides, Kubernetes affecte automatiquement requests égal aux limits. Le Pod devient alors aussi Guaranteed.
7. Applications Java dans les containers
À partir de Java 10, la JVM reconnaît les cgroups. Java 21 supporte à la fois cgroup v1 et v2. La JVM lit limits.memory pour calculer la taille du heap, et limits.cpu pour calculer le nombre de processeurs. Le problème se situe dans les valeurs par défaut : la taille maximale du heap n’est que 25% de la limite de mémoire. Votre application gaspille la plupart de la mémoire demandée.
Ajoutez le composant suivant à Spring Boot pour enregistrer ce que la JVM voit réellement :
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);
}
}
Avec une limite de 768Mi et sans configuration supplémentaire, le log affiche :
INFO ContainerResourceLogger : availableProcessors=1, maxHeap=192Mi
Vous devez ajuster le ratio du heap avec MaxRAMPercentage au lieu de coder en dur -Xmx :
env:
- name: JAVA_TOOL_OPTIONS
value: "-XX:MaxRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError"
Après le redéploiement :
INFO ContainerResourceLogger : availableProcessors=1, maxHeap=576Mi
Les 25% restants sont réservés à metaspace, thread stack, code cache, direct buffer et GC. Ne définissez pas ce ratio à 90% ou plus. Le heap n’est pas plein mais la mémoire totale du processus dépasse la limite, et le container se fait tuer sans aucune OutOfMemoryError dans les logs.
Note :
availableProcessorsest calculé à partir de la limite CPU, puis arrondi à la hausse. Une limite de 500m donne 1 processeur. La JVM choisit généralementSerialGC, et les pools de threads commeForkJoinPool.commonPool()n’ont qu’1 worker. Si votre application a besoin de beaucoup de threads parallèles, revérifiez ces valeurs.
8. Définir des valeurs par défaut pour un namespace
Pour éviter que des Pod BestEffort ne s’infiltrent dans le cluster, vous pouvez utiliser LimitRange pour assigner des valeurs par défaut par 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"
Tout container qui ne déclare pas de resources recevra automatiquement ces valeurs quand il sera créé.
Combinez cela avec ResourceQuota pour limiter les ressources totales que tout le namespace peut demander :
apiVersion: v1
kind: ResourceQuota
metadata:
name: shop-quota
namespace: shop
spec:
hard:
requests.cpu: "8"
requests.memory: "16Gi"
limits.memory: "24Gi"
Quand un namespace a un quota pour CPU ou mémoire, tout Pod qui manque les requests/limits correspondantes sera rejeté par le serveur API. C’est pourquoi LimitRange devrait l’accompagner.
9. Erreurs courantes et quand ne pas l’utiliser
- Ne rien déclarer du tout. Le Pod devient
BestEffort, est évincé en premier, et le scheduler n’a pas de données pour répartir les ressources. - La limite de mémoire est inférieure aux besoins au démarrage. Spring Boot utilise généralement le plus de mémoire lors de la phase d’initialisation du contexte. Mesurez avec
kubectl topsur un cycle de charge réel, puis fixez le chiffre. - Les requests définies trop haut « juste pour être sûr ». Le nœud semble plein alors que le CPU est inactif, ce qui fait que le cluster autoscaler ajoute des nœuds inutiles et coûte cher.
- La limite CPU est trop stricte pour les applications JVM. Le compilateur JIT et le GC s’exécutent en parallèle avec l’application, ils sont donc facilement limités au démarrage. Le résultat est un démarrage lent et l’échec des readiness probes.
- Définir
-Xmxexactement égal à la limite de mémoire. Le container sera certainement tué, car la JVM utilise aussi de la mémoire en dehors du heap.
Note : Beaucoup d’équipes choisissent de ne pas définir de limite CPU pour les services sensibles à la latence, en définissant seulement les requests CPU. Le container peut alors utiliser le CPU inactif du nœud sans être limité, tandis que la request garantit un minimum. Cette approche ne convient pas aux clusters partagés entre plusieurs équipes, car sans limite, un service défaillant peut ralentir tous les autres. À l’inverse, vous devez toujours définir une limite de mémoire : sans limite de mémoire, une fuite mémoire peut entrainer tout le nœud en manque de mémoire.
10. Conclusion
Dans cet article, vous avez vu que les requests déterminant la scheduling et le niveau minimum de ressources garanti, tandis que les limits définissent le plafond. Dépasser la limite CPU ralentit le container, dépasser la limite de mémoire le tue. La façon dont vous combinez ces deux valeurs crée une classe QoS, et la classe QoS détermine l’ordre d’éviction quand le nœud manque de ressources.
Pour les applications Java, utilisez MaxRAMPercentage pour que le heap soit proportionnel à la limite de mémoire, et laissez assez d’espace pour la partie non-heap. Enfin, utilisez LimitRange et ResourceQuota pour que tout le namespace suive un même standard. Les meilleurs chiffres viennent toujours de la mesure de la charge réelle, pas de suppositions.