Déployer une haute disponibilité multi-répliques¶
Ce guide explique comment faire tourner Repod avec plusieurs répliques
backend-api — d'abord la configuration minimale active-passive (survit à
la mort d'une réplique, aucune configuration supplémentaire), puis l'état
de job actif-actif (Redis) afin que les jobs scan/install/mirror/sync et
les flux d'événements/logs en direct fonctionnent quelle que soit la
réplique sur laquelle atterrit une requête. Pour les concepts et
mécanismes derrière les deux modes, voir
Architecture — Haute disponibilité.
1. Prérequis¶
Les deux modes ci-dessous ont d'abord besoin des deux mêmes éléments en
place — ce ne sont pas des options supplémentaires, c'est ce qui rend
sûr le fait de faire tourner plus d'une réplique backend-api :
- Un point de terminaison PostgreSQL HA externe.
DATABASE_URLsur chaque réplique doit pointer vers la même base de données — un VIP/point de terminaison devant un cluster Patroni/pgpool, RDS Multi-AZ, Cloud SQL HA, ou équivalent. Ne pointez pas les répliques vers le servicedbembarqué (un seul conteneur, lui-même un point unique de défaillance). Le pool SQLAlchemy (pool_pre_ping=True,pool_recycle=1800) gère déjà la reconnexion après un basculement côté Postgres. /repospartagé en lecture-écriture entre chaque réplique. Tous les artefacts de paquets, les manifestes, le trousseau GPG, le staging, les journaux d'audit etsettings.jsonvivent sous/repos/— chaque réplique a besoin de l'arborescence identique, pas d'une copie par hôte. Utilisez NFS, EFS (AWS), Filestore (GCP), ou un système de fichiers partagé équivalent monté sur chaque hôte Docker exécutant une réplique.
Les deux prérequis sont requis même pour l'active-passive
Même le déploiement multi-répliques le plus simple — sans Redis, sans
rien d'autre de configuré — a besoin de ces deux éléments. L'élection
de leader seule ne supprime pas le besoin d'une base de données
partagée et d'un /repos partagé ; elle décide seulement quelle
réplique exécute le planificateur.
2. Configuration minimale : active-passive¶
L'active-passive ne nécessite aucune nouvelle variable d'environnement ni
aucun service supplémentaire. Pointez le DATABASE_URL de chaque réplique
vers votre point de terminaison PostgreSQL HA, montez le /repos partagé
sur chaque hôte, et démarrez autant de conteneurs backend-api que vous
le souhaitez. Chaque réplique élit automatiquement sa position de leader
au démarrage via un verrou consultatif PostgreSQL
(backend/services/leader_election.py) — aucune étape de coordination
n'est requise de votre part.
Étape 1 — pointer chaque réplique vers la base de données et le
/repos partagés
export DATABASE_URL_HA=postgresql://repod:CHANGE_ME@pg-ha-endpoint:5432/repod
export REPOS_NFS_MOUNT=/mnt/repod-nfs # already mounted on every Docker host
Étape 2 — démarrer les répliques en utilisant l'overlay documenté
docker-compose.ha.yml à la racine du dépôt est un overlay de référence
qui désactive le service db embarqué, pointe backend vers
DATABASE_URL_HA, et remplace le bind mount local ./repos par le
montage NFS/EFS partagé :
Lisez docker-compose.ha.yml avant de l'utiliser — il est documenté
comme un point de départ illustratif, pas un remplacement direct pour
tout environnement (il suppose un seul hôte Docker exécutant plusieurs
répliques via --scale ; un vrai déploiement multi-hôtes exécute
typiquement un conteneur backend-api par hôte à la place, derrière un
répartiteur de charge, en utilisant les mêmes variables
d'environnement).
Étape 3 — placer un répartiteur de charge devant
Routez le trafic /api/v1/* ordinaire en round-robin sur chaque réplique
— les lectures, uploads, et la plupart des points de terminaison
fonctionnent de façon identique sur n'importe quelle réplique car ils ne
touchent que la base de données partagée et le /repos partagé. Aucun
routage sticky n'est requis pour l'active-passive seule.
Seul le leader exécute les six jobs cron APScheduler (security_sync_daily,
sla_check_daily, retention_daily, inventory_scan, backup_daily,
mirror_daily, plus les jobs supplémentaires listés dans main.py) —
scheduler_state.scheduler reste non défini sur les répliques passives.
Une poignée de points de terminaison qui démarrent un job en arrière-plan
suivi en mémoire sont protégés par Depends(require_leader) et renvoient
503 sur une réplique passive : POST /import/sync/start, POST
/import/mirror/start/{source_id}, POST /install/jobs, plus (via
require_leader() directement, sans exception de flux distribué) POST
/app-deps/clients/{id}/scan, POST /drift/clients/{id}/scan, POST
/drift/scan-by-tag, POST /inventory/clients/{id}/compliance/scan, et
POST /inventory/compliance/scan-by-tag. Les points de terminaison de
scan d'inventaire (POST /inventory/clients/{id}/scan, /scan-all,
/scan-by-tag) utilisent require_leader_for("scan") à la place, ce qui
devient un no-op une fois que l'état de job actif-actif (ci-dessous) est
activé pour ce flux.
Si vous n'avez jamais besoin de Redis ni de rendre les flux de jobs agnostiques à la réplique, ceci constitue un déploiement HA complet et fonctionnel : un leader meurt, son verrou consultatif PostgreSQL, lié à sa session, est libéré automatiquement, et une autre réplique prend le relais de la position de leader — voir Comportement en cas de basculement ci-dessous pour ce que « prend le relais » signifie exactement en pratique.
3. Activer l'état de job actif-actif (Redis)¶
L'active-passive seule signifie que la progression d'un job, son état
d'annulation, et ses logs ne vivent que dans la mémoire de la réplique qui
l'a créé — un client interrogeant une réplique différente ne voit rien,
ce qui explique pourquoi la création de job est verrouillée au leader en
premier lieu. Définir JOB_STATE_BACKEND=redis déplace cet état vers
Redis afin que n'importe quelle réplique puisse lire ou agir sur un job
quelle que soit la réplique qui l'a démarré, et lève la contrainte de
leader pour les flux vérifiés comme s'exécutant réellement sur le backend
distribué.
Ceci est opt-in par déploiement (pas par composant — voir la note
ci-dessous) et désactivé par défaut (JOB_STATE_BACKEND=local,
comportement inchangé).
Étape 1 — démarrer le service redis
docker-compose.yaml définit déjà un service redis (redis:7-alpine,
sans persistance — l'état de job est transitoire par conception, un
redémarrage de Redis perd simplement l'historique des jobs en cours,
sans jamais causer de corruption), protégé par le profil Compose
ha-active-active afin qu'il ne soit jamais démarré par un simple
docker compose up :
docker compose -f docker-compose.yaml -f docker-compose.ha.yml \
--profile ha-active-active up -d --scale backend=3
Étape 2 — pointer chaque réplique backend-api vers Redis
JOB_STATE_REDIS_URL est optionnel si REDIS_URL (la même variable
utilisée par le cache de réponses de services/cache.py) est déjà
définie — JOB_STATE_REDIS_URL n'existe que pour permettre de diriger le
trafic de coordination de jobs vers une instance/DB Redis distincte du
cache de réponses, si vous le souhaitez. Si aucune des deux n'est
définie alors que JOB_STATE_BACKEND=redis, chaque composant retombe sur
un comportement local (voir
Résolution de problèmes ci-dessous).
Ce que « opt-in par composant » signifie en pratique
Un seul bouton JOB_STATE_BACKEND=redis active tous les composants à la
fois — il n'existe pas de variable d'environnement par flux. Mais chaque
composant rapporte son backend réellement actif de façon indépendante
dans GET /health (ci-dessous), car chacun retombe indépendamment sur le
mode local si Redis est injoignable au moment où il en a besoin. Une fois
réellement actif, cela couvre :
- Les quatre suiveurs de jobs : scan d'inventaire, installation, mirror,
sync — la création, l'interrogation, l'annulation et la confirmation de
jobs deviennent toutes sûres sur n'importe quelle réplique, et les
contraintes
require_leader/require_leader_for("scan")pour ces flux cessent de s'appliquer. - Le bus d'événements SSE du tableau de bord (
GET /dashboard/events) — un événement publié sur une réplique atteint les abonnés connectés à chaque réplique. - Le flux de logs backend en direct (
GET /logs/stream,GET /logs,GET /logs/services) — même distribution inter-répliques, plus un historique borné partagé (les 2000 dernières entrées) afin qu'une réplique n'ayant jamais enregistré une entrée localement puisse quand même la servir depuis Redis.
Ni le bus SSE ni le flux de logs n'ont jamais eu de contrainte
require_leader au départ (n'importe quelle réplique acceptait déjà les
abonnés) — activer Redis pour eux change la portée de distribution
(une seule réplique → tout le parc), pas l'accès.
les scans drift / app-deps / compliance restent leader-only dans tous les cas
POST /app-deps/clients/{id}/scan, POST /drift/clients/{id}/scan,
POST /drift/scan-by-tag, POST /inventory/clients/{id}/compliance/scan,
et POST /inventory/compliance/scan-by-tag n'ont pas de backend
d'état distribué propre — ils restent verrouillés par le simple
require_leader() que JOB_STATE_BACKEND=redis soit défini ou non.
Une réplique passive renvoie toujours 503 sur ces cinq points de
terminaison.
4. Vérifier que ça a fonctionné¶
Interrogez GET /health sur chaque réplique et inspectez
checks.info.ha :
{
"ok": true,
"is_leader": true,
"instance_id": "backend-a1b2c3d4",
"scheduler_active": true,
"job_state_backend": {
"scan": "redis",
"install": "redis",
"mirror": "redis",
"sync": "redis",
"sse": "redis",
"logs": "redis"
}
}
is_leader/instance_id— confirmez qu'exactement une réplique rapporteis_leader: trueà la fois, et queinstance_iddiffère entre répliques (il est dérivé du nom d'hôte plus un suffixe aléatoire, donc deux conteneurs n'entrent jamais en collision même avec des préfixes de nom d'hôte identiques).scheduler_active— devrait êtretrueuniquement sur le leader ;falsesur chaque réplique passive.job_state_backend.{scan,install,mirror,sync,sse,logs}— chacun rapporte"redis"uniquement quand ce composant s'exécute réellement contre un Redis actuellement joignable, jamais juste l'intention configurée. Si vous définissezJOB_STATE_BACKEND=rediset voyez toujours"local"pour l'un d'eux, consultez la section résolution de problèmes ci-dessous avant de supposer que l'actif-actif est vraiment en place.
Une vérification rapide de bout en bout : démarrez un job de scan
d'inventaire via une réplique (derrière le répartiteur de charge, donc
vous ne contrôlez pas laquelle), puis interrogez
GET /inventory/clients/{id}/scan-status via une réplique différente
(contournez temporairement le répartiteur de charge pour la cibler
directement) — avec Redis actif, la seconde réplique devrait voir la
progression en direct ; avec local, elle renverrait 404/ne rapporterait
rien.
5. Comportement en cas de basculement¶
Le verrou consultatif (pg_try_advisory_lock) qui porte la position de
leader est lié à la session dans PostgreSQL : il est attaché à la
connexion de base de données spécifique que le leader a ouverte, pas à un
délai d'expiration ou un battement de cœur.
- Si le processus du leader meurt (crash de conteneur, kill OOM, panne d'hôte), sa connexion PostgreSQL se ferme et PostgreSQL libère le verrou consultatif automatiquement — aucune intervention manuelle n'est nécessaire côté base de données.
- L'acquisition n'a lieu qu'au démarrage.
acquire_leadership()s'exécute une fois au démarrage du cycle de vie d'une réplique et n'est jamais retentée sur un processus en cours d'exécution. Une réplique passive survivante ne sonde pas pour prendre la position de leader tant qu'elle continue de tourner — le verrou ne redevient acquérable qu'après avoir été libéré, et une réplique ne tente d'acquérir la position de leader que lorsqu'elle (re)démarre. En pratique, cela signifie que le basculement nécessite de redémarrer le conteneur du leader mort (ou d'en démarrer un nouveau) — quel que soit le processus de réplique qui exécute ensuiteacquire_leadership()après la libération du verrou devient le nouveau leader. Si votre orchestrateur (Docker Composerestart: unless-stopped, Kubernetes, systemd) redémarre déjà automatiquement un conteneur planté, le basculement se produit aussi automatiquement, à ce redémarrage — mais c'est le redémarrage qui déclenche la ré-élection, pas une surveillance en arrière-plan. - Jusqu'à l'élection d'un nouveau leader, les six jobs cron et les
points de terminaison verrouillés par
require_leaderlistés ci-dessus sont indisponibles (503) — tout le reste (lectures, uploads, la plupart de l'API) continue de fonctionner normalement sur les répliques survivantes pendant ce temps. - Fail-open sur les dialectes non-PostgreSQL ou les erreurs
d'élection : si
DATABASE_URLpointe vers un dialecte non-PostgreSQL (par ex. SQLite, utilisé dans les tests) ou siacquire_leadership()rencontre une erreur inattendue, cette réplique devient simplement leader (is_leader() == True) — c'est ce qui maintient un déploiement mono-instance ou de test pleinement fonctionnel sans que toute cette machinerie ne s'engage.
6. Résolution de problèmes : Redis configuré mais injoignable¶
Si JOB_STATE_BACKEND=redis est défini mais que Redis ne peut être
atteint (URL incorrecte, partition réseau, Redis lui-même en panne),
chaque composant retombe sur le comportement local indépendamment —
c'est un comportement fail-soft par conception, jamais un échec de
démarrage bloquant. Ce qu'il faut chercher :
- Logs : chaque repli est journalisé au niveau
ERROR(pasWARNING, contrairement au même schéma de repli dansservices/cache.py) — une panne Redis affectant la correction HA est traitée comme plus sévère qu'une dégradation de la couche cache. Recherchez dans les logs backend-api le nom du composant (scan,install,mirror,sync,sse,logs) accompagné du message de repli. GET /health:checks.info.ha.job_state_backend.{flow}rapporte"local"pour le(s) composant(s) affecté(s) — c'est le signal faisant autorité, toujours à jour, puisqu'il reflète le backend réellement utilisé après tout repli, pas l'intention configurée.- Ce qui fonctionne encore : rien ne casse complètement. Un composant
qui est retombé sur
"local"se comporte exactement comme siJOB_STATE_BACKENDn'avait jamais été défini pour ce composant — la création de scan/install/mirror/sync est rejetée avec503sur les répliques passives (la contrainte de leader reste appliquée, précisément pour éviter un faux sentiment de sécurité distribuée), et les flux SSE/logs cessent simplement de se distribuer entre répliques (chaque réplique ne sert de nouveau que ses propres abonnés locaux) sans rien rejeter. - Résolution : corrigez l'accessibilité de Redis (vérifiez
JOB_STATE_REDIS_URL/REDIS_URL, la politique réseau, l'état de santé du conteneurredis) et redémarrez les répliquesbackend-apiaffectées — il n'existe pas de sonde de reconnexion en direct qui fasse basculer un composant de nouveau vers"redis"sans redémarrage.
Voir aussi¶
- Architecture — Haute disponibilité (active-passive) et Haute disponibilité (active-active) pour le raisonnement de conception derrière les deux mécanismes.
- Sauvegarder et restaurer Repod — un
pg_dumpcomplet de la base de données plus une archive/repos; non affecté par le nombre de répliques en cours d'exécution, puisqu'une seule d'entre elles suffit pour l'exécuter. docker-compose.ha.yml(racine du dépôt) — l'overlay de référence complet, incluant les notes de routage du répartiteur de charge pour chaque point de terminaison référencé ci-dessus.