Reprise après sinistrePostgreSQLHaute disponibilitéAWSGCPAvancéFiabilité

Failover PostgreSQL multi-région, testé

Jerry RajaonarivoMis à jour le 30 sept. 202611 min de lecture

Cette configuration utilise une réplication en streaming inter-région pour un failover rapide et ajoute un contrôle de restauration nocturne pour détecter les problèmes que la réplication ne protège pas, comme une mauvaise migration, une table corrompue ou un DELETE accidentel sans clause WHERE.

Architecture cible

La région standby fait tourner une couche applicative réduite mais jamais à zéro, sans trafic en temps normal. Un failover consiste alors à basculer le trafic et à promouvoir la base, sans démarrage à froid.

Réplication : synchrone vs. asynchrone, et ce que ça vous coûte

La réplication synchrone vous donne un RPO de zéro (aucune transaction validée n'est jamais perdue), mais chaque écriture attend un aller-retour vers le standby. Entre régions avec 40-80 ms de latence, c'est inenvisageable. La réplication fait donc deux métiers différents selon la distance :

  • Réplication synchrone vers des répliques de la même région pour un failover local rapide, sans perte de données.
  • Réplication en streaming asynchrone vers le standby inter-région pour un failover de niveau sinistre, en acceptant un RPO faible (de l'ordre de la seconde) en échange d'une latence d'écriture acceptable.
ini
# postgresql.conf sur le primaire
wal_level = replica
max_wal_senders = 10
wal_keep_size = 2GB
# Plafonne le WAL qu'un slot déconnecté peut retenir, pour qu'un standby mort ne remplisse pas le disque
max_slot_wal_keep_size = 100GB
 
archive_mode = on
archive_command = 'wal-g wal-push %p'
# Force un changement de segment WAL au moins toutes les 60 s, pour que l'archive (et le RPO
# des sauvegardes) n'ait jamais plus d'une minute environ de retard, même sur une base calme
archive_timeout = 60
 
ssl = on
ssl_cert_file = '/etc/postgresql/tls/server.crt'
ssl_key_file  = '/etc/postgresql/tls/server.key'
 
# Un commit attend N'IMPORTE LAQUELLE des deux répliques LOCALES. Nommer un seul standby ici
# bloque les écritures du primaire dès que ce standby tombe.
# Le standby inter-région n'est pas listé : il reste asynchrone et les écritures n'attendent jamais le WAN.
synchronous_standby_names = 'ANY 1 (local_replica_a, local_replica_b)'

Le compromis existe toujours, mais il est plus petit. Si les deux répliques locales sont hors service, les commits sur le primaire restent bloqués jusqu'au retour de l'une d'elles, ou jusqu'à ce que vous vidiez synchronous_standby_names et rechargiez la configuration, en acceptant alors sciemment l'asynchrone. C'est le prix d'un RPO nul, et c'est pour ça qu'il faut deux candidates synchrones. archive_timeout = 60 a aussi un coût. Chaque bascule forcée archive un segment complet de 16 Mo (compressé par WAL-G), même quand il ne s'est presque rien passé.

Les connexions de réplication doivent passer uniquement en TLS. Dans pg_hba.conf sur le primaire :

text
# TYPE   DATABASE     USER        ADDRESS        METHOD
hostssl  replication  replicator  10.20.0.0/16   scram-sha-256

Construire le standby inter-région

N'écrivez pas la configuration du standby à la main. Clonez-le depuis le primaire avec pg_basebackup. -R écrit standby.signal ainsi que primary_conninfo, -C -S crée un slot de réplication physique sur le primaire pour qu'il conserve le WAL dont ce standby a encore besoin, et -X stream transmet le WAL pendant la copie pour que le clone soit cohérent.

bash
# Sur l'hôte de la région B, en tant qu'utilisateur postgres, avec un répertoire de données VIDE.
# Le mot de passe vient de ~/.pgpass (chmod 600), jamais de la ligne de commande :
#   primary.internal:5432:replication:replicator:<mot de passe>
pg_basebackup \
  -d "host=primary.internal port=5432 user=replicator sslmode=verify-full sslrootcert=/etc/postgresql/tls/ca.crt application_name=region_b_standby" \
  -D /var/lib/postgresql/data \
  -R -X stream -C -S region_b_standby -P
 
# -R peut recopier le mot de passe utilisé dans postgresql.auto.conf.
# Vérifiez, et supprimez-le s'il est présent, pour qu'il ne vive que dans .pgpass :
grep primary_conninfo /var/lib/postgresql/data/postgresql.auto.conf

sslmode=verify-full vérifie la chaîne de certificats et aussi que le certificat correspond bien à primary.internal. Personne ne peut donc rediriger le flux de réplication vers un autre hôte sans que vous le voyiez. En plus de ce qu'écrit -R, le standby dispose d'un repli sur l'archive WAL quand il prend plus de retard que le streaming ne peut en couvrir :

ini
# postgresql.conf sur le standby inter-région
restore_command = 'wal-g wal-fetch %f %p'
recovery_target_timeline = 'latest'
hot_standby = on

Promouvoir le standby

Dans cette configuration, la promotion inter-région reste manuelle et pilotée par un runbook. Des outils comme Patroni (adossé à etcd/Consul pour l'élection du leader) automatisent bien la promotion à l'intérieur d'une région, et la ligne « failover local » du tableau ci-dessous part de ce principe. La mise en place de Patroni sort du cadre de cet article. Entre régions, automatiser le failover doit rester un choix réfléchi. Un faux positif sur une liaison WAN, alors que l'ancien primaire tourne encore, vous laisse avec un split-brain sur les bras.

La promotion elle-même est courte :

bash
# 1. Isolez d'abord l'ancien primaire (arrêtez-le, ou coupez son réseau/security group).
#    Deux primaires accessibles en écriture, c'est pire que quelques minutes d'interruption.
 
# 2. Sur le standby de la région B, vérifiez ce qu'il a reçu et rejoué
psql -Atc "SELECT pg_last_wal_receive_lsn(), pg_last_wal_replay_lsn(), now() - pg_last_xact_replay_timestamp() AS replay_delay;"
 
# 3. Promotion (PostgreSQL 12+ ; attend jusqu'à 60 s la fin de la promotion)
psql -Atc "SELECT pg_promote(wait => true, wait_seconds => 60);"
 
# 4. Vérifiez qu'il accepte maintenant les écritures : doit afficher 'f'
psql -Atc "SELECT pg_is_in_recovery();"

pg_ctl promote -D /var/lib/postgresql/data fait la même chose depuis le shell si vous ne pouvez pas vous connecter. Après la promotion, le nouveau primaire archive dans le même bucket WAL-G sur une nouvelle timeline, et l'ancien primaire doit être reconstruit en standby (pg_rewind ou un nouveau pg_basebackup) avant de revenir dans le cluster.

Validation des sauvegardes : l'étape que presque tout le monde saute

Ce contrôle tourne chaque nuit, entièrement automatisé, sur une instance jetable restaurée depuis la dernière sauvegarde complète archivée + WAL. Deux détails comptent autant que les contrôles eux-mêmes. L'instance de test doit bien rejouer le WAL (un simple pg_ctl start sur une sauvegarde restaurée ne le fait pas), et elle ne doit jamais pousser son propre WAL dans l'archive de production.

bash
#!/usr/bin/env bash
# Tourne en tant qu'utilisateur postgres, avec le même environnement WAL-G (bucket, préfixe) que la production.
# Donnez à cet hôte des identifiants en LECTURE SEULE sur le bucket, comme seconde ligne de défense.
set -Eeuo pipefail
 
RESTORE_TARGET="/var/lib/postgresql/restore-verify"
RESTORE_LOG="/var/log/postgresql/restore-verify.log"
PORT=5433
DB="app"
EXPECTED_MIN_ROWS=1000000
SAMPLE_ROWS=100000
MAX_REPLAY_DELAY_SECONDS=900
# Connexion en lecture seule à une réplique de production, utilisée comme référence
PROD_DSN="host=replica.internal dbname=app user=restore_check sslmode=verify-full"
 
cleanup() {
  pg_ctl -D "$RESTORE_TARGET" stop -m immediate >/dev/null 2>&1 || true
  rm -rf "$RESTORE_TARGET"
}
trap cleanup EXIT   # arrête et efface toujours l'instance de test, même si un contrôle échoue
 
rm -rf "$RESTORE_TARGET"
mkdir -p "$RESTORE_TARGET" && chmod 700 "$RESTORE_TARGET"
 
echo "[1/5] Récupération de la dernière sauvegarde complète via wal-g..."
wal-g backup-fetch "$RESTORE_TARGET" LATEST
 
echo "[2/5] Configuration d'une recovery isolée..."
# Les paquets Debian/Ubuntu gardent la configuration dans /etc, la sauvegarde peut donc ne pas la contenir
[ -f "$RESTORE_TARGET/postgresql.conf" ] || : > "$RESTORE_TARGET/postgresql.conf"
[ -f "$RESTORE_TARGET/pg_hba.conf" ] || echo "local all all peer" > "$RESTORE_TARGET/pg_hba.conf"
# postgresql.auto.conf est lu en dernier : ces valeurs écrasent celles venues de la production
cat >> "$RESTORE_TARGET/postgresql.auto.conf" <<CONF
port = $PORT
listen_addresses = 'localhost'
archive_mode = off
archive_command = ''
synchronous_standby_names = ''
restore_command = 'wal-g wal-fetch %f %p'
recovery_target_action = 'promote'
hot_standby = on
CONF
# Sans ce fichier, PostgreSQL n'entre pas en archive recovery et ne rejoue rien
touch "$RESTORE_TARGET/recovery.signal"
 
echo "[3/5] Démarrage de PostgreSQL et rejeu du WAL jusqu'à la fin de l'archive..."
pg_ctl -D "$RESTORE_TARGET" -l "$RESTORE_LOG" start -w -t 600
for _ in $(seq 1 720); do   # jusqu'à 1 h de rejeu du WAL
  [ "$(psql -p "$PORT" -d postgres -Atc 'SELECT pg_is_in_recovery()')" = "f" ] && break
  sleep 5
done
if [ "$(psql -p "$PORT" -d postgres -Atc 'SELECT pg_is_in_recovery()')" != "f" ]; then
  echo "ÉCHEC : toujours en recovery après 1 h, voir $RESTORE_LOG" >&2
  exit 1
fi
 
echo "[4/5] Exécution des contrôles d'intégrité..."
REPLAY_DELAY=$(psql -p "$PORT" -d "$DB" -Atc \
  "SELECT coalesce(extract(epoch FROM now() - pg_last_xact_replay_timestamp())::int, 999999);")
ROW_COUNT=$(psql -p "$PORT" -d "$DB" -Atc "SELECT count(*) FROM orders;")
MAX_ID=$(psql -p "$PORT" -d "$DB" -Atc "SELECT max(id) FROM orders;")
 
# Échantillon déterministe et borné : les SAMPLE_ROWS derniers ids inférieurs ou égaux au max restauré.
# ORDER BY + LIMIT sont dans la sous-requête, le hash ne couvre donc vraiment que cet échantillon.
# Ne hachez que des colonnes qui ne changent jamais après l'insertion.
SAMPLE_SQL="SELECT md5(string_agg(id::text, ',' ORDER BY id)) FROM (SELECT id FROM orders WHERE id <= $MAX_ID ORDER BY id DESC LIMIT $SAMPLE_ROWS) s;"
RESTORED_SUM=$(psql -p "$PORT" -d "$DB" -Atc "$SAMPLE_SQL")
PROD_SUM=$(psql "$PROD_DSN" -Atc "$SAMPLE_SQL")
 
if [ "$REPLAY_DELAY" -gt "$MAX_REPLAY_DELAY_SECONDS" ]; then
  echo "ÉCHEC : la dernière transaction rejouée date de ${REPLAY_DELAY} s, l'archive WAL semble en retard" >&2
  exit 1
fi
if [ "$ROW_COUNT" -lt "$EXPECTED_MIN_ROWS" ]; then
  echo "ÉCHEC : nombre de lignes restaurées ($ROW_COUNT) sous le seuil attendu ($EXPECTED_MIN_ROWS)" >&2
  exit 1
fi
if [ "$RESTORED_SUM" != "$PROD_SUM" ]; then
  echo "ÉCHEC : checksum d'échantillon différent (restauré $RESTORED_SUM, production $PROD_SUM)" >&2
  exit 1
fi
 
echo "[5/5] Restauration vérifiée : $ROW_COUNT lignes, id max $MAX_ID, checksum d'échantillon identique à la production"

Le checksum exécute la même requête bornée sur la copie restaurée et sur une réplique de production, puis compare les deux. Une ligne manquante apparaît donc comme un écart, au lieu d'un hash que personne ne regarde. Il suppose que les lignes de orders ne sont jamais supprimées physiquement. Si c'est le cas chez vous, comparez plutôt le nombre de lignes de cette plage d'ids, avec une petite tolérance.

Branchez le code de sortie sur votre alerting et traitez une restauration nocturne échouée comme une alerte d'astreinte. De tous les contrôles de ce playbook, c'est celui qui se rentabilise le plus vite. Après un passage au vert, vous savez que la sauvegarde de la nuit se restaure.

Failover au niveau DNS

hcl
resource "aws_route53_health_check" "primary_region" {
  fqdn              = "app-primary.example.com"
  port              = 443
  type              = "HTTPS"
  resource_path     = "/healthz"
  failure_threshold = 3
  request_interval  = 10
}
 
resource "aws_route53_record" "app_failover_primary" {
  zone_id = var.zone_id
  name    = "app.example.com"
  type    = "A"
 
  failover_routing_policy {
    type = "PRIMARY"
  }
 
  set_identifier  = "primary"
  health_check_id = aws_route53_health_check.primary_region.id
  alias {
    name                   = aws_lb.region_a.dns_name
    zone_id                = aws_lb.region_a.zone_id
    evaluate_target_health = true
  }
}

L'enregistrement de failover SECONDARY correspondant du standby prend le relais automatiquement dès que les health checks échouent, donc personne n'a à modifier le DNS à la main pendant un incident. Le DNS ne fait que déplacer le trafic, cela dit. La base de la région B reste une réplique en lecture seule tant que personne n'a exécuté le runbook de promotion ci-dessus, et d'ici là les lectures fonctionnent mais les écritures échouent. Assurez-vous que l'application passe proprement en mode lecture seule plutôt que de renvoyer un mur d'erreurs 500.

Objectifs RTO/RPO visés

| Scénario | RPO | RTO | |---|---|---| | Panne d'une seule AZ (répliques synchrones locales, Patroni dans la région) | 0 (aucune perte de données) | Généralement moins d'une minute (automatisé) | | Panne de région complète (promotion du standby asynchrone) | Quelques secondes d'écritures (le lag de réplication au moment de la panne) | Quelques minutes (failover DNS automatique, promotion manuelle via le runbook) | | Données corrompues / mauvaise migration (restauration depuis sauvegarde) | Jusqu'à ~1 min de WAL archivé (archive_timeout = 60) | 15-30 min (manuel, délibéré) |

Le RTO d'un failover régional dépend du temps qu'il faut à votre astreinte pour être alertée, décider, isoler l'ancien primaire et promouvoir le standby. C'est pour ça que le runbook doit être répété régulièrement.

Remarquez que la ligne « données corrompues » a un RTO plus long, par conception. Une récupération rapide et automatique d'un problème de données auto-infligé ne ferait que réappliquer la même mauvaise migration.

Le résultat

La réplication en streaming et les enregistrements de failover Route53 sont des briques mûres et bien comprises. Ce qui décide de l'issue pendant un véritable incident, c'est si le chemin de failover a réellement été testé, et si « la sauvegarde fonctionne » est un fait vérifié plutôt qu'une supposition.

Si votre plan de reprise n'a jamais eu de véritable test de restauration exécuté contre lui, c'est exactement l'écart qu'une mission de Revue d'architecture est conçue pour combler, avant qu'un incident ne le découvre à votre place.

ALLER PLUS LOIN

Envie de faire tourner ça en production ?

Ce tutoriel couvre les concepts et l'architecture. Si vous voulez l'implémenter dans votre propre infrastructure, ou monter en compétence pour posséder ce sujet durablement, je propose du mentorat individuel construit autour de votre environnement réel, pas une formation générique.

Ce tutoriel

  • Concepts clés et architecture principale
  • Extraits de code illustratifs
  • Le raisonnement derrière chaque décision

Mentorat individuel

  • Des sessions de travail sur votre propre environnement
  • Des réponses directes aux cas particuliers que vous rencontrez
  • Un retour sur votre implémentation réelle
  • Un accompagnement continu pendant que vous la construisez
Me contacterRéponse sous 1 jour ouvré.

Tutoriels similaires