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.

Pour aller plus loin

  • Le détail des mécanismes de placement (taint/toleration/nodeSelector, operator: Exists, cas DaemonSet) : guide placement des pods.
  • 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.