[FIX] long_test : bail attendu, rallumage à froid, marge mémoire
Trois mesures faites sur une descente à cinq étages, et une conclusion de ma part corrigée par le contre-essai. Le bail DHCP se fait attendre. L'étage 3 était créé, en type='kvm', et « domifaddr » ne rendait rien : l'invité n'avait pas encore demandé son adresse — 87 s puis 94 s selon les tours, quand deploy_qemu s'accorde 90 s et rend 0 sans l'avoir trouvée. Lu une fois, cela ne prouvait rien. Un redémarrage demandé à l'invité peut le laisser bloqué dans son micrologiciel : RIP immobile 46 minutes, pas un octet lu, trois vCPU à fond. J'ai d'abord conclu que c'était la taille de la mémoire, parce que la même machine à 2 Go démarrait. Le contre-essai à 4 Go l'a réfuté : elle démarre aussi, à froid. La différence est le REDÉMARRAGE, pas la mémoire — à chaud elle reste dans l'UEFI, à froid elle charge son noyau en 60 à 90 s, à 2, 3 et 4 Go. La descente rallume donc une fois par le parent, et une seule : une boucle de rallumage cacherait un vrai échec. La mémoire de la pile QEMU est doublée pour une autre raison, mesurée elle aussi : l'étage 2 avec 5 Go hébergeait un invité de 4 Go et n'avait plus que 127 Mo de libre. Ce n'est pas le plancher qui compte, c'est l'écart. --- EN --- Three measurements from a five-level descent, and a conclusion of mine refuted by the counter-test. The DHCP lease takes its time. Level 3 was created, type='kvm', and "domifaddr" returned nothing: the guest had not yet asked for its address — 87 s then 94 s depending on the run, while deploy_qemu allows itself 90 s and returns 0 without having found it. Read once, that proved nothing. A reboot asked of the guest can leave it stuck in its firmware: static RIP for 46 minutes, not a byte read, three vCPU at full tilt. I first concluded it was the memory size, because the same machine booted at 2 GB. The counter-test at 4 GB refuted it: it boots too, cold. The difference is the REBOOT, not the memory — warm it stays in UEFI, cold it loads its kernel in 60 to 90 s, at 2, 3 and 4 GB. The descent therefore power-cycles once through the parent, and only once: a restart loop would hide a real failure. The QEMU stack's memory is doubled for another, also measured reason: level 2 with 5 GB hosted a 4 GB guest and had 127 MB left. It is not the floor that matters, it is the gap. Assisted-by: claude-opus-5 (cherry picked from commit e3f60e3ddf066eb76d434bbfe6b01f2271fb1874)
This commit is contained in:
parent
5af6c94c0a
commit
6e6e99c304
5 changed files with 193 additions and 22 deletions
|
|
@ -36,6 +36,7 @@ import re
|
|||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
RACINE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, RACINE)
|
||||
|
|
@ -327,6 +328,31 @@ class Descente(descente.Descente):
|
|||
)
|
||||
return vu["libvirtd"] and vu["reseau"]
|
||||
|
||||
def rallumer_a_froid(self, parent, nom):
|
||||
"""« virsh destroy » puis « start » : un processus QEMU neuf.
|
||||
|
||||
Mesuré sur la machine bloquée : à chaud elle restait 46 minutes au
|
||||
même pointeur d'instruction, dans son micrologiciel ; à froid elle a
|
||||
chargé son noyau en 60 à 90 secondes, trois fois de suite, à 2, 3 et
|
||||
4 Go. Ce n'est donc pas la taille de la mémoire — c'est la façon de
|
||||
redémarrer.
|
||||
"""
|
||||
if self.dry_run or not parent:
|
||||
return False
|
||||
self.executer(
|
||||
parent,
|
||||
f"virsh -c qemu:///system destroy {nom} 2>/dev/null; true",
|
||||
self.delai("controle"),
|
||||
"extinction",
|
||||
)
|
||||
code, _o = self.executer(
|
||||
parent,
|
||||
f"virsh -c qemu:///system start {nom}",
|
||||
self.delai("controle"),
|
||||
"rallumage",
|
||||
)
|
||||
return code == 0
|
||||
|
||||
def controler(self, hote):
|
||||
"""CET étage peut-il héberger le suivant SANS l'émuler ?
|
||||
|
||||
|
|
@ -388,6 +414,35 @@ class Descente(descente.Descente):
|
|||
return None
|
||||
return ("default",)
|
||||
|
||||
def attendre_adresse(self, parent, nom):
|
||||
"""Le bail DHCP de l'enfant, attendu. Rend l'adresse, ou "".
|
||||
|
||||
ATTENDU, et non lu une fois. Constaté au troisième étage : le domaine
|
||||
était créé, en type='kvm', et « domifaddr » ne rendait rien — l'invité
|
||||
n'avait pas encore demandé son bail. Plus l'étage est profond, plus il
|
||||
démarre lentement, et c'est justement ce qu'on mesure.
|
||||
|
||||
`deploy_qemu` attend lui-même l'adresse — 90 secondes par défaut — puis
|
||||
rend 0 quand il ne l'a pas trouvée. Son code de sortie ne prouve donc
|
||||
rien ici non plus.
|
||||
"""
|
||||
debut = time.time()
|
||||
delai = self.delai("ssh")
|
||||
while time.time() - debut < delai:
|
||||
_c, sortie = self.executer(
|
||||
parent,
|
||||
f"virsh -c qemu:///system domifaddr {nom} --source lease",
|
||||
DELAIS["controle"],
|
||||
"domifaddr",
|
||||
)
|
||||
adresse = parse_domifaddr(sortie)
|
||||
if adresse:
|
||||
if time.time() - debut > 20:
|
||||
self.dire(f" bail après {int(time.time() - debut)} s")
|
||||
return adresse
|
||||
time.sleep(15)
|
||||
return ""
|
||||
|
||||
def creer_enfant(self, parent, niveau, res, prepare, noter=None):
|
||||
"""Une VM dans le parent, par NOTRE deploy_qemu.py.
|
||||
|
||||
|
|
@ -449,13 +504,7 @@ class Descente(descente.Descente):
|
|||
)
|
||||
return None, None
|
||||
self.dire(f" domaine kvm, cpu {vu['cpu'] or '?'}")
|
||||
_c, sortie = self.executer(
|
||||
parent,
|
||||
f"virsh -c qemu:///system domifaddr {nom} --source lease",
|
||||
self.delai("ssh"),
|
||||
"domifaddr",
|
||||
)
|
||||
adresse = parse_domifaddr(sortie)
|
||||
adresse = self.attendre_adresse(parent, nom)
|
||||
if not adresse:
|
||||
self.dire(" ✗ créée, mais sans adresse : rien à joindre")
|
||||
return None, None
|
||||
|
|
|
|||
|
|
@ -209,6 +209,22 @@ class Descente:
|
|||
"""Les services de la pile répondent-ils, une fois redémarrés ?"""
|
||||
raise NotImplementedError
|
||||
|
||||
def rallumer_a_froid(self, parent, nom):
|
||||
"""Éteindre puis rallumer la machine DEPUIS SON PARENT. Rend True si
|
||||
la pile sait le faire.
|
||||
|
||||
Un redémarrage demandé à l'invité — « systemctl reboot » — peut le
|
||||
laisser bloqué dans son micrologiciel : constaté une fois au troisième
|
||||
étage, pointeur d'instruction immobile pendant 46 minutes, pas un
|
||||
octet lu. Un démarrage à FROID, lui, repart d'un processus neuf, et
|
||||
les trois essais faits ensuite ont tous abouti.
|
||||
|
||||
La cause du gel n'est pas établie ; le remède ne l'attend pas. Une
|
||||
descente ne doit pas passer six heures à guetter une machine qui ne
|
||||
reviendra jamais, quand son parent peut la rallumer.
|
||||
"""
|
||||
return False
|
||||
|
||||
def controler(self, hote):
|
||||
"""Cet étage peut-il HÉBERGER le suivant ?
|
||||
|
||||
|
|
@ -337,7 +353,7 @@ class Descente:
|
|||
time.sleep(15)
|
||||
return None
|
||||
|
||||
def redemarrer_et_verifier(self, hote):
|
||||
def redemarrer_et_verifier(self, hote, parent=None, nom=""):
|
||||
"""Redémarre, attend le retour, exige le noyau voulu.
|
||||
|
||||
L'installation pose le noyau sans redémarrer — lancée par ssh, un
|
||||
|
|
@ -379,6 +395,13 @@ class Descente:
|
|||
f" noyau {noyau} après {int(time.time() - debut)} s"
|
||||
)
|
||||
return True
|
||||
# Une seule reprise, et seulement si le parent peut la donner : la
|
||||
# machine est peut-être bloquée dans son micrologiciel, où un
|
||||
# redémarrage demandé à l'invité ne la sortira jamais. Une fois, pas
|
||||
# deux — une boucle de rallumage cacherait un vrai échec.
|
||||
if parent and nom and self.rallumer_a_froid(parent, nom):
|
||||
self.dire(" ↻ rallumée à froid par son parent")
|
||||
return self.redemarrer_et_verifier(hote)
|
||||
self.dire(" ✗ pas revenue sur le noyau attendu")
|
||||
return False
|
||||
|
||||
|
|
@ -582,7 +605,12 @@ class Descente:
|
|||
# terminé sans qu'on sache s'il pouvait héberger le suivant.
|
||||
for etape, action in (
|
||||
("install", lambda: self.installer(cible)),
|
||||
("reboot", lambda: self.redemarrer_et_verifier(cible)),
|
||||
(
|
||||
"reboot",
|
||||
lambda: self.redemarrer_et_verifier(
|
||||
cible, parent, etage.get("nom", "")
|
||||
),
|
||||
),
|
||||
("systeme", lambda: self.preparer_systeme(cible)),
|
||||
("services", lambda: self.remettre_debout(cible)),
|
||||
("controle", lambda: self.controler(cible)),
|
||||
|
|
|
|||
|
|
@ -183,12 +183,17 @@ COUTS_PVE = Couts(
|
|||
# Un hôte libvirt nu : libvirtd et qemu-kvm, rien d'autre. Le poste qui domine
|
||||
# n'est plus le système mais l'IMAGE CLOUD que l'étage télécharge pour créer
|
||||
# son enfant — d'où un disque cible qui n'est pas si petit.
|
||||
#
|
||||
# La mémoire a été DOUBLÉE après une descente à cinq étages : l'étage 2, avec
|
||||
# 5 Go, hébergeait un invité de 4 Go et il ne lui restait que 127 Mo de libre.
|
||||
# Un parent qui ne garde pas de quoi respirer sert mal son enfant, et deux
|
||||
# gibioctets d'écart plutôt qu'un lui laissent cette marge.
|
||||
COUTS_QEMU = Couts(
|
||||
ram_par_etage=1024,
|
||||
ram_par_etage=2048,
|
||||
disque_par_etage=6,
|
||||
ram_cible=2048,
|
||||
ram_cible=4096,
|
||||
disque_cible=20,
|
||||
ram_min=1024,
|
||||
ram_min=2048,
|
||||
disque_min=12,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -308,15 +308,18 @@ class TestUneVmEmuleeNestPasUneMesure(unittest.TestCase):
|
|||
self.d.niveau_courant = 2
|
||||
self.d._envoyer_cli = lambda hote: True
|
||||
|
||||
def _machine(self, xml, adresse=" x y ipv4 10.0.0.9/24"):
|
||||
def _machine(self, xml, adresse="10.0.0.9"):
|
||||
"""Chaque test une seule chose : l'attente du bail est bouchonnée ici,
|
||||
elle a sa propre classe. Sans ce bouchon, un enfant sans adresse
|
||||
faisait tourner la vraie boucle d'attente — des heures."""
|
||||
|
||||
def executer(hote, cmd, delai, etiquette="", **k):
|
||||
if "dumpxml" in cmd:
|
||||
return 0, xml
|
||||
if "domifaddr" in cmd:
|
||||
return 0, adresse
|
||||
return 0, ""
|
||||
|
||||
self.d.executer = executer
|
||||
self.d.attendre_adresse = lambda parent, nom: adresse
|
||||
|
||||
def test_an_emulated_child_is_refused(self):
|
||||
self._machine("<domain type='qemu' id='1'><name>x</name>")
|
||||
|
|
@ -364,7 +367,7 @@ class TestUneVmEmuleeNestPasUneMesure(unittest.TestCase):
|
|||
self.assertEqual(vus, ["deep-qemu-4"])
|
||||
|
||||
def test_a_child_without_an_address_is_refused(self):
|
||||
self._machine("<domain type='kvm'>", adresse=" x y N/A N/A")
|
||||
self._machine("<domain type='kvm'>", adresse="")
|
||||
with contextlib.redirect_stdout(io.StringIO()) as sortie:
|
||||
identite, _a = self.d.creer_enfant(
|
||||
{"target": "p"},
|
||||
|
|
@ -376,6 +379,74 @@ class TestUneVmEmuleeNestPasUneMesure(unittest.TestCase):
|
|||
self.assertIn("sans adresse", sortie.getvalue())
|
||||
|
||||
|
||||
class TestLeBailSeFaitAttendre(unittest.TestCase):
|
||||
"""Constaté au troisième étage : le domaine était créé, en type='kvm', et
|
||||
« domifaddr » ne rendait rien — l'invité n'avait pas encore demandé son
|
||||
bail. Plus l'étage est profond, plus il démarre lentement, et c'est
|
||||
justement ce qu'on mesure.
|
||||
|
||||
`deploy_qemu` attend lui-même l'adresse puis rend 0 quand il ne l'a pas
|
||||
trouvée : son code de sortie ne prouve rien ici non plus."""
|
||||
|
||||
def setUp(self):
|
||||
self.d = deep_qemu.Descente.__new__(deep_qemu.Descente)
|
||||
self.d.dry_run = False
|
||||
self.d.journal = None
|
||||
self.d.niveau_courant = 3
|
||||
self.d.profondeur_racine = 0
|
||||
vrai = deep_qemu.time.sleep
|
||||
deep_qemu.time.sleep = lambda _s: None
|
||||
self.addCleanup(setattr, deep_qemu.time, "sleep", vrai)
|
||||
|
||||
def test_it_retries_until_the_lease_appears(self):
|
||||
tours = {"n": 0}
|
||||
|
||||
def executer(hote, cmd, delai, etiquette="", **k):
|
||||
tours["n"] += 1
|
||||
if tours["n"] < 3:
|
||||
return 0, " x y N/A N/A"
|
||||
return 0, " vnet0 52:54:00:aa:bb:cc ipv4 192.168.133.42/24"
|
||||
|
||||
self.d.executer = executer
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(
|
||||
self.d.attendre_adresse({"target": "p"}, "deep-qemu-3"),
|
||||
"192.168.133.42",
|
||||
)
|
||||
self.assertEqual(tours["n"], 3)
|
||||
|
||||
def test_a_single_probe_would_have_missed_it(self):
|
||||
"""Le contrôle qui dit pourquoi la boucle existe : au premier tour, il
|
||||
n'y a rien à lire.
|
||||
|
||||
Borné DANS la sonde plutôt qu'en détournant l'horloge : détourner
|
||||
time.time détourne aussi celle d'unittest, et le test ne finissait
|
||||
plus. Vécu il y a dix minutes.
|
||||
"""
|
||||
tours = {"n": 0}
|
||||
|
||||
def executer(hote, cmd, delai, etiquette="", **k):
|
||||
tours["n"] += 1
|
||||
if tours["n"] > 4:
|
||||
raise AssertionError("sondé sans fin")
|
||||
return 0, " x y N/A N/A"
|
||||
|
||||
self.d.executer = executer
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
with self.assertRaises(AssertionError):
|
||||
self.d.attendre_adresse({"target": "p"}, "deep-qemu-3")
|
||||
# Plusieurs tours, pas un seul : c'est tout l'objet de la boucle.
|
||||
self.assertGreater(tours["n"], 1)
|
||||
|
||||
def test_the_wait_grows_with_the_depth(self):
|
||||
"""Le budget est celui du ssh à cet étage : un invité au quatrième
|
||||
démarre des dizaines de fois plus lentement qu'au premier."""
|
||||
self.d.niveau_courant = 1
|
||||
court = self.d.delai("ssh")
|
||||
self.d.niveau_courant = 4
|
||||
self.assertGreater(self.d.delai("ssh"), court)
|
||||
|
||||
|
||||
class TestNeDetruireQueLeSien(unittest.TestCase):
|
||||
"""« virsh undefine --remove-all-storage » efface un disque pour de bon."""
|
||||
|
||||
|
|
@ -463,18 +534,36 @@ class TestLesDeuxTestsLongsSeRessemblent(unittest.TestCase):
|
|||
self.pve.FAMILLE.detruire_une, self.qemu.FAMILLE.detruire_une
|
||||
)
|
||||
|
||||
def test_the_qemu_stack_asks_for_less(self):
|
||||
"""libvirtd seul tient dans un gibioctet là où cinq démons PVE en
|
||||
demandent deux."""
|
||||
def test_the_qemu_stack_asks_for_less_disk(self):
|
||||
"""Une Debian avec qemu-kvm occupe ~3 Go là où un nœud Proxmox en
|
||||
prend 5,6 — et l'image cloud que l'étage télécharge pour son enfant
|
||||
pèse plus lourd que son propre système."""
|
||||
from script.proxmox import nesting
|
||||
|
||||
pve = nesting.nesting_plan(3, 28, 39000, 150)
|
||||
qemu = nesting.nesting_plan(3, 28, 39000, 150, nesting.COUTS_QEMU)
|
||||
self.assertLess(qemu["niveaux"][0]["ram"], pve["niveaux"][0]["ram"])
|
||||
self.assertLess(
|
||||
qemu["niveaux"][0]["disque"], pve["niveaux"][0]["disque"]
|
||||
)
|
||||
|
||||
def test_every_parent_keeps_room_to_breathe(self):
|
||||
"""La mémoire de la pile QEMU a été DOUBLÉE après mesure, et n'est
|
||||
donc plus inférieure à celle de Proxmox : à cinq étages, l'étage 2
|
||||
avait 5 Go, hébergeait un invité de 4 Go, et il ne lui restait que
|
||||
127 Mo de libre. Ce qui compte n'est pas le plancher mais l'ÉCART —
|
||||
un parent qui ne respire pas sert mal son enfant."""
|
||||
from script.proxmox import nesting
|
||||
|
||||
niveaux = nesting.nesting_plan(5, 28, 39000, 200, nesting.COUTS_QEMU)[
|
||||
"niveaux"
|
||||
]
|
||||
for parent, enfant in zip(niveaux, niveaux[1:]):
|
||||
self.assertGreaterEqual(
|
||||
parent["ram"] - enfant["ram"],
|
||||
2048,
|
||||
f"étage {parent['niveau']} n'a pas 2 Go de marge",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -405,7 +405,7 @@ class TestUnRapportQuiSurvitAuProcessus(unittest.TestCase):
|
|||
d.creer_enfant = creer_enfant
|
||||
d.ecrire_alias = lambda *a, **k: None
|
||||
d.attendre_ssh = lambda cible, delai, parent=None: 1
|
||||
d.redemarrer_et_verifier = lambda cible: True
|
||||
d.redemarrer_et_verifier = lambda cible, parent=None, nom="": True
|
||||
d.remettre_debout = lambda cible: True
|
||||
d.preparer_systeme = lambda cible: True
|
||||
d.controler = lambda cible: True
|
||||
|
|
@ -917,7 +917,7 @@ class TestUneVmCreeeEstToujoursNommee(unittest.TestCase):
|
|||
d.ecrire_alias = lambda *a, **k: None
|
||||
d.attendre_ssh = lambda cible, delai, parent=None: 1
|
||||
d.installer = lambda cible: True
|
||||
d.redemarrer_et_verifier = lambda cible: True
|
||||
d.redemarrer_et_verifier = lambda cible, parent=None, nom="": True
|
||||
d.remettre_debout = lambda cible: True
|
||||
d.preparer_systeme = lambda cible: True
|
||||
d.controler = lambda cible: True
|
||||
|
|
|
|||
Loading…
Reference in a new issue