Skip to content

ADR 025 — Gouvernance des ressources : requests/limits mesurĂ©s, QoS, LimitRange et ResourceQuota (2026-06-24)

Statut

AcceptĂ© (2026-06-24). Right-sizing (DĂ©cision 1/2) + LimitRange au bootstrap (DĂ©cision 3) validĂ©s live from-scratch le 2026-06-25 (no BestEffort dans les 8 namespaces possĂ©dĂ©s). ResourceQuota livrĂ© au Sprint 6 (#136, 2026-07-26, un quota par namespace d'env — voir DĂ©cision 3). La validation a rĂ©vĂ©lĂ© que des requests honnĂȘtes sur-souscrivent le nƓud unique (requests mĂ©moire Ă  99 %, prometheus non planifiable) → #114 (capacitĂ©) devient un prĂ©requis dur ; #112 reste ouvert et sera clos avec #114 (voir « Validation live »). Issue #112, nĂ© de l'incident INC-060. DĂ©cision 2 complĂ©tĂ©e le 2026-08-12 (#164) : le HPA de fastapi scale sur CPU seul, la mĂ©trique mĂ©moire Ă©tant un point fixe mathĂ©matique (voir « Corollaire (#164) »). DĂ©cision 2 appliquĂ©e le 2026-08-15 (#169, INC-066) : limits.cpu retirĂ©e du conteneur applicatif, la configuration contredisait la prescription et la panne annoncĂ©e s'est produite (voir « Corollaire (#169) »). ADR compagnon : ADR 026 (segmentation des node groups + Spot, #114) traite le volet « capacitĂ© matĂ©rielle » ; le prĂ©sent ADR traite le volet « discipline des ressources ».

Contexte

L'incident INC-060 a coupĂ© tout l'ingress (~10 min) sur le nƓud unique t3.medium : un redĂ©ploiement de Grafana a dĂ©clenchĂ© un pic mĂ©moire qui a fait basculer un nƓud sur-engagĂ© (limits.memory cumulĂ©es = 168 % de la capacitĂ©). Le dĂ©clencheur (prĂ©install des plugins Grafana) est traitĂ© Ă  part. La cause racine est l'overcommit sans garde-fou : les pods se planifient sur des requests sous-Ă©valuĂ©es, mais la pression au runtime vient des limits, et rien n'empĂȘche leur somme de dĂ©passer la capacitĂ© physique.

À l'approche de Tempo (#108, gourmand) et d'un 2e workload (frontend #109), continuer sans gouvernance rejouerait l'incident.

DĂ©cision 1 — Dimensionner sur la mesure, pas sur l'intuition

On relÚve la consommation réelle par pod (régime stable et pic) avec l'outillage déjà en place (Prometheus + kube-state-metrics + node-exporter) :

  • MĂ©moire : container_memory_working_set_bytes (c'est la mĂ©trique qui dĂ©clenche l'OOMKill, pas le RSS).
  • CPU : rate(container_cpu_usage_seconds_total[5m]).
  • Aide Ă  la recommandation : VPA en mode recommender (Off) ou Goldilocks (dashboard de reco par namespace), sans appliquer automatiquement.

DĂ©cision 2 — RĂ©gler mĂ©moire et CPU diffĂ©remment (compressible vs non)

  • MĂ©moire (non compressible → dĂ©passement = OOMKill) : request = working set en rĂ©gime + marge ; limit couvre le pic mesurĂ© (+ ~15-20 %). Sur les composants critiques, request = limit → classe QoS Guaranteed (les derniers Ă©vincĂ©s sous pression).
  • CPU (compressible → dĂ©passement = throttling, pas de kill) : request = usage p95 ; limit large ou absente sur le latency-sensitive (le throttling CPU fait souvent plus de mal qu'un lĂ©ger dĂ©passement). ArbitrĂ© selon ce que le check kube-linter exige en CI.

Corollaire (#164) — dimensionner sur la mĂ©moire, mais ne pas scaler dessus

La distinction compressible / non compressible vaut pour les requests et limits. Elle ne dit rien du HPA, et l'assimilation des deux a coûté un défaut de configuration : le HPA de fastapi portait une métrique mémoire à 80 % en plus du CPU à 70 %. Mesuré le 2026-08-11, elle était inutile pour monter et nuisible pour descendre.

  • Elle ne dĂ©clenche jamais de scale-up. Sous fortio -c 150, le CPU est montĂ© Ă  334 % de sa cible pendant que la mĂ©moire plafonnait Ă  74 %, sous le seuil. Tous les scale-up observĂ©s venaient du CPU.
  • Elle interdit tout scale-down. Un pod consomme 89 Mi pour une request de 128 Mi, soit 69 % pour une cible de 80 %. Or desired = ceil(replicas × 69/80) = ceil(replicas × 0,8625) redonne replicas pour tout N : 2→2, 3→3, 4→4, 5→5. Chaque nombre de replicas se justifie lui-mĂȘme, le HPA reste verrouillĂ© sur le maximum atteint (prod Ă  5 replicas 25 min aprĂšs la fin de la charge, CPU Ă  3 %). Pour redescendre de 4 Ă  3 il aurait fallu passer sous 60 % de mĂ©moire, de 3 Ă  2 sous 53 % — inatteignable.

La raison de fond : les 89 Mi sont l'empreinte de base de l'application — interprĂ©teur Python, FastAPI, SQLAlchemy, dĂ©pendances chargĂ©es Ă  l'import. Elle est payĂ©e au dĂ©marrage et ne dĂ©pend pas du trafic (88-90 Mi au repos, ~95 Mi sous charge, soit ~7 % de variation contre un facteur 100 cĂŽtĂ© CPU). Une empreinte quasi constante ne peut pas piloter un autoscaler : elle produit un ratio permanent, donc un point fixe. Le HPA de fastapi scale donc sur CPU seul.

Ce n'est pas un argument contre le dimensionnement mĂ©moire de la DĂ©cision 2, qui reste mesurĂ© et nĂ©cessaire — c'est la borne de ce que cette mesure autorise Ă  faire. Corollaire opĂ©rationnel : maxReplicas devenait un plancher de fait aprĂšs le premier pic, ce qui vidait de leur sens les alignements de #149 et #158 et aurait rendu Karpenter (#163) plus coĂ»teux, pas moins, en maintenant un nƓud allumĂ©.

⚠ Baisser la request mĂ©moire n'est pas la parade. À 96 Mi le ratio monterait Ă  93 %, au-dessus de la cible : le HPA scalerait alors en permanence. SymĂ©trique du mĂȘme piĂšge, dĂ©jĂ  relevĂ© sur #158.

Corollaire (#169) — la limite CPU du latency-sensitive a produit la panne annoncĂ©e

La DĂ©cision 2 prescrit une limit CPU « large ou absente sur le latency-sensitive ». La configuration livrĂ©e portait pourtant limits.cpu: 500m, soit 5× la request, sur fastapi — le service le plus sensible Ă  la latence du cluster. MesurĂ© le 2026-08-12 (INC-066), la dĂ©faillance dĂ©crite entre parenthĂšses par cette mĂȘme DĂ©cision 2 s'est produite telle quelle.

Sous une charge jouĂ©e par le chemin de production (NLB → Envoy → pods), les pods collent au plafond : 496m et 491m pour 500m. ThrottlĂ©s, ils ralentissent sur tout, y compris sur la readiness /healthz/ready, qui fait un SELECT 1. La sonde dĂ©passe ses 3 s, trois Ă©checs consĂ©cutifs suffisent, le pod sort de l'EndpointSlice — et Envoy, Ă  court d'upstreams, sert des 503 aux clients. Le trafic du pod retirĂ© se reporte sur les survivants, qui saturent Ă  leur tour.

Le mĂ©canisme censĂ© protĂ©ger la disponibilitĂ© la dĂ©grade : retirer du Service un pod parce qu'il est occupĂ© prive le systĂšme de capacitĂ© au moment prĂ©cis oĂč il en manque. C'est le schĂ©ma dĂ©jĂ  rencontrĂ© sur la liveness (#162), un cran plus bas — lĂ  on redĂ©marrait un pod sain, ici on cesse de lui parler.

limits.cpu est donc retirée du conteneur applicatif, ce qui aligne la configuration sur la prescription plutÎt que d'ajouter un réglage compensatoire. Deux vérifications faites avant, parce qu'une limite peut revenir par la bande : le ResourceQuota de chaque overlay ne borne que la mémoire (voir Décision 3), et le LimitRange default-limits du bootstrap n'a pas de default.cpu.

L'arbitrage kube-linter annoncé par la Décision 2 est tranché, par la mesure et non par l'hypothÚse : le check unset-cpu-requirements (v0.8.3, la version épinglée en CI, activé par défaut) porte le paramÚtre requirementsType: request. Il n'exige donc aucune limite, et kube-linter lint k8s/base/ k8s/frontend/ sort en 0 sans elle. Le frontend, lui, n'en portait déjà aucune.

DĂ©cision 3 — Border par namespace : LimitRange + ResourceQuota

  • LimitRange (defaults par namespace) : tout pod sans requests/limits hĂ©rite de valeurs par dĂ©faut → plus de pod BestEffort qui se faufile. Placement (corrigĂ© en live, voir plus bas) : un LimitRange n'agit qu'Ă  l'admission du pod (defaults injectĂ©s Ă  la crĂ©ation, jamais rĂ©troactivement). Il doit donc exister avant les pods → il est créé au bootstrap (Ansible), avant tout composant, et non par une Application GitOps qui se synchronise trop tard. Poulet/Ɠuf assumĂ© : une gouvernance qui doit prĂ©cĂ©der ArgoCD ne peut pas ĂȘtre gĂ©rĂ©e par ce mĂȘme ArgoCD.
  • ResourceQuota plafonnant la somme des limits.memory par namespace (livrĂ© au Sprint 6, #136) → c'est le garde-fou qui rendrait l'overcommit mĂ©moire structurellement impossible Ă  l'admission. Nuance acquise en live : la somme des limits n'est pas la cause directe d'INC-060 (les limits ne rĂ©servent rien) ; le quota reste utile comme plafond dur, mais le vrai levier anti-incident immĂ©diat a Ă©tĂ© le dĂ©clencheur (prĂ©install Grafana, !195) et la capacitĂ© (#114).

Dimensionnement du ResourceQuota (#136)

Un quota vit dans l'overlay (valeur propre à l'env), contrairement au LimitRange qui vit au bootstrap (il doit précéder ArgoCD). Formule retenue :

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

Le +2 couvre le surge d'un rolling update (maxSurge: 25 %). C'est le paramĂštre qui compte : un quota calĂ© au ras des rĂ©plicas nominaux laisse tourner la charge mais fait Ă©chouer le prochain dĂ©ploiement, ce qui est le pire des deux mondes (le blocage ne se rĂ©vĂšle qu'au dĂ©ploiement suivant, pas au moment oĂč on pose le quota).

Pas de limits.cpu dans le quota. C'est la consĂ©quence directe de la DĂ©cision 2 (CPU compressible → pas de limite CPU par dĂ©faut dans le LimitRange). Un ResourceQuota qui borne une ressource exige que chaque pod du namespace la dĂ©clare : borner limits.cpu rendrait donc tout le namespace non dĂ©ployable, puisque ni le LimitRange ni les manifests frontend n'injectent de limite CPU. On borne la mĂ©moire, pas le CPU.

VĂ©rifiĂ© sur cluster kind le 2026-07-26, quotas rĂ©els du repo (#136) : rĂ©plicas nominaux admis ; rolling update convergent en consommant exactement la marge de surge ; pod sans requests admis en Burstable grĂące au LimitRange (jamais BestEffort) ; dĂ©passement refusĂ© Ă  l'admission (ReplicaFailure), les quatre dimensions du quota saturant au mĂȘme point. Ajouter limits.cpu au quota a bien gelĂ© le namespace entier (must specify limits.cpu, Deployment Ă  0 UP-TO-DATE alors que les pods existants restaient sains) — le mode de dĂ©faillance annoncĂ© ci-dessus, reproduit.

ReconfirmĂ© sur EKS le 2026-07-26 (cluster rĂ©el, 6 namespaces d'env) : les 6 quotas prĂ©sents sans limits.cpu, 6/6 LimitRange conformes, 10/10 pods Burstable et zĂ©ro BestEffort dans les envs. Le refus de dĂ©passement cite les quatre dimensions ensemble (exceeded quota: default-quota, requested: pods=1, used: pods=3, limited: pods=3) et vient du replicaset-controller en FailedCreate : les pods sous le plafond restent Running. Le quota refuse, il n'Ă©vince pas — c'est la propriĂ©tĂ© Ă  retenir. La saturation simultanĂ©e des quatre dimensions se vĂ©rifie sur les 6 envs, ce qui rend le quota lisible comme un simple plafond de pods.

Couplage Ă  surveiller (#149). Le +2 de la formule fige une hypothĂšse sur maxReplicas dans un fichier (resourcequota.yaml) diffĂ©rent de celui qui la porte (le patch HPA de l'overlay). fastapi-prod Ă©tait le cas limite : HPA max 5 + maxSurge 25 % = pic Ă  7 pods pour un plafond de 7, soit zĂ©ro marge. CorrigĂ© le 2026-08-11 en alignant les bornes sur la capacitĂ© mesurĂ©e (HPA 2-4, quota 6 pods) plutĂŽt qu'en relevant le quota — voir ci-dessous pourquoi relever seul aurait aggravĂ© la lisibilitĂ©. Changer maxReplicas sans recalculer le quota casse le prochain rollout, longtemps aprĂšs le changement qui en est la cause.

Survenu en rĂ©el le 2026-08-11, sans que le cas soit provoquĂ©. Le dĂ©placement des rĂ©conciliateurs de plateforme (#158) a permis au HPA prod d'atteindre 5 rĂ©plicas pour la premiĂšre fois, et il y est restĂ© verrouillĂ© (la mĂ©trique mĂ©moire d'un processus Python ne redescend pas aprĂšs un pic — cause analysĂ©e en DĂ©cision 2, « Corollaire (#164) »). Le rollout suivant s'est donc dĂ©clenchĂ© HPA au maximum : 10 FailedCreate ... exceeded quota, sur les quatre dimensions simultanĂ©ment. Deux enseignements qui complĂštent la propriĂ©tĂ© « le quota refuse, il n'Ă©vince pas » :

  • le rollout a abouti malgrĂ© tout, par rĂ©essais au fur et Ă  mesure que les anciens pods disparaissaient. Le mode de dĂ©faillance rĂ©el n'est donc pas un dĂ©ploiement bloquĂ© mais un dĂ©ploiement plus lent et plus fragile, que kubectl rollout status dĂ©clare successfully rolled out sans rien signaler ;
  • relever le quota seul n'aurait rien rĂ©glĂ© : au mĂȘme instant le nƓud Ă©tait Ă  98 % de mĂ©moire rĂ©servĂ©e. Le pod aurait Ă©tĂ© admis puis serait restĂ© Pending, dĂ©plaçant le symptĂŽme de « refusĂ© Ă  l'admission » vers « acceptĂ© mais jamais placĂ© », moins visible et non plus sain. Quota et capacitĂ© se rĂšglent ensemble, jamais l'un sans l'autre.

Corollaire (#148) — hors du pĂ©rimĂštre du LimitRange, le rĂ©glage est Ă  la charge du composant

Le LimitRange posé au bootstrap couvre les namespaces d'environnement. Il ne s'applique pas à kube-system, et c'est délibéré : y injecter des defaults reviendrait à dimensionner à l'aveugle des composants systÚme dont on ne maßtrise ni les manifestes ni les besoins.

Cette exclusion dit qu'on ne defaulte pas ce namespace. Elle ne dit pas qu'on peut y laisser n'importe quoi en BestEffort. La nuance a été perdue une fois : le Done when de #136 ne portait que sur les namespaces d'env, tous Burstable, et personne n'a regardé ce qui restait hors périmÚtre. Relevé le 2026-07-26 pendant la validation de #136, cinq pods Cilium et Hubble étaient BestEffort, dont cilium-envoy, le dataplane L7.

Un pod BestEffort est le premier Ă©vincĂ© sous MemoryPressure, ce qui est le scĂ©nario d'INC-060. LĂ  oĂč aucun LimitRange n'agit, la seule façon de rĂ©gler la QoS est Ă  la source du composant, c'est-Ă -dire dans ses values Helm — ici ansible/bootstrap.yml, qui installe Cilium.

Deux rÚgles en découlent, appliquées en #148 :

  • La criticitĂ© dĂ©cide, pas le namespace. cilium-envoy et cilium-operator reçoivent des requests explicites. Hubble (relay et UI) reste volontairement BestEffort : c'est ce qui rend l'ordre d'Ă©viction intentionnel plutĂŽt qu'accidentel. Sous pression, le nƓud sacrifie l'observabilitĂ© et garde le dataplane.
  • La mesure vaut dans les conditions oĂč elle a Ă©tĂ© prise. Le pic mesurĂ© de cilium-envoy (16,1 MiB, 1,6 m) l'a Ă©tĂ© sans aucune politique L7 : le dĂ©pĂŽt ne contient que des NetworkPolicy standard L3/L4, donc le proxy tourne Ă  vide — il n'a pas bougĂ© pendant que 200 580 requĂȘtes traversaient le cluster le 2026-08-16. Le jour oĂč une CiliumNetworkPolicy avec des rĂšgles HTTP sera Ă©crite, ces valeurs deviendront fausses. Elles portent donc leur condition de validitĂ© en commentaire, Ă  cĂŽtĂ© d'elles.

Conséquences

  • L'incident INC-060 ne peut plus se reproduire par overcommit : un dĂ©ploiement qui ferait dĂ©passer le quota est refusĂ© Ă  l'admission, au lieu de tuer le nƓud au runtime.
  • Les valeurs (requests/limits, plafond de quota) se calent et se valident en live (MR3) : un quota trop serrĂ© bloquerait les dĂ©ploiements, donc il se vĂ©rifie sur le cluster rĂ©el, pas seulement par helm template.
  • Classe QoS explicite sur le critique → comportement d'Ă©viction prĂ©visible sous pression.
  • Alternatives Ă©cartĂ©es : monter un nƓud plus gros sans gouvernance (dĂ©place le mur sans le supprimer) ; Karpenter tout de suite (ne corrige pas l'overcommit runtime, il rĂ©agit au Pending — donc inutile sans requests honnĂȘtes ; reportĂ© Sprint 6).

Validation live (2026-06-25)

Boot from-scratch, mesures par pod via Prometheus (metrics-server réparé en parallÚle, #115/INC-061).

  • DĂ©clencheur (!195) ✅ : cluster montĂ© propre, api 200, aucune cascade NodeNotReady au redĂ©ploiement Grafana (prĂ©install plugins dĂ©sactivĂ©). C'est le gain anti-incident immĂ©diat.
  • Right-sizing (!196) ✅ : valeurs mesurĂ©es bien dĂ©ployĂ©es (fastapi req 100m/128Mi limit 256Mi ; argocd-application-controller req 512Mi limit 1Gi — le runaway BestEffort Ă  ~800Mi est dĂ©sormais capĂ© ; grafana et prometheus req 384Mi limit 512Mi).
  • Nuance honnĂȘte sur l'overcommit : la somme des limits.memory du nƓud est montĂ©e (168 % → 217 %), pas baissĂ©e. Ce n'est pas un Ă©chec : c'est l'effet mĂ©canique d'avoir donnĂ© une limite explicite (1Gi) Ă  un gros pod jusque-lĂ  non bornĂ© (BestEffort = 0 dans la somme). Borner un runaway fait monter la somme des limits tout en rĂ©duisant le risque rĂ©el. La mĂ©trique qui compte est ailleurs : requests mĂ©moire Ă  91 % → nƓud tendu, ce qui confirme que le vrai fix capacitĂ© est #114, pas le right-sizing seul.
  • LimitRange (!197 → !199) — trou trouvĂ© puis corrigĂ©, reprouvĂ© from-scratch ✅ : livrĂ© en GitOps (sync-wave 2), il arrivait aprĂšs les composants dĂ©jĂ  dĂ©marrĂ©s → ~14 pods plateforme (argocd, cert-manager, ESO, external-dns) restaient BestEffort (un LimitRange n'agit qu'Ă  l'admission). CorrigĂ© (!199) : LimitRange dĂ©placĂ© au bootstrap Ansible, créé avant tout composant. 2e boot from-scratch (2026-06-25) : plus AUCUN BestEffort dans les 8 namespaces possĂ©dĂ©s, fastapi nĂ© restricted (PSA) une seule fois, LimitRange prĂ©sent partout avant les pods.

La gouvernance honnĂȘte rĂ©vĂšle la sur-souscription (le vrai enseignement)

ConsĂ©quence directe et assumĂ©e du LimitRange efficace : chaque pod jusque-lĂ  BestEffort rĂ©serve dĂ©sormais le defaultRequest (64Mi). Sur le boot from-scratch, le nƓud unique t3.medium est montĂ© Ă  99 % de requests mĂ©moire (3286Mi / ~3319 allouables) → prometheus-kps-prometheus-0 (request 384Mi) ne peut plus ĂȘtre planifiĂ© (FailedScheduling: Insufficient memory). À noter : MemoryPressure: False — c'est de la sur-rĂ©servation (somme des requests), pas un manque de RAM rĂ©elle (usage ~69 %) ; les pods ne consomment pas ces 64Mi, ils bloquent juste le scheduler.

DĂ©cision (assumĂ©e avec Youssef) : ne PAS band-aider en baissant le defaultRequest Ă  un plancher-jeton (16Mi) qu'il faudrait remonter ensuite. On garde des requests honnĂȘtes (64Mi) : c'est future-correct (dĂšs que la capacitĂ© existe, tout se planifie sans rien retoucher). La gouvernance par rĂ©servations et la capacitĂ© sont couplĂ©es : on ne peut pas rĂ©server un plancher pour chaque pod sur un nƓud dĂ©jĂ  plein.

→ #114 (ADR 026) passe de « souhaitable » Ă  prĂ©requis dur, prouvĂ© par la mesure (requests Ă  99 %). #112 reste ouvert : la gouvernance est livrĂ©e et validĂ©e (no BestEffort, runaways capĂ©s), mais la stack ne planifie entiĂšrement qu'aprĂšs #114 (capacitĂ©). On clĂŽt #112 avec #114.

Évolution possible

  • ADR 026 (#114) : segmenter en node groups core (on-demand) / observability (Spot) pour isoler les workloads gourmands du trafic.
  • Karpenter (Sprint 6) : une fois les requests fiables, l'autoscaling des nƓuds devient pertinent (il se dĂ©clenche sur le Pending, qui dĂ©pend des requests).

Date : 2026-06-24 Sprint : 5 Issue : #112 (INC-060)