0072 — cluster scale : ajuster les replicas au nombre de nœuds
Accepted (2026-06-19) — livraison INCRÉMENTALE. Le code est livré
(nestor/scale.py, cluster scale) ; promu depuis Proposed (2026-06-15).
Contexte
Section intitulée « Contexte »Le dépôt monte des topologies de 1 à N nœuds (ADR 0023/0056). Les workloads
applicatifs sont versionnés avec un nombre de replicas figé dans le
manifeste — tous à replicas: 1 aujourd’hui : gitea
(platform/gitea/deployment.yaml:7), registry
(platform/container-registry/deployment.yaml:7), rstudio
(apps/rstudio/deployment.yaml:7), mailpit
(platform/mailpit/mailpit.yaml:23). Ce 1 convient au banc mono-nœud et à
socle.example, mais gâche un cluster multi-workers : sur 4 nœuds Ready, un
Deployment à 1 replica n’utilise qu’un nœud et ne survit pas à sa perte.
Le besoin : adapter le nombre de replicas applicatifs au nombre de workers
Ready. C’est une capacité dynamique — elle dépend de l’état RÉEL du
cluster à un instant t (combien de nœuds répondent Ready), pas d’une intention
écrite dans topology.yaml.
Or l’outil a une frontière nette
(ADR 0056 §2/§7) : il génère des
artefacts et constate un état, il ne converge jamais ; Ansible reste le
seul moteur idempotent. Le réel est lu, jamais stocké. La lecture du réel
existe déjà et est éprouvée : _ready_nodes() (scripts/topology.py:448-476)
renvoie les nœuds Ready via kubectl get nodes, avec repli sur le kubeconfig
du banc et double timeout (--request-timeout + timeout=) ; cmd_preview
l’affiche déjà dans sa section RÉEL (scripts/topology.py:893,899).
Deux modélisations s’affrontent.
-
Le scaling comme COUCHE du DAG déclaratif (ADR 0069). Une couche est un ENSEMBLE ordonné par le graphe de dépendances atomique (
rollback-lib.sh, ADR 0066), montée une fois, idempotente, dérivée du déclaré (declared_layers,nestor/model.py:91-100). Le nombre de replicas « bon » dépend du nombre de workers Ready au runtime — une donnée du RÉEL, pas du DAG. Mettrescaledans le DAG, ce serait y injecter une valeur qui change entre deuxkubectl get nodes: mauvais fit (un DAG ordonne des briques déclarées, il ne lit pas le cluster). -
Le scaling comme COMMANDE
cluster scale, façade fine au-dessus de_ready_nodes(), qui DÉRIVE une cible de replicas du réel et l’applique. Plus naturel : c’est une opération de runtime (« ajuste-toi à ce qui tourne »), pas une brique de montage (« installe monitoring »). Calquepulumi/k8sfamilier : un verbe runtime distinct du cycle déclaratif.
Distinction structurante : replicas ≠ resources. Le bloc resources de la
topologie (model.py:51,140 ; topologies/ha-3cp.example.yaml:64-66 :
cpus: 2, memory: 6GiB) dimensionne les VM Lima (terrain local), pas les
replicas applicatifs. scale ne touche pas resources : il ajuste un compte de
pods, pas la taille des machines.
Décision
Section intitulée « Décision »Le scaling est une COMMANDE cluster scale, PAS une couche du DAG. Un verbe
de runtime (lit le réel, ajuste un compte), distinct du cycle déclaratif
up/next/destroy (monte des briques déclarées).
1. Pourquoi une commande, pas une couche
Section intitulée « 1. Pourquoi une commande, pas une couche »- Une couche est déclarative et statique : ordonnée par le DAG (ADR 0069),
dérivée de
declared_layers(model.py:91-100), montée àup. Le bon nombre de replicas dépend des workers Ready au runtime (_ready_nodes(),topology.py:448) — il change sans quetopology.yamlchange. Une valeur runtime n’a pas sa place dans un graphe de briques déclarées. scalen’a aucune dépendance de DAG : il ne se monte pas « après monitoring » ; il s’applique quand l’opérateur le demande, sur des couches déjà montées. Lui donner une place dans la séquenceupserait arbitraire.- La frontière ADR 0056 §7 (« on ne stocke pas de state, on le lit ») colle au
modèle commande :
scalelit_ready_nodes(), calcule une cible, applique ; il ne persiste rien (pas de champ replicas danstopology.yaml).
2. Ce que la commande ajuste
Section intitulée « 2. Ce que la commande ajuste »- Cible = les Deployments applicatifs
statelessà replicas pilotables : par défautgitea,registry,mailpit,rstudio. Liste allowlistée (table dans le paquet, comme_LAYER_SIGNAL,topology.py:488-495) : on ne scale QUE ce qu’on a explicitement déclaré scalable — jamais « tous les Deployments du cluster ». - Exclus par construction (cf. §4) : StatefulSets (
loki,argocd—platform/loki/loki.yaml,platform/argocd/argocd.yaml), workloads à HA gérée par opérateur (CNPGinstances: 3,platform/cloudnative-pg/cluster.yaml:26), singletons (operators, provisioners), et tout le control-plane.
3. Lecture du réel et formule
Section intitulée « 3. Lecture du réel et formule »- Source du réel :
_ready_nodes()(topology.py:448) déjà éprouvé. On en dérive le nombre de workers Ready en croisant avecworker_nodes(model.py:65-73) +hyperconverged_nodes(model.py:76-83) : un nœud control+worker hyperconvergé schedule (le détaint, ADR 0007/0055) et compte donc comme capacité d’exécution, même s’il n’est pas dansworker_nodes(qui ne liste que les workers PURS). - Formule proposée (à valider) :
replicas = clamp(workers_ready, min=1, max=PLAFOND_PAR_WORKLOAD). Variante HA :replicas = min(workers_ready, 3)(3 = quorum applicatif courant, borné parmax-replicas). Linéaire et lisible ; pas de fonction exotique. La formule exacte est un point à valider (cf. ci-dessous). - Read-only par défaut :
cluster scaleSANS--applyaffiche le PLAN (workload → replicas actuels → cible dérivée), à la manière du PLAN decmd_preview(topology.py:918-928) ;--applyexécute. Aucune mutation silencieuse (même posture queup/destroy: confirmation/--yes,topology.py:944-955).
4. Garde-fous
Section intitulée « 4. Garde-fous »- Jamais le control-plane : la cible exclut tout pod control-plane ; la capacité comptée est celle des workers Ready (workers purs + hyperconvergés schedulables), pas les CP dédiés.
- Jamais au-delà de la capacité : un plafond par workload dans
l’allowlist (
max-replicas) borne la cible ;replicas ≤ workers_ready(pas plus de replicas que de nœuds pour exécuter, sinon des podsPending). On NE scale PAS vers le bas en dessous de 1 (jamais 0 replica → service coupé). - Jamais les workloads stateful / opérés : StatefulSets et clusters
d’opérateur (CNPG, Ceph) hors périmètre — leur réplication est portée par
l’opérateur (
instances: 3), pas par unkubectl scaleexterne qui se battrait avec lui (même piège que apply-vs-patch, MEMORY idempotence). - Cohérence avec GitOps : sur une couche
gitopsoù ArgoCD réconcilie les manifestes (platform/argocd/), unkubectl scaledirect est écrasé au prochain sync (git = source de vérité).scaleAVERTIT si le workload est managé par ArgoCD et n’agit pas en aveugle — sinon le scaling est un drift éphémère, pas un résultat reproductible (ADR 0052). - Anti-blocage : réutiliser le double timeout de
_ready_nodes()(topology.py:461,466) — un cluster injoignable rend le PLAN vide, jamais un gel.
5. Place dans la CLI
Section intitulée « 5. Place dans la CLI »scale est un verbe top-level du cycle de vie (à côté de
preview/up/next/destroy, _DISPATCH topology.py:1392-1407), routé par
une cmd_scale façade fine. La logique pure (dérivation cible = f(workers
Ready, allowlist), clamp, exclusions) vit dans le paquet nestor/ (ADR
0017/0056 §2 : la logique testable hors I/O), testée sans cluster ; la seule I/O
réelle est _ready_nodes() + kubectl scale.
Conséquences
Section intitulée « Conséquences »- Le réel pilote le runtime sans polluer le déclaratif :
topology.yamlne gagne PAS de champreplicas(resterait faux dès qu’un nœud tombe). Le DAG ADR 0069 reste un graphe de briques déclarées, inchangé. scaleréutilise_ready_nodes()(topology.py:448) et les dérivations de nœuds (model.py:60-83) — zéro nouvelle lecture du réel, zéro nouveau graphe.- Manifestes inchangés (
replicas: 1reste le défaut versionné, sûr pour le banc mono-nœud) ;scale --applyest l’override runtime explicite, jamais le défaut. - Frontière ADR 0056 §7 respectée : lit/calcule/applique via
kubectl, ne stocke pas de state. La mutationkubectl scaleest une convergence ponctuelle demandée, distincte de la convergence idempotente d’Ansible (qui, elle, reste le moteur des couches). - Preuve (ADR 0034/0052) : sur un banc multi-workers,
scale --applyporte un workload à N replicas répartis ; un rejeuscale(cluster inchangé) ne change rien (idempotence runtime : cible == état → no-op).
À revoir si
Section intitulée « À revoir si »- Le besoin devient continu (réagir à un nœud qui tombe sans intervention) : alors ce n’est plus une commande ponctuelle mais un contrôleur (HPA piloté sur métriques, ou un opérateur maison) — sortir du modèle façade read-only.
- Les workloads passent sous GitOps strict (tout manifeste réconcilié par
ArgoCD) :
scaledevrait alors écrire le replicas dans git (PR/commit) et laisser ArgoCD converger, plutôt quekubectl scaledirect — bascule de l’impératif vers le déclaratif versionné. - Un besoin de scaling par couche émerge (replicas dérivés par workload selon la couche) : la formule deviendrait une table dans l’allowlist plutôt qu’une fonction unique.
Alternatives écartées
Section intitulée « Alternatives écartées »scalecomme couche du DAG (ADR 0069) : injecte une valeur runtime (workers Ready) dans un graphe de briques déclarées ; n’a aucune dépendance de DAG ; change entre deux lectures du cluster. Mauvais fit — rejeté au profit du verbe runtime.- Champ
replicasdéclaré danstopology.yaml: fige une valeur qui devient fausse dès qu’un nœud tombe ; duplique l’information « combien de workers » déjà portée parnodes/_ready_nodes(); contredit « le réel est lu, pas stocké » (ADR 0056 §7). Rejeté. - Scaler TOUS les Deployments du cluster : touche operators, singletons,
workloads stateful par accident. Rejeté au profit d’une allowlist
explicite (parité avec
_LAYER_SIGNAL). kubectl scaledirect sur les StatefulSets/CNPG : se bat avec l’opérateur qui possède le compte (instances: 3) — même classe de bug que apply-vs-patch. Rejeté : la réplication des stateful reste à l’opérateur.- HPA (autoscaler) d’emblée : réagit à des métriques de charge, pas au nombre de nœuds ; exige metrics-server + une politique de charge ; sur-dimensionné pour « adapter au nombre de workers ». Reporté (cf. « À revoir si »).