Як один важкий чат міг би покласти наш API
28 вересня, приблизно через 17 хвилин після релізу, обидва наші продакшн API-сервери одночасно вичерпали пам'ять, перезапустилися, і майже п'ять хвилин балансувальник навантаження не мав жодного справного сервера, куди б спрямувати трафік. Наше перше виправлення дало API вдвічі більше пам'яті. Справжня причина з'ясувалася того ж дня: відкриття одного конкретного чату змушувало API завантажити 53 МБ повідомлень одним запитом. Тепер історія чату завантажується сторінками, обмеженими і кількістю, і розміром, а цей чат відкривається з першою сторінкою 5 МБ замість аварії.
Що зламалося
Наш API працює як контейнер на кожному з двох серверів за балансувальником навантаження. Кожен контейнер мав ліміт пам'яті 384 МіБ. У спокої він використовує 130–140 МіБ, тож ліміт здавався таким, що з великим запасом.
Обидва контейнери одночасно вперлися в цей ліміт і впали з помилкою «JavaScript heap out of memory». Купа — це пам'ять, яку процес Node.js використовує для об'єктів, з якими працює. Оскільки обидва сервери впали разом, немає куди спрямувати запити. Це найгірший тип простою для конфігурації з двома серверами: резервування допомагає лише тоді, коли відмови не збігаються в часі.
Перше виправлення: більше місця
Час указував на деплой. Після релізу ходи агента, перервані деплоєм, відновлюються і знову зберігають свої транскрипти, і цей сплеск роботи здавався очевидним джерелом піку пам'яті.
Тому перша зміна підняла ліміт API з 384 МіБ до 768 МіБ. Ми застосували її наживо на обох серверах того ж дня і закомітили в конфігурацію деплою, щоб наступний деплой тихо не повернув 384. Виправлення, зроблене вручну на сервері, який наступний деплой відкотить, — звичайний спосіб, яким та сама аварія повертається.
Більше пам'яті було розумним запасом міцності. Але це не було поясненням.
Справжня причина: один запит, 71 МБ
Пізніше того дня ми відтворили збій на копії одного чату, використовуючи старий ліміт пам'яті продакшну. Копія сама по собі клала API, без жодного деплою.
Коли ви відкриваєте проєкт, веб-застосунок запитує в API історію чату. Ендпоінт повертав найновіші 30 повідомлень цілком. Для більшості чатів 30 повідомлень — це мало. Для цього чату найновіші 30 повідомлень важили 53 МБ у базі даних і давали відповідь на 71 МБ. Кілька відповідей агента були по 4–10 МБ кожна, переважно транскрипти субагентів: допоміжних агентів, яких головний агент запускає, щоб паралельно опрацьовувати частину завдання, а їхня повна робота зберігається разом із відповіддю.
Побудова такої відповіді вимагала більше купи, ніж мав API. І кожне відкриття або оновлення цього проєкту знову надсилало запит. Один чат міг покласти той сервер, на який потрапив, а оскільки сторінку можна було перезавантажити, він міг дістатися і до обох.
«30 повідомлень» ніколи не було справжнім обмеженням розміру. Це було обмеження за кількістю, яке для звичайних чатів випадково було малим.
Виправлення: сторінки, обмежені і розміром, і кількістю
Сторінка історії тепер містить 30 повідомлень або близько 4 МБ, залежно від того, що трапиться раніше.
Працює це у два кроки:
- Спершу запитати розміри. API просить у бази даних ідентифікатори та збережені розміри найновіших повідомлень, без їхнього вмісту. Цей запит дешевий, якими б великими не були повідомлення.
- Завантажити лише те, що вміщується. Рухаючись від найновіших до найстаріших, він додає повідомлення, доки наступне не виштовхне сторінку за 4 МБ. Тільки ці рядки потім завантажуються повністю.
Дві норми роблять це безпечним:
- Найновіше повідомлення завжди потрапляє на сторінку, незалежно від розміру. Сторінка, яка нічого не повертає, залишила б чат застряглим, без можливості прокрутити назад.
- Нічого не обрізається. Жодне повідомлення не скорочується і не відкидається. Решта чату завантажується через елемент керування «Прокрутіть вгору для ранніших повідомлень», який уже використовувався для старіших повідомлень, по одній сторінці.
Ми знайшли ще одне місце з такою ж проблемою. Коли ви зупиняєте хід, API перевіряє останні кілька відповідей, щоб побачити, чи є серед них порожні, які треба прибрати. Для цього він завантажував вісім повних повідомлень. У чаті з багатомегабайтними відповідями це та сама проблема в меншому вигляді. Тепер він ставить базі даних просте питання «чи має це повідомлення будь-який вміст?» без завантаження самого вмісту.
Як ми перевірили
На копії чату, з давнім лімітом 384 МіБ:
- Перша сторінка становить 5 МБ. Раніше той самий запит клав API.
- П'ять вкладок браузера, що одночасно відкривають чат, сягають піку 154 МБ пам'яті.
- Прокрутка назад через увесь чат повертає всі 190 повідомлень, кожне рівно один раз і в правильному порядку.
Логіка вибору сторінок також має модульні тести, зокрема один, побудований на реальних розмірах найновіших повідомлень цього чату. Перша сторінка повертає одне повідомлення, каже, що є ще, а наступна сторінка починається з наступного за ним.
Що ми з цього винесли
- Кількість — це не обмеження розміру. Будь-який ендпоінт, що повертає «найновіші N» чогось, що може необмежено рости, потребує також бюджету в байтах. Дані рано чи пізно знайдуть цю прогалину.
- Очевидний підозрюваний не завжди є причиною. Деплой був справжнім тригером для додаткової роботи, і більше пам'яті того вартувало. Але збій відтворився без деплою, і без відтворення ми б зупинилися на збільшенні пам'яті.
- Два сервери падають разом, коли отримують один і той самий запит. Резервування захищає від відмови одного сервера. Воно не захищає від запиту, який убиває будь-який сервер, до якого він потрапляє, особливо якщо користувач природно повторить його, оновивши сторінку.
- Виправляйте в конфігурації, а не лише на сервері. Збільшення пам'яті потрапило в конфігурацію деплою того ж дня, тож наступний деплой його зберіг.
Це наша друга недавня історія про пам'ять, пов'язана з виводом агента. Перша — мітка статусу, яка поставила в чергу 690 МБ рядків. Якщо ви використовуєте субагентів у Mythex, про те, як вони працюють, можна прочитати в документації.