diff --git a/CHANGELOG.md b/CHANGELOG.md index 715d7ed..cb10bc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,56 @@ # CHANGELOG — Set-OPS +## 2026-08-10 — Le MTU de la zone n'atteignait pas les invités + +Question de l'exploitant : « je ne vois nulle part un MTU à 1450 ». Elle était fondée. + +**Ce qui existait déjà** : `MTU_OVERLAY_DEFAUT = 1450` dans `scripts/underlay.py`, dont +**P23** dérive sa garde (*transport ≥ overlay + 50*), et que le devis SDN pose sur chaque +zone. Vérifié sur le cluster : zones `t11` et `t17` bien à **1450**. + +**Ce qui manquait** : le MTU d'une zone ne se propage pas à la carte de l'invité. Les +quatorze VM tournaient à **1500**, et `cloner_vm_debian.yml` ne contenait aucune occurrence +de `mtu`. La VM émettait donc des trames que son propre chemin ne pouvait pas encapsuler — +la connexion s'établit, les petites requêtes passent, les grosses réponses restent +suspendues. C'est la panne que le registre des flux décrit comme « la plus coûteuse à +diagnostiquer », et pour laquelle il déclare l'ICMP « fragmentation nécessaire ». + +**Pourquoi personne ne l'avait vue** : les quatorze VM vivent sur `asgard`. Deux VM du même +hyperviseur communiquent par le pont local, **sans encapsulation** — rien ne rencontre le +1450. Le défaut serait apparu au premier éclatement de la flotte sur plusieurs nœuds, c'est- +à-dire exactement quand il faudra héberger les deux tenants ensemble. + +**Corrigé en deux endroits, et `mtu=1` plutôt que `1450`** — la valeur Proxmox qui signifie +« hérite du pont » : juste en SDN (1450) comme hors SDN (1500), et encore juste le jour où la +fabric passera aux trames jumbo. + +- le **gabarit** le porte (`net0 … mtu=1`), ce qui couvre les clonages qui ne passent pas par + le playbook — un clone fait à la main, par exemple. Proposition de l'exploitant, et elle + est meilleure : elle attrape tous les chemins ; +- `cloner_vm_debian.yml` le repose à chaque clone, parce qu'un gabarit **se recapture** (fait + la veille) et que ce qui n'est pas versionné se perd en silence. + +### `make mtu-mesurer` — septième devis + +Il rattache chaque hôte à sa zone par son **pont dérivé** (`t17serv` → zone `t17`) et lit le +MTU attendu dans `devis_sdn.py`, la source qui configure les zones. Rien n'est saisi. Il a +trouvé l'écart sur 14 hôtes du premier coup, et l'a confirmé corrigé. + +### Ce que j'ai cassé en corrigeant + +Appliquer `mtu=1` aux cartes de VM **en marche** a coupé le réseau des **quatorze machines +d'un coup** : Proxmox détache et rebranche la carte, l'invité ne reconfigure pas son +interface. Flotte à 0/14 pendant trois minutes. + +Ce qui a permis d'en sortir : les VM tournaient et l'**agent qemu répondait** — un canal +indépendant du réseau invité. Redémarrage par l'API, la configuration s'est appliquée +proprement au démarrage, 14/14 ensuite. + +La faute est d'avoir appliqué à la flotte entière un changement dont je n'avais pas mesuré +l'effet à chaud. Dans le playbook, la même tâche s'exécute **avant** le démarrage du clone : +aucun risque. Sur une VM déjà en service : poser la configuration, **puis** redémarrer — et +sur une seule d'abord. + ## 2026-08-10 — Technolibre est debout : six devis, et P35 **L'épreuve de portabilité est passée.** Un second écosystème souverain complet, monté depuis diff --git a/Makefile b/Makefile index 336f23f..6ab1130 100644 --- a/Makefile +++ b/Makefile @@ -418,7 +418,7 @@ ca-empreinte: ansible-runtime _instance-requise ## Empreinte de la racine, lue S -a "step certificate fingerprint /etc/step-ca/certs/root_ca.crt" 2>/dev/null \ | tail -1 | tr -d ' \r' -.PHONY: frontiere-plan frontiere-appliquer frontiere-mesurer +.PHONY: frontiere-plan frontiere-appliquer frontiere-mesurer mtu-mesurer courriel-plan: ansible-runtime ## Chaine Postfix -> LDAP -> Dovecot -> IMAP (aucune ecriture) @rm -f $(SETOPS_INSTANCE)/devis-courriel.json.* @ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-courriel.yml >/dev/null @@ -433,6 +433,10 @@ expositions-plan: ansible-runtime ## Chaque exposition du plan repond-elle ? (ed @ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-expositions.yml >/dev/null @python3 scripts/devis_expositions.py +mtu-mesurer: ansible-runtime ## L'invite porte-t-il le MTU de sa zone SDN ? (aucune ecriture) + @ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-mtu.yml >/dev/null + @python3 scripts/devis_mtu.py + frontiere-mesurer: ansible-runtime ## La frontiere refuse-t-elle ce qui n'est pas declare ? (sonde reelle, aucune ecriture) @ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-frontiere.yml >/dev/null @python3 scripts/devis_frontiere.py diff --git a/docs/devis-services.md b/docs/devis-services.md index c3e0afd..ba7ab90 100644 --- a/docs/devis-services.md +++ b/docs/devis-services.md @@ -12,6 +12,7 @@ make expositions-plan # chaque `expose:` du plan répond-il, depuis l'edge et make postgresql-plan # chiffrement imposé, et à quels réseaux make courriel-plan # Postfix → LDAP → LMTP → Dovecot → IMAP make frontiere-mesurer # ce qui n'est pas déclaré à la frontière est-il refusé ? +make mtu-mesurer # l'invité porte-t-il le MTU de sa zone SDN ? ``` ## Le trou qu'il comble diff --git a/docs/modeles_vm/debian13-proxmox.md b/docs/modeles_vm/debian13-proxmox.md index 3d3d516..79ec016 100644 --- a/docs/modeles_vm/debian13-proxmox.md +++ b/docs/modeles_vm/debian13-proxmox.md @@ -33,6 +33,25 @@ aucun secret aucune donnée propre à un clone final ``` +### La carte réseau du gabarit porte `mtu=1` + +`mtu=1` est la valeur Proxmox qui signifie **« hérite du pont »** (virtio uniquement) — et +non « MTU de 1 octet ». Tout clone naît donc au MTU du pont auquel il est réellement +attaché : **1450** sur une zone SDN EVPN (VXLAN coûte 50 octets), **1500** sur un pont +classique. + +Sans elle, l'invité naît à 1500 quel que soit le pont. La panne qui en découle est sournoise +et **invisible tant que toutes les VM vivent sur le même hyperviseur** — elles communiquent +alors par le pont local, sans encapsulation. Mesuré le 2026-08-10 : zones à 1450, les +quatorze invités à 1500, aucun symptôme, parce que les quatorze étaient sur `asgard`. + +Écrire `1450` en dur serait faux hors SDN ; hériter reste juste dans les deux mondes, et le +jour où la fabric passera aux trames jumbo. + +**Ce réglage se perd à chaque recapture du gabarit** — le vérifier fait partie de la +recapture. `cloner_vm_debian.yml` le repose de toute façon à chaque clone, et +`make mtu-mesurer` contrôle le résultat sur la flotte. + ## Runbook du playbook prepare ### Objectif diff --git a/playbooks/maintenance/devis-mtu.yml b/playbooks/maintenance/devis-mtu.yml new file mode 100644 index 0000000..eba4cf3 --- /dev/null +++ b/playbooks/maintenance/devis-mtu.yml @@ -0,0 +1,60 @@ +--- +# Devis du MTU des invites — RELEVE seul. Ne modifie rien (D-23). +# +# Question posee : la carte d'une VM porte-t-elle le MTU de la zone a laquelle elle est +# reellement attachee ? +# +# En SDN EVPN, VXLAN coute 50 octets : la zone est a 1450 quand le transport est a 1500. +# Le MTU de la ZONE est pose par `make sdn-appliquer` cote hyperviseur — mais l'invite, +# lui, nait a 1500 si sa carte ne l'herite pas explicitement (`mtu=1` chez Proxmox). +# +# CE QUE CA PRODUIT QUAND C'EST FAUX, et pourquoi ca merite une garde : la VM emet des +# trames que son propre chemin ne peut pas encapsuler. La connexion s'etablit, les petites +# requetes passent, les grosses reponses restent suspendues — le registre des flux la +# decrit comme « la panne la plus couteuse a diagnostiquer », et c'est pour elle qu'il +# declare l'ICMP « fragmentation necessaire ». +# +# INVISIBLE TANT QUE TOUTES LES VM VIVENT SUR LE MEME HYPERVISEUR : elles communiquent +# alors par le pont local, sans encapsulation, et rien ne rencontre le 1450. Mesure du +# 2026-08-10 : zones a 1450, les 14 invites a 1500, aucun symptome — parce que les 14 +# etaient sur `asgard`. +# +# make mtu-mesurer + +- name: Relevé du MTU vu par les invités + hosts: hotes_actifs + become: false + gather_facts: false + + tasks: + - name: Lire le MTU de l'interface principale + ansible.builtin.slurp: + src: "/sys/class/net/{{ devis_mtu_interface | default('eth0') }}/mtu" + register: devis_mtu_lu + failed_when: false + + - name: Retenir le MTU de cet hôte + ansible.builtin.set_fact: + devis_mtu_hote: >- + {{ (devis_mtu_lu.content | default('') | b64decode | trim | int) + if devis_mtu_lu.content is defined else 0 }} + +- name: Déposer le relevé + hosts: localhost + connection: local + become: false + gather_facts: false + + tasks: + - name: Écrire le relevé sur le contrôleur + ansible.builtin.copy: + dest: "{{ playbook_dir }}/../../instance/devis-mtu.json" + mode: "0600" + content: >- + {{ {'invites': dict(groups['hotes_actifs'] + | map('extract', hostvars, 'devis_mtu_hote') | list + | zip(groups['hotes_actifs']) | map('reverse') | list), + 'ponts': dict(groups['hotes_actifs'] + | map('extract', hostvars, 'proxmox_pont') | list + | zip(groups['hotes_actifs']) | map('reverse') | list)} + | to_nice_json }} diff --git a/playbooks/proxmox/cloner_vm_debian.yml b/playbooks/proxmox/cloner_vm_debian.yml index 62a214f..4edcb86 100644 --- a/playbooks/proxmox/cloner_vm_debian.yml +++ b/playbooks/proxmox/cloner_vm_debian.yml @@ -320,6 +320,36 @@ bridge: "{{ proxmox_clone_pont }}" tag: "{{ proxmox_clone_vlan | int if proxmox_clone_vlan is defined and proxmox_clone_vlan | string | length > 0 else omit }}" firewall: "{{ proxmox_clone_parefeu_interface | default(false) | bool }}" + # `mtu: 1` est la valeur Proxmox pour « HERITE DU PONT » (virtio uniquement). + # + # Sans elle, l'invite nait a 1500 quel que soit le pont. En SDN EVPN la zone est a + # 1450 (VXLAN coute 50 octets) : la VM emet donc des trames que son propre chemin + # ne peut pas encapsuler. Ca ne casse pas franchement — la connexion s'etablit, + # les petites requetes passent, les grosses reponses restent suspendues. C'est + # exactement la panne que le registre des flux decrit comme « la plus couteuse a + # diagnostiquer », et pourquoi il declare l'ICMP « fragmentation necessaire ». + # + # Mesure du 2026-08-10 : zones `t11` et `t17` a 1450 sur le cluster, les 14 invites + # a 1500. Invisible tant que toutes les VM vivent sur le MEME hyperviseur — elles + # communiquent alors par le pont local, sans encapsulation. La panne apparaitrait + # au premier eclatement de la flotte sur plusieurs noeuds. + # + # HERITER plutot qu'ecrire 1450 : hors SDN le pont est a 1500 et la VM suit. Une + # seule source de verite — celle du pont auquel elle est reellement attachee — et + # la valeur reste juste le jour ou la fabric passera aux trames jumbo. + # + # Le GABARIT le porte aussi (`net0 ... mtu=1`), ce qui couvre les clonages qui ne + # passent pas par ce playbook — un clone fait a la main dans l'interface, par + # exemple. On garde neanmoins la ligne ici : un gabarit se RECAPTURE (fait le + # 2026-08-09), et ce qui n'est pas versionne se perd en silence. Le playbook est + # la garantie qui survit a la recapture ; `make mtu-mesurer` verifie le resultat. + # + # Cette tache s'execute AVANT le demarrage du clone (voir « Demarrer le clone » + # plus bas) : aucun risque de rebranchement a chaud. Applique a une VM EN MARCHE, + # le meme changement detache et rebranche la carte sans que l'invite reconfigure + # son interface — 14 machines coupees d'un coup le 2026-08-10, et il a fallu les + # redemarrer. Sur une VM deja en service : poser la config, puis redemarrer. + mtu: 1 state: present when: - proxmox_clone_pont is defined diff --git a/scripts/devis_mtu.py b/scripts/devis_mtu.py new file mode 100644 index 0000000..d80ea7c --- /dev/null +++ b/scripts/devis_mtu.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Devis du MTU : l'invite porte-t-il le MTU de la zone a laquelle il est attache ? + +Le MTU de la ZONE est pose sur l'hyperviseur par `make sdn-appliquer`. Rien ne garantit +que l'INVITE le porte : sa carte doit l'heriter du pont (`mtu=1` chez Proxmox), sans quoi +il nait a 1500 quelle que soit la zone. + +Quand les deux divergent, la panne est sournoise : la connexion s'etablit, les petites +requetes passent, les grosses reponses restent suspendues. Elle depend alors entierement de +la decouverte de MTU de chemin — donc de messages ICMP que n'importe quel pare-feu peut +avaler en silence. + +ET ELLE EST INVISIBLE TANT QUE LA FLOTTE TIENT SUR UN SEUL HYPERVISEUR : deux VM du meme +noeud communiquent par le pont local, sans encapsulation. Mesure du 2026-08-10 : zones +`t11`/`t17` a 1450, les 14 invites a 1500, et aucun symptome — les 14 etaient sur `asgard`. +Le jour ou on repartit la flotte, tout casse d'un coup et rien ne dit pourquoi. + +Le MTU attendu n'est pas saisi : il est lu dans `devis_sdn.py`, la source qui configure les +zones. Le rattachement hote -> zone se derive du pont porte par l'inventaire (`t17serv` est +un VNet de la zone `t17`). + +Aucun acces reseau ici — c'est le playbook qui releve (D-23). + + make mtu-mesurer +""" +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path + +RACINE = Path(__file__).resolve().parent.parent + + +def zones_attendues() -> dict[str, int]: + """MTU attendu par VNet, lu a la source qui configure les zones.""" + r = subprocess.run([sys.executable, str(RACINE / "scripts" / "devis_sdn.py"), "--json"], + cwd=RACINE, capture_output=True, text=True) + if r.returncode != 0: + raise SystemExit("Le devis SDN ne se genere pas :\n" + r.stderr.strip()[:300]) + par_vnet: dict[str, int] = {} + for z in (json.loads(r.stdout).get("zones") or []): + for v in (z.get("vnets") or []): + par_vnet[str(v.get("vnet"))] = int(z.get("mtu") or 0) + return par_vnet + + +def analyser(releve: dict, attendus: dict[str, int]) -> tuple[list[str], list[str]]: + ecarts: list[str] = [] + lignes: list[str] = [] + invites = releve.get("invites") or {} + ponts = releve.get("ponts") or {} + for hote in sorted(invites): + vu = int(invites[hote] or 0) + pont = str(ponts.get(hote) or "") + attendu = attendus.get(pont) + if attendu is None: + # Hors SDN (pont classique) : le devis n'a pas d'attendu derive, il se tait + # plutot que d'inventer un seuil. + lignes.append(f" -- {hote:<16} {vu:>5} pont {pont or '(inconnu)'} — hors zone SDN") + continue + ok = vu == attendu + lignes.append(f" {'ok ' if ok else 'ECART'} {hote:<16} {vu:>5} attendu {attendu} " + f"(zone du pont {pont})") + if not ok: + ecarts.append(f"{hote} : carte a {vu}, zone {pont} a {attendu}") + return ecarts, lignes + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--releve", default=None) + a = ap.parse_args(argv) + chemin = Path(a.releve) if a.releve else RACINE / "instance" / "devis-mtu.json" + if not chemin.is_file(): + raise SystemExit(f"Aucun releve : {chemin}\nLancer d'abord `make mtu-mesurer`.") + ecarts, lignes = analyser(json.loads(chemin.read_text()), zones_attendues()) + print("Devis du MTU — carte de l'invite contre MTU de sa zone\n") + print("\n".join(lignes)) + if ecarts: + print(f"\nECART : {len(ecarts)} invite(s) ne portent pas le MTU de leur zone.") + for e in ecarts: + print(" -", e) + print("\n Corriger : `mtu: 1` sur la carte (herite du pont) — deja pose par\n" + " `cloner_vm_debian.yml`. Une VM existante doit redemarrer pour le prendre.") + return 1 + print("\nCONFORME : chaque invite porte le MTU de sa zone.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())