Com un xat pesat podia tombar la nostra API
El 28 de setembre, uns 17 minuts després d'una publicació, els dos servidors de l'API de producció es van quedar sense memòria al mateix moment, es van reiniciar, i durant uns cinc minuts l'equilibrador de càrrega no va tenir cap servidor sa al qual enviar trànsit. El nostre primer arranjament va donar a l'API el doble de memòria. La causa real va aparèixer més tard aquell mateix dia: obrir un xat concret feia que l'API carregués 53 MB de missatges en una sola petició. Ara l'historial del xat es carrega en pàgines limitades per mida a més de per quantitat, i aquell xat s'obre amb una primera pàgina de 5 MB en lloc de caure.
Què va caure
La nostra API funciona com un contenidor a cadascun dels dos servidors, darrere d'un equilibrador de càrrega. Cada contenidor tenia un límit de memòria de 384 MiB. En repòs, en fa servir 130–140 MiB, així que el límit semblava amb molt de marge.
Ambdós contenidors van arribar al límit alhora i van caure amb «JavaScript heap out of memory». L'heap és la memòria que usa un procés de Node.js per als objectes amb què treballa. Com que tots dos servidors van fallar alhora, no quedava on enviar les peticions. És el pitjor tipus de caiguda per a una configuració amb dos servidors: la redundància només ajuda quan les fallades no passen al mateix temps.
El primer arranjament: més espai
El moment apuntava al desplegament. Després d'una publicació, els torns de l'agent que el desplegament havia interromput es reprenen i tornen a desar les seves transcripcions, i aquella ràfega de feina semblava la font òbvia d'un pic de memòria.
Així que el primer canvi va pujar el límit de l'API de 384 MiB a 768 MiB. El vam aplicar en directe a tots dos servidors el mateix dia, i ho vam confirmar a la configuració de desplegament perquè el següent desplegament no tornés 384 silenciosament. Un arranjament fet a mà en un servidor que el següent desplegament desfà és una manera habitual de fer tornar la mateixa caiguda.
Més memòria era un marge de seguretat raonable. No era una explicació.
La causa real: una petició, 71 MB
Més tard aquell dia vam reproduir la caiguda en una còpia d'un xat, amb el límit de memòria antic de producció. La còpia va tombar l'API per si sola, sense cap desplegament de per mig.
Quan obres un projecte, la web demana a l'API l'historial del xat. El punt final retornava els 30 missatges més nous, sencers. Per a la majoria de xats, 30 missatges és poc. Per a aquest, els 30 missatges més nous eren 53 MB a la base de dades i una resposta de 71 MB. Diverses de les respostes de l'agent feien entre 4 i 10 MB cadascuna, sobretot les transcripcions dels subagents: els agents ajudants que l'agent principal inicia per treballar en paral·lel en una part d'una tasca, i el treball complet dels quals es desa amb la resposta.
Construir aquella resposta requeria més heap del que tenia l'API. I cada vegada que s'obria o es refrescava el projecte, es tornava a enviar la petició. Un sol xat podia tombar el servidor que li tocava, i com que la pàgina es podia recarregar, podia arribar als dos.
«30 missatges» mai no havia estat un límit real de mida. Era un límit de quantitat que per casualitat era petit per als xats normals.
La solució: pàgines limitades per mida a més de per quantitat
Una pàgina d'historial ara té 30 missatges o uns 4 MB d'ells, el que arribi primer.
Funciona en dos passos:
- Demana primer les mides. L'API demana a la base de dades els identificadors i les mides desades dels missatges més nous, sense els seus continguts. Aquesta consulta és barata sigui quina sigui la mida dels missatges.
- Carrega només el que cap. Anant del més nou al més antic, afegeix missatges fins que el següent faria que la pàgina superés els 4 MB. Només llavors es carreguen completament aquestes files.
Dues regles fan que sigui segur:
- El missatge més nou sempre entra a la pàgina, sigui quina sigui la seva mida. Una pàgina que no retornés res deixaria el xat encallat, sense manera de tornar enrere.
- No es talla res. Cap missatge es trunca ni s'elimina. La resta del xat es carrega amb el control «Desplaça cap amunt per veure missatges anteriors», que els missatges més antics ja usaven, una pàgina cada vegada.
Vam trobar un lloc més amb la mateixa forma. Quan atures un torn, l'API comprova els últims missatges de resposta per veure si alguns són buits i s'han de netejar. Ho feia carregant vuit missatges sencers. En un xat amb respostes de diversos megabytes, és el mateix problema en versió més petita. Ara fa a la base de dades una pregunta de sí o no —aquest missatge té algun contingut?— sense carregar el contingut en si.
Com ho hem comprovat
A la còpia del xat, amb el límit antic de 384 MiB:
- La primera pàgina és de 5 MB. Abans, la mateixa petició tombava l'API.
- Cinc pestanyes del navegador obrint el xat alhora arriben a un pic de 154 MB de memòria.
- Recórrer tot el xat fins al principi retorna els 190 missatges, cadascun exactament una vegada, en ordre.
La lògica de selecció de pàgines també té proves unitàries, incloses una construïda amb les mides reals dels missatges més nous d'aquell xat. La primera pàgina torna amb un missatge, indica que n'hi ha més, i la següent comença al que hi ha després.
Què n'hem après
- Un recompte no és un límit de mida. Qualsevol punt final que retorni «els N més nous» d'una cosa que pot créixer sense límit també necessita un pressupost en bytes. Les dades acabaran trobant el forat.
- El sospitós obvi no sempre és la causa. El desplegament era un desencadenant real d'una feina extra, i més memòria valia la pena. Però la caiguda es reproduïa sense desplegament, i sense reproduir-la ens hauríem aturat a l'augment de memòria.
- Dos servidors cauen junts quan reben la mateixa petició. La redundància protegeix davant que un servidor mori. No protegeix davant d'una petició que tomba qualsevol servidor que arribi, sobretot una que un usuari reintentarà de manera natural refrescant la pàgina.
- Arregla-ho a la configuració, no només a la màquina. L'augment de memòria va anar a la configuració de desplegament el mateix dia, perquè el següent desplegament el mantingués.
Aquesta és la nostra segona història recent de memòria relacionada amb la sortida de l'agent. La primera era una etiqueta d'estat que encuava 690 MB de cadenes. Si uses subagents a Mythex, pots llegir com funcionen a la documentació.