Как един тежък чат можеше да свали нашето API
На 28 септември, около 17 минути след едно пускане, и двата ни production API сървъра изчерпаха паметта си в един и същи момент, рестартираха се и за около пет минути балансьорът нямаше здрав сървър, към който да изпраща трафик. Първата ни поправка даде на API два пъти повече памет. Истинската причина се появи по-късно същия ден: отварянето на един конкретен чат караше API да зареди 53 MB съобщения в една заявка. Историята на чата сега се зарежда на страници, ограничени по размер, а не само по брой, и този чат се отваря с първа страница от 5 MB, вместо да се срине.
Какво падна
API работи като контейнер на всеки от двата сървъра зад балансьор. Всеки контейнер имаше лимит на паметта от 384 MiB. В покой използва 130–140 MiB, така че лимитът изглеждаше с достатъчно запас.
И двата контейнера достигнаха лимита едновременно и се срутиха с „JavaScript heap out of memory“. Heap е паметта, която процес на Node.js използва за обектите, с които работи. Тъй като и двата сървъра паднаха едновременно, нямаше къде да се насочват заявките. Това е най-лошият вид авария за конфигурация с два сървъра: резервирането помага само когато отказите не се случват едновременно.
Първата поправка: повече място
Времето сочеше към пускането. След едно пускане агентските ходове, които пускането е прекъснало, се възобновяват и записват отново транскриптите си, а този изблик на работа изглеждаше като очевиден източник на скок в паметта.
Затова първата промяна вдигна лимита на API от 384 MiB на 768 MiB. Приложихме я на живо на двата сървъра още същия ден и я commit-нахме в конфигурацията за деплой, за да не върне следващият деплой тихо 384. Поправка, направена на ръка на сървър, който следващият деплой отменя, е честият начин една и съща авария да се върне.
По-голямата памет беше разумен резерв. Не беше обяснение.
Истинската причина: една заявка, 71 MB
По-късно същия ден възпроизведохме срива на копие на един чат, използвайки стария лимит на паметта от production. Копието срина API само по себе си, без никакво пускане.
Когато отворите проект, уеб приложението пита API за историята на чата. Крайната точка връщаше последните 30 съобщения цели. За повечето чатове 30 съобщения са малко. За този чат последните 30 съобщения бяха 53 MB в базата данни и 71 MB отговор. Няколко от отговорите на агента бяха по 4–10 MB всеки, главно транскриптите на подагентите: помощните агенти, които главният агент стартира, за да работят паралелно по част от задача, чиято пълна работа се записва с отговора.
Изграждането на този един отговор взимаше повече heap, отколкото API имаше. А всяко отваряне или презареждане на този проект изпращаше заявката отново. Един единствен чат можеше да свали сървъра, на който попадне, а тъй като страницата можеше да се презареди, можеше да стигне и до двата.
„30 съобщения“ никога не е било реален лимит на размер. Беше лимит на брой, който случайно беше малък за обикновените чатове.
Поправката: страници, ограничени и по размер, и по брой
Страница история вече е 30 съобщения или около 4 MB от тях, в зависимост от това кое настъпи първо.
Работа на две стъпки:
- Първо питаме за размерите. API пита базата данни за ID-тата и записаните размери на най-новите съобщения, без съдържанието им. Тази заявка е евтина, без значение колко големи са съобщенията.
- Зареждаме само това, което се побира. Вървейки от най-новото към най-старото, добавя съобщения, докато следващото би изтласкало страницата над 4 MB. Само тези редове след това се зареждат напълно.
Две правила пазят това безопасно:
- Най-новото съобщение винаги влиза в страницата, независимо от размера си. Страница, която не връща нищо, би оставила чата заседнал, без начин да превъртите назад.
- Нищо не се отрязва. Никое съобщение не се съкращава и не се отхвърля. Останалата част от чата се зарежда през контролата „Scroll up for earlier messages“, която по-старите съобщения вече използваха, по една страница наведнъж.
Намерихме още едно място със същата форма. Когато спрете ход, API проверява последните няколко отговора, за да види дали някой е празен и трябва да бъде почистен. Правеше го, като зареждаше цели осем съобщения. При чат с многомегабайтни отговори това е същият проблем в по-малка форма. Сега задава на базата данни въпрос с да или не — има ли това съобщение някакво съдържание? — без да зарежда самото съдържание.
Как го проверихме
На копието на чата, работещо със стария лимит от 384 MiB:
- Първата страница е 5 MB. Преди същата заявка срутваше API.
- Пет браузърни таба, отварящи чата едновременно, достигат пик от 154 MB памет.
- Превъртането назад през целия чат връща всичките 190 съобщения, всяко точно веднъж, в ред.
Логиката за избор на страници има и unit тестове, включително един, построен от реалните размери на най-новите съобщения на този чат. Първата страница връща едно съобщение, казва че има още, а следващата страница започва от онова след него.
Какво взехме от това
- Броят не е лимит на размер. Всяка крайна точка, която връща „най-новите N“ от нещо, което може да расте безкрайно, има нужда и от бюджет в байтове. Данните в крайна сметка намират пролуката.
- Очевидният заподозрян не винаги е причината. Пускането беше реален тригер за допълнителна работа, а повече памет си струваше. Но срива се възпроизведе без пускане и без възпроизвеждането му щяхме да спрем на увеличението на паметта.
- Два сървъра се провалят заедно, когато получат една и съща заявка. Резервирането защитава от смъртта на един сървър. Не защитава от заявка, която убива всеки сървър, до който стигне — особено такава, която потребител ще повтори естествено, като презареди.
- Поправяйте в конфигурацията, не само на машината. Увеличението на паметта влезе в конфигурацията за деплой същия ден, така че следващият деплой го запази.
Това е втората ни скорошна история за паметта, свързана с изхода на агента. Първата беше етикет за статус, който нареди 690 MB низове. Ако използвате подагенти в Mythex, можете да прочетете как работят в документацията.