From bd0e40753be4a7b7e2e0f42fa18db7cee4a4e643 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Sun, 13 Sep 2026 19:57:11 -0400 Subject: [PATCH] memoire : ce que l hote a le droit de reprendre, et pourquoi il ne le pouvait pas 61 Go declares a 21 machines, 12 reellement utilises. balloon valait 0 partout, ce qui ne desactive pas le ballooning : cela RETIRE le peripherique de la ligne de commande QEMU. Le moniteur repond "No balloon device has been activated". Le moteur derive desormais un plancher par machine sur les deux chemins de creation, et le playbook de clonage le pose a cote de memory. La table des planchers a ete corrigee deux fois par la mesure : shared_buffers vaut 128 Mo et non une part de la RAM, une JVM tient 605 Mo, Redis 16 Mo. Ces machines tiennent du cache de pages, pas un jeu de donnees. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q --- CHANGELOG.md | 63 +++++++++++++++++++++++++ Makefile | 3 ++ docs/audit/preuve-2026-09-13.md | 8 ++-- playbooks/proxmox/cloner_vm_debian.yml | 16 +++++++ scripts/instancier.py | 8 +++- scripts/inventory_rules.py | 64 ++++++++++++++++++++++++++ scripts/site_machines.py | 14 ++++++ 7 files changed, 171 insertions(+), 5 deletions(-) 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.