ajoute le self-healing des VM (sonde de santé, relance automatique, stock écarté) — bloc A, semaine 6
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
b054888f5a
commit
f2649d0f8b
21 changed files with 938 additions and 28 deletions
|
|
@ -6,7 +6,7 @@ use RuntimeException;
|
|||
|
||||
/**
|
||||
* Parle au service correcteur (Go, sur l'hyperviseur) par son socket Unix.
|
||||
* Le portail n'envoie que trois identifiants : jamais une commande. Les
|
||||
* Le portail n'envoie que des identifiants : jamais une commande. Les
|
||||
* commandes sont lues dans le lab.yaml par le correcteur lui-même.
|
||||
*/
|
||||
class ClientCorrecteur
|
||||
|
|
@ -15,6 +15,32 @@ class ClientCorrecteur
|
|||
* @return array{reussi: bool, erreur: ?string}
|
||||
*/
|
||||
public function verifier(string $instanceId, string $lab, string $verification): array
|
||||
{
|
||||
// 10 s par vérification côté correcteur, plus une marge.
|
||||
$reponse = $this->envoyer(['instance' => $instanceId, 'lab' => $lab, 'verification' => $verification], 15);
|
||||
|
||||
return ['reussi' => $reponse['reussi'], 'erreur' => $reponse['erreur'] ?? null];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sonde de santé (self-healing) : état libvirt de la VM et réponse de son
|
||||
* agent invité. Rien n'est exécuté dans la VM.
|
||||
*
|
||||
* @return array{repond: bool, etat: ?string, erreur: ?string}
|
||||
*/
|
||||
public function sante(string $instanceId): array
|
||||
{
|
||||
// 8 s maximum côté correcteur, plus une marge.
|
||||
$reponse = $this->envoyer(['instance' => $instanceId, 'sante' => true], 10);
|
||||
|
||||
return ['repond' => $reponse['reussi'], 'etat' => $reponse['etat'] ?? null, 'erreur' => $reponse['erreur'] ?? null];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string|bool> $requete
|
||||
* @return array{reussi: bool, erreur?: string, etat?: string}
|
||||
*/
|
||||
private function envoyer(array $requete, int $delai): array
|
||||
{
|
||||
$flux = @stream_socket_client('unix://'.config('labs.correcteur.socket'), $codeErreur, $messageErreur, 2);
|
||||
|
||||
|
|
@ -22,10 +48,9 @@ class ClientCorrecteur
|
|||
throw new RuntimeException("Correcteur injoignable : {$messageErreur}");
|
||||
}
|
||||
|
||||
// 10 s par vérification côté correcteur, plus une marge.
|
||||
stream_set_timeout($flux, 15);
|
||||
stream_set_timeout($flux, $delai);
|
||||
|
||||
fwrite($flux, json_encode(['instance' => $instanceId, 'lab' => $lab, 'verification' => $verification])."\n");
|
||||
fwrite($flux, json_encode($requete)."\n");
|
||||
$ligne = fgets($flux);
|
||||
fclose($flux);
|
||||
|
||||
|
|
@ -35,6 +60,6 @@ class ClientCorrecteur
|
|||
throw new RuntimeException('Réponse illisible du correcteur');
|
||||
}
|
||||
|
||||
return ['reussi' => $reponse['reussi'], 'erreur' => $reponse['erreur'] ?? null];
|
||||
return $reponse;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,6 +107,44 @@ class StockChaud
|
|||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stacks prêtes, tous labs confondus (lues par SurveillanceVm).
|
||||
*
|
||||
* @return list<array{lab: string, nom: string, id: string, instances: array<string, string>, cree_a: int, brut: string}>
|
||||
*/
|
||||
public function pretes(): array
|
||||
{
|
||||
$pretes = [];
|
||||
|
||||
foreach (array_keys($this->labsDisponibles()) as $lab) {
|
||||
foreach (Redis::lrange(self::clePretes($lab), 0, -1) as $brut) {
|
||||
$stack = json_decode($brut, true);
|
||||
|
||||
if (is_array($stack) && isset($stack['nom'], $stack['id'], $stack['instances'])) {
|
||||
$pretes[] = ['lab' => $lab, 'cree_a' => (int) ($stack['cree_a'] ?? 0), 'brut' => $brut] + $stack;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $pretes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retire une stack prête précise du stock et la supprime. Faux si elle
|
||||
* n'y est plus (déjà donnée à un élève entre-temps) : on n'y touche pas.
|
||||
*/
|
||||
public function retirer(string $lab, string $brut): bool
|
||||
{
|
||||
if ((int) Redis::lrem(self::clePretes($lab), 1, $brut) === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$stack = json_decode($brut, true);
|
||||
$this->supprimerSansErreur($stack['nom'], $stack['id']);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function commandeTerminee(string $lab, string $base): void
|
||||
{
|
||||
Redis::zrem(self::cleCommandes($lab), $base);
|
||||
|
|
|
|||
241
portail/app/Services/Labs/SurveillanceVm.php
Normal file
241
portail/app/Services/Labs/SurveillanceVm.php
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Labs;
|
||||
|
||||
use App\Models\LabSession;
|
||||
use App\Models\LabSessionMachine;
|
||||
use App\Services\OpenStack\NovaClient;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Self-healing des VM (bloc A, semaine 6), appelé chaque minute par
|
||||
* labs:surveiller-vm. Chaque VM est sondée par le correcteur (état libvirt
|
||||
* et guest-ping de l'agent invité : rien n'est exécuté dans la VM).
|
||||
*
|
||||
* - VM du stock muette 2 minutes de suite : sa stack sort du stock et est
|
||||
* supprimée ; labs:remplir-stock la remplace. Aucun élève ne la reçoit.
|
||||
* - VM d'élève muette 3 minutes de suite : redémarrage forcé par Nova (ou
|
||||
* démarrage si elle est éteinte), 2 fois par heure au plus ; au-delà, le
|
||||
* prof voit « ne répond plus » dans la salle d'attente.
|
||||
*
|
||||
* Garde-fous : délai de grâce après la création (la VM démarre) ; aucune
|
||||
* action si le correcteur est injoignable ou si toutes les VM sont muettes
|
||||
* à la fois (panne de l'hôte, pas des VM : on ne redémarre pas tout).
|
||||
*
|
||||
* Clés Redis : sante:muette:{instance} (sondes ratées de suite, 10 min),
|
||||
* sante:relances:{instance} (relances de l'heure), sante:compteurs (totaux
|
||||
* pour Prometheus).
|
||||
*/
|
||||
class SurveillanceVm
|
||||
{
|
||||
// Une VM fraîchement créée démarre encore (≈ 50 s, 75 s au p95 sous charge).
|
||||
public const GRACE_SECONDES = 180;
|
||||
|
||||
public const SEUIL_STOCK = 2;
|
||||
|
||||
public const SEUIL_SESSION = 3;
|
||||
|
||||
public const RELANCES_MAX_PAR_HEURE = 2;
|
||||
|
||||
// En dessous, « toutes muettes » peut être une vraie panne de ces VM.
|
||||
private const MIN_VM_PANNE_GENERALE = 3;
|
||||
|
||||
public function __construct(
|
||||
private readonly ClientCorrecteur $correcteur,
|
||||
private readonly NovaClient $nova,
|
||||
private readonly StockChaud $stock,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{sondees: int, muettes: int, retirees_stock: int, relancees: int, irreparables: int, panne_generale: bool, correcteur_injoignable: bool}
|
||||
*/
|
||||
public function surveiller(): array
|
||||
{
|
||||
$rapport = ['sondees' => 0, 'muettes' => 0, 'retirees_stock' => 0, 'relancees' => 0, 'irreparables' => 0, 'panne_generale' => false, 'correcteur_injoignable' => false];
|
||||
$limite = time() - self::GRACE_SECONDES;
|
||||
|
||||
$stacks = array_values(array_filter($this->stock->pretes(), fn ($s) => $s['cree_a'] < $limite));
|
||||
$machines = LabSessionMachine::query()
|
||||
->whereHas('labSession', fn ($q) => $q->where('etat', LabSession::ACTIF))
|
||||
->where('created_at', '<', now()->subSeconds(self::GRACE_SECONDES))
|
||||
->get();
|
||||
|
||||
// 1. Sonder tout, avant d'agir sur quoi que ce soit.
|
||||
$etats = [];
|
||||
try {
|
||||
foreach ($stacks as $stack) {
|
||||
foreach ($stack['instances'] as $instance) {
|
||||
$etats[$instance] = $this->correcteur->sante($instance);
|
||||
}
|
||||
}
|
||||
foreach ($machines as $machine) {
|
||||
$etats[$machine->instance_id] = $this->correcteur->sante($machine->instance_id);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
Log::error('Surveillance des VM : correcteur injoignable, aucune action', ['raison' => $e->getMessage()]);
|
||||
$rapport['correcteur_injoignable'] = true;
|
||||
$this->publierMetriques($rapport);
|
||||
|
||||
return $rapport;
|
||||
}
|
||||
|
||||
$muettes = array_filter($etats, fn ($e) => ! $e['repond']);
|
||||
$rapport['sondees'] = count($etats);
|
||||
$rapport['muettes'] = count($muettes);
|
||||
|
||||
if (count($etats) >= self::MIN_VM_PANNE_GENERALE && count($muettes) === count($etats)) {
|
||||
Log::critical("Surveillance des VM : les {$rapport['sondees']} VM sont muettes à la fois, panne de l'hôte probable : aucune action");
|
||||
$rapport['panne_generale'] = true;
|
||||
$this->publierMetriques($rapport);
|
||||
|
||||
return $rapport;
|
||||
}
|
||||
|
||||
// 2. Compter les sondes ratées de suite, oublier celles qui répondent.
|
||||
$suite = [];
|
||||
foreach ($etats as $instance => $etat) {
|
||||
if ($etat['repond']) {
|
||||
Redis::del("sante:muette:{$instance}");
|
||||
} else {
|
||||
$suite[$instance] = (int) Redis::incr("sante:muette:{$instance}");
|
||||
Redis::expire("sante:muette:{$instance}", 600);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Stock : une VM muette suffit à écarter toute la stack.
|
||||
foreach ($stacks as $stack) {
|
||||
$pire = max(array_map(fn ($i) => $suite[$i] ?? 0, array_values($stack['instances'])));
|
||||
|
||||
if ($pire >= self::SEUIL_STOCK && $this->stock->retirer($stack['lab'], $stack['brut'])) {
|
||||
Log::warning("Surveillance des VM : stack {$stack['nom']} du stock muette depuis {$pire} min, retirée et remplacée");
|
||||
Redis::hincrby('sante:compteurs', 'retraits_stock', 1);
|
||||
foreach ($stack['instances'] as $instance) {
|
||||
Redis::del("sante:muette:{$instance}");
|
||||
}
|
||||
$rapport['retirees_stock']++;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Sessions d'élèves.
|
||||
foreach ($machines as $machine) {
|
||||
$instance = $machine->instance_id;
|
||||
|
||||
if (($etats[$instance]['repond'] ?? false)) {
|
||||
if ($machine->sante === LabSessionMachine::IRREPARABLE) {
|
||||
$machine->update(['sante' => null, 'sante_a' => now()]);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (($suite[$instance] ?? 0) < self::SEUIL_SESSION) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->reparer($machine, $etats[$instance]['etat'])) {
|
||||
$rapport['relancees']++;
|
||||
}
|
||||
}
|
||||
|
||||
$rapport['irreparables'] = LabSessionMachine::query()
|
||||
->whereHas('labSession', fn ($q) => $q->where('etat', LabSession::ACTIF))
|
||||
->where('sante', LabSessionMachine::IRREPARABLE)
|
||||
->count();
|
||||
|
||||
$this->publierMetriques($rapport);
|
||||
|
||||
return $rapport;
|
||||
}
|
||||
|
||||
private function reparer(LabSessionMachine $machine, ?string $etat): bool
|
||||
{
|
||||
$instance = $machine->instance_id;
|
||||
$cle = "sante:relances:{$instance}";
|
||||
|
||||
if ((int) Redis::get($cle) >= self::RELANCES_MAX_PAR_HEURE) {
|
||||
if ($machine->sante !== LabSessionMachine::IRREPARABLE) {
|
||||
Log::error("Surveillance des VM : {$machine->nom} ({$instance}) toujours muette après ".self::RELANCES_MAX_PAR_HEURE.' relances, le prof est prévenu');
|
||||
Redis::hincrby('sante:compteurs', 'abandons', 1);
|
||||
$machine->update(['sante' => LabSessionMachine::IRREPARABLE, 'sante_a' => now()]);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if ($etat === 'shut off') {
|
||||
$this->nova->demarrer($instance);
|
||||
} else {
|
||||
$this->nova->redemarrerForce($instance);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
Log::error("Surveillance des VM : relance de {$instance} refusée par Nova", ['raison' => $e->getMessage()]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
Redis::incr($cle);
|
||||
Redis::expire($cle, 3600);
|
||||
Redis::del("sante:muette:{$instance}");
|
||||
Redis::hincrby('sante:compteurs', 'relances_session', 1);
|
||||
$machine->update(['sante' => LabSessionMachine::RELANCEE, 'sante_a' => now()]);
|
||||
Log::warning("Surveillance des VM : {$machine->nom} ({$instance}, état « {$etat} ») relancée automatiquement");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Métriques pour Prometheus (collecteur textfile de node_exporter). Le
|
||||
* fichier est créé par Ansible au nom de www-data ; une seule écriture
|
||||
* courte, verrouillée.
|
||||
*
|
||||
* @param array<string, int|bool> $rapport
|
||||
*/
|
||||
private function publierMetriques(array $rapport): void
|
||||
{
|
||||
$fichier = config('labs.surveillance.metriques');
|
||||
|
||||
if (! $fichier) {
|
||||
return;
|
||||
}
|
||||
|
||||
$compteurs = Redis::hgetall('sante:compteurs') ?: [];
|
||||
$lignes = [
|
||||
'# HELP syllabus_vm_surveillance_timestamp Dernier passage de la surveillance des VM.',
|
||||
'# TYPE syllabus_vm_surveillance_timestamp gauge',
|
||||
'syllabus_vm_surveillance_timestamp '.time(),
|
||||
'# HELP syllabus_vm_sondees VM sondées au dernier passage.',
|
||||
'# TYPE syllabus_vm_sondees gauge',
|
||||
'syllabus_vm_sondees '.$rapport['sondees'],
|
||||
'# HELP syllabus_vm_muettes VM dont l\'agent invité ne répond pas.',
|
||||
'# TYPE syllabus_vm_muettes gauge',
|
||||
'syllabus_vm_muettes '.$rapport['muettes'],
|
||||
'# HELP syllabus_vm_irreparables VM d\'élèves toujours muettes après les relances.',
|
||||
'# TYPE syllabus_vm_irreparables gauge',
|
||||
'syllabus_vm_irreparables '.$rapport['irreparables'],
|
||||
'# HELP syllabus_vm_panne_generale 1 si toutes les VM sont muettes à la fois (aucune action).',
|
||||
'# TYPE syllabus_vm_panne_generale gauge',
|
||||
'syllabus_vm_panne_generale '.(int) ($rapport['panne_generale'] || $rapport['correcteur_injoignable']),
|
||||
'# HELP syllabus_vm_reparations_total Réparations automatiques depuis la remise à zéro de Redis.',
|
||||
'# TYPE syllabus_vm_reparations_total counter',
|
||||
'syllabus_vm_reparations_total{type="relance_session"} '.(int) ($compteurs['relances_session'] ?? 0),
|
||||
'syllabus_vm_reparations_total{type="retrait_stock"} '.(int) ($compteurs['retraits_stock'] ?? 0),
|
||||
'syllabus_vm_reparations_total{type="abandon"} '.(int) ($compteurs['abandons'] ?? 0),
|
||||
];
|
||||
|
||||
$flux = @fopen($fichier, 'c');
|
||||
if ($flux === false) {
|
||||
Log::warning("Surveillance des VM : métriques non écrites dans {$fichier}");
|
||||
|
||||
return;
|
||||
}
|
||||
flock($flux, LOCK_EX);
|
||||
ftruncate($flux, 0);
|
||||
fwrite($flux, implode("\n", $lignes)."\n");
|
||||
fflush($flux);
|
||||
flock($flux, LOCK_UN);
|
||||
fclose($flux);
|
||||
}
|
||||
}
|
||||
|
|
@ -30,4 +30,35 @@ class NovaClient
|
|||
|
||||
return $reponse->json('remote_console.url');
|
||||
}
|
||||
|
||||
/**
|
||||
* Redémarrage forcé (bloc A, self-healing) : équivalent d'un appui sur
|
||||
* le bouton reset. Le disque de la VM est conservé.
|
||||
*/
|
||||
public function redemarrerForce(string $instanceId): void
|
||||
{
|
||||
$this->action($instanceId, ['reboot' => ['type' => 'HARD']]);
|
||||
}
|
||||
|
||||
/** Démarre une VM éteinte (l'élève a lancé « poweroff »). */
|
||||
public function demarrer(string $instanceId): void
|
||||
{
|
||||
$this->action($instanceId, ['os-start' => null]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $corps
|
||||
*/
|
||||
private function action(string $instanceId, array $corps): void
|
||||
{
|
||||
$reponse = Http::withHeaders([
|
||||
'X-Auth-Token' => $this->auth->jeton(),
|
||||
'OpenStack-API-Version' => 'compute 2.99',
|
||||
])
|
||||
->post(config('openstack.nova_url')."/servers/{$instanceId}/action", $corps);
|
||||
|
||||
if ($reponse->failed()) {
|
||||
throw new RuntimeException("Nova a refusé l'action ".array_key_first($corps)." sur {$instanceId} : ".$reponse->body());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue