diff --git a/CHANGELOG.md b/CHANGELOG.md index c64a4b9..e6839f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,68 @@ # CHANGELOG — Set-OPS +## 2026-09-13 (6) — `balloon: 0` ne desactive pas le ballooning : il retire le peripherique + +Soixante-et-un gigaoctets de RAM etaient **declares** a vingt-et-une machines. Mesure a +l'interieur des invites : **douze** reellement utilises. Les quarante-neuf autres etaient +tenus sans etre lus, et l'hote ne pouvait pas en reprendre un seul octet. + +### Ce que la mesure a corrige, deux fois + +La premiere table de planchers etait ecrite sur une crainte : `0,75` a PostgreSQL et a +Keycloak « parce qu'ils se dimensionnent au demarrage ». Releve sur les machines reelles : + +| ce qu'on croyait | ce qui est | +|--------------------------------------|-------------------------------| +| `shared_buffers` derive de la RAM | **128 Mo**, le defaut Debian | +| une JVM qui reserve son tas | **605 Mo** sur 3 Go | +| Redis « tout en memoire » | **16 Mo** residents, sans `maxmemory` | + +Ces machines tiennent du **cache de pages**, pas un jeu de donnees. Le reprendre ralentit +des lectures — sur du NVMe — ; il ne fait pas swapper. Leurs planchers sont redescendus au +cas general. Ce qui reste haut le reste pour une raison mesurable : un collecteur garde ses +series EN MEMOIRE et grossit avec le temps. + +### Le piege, et il n'etait pas dans la doctrine + +`balloon: 0` se lit « ballooning desactive ». C'est plus fort que ca : **le peripherique +n'est pas sur la ligne de commande QEMU**. Le moniteur repond « No balloon device has been +activated ». Donc `qm set --balloon N` ecrit la config et ne change rien tant que la VM n'a +pas ete **redemarree depuis sa config** — un `reboot` a l'interieur de l'invite ne suffit +pas, et le branchement a chaud est refuse : sur q35, `pcie.0` n'est pas enfichable. + +`qm reboot` fait l'arret propre puis le demarrage depuis la config. Les vingt-et-une +machines y sont passees une par une, feuilles d'abord, DNS et PKI en dernier, chacune +verifiee sur son `balloon_min` avant la suivante. + +### Comment l'hote decide, lu dans la source et non dans une doc + +`pvestatd` vise **80 %** de la RAM de l'hote (`goal = memtotal × 0,80 − memused`) et ne +deplace au plus que **100 Mio par VM par cycle**. Le filet ne se tend donc que quand la +charge arrive — c'est le comportement voulu, et c'est pourquoi il ne s'est rien passe de +visible au moment de poser les planchers. + +### Le resultat + +| | declare | plancher | reprenable | +|---|---:|---:|---:| +| locataire Chezlepro (14) | 40 960 Mo | 22 912 Mo | **17,6 Go** | +| site (7) | 20 480 Mo | 11 264 Mo | **9,0 Go** | + +`asgard` est passe de 46,1 a 36,0 Go utilises. Le moteur derive desormais le plancher sur +les **deux** chemins de creation — `instancier` pour un locataire, `site_machines` pour le +site — et le playbook de clonage le pose a cote de `memory`, de sorte qu'une machine neuve +nait avec son plancher. + +Un plancher ecrit au plan (`memoire_min`) prime toujours : la table est un defaut raisonne, +pas une contrainte. + +### Ce qui reste ouvert, dit franchement + +Les grosses VM hors Set-OPS — ERPLibre, ZIMBRA, Nextcloud, eregion — portent **92 Go +declares avec `balloon: 0`** sur `gandalf` et `vishnu`, pour environ 48 Go reellement lus. +C'est la que dort le reste de la marge. Elles n'ont pas ete touchees : elles ne relevent +pas de ce depot. + ## 2026-09-13 (5) — Le gabarit dore etait declare deux fois, et les deux clonaient `plan/10-intrants.yml` disait **9006** (`modeleSetOPS-minimal`). `underlay.yml` disait diff --git a/Makefile b/Makefile index fe53c5e..a740ea0 100644 --- a/Makefile +++ b/Makefile @@ -1172,6 +1172,7 @@ site-creer: ## Cree les machines du site depuis l'underlay — CONFIRMER=true TAILLE_DISQUE="$$SETOPS_DISQUE" \ COEURS="$$SETOPS_COEURS" \ MEMOIRE="$$SETOPS_MEMOIRE" \ + MEMOIRE_MIN="$$SETOPS_MEMOIRE_MIN" \ NOEUD_PROXMOX="$$SETOPS_NOEUD" \ VMID_MODELE="$${SETOPS_VMID_MODELE:-$(VMID_MODELE)}" \ CLONE_COMPLET="$$SETOPS_CLONE_COMPLET" \ @@ -1319,6 +1320,7 @@ cloner-vm: ansible-runtime ## Clone une VM depuis le gabarit dore — HOTE= [[ -n "$(TAILLE_DISQUE)" ]] && extra_vars+=( -e proxmox_clone_taille_disque="$(TAILLE_DISQUE)" ); \ [[ -n "$(COEURS)" ]] && extra_vars+=( -e proxmox_clone_coeurs="$(COEURS)" ); \ [[ -n "$(MEMOIRE)" ]] && extra_vars+=( -e proxmox_clone_memoire="$(MEMOIRE)" ); \ + [[ -n "$(MEMOIRE_MIN)" ]] && extra_vars+=( -e proxmox_clone_memoire_min="$(MEMOIRE_MIN)" ); \ [[ -n "$(DISQUE_PROXMOX)" ]] && extra_vars+=( -e proxmox_clone_disque="$(DISQUE_PROXMOX)" ); \ [[ -n "$(DNS)" ]] && extra_vars+=( -e proxmox_clone_dns="$(DNS)" ); \ [[ -n "$(DOMAINE_RECHERCHE)" ]] && extra_vars+=( -e proxmox_clone_domaines_recherche="$(DOMAINE_RECHERCHE)" ); \ @@ -1372,6 +1374,7 @@ creer-vm: _instance-requise ## Cree une VM et attend qu'elle soit joignable — TAILLE_DISQUE="$${SETOPS_DISQUE:-$(TAILLE_DISQUE)}" \ COEURS="$${SETOPS_COEURS:-$(COEURS)}" \ MEMOIRE="$${SETOPS_MEMOIRE:-$(MEMOIRE)}" \ + MEMOIRE_MIN="$${SETOPS_MEMOIRE_MIN:-$(MEMOIRE_MIN)}" \ NOEUD_PROXMOX="$${SETOPS_NOEUD:-$(NOEUD_PROXMOX)}" \ VMID_MODELE="$(VMID_MODELE)" \ FORMAT_DISQUE="$(FORMAT_DISQUE)" \ diff --git a/docs/audit/preuve-2026-09-13.md b/docs/audit/preuve-2026-09-13.md index 3c5e843..ba977af 100644 --- a/docs/audit/preuve-2026-09-13.md +++ b/docs/audit/preuve-2026-09-13.md @@ -37,14 +37,14 @@ | P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (22 sections). | | P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 13 reseau(x), aucune collision avec la plage tenant. | | P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | note : serveur_powerdns declare un port `derive` que le plan du site ne resout pas — aucune regle emise. | -| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 3 tenant(s), 49 groupe(s), 87 regle(s). | +| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 3 tenant(s), 48 groupe(s), 85 regle(s). | | P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 14 hote(s) x 6 integration(s) universelle(s) : aucune lacune, aucune recopie (0 exemption(s) derivee(s) du service rendu). | | P27 | Propriete des intrants : hebergeur et tenant separes | AFF-109 | ✅ OK | 8 cle(s) de cluster chez l'hebergeur, aucune recopiee dans les group_vars du tenant. | -| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 4 pool(s) Proxmox, 41 VM placee(s), aucun nom ni VMID en collision. | +| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 4 pool(s) Proxmox, 40 VM placee(s), aucun nom ni VMID en collision. | | P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 33 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 22, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv | | P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 3 zone(s), 15 VNet(s), 15 sous-reseau(x), aucune collision. | | P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 63 scripts expliques et atteignables, 121 cibles make documentees, 68 roles avec README. | -| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 38 exigence(s) de role, toutes satisfaites (139 cle(s) declaree(s) par l'instance). | +| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 38 exigence(s) de role, toutes satisfaites (140 cle(s) declaree(s) par l'instance). | | P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 34 revendication(s) de port, aucune collision entre roles co-localises (36 groupes). | | P34 | Chaque document declare son lecteur | — | ✅ OK | 45 document(s) declarent leur lecteur (39 genere(s) exempte(s)). | | P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 5 application(s) exigeant une base l'ont toutes (4 entree(s) au registre). | @@ -62,7 +62,7 @@ | P47 | Zones inverses : couvrir l'occupe, et rien de plus | — | ✅ OK | Les zones inverses couvrent l'occupe et rien de plus (5 cas evalues, dont un site a quatre zones et un tenant a une). | | P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ✅ OK | La carte designe 89 chemin(s) qui existent, et ses 7 chiffres correspondent a la mesure. | | P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (127 lignes). | -| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 239 regles `pass`), tous non consignes et tous motives. | +| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 236 regles `pass`), tous non consignes et tous motives. | | P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 | | P52 | Materialiser n'exige pas d'entrer dans le tenant | — | ✅ OK | `creer-vm` confirme par l'agent invite (API des hyperviseurs, deja utilisee pour creer), sans exiger d'entrer dans le tenant. | | P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 15 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere muette (actions : b | diff --git a/playbooks/proxmox/cloner_vm_debian.yml b/playbooks/proxmox/cloner_vm_debian.yml index e36a1c3..738e642 100644 --- a/playbooks/proxmox/cloner_vm_debian.yml +++ b/playbooks/proxmox/cloner_vm_debian.yml @@ -497,6 +497,21 @@ vmid: "{{ proxmox_clone_vmid | int }}" cores: "{{ proxmox_clone_coeurs | int }}" memory: "{{ proxmox_clone_memoire | int }}" + # LE PLANCHER DE MEMOIRE — ce que l'hote peut REPRENDRE, pas ce qu'il donne. + # + # `balloon` vaut 0 sur toute la flotte, c'est-a-dire ballooning DESACTIVE : l'hote + # ne peut jamais reprendre une page, meme jamais touchee. Mesure : 40 Go alloues + # a un ecosysteme pour 15,5 Go reellement utilises — 39 %. Les 24,5 Go restants + # sont tenus sans etre lus. + # + # CE QUE LE BALLOONING N'EST PAS : une compression. L'invite voit `memory` au + # demarrage et s'y dimensionne — `shared_buffers` de PostgreSQL, tas d'une JVM, + # series en memoire d'un collecteur. Reprendre en dessous de ce qu'il a reserve + # le fait swapper A L'INTERIEUR, ce qui coute plus que la RAM gagnee. + # + # D'ou un PLANCHER DECLARE PAR MACHINE plutot qu'un ratio global : les machines + # qui se dimensionnent au demarrage gardent un plancher haut, les autres non. + balloon: "{{ proxmox_clone_memoire_min | int }}" update: true # Le noeud vient de finir un clone COMPLET : son API repond encore lentement, et # la lecture expire a 5 s. Mesure du 2026-08-10 : « Read timed out » sur le tout @@ -509,6 +524,7 @@ when: - proxmox_clone_coeurs is defined and proxmox_clone_coeurs | string | length > 0 - proxmox_clone_memoire is defined and proxmox_clone_memoire | string | length > 0 + - proxmox_clone_memoire_min is defined and proxmox_clone_memoire_min | string | length > 0 # Un desarmement doit se VOIR. Sans ce message, l'ecart entre ce que l'ecosysteme # declare et ce que la VM recoit resterait muet — et c'est precisement le genre de diff --git a/scripts/instancier.py b/scripts/instancier.py index 75ecf81..4c17a05 100644 --- a/scripts/instancier.py +++ b/scripts/instancier.py @@ -29,7 +29,7 @@ import yaml import underlay as underlay_mod # noqa: E402 from devis_sdn import vnet_de # noqa: E402 -from inventory_rules import ( +from inventory_rules import ( # noqa: E402 ecriture_atomique, charger_applications, charger_domaines, @@ -37,6 +37,7 @@ from inventory_rules import ( charger_serveurs, deriver_nomenclature, deriver_ressources, + plancher_memoire, expositions_des_applications, fonction_seq, integrations_de, @@ -337,6 +338,11 @@ def generer() -> dict: ressources = deriver_ressources(sorted(services_par_hote.get(nom, set())), ROLES) hostvars.setdefault("proxmox_coeurs", ressources["coeurs"]) hostvars.setdefault("proxmox_memoire", ressources["memoire"]) + # LE PLANCHER — ce que l'hote peut REPRENDRE, derive de ce que la machine PORTE. + # Voir `inventory_rules.plancher_memoire` : une base de donnees garde un plancher + # haut, un service au repos non. `setdefault` laisse le plan trancher. + hostvars.setdefault("proxmox_memoire_min", plancher_memoire( + int(hostvars["proxmox_memoire"]), sorted(services_par_hote.get(nom, set())))) hostvars.setdefault("proxmox_disque_taille", ressources["disque"]) # Variables derivees des liens (bindings) declares par les applications. for var, val in injections.get(nom, {}).items(): diff --git a/scripts/inventory_rules.py b/scripts/inventory_rules.py index 43c46ee..70cb02e 100644 --- a/scripts/inventory_rules.py +++ b/scripts/inventory_rules.py @@ -936,6 +936,70 @@ MIN_COEURS = 1 MAX_COEURS = 8 CHAMPS_EMPREINTE = ("coeurs", "memoire_mo", "disque_go") +# --- LE PLANCHER DE MEMOIRE (2026-09-13) --------------------------------------------- +# +# `balloon` valait 0 sur toute la flotte : ballooning DESACTIVE, donc l'hote ne pouvait +# jamais reprendre une page meme jamais touchee. Mesure : 40 Go alloues a un ecosysteme +# pour 15,5 Go lus — 39 %. +# +# LE BALLOONING NE COMPRIME RIEN. L'invite voit `memory` au demarrage et s'y dimensionne. +# Reprendre sous ce qu'il a reserve le fait swapper A L'INTERIEUR, ce qui coute plus que +# la RAM rendue. Le plancher depend donc de CE QUE LA MACHINE PORTE, pas d'un ratio unique. +# +# TROIS FAMILLES, et la frontiere entre elles est « est-ce que ca se dimensionne au +# demarrage a partir de la RAM totale ? » : +# +# 1,00 ne rend rien — la machine reserve tout et s'en sert +# 0,75 rend peu — elle reserve au demarrage mais laisse du mou +# 0,50 rend la moitie — service au repos l'essentiel du temps +# +# Un plancher ECRIT AU PLAN (`memoire_min` dans `gabarit:`) prime toujours : cette table +# est un defaut raisonne, pas une contrainte. +# LA PREMIERE VERSION DE CETTE TABLE ETAIT ECRITE SUR UNE CRAINTE, PAS SUR UNE MESURE. +# Elle donnait 0,75 a PostgreSQL et a Keycloak « parce qu'ils se dimensionnent au +# demarrage ». Releve sur les machines reelles : +# +# shared_buffers 128 Mo — le defaut Debian, PAS derive de la RAM totale +# processus Java (tas) 605 Mo — sur une machine de 3 Go +# +# Ce que ces machines tiennent vraiment, c'est du CACHE DE PAGES. Le reprendre ralentit +# des lectures ; il ne fait pas swapper. Et leurs disques vivent sur du NVMe : une lecture +# reprise coute peu. Leur plancher redescend donc au cas general. +# +# CE QUI RESTE HAUT, et pour une raison mesurable : un collecteur garde ses series EN +# MEMOIRE et grossit avec le temps — reprendre sous son empreinte actuelle le ferait +# swapper des la prochaine fenetre de retention. +PLANCHER_MEMOIRE = { + "serveur_prometheus": 0.75, # series en memoire, croissantes + "serveur_loki": 0.75, + "serveur_nextcloud": 0.75, # cache applicatif + traitements de fichiers + "serveur_collabora": 0.75, # un document ouvert est un processus +} + +# REDIS N'EST PAS DANS CETTE TABLE, ET C'EST UNE MESURE (2026-09-13). « Tout en memoire +# par construction » justifiait un plancher de 1,00 — donc aucune reprise possible sur la +# machine qui le porte. Releve : 16 Mo residents, et aucun `maxmemory` configure. +# +# Ce qu'il tient ici, ce sont des sessions et du cache applicatif, pas un jeu de donnees. +# L'epingler ferait garder plusieurs gigaoctets pour seize megaoctets. +# +# LE JOUR OU `maxmemory` SERA POSE, le plancher devra le suivre — un Redis borne a 2 Go +# doit garder 2 Go. Un Redis NON borne est d'ailleurs un sujet en soi : rien ne l'empeche +# aujourd'hui de consommer la machine entiere. +PLANCHER_DEFAUT = 0.50 + + +def plancher_memoire(memoire_mo: int, groupes) -> int: + """Ce que l'hote a le droit de REPRENDRE a cette machine, en Mo. + + Le plus HAUT plancher de ses groupes gagne : une machine qui porte une base ET un + service au repos se dimensionne sur la base. Prendre le plus bas rendrait la memoire + que la base a reservee. + """ + ratios = [PLANCHER_MEMOIRE[g] for g in (groupes or []) if g in PLANCHER_MEMOIRE] + r = max(ratios) if ratios else PLANCHER_DEFAUT + return max(512, int(round(memoire_mo * r))) + def charger_empreinte_role(racine_roles: Path, groupe: str) -> dict | None: """Lit roles//meta/empreinte.yml -> empreinte normalisee, ou None.""" diff --git a/scripts/site_machines.py b/scripts/site_machines.py index 57e4dbe..1706e40 100755 --- a/scripts/site_machines.py +++ b/scripts/site_machines.py @@ -31,6 +31,16 @@ import underlay as U # noqa: E402 # modeste : une machine de l'hebergeur sert UN role, elle n'heberge pas de charge. GABARIT_DEFAUT = {"vcpu": 2, "memoire": 2048, "disque": "40G"} +# Le plancher de memoire suit la MEME regle que pour un tenant — un site n'est pas un +# genre different de machine, seulement un autre proprietaire. +from inventory_rules import plancher_memoire as _plancher # noqa: E402 + + +def _groupes_de(nom: str) -> list[str]: + """Les groupes que cette machine du site porte, d'apres le plan.""" + apps = U.lire_plan_site("applications.yml").get("applications") or {} + return sorted({str(a.get("groupe")) for a in apps.values() if a.get("hote") == nom}) + def _taille(v) -> str: """Une taille de disque PORTE TOUJOURS SON UNITE. @@ -147,6 +157,10 @@ def parametres(nom: str) -> dict[str, str]: "SETOPS_NOEUD": str(m["noeud"]), "SETOPS_COEURS": str(g["vcpu"]), "SETOPS_MEMOIRE": str(g["memoire"]), + # LE PLANCHER — meme regle que pour un tenant, derivee de ce que la machine PORTE. + # Un plancher ecrit au plan (`gabarit.memoire_min`) prime. + "SETOPS_MEMOIRE_MIN": str(g.get("memoire_min") or _plancher( + int(g["memoire"]), _groupes_de(nom))), "SETOPS_DISQUE": _taille(g["disque"]), } # UN SEGMENT NON ETIQUETE N'A PAS DE TAG, et lui en donner un le couperait du fil.