Un clavardage trop lourd pouvait faire tomber notre API
Le 28 septembre, environ 17 minutes après une version, nos deux serveurs d'API de production ont manqué de mémoire en même moment, ont redémarré, et pendant environ cinq minutes le répartiteur de charge n'avait aucun serveur sain vers lequel envoyer le trafic. Notre première correction a donné à l'API le double de mémoire. La cause réelle est apparue plus tard dans la journée : ouvrir un clavardage précis demandait à l'API de charger 53 Mo de messages en une seule requête. L'historique des clavardages se charge maintenant par pages limitées par la taille autant que par le nombre, et ce clavardage s'ouvre avec une première page de 5 Mo au lieu de planter.
Ce qui est tombé
Notre API fonctionne comme un conteneur sur chacun de deux serveurs, derrière un répartiteur de charge. Chaque conteneur avait une limite de mémoire de 384 MiB. Au repos, il utilise 130 à 140 MiB, donc la limite semblait laisser beaucoup de marge.
Les deux conteneurs ont atteint cette limite ensemble et ont planté avec le message « JavaScript heap out of memory ». Le tas est la mémoire qu'utilise un processus Node.js pour les objets avec lesquels il travaille. Comme les deux serveurs ont échoué en même temps, il n'y avait plus nulle part où acheminer les requêtes. C'est le pire type de panne pour une configuration à deux serveurs : la redondance n'aide que lorsque les défaillances ne surviennent pas simultanément.
La première correction : plus de marge
Le moment de l'incident pointait vers le déploiement. Après une version, les tours d'agent interrompus par le déploiement sont repris et leurs transcriptions sont de nouveau enregistrées, et cette rafale de travail semblait être la source évidente d'un pic de mémoire.
La première modification a donc fait passer la limite de l'API de 384 MiB à 768 MiB. Nous l'avons appliquée en direct sur les deux serveurs le jour même, et nous l'avons ajoutée à la configuration de déploiement afin que le prochain déploiement ne ramène pas discrètement la valeur à 384. Une correction faite à la main sur un serveur, que le déploiement suivant annule, est une façon courante de voir la même panne revenir.
Plus de mémoire était une marge de sécurité raisonnable. Ce n'était pas une explication.
La cause réelle : une requête, 71 Mo
Plus tard ce jour-là, nous avons reproduit le plantage sur une copie d'un clavardage, avec l'ancienne limite de mémoire de la production. La copie a fait tomber l'API à elle seule, sans aucun déploiement en cause.
Lorsque vous ouvrez un projet, l'application Web demande à l'API l'historique du clavardage. Le point de terminaison renvoyait les 30 messages les plus récents, en entier. Pour la plupart des clavardages, 30 messages représentent peu de chose. Pour celui-ci, les 30 messages les plus récents occupaient 53 Mo dans la base de données et une réponse de 71 Mo. Plusieurs réponses de l'agent faisaient 4 à 10 Mo chacune, surtout à cause des transcriptions des sous-agents : les agents auxiliaires que l'agent principal lance pour travailler en parallèle sur une partie de la tâche, et dont le travail complet est enregistré avec la réponse.
Construire cette réponse exigeait plus de tas que l'API n'en avait. Et chaque ouverture ou rafraîchissement de ce projet renvoyait la requête. Un seul clavardage pouvait faire tomber le serveur qui le recevait, et comme la page pouvait être rechargée, il pouvait atteindre les deux serveurs.
« 30 messages » n'avait jamais été une vraie limite de taille. C'était une limite de nombre qui se trouvait être petite pour les clavardages ordinaires.
La correction : des pages limitées par la taille autant que par le nombre
Une page d'historique compte maintenant 30 messages ou environ 4 Mo de messages, selon ce qui arrive en premier.
Le fonctionnement se fait en deux étapes :
- Demander d'abord les tailles. L'API demande à la base de données les identifiants et les tailles enregistrées des messages les plus récents, sans leur contenu. Cette requête est peu coûteuse, quelle que soit la taille des messages.
- Charger seulement ce qui tient. En remontant du plus récent au plus ancien, elle ajoute des messages jusqu'à ce que le suivant fasse dépasser 4 Mo à la page. Seules ces lignes sont ensuite chargées en entier.
Deux règles assurent la sécurité :
- Le message le plus récent figure toujours sur la page, quelle que soit sa taille. Une page qui ne renverrait rien bloquerait le clavardage, sans moyen de remonter dans l'historique.
- Rien n'est coupé. Aucun message n'est tronqué ni supprimé. Le reste du clavardage se charge par la commande « Défiler vers les messages précédents », qu'utilisaient déjà les messages plus anciens, une page à la fois.
Nous avons trouvé un autre endroit ayant la même forme. Lorsque vous arrêtez un tour, l'API vérifie les dernières réponses pour voir si certaines sont vides et doivent être nettoyées. Elle le faisait en chargeant huit messages entiers. Sur un clavardage avec des réponses de plusieurs mégaoctets, c'est le même problème sous une forme plus petite. Elle pose maintenant à la base de données une question par oui ou par non (ce message a-t-il du contenu ?) sans charger le contenu lui-même.
Comment nous l'avons vérifié
Sur la copie du clavardage, avec l'ancienne limite de 384 MiB :
- La première page fait 5 Mo. Auparavant, la même requête faisait planter l'API.
- Cinq onglets de navigateur ouvrant le clavardage en même temps atteignent un pic de 154 Mo de mémoire.
- Le défilement de tout le clavardage renvoie les 190 messages, chacun une seule fois, dans l'ordre.
La logique de choix des pages a aussi des tests unitaires, dont un construit à partir des tailles réelles des messages les plus récents de ce clavardage. La première page revient avec un message, indique qu'il y en a d'autres, et la page suivante commence au message qui suit.
Ce que nous en avons retenu
- Un nombre n'est pas une limite de taille. Tout point de terminaison qui renvoie « les N plus récents » d'un élément qui peut grandir sans limite a aussi besoin d'un budget en octets. Les données finissent toujours par révéler l'écart.
- Le suspect évident n'est pas toujours la cause. Le déploiement a bien été un facteur déclenchant de travail supplémentaire, et plus de mémoire valait la peine. Mais le plantage s'est reproduit sans déploiement, et sans cette reproduction nous nous serions arrêtés à l'augmentation de la mémoire.
- Deux serveurs tombent ensemble s'ils reçoivent la même requête. La redondance protège contre la mort d'un serveur. Elle ne protège pas contre une requête qui tue tout serveur qu'elle atteint, surtout lorsqu'un utilisateur la relancera naturellement en rafraîchissant la page.
- Corrigez dans la configuration, pas seulement sur la machine. L'augmentation de mémoire a été ajoutée à la configuration de déploiement le jour même, afin que le déploiement suivant la conserve.
C'est notre deuxième récit récent de mémoire lié à la sortie de l'agent. Le premier était une étiquette d'état qui mettait 690 Mo de chaînes en file d'attente. Si vous utilisez des sous-agents sur Mythex, vous pouvez lire comment ils fonctionnent dans la documentation.