
День, когда наше приложение устроило DDoS самому себе
Копившиеся отложенные загрузки, выпущенные все разом при запуске, превратили каждый клиент GeekBye в маленькую атаку denial-of-service на наши собственные серверы. Исправление — и лестница liveness соединения, которую оно заставило нас построить — одна из самых полезных вещей, которым научил нас v2.
Каждый инженер распределённых систем рано или поздно встречает thundering herd (обезумевшее стадо): массу клиентов, делающих одно и то же в один и тот же миг, и общий ресурс за ними прогибается. Обычно это чьи-то чужие клиенты. В GeekBye v2.0.1 это были наши, а атакующим было наше собственное приложение.
Как ассистент для заметок атакует сам себя
GeekBye умеет загружать записи на ваш Google Drive. Если запись не может загрузиться сразу — вы были офлайн, приложение закрылось, Drive заикнулся — она отправляется в очередь, чтобы повторить попытку позже. Разумно.
Отказ был в том, когда наступало это «позже». При следующем запуске приложение пыталось опустошить всю очередь разом. Один пользователь с неделей отложенных записей превращался во всплеск одновременных запросов на загрузку в тот миг, когда открывал приложение. Умножьте на каждого пользователя, запускающего приложение утром, и наш backend просили аутентифицировать, проверить лимиты и обработать стену трафика в те же несколько секунд — от клиентов, которые все, технически, вели себя правильно.
Наш backend сделал правильную вещь и ограничил этот поток (rate-limit). И вот тут началась вторичная поломка. Ответ о превышении лимита — это HTTP 429, а 429 очень похоже на другие отказы, если не быть осторожным:
- Стартовое получение профиля получило
429, и приложение сочло это неудачной аутентификацией — на миг выйдя из полностью валидной сессии пользователя. - Соединение транскрипции получило общее
429и показало монит об апгрейде из-за исчерпания лимита аудио — сообщая платящим пользователям, что они достигли квоты, которой не достигали.
Так одна первопричина — неупорядоченная очередь — дала три видимых симптома: нагрузку на сервер, ложные выходы из сессии и ложные апселлы. Классическая форма self-DoS: нагрузка — это плохо, но именно неверная интерпретация симптомов нагрузки — это то, что пользователи реально ощущают.
Исправление, в двух слоях
Останови панику стада. Очередь загрузок теперь растянута во времени — запросы распределены с backoff, а после любого 429 следует cooldown, чтобы клиент отступал от нагруженного сервера, а не наваливался на него сильнее. Thundering herd становится упорядоченной очередью.
Останови неверную интерпретацию. Переходное 429 или 5xx при запуске больше не выкидывает вас из сессии — клиент различает «сервер ненадолго занят» и «ваша сессия недействительна». А общее 429 о превышении лимита на пути транскрипции больше не выдаёт себя за ошибку исчерпания квоты аудио; только настоящий ответ о квоте показывает монит об апгрейде. Урок, который засел: код ошибки — это не значение ошибки. 429 значит «сбавь темп», а не «ты не авторизован» и не «у тебя кончилась квота» — и клиент обязан знать разницу.
Лестница liveness, что последовала
Упорядочивание стада вскрыло более тихую проблему: когда соединение действительно портилось под нагрузкой, как быстро мы это замечали? Медленнее, чем хотелось. Так что v2.0.4 выстроил лестницу liveness соединения для транскрипции в реальном времени:
- Heartbeat'ы — сокет транскрипции пингуется через фиксированный интервал, так что тихо умершее соединение обнаруживается за секунды, а не в ожидании, пока откажет следующий фрагмент аудио.
- Пинок переподключения при возврате сети — когда ОС сообщает, что сеть вернулась, приложение проактивно восстанавливает соединение, вместо того чтобы ждать, пока наткнётся на это открытие.
- Liveness через контрольный ping/pong — round-trip на уровне приложения, подтверждающий не просто «сокет открыт», но «другой конец действительно отвечает».
- Ворота переподключения, принадлежащие сервису — одно место решает, переподключаться ли, вместо нескольких частей приложения, наперегонки пытающихся это сделать и мешающих друг другу.
Ничто из этого не эффектно. Всё это — причина, по которой сессия в v2 ощущается так, будто она просто... остаётся на связи.
Это случилось снова на этой неделе — этажом ниже
Вот часть, которая делает это больше, чем военной байкой. Тот же класс отказа всплыл несколько дней назад, уровнем ниже нас. Один клиент выпустил 21 старт сессии транскрипции менее чем за 400 миллисекунд — и споткнулся о действующий на всё аккаунт лимит нашего провайдера речи: 15 одновременных запросов. Паника стада против общей инфраструктуры, ровно как очередь загрузок, только нацеленная на зависимость, а не на наш собственный backend.
Форма идентична, и исправление тоже: наваливающемуся клиенту нужен single-flight guard, чтобы он не мог выпустить двадцать стартов на одно намерение, а общему ресурсу нужен лимит на пользователя, чтобы один клиент не мог поглотить весь пул. Мы уже строили клиентскую версию этого — pacer загрузок и есть этот паттерн. Теперь мы применяем его к стартам сессий. Thundering herd — это не баг, который исправляешь однажды; это форма, которую учишься распознавать всюду, где клиенты встречают общий лимит.
Три вещи на вынос
- Ваши собственные клиенты — это нагрузочный тест, который вы не планировали. Всё, что группируется-при-старте, повторяется-при-запуске или переподключается-при-пробуждении, — это thundering herd, ждущий достаточного числа пользователей. Растяните это во времени, прежде чем они у вас появятся.
- Отличайте код от значения.
429— самый неправильно читаемый статус на свете. «Сбавь темп» — это не «выйди из системы» и не «заплати нам». Направьте каждый отказ к тому, что он реально значит. - Liveness — это лестница, а не флаг. «Живо ли соединение?» имеет несколько честных ответов на разных слоях — сокет открыт, байты идут, другой конец отвечает, сеть присутствует. Надёжное приложение проверяет больше одного.
Это та неэффектная машинерия под спокойствием GeekBye v2. О функциях надёжности, которые она сделала возможными, читайте почему ваш ИИ-ассистент для заметок останавливается на плохом Wi-Fi и транскрипция в реальном времени, когда файрвол блокирует WebSocket (v2.0.8). О соседних релизах этой серии — почему ваш ИИ-ассистент для заметок останавливает запись посреди встречи (v2.0.9).