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 lettre m signifie 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 :

  1. 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 Pending avec un événement Insufficient cpu ou Insufficient memory.
  2. 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 : availableProcessors est calculé à partir de la limite CPU, puis arrondi à la hausse. Une limite de 500m donne 1 processeur. La JVM choisit généralement SerialGC, et les pools de threads comme ForkJoinPool.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 top sur 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 -Xmx exactement é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.

kubernetesresource-managementjvmdevopsspring-boot