Aller au contenu
Ressources, planification et qualité de service

Ressources, planification et qualité de service

200 Pratiquer ⏱ 1 h 20 kuberneteskubectlkind

À la fin, vous saurez

  • Distinguer requests et limits, et prévoir l'effet de chacune sur le planificateur et sur le noyau
  • Déterminer la classe de qualité de service d'un pod et l'ordre dans lequel un nœud sous pression évince les pods
  • Encadrer les ressources d'un namespace avec une LimitRange et un ResourceQuota
  • Répartir les réplicas d'un Deployment entre nœuds et zones avec des contraintes de répartition
  • Expliquer les teintes et les tolérances, et le nodeSelector
  • Poser un HorizontalPodAutoscaler sur l'utilisation du processeur, et arbitrer la question des limites de processeur

Prérequis

Testé avec kubectl 1.36.0 kubernetes 1.36 , vérifié le 5 octobre 2026

Pourquoi

Les deux pods de Signalements tournent, configurés (leçon 7), sur le cluster formation. Rien ne dit au cluster de quoi ils ont besoin. Le planificateur les a placés sur un nœud au hasard de la place libre ; le noyau leur laisse prendre autant de mémoire et de processeur qu'ils en demandent. Tant qu'ils sont seuls, tout va bien.

Imaginons la suite, telle qu'elle arrive dans tous les clusters partagés. Un autre service est déployé sur les mêmes nœuds, sans indication non plus. Un soir, l'export nocturne de ce service charge un fichier entier en mémoire. Le nœud manque de mémoire. Le kubelet, puis le noyau, doivent choisir qui tuer, et ils choisissent avec des règles que personne dans l'équipe n'a lues. Le pod de Signalements disparaît, l'autre réplica se trouvait sur le même nœud, et l'API ne répond plus. Le lendemain, quelqu'un met des limites partout « pour être tranquille », et Signalements devient mystérieusement lent aux heures de pointe, alors que les nœuds ont du processeur libre.

Ces deux incidents viennent du même endroit : les ressources déclarées par les conteneurs. Kubernetes en fait trois usages distincts, qu'il faut comprendre séparément : le placement des pods par le planificateur, le plafonnement par le noyau, et le choix des victimes quand un nœud manque de mémoire. Cette leçon les prend dans cet ordre, puis répartit Signalements pour qu'aucune panne d'un nœud ou d'une zone ne l'emporte en entier.

Les concepts

Requests et limits

Chaque conteneur peut déclarer, pour le processeur et la mémoire :

  • une request (requête) : la quantité qu'on lui réserve. Le planificateur ne place le pod que sur un nœud qui a encore cette quantité de libre, et le noyau lui garantit au moins cette part ;
  • une limit (limite) : la quantité qu'il ne dépassera pas. Le noyau l'impose.
          resources:
            requests:
              cpu: 100m
              memory: 256Mi
            limits:
              memory: 256Mi

Les unités demandent de l'attention :

  • Processeur : 1 vaut un cœur (un vCPU sur une machine virtuelle). 100m se lit « cent millicœurs », un dixième de cœur, et équivaut à 0.1. La documentation conseille la forme en m, plus difficile à mal écrire. La plus petite valeur acceptée est 1m.
  • Mémoire : en octets, avec des suffixes décimaux (M, G) ou binaires (Mi, Gi). 256Mi font 268 435 456 octets. Attention à la casse : 400m de mémoire signifie 0,4 octet (le suffixe m désigne des millièmes), une erreur que la documentation cite elle-même.

Ce que fait le planificateur des requests

Le planificateur (kube-scheduler) choisit un nœud en deux étapes. Il filtre d'abord les nœuds qui peuvent accueillir le pod (ressources suffisantes, contraintes respectées) ; ce sont les nœuds faisables. Il note ensuite chacun d'eux selon plusieurs critères, et retient le meilleur.

Pour les ressources, il raisonne uniquement sur les requests, jamais sur la consommation réelle. Un nœud de 2 vCPU dont les pods demandent 1,8 vCPU au total ne peut plus accueillir un pod qui demande 300m, même si ces pods dorment et que le processeur est inoccupé. À l'inverse, un nœud dont les pods ne demandent rien est toujours « libre », même s'il est saturé. Ce que le planificateur compare, c'est la somme des requests à la capacité allouable du nœud, c'est-à-dire sa capacité moins ce que l'on réserve au système et au kubelet.

Conséquence directe : un pod sans request est invisible pour le planificateur. Des pods sans requests s'entassent sur les mêmes nœuds, et le planificateur n'a aucun moyen de les répartir selon leur besoin.

Ce que fait le noyau des limits

Les limits sont transmises au moteur de conteneurs, qui les inscrit dans le cgroup du conteneur ; c'est ensuite le noyau qui les applique, différemment pour les deux ressources. Le cours Docker : les fondamentaux a montré ces fichiers de cgroup à la main.

  • Le processeur est compressible. Une limit de 500m devient cpu.max = 50000 100000 : 50 ms de temps processeur par période de 100 ms. Quand le conteneur a consommé son quota avant la fin de la période, ses processus sont étranglés (throttled) : mis en pause jusqu'à la période suivante, même si le nœud a du processeur libre. La documentation du noyau décrit ce mécanisme, appelé CFS bandwidth control. Le conteneur ne meurt pas ; il ralentit.
  • La mémoire ne l'est pas. Une limit de 256Mi devient memory.max. Quand le conteneur la dépasse et que le noyau ne peut pas récupérer de mémoire (cache de fichiers), l'OOM killer tue un processus du conteneur, le plus souvent le principal : le conteneur termine avec la raison OOMKilled et le code 137, puis le kubelet le redémarre. La documentation précise que cette application est réactive : un conteneur peut momentanément dépasser sa limite, et n'est tué que quand le noyau constate la pression.

Les requests, elles, servent aussi au noyau : la request de processeur fixe le poids du cgroup (cpu.weight), c'est-à-dire la part de processeur garantie au conteneur quand tout le monde en veut.

Les classes de qualité de service

À partir des requests et des limits de ses conteneurs, Kubernetes range chaque pod dans une classe de qualité de service (QoS class), visible dans kubectl get pod -o jsonpath='{.status.qosClass}' :

ClasseCondition (pour tous les conteneurs du pod)Ce que cela vaut
GuaranteedRequests et limits de processeur et de mémoire, non nulles, et égalesLe mieux protégé ; ne dépasse jamais ses limites
BurstablePas Guaranteed, mais au moins une request ou une limit quelque partGaranti jusqu'à ses requests, peut déborder au-delà
BestEffortAucune request ni limit de processeur ou de mémoirePrend ce qui reste, sacrifié en premier

La classe se fixe à la création du pod et ne change plus.

Quand un nœud manque de mémoire

Deux mécanismes interviennent, dans cet ordre.

L'éviction par le kubelet. Le kubelet surveille quelques signaux du nœud et les compare à des seuils d'éviction. Les seuils durs par défaut sur Linux sont memory.available<100Mi, nodefs.available<10%, imagefs.available<15% et nodefs.inodesFree<5%. Quand un seuil est franchi, le nœud passe en état MemoryPressure ou DiskPressure et reçoit la teinte correspondante : sous pression de disque, il n'accepte plus de nouveaux pods ; sous pression de mémoire, il refuse les nouveaux pods BestEffort (les autres reçoivent automatiquement une tolérance). Le kubelet évince alors des pods pour revenir sous le seuil. Il les classe ainsi, d'après la documentation :

  1. d'abord les pods dont la consommation dépasse leurs requests, par priorité puis par ampleur du dépassement ;
  2. en dernier, ceux qui restent sous leurs requests (donc les Guaranteed, et les Burstable sobres), par priorité.

En pratique, cela donne presque l'ordre des classes : BestEffort, puis Burstable qui débordent, puis le reste. Un pod évincé est terminé ; si un Deployment le gère, un nouveau pod est créé, ailleurs si possible.

L'OOM killer du noyau. Si la mémoire s'épuise trop vite pour que le kubelet réagisse, c'est le noyau qui tue. Le kubelet l'a préparé en réglant le score oom_score_adj de chaque conteneur selon sa classe : -997 pour Guaranteed (presque jamais choisi), 1000 pour BestEffort (choisi en premier), et pour Burstable une valeur entre 2 et 999, d'autant plus basse que sa request de mémoire est grande par rapport à la mémoire du nœud.

Le message est simple : la request de mémoire est votre assurance. Un conteneur qui consomme moins que sa request est le dernier sacrifié.

Encadrer un namespace

Deux objets permettent à l'équipe qui administre le cluster de fixer des règles par namespace :

  • une LimitRange donne des valeurs par défaut aux conteneurs qui n'en déclarent pas, et des bornes (minimum, maximum, rapport limite sur request). Elle s'applique à l'admission des pods : modifier une LimitRange ne touche pas les pods déjà en marche ;
  • un ResourceQuota plafonne le total du namespace : somme des requests de processeur, de mémoire, nombre de pods, de Services, de volumes. Dès qu'un quota porte sur le processeur ou la mémoire, chaque pod doit déclarer les valeurs correspondantes, sinon il est refusé (d'où l'intérêt d'une LimitRange qui fournit des défauts). Un dépassement est refusé par le serveur d'API avec un code 403 Forbidden et un message qui nomme le quota.

Choisir les nœuds : sélecteurs, affinités, teintes

Par défaut, un pod peut aller sur n'importe quel nœud faisable. Plusieurs outils orientent ce choix :

  • nodeSelector : le pod ne va que sur les nœuds qui portent certaines étiquettes (labels), par exemple k8s.scaleway.com/pool-name: general. Simple et suffisant dans la plupart des cas.
  • L'affinité de nœud (nodeAffinity) : la même chose avec des expressions (« dans l'une de ces zones »), en version obligatoire ou préférée.
  • L'affinité et l'anti-affinité de pods : placer un pod près ou loin d'autres pods selon leurs étiquettes. L'anti-affinité « jamais deux réplicas sur le même nœud » est l'usage classique ; elle est coûteuse à calculer sur les grands clusters.
  • Les contraintes de répartition (topologySpreadConstraints) : répartir les réplicas équitablement entre des domaines (nœuds, zones), avec un écart maximal (maxSkew). C'est aujourd'hui l'outil recommandé pour la haute disponibilité, et celui qu'utilise la documentation de Scaleway pour les clusters Kapsule répartis sur plusieurs zones.
  • Les teintes et tolérances (taints and tolerations) : l'inverse d'une affinité. Une teinte posée sur un nœud repousse les pods ; seuls ceux qui ont une tolérance correspondante peuvent y aller. Les effets sont NoSchedule (plus de nouveaux pods), PreferNoSchedule (à éviter) et NoExecute (les pods sans tolérance sont aussi chassés). Kubernetes pose lui-même des teintes sur les nœuds en difficulté (node.kubernetes.io/not-ready, node.kubernetes.io/memory-pressure...). Chez Scaleway, une étiquette d'instance de la forme taint=cle=valeur:Effet posée sur un pool devient une teinte k8s.scaleway.com/cle=valeur sur ses nœuds.

Une tolérance permet d'aller sur un nœud teinté, elle n'y oblige pas : pour réserver des nœuds à un usage (des nœuds à processeur graphique, par exemple), on combine une teinte sur les nœuds et une affinité dans les pods.

Redimensionner un pod en place

Depuis Kubernetes 1.35, le redimensionnement en place (in-place pod resize) est stable : on peut changer les requests et limits de processeur et de mémoire d'un pod en marche, par la sous-ressource resize, sans le recréer. Le champ resizePolicy de chaque conteneur dit si le changement s'applique à chaud (NotRequired, par défaut) ou demande un redémarrage du conteneur (RestartContainer). Les limites documentées : la classe de qualité de service ne peut pas changer, une request ou une limit déclarée ne peut pas être retirée, et la diminution d'une limite de mémoire n'est appliquée que si la consommation le permet, sans garantie contre un OOM.

Pour un pod géré par un Deployment, ce redimensionnement est surtout un outil de dépannage ou d'automatisation : le modèle du Deployment, lui, n'a pas changé, et le prochain déploiement recréera les pods avec les anciennes valeurs. La façon normale de changer les ressources d'une application reste de modifier le Deployment.

Mettre à l'échelle automatiquement

Un HorizontalPodAutoscaler (HPA, API autoscaling/v2) ajuste le nombre de réplicas d'un Deployment selon une métrique. Pour une cible d'utilisation du processeur, l'utilisation se calcule par rapport aux requests : 70 % d'utilisation d'un pod qui demande 100m, c'est 70 millicœurs. Toutes les 15 secondes par défaut, le contrôleur calcule :

réplicas voulus = plafond(réplicas actuels × valeur actuelle / valeur cible)

Il ignore les écarts inférieurs à une tolérance (10 % par défaut), et il attend une fenêtre de stabilisation de 5 minutes avant de réduire, pour éviter le va-et-vient. Les métriques de processeur et de mémoire viennent du metrics-server, un composant à part qui doit être installé dans le cluster.

Un HPA sur le processeur n'a de sens qu'avec des requests justes, puisque l'utilisation est calculée par rapport à elles.

En pratique

Les commandes ci-dessous visent le cluster formation, namespace signalements. Les commandes avec --dry-run=client ou --local ne contactent pas le cluster et leurs sorties sont réelles, produites avec kubectl 1.36 ; pour les autres, la leçon décrit ce que vous devez observer.

Mesurer avant de dimensionner

On ne devine pas les ressources d'une application, on les mesure. Installez metrics-server sur kind (le cluster local ne l'a pas par défaut ; sur Kapsule, vérifiez sa présence avec kubectl get deployment -n kube-system metrics-server) en suivant le dépôt kubernetes-sigs/metrics-server. Sur kind, il faut lui ajouter l'option --kubelet-insecure-tls, parce que les certificats des kubelets de kind ne sont pas signés pour leurs adresses : une concession acceptable sur un cluster de formation, jamais en production.

Puis observez Signalements au repos, puis sous charge (une boucle de curl sur /signalements depuis un pod de test, ou un outil comme hey) :

$ kubectl top pod -n signalements --containers
$ kubectl top node

kubectl top affiche la consommation instantanée de processeur (en millicœurs) et de mémoire (working set, en Mio) de chaque conteneur. Notez les valeurs au repos et au pic : deux workers gunicorn et Flask occupent typiquement quelques dizaines de Mio et presque pas de processeur au repos. Ce sont vos mesures qui fixent les chiffres suivants, et un système de métriques (cours Prometheus) les donne sur des semaines plutôt que sur un instant.

Déclarer les ressources

Supposons qu'on ait mesuré un pic de 180 Mio et 150 millicœurs. Une règle de départ raisonnable : request de mémoire au-dessus du pic observé avec une marge, limite de mémoire égale à la request, request de processeur proche de la consommation habituelle, et pas de limite de processeur (voir En production pour le débat).

kubectl set resources modifie le manifeste localement avec --local :

$ kubectl set resources -f deployment.yaml --local \
    --requests=cpu=100m,memory=256Mi --limits=memory=256Mi -o yaml

Dans la sortie, le conteneur a reçu :

        resources:
          limits:
            memory: 256Mi
          requests:
            cpu: 100m
            memory: 256Mi

Ce pod sera Burstable : il a des requests, mais pas de limite de processeur égale à sa request. Appliquez le manifeste (le modèle de pod change, donc les pods sont remplacés progressivement), puis vérifiez la classe et les cgroups :

$ kubectl apply -f deployment.yaml
$ kubectl get pods -n signalements -o custom-columns=NOM:.metadata.name,QOS:.status.qosClass
$ kubectl exec -n signalements deploy/signalements -- cat /sys/fs/cgroup/memory.max
$ kubectl exec -n signalements deploy/signalements -- cat /sys/fs/cgroup/cpu.max

Avec cgroups v2, memory.max contient 268435456 (256 Mio), et cpu.max contient max 100000 : pas de quota, une période de 100 ms. La request de processeur, elle, se retrouve dans cpu.weight.

Enfin, regardez ce que le planificateur voit d'un nœud :

$ kubectl describe node formation-worker

La section Allocated resources donne, pour le nœud, la somme des requests et des limits de tous ses pods, en valeur et en pourcentage de la capacité allouable. C'est cette somme, et non la consommation réelle, qui décide si un nouveau pod peut y être placé.

Provoquer un OOMKilled

Pour voir le mécanisme une fois, dans un pod jetable :

apiVersion: v1
kind: Pod
metadata:
  name: gourmand
  namespace: signalements
spec:
  restartPolicy: Never
  containers:
    - name: gourmand
      image: python:3.14-slim
      command: ["python3", "-c", "import time; b = b'x' * (400 * 1024 * 1024); time.sleep(3600)"]
      resources:
        requests: {memory: 128Mi, cpu: 50m}
        limits: {memory: 128Mi}

Le programme construit une chaîne de 400 Mio, donc écrit réellement dans 400 Mio de mémoire (un simple bytearray de cette taille ne suffirait pas : le système lui fournit des pages à zéro qu'il ne réserve qu'à la première écriture), dans un conteneur limité à 128 Mio. kubectl get pod gourmand -n signalements montre le pod en OOMKilled, et kubectl describe pod gourmand montre dans l'état du conteneur la raison OOMKilled et le code de sortie 137 (128 + 9, le signal SIGKILL). Supprimez-le ensuite. Pour un pod géré par un Deployment, le kubelet aurait redémarré le conteneur, avec des délais croissants : c'est l'état CrashLoopBackOff de la leçon 12.

Encadrer le namespace

Une LimitRange pour donner des valeurs par défaut aux conteneurs qui n'en déclarent pas :

apiVersion: v1
kind: LimitRange
metadata:
  name: defauts
  namespace: signalements
spec:
  limits:
    - type: Container
      defaultRequest:
        cpu: 50m
        memory: 128Mi
      default:
        memory: 256Mi
      max:
        memory: 1Gi

Un ResourceQuota pour plafonner le total, ici généré par kubectl :

$ kubectl create quota signalements -n signalements \
    --hard=requests.cpu=2,requests.memory=4Gi,limits.memory=8Gi,pods=20 \
    --dry-run=client -o yaml
apiVersion: v1
kind: ResourceQuota
metadata:
  name: signalements
  namespace: signalements
spec:
  hard:
    limits.memory: 8Gi
    pods: "20"
    requests.cpu: "2"
    requests.memory: 4Gi
status: {}

Après application, kubectl describe quota signalements -n signalements montre, pour chaque ressource, l'usage et le plafond. Essayez de passer le Deployment à 30 réplicas : le Deployment l'accepte, mais son ReplicaSet ne parvient pas à créer les pods au-delà du quota, et ses événements (kubectl describe replicaset -n signalements) contiennent le refus exceeded quota du serveur d'API. Remettez 2 réplicas.

Répartir les réplicas

Sur le cluster formation (un plan de contrôle et deux nœuds de travail), on veut qu'un nœud perdu n'emporte pas les deux réplicas. Dans le modèle de pod du Deployment :

    spec:
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: DoNotSchedule
          nodeTaintsPolicy: Honor
          labelSelector:
            matchLabels:
              app.kubernetes.io/name: signalements
  • topologyKey: kubernetes.io/hostname : chaque nœud est un domaine. Cette étiquette est posée par le kubelet sur tous les nœuds.
  • maxSkew: 1 : l'écart entre le domaine le plus chargé et le moins chargé ne dépasse pas un pod.
  • whenUnsatisfiable: DoNotSchedule : si la contrainte ne peut pas être respectée, le pod reste Pending plutôt que d'être mal placé. ScheduleAnyway en fait une simple préférence.
  • labelSelector : les pods comptés, ceux de Signalements.
  • nodeTaintsPolicy: Honor : ne compter comme domaines que les nœuds où le pod a le droit d'aller. Sans ce réglage, la valeur par défaut est Ignore : tous les nœuds comptent, y compris le plan de contrôle de kind, teinté et donc inaccessible. Il compterait comme un domaine à zéro pod, et dès un troisième réplica (deux sur un nœud de travail, un sur l'autre, zéro sur le plan de contrôle), l'écart dépasserait 1 et le pod resterait Pending.

Après application, kubectl get pods -n signalements -o wide montre les deux réplicas sur deux nœuds différents.

Sur un cluster Kapsule dont les pools sont répartis sur plusieurs zones, on ajoute une contrainte sur topology.kubernetes.io/zone, l'étiquette de zone des nœuds, que la documentation de Scaleway utilise dans son exemple multi-zones. Les nœuds de kind n'ont pas cette étiquette : sur kind, une contrainte de zone laisserait les pods Pending (avec DoNotSchedule).

Une teinte, pour voir

$ kubectl taint nodes formation-worker2 reserve=batch:NoSchedule
$ kubectl rollout restart deployment/signalements -n signalements
$ kubectl get pods -n signalements -o wide

Les nouveaux pods évitent formation-worker2, et vont tous deux sur formation-worker : avec nodeTaintsPolicy: Honor, le nœud teinté ne compte plus comme domaine, il n'en reste qu'un, et la contrainte est satisfaite. Refaites l'expérience après avoir retiré la ligne nodeTaintsPolicy : le second réplica reste Pending, et kubectl describe pod explique, dans ses événements, qu'un nœud a une teinte que le pod ne tolère pas et que les autres ne satisfont pas la contrainte de répartition. Lire ce message du planificateur est un excellent exercice. Retirez la teinte (le - final la supprime) :

$ kubectl taint nodes formation-worker2 reserve=batch:NoSchedule-

Un premier HorizontalPodAutoscaler

Le manifeste, en API autoscaling/v2 :

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: signalements
  namespace: signalements
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: signalements
  minReplicas: 2
  maxReplicas: 6
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70

Il vise 70 % d'utilisation moyenne du processeur, rapportée aux requests (100m), entre 2 et 6 réplicas. La commande kubectl autoscale deployment signalements -n signalements --min=2 --max=6 --cpu=70% crée l'équivalent ; dans kubectl 1.36, l'ancienne option --cpu-percent est dépréciée au profit de --cpu, qui accepte un pourcentage ou une quantité. Appliquez le manifeste, chargez l'application, et suivez :

$ kubectl get hpa -n signalements --watch

La colonne des cibles montre l'utilisation courante et la cible ; le nombre de réplicas monte quand la charge dépasse 70 %, et redescend cinq minutes après la fin de la charge, à cause de la fenêtre de stabilisation. Grâce à nodeTaintsPolicy: Honor, les réplicas supplémentaires se répartissent entre les deux nœuds de travail sans rester bloqués à cause du plan de contrôle.

Sous le capot

De la request au cgroup

Le chemin d'une valeur de resources est le suivant. Le serveur d'API la valide et l'enregistre. Le planificateur la lit pour filtrer les nœuds. Le kubelet du nœud choisi la traduit en paramètres de conteneur et les passe au moteur de conteneurs (containerd, par l'interface CRI), qui crée le cgroup du conteneur sous celui du pod, lui-même sous celui de sa classe de qualité de service (kubepods.slice, puis kubepods-burstable.slice ou kubepods-besteffort.slice ; les Guaranteed directement sous kubepods.slice). Le noyau applique ensuite memory.max, cpu.max et cpu.weight. Cette hiérarchie permet au kubelet de réserver des ressources au système et de protéger les classes les unes des autres.

Pourquoi l'étranglement surprend

Le quota de processeur se compte par période de 100 ms, pour tous les cœurs ensemble. Un conteneur limité à 500m dispose de 50 ms de processeur par période. S'il fait tourner quatre fils d'exécution en même temps sur quatre cœurs, il consomme ses 50 ms en 12,5 ms de temps réel, puis tous ses fils attendent 87,5 ms. Sa consommation moyenne reste sous la limite, mais chaque requête qui tombe dans cette fenêtre attend : la latence au 99e centile s'envole. C'est pourquoi un tableau de bord qui montre « 40 % de la limite en moyenne » n'exclut pas un étranglement sévère ; la métrique à suivre est la part des périodes étranglées (nr_throttled dans cpu.stat).

Un défaut du noyau a longtemps aggravé le phénomène pour les applications à nombreux fils : une partie du quota attribué à chaque cœur expirait sans être utilisée. Il a été corrigé dans Linux 5.4 et rétroporté dans des versions stables antérieures ; les nœuds d'aujourd'hui en sont protégés.

Ce que les environnements d'exécution font de la limite

Certains environnements dimensionnent leur parallélisme d'après la limite de processeur. Depuis Go 1.25, le moteur d'exécution de Go fixe par défaut GOMAXPROCS à la limite de bande passante du cgroup si elle est inférieure au nombre de cœurs, et les notes de version précisent qu'il ne tient pas compte des requests. La machine virtuelle Java dimensionne aussi ses fils de ramasse-miettes d'après les ressources du conteneur. Une application sans limite de processeur, sur un nœud de 32 cœurs, se croit donc parfois propriétaire de 32 cœurs. Gunicorn, lui, crée exactement le nombre de workers qu'on lui donne (--workers 2) : c'est vous qui le reliez à la request.

Pièges courants

Pas de requests du tout. Pods BestEffort, invisibles pour le planificateur, premiers sacrifiés. Une LimitRange avec des valeurs par défaut évite le pire.

Une limite de mémoire sous le pic réel. Le conteneur est OOMKilled à chaque pic, redémarre, et se retrouve en CrashLoopBackOff aux heures de pointe. La request et la limite de mémoire se fixent au-dessus du pic mesuré.

400m de mémoire. C'est 0,4 octet. 400Mi.

Des requests gonflées « par sécurité ». Le planificateur réserve la place même si elle n'est jamais utilisée : des nœuds pleins sur le papier et vides en réalité, des pods Pending, une facture qui monte. Les requests se mesurent et se révisent.

Un HPA sans requests de processeur. Sans request, l'utilisation en pourcentage n'est pas calculable : le HPA ne peut pas agir, et ses événements le disent.

Croire qu'une tolérance attire le pod. Elle l'autorise seulement ; il faut une affinité ou un nodeSelector pour l'y envoyer.

Une contrainte de répartition DoNotSchedule sur une étiquette absente (la zone sur kind) : pods Pending indéfiniment.

Redimensionner en place un pod de Deployment et s'étonner que le changement disparaisse au déploiement suivant.

Sécurité

  • Les limites protègent la disponibilité : sans limite de mémoire, un conteneur compromis ou défaillant peut épuiser la mémoire du nœud et faire évincer ses voisins. C'est un déni de service à l'intérieur du cluster. Une limite de mémoire sur chaque conteneur est un minimum.
  • Les quotas protègent le cluster : un pipeline mal configuré ou une clé volée qui crée des centaines de pods est arrêté par le quota de pods et de ressources du namespace.
  • Les teintes ne sont pas une frontière de sécurité. Quiconque peut créer un pod peut lui ajouter la tolérance et s'installer sur les nœuds teintés. Pour isoler réellement des charges sensibles, il faut des contrôles d'admission qui interdisent ces tolérances, ou des clusters séparés (cours Sécurité de Kubernetes).
  • Le metrics-server reçoit les métriques de tous les kubelets : l'option --kubelet-insecure-tls désactive la vérification de leurs certificats, ce qui ne se fait que sur un cluster local.

En production

  • Le débat des limites de processeur. Deux positions argumentées s'opposent. Natan Yellin (Robusta, 2022) plaide pour ne jamais poser de limite de processeur : les requests garantissent déjà à chacun sa part quand le processeur est disputé, et une limite ne fait qu'interdire d'utiliser un processeur libre, au prix d'étranglements qui dégradent la latence ; il recommande en revanche des limites de mémoire égales aux requests. Les partisans des limites répondent par la prévisibilité : une application qui a toujours eu du processeur en surplus en préproduction peut s'effondrer le jour où le nœud est plein, et la classe Guaranteed (qui exige des limites) donne la meilleure protection et l'accès à des cœurs dédiés. Une position raisonnable : requests justes partout, limite de mémoire égale à la request, pas de limite de processeur pour les services sensibles à la latence, des limites pour les traitements de fond qui pourraient affamer leurs voisins, et une surveillance de l'étranglement là où il y a des limites.
  • Réviser les requests : la consommation change avec les versions. Les tableaux de bord de consommation rapportée aux requests (cours Prometheus) et des outils de recommandation (le Vertical Pod Autoscaler en mode recommandation) aident à réajuster.
  • Répartir entre zones : sur Kapsule, la documentation de Scaleway recommande au moins trois nœuds répartis sur au moins deux zones, et des contraintes de répartition sur topology.kubernetes.io/zone. Elle rappelle aussi que l'accès au plan de contrôle passe par un répartiteur dans la zone principale de la région : la perte de cette zone rend le plan de contrôle injoignable, même si les nœuds survivent ailleurs. Les pods déjà en marche, eux, continuent de servir (cours Le cloud : les fondamentaux, leçon 3).
  • Le HPA ne crée pas de nœuds. Si les nouveaux réplicas ne tiennent plus sur les nœuds existants, ils restent Pending ; c'est l'autoscaler de cluster (activé par pool sur Kapsule) qui ajoute des nœuds, avec quelques minutes de délai. Laissez de la marge.
  • Des priorités (PriorityClass) permettent de dire au planificateur et au kubelet quels pods comptent le plus ; elles se gèrent au niveau du cluster.

Exercices

1. Quelle classe ? (niveau 100). Donnez la classe de qualité de service de chaque pod : (a) un conteneur avec requests: {cpu: 200m, memory: 256Mi} et limits: {cpu: 200m, memory: 256Mi} ; (b) le même avec limits: {memory: 256Mi} seulement ; (c) deux conteneurs, l'un comme (a), l'autre sans aucune ressource ; (d) aucun conteneur ne déclare rien.

Solution

(a) Guaranteed : requests et limits de processeur et de mémoire présentes et égales. (b) Burstable : pas de limite de processeur, donc pas Guaranteed, mais des requests. (c) Burstable : Guaranteed exige que tous les conteneurs remplissent la condition ; le second ne déclare rien. (d) BestEffort.

2. Le pod qui ne se place pas (niveau 200). Un nœud de travail a 2 vCPU et 4 Gio allouables. Ses pods demandent au total 1800m et 2 Gio, mais kubectl top node montre 15 % de processeur utilisé. Un nouveau pod demande 300m et 512 Mio. Peut-il y être placé ? Que proposez-vous ?

Solution

Non : le planificateur compare les requests, et 1 800 + 300 = 2 100 millicœurs dépassent les 2 000 allouables, quelle que soit la consommation réelle. Le pod reste Pending si aucun autre nœud ne convient, avec un événement Insufficient cpu. La consommation observée (15 %) suggère que des requests sont surdimensionnées : on mesure la consommation de chaque application sur une période représentative et l'on réduit leurs requests. Ajouter un nœud réglerait le symptôme et paierait de la place inutilisée.

3. Lire un OOMKilled (niveau 200). Signalements redémarre plusieurs fois par jour. kubectl describe pod montre dans Last State : Terminated, raison OOMKilled, code 137. Les requests et limits de mémoire sont à 256Mi. Quelles hypothèses formulez-vous, et comment les départager ?

Solution

Soit la consommation normale de pointe dépasse 256 Mio (sous-dimensionnement), soit l'application fuit (la mémoire monte régulièrement jusqu'à la limite), soit une requête particulière charge beaucoup de données d'un coup (un export, une photo volumineuse). On départage en suivant la mémoire dans le temps : kubectl top pod à intervalles, ou mieux les métriques du conteneur dans un système de supervision. Une montée en dents de scie qui repart de bas à chaque redémarrage indique une fuite ; des pics corrélés à certaines requêtes indiquent un traitement trop gourmand ; un plateau juste sous la limite indique un sous-dimensionnement, à corriger en montant request et limite ensemble. Le code 137 confirme un SIGKILL (128 + 9).

4. Tenir la perte d'une zone (niveau 200). Un cluster Kapsule a trois pools, un par zone de fr-par, d'un nœud chacun. Écrivez les contraintes de répartition qui garantissent que Signalements (3 réplicas) a un réplica par zone, et dites ce qui se passe à la perte d'une zone avec DoNotSchedule et avec ScheduleAnyway.

Solution
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: DoNotSchedule
          labelSelector:
            matchLabels:
              app.kubernetes.io/name: signalements

Avec trois réplicas et trois zones, un réplica par zone. Si une zone disparaît, son pod est recréé par le Deployment. Avec DoNotSchedule, le placer dans l'une des deux zones restantes donnerait 2, 1 et 0 : l'écart entre la zone la plus chargée et la zone perdue (qui compte encore comme domaine tant que des nœuds y figurent) dépasserait maxSkew, et le pod peut rester Pending ; le service tourne sur deux réplicas. Avec ScheduleAnyway, le pod est placé dans une zone restante, au prix d'un déséquilibre temporaire. Le choix dépend de ce qu'on préfère : capacité immédiate ou répartition stricte. Dans les deux cas, la capacité restante doit suffire à porter la charge (stabilité statique).

Récapitulatif

  • Une request réserve, une limit plafonne. Le planificateur ne regarde que les requests (somme comparée à la capacité allouable) ; le noyau applique les limits par les cgroups.
  • Le processeur est compressible : au-delà de la limite, étranglement par périodes de 100 ms. La mémoire ne l'est pas : au-delà, OOMKilled, code 137.
  • Trois classes de qualité de service : Guaranteed (requests égales aux limits partout), Burstable, BestEffort. Sous pression mémoire, le kubelet évince d'abord les pods qui dépassent leurs requests ; le noyau tue d'abord les BestEffort. La request de mémoire est l'assurance.
  • LimitRange (défauts et bornes par conteneur) et ResourceQuota (totaux du namespace) encadrent un namespace.
  • nodeSelector, affinités, contraintes de répartition (maxSkew, topologyKey) orientent le placement ; une teinte repousse, une tolérance autorise sans attirer.
  • Le redimensionnement en place est stable depuis 1.35, sans changer la classe de qualité de service.
  • Un HPA ajuste les réplicas d'après une utilisation rapportée aux requests, grâce au metrics-server, avec une fenêtre de stabilisation de 5 minutes à la baisse.
  • Requests mesurées, limite de mémoire égale à la request ; la limite de processeur est un choix argumenté.

Pour aller plus loin

  • La page Resource Management for Pods and Containers et la page Node-pressure Eviction de la documentation, à lire ensemble.
  • La documentation du noyau sur le CFS Bandwidth Control, pour comprendre l'étranglement à la source.
  • La page de Scaleway sur les clusters Kapsule multi-zones, avec ses limites.
  • La leçon suivante, qui donne à la base de formation un stockage qui survit aux pods.
Voir ma constellation →

Sources