Jak jeden ciężki czat mógł położyć nasze API
28 września, ok. 17 minut po wydaniu, oba nasze produkcyjne serwery API jednocześnie wyczerpały pamięć, zrestartowały się i przez mniej więcej pięć minut moduł równoważenia obciążenia nie miał zdrowego serwera, do którego mógłby kierować ruch. Nasza pierwsza poprawka dała API dwa razy więcej pamięci. Prawdziwa przyczyna wyszła na jaw później tego dnia: otwarcie jednego konkretnego czatu wymagało, by API załadowało 53 MB wiadomości w jednym żądaniu. Historia czatu ładuje się teraz w stronach ograniczonych rozmiarem, a także liczbą, a ten czat otwiera się pierwszą stroną 5 MB zamiast awarią.
Co się położyło
Nasze API działa jako kontener na każdym z dwóch serwerów za modułem równoważenia obciążenia. Każdy kontener miał limit pamięci 384 MiB. W spoczynku zużywa 130–140 MiB, więc limit wyglądał na dużą rezerwę.
Oba kontenery dobiły do tego limitu jednocześnie i padły z komunikatem „JavaScript heap out of memory”. Sterta to pamięć, której proces Node.js używa do przechowywania obiektów, nad którymi pracuje. Ponieważ oba serwery padły naraz, nie było dokąd kierować żądań. To najgorszy rodzaj awarii w konfiguracji z dwoma serwerami: nadmiarowość pomaga tylko wtedy, gdy awarie nie zdarzają się jednocześnie.
Pierwsza poprawka: więcej miejsca
Czas wskazywał na wdrożenie. Po wydaniu tury agenta przerwane przez wdrożenie są wznawiane i na nowo zapisują swoje transkrypty, a ten nagły nawał pracy wyglądał na oczywiste źródło skoku pamięci.
Więc pierwsza zmiana podniosła limit API z 384 MiB do 768 MiB. Zastosowaliśmy ją na żywo na obu serwerach tego samego dnia i zatwierdziliśmy w konfiguracji wdrożenia, żeby następne wdrożenie po cichu nie przywróciło 384. Poprawka zrobiona ręcznie na serwerze, którą następne wdrożenie cofa, to częsty sposób, w jaki ta sama awaria wraca.
Więcej pamięci było rozsądnym marginesem bezpieczeństwa. Nie było wyjaśnieniem.
Prawdziwa przyczyna: jedno żądanie, 71 MB
Później tego dnia odtworzyliśmy awarię na kopii jednego czatu, używając starego limitu pamięci z produkcji. Kopia sama położyła API, bez udziału wdrożenia.
Gdy otwierasz projekt, aplikacja webowa prosi API o historię czatu. Endpoint zwracał najnowsze 30 wiadomości w całości. W większości czatów 30 wiadomości to niewiele. W tym jednym najnowsze 30 wiadomości zajmowało 53 MB w bazie danych, a odpowiedź miała 71 MB. Kilka odpowiedzi agenta miało po 4–10 MB, głównie transkrypty podagentów: pomocniczych agentów, których główny agent uruchamia, by równolegle pracować nad częścią zadania, a których pełna praca jest zapisywana razem z odpowiedzią.
Zbudowanie takiej odpowiedzi wymagało więcej sterty, niż API miało. A każde otwarcie lub odświeżenie tego projektu wysyłało żądanie ponownie. Pojedynczy czat mógł położyć ten serwer, na który trafił, a ponieważ strona dało się przeładować, mógł dotrzeć do obu.
„30 wiadomości” nigdy nie było prawdziwym limitem rozmiaru. Było limitem liczby, który przypadkiem był mały dla zwykłych czatów.
Poprawka: strony ograniczone rozmiarem i liczbą
Strona historii to teraz 30 wiadomości albo ok. 4 MB ich treści, zależnie od tego, co nastąpi pierwsze.
Działa to w dwóch krokach:
- Najpierw pytaj o rozmiary. API prosi bazę danych o identyfikatory i zapisane rozmiary najnowszych wiadomości, bez ich treści. To zapytanie jest tanie bez względu na to, jak duże są wiadomości.
- Ładuj tylko to, co się mieści. Idąc od najnowszej do najstarszej, dodaje wiadomości, aż kolejna przekroczyłaby 4 MB strony. Dopiero wtedy ładowane są w całości tylko te wiersze.
Dwie zasady zapewniają bezpieczeństwo:
- Najnowsza wiadomość zawsze trafia na stronę, bez względu na rozmiar. Strona, która nic by nie zwróciła, zostawiłaby czat utknięty, bez możliwości przewinięcia wstecz.
- Nic nie jest ucinane. Żadna wiadomość nie jest skracana ani pomijana. Reszta czatu ładuje się przez kontrolkę „Przewiń w górę, by zobaczyć wcześniejsze wiadomości”, z której starsze wiadomości korzystały już wcześniej, po jednej stronie naraz.
Znaleźliśmy jeszcze jedno miejsce o tym samym kształcie. Gdy zatrzymujesz turę, API sprawdza ostatnie kilka odpowiedzi, czy któraś jest pusta i powinna zostać posprzątana. Robiło to, ładując osiem całych wiadomości. Na czacie z wieloma odpowiedziami po kilka megabajtów to ten sam problem w mniejszej postaci. Teraz zadaje bazie danych pytanie tak-czy-nie — czy ta wiadomość ma jakąkolwiek treść? — bez ładowania samej treści.
Jak to sprawdziliśmy
Na kopii czatu, działając z dawnym limitem 384 MiB:
- Pierwsza strona ma 5 MB. Wcześniej ta sama prośba kładła API.
- Pięć kart przeglądarki otwierających czat naraz osiąga szczyt 154 MB pamięci.
- Przewinięcie całego czatu wstecz zwraca wszystkie 190 wiadomości, każdą dokładnie raz, w kolejności.
Logika wyboru stron ma też testy jednostkowe, w tym jeden zbudowany z rzeczywistych rozmiarów najnowszych wiadomości tego czatu. Pierwsza strona zwraca jedną wiadomość, sygnalizuje, że są kolejne, a następna strona zaczyna się od tej po niej.
Co z tego wynieśliśmy
- Licznik to nie limit rozmiaru. Każdy endpoint, który zwraca „najnowsze N” czegoś, co może rosnąć bez ograniczeń, potrzebuje też budżetu bajtów. Dane w końcu znajdą tę lukę.
- Oczywisty podejrzany nie zawsze jest przyczyną. Wdrożenie było prawdziwym wyzwalaczem dodatkowej pracy, a więcej pamięci było warte zachodu. Ale awaria odtworzyła się bez wdrożenia, a bez odtworzenia zatrzymalibyśmy się na zwiększeniu pamięci.
- Dwa serwery padają razem, gdy dostają to samo żądanie. Nadmiarowość chroni przed padnięciem jednego serwera. Nie chroni przed żądaniem, które zabija każdy serwer, do którego trafi — zwłaszcza takim, które użytkownik naturalnie ponowi, odświeżając stronę.
- Napraw w konfiguracji, nie tylko na maszynie. Zwiększenie pamięci trafiło tego samego dnia do konfiguracji wdrożenia, więc następne wdrożenie je zachowało.
To nasza druga ostatnia historia dotycząca pamięci i wyjścia agenta. Pierwsza to etykieta statusu, która ustawiła w kolejce 690 MB napisów. Jeśli używasz podagentów w Mythex, możesz przeczytać, jak działają, w dokumentacji.