Wie ein einziger schwerer Chat unsere API lahmlegen konnte
Am 28. September, rund 17 Minuten nach einem Release, sind beide unserer Produktions-API-Server im selben Moment aus dem Arbeitsspeicher gelaufen, neu gestartet, und für etwa fünf Minuten hatte der Load Balancer keinen gesunden Server, an den er Traffic schicken konnte. Unsere erste Lösung gab der API den doppelten Speicher. Die eigentliche Ursache zeigte sich später am selben Tag: Das Öffnen eines bestimmten Chats ließ die API 53 MB Nachrichten in einer einzigen Anfrage laden. Der Chat-Verlauf wird jetzt in Seiten geladen, die sowohl nach Anzahl als auch nach Größe begrenzt sind, und dieser Chat öffnet sich mit einer ersten Seite von 5 MB statt mit einem Absturz.
Was ausgefallen ist
Unsere API läuft als Container auf jedem von zwei Servern hinter einem Load Balancer. Jeder Container hatte ein Speicherlimit von 384 MiB. Im Ruhezustand verbraucht er 130 bis 140 MiB, das Limit sah also nach viel Puffer aus.
Beide Container erreichten dieses Limit gleichzeitig und stürzten mit „JavaScript heap out of memory“ ab. Der Heap ist der Speicher, den ein Node.js-Prozess für die Objekte nutzt, mit denen er arbeitet. Da beide Server gleichzeitig ausfielen, gab es nirgends mehr hin, wohin Anfragen geleitet werden konnten. Das ist die schlimmste Art von Ausfall für ein Setup mit zwei Servern: Redundanz hilft nur, wenn die Fehler nicht zur gleichen Zeit auftreten.
Die erste Lösung: mehr Platz
Der Zeitpunkt wies auf das Deployment hin. Nach einem Release werden Agenten-Züge, die das Deployment unterbrochen hat, fortgesetzt und ihre Transkripte erneut gespeichert. Diese Welle an Arbeit sah nach der naheliegenden Ursache für den Speicherspitz aus.
Deshalb haben wir das Limit der API von 384 MiB auf 768 MiB angehoben. Wir haben das am selben Tag live auf beiden Servern angewendet und es in der Deployment-Konfiguration festgeschrieben, damit das nächste Deployment nicht unbemerkt wieder 384 einträgt. Eine Korrektur, die von Hand auf einem Server erfolgt und vom nächsten Deployment rückgängig gemacht wird, ist ein häufiger Weg, auf dem derselbe Ausfall zurückkommt.
Mehr Speicher war ein vernünftiger Sicherheitspuffer. Eine Erklärung war er nicht.
Die eigentliche Ursache: eine Anfrage, 71 MB
Später am selben Tag haben wir den Absturz auf einer Kopie eines Chats reproduziert, mit dem alten Speicherlimit von Produktion. Die Kopie brachte die API allein zum Absturz, ganz ohne Deployment.
Wenn du ein Projekt öffnest, fragt die Web-App die API nach dem Verlauf des Chats. Der Endpunkt lieferte die neuesten 30 Nachrichten vollständig. Bei den meisten Chats sind 30 Nachrichten wenig. Bei diesem Chat waren die neuesten 30 Nachrichten 53 MB in der Datenbank und eine Antwort von 71 MB. Mehrere Antworten des Agenten waren jeweils 4 bis 10 MB groß, meist die Transkripte von Sub-Agenten: den Hilfsagenten, die der Hauptagent startet, um Teile einer Aufgabe parallel zu bearbeiten, und deren vollständige Arbeit mit der Antwort gespeichert wird.
Das Erstellen dieser einen Antwort brauchte mehr Heap, als die API hatte. Und bei jedem Öffnen oder Neuladen des Projekts wurde die Anfrage erneut gesendet. Ein einzelner Chat konnte also den Server abschießen, auf dem er landete, und da die Seite neu geladen werden konnte, konnte er beide erreichen.
„30 Nachrichten“ war nie eine echte Begrenzung der Größe gewesen. Es war eine Begrenzung der Anzahl, die bei normalen Chats zufällig klein genug war.
Die Lösung: Seiten, die nach Größe und Anzahl begrenzt sind
Eine Seite des Verlaufs umfasst jetzt 30 Nachrichten oder etwa 4 MB davon, je nachdem, was zuerst erreicht wird.
Das läuft in zwei Schritten:
- Zuerst die Größen abfragen. Die API fragt die Datenbank nach den IDs und gespeicherten Größen der neuesten Nachrichten ab, ohne deren Inhalt. Diese Abfrage ist unabhängig von der Größe der Nachrichten günstig.
- Nur laden, was passt. Von der neuesten zur ältesten Nachricht werden Nachrichten hinzugefügt, bis die nächste die Seite über 4 MB bringen würde. Nur diese Zeilen werden dann vollständig geladen.
Zwei Regeln machen das sicher:
- Die neueste Nachricht kommt immer auf die Seite, egal wie groß sie ist. Eine Seite, die nichts zurückgibt, würde den Chat festfahren lassen, ohne Möglichkeit, zurückzublättern.
- Nichts wird abgeschnitten. Keine Nachricht wird gekürzt oder weggelassen. Der Rest des Chats lädt über die Steuerung „Scroll up for earlier messages“, die ältere Nachrichten schon vorher nutzten, Seite für Seite.
Eine weitere Stelle hatte dieselbe Form. Wenn du einen Zug stoppst, prüft die API die letzten Antworten, ob einige leer sind und aufgeräumt werden sollten. Dazu lud sie acht ganze Nachrichten. Bei einem Chat mit mehrmegabyte-großen Antworten ist das in kleinerer Form dasselbe Problem. Jetzt stellt sie der Datenbank eine Ja-oder-Nein-Frage, ob diese Nachricht irgendeinen Inhalt hat, ohne den Inhalt selbst zu laden.
Wie wir es geprüft haben
Auf der Kopie des Chats, mit dem alten Limit von 384 MiB:
- Die erste Seite hat 5 MB. Vorher ließ dieselbe Anfrage die API abstürzen.
- Fünf Browser-Tabs, die den Chat gleichzeitig öffnen, erreichen eine Speicherspitze von 154 MB.
- Beim Zurückblättern durch den gesamten Chat kommen alle 190 Nachrichten zurück, jede genau einmal, in der richtigen Reihenfolge.
Die Logik zur Seitenauswahl hat außerdem Unit-Tests, darunter einen, der aus den echten Größen der neuesten Nachrichten dieses Chats aufgebaut ist. Die erste Seite enthält eine Nachricht, meldet, dass es weitere gibt, und die nächste Seite beginnt bei der darauffolgenden.
Was wir daraus gelernt haben
- Eine Anzahl ist keine Größenbegrenzung. Jeder Endpunkt, der „die neuesten N“ von etwas zurückgibt, das unbegrenzt wachsen kann, braucht auch ein Byte-Budget. Die Daten finden die Lücke irgendwann.
- Der naheliegende Verdächtige ist nicht immer die Ursache. Das Deployment war ein echter Auslöser für zusätzliche Arbeit, und mehr Speicher war es wert. Aber der Absturz ließ sich ohne Deployment reproduzieren, und ohne Reproduktion wären wir bei der Speichererhöhung stehen geblieben.
- Zwei Server scheitern gemeinsam, wenn sie dieselbe Anfrage bekommen. Redundanz schützt davor, dass ein Server ausfällt. Sie schützt nicht vor einer Anfrage, die jeden Server tötet, den sie erreicht, besonders eine, die ein Nutzer durch Neuladen ganz natürlich wiederholt.
- Repariere es in der Konfiguration, nicht nur auf dem Server. Die Speichererhöhung kam am selben Tag in die Deployment-Konfiguration, damit das nächste Deployment sie behielt.
Dies ist unsere zweite aktuelle Speichergeschichte rund um die Ausgabe des Agenten. Die erste war eine Statusanzeige, die 690 MB Strings in die Warteschlange stellte. Wenn du auf Mythex Sub-Agenten nutzt, kannst du in der Dokumentation nachlesen, wie sie funktionieren.