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 ;limitcouvre 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 ;limitlarge 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
requestde 128 Mi, soit 69 % pour une cible de 80 %. Ordesired = ceil(replicas Ă 69/80) = ceil(replicas Ă 0,8625)redonnereplicaspour 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 podBestEffortqui 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.ResourceQuotaplafonnant la somme deslimits.memorypar 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 statusdĂ©claresuccessfully rolled outsans 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-envoyetcilium-operatorreçoivent des requests explicites. Hubble (relay et UI) reste volontairementBestEffort: 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Ăč uneCiliumNetworkPolicyavec 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,
api200, 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.memorydu 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 :requestsmĂ©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,
fastapiné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)