← Retour aux articles
3 October 2026 17 min de lecture MaintenantOpenSVCHaute disponibilitéGoFrance 2030

Dix pannes, dix bascules : le premier banc haute disponibilité de Maintenant sur OpenSVC

Premier jalon du chantier HA financé par Hyper Open X : ce qu'on a changé dans le binaire Go, comment OpenSVC porte la bascule, et le banc libvirt qui casse tout ça méthodiquement pour mesurer le temps de reprise.

Dans le bilan de mai à août, j’annonçais le chantier haute disponibilité du programme Hyper Open X : Maintenant devient un service OpenSVC sur deux nœuds, avec une adresse de service qui suit l’instance active et une bascule automatique quand ce nœud tombe. Je parlais aussi d’un banc d’essai, deux nœuds, un arbitre de quorum, un WAN simulé et des scénarios de panne rejouables, alors encore en conception.

Le banc existe maintenant. Le 2 octobre, il a joué les dix scénarios de panne applicables au premier mode de déploiement, et les dix passent : arrêt brutal du nœud actif, kernel panic, gel de l’agent de cluster, coupure entre les deux sites, perte de l’adresse de service, crash de l’application, application gelée, WAN dégradé, split-brain forcé, et la bascule manuelle propre qui sert de référence.

Il faut dire précisément ce que ça prouve. Ce premier mode, ip-only, ne réplique aucune donnée : chaque nœud a sa propre base SQLite. Il valide toute la mécanique de bascule (l’adresse qui se déplace, l’application qui redémarre au bon endroit, le nœud défaillant qu’on éteint avant de reprendre son service, le quorum qui empêche les deux côtés de servir en même temps), mais pas encore la conservation des données. Chaque scénario n’a été joué qu’une fois. La réplication PostgreSQL est écrite et attend son premier passage sur le banc.

Cet article raconte ce qu’on a modifié dans l’application, comment OpenSVC est utilisé, comment le banc fonctionne, et surtout ce qu’il nous a appris en cassant des choses.

Le partage des rôles : le binaire ne décide rien

La règle de départ est simple : le binaire n’élit personne, ne détecte aucune panne et ne déplace rien. Toute la logique de cluster vit dans OpenSVC. Maintenant reste un binaire Go statique qui démarre là où on lui dit de démarrer, et le pitch « un seul conteneur, zéro dépendance » tient toujours pour ceux qui n’ont pas besoin de HA. Un test vérifie d’ailleurs que le code produit ne mentionne jamais le gestionnaire de cluster.

OpenSVC (ici om3, la v3 de l’agent) gère Maintenant comme un service de type failover : une seule instance active à la fois, sur l’un des deux nœuds. Le service tient en deux ressources :

  • ip#0, l’adresse de service, celle que les utilisateurs, la status page et les agents gRPC connaissent. Elle monte sur le nœud actif et le suit en cas de bascule ;
  • app#0, le binaire lui-même, lancé au premier plan par OpenSVC (pas d’unité systemd, pas de conteneur), avec un script de vérification qui interroge /api/v1/health.

Autour de ces deux nœuds, il y a deux machines de plus. Un arbitre sur un troisième site sert de témoin pour le quorum : quand les deux nœuds ne se voient plus, celui qui obtient la voix de l’arbitre garde le service, l’autre s’arrête. Et sur le banc, une machine de mesure, hors des deux sites, observe le service comme le ferait un utilisateur.

La décision de bascule suit un ordre précis. OpenSVC tente d’abord de redémarrer la ressource fautive sur place (deux essais). Si ça ne suffit pas, il déplace le service sur l’autre nœud. Et si c’est le nœud entier qui ne répond plus, le survivant commence par l’éteindre de force (le fencing, ou STONITH) avant de reprendre le service. Un fencing qui ne peut pas prouver que le pair est bien éteint bloque la reprise : mieux vaut une coupure qu’un service actif sur les deux nœuds.

Enfin, le service ne revient jamais tout seul sur le nœud préféré après réparation. Le retour est une opération manuelle, et en mode PostgreSQL elle est refusée tant que la copie du nœud préféré n’a pas rattrapé son retard.

Quatre modes, deux écrits

La question des données se règle par mode de déploiement :

ModeDonnéesÉtat
ip-onlySQLite locale à chaque nœud, non répliquéeécrit, banc vert
postgres/streamingPostgreSQL 16 sur les deux nœuds, réplication synchroneécrit, pas encore passé sur le banc
sqlite/drbdSQLite sur un volume répliqué par DRBDà écrire
postgres/drbdPostgreSQL sur un volume DRBDà écrire

En postgres/streaming, PostgreSQL tourne en dehors du service OpenSVC, sur les deux nœuds : un primaire sur le nœud actif, un standby synchrone sur l’autre. C’est le service qui promeut le standby, via un script lancé avant le démarrage de l’application (blocking_pre_start) : si la promotion échoue, l’application ne démarre pas. Le script refuse de promouvoir un standby qui n’a jamais rattrapé le primaire, ou dont le pair répond encore comme primaire. Un ancien primaire qui revient se marque comme clôturé, refuse de redémarrer en primaire, et se reconstruit en standby avec pg_rewind (ou pg_basebackup si les WAL nécessaires ont disparu).

Ce qu’on a changé dans Maintenant

Sept modifications, toutes bornées et toutes inertes tant qu’on ne les active pas. Un test (compat_unchanged_test.go) vérifie qu’une instance lancée sans ces réglages garde exactement la même configuration, les mêmes chemins et la même chaîne de connexion SQLite qu’avant.

1. Un seul répertoire d’état. MAINTENANT_STATE_DIR (ou --state-dir) désigne une racine absolue qui contient tout ce qui fait l’identité d’une instance : la base, le cache de licence, l’identité de l’agent embarqué, l’identité de télémétrie, les certificats gRPC, et même le répertoire temporaire de SQLite. Avant, ces fichiers étaient éparpillés à côté de la base ou du binaire. Maintenant, déplacer un volume suffit à déplacer l’instance entière. Le répertoire est vérifié au démarrage : chemin absolu, existant, et accessible en écriture (on y écrit un fichier de test). Au passage, ça a corrigé un bug de --copy-store-to, qui résolvait le chemin de la base avant de connaître la racine d’état.

2. Le mode synchronous de SQLite devient réglable. MAINTENANT_SQLITE_SYNCHRONOUS accepte NORMAL (la valeur historique) ou FULL, et refuse tout le reste, OFF en particulier. En WAL avec NORMAL, les toutes dernières transactions validées peuvent être perdues sur une coupure de courant. Sur un volume répliqué, on veut qu’une écriture acquittée soit réellement sur le disque : c’est FULL, prévu pour le futur mode DRBD.

3 et 4. Deux garde-fous de démarrage. MAINTENANT_REQUIRE_STATE_DIR fait refuser le démarrage si la racine d’état n’est pas définie : un nœud passif sans volume monté doit échouer, pas écrire à côté du binaire. MAINTENANT_REQUIRE_EXISTING_DATA refuse de démarrer sur une base vide, pour qu’un nœud rattaché au mauvais volume ne crée pas un schéma neuf par-dessus.

Ces trois réglages (2 à 4) sont réservés à l’édition Pro. En dessous, ils sont ignorés avec un avertissement, et le démarrage n’est jamais refusé. Pour que ce contrôle fonctionne, il a fallu résoudre l’édition avant d’ouvrir le stockage, alors que la licence était jusque-là initialisée après. La racine d’état, elle, reste disponible dans toutes les éditions.

5. Des keepalives gRPC des deux côtés. Après une bascule, un agent connecté en gRPC garde un flux ouvert vers le nœud mort. Sans keepalive, seul le délai de retransmission TCP du noyau finit par s’en rendre compte, et ça se compte en minutes. Le client et le serveur envoient désormais un ping toutes les 20 secondes avec 10 secondes de délai, ce qui borne la détection d’un flux mort à une trentaine de secondes. La politique côté serveur reste permissive, pour qu’un agent plus ancien sans keepalive ne soit jamais rejeté ; un test le vérifie.

6. Un délai de grâce pour les heartbeats au démarrage. Un job cron qui aurait dû pinger pendant les vingt secondes où le service changeait de nœud déclenchait une fausse alerte « heartbeat manqué » sur le nouveau nœud. Une échéance manquée qui tombe dans les deux minutes suivant le démarrage du processus, et qui chevauche ce redémarrage, n’est plus alertée. Une bascule ne doit pas créer d’alertes à elle seule, et le banc mesure justement celles qu’elle provoque.

7. Les endpoints locaux sont rechargés au démarrage. Celui-là est un vrai bug, latent depuis longtemps : après un redémarrage, un endpoint HTTP ou TCP surveillé directement par le serveur n’était plus sondé tant que personne ne le modifiait. Quand un redémarrage est rare, ça passe inaperçu. Avec la HA, un redémarrage devient un événement normal, et le bug devient visible.

Rien n’a changé dans le code de stockage PostgreSQL : le mode postgres/streaming réutilise le backend PostgreSQL optionnel mergé en août, connecté en socket Unix local au primaire du nœud actif.

Le banc d’essai

Une HA qu’on n’a pas vue tomber en panne, c’est une hypothèse. Le banc sert à transformer « ça devrait basculer » en un chiffre, attaché à une version précise de chaque composant, et rejouable par n’importe qui.

Quatre VM sur un poste de dev

Le banc tourne sur une machine Linux avec KVM et libvirt, sans compte root pour l’usage courant. Il monte quatre machines virtuelles Debian 12 :

  • site-a et site-b, les deux nœuds du cluster (2 vCPU, 4 Go, plus un second disque pour l’état répliqué) ;
  • arb, l’arbitre du quorum, un simple endpoint HTTP servi par nginx ;
  • probe, la machine de mesure.

Chaque VM démarre sur un overlay qcow2 d’une image cloud Debian mise en cache, configurée par cloud-init avec des adresses statiques. Un seul fichier, topology.yml, décrit les adresses, les tailles et les images. Le déploiement passe ensuite par les mêmes rôles Ansible que la production de référence : le banc et la prod ne diffèrent que par l’inventaire et par la méthode de fencing. Sur le banc, un nœud « tire » sur son pair en se connectant en SSH à l’hôte libvirt avec une clé limitée à une seule commande, qui fait un virsh destroy de la VM visée et vérifie qu’elle est bien arrêtée. En production, les deux mêmes variables portent de l’IPMI, un PDU ou l’API d’un hébergeur. Un déploiement sans fencing est refusé.

Tout se pilote avec un seul script :

deploy/ha/lab/lab host-setup        # une fois, demande sudo pour la préparation de l'hôte
deploy/ha/lab/lab up --mode ip-only # construit, déploie, vérifie que tout est vert
deploy/ha/lab/lab run-all           # joue tous les scénarios du mode, écrit le rapport
deploy/ha/lab/lab down

lab chaos <n> et lab heal <n> injectent et réparent une panne à la main, et lab status ne répond zéro que si exactement un nœud tient l’adresse et le service.

Trois sondes, et des règles de comptage strictes

Les mesures viennent de trois petits programmes Go qui tournent sur la machine de mesure du début à la fin, sans jamais être relancés entre deux scénarios :

  • availability envoie une requête toutes les 100 ms sur l’adresse de service et note chaque réponse avec une horloge monotone. La plus longue interruption pendant un scénario donne le temps de reprise (RTO). La cadence est tenue même quand les requêtes restent bloquées, puisque c’est justement pendant une panne qu’elles le sont.
  • writes écrit des enregistrements numérotés via l’API (des pings de heartbeat, le chemin d’écriture le plus sollicité du produit) et note pour chacun ce que le serveur a répondu : acquitté, refusé, ou inconnu faute de réponse. Après la reprise, il relit tout. Seul un enregistrement acquitté puis introuvable compte comme une perte. Une écriture restée sans réponse n’est comptée ni comme perte ni comme succès, parce que personne ne sait si elle a été validée.
  • observer surveille la télémétrie et les alertes. Il mesure le trou dans l’historique pendant la bascule, qu’on ne présente jamais comme une perte de données (ces points n’ont pas été collectés, ils n’ont pas été perdus), et compte les alertes provoquées par la bascule, uniquement sur des sujets qui étaient sains juste avant.

Chaque passage produit un rapport versionné dans le dépôt (deploy/ha/reports/), avec les versions de Maintenant, d’OpenSVC, du noyau, le commit du banc et les réglages utilisés. Un rapport sans versions n’a aucune valeur de preuve. Une seule écriture acquittée perdue fait échouer le passage, et deux nœuds qui écrivent en même temps aussi, quel que soit le reste.

Les scénarios et les premiers chiffres

Douze scénarios permanents, plus des scénarios propres au mode PostgreSQL (standby absent, retour de l’ancien primaire, rétention des WAL épuisée, promotion forcée, retour sur le nœud préféré). Voici le passage du 2 octobre en mode ip-only, sur om3 v3.0.0-rc30 et Debian 12, une répétition par scénario :

#Panne injectéeCommentAttenduRTO mesuré
1Bascule manuelle propreom maintenant switchréférence1,2 s
2Perte brutale du nœud actifvirsh destroyfencing puis bascule, < 60 s23,2 s
3Kernel panicecho c > /proc/sysrq-triggeridem, < 60 s23,9 s
4Agent de cluster geléSIGSTOP sur le démon om3le service continue0 s
5Coupure entre les sitesiptables entre les nœudsun seul côté sert2,6 s
6Perte de l’adresse de serviceip addr delremise en place sur le même nœud2,4 s
7Crash de l’applicationpkill -9redémarrage local, < 30 s8,9 s
8Application geléeSIGSTOP sur maintenantdétection par le contenu du health check, < 120 s78,7 s
11WAN dégradétc netem 80 ms, 3 % de perteaucune bascule0,1 s
12Split-brain forcécoupure + arbitre injoignablele côté isolé s’arrête sans rien démarrer4,5 s

Les scénarios 9 (volume plein) et 10 (perte du primaire PostgreSQL) ne s’appliquent pas à ce mode, qui n’a ni volume de service ni PostgreSQL.

Le scénario 8 est celui que beaucoup de mises en HA ratent : le processus est vivant, son PID existe, il ne répond juste plus. Un contrôle de PID le déclare en bonne santé. Le script de vérification lit donc le corps de /api/v1/health et distingue trois états : vivant, dégradé (le stockage ne répond plus, ce qui ne doit pas déclencher de bascule) et mort.

Le scénario 12 mérite aussi une explication. Quand l’arbitre est injoignable des deux côtés en plus de la coupure, les deux nœuds s’arrêtent : c’est une panne totale, et c’est le comportement correct. Une coupure se répare, alors que deux bases qui ont divergé ne se réconcilient pas.

Ce que le banc nous a appris

Le premier passage complet, dans l’après-midi du 2 octobre, donnait 1 scénario vert sur 10. Dans la soirée, on est passé à 8, puis 9, puis 10. Entre les deux, une vingtaine de correctifs. Certains concernaient le banc lui-même, d’autres la façon d’utiliser OpenSVC. Les deux comptent autant, parce qu’un banc qui ment est pire que pas de banc du tout.

Sur OpenSVC

Le quorum est désactivé par défaut. Sans quorum, un cluster coupé en deux écrit « cluster is split, ignore » dans ses logs, et les deux côtés démarrent le service. Autrement dit, un split-brain : exactement ce que le banc doit prouver impossible. Le rôle Ansible active cluster.quorum, déclare l’arbitre, règle node.split_action=crash, puis relit les quatre réglages sur chaque nœud et échoue en nommant celui où l’un d’eux n’a pas pris.

Le port de l’arbitre doit être explicite. Quand l’URI de l’arbitre ne précise pas de port, om3 utilise 1214 par défaut, alors que son propre listener écoute sur 1215. La documentation se contredit d’ailleurs sur ce point : le CHANGELOG et la référence des mots-clés ne donnent pas la même valeur. On indique donc toujours le port.

Les nœuds se trouvent par leur nom. Trois correctifs pour un même symptôme : le join réussissait, mais le cluster ne se formait jamais. om3 envoie ses heartbeats depuis l’adresse sur laquelle se résout son propre nom d’hôte, et cloud-init l’avait associé à 127.0.1.1. Après le join, il contacte ses pairs par leur nom de nœud, que ni le DNS de libvirt ni cloud-init ne déclaraient. Et le certificat qu’il génère nomme le nœud et 127.0.0.1, pas l’adresse d’interconnexion, donc rejoindre le cluster par IP échoue. La solution tient en une ligne de principe : épingler chaque nœud, lui compris, dans /etc/hosts, et rejoindre par nom.

Le code retour 1 veut dire « arrêté ». Notre script de vérification renvoie 0 pour vivant, 1 pour dégradé et 2 pour mort. om3 lit 1 comme un service arrêté, donc un stockage lent aurait déclenché une bascule. retcodes = 0:up 1:warn 2:down remet les choses en ordre.

Les ressources ne sont vérifiées qu’une fois par minute. Par défaut, om3 contrôle les ressources toutes les 60 secondes, donc un crash de l’application était détecté entre 0 et 60 secondes plus tard. Le scénario 7 mesurait 37,4 puis 41,4 secondes, au-delà du budget de 30 secondes. Avec monitor_schedule = @10s, il passe à 8,9 secondes.

stonith = true se déclare sur le service. La commande de fencing se configure sur le nœud, mais un service de type failover qui ne porte pas DEFAULT.stonith=true ne l’appelle jamais.

L’objet n’est pas prêt juste après sa création. Le dégel du service, lancé une seconde après om maintenant create, était rejeté avec une raison vide : le démon n’avait pas encore évalué l’objet. On attend maintenant qu’il apparaisse comme gelé avant de le dégeler.

La version se fige. om3 est encore en release candidate. La rc31 notée dans notre état de l’art n’est publiée sur aucune branche de packages.opensvc.com, et la branche prod ne fournit opensvc-server que pour Debian 12 : au 12 septembre, Debian 13 ne pouvait pas héberger un nœud. Le banc tourne donc sur la v3.0.0-rc30 de la branche prod, épinglée dans apt, et le rôle refuse d’aller plus loin si un nœud exécute autre chose. Chaque mesure est attachée à une version : une mise à jour silencieuse invaliderait les preuves sans que personne ne s’en rende compte.

Sur le banc lui-même

Les bugs du banc sont les plus dangereux, parce qu’ils produisent des résultats faux qui ont l’air vrais. Quelques exemples :

  • la réparation visait le nœud actif au moment de réparer, pas celui qu’on avait cassé. Après une bascule, elle essayait donc de redémarrer le survivant. Le couple injecté est désormais enregistré au moment de l’injection ;
  • plusieurs scénarios vérifiaient l’état avant que la panne ait produit son effet. Un délai d’attente par scénario (settle_s) et des vérifications plus strictes (exactement un détenteur de l’adresse, vérifié depuis l’hôte) ont corrigé ça ;
  • des variables étaient développées côté distant au lieu de l’être localement. Le scénario 6 aurait supprimé une adresse vide ;
  • les sondes visaient le port 80 au lieu du port du service ;
  • la commande de fencing, exécutée par un compte non privilégié, parlait à qemu:///session au lieu de qemu:///system. Elle ne trouvait aucune VM et refusait donc tous les fencings. C’était le bon réflexe (dans le doute, on bloque), mais pour une mauvaise raison.

Enfin, le travail de préparation a fait remonter deux vrais bugs du produit, décrits plus haut : les endpoints locaux qui n’étaient plus sondés après un redémarrage, et les fausses alertes de heartbeat après une bascule. Les deux touchaient aussi les installations sans HA.

La suite

Ce premier banc vert valide la mécanique. Il reste à prouver que les données survivent, et à le prouver avec assez de répétitions pour que le chiffre veuille dire quelque chose :

  • passer postgres/streaming sur le banc. Les rôles, la promotion, la clôture de l’ancien primaire et les scénarios 15 à 19 sont écrits. Il faut maintenant les jouer, puis lancer la campagne prévue : vingt répétitions de la perte brutale du nœud actif avec zéro écriture acquittée perdue, et dix répétitions du retour de l’ancien primaire ;
  • écrire le mode sqlite/drbd. C’est celui qui compte le plus pour Maintenant, parce qu’il garde l’application sans base externe. Le réglage MAINTENANT_SQLITE_SYNCHRONOUS=FULL est déjà prêt pour lui ;
  • mesurer en mode serveur, avec de vrais agents gRPC, pour vérifier qu’ils se reconnectent avec la même identité en moins de 90 secondes, et mesurer le trou de télémétrie et les alertes provoquées ;
  • écrire le guide d’exploitation et le scénario de mise à jour du binaire sans coupure.

L’objectif du programme reste le même : un déploiement actif/passif sur deux nœuds, documenté et validé en environnement représentatif d’ici avril 2027, puis déployé chez un partenaire pilote. Les options HA du binaire font partie de l’édition Pro, et elles ne seront jamais un prérequis : docker run continue de suffire à ceux qui n’en ont pas besoin.

Si tu fais tourner Maintenant en production et que tu veux tester la bascule sur ton infrastructure cet automne, écris-moi ou réponds au formulaire. L’édition Pro reste offerte pendant le programme aux instances qui testent réellement et envoient un retour écrit.


*Maintenant est un logiciel libre sous licence Apache 2.0. Le site : maintenant.dev. Le code : github.com/kolapsis/maintenant. *

France 2030 Cap Digital

Ce projet a été financé par le Gouvernement dans le cadre du plan France 2030 opéré par Bpifrance.

Besoin d'aide sur ce sujet ?

Réserver un créneau