Le premier test automatisé de basculement Redis Sentinel — en une seule annotation
Ingénierie Tests 1 septembre 2026

Le premier test automatisé de basculement Redis Sentinel — en une seule annotation

Un master, deux réplicas, trois Sentinels et un quorum correct — dans un test JUnit, à partir d'une seule annotation. La couche 3 du Macstab Chaos Stack transforme le basculement que votre code de cache n'a jamais vécu en une assertion déterministe dans la CI.

C
Christian Schnapka
Macstab Engineering
6 min de lecture
Aussi sur Medium Lire sur Medium

Vert sur tous les tableaux de bord, chaque écriture refusée

Votre Redis tourne. Il répond à PING. Ses réplicas sont synchronisés, tous les tableaux de bord sont au vert — et il refuse chaque écriture depuis quarante minutes.

Un seul snapshot en arrière-plan a échoué, et cela a suffi. stop-writes-on-bgsave-error vaut yes par défaut : à partir de cet instant, Redis rejette tout ce qui modifie le jeu de données. Les lectures restent parfaites. Le master répond à PING, la seule chose que consulte le contrôle de santé de Sentinel, donc rien ne passe en ODOWN et aucun basculement n'est jamais déclenché.

Et le code que vous avez écrit pour l'autre panne, celle où un nœud meurt vraiment, n'a jamais été exécuté. La répéter honnêtement exige un master, deux réplicas, trois Sentinels, un quorum correct, une réplication convergée et un kill au moment exact. Alors on mocke le cache, et la première fois que votre bibliothèque cliente voit un +switch-master, c'est à 03:14 en production.

macstab-chaos-redis, la troisième couche du Macstab Chaos Stack, supprime cette excuse. En dessous se trouvent six bibliothèques C99 LD_PRELOAD et un agent de bytecode JVM. Cette couche apporte ce qu'aucune des deux ne peut fournir : une topologie conforme à la production dans laquelle injecter des fautes.

Une annotation au lieu de quatre-vingt-dix lignes

Après la dépendance, une seule annotation au niveau de la classe constitue toute la configuration. @RedisStandalone vous donne un vrai Redis 7.4 sur un port aléatoire. @RedisSentinel vous donne un master, deux réplicas et trois Sentinels avec un quorum de 2, sur leur propre réseau Docker privé. Pas de @Testcontainers, pas de champ conteneur statique, pas de @DynamicPropertySource, pas de @AfterAll que vous comptiez écrire et n'avez jamais écrit. Les handles comme SentinelCluster arrivent en paramètres de la méthode de test.

L'équivalent Sentinel écrit à la main fait environ quatre-vingt-dix lignes, porteuses de connaissances que le code n'énonce jamais : une ligne de log précise est la seule preuve qu'un réplica a fini de se synchroniser, Sentinel doit recevoir une IP et non un alias, et 2 000 doit remplacer la valeur par défaut de 30 000, sinon aucune assertion ne peut aboutir. Supprimez l'une d'elles et les tests compilent toujours. Ils se mettent simplement à mentir. Avec l'annotation, ce savoir vit dans le framework, écrit une fois et couvert par des tests. Le nettoyage passe par le store CloseableResource de JUnit, avec le reaper Ryuk de Testcontainers comme second chemin indépendant.

java
// Gradle
testImplementation 'com.macstab.chaos:macstab-chaos-redis:1.0.0'

@RedisSentinel                       // 1 master + 2 replicas + 3 sentinels, quorum 2
class FailoverTest { }
Topologie de @RedisSentinel : trois Sentinels surveillant un master Redis et deux réplicas sur un réseau bridge Docker privé
Un master, deux réplicas et trois Sentinels sur un réseau bridge privé qui naît avec la classe de test et disparaît avec elle.

Six conteneurs, dans le seul ordre qui fonctionne

Sentinel suit un master par son adresse, donc l'ordre de démarrage n'est pas négociable. Quatre phases s'enchaînent, chacune conditionnée par un signal sémantique plutôt que par un sleep : le master sur Ready to accept connections, les réplicas sur MASTER <-> REPLICA sync: Finished with success, les Sentinels sur un SENTINEL master mymaster réussi, puis une fenêtre de stabilisation fixe de deux secondes. Le signal des réplicas compte le plus : un port ouvert ou un PING réussit alors qu'un réplica charge encore le RDB, et votre première vraie commande reçoit -LOADING.

Chaque Sentinel ne connaît que l'IP Docker interne du master et découvre ses pairs et les réplicas via __sentinel__:hello. Redis documente une découverte complète en moins de 10 secondes ; l'attente de deux secondes est donc un plancher pragmatique, pas la preuve que le gossip s'est stabilisé : vérifiez qu'un basculement se termine, pas num-other-sentinels à t=0.

down-after-milliseconds passe de la valeur par défaut de Redis, 30 000, à 2 000, et failover-timeout de 180 000 à 5 000. La valeur par défaut est juste en production, où une pause GC ne doit pas déclencher de basculement, mais elle rend l'assertion impossible dans un budget CI. Le prix est une fenêtre serrée : sur un runner chargé, un test vérifiant qu'aucun basculement n'a eu lieu peut devenir instable ; élargissez-la pour cette classe.

Séquence de démarrage du cluster Sentinel : master, réplicas, Sentinels et phase de stabilisation, chacun avec son signal de disponibilité
Quatre phases, chacune conditionnée par un signal sémantique plutôt que par un sleep — et l'unique Thread.sleep assumé à la fin.

Le quorum n'est pas la majorité d'élection

Le quorum est le nombre de Sentinels qui doivent atteindre indépendamment SDOWN avant que le verdict devienne ODOWN, et ODOWN ne fait qu'autoriser une tentative de basculement. Pour l'exécuter, il faut un leader élu par une majorité de tous les Sentinels, versionné par une configuration epoch unique. Un quorum = 1 sur cinq Sentinels produit donc un ODOWN rapide et rien d'autre si seuls deux d'entre eux se voient. La règle de majorité empêche aussi une minorité isolée de promouvoir un second master. Le module calcule quorum = (sentinels / 2) + 1, alignant ODOWN sur la majorité d'élection.

Un piège, signalé avant que vous ne tombiez dedans : aujourd'hui, quorum(), version() et masterName() sur @RedisSentinel n'atteignent pas la configuration des conteneurs. Les valeurs par défaut fonctionnent correctement, mais la configuration surveille toujours mymaster et les nœuds tournent sous redis:7-alpine. En attendant que ce soit câblé, passez mymaster à votre client.

Linux uniquement, et en toute transparence

Sentinel est un protocole de découverte, pas un proxy. Un client compatible Sentinel demande le master, reçoit l'adresse interne, par exemple 172.18.0.2:6379, et s'y connecte directement. Sur un hôte Linux, ou dans un conteneur partageant le réseau du daemon, cette adresse est routable. Sur macOS ou Windows, Docker Desktop garde le bridge dans une VM, et chaque client compatible Sentinel échoue sur ce second saut — juste après la réussite du handshake.

Le module contient des builders pour les directives announce qui régleraient ce problème, mais le chemin actuel de l'extension ne les appelle pas. @RedisSentinel porte donc @DisabledOnNonLinuxHost, qui vérifie /.dockerenv avant os.name, si bien qu'un dev container sur Mac s'exécute toujours. Sur un portable macOS, la suite Sentinel se termine en deux secondes avec quatre tests SKIPPED et la raison affichée. Un build rouge que personne ne peut corriger localement apprend à une équipe à ignorer les builds rouges ; un skip motivé garde la suite digne de confiance.

Comparaison d'une résolution client Sentinel sur un hôte Linux et sur Docker Desktop, où la connexion directe à l'adresse interne échoue
Le handshake Sentinel réussit sur les deux plateformes. Seule la connexion directe à l'adresse renvoyée par Sentinel dépend du fait que le bridge des conteneurs soit une véritable interface de l'hôte.

Le basculement comme assertion

triggerFailover() trouve le conteneur qui annonce role:master à cet instant précis — en interrogeant Redis via INFO replication, pas Docker — et le tue avec SIGKILL. SIGTERM laisserait Redis fermer proprement ses sockets et enverrait les Sentinels sur un chemin de détection différent, généralement plus rapide, que la mort réelle d'un nœud. Ensuite, il interroge toutes les 500 ms en vidant le cache des rôles à chaque passage, et renvoie la Duration entre le kill et la première observation d'un nouveau master, ou lève une FailoverException avec le timeout, le temps écoulé et le nombre de tentatives.

L'horloge n'est que la moitié de l'histoire. Redis réplique de manière asynchrone, donc une promotion peut écarter la fin du flux d'écritures. Écrivez une séquence monotone, déclenchez le basculement, relisez-la depuis le nouveau master et vérifiez la perte que vous pouvez tolérer. Si la réponse est aucune, WAIT, min-replicas-to-write et min-replicas-max-lag se testent exactement de la même façon.

java
@RedisSentinel(replicas = 2, sentinels = 3)
class FailoverTest {

  @Test
  void promotesWithinSlo(SentinelCluster cluster) {
    Duration elapsed = cluster.getControl().triggerFailover(Duration.ofSeconds(30));

    assertThat(elapsed).isLessThan(Duration.ofSeconds(15));
    assertThat(cluster.getControl().getMaster().isRunning()).isTrue();
  }
}
Diagramme de séquence de triggerFailover() : SIGKILL au master, SDOWN, ODOWN, élection du leader, promotion et interrogation jusqu'à l'observation d'un nouveau master
SIGKILL → fenêtre de détection → quorum → vote majoritaire → promotion → la première interrogation qui observe un nouveau master.

De vraies fautes sur une vraie topologie

Avec une vraie topologie en dessous, les couches inférieures deviennent des instruments. enableNetworkChaos = true accorde NET_ADMIN, jamais --privileged, pour la latence, la gigue, la perte de paquets tc/netem et les partitions iptables. enableConnectionChaos = true charge libchaos-net avant le démarrage du conteneur, pour l'injection d'errno sur connect, send, recv et d'autres appels socket. Les deux se combinent : une boucle de retry encaisse 5 % de perte de paquets et tombe pourtant sur un seul ECONNRESET renvoyé par send().

Un décalage d'horloge de deux secondes ou plus fait entrer un Sentinel en mode TILT, où il continue de surveiller mais cesse d'agir, visible via sentinel_tilt dans INFO. Et la panne du début devient reproductible : une règle libchaos-io renvoyant EIO à l'écriture fait échouer le snapshot, et l'assertion tient en une ligne — votre chemin d'écriture doit lever une erreur précise et exploitable au lieu de perdre des écritures. Au-dessus, macstab-chaos-testpacks-l3-redis regroupe des fautes composées en incidents nommés, au niveau de la méthode :

  • @IncidentChaosRedisNetworkFlap : ECONNRESET sur connect et recv, provoquant une tempête d'élections plutôt qu'un basculement propre
  • @IncidentChaosRedisFailoverStorm : ECONNREFUSED sur connect, EAI_AGAIN sur les résolutions DNS et un décalage de CLOCK_REALTIME
  • @IncidentChaosRedisClockDrift : décalage d'horloge entre nœuds, courses à l'expiration des TTL et Sentinel calculant mal ses propres timeouts
  • @IncidentChaosRedisOomEviction, @IncidentChaosRedisCacheAvalanche et @IncidentChaosRedisSlowlog : éviction sous pression mémoire, tempêtes d'expirations synchronisées et latence de niveau SLOWLOG

Key Takeaways

Tuer un pod répond à une seule question : le système meurt-il ? Un vrai cluster Sentinel dans un test JUnit répond à celle qui change réellement votre code : ce que fait votre bibliothèque cliente pendant une promotion, quelle connexion elle tient encore, sur quel nœud elle lit, combien d'écritures ont disparu et combien de temps il a fallu pour s'en apercevoir.

Les limites sont annoncées d'emblée : le mode Redis Cluster n'est pas couvert, Sentinel ne tourne que sur un hôte Linux ou dans un conteneur, trois attributs de @RedisSentinel ne sont pas encore câblés, et le budget mémoire est un garde-fou nominal, pas une mesure. Dans ces limites, un basculement devient quelque chose qui peut faire passer une pull request au rouge. Le module s'appelle macstab-chaos-redis, Apache 2.0, sur github.com/macstab/chaos-testing.

#redis #sentinel #junit5 #testcontainers #chaos-engineering
C

Christian Schnapka

Macstab Engineering

Christian Schnapka conçoit les outils d'ingénierie open source de Macstab. Il publie les versions longues de ces investigations sur Medium — chaque mesure, chaque réserve et chaque échec compris.