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 où 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 :
- 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. - Caler : mémoire
request= régime + marge,limit= pic mesuré ;request = limit(Guaranteed) sur le critique. CPUrequest= p95,limitlarge ou absente. - Border :
LimitRange(defaults par namespace) +ResourceQuotaplafonnant la somme deslimits.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.