Το πρώτο αυτοματοποιημένο τεστ failover του Redis Sentinel — με ένα μόνο annotation
Μηχανική Δοκιμές September 1, 2026

Το πρώτο αυτοματοποιημένο τεστ failover του Redis Sentinel — με ένα μόνο annotation

Ένας master, δύο replicas, τρία Sentinels και σωστό quorum — μέσα σε ένα τεστ JUnit, από ένα μόνο annotation. Το Layer 3 του Macstab Chaos Stack μετατρέπει το failover που ο κώδικας cache σας δεν έχει επιβιώσει ποτέ σε ντετερμινιστικό assertion στο CI.

C
Christian Schnapka
Macstab Engineering
6 λεπτά ανάγνωσης
Επίσης στο Medium Διαβάστε στο Medium

Πηγαίος κώδικας στο GitHub

Πράσινο σε κάθε dashboard, κάθε εγγραφή απορρίπτεται

Το Redis σας λειτουργεί. Απαντά στο PING. Τα replicas είναι συγχρονισμένα, κάθε dashboard είναι πράσινο — και εδώ και σαράντα λεπτά απορρίπτει κάθε εγγραφή.

Αρκούσε ένα αποτυχημένο snapshot στο παρασκήνιο. Το stop-writes-on-bgsave-error είναι εξ ορισμού yes, οπότε από εκείνη τη στιγμή το Redis απορρίπτει οτιδήποτε τροποποιεί τα δεδομένα. Οι αναγνώσεις εξακολουθούν να φαίνονται τέλειες. Ο master απαντά στο PING, το μόνο που ελέγχει το health check του Sentinel, έτσι τίποτα δεν κλιμακώνεται σε ODOWN και κανένα failover δεν ξεκινά ποτέ.

Και ο κώδικας που γράψατε για την άλλη αστοχία, εκείνη όπου ένας κόμβος πεθαίνει πραγματικά, δεν έχει εκτελεστεί ποτέ. Η τίμια πρόβα της απαιτεί έναν master, δύο replicas, τρία Sentinels, σωστό quorum, συγκλίνουσα αναπαραγωγή και ένα kill την ακριβώς σωστή στιγμή. Έτσι το cache γίνεται mock, και η πρώτη φορά που η βιβλιοθήκη client βλέπει ένα +switch-master είναι στις 03:14 στην παραγωγή.

Το macstab-chaos-redis, το τρίτο layer του Macstab Chaos Stack, αφαιρεί αυτή τη δικαιολογία. Από κάτω βρίσκονται έξι βιβλιοθήκες C99 LD_PRELOAD και ένας JVM bytecode agent. Αυτό το layer προσφέρει ό,τι κανένα από τα δύο δεν μπορεί: μια τοπολογία με μορφή παραγωγής στην οποία αξίζει να εισαχθούν σφάλματα.

Ένα annotation αντί για ενενήντα γραμμές

Μετά το dependency, ένα μόνο annotation σε επίπεδο κλάσης είναι όλο το setup. Το @RedisStandalone σάς δίνει ένα πραγματικό Redis 7.4 σε τυχαία θύρα. Το @RedisSentinel σάς δίνει έναν master, δύο replicas και τρία Sentinels με quorum 2, στο δικό τους ιδιωτικό δίκτυο Docker. Χωρίς @Testcontainers, χωρίς στατικό πεδίο container, χωρίς @DynamicPropertySource, χωρίς το @AfterAll που σκοπεύατε να γράψετε και δεν γράψατε ποτέ. Handles όπως το SentinelCluster φτάνουν ως παράμετροι της μεθόδου test.

Το χειρόγραφο αντίστοιχο για Sentinel είναι περίπου ενενήντα γραμμές και κουβαλά γνώση που ο κώδικας δεν δηλώνει πουθενά: μία συγκεκριμένη γραμμή log είναι η μόνη απόδειξη ότι ένα replica ολοκλήρωσε τον συγχρονισμό, το Sentinel πρέπει να λάβει IP και όχι alias, και το 2 000 πρέπει να αντικαταστήσει την προεπιλογή 30 000, αλλιώς κανένα assertion δεν μπορεί ποτέ να ολοκληρωθεί. Διαγράψτε οποιοδήποτε από αυτά και τα tests εξακολουθούν να κάνουν compile. Απλώς αρχίζουν να λένε ψέματα. Με το annotation, αυτή η γνώση ζει μέσα στο framework, γραμμένη μία φορά και καλυμμένη από tests. Ο καθαρισμός γίνεται μέσω του CloseableResource store του JUnit, με τον Ryuk reaper του Testcontainers ως ανεξάρτητη δεύτερη διαδρομή.

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

@RedisSentinel                       // 1 master + 2 replicas + 3 sentinels, quorum 2
class FailoverTest { }
Τοπολογία του @RedisSentinel: τρία Sentinels παρακολουθούν έναν Redis master με δύο replicas σε ιδιωτικό δίκτυο Docker bridge
Ένας master, δύο replicas και τρία Sentinels σε ένα ιδιωτικό δίκτυο bridge που γεννιέται με την κλάση test και πεθαίνει μαζί της.

Έξι containers, με τη μόνη σειρά που λειτουργεί

Το Sentinel παρακολουθεί έναν master μέσω της διεύθυνσής του, οπότε η σειρά εκκίνησης δεν είναι διαπραγματεύσιμη. Τέσσερις φάσεις εκτελούνται διαδοχικά, καθεμία εξαρτημένη από ένα σημασιολογικό σήμα αντί για sleep: ο master από το Ready to accept connections, τα replicas από το MASTER <-> REPLICA sync: Finished with success, τα Sentinels από ένα επιτυχές SENTINEL master mymaster και, τέλος, ένα σταθερό παράθυρο σταθεροποίησης δύο δευτερολέπτων. Το σήμα των replicas είναι το πιο σημαντικό: μια ανοιχτή θύρα ή ένα PING πετυχαίνουν ενώ το replica φορτώνει ακόμη το RDB, και η πρώτη πραγματική εντολή σας λαμβάνει -LOADING.

Κάθε Sentinel γνωρίζει μόνο την εσωτερική IP Docker του master και ανακαλύπτει peers και replicas μέσω του __sentinel__:hello. Το Redis τεκμηριώνει πλήρη ανακάλυψη εντός 10 δευτερολέπτων, οπότε η αναμονή δύο δευτερολέπτων είναι ένα πραγματιστικό κατώτατο όριο, όχι απόδειξη ότι το gossip έχει σταθεροποιηθεί: ελέγξτε ότι ένα failover ολοκληρώνεται, όχι το num-other-sentinels στο t=0.

Το down-after-milliseconds πέφτει από την προεπιλογή του Redis, 30 000, στα 2 000, και το failover-timeout από 180 000 στα 5 000. Η προεπιλογή είναι σωστή στην παραγωγή, όπου μια παύση GC δεν πρέπει να προκαλεί failover, αλλά καθιστά το assertion αδύνατο μέσα σε προϋπολογισμό CI. Το τίμημα είναι ένα στενό παράθυρο: σε φορτωμένο runner, ένα test που ελέγχει ότι δεν έγινε failover μπορεί να γίνει ασταθές· διευρύνετέ το για εκείνη την κλάση.

Ακολουθία εκκίνησης του Sentinel cluster: master, replicas, Sentinels και φάση σταθεροποίησης, καθεμία με το σήμα ετοιμότητάς της
Τέσσερις φάσεις, καθεμία εξαρτημένη από σημασιολογικό σήμα αντί για sleep — και το ένα τίμιο Thread.sleep στο τέλος.

Το quorum δεν είναι η εκλογική πλειοψηφία

Το quorum είναι ο αριθμός των Sentinels που πρέπει να φτάσουν ανεξάρτητα σε SDOWN πριν η ετυμηγορία γίνει ODOWN, και το ODOWN απλώς εξουσιοδοτεί μια απόπειρα failover. Η εκτέλεσή του απαιτεί έναν leader εκλεγμένο από την πλειοψηφία όλων των Sentinels, με έκδοση μέσω ενός μοναδικού configuration epoch. Έτσι, quorum = 1 σε πέντε Sentinels παράγει γρήγορο ODOWN και τίποτα άλλο, αν μόνο δύο από αυτά βλέπουν το ένα το άλλο. Ο κανόνας πλειοψηφίας εμποδίζει επίσης μια απομονωμένη μειοψηφία να προαγάγει δεύτερο master. Το module υπολογίζει quorum = (sentinels / 2) + 1, ευθυγραμμίζοντας το ODOWN με την εκλογική πλειοψηφία.

Μια αιχμηρή γωνία, δηλωμένη πριν σκοντάψετε πάνω της: σήμερα τα quorum(), version() και masterName() του @RedisSentinel δεν φτάνουν στη διαμόρφωση των containers. Οι προεπιλογές λειτουργούν σωστά, αλλά η διαμόρφωση παρακολουθεί πάντα το mymaster και οι κόμβοι τρέχουν redis:7-alpine. Μέχρι να συνδεθεί αυτό, περάστε το mymaster στον client σας.

Μόνο Linux, και με ειλικρίνεια

Το Sentinel είναι πρωτόκολλο ανακάλυψης, όχι proxy. Ένας Sentinel-aware client ρωτά για τον master, λαμβάνει την εσωτερική διεύθυνση, π.χ. 172.18.0.2:6379, και συνδέεται απευθείας σε αυτήν. Σε host Linux, ή μέσα σε container που μοιράζεται το δίκτυο του daemon, η διεύθυνση αυτή δρομολογείται. Σε macOS ή Windows, το Docker Desktop κρατά το bridge μέσα σε VM, και κάθε Sentinel-aware client αποτυγχάνει σε αυτό το δεύτερο βήμα — αμέσως αφού το handshake έχει ήδη πετύχει.

Το module περιέχει builders για τις οδηγίες announce που θα το έλυναν αυτό, αλλά η τρέχουσα διαδρομή του extension δεν τους καλεί. Γι' αυτό το @RedisSentinel φέρει το @DisabledOnNonLinuxHost, που ελέγχει πρώτα το /.dockerenv και μετά το os.name, ώστε ένα dev container σε Mac να εξακολουθεί να τρέχει. Σε laptop macOS, η σουίτα Sentinel τελειώνει σε δύο δευτερόλεπτα με τέσσερα SKIPPED tests και τυπωμένο τον λόγο. Ένα κόκκινο build που κανείς δεν μπορεί να διορθώσει τοπικά μαθαίνει μια ομάδα να αγνοεί τα κόκκινα builds· ένα skip με αιτιολόγηση κρατά τη σουίτα αξιόπιστη.

Σύγκριση αναζήτησης Sentinel client σε host Linux και σε Docker Desktop, όπου η απευθείας σύνδεση στην εσωτερική διεύθυνση αποτυγχάνει
Το handshake με το Sentinel πετυχαίνει και στις δύο πλατφόρμες. Μόνο η απευθείας σύνδεση στη διεύθυνση που επέστρεψε το Sentinel εξαρτάται από το αν το bridge των containers είναι πραγματική διεπαφή του host.

Το failover ως assertion

Το triggerFailover() εντοπίζει το container που αναφέρει role:master αυτή ακριβώς τη στιγμή — ρωτώντας το Redis μέσω INFO replication, όχι το Docker — και το τερματίζει με SIGKILL. Το SIGTERM θα άφηνε το Redis να κλείσει ομαλά τα sockets του και θα έστελνε τα Sentinels σε διαφορετική, συνήθως ταχύτερη διαδρομή ανίχνευσης από τον πραγματικό θάνατο ενός κόμβου. Έπειτα κάνει polling κάθε 500 ms, καθαρίζοντας την cache ρόλων σε κάθε πέρασμα, και επιστρέφει τη Duration από το kill έως την πρώτη παρατήρηση νέου master, ή πετά FailoverException με το timeout, τον χρόνο που πέρασε και τον αριθμό προσπαθειών.

Το χρονόμετρο είναι μόνο η μισή ιστορία. Το Redis αναπαράγει ασύγχρονα, οπότε μια προαγωγή μπορεί να απορρίψει την ουρά της ροής εγγραφών. Γράψτε μια μονότονη ακολουθία, προκαλέστε το failover, διαβάστε την πίσω από τον νέο master και ελέγξτε την απώλεια που μπορείτε να ανεχθείτε. Αν η απάντηση είναι καμία, τα WAIT, min-replicas-to-write και min-replicas-max-lag ελέγχονται με ακριβώς τον ίδιο τρόπο.

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();
  }
}
Διάγραμμα ακολουθίας του triggerFailover(): SIGKILL στον master, SDOWN, ODOWN, εκλογή leader, προαγωγή και polling μέχρι να παρατηρηθεί νέος master
SIGKILL → παράθυρο ανίχνευσης → quorum → ψηφοφορία πλειοψηφίας → προαγωγή → το πρώτο polling που βλέπει νέο master.

Πραγματικά σφάλματα σε πραγματική τοπολογία

Με μια πραγματική τοπολογία από κάτω, τα χαμηλότερα layers γίνονται όργανα. Το enableNetworkChaos = true παραχωρεί NET_ADMIN, ποτέ --privileged, για latency, jitter και απώλεια πακέτων μέσω tc/netem και partitions μέσω iptables. Το enableConnectionChaos = true φορτώνει τη libchaos-net πριν ξεκινήσει το container, για έγχυση errno σε connect, send, recv και άλλες κλήσεις socket. Τα δύο συνδυάζονται: ένας βρόχος retry αντέχει 5 % απώλεια πακέτων και παρ' όλα αυτά καταρρέει σε ένα μόνο ECONNRESET από το send().

Μια απόκλιση ρολογιού δύο δευτερολέπτων ή περισσότερο φέρνει ένα Sentinel σε κατάσταση TILT, όπου συνεχίζει να παρακολουθεί αλλά σταματά να ενεργεί, ορατό ως sentinel_tilt στο INFO. Και η αστοχία της αρχής γίνεται αναπαραγώγιμη: ένας κανόνας libchaos-io που επιστρέφει EIO στην εγγραφή κάνει το snapshot να αποτύχει, και το assertion είναι μία γραμμή — η διαδρομή εγγραφής σας πρέπει να εγείρει συγκεκριμένο, αξιοποιήσιμο σφάλμα αντί να χάνει εγγραφές. Από πάνω, το macstab-chaos-testpacks-l3-redis ομαδοποιεί σύνθετα σφάλματα σε επώνυμα incidents σε επίπεδο μεθόδου:

  • @IncidentChaosRedisNetworkFlap: ECONNRESET σε connect και recv, που προκαλεί καταιγίδα εκλογών αντί για καθαρό failover
  • @IncidentChaosRedisFailoverStorm: ECONNREFUSED σε connect, EAI_AGAIN στις αναζητήσεις DNS και απόκλιση CLOCK_REALTIME
  • @IncidentChaosRedisClockDrift: απόκλιση ρολογιών μεταξύ κόμβων, races στη λήξη TTL και Sentinel που υπολογίζει λάθος τα δικά του timeouts
  • @IncidentChaosRedisOomEviction, @IncidentChaosRedisCacheAvalanche και @IncidentChaosRedisSlowlog: eviction υπό πίεση μνήμης, συγχρονισμένες καταιγίδες λήξεων και latency σε επίπεδο SLOWLOG

Key Takeaways

Το kill ενός pod απαντά σε ακριβώς μία ερώτηση: πεθαίνει το σύστημα; Ένα πραγματικό Sentinel cluster μέσα σε ένα test JUnit απαντά σε εκείνη που πραγματικά αλλάζει τον κώδικά σας: τι κάνει η βιβλιοθήκη client κατά τη διάρκεια μιας προαγωγής, ποια σύνδεση κρατά ακόμη, από ποιον κόμβο διαβάζει, πόσες εγγραφές χάθηκαν και πόσος χρόνος χρειάστηκε για να γίνει αντιληπτό.

Τα όρια δηλώνονται από την αρχή: η λειτουργία Redis Cluster δεν καλύπτεται, το Sentinel τρέχει μόνο σε host Linux ή μέσα σε container, τρία attributes του @RedisSentinel δεν έχουν ακόμη συνδεθεί και ο προϋπολογισμός μνήμης είναι ονομαστικό προστατευτικό όριο, όχι μέτρηση. Μέσα σε αυτά τα όρια, ένα failover γίνεται κάτι για το οποίο ένα pull request μπορεί να γίνει κόκκινο. Το module είναι το macstab-chaos-redis, Apache 2.0, στο github.com/macstab/chaos-testing.

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

Christian Schnapka

Macstab Engineering

Ο Christian Schnapka αναπτύσσει τα ανοιχτά εργαλεία μηχανικής πίσω από τη Macstab. Δημοσιεύει τις εκτενείς εκδοχές αυτών των ερευνών στο Medium — με κάθε μέτρηση, κάθε επιφύλαξη και κάθε απώλεια.