Skip to content

Capacité et ressources : requests, limits, QoS

Ce guide explique comment Kubernetes gère la mémoire et le CPU des pods, et pourquoi un nœud peut tomber alors que « tout avait l'air de tenir ». Il est né de l'incident INC-060 (le redéploiement de Grafana a fait tomber tout l'ingress) et accompagne l'ADR 025.

L'idée en une phrase

Le scheduler place les pods d'après leurs requests (ce qu'ils réservent), mais c'est le kubelet qui les borne au runtime d'après leurs limits (leur plafond). Quand la somme des limits dépasse la capacité physique, on est en overcommit : ça tient tant que personne ne consomme son plafond, et ça casse au premier pic.

1. Request vs limit : deux acteurs, deux moments

flowchart TB
    P["Pod<br/>request = réservation garantie<br/>limit = plafond maximal"]
    P --> S["kube-scheduler<br/>choisit le nœud selon les REQUESTS"]
    P --> K["kubelet / cgroups<br/>bornent au runtime selon les LIMITS"]
    K -->|"mémoire dépasse la limit"| OOM["OOMKill (exit 137)<br/>mémoire = non compressible"]
    K -->|"CPU dépasse la limit"| THR["Throttling<br/>CPU = compressible"]
  • La request sert à deux choses : décider le pod tient (le scheduler additionne les requests des pods d'un nœud et refuse d'en placer un qui ne rentre pas) et garantir un minimum réservé.
  • La limit est un plafond appliqué au runtime par les cgroups Linux.
  • Mémoire et CPU ne se comportent pas pareil :
  • Mémoire = non compressible. On ne peut pas « ralentir » la mémoire : si un conteneur dépasse sa limit mémoire, le kernel le tue (OOMKill, exit 137).
  • CPU = compressible. Dépasser la limit CPU ne tue rien, ça throttle (le conteneur attend son tour). C'est pour ça qu'on serre la mémoire et qu'on est plus souple sur le CPU.

La métrique qui compte pour l'OOM

Pour dimensionner la mémoire, on regarde container_memory_working_set_bytes (la mémoire « vivante » que le kernel surveille pour l'OOM), pas le RSS.

2. Les classes de QoS : qui meurt en premier

Selon la façon dont on règle requests et limits, Kubernetes classe le pod, et c'est cette classe qui décide de l'ordre d'éviction quand le nœud manque de mémoire :

Classe Réglage Éviction sous pression
Guaranteed request = limit (mémoire et CPU) en dernier
Burstable requests < limits (ou partiels) au milieu
BestEffort ni request ni limit en premier

D'où la règle : sur les composants critiques (la base du trafic, la plateforme), on vise Guaranteed (request = limit en mémoire). Un LimitRange par namespace évite qu'un pod oublié se retrouve en BestEffort et soit sacrifié au mauvais moment.

⚠️ Piège vécu (#112, INC-061) : un LimitRange n'agit qu'à l'admission du pod (il injecte ses defaults à la création, jamais rétroactivement). Il doit donc exister avant les pods. Livré en GitOps (Application synchronisée tard), il arrivait après des composants déjà démarrés → ils restaient BestEffort. On le crée donc au bootstrap, avant tout composant. Corollaire : une gouvernance qui doit précéder le contrôleur GitOps ne peut pas être gérée par ce même contrôleur (poulet/œuf).

3. Ce qui s'est passé dans INC-060 (l'overcommit qui casse)

flowchart TB
    A["Redéploiement Grafana<br/>préinstall plugins sur emptyDir"] --> B["Pic mémoire + CPU"]
    B --> C["Nœud sur-engagé<br/>Σ limits.memory = 168% de la capacité"]
    C --> D["Stall du kubelet<br/>NodeNotReady"]
    D --> E["Cascade de liveness KO<br/>~11 pods tués"]
    E --> F["Plan de données Envoy redémarre<br/>(backend du NLB)"]
    F --> G["Plus aucune cible saine<br/>api + argocd + grafana à 000"]

Le point clé : aucun autoscaler ne sauve cette situation. Le HPA, Karpenter et le Cluster Autoscaler réagissent à des pods en Pending (rien à placer). Ici les pods schedulaient très bien (requests basses) ; le problème est apparu au runtime, quand les limits cumulées ont dépassé la RAM réelle. Le vrai levier n'est pas « ajouter des nœuds automatiquement », c'est dimensionner et borner.

4. Le garde-fou : mesurer puis borner

flowchart LR
    subgraph AV["Avant (INC-060)"]
        A1["Σ limits.memory = 168%"]
        A2["aucun garde-fou"]
    end
    subgraph AP["Après (ADR 025)"]
        B1["requests mesurés<br/>(working set réel)"]
        B2["ResourceQuota :<br/>Σ limits.memory ≤ capacité"]
        B3["QoS Guaranteed<br/>sur le critique"]
    end
    AV ==>|"gouvernance"| AP

La démarche en trois temps :

  1. Mesurer la conso réelle par pod (régime + pic) via Prometheus (container_memory_working_set_bytes, rate(container_cpu_usage_seconds_total[5m])), éventuellement avec VPA recommender ou Goldilocks pour les recommandations.
  2. Caler : mémoire request = régime + marge, limit = pic mesuré ; request = limit (Guaranteed) sur le critique. CPU request = p95, limit large ou absente.
  3. Border : LimitRange (defaults par namespace) + ResourceQuota plafonnant la somme des limits.memory → un déploiement qui ferait déborder est refusé à l'admission, au lieu de noyer le nœud au runtime.

Pourquoi le quota porte sur les limits, pas que sur les requests

Dans INC-060, c'est la somme des limits (168 %) qui a saturé le nœud, pas la somme des requests. Plafonner uniquement les requests laisserait l'overcommit possible. Le garde-fou doit donc borner la somme des limits mémoire.

5. Augmenter l'offre : segmenter en node groups (#114, ADR 026)

Borner la demande (sections 1 à 4) ne suffit pas si le nœud est trop petit. La validation de la gouvernance l'a prouvé : avec des requests honnêtes, le mono-node t3.medium est monté à 99 % de requests mémoire et prometheus est devenu non planifiable (Insufficient memory, alors que MemoryPressure: False → c'est de la sur-réservation, pas un manque de RAM). La gouvernance et la capacité sont couplées.

La réponse : deux managed node groups au lieu d'un, séparés par taint / toleration / nodeSelector.

flowchart TB
    subgraph CORE["node group core (on-demand, t3.medium)"]
        direction TB
        C1["FastAPI + Envoy/NLB"]
        C2["ArgoCD, cert-manager, ESO, ExternalDNS"]
    end
    subgraph OBS["node group observability (Spot, t3.large)<br/>taint workload=observability:NoSchedule"]
        direction TB
        O1["Prometheus, Grafana, Alertmanager"]
        O2["Loki, Tempo (à venir)"]
    end
    DS["DaemonSets : node-exporter, alloy<br/>(toleration Exists → sur TOUS les nodes)"]
    DS -.tournent partout.-> CORE
    DS -.tournent partout.-> OBS

Trois pièces, qui ne font pas la même chose :

Mécanisme Rôle Sans lui
taint (sur le nœud obs) repousse tout pod qui ne le tolère pas n'importe quel pod atterrit sur le nœud obs
toleration (sur le pod) autorise le pod à aller sur le nœud taché le pod obs reste bloqué hors du nœud obs (Pending)
nodeSelector (sur le pod) force le pod vers le nœud étiqueté le pod obs pourrait retomber sur core (la toleration n'attire pas, elle permet)

Le piège DaemonSet

node-exporter (métriques nœud) et alloy (logs) doivent tourner sur chaque nœud. On leur met une toleration large (operator: Exists, tolère tout) mais surtout PAS de nodeSelector : un nodeSelector les confinerait au nœud obs et on perdrait la télémétrie du nœud core. Toleration ≠ nodeSelector.

Pourquoi Spot sur l'observabilité (et pas sur le trafic)

L'obs est gourmande mais jetable (aucune persistance, déjà perdue au teardown du soir) → on la met sur des instances Spot (~-70 %), avec plusieurs types (t3.large/t3a.large/m5.large) pour limiter les interruptions. Le trafic reste sur de l'on-demand : une interruption Spot (préavis 2 min) ne doit jamais couper l'API. Pas de stateful in-cluster (la DB est sur RDS) → l'éviction ne perd aucune donnée. Sans autoscaler, une interruption laisse les pods obs Pending jusqu'au prochain start : acceptable pour un lab éphémère, comblé plus tard par Karpenter (Sprint 6).

Le bénéfice anti-incident

Après segmentation, un pic mémoire de l'obs (le déclencheur d'INC-060) ne peut plus tuer le nœud du trafic : ils sont sur des machines distinctes. L'isolation devient matérielle, pas seulement une affaire de requests/limits.

6. Borner chaque environnement : le ResourceQuota par env (#136)

Le multi-env (ADR 029) ajoute un risque que le mono-env n'avait pas : dev et staging tournent sur les mêmes nœuds que prod. Un test de charge lancé en dev, ou un HPA qui s'emballe, peut manger la capacité de la prod. Le ResourceQuota est le plafond dur qui rend ça impossible — à l'admission, avant que le pod n'existe.

LimitRange et ResourceQuota : le duo, jamais l'un sans l'autre

Objet Rôle Où il vit Quand il agit
LimitRange fournit les valeurs par défaut au pod qui n'en déclare pas bootstrap Ansible admission du pod
ResourceQuota plafonne la somme du namespace overlay de l'env admission du pod

Les deux vivent à des endroits différents pour une raison précise. Le LimitRange doit exister avant tout pod, donc avant même qu'ArgoCD soit installé : c'est le bootstrap qui le pose (leçon INC-061, un LimitRange livré en GitOps arrivait après les pods qu'il devait cadrer). Le quota, lui, porte une valeur propre à chaque environnement : sa place naturelle est l'overlay.

Et l'ordre compte : un quota posé sans LimitRange rejette tout pod sans requests au lieu de le laisser naître Burstable. Le LimitRange n'est pas un confort, c'est le prérequis qui rend le quota vivable.

Comment on calcule le plafond

plafond = (réplicas max de l'env + 2) × consommation d'un pod

Le +2 est le paramètre qui compte : il couvre le surge d'un rolling update (maxSurge: 25 %), pendant lequel anciens et nouveaux pods coexistent. Un quota calé au ras des réplicas nominaux laisse tourner la charge et fait échouer le prochain déploiement — le pire des deux mondes, parce que la panne ne se révèle pas au moment où on pose le quota, mais des jours plus tard, au déploiement suivant.

D'où des plafonds qui montent avec la criticité de l'env : dev 4 pods (HPA 1-2, env jetable), staging 5 (HPA 2-3, iso-prod), prod 6 (HPA 2-4).

`prod` était à 7 pods (HPA 2-5) jusqu'au 2026-08-11. Ces bornes, héritées du mono-env,
promettaient une élasticité que le cluster ne pouvait pas tenir : un rollout déclenché
HPA au maximum a produit 10 `FailedCreate ... exceeded quota` (#149). Elles sont
désormais alignées sur la capacité mesurée (#158).

Le piège limits.cpu : borner une ressource, c'est la rendre obligatoire

C'est la mécanique la moins intuitive du ResourceQuota. Dès qu'un quota borne une ressource, chaque pod du namespace doit la déclarer, sinon il est refusé.

Or la Décision 2 de l'ADR 025 dit exactement l'inverse pour le CPU : pas de limite CPU par défaut, parce que le CPU est compressible et qu'une limite throttle en silence (le pod ralentit sans jamais redémarrer, c'est invisible dans les événements). Le LimitRange n'injecte donc aucune limits.cpu.

Mettre limits.cpu dans le quota, c'est geler le namespace entier : must specify limits.cpu à l'admission, Deployment à 0 UP-TO-DATE, pendant que les pods déjà en place continuent de tourner normalement. Tout a l'air sain, plus rien ne se déploie. Reproduit volontairement en test le 2026-07-26 pour vérifier le mode de défaillance.

La règle : on borne la mémoire (incompressible, un dépassement tue le pod), pas le CPU.

Ce que le quota ne fait pas

Un quota refuse des créations, il n'évince rien. Si un namespace atteint son plafond, les pods qui tournent restent sains ; c'est le prochain scale-up ou le prochain rollout qui échoue, avec un exceeded quota dans les conditions du Deployment (ReplicaFailure). D'où le réflexe de supervision : surveiller Used vs Hard avant de déployer, pas après.

Pour aller plus loin

  • Le détail des mécanismes de placement (taint/toleration/nodeSelector, operator: Exists, cas DaemonSet) : guide placement des pods.
  • Tester tout ça sur un vrai cluster (tests positifs et négatifs) : runbook de validation, section Gouvernance des ressources.
  • Le pourquoi complet de la segmentation (alternatives écartées, tradeoffs Spot) : ADR 026 (node groups core / observability).
  • La discipline requests/limits qui a rendu la capacité nécessaire : ADR 025.
  • Autoscaling des nœuds (Karpenter) : Sprint 6, une fois les requests fiables.