Des alertes qui ne se déclenchent qu'une fois, et non une fois par serveur
L'une de nos alertes opérationnelles, « crédits faibles » pour l'application publiée d'un créateur, est censée nous parvenir au plus une fois par jour. Un après-midi, elle est arrivée cinq fois. La cause : chaque copie de notre API retenait en mémoire les alertes qu'elle avait déjà envoyées, donc deux serveurs les envoyaient en double, et chaque déploiement effaçait cette mémoire et les laissait les renvoyer. Le même schéma faisait aussi exécuter deux fois un travail de fond. Les deux se coordonnent désormais via un stockage partagé, et les deux échouent du côté sûr si ce stockage est indisponible.
Le symptôme
Mythex envoie des alertes à l'équipe lorsqu'un point demande une vérification : un service est en panne, une tâche est bloquée, ou le solde de crédits d'un créateur baisse alors que son application est publiée. Chaque alerte a une fenêtre de déduplication, c'est-à-dire une période pendant laquelle la même alerte n'est envoyée qu'une fois. Pour l'alerte de crédits faibles, cette fenêtre est de 24 heures.
Cinq copies de la même alerte dans un après-midi signifiaient que la fenêtre ne tenait pas. Pire, elles arrivaient par paires, à environ 11 minutes d'intervalle.
Pourquoi cela arrivait
Le paquet d'alertes de notre code conservait sa déduplication en mémoire : une table « clé d'alerte → date du dernier envoi », à l'intérieur du processus en cours. Cela fonctionne lorsqu'il n'existe qu'un seul processus qui ne redémarre jamais. La production n'a ni l'un ni l'autre :
- Deux réplicas de l'API. La production fait tourner deux copies de l'API. Chacune avait sa propre table, donc chacune envoyait l'alerte une fois. C'est ce qui produisait les paires.
- Les déploiements. Chaque déploiement remplace les deux réplicas par des processus neufs aux tables vides. Après un déploiement, l'alerte repartait, comme si elle n'avait jamais été envoyée.
L'intervalle d'environ 11 minutes entre les deux alertes de chaque paire venait de la tâche qui déclenche cette alerte. Elle tourne sur les deux réplicas et prend un verrou, si bien que les réplicas l'obtiennent à tour de rôle, et chacun déclenchait l'alerte à son tour.
Il y avait un second bug, plus discret, dans le même code. Pour éviter que la table ne grossisse indéfiniment, elle se nettoyait elle-même dès qu'elle atteignait 500 clés, en supprimant toutes les entrées de plus de 15 minutes, quelle que soit leur fenêtre. Sous charge, une déduplication de 24 heures pouvait ne durer qu'un quart d'heure.
La correction
Un enregistrement partagé par alerte
Avant d'envoyer une alerte, l'API réserve désormais la clé de déduplication de cette alerte dans Redis, un stockage en mémoire partagé auquel les deux réplicas se connectent, et qui ne redémarre pas lorsque l'API redémarre. La réservation est une seule opération « n'écrire cette clé que si elle n'existe pas, et l'expirer après la fenêtre ». Le premier réplica qui réserve la clé envoie l'alerte ; l'autre trouve la clé prise et reste silencieux. Un nouveau processus après un déploiement retrouve la clé encore présente.
La clé et la fenêtre viennent de la même règle que celle du paquet d'alertes, désormais partagée entre les deux couches, si bien qu'elles ne peuvent pas diverger sur ce qui compte comme « la même alerte ».
Échouer ouvert, et non fermé
La décision la plus importante a été ce qu'il faut faire lorsque Redis est injoignable. Nous avons choisi d'envoyer l'alerte quand même. Si Redis est en panne, ou ne répond pas dans un délai de 1,5 seconde, l'alerte part. Une alerte en double est du bruit. Une alerte perdue, c'est quelqu'un qui n'est pas prévenu.
Les alertes les plus graves, P0, ne sont jamais retenues par la déduplication.
Chaque entrée garde sa propre fenêtre
Dans la déduplication en mémoire du paquet d'alertes, chaque entrée enregistre désormais la date de fin de sa fenêtre, et le nettoyage ne supprime que les entrées dont la fenêtre est terminée. Une clé de 24 heures reste 24 heures, quel que soit le nombre d'autres alertes reçues.
Le même bug, ailleurs
Le même schéma avait aussi doublé un travail de fond : une tâche de réconciliation qui maintient notre registre du statut de chaque espace de travail en phase avec celui de notre fournisseur de bacs à sable. Chaque passage liste chaque bac à sable du compte (des milliers en production, environ cinq secondes) ainsi que chaque projet en cours d'exécution ou en pause. Les deux réplicas l'exécutaient toutes les deux minutes.
Ses écritures sont idempotentes, c'est-à-dire que les exécuter deux fois donne le même résultat qu'une seule fois, donc rien ne cassait. Simplement, c'était deux fois le travail. Désormais, chaque fenêtre de deux minutes est réservée dans Redis, de la même façon que nos compteurs d'usage réservaient déjà les leurs, et seul le réplica qui réserve la fenêtre exécute le passage. Si Redis est injoignable, le passage s'exécute quand même : faire le travail deux fois est sans conséquence, ne pas le faire l'est.
Comment nous l'avons testé
Les tests reproduisent la configuration réelle. Ils chargent le module d'alertes deux fois, comme deux « réplicas » distincts, pointent les deux vers un même stockage partagé, déclenchent la même alerte depuis chacun, puis chargent une troisième copie neuve pour simuler « après un déploiement » et déclenchent de nouveau l'alerte. Exactement une alerte est envoyée. D'autres tests vérifient qu'une alerte d'un autre créateur part toujours, qu'une P0 est envoyée depuis les deux réplicas, et que l'alerte est toujours envoyée lorsque le stockage est en panne ou dépasse le délai. La correction du nettoyage a son propre test : une clé de 24 heures survit à 600 autres alertes arrivant 20 minutes plus tard.
Pour la tâche de réconciliation, deux réplicas qui se déclenchent en même temps, plus un troisième déclenchement dans la même fenêtre, produisent une seule liste.
Ce que nous en retenons
- L'état en mémoire est propre à chaque processus. Tout ce qui doit être vrai entre serveurs, « envoyé une fois », « exécuté une fois », doit vivre quelque part qu'ils partagent.
- Les déploiements sont des redémarrages. Un état qui ne survit que jusqu'au prochain redémarrage ne survit pas à une journée de travail normale.
- Décidez dans quel sens échouer. Pour les alertes, un stockage partagé en panne doit signifier davantage d'alertes, et non moins.
- Cherchez le schéma, pas seulement l'occurrence. L'alerte était le cas bruyant ; la tâche de réconciliation doublée était le même bug, qui coûtait discrètement du travail.
Rien de tout cela n'est visible depuis l'intérieur de Mythex. Pour en savoir plus sur la façon dont nous maintenons la plateforme stable, consultez comment nous sommes passés à zéro erreur 502 pendant les déploiements et le fichier de swap manquant. Si l'idée de faire tourner plusieurs copies d'un service vous est nouvelle, notre guide sur ce qu'est un serveur présente les bases.