Retour à Haute Disponibilité
    Runbook opérateur

    HA / Multi-AZ — Runbook Pulse

    Runbook opérateur pour passer Pulse en actif-passif multi-AZ : activation, failover, monitoring, rollback.

    Destiné à l'opérateur d'astreinte. Prérequis : un déploiement Pulse mono-nœud fonctionnel que vous voulez basculer en actif-passif sur deux AZ, puis maintenir en bonne santé.

    1. Ce que le mode HA vous apporte

    Le mode HA exécute deux JVM Pulse derrière un load-balancer stateful, adossées à un Postgres primaire + réplica synchrone dans une seconde AZ. Un nœud est leader (les écritures y vont) — l'autre est standby (lectures, prend le relais au failover). RTO 30 s pour un crash JVM, 45 s pour une panne d'AZ. Disponibilité : de 99,5 % en mono-hôte à 99,9 % en HA.

    L'élection de leader s'appuie sur un verrou consultatif Postgres à portée session, avec un compteur d'epoch par promotion pour évincer les écritures d'un leader obsolète.

    SLO de latence de failover

    Une fois le verrou consultatif libéré (arrêt planifié : libération immédiate ; crash : libération quand Postgres réclame la session morte), un standby est promu dans une borne fixée par l'intervalle leaseHeartbeat (défaut 5000 ms) :

    • Promotion attendue : leaseHeartbeat × 2 ≈ 10 s au défaut.
    • Plafond appliqué (SLO) : leaseHeartbeat × 3 = 15 s.

    Le RTO bout-en-bout (30 s JVM / 45 s AZ) ajoute la détection de crash (health-check LB + réclamation de session par Postgres). Réduire leaseHeartbeat rétrécit la fenêtre au prix de plus de trafic sur le verrou. Un test d'intégration mesure la promotion réelle et casse le build si elle dépasse leaseHeartbeat × 3.

    2. Prérequis

    • Postgres 14+ (verrous consultatifs activés par défaut). RDS Multi-AZ ou cluster Patroni auto-hébergé. Réplication synchrone non négociable pour RPO=0.
    • 2 JVM Pulse, chacune sur un hôte dans une AZ différente.
    • Le module cluster-bridge sur le classpath de chaque instance Pulse. Auto-enregistrement via SPI, aucun changement de code.
    • Un LB stateful en frontal (ALB / Caddy / HAProxy). Health-check : GET /api/pulse/health × 2 misses → down.

    3. Activation

    Sur CHAQUE hôte Pulse :

    bash
    1export PULSE_DB_URL='jdbc:postgresql://pg-cluster.internal:5432/pulse?targetServerType=primary'
    2export PULSE_DB_USER=pulse
    3export PULSE_DB_PASSWORD=<vault>
    4export PULSE_NODE_ID=pulse-a            # unique per host
    5export PULSE_BIND_HOST=10.0.1.12        # routable address
    6export PULSE_BIND_PORT=9090
    7export PULSE_REGION_ID=us-east-1a       # or 1b on the standby
    8export PULSE_HA_MODE=true               # the toggle
    9systemctl restart pulse

    Ou, sans redémarrage, bascule à chaud :

    bash
    curl -X POST https://pulse-a.internal/api/admin/cluster/enable \
      -H "Authorization: Bearer $ADMIN_TOKEN" \
      -d '{"nodeId":"pulse-a","address":"10.0.1.12:9090","regionId":"us-east-1a"}'

    Sans le module bridge ou avec PULSE_HA_MODE=false, le coordinateur démarre en mode passif (membre unique, toujours leader, epoch fixe 1).

    Ligne de log attendue au démarrage :

    ClusterCoordinator started: node=pulse-a region=us-east-1a lock=pulse-cluster-leader heartbeat=5000ms
    Promoted to LEADER: node=pulse-a epoch=1

    4. Lire l'état du cluster

    bash
    curl -s https://pulse-a.internal/api/admin/cluster/status | jq
    json
    1{
    2  "self": {"nodeId":"pulse-a","role":"LEADER","epoch":4,"address":"10.0.1.12:9090"},
    3  "members": [
    4    {"nodeId":"pulse-a","role":"LEADER","epoch":4},
    5    {"nodeId":"pulse-b","role":"FOLLOWER","epoch":4}
    6  ],
    7  "coordinator": "enterprise"
    8}

    UI : Paramètres → Cluster affiche les mêmes données avec les badges de rôle. Logs : chaque promotion/rétrogradation logge une ligne — grep "Promoted to LEADER\\|transition.*FOLLOWER" /var/log/pulse/pulse.log.

    5. Failover manuel

    À utiliser quand le leader est assez vivant pour tenir le verrou mais dégradé (pause GC, scheduler bloqué). Un failover automatique sain ne demande aucune action.

    bash
    1# 1. On the standby — force promotion (admin scope required)
    2curl -X POST https://pulse-b.internal/api/admin/cluster/force-promote \
    3  -H "Authorization: Bearer $ADMIN_TOKEN"
    4# Expected: {"status":"promoted","previousRole":"FOLLOWER","newEpoch":5}
    5
    6# 2. Verify the advisory lock moved
    7psql -h pg-cluster.internal -U pulse -c \
    8  "SELECT pid, mode, granted FROM pg_locks WHERE locktype='advisory';"
    9# Expected: one row, granted=t, pid matches pulse-b's connection
    10
    11# 3. Verify the standby's role flipped
    12curl -s https://pulse-b.internal/api/admin/cluster/status | jq -r '.self.role'
    13# Expected: LEADER
    14
    15# 4. Drain the old leader — auto-downgrades on the next heartbeat (5s).
    16#    To accelerate:
    17curl -X POST https://pulse-a.internal/api/admin/cluster/step-down \
    18  -H "Authorization: Bearer $ADMIN_TOKEN"
    19# Expected log on pulse-a: "transition: LEADER → FOLLOWER"

    6. Vérifier la sync du réplica Postgres

    bash
    1# Connect to the STANDBY postgres replica
    2psql -h pg-replica.internal -U pulse -c "
    3  SELECT pg_last_wal_receive_lsn() AS received,
    4         pg_last_wal_replay_lsn()  AS replayed,
    5         pg_wal_lsn_diff(pg_last_wal_receive_lsn(),
    6                         pg_last_wal_replay_lsn()) AS lag_bytes;"

    Attendu : lag_bytes < 16 MB en charge normale. Plus de 5 min de replay lag soutenu est alertable. Pour forcer un rattrapage manuel si un réplica est coincé :

    bash
    1# On the standby Postgres host
    2sudo systemctl stop postgresql
    3sudo -u postgres pg_basebackup -h pg-primary.internal -D /var/lib/postgresql/14/main \
    4  -U replicator -P -R --wal-method=stream
    5sudo systemctl start postgresql

    Reconstruit le réplica depuis le primaire ; 5–30 min pour un cluster de 100 GB. Pulse continue à servir depuis le primaire pendant ce temps.

    7. Redémarrer proprement le primaire

    bash
    1# 1. On the primary (Pulse A): tray → Quit, OR
    2systemctl stop pulse
    3# Expected log: "ClusterCoordinator stopped"
    4
    5# 2. On the standby (Pulse B), watch the auto-promotion
    6tail -f /var/log/pulse/pulse.log | grep -E "Promoted|MemberLeft"
    7# Expected within 5–10s:
    8#   MemberLeft: pulse-a
    9#   Promoted to LEADER: node=pulse-b epoch=N+1
    10
    11# 3. Restart the binary on Pulse A
    12systemctl start pulse
    13# Expected log: "ClusterCoordinator started: ... role=FOLLOWER"
    14# It comes up as FOLLOWER and only takes leadership on the next election.

    8. Défaillances courantes + reprise

    Les deux nœuds se croient leader (split brain)

    Symptôme : pg_locks affiche deux lignes advisory granted pour la même clé, ou des écritures ackées disparaissent en lecture après failover.

    Mitigation en bande : le coordinateur incrémente l'epoch à chaque promotion. Le data-plane rejette les écritures d'epoch inférieur au courant — les écritures d'un leader obsolète sont droppées, pas commitées.

    bash
    1# 1. Identify the stale node (lower epoch = loser)
    2for n in pulse-a pulse-b; do
    3  echo "=== $n ==="
    4  curl -s https://$n.internal/api/admin/cluster/status | jq '.self | {nodeId, epoch, role}'
    5done
    6
    7# 2. Force-kill the lower-epoch node
    8ssh root@<lower-epoch-host> systemctl stop pulse
    9
    10# 3. Restart it cleanly — it'll join as FOLLOWER on the higher epoch
    11ssh root@<lower-epoch-host> systemctl start pulse

    Réplica Postgres en retard > 5 min

    Soit vous acceptez la cohérence éventuelle (pointer le pool de lectures du LB sur targetServerType=primary le temps du rattrapage), soit vous reconstruisez le réplica (§6).

    Les deux nœuds ne joignent plus Postgres

    Les deux se rétrogradent en FOLLOWER (pas de détenteur du verrou = pas de leader). Le cluster est read-only jusqu'à récupération de Postgres. Action : rétablir la connectivité — l'élection repart automatiquement au prochain heartbeat.

    9. Monitoring

    Le bridge expose des métriques Prometheus sur l'endpoint /metrics existant. Scrapez et alertez sur :

    MetricTypeCondition d'alerte
    pulse_cluster_rolegaugeSomme cluster ≠ 1 leader > 30 s
    pulse_cluster_epochcounterTaux > 6/heure (flapping)
    pulse_cluster_postgres_replication_lag_bytesgauge> 16 MB soutenu 60 s
    pulse_cluster_advisory_lock_heldgaugeSomme ≠ 1 > 30 s
    pulse_cluster_heartbeat_failures_totalcounterTaux > 0,2/s pendant 5 min

    Valeurs : pulse_cluster_role = 1 (LEADER), 0 (FOLLOWER), -1 (UNAVAILABLE). advisory_lock_held = 1 sur le détenteur, 0 ailleurs.

    Règles PagerDuty suggérées : gap de leader > 60 s = page on-call ; replication lag > 30 s = canal warn ; epoch flapping = canal warn.

    10. Rollback vers mono-nœud

    bash
    1# 1. Stop the standby
    2ssh root@pulse-b systemctl stop pulse
    3
    4# 2. On the primary: disable HA
    5curl -X POST https://pulse-a.internal/api/admin/cluster/disable \
    6  -H "Authorization: Bearer $ADMIN_TOKEN"
    7# OR set PULSE_HA_MODE=false and restart.
    8
    9# 3. Optional: remove the bridge module from the classpath
    10systemctl restart pulse
    11# Expected log: "ClusterCoordinator: standalone"

    Les données Postgres restent intactes. Pulse reprend en mono-nœud sur la même DB. Le réplica peut être laissé idle ou démantelé — au choix.