Aller au contenu

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 :

  1. Un point de terminaison PostgreSQL HA externe. DATABASE_URL sur 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 service db embarqué (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.
  2. /repos partagé en lecture-écriture entre chaque réplique. Tous les artefacts de paquets, les manifestes, le trousseau GPG, le staging, les journaux d'audit et settings.json vivent 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é :

docker compose -f docker-compose.yaml -f docker-compose.ha.yml \
  up -d --scale backend=3

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_BACKEND=redis
JOB_STATE_REDIS_URL=redis://repod-redis:6379/0

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 :

curl -s http://localhost:8000/health | jq '.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 rapporte is_leader: true à la fois, et que instance_id diffè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 être true uniquement sur le leader ; false sur 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éfinissez JOB_STATE_BACKEND=redis et 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 ensuite acquire_leadership() après la libération du verrou devient le nouveau leader. Si votre orchestrateur (Docker Compose restart: 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_leader listé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_URL pointe vers un dialecte non-PostgreSQL (par ex. SQLite, utilisé dans les tests) ou si acquire_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 (pas WARNING, contrairement au même schéma de repli dans services/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 si JOB_STATE_BACKEND n'avait jamais été défini pour ce composant — la création de scan/install/mirror/sync est rejetée avec 503 sur 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 conteneur redis) et redémarrez les répliques backend-api affecté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