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 :
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 pulseOu, sans redémarrage, bascule à chaud :
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=14. Lire l'état du cluster
curl -s https://pulse-a.internal/api/admin/cluster/status | jq1{
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.
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
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é :
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 postgresqlReconstruit 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
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.
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 pulseRé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 :
| Metric | Type | Condition d'alerte |
|---|---|---|
| pulse_cluster_role | gauge | Somme cluster ≠ 1 leader > 30 s |
| pulse_cluster_epoch | counter | Taux > 6/heure (flapping) |
| pulse_cluster_postgres_replication_lag_bytes | gauge | > 16 MB soutenu 60 s |
| pulse_cluster_advisory_lock_held | gauge | Somme ≠ 1 > 30 s |
| pulse_cluster_heartbeat_failures_total | counter | Taux > 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
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.