Ретраи без джиттера превратили чужой сбой в наш собственный DDoS
В конце августа 2026 года у нас случился инцидент, после которого пришлось переписывать retry-логику во всех сервисах. Формально виноват был не мы — у партнёрского API случился короткий сбой на его стороне. Но наша реакция на этот сбой оказалась хуже самого сбоя: мы сами обрушили сервис, который пытались просто дождаться. Разбираю по шагам, что произошло, что мы видели в метриках, какие версии отбросили и что изменили, чтобы это не повторилось.
Содержание
Что сломалось
У нас есть сервис обработки заказов — назовём его orders-svc. Он на каждый заказ дёргает внешний API логистического партнёра, чтобы получить расчёт доставки и зарезервировать слот. Обычная синхронная интеграция: HTTP-запрос, таймаут 3 секунды, в случае ошибки — три ретрая.
В 14:12 по московскому времени партнёрский API отвечал ошибками и таймаутами примерно 40 секунд — судя по их собственному статус-пейджу, это был плановый рестарт балансировщика на их стороне, чуть более долгий, чем они рассчитывали. Само по себе это не страшно: у нас orders-svc крутится в 12 инстансах за общим балансировщиком, и retry-логика как раз для таких случаев и придумана.
Проблема началась не в момент сбоя партнёра, а через несколько секунд после того, как он восстановился. Партнёрский API снова начал отвечать 200 — и почти сразу же лёг опять, уже минут на 25. Наш дашборд в это время показывал классическую картину DDoS-атаки: резкий скачок числа исходящих соединений, рост CPU на orders-svc до 95%+, очередь заявок на балансировщике растёт быстрее, чем разгребается.
Что видели в логах и метриках
Первое, за что зацепился глаз — синхронность. В Grafana график исходящих запросов к API партнёра выглядел не как органический рост нагрузки, а как последовательность резких пиков через равные интервалы:
14:12:40 — провал (партнёр лёг)
14:13:20 — пик исходящих запросов (~9x от базовой линии)
14:13:22 — провал (партнёр снова лёг)
14:14:02 — пик (~11x)
14:14:04 — провал
14:14:44 — пик (~14x)
Интервал между пиками — ровно 40 секунд. Это не похоже на поведение живых пользователей или органический трафик — слишком идеальная периодичность. Посмотрели логи orders-svc за один такой пик:
[14:13:19.812] retry attempt=1 order_id=88213 delay=2000ms
[14:13:19.813] retry attempt=1 order_id=88214 delay=2000ms
[14:13:19.815] retry attempt=1 order_id=88219 delay=2000ms
[14:13:19.816] retry attempt=1 order_id=88221 delay=2000ms
...
[14:13:19.910] retry attempt=1 order_id=88602 delay=2000ms
За сотню миллисекунд — сотни строк с одинаковым delay=2000ms. То есть сотни запросов, которые получили ошибку почти одновременно, поставили себе ретрай через фиксированные 2 секунды — и все вместе выстрелят в один и тот же момент.
Метрики со стороны nginx перед orders-svc показали то же самое зеркально: upstream_response_time в норме держался на 80-150 мс, а в моменты пиков резко улетал за 3000 мс с ростом числа 502 и 504 — балансировщик partner API просто не успевал разгрести очередь одновременных соединений и начинал сбрасывать часть из них, что порождало новую волну ретраев.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверКакие гипотезы отбросили
Первая мысль была логичная для такой картины — атака. Проверили логи WAF и внешнего балансировщика: все всплески трафика шли изнутри нашей же сети, с IP-адресов наших инстансов orders-svc, никакого внешнего источника не было. Это сразу закрыло версию про DDoS на нас — и, как выяснилось позже, мы сами были источником похожей нагрузки на партнёра.
Вторая версия — баг с бесконечным циклом после недавнего деплоя. Посмотрели git log: последний деплой orders-svc был четыре дня назад, к моменту инцидента отношения не имел. Проверили счётчик ретраев в коде — он честно ограничен тремя попытками на запрос, зацикливания нет, каждый воркер останавливался после третьей неудачи.
Третья версия — сетевой партишн между нами и партнёром, то есть проблема не в самом API, а в маршрутизации. Проверили traceroute и DNS-резолвинг в момент инцидента — задержки в норме, резолвинг стабильный, пакеты доходили. Это не был сетевой сбой на нашей стороне.
Четвёртая версия — исчерпание пула соединений на нашей стороне из-за утечки. Посмотрели метрики HTTP-клиента: keep-alive пул не рос бесконтрольно, соединения закрывались штатно после таймаута. Ресурсов у нас хватало с запасом — CPU был загружен не из-за утечки, а из-за реального объёма исходящих запросов.
Ни одна из этих версий не объясняла главного — идеальную периодичность пиков в 40 секунд. Как только мы сосредоточились именно на этом паттерне, версия сложилась быстро.
В чём была реальная причина
HTTP-клиент к партнёрскому API был настроен на 3 ретрая с фиксированной задержкой 2 секунды между попытками, без какого-либо случайного разброса (джиттера). Конфиг выглядел примерно так:
partner_api:
timeout_ms: 3000
retries: 3
retry_delay_ms: 2000
retry_backoff: fixed
Когда партнёр лёг на 40 секунд, все запросы, которые в этот момент летели к нему с 12 инстансов orders-svc, получили ошибку почти в одну и ту же миллисекунду — потому что таймаут у всех одинаковый (3000 мс), и все они стартовали примерно синхронно, ориентируясь на общий поток входящих заказов. Все они поставили себе ретрай через одинаковые 2000 мс. Через 2 секунды — второй синхронный залп, ещё через 2 секунды — третий. Партнёрский API, у которого штатная нагрузка около 40-50 запросов в секунду, в момент восстановления получал залп в несколько сотен запросов за миллисекунды — то есть по факту наш поток ретраев вёл себя как impulse-нагрузка, которую сложно отличить от DDoS-атаки на его стороне.
Партнёр только-только поднял балансировщик после планового рестарта, его сервис ещё прогревался (кэши не заполнены, пул соединений к БД не разогрет) — и залп синхронных ретраев от нас добивал его снова, каждый раз чуть дольше предыдущего, потому что сервис не успевал стабилизироваться между волнами. Получился порочный круг: партнёр падает → мы синхронно ретраим → партнёр падает от нашего залпа → мы снова синхронно ретраим. Круг разорвался только когда очередь заказов на нашей стороне частично исчерпалась (клиенты стали получать таймауты и переставали ждать), интенсивность потока снизилась, и партнёр наконец успел стабилизироваться.
Важный нюанс: сам по себе retry с фиксированной задержкой — не баг, это стандартный паттерн. Проблема ровно в отсутствии случайного разброса между инстансами. Пока у вас один инстанс — эффекта нет. Как только сервис масштабируется горизонтально и у всех реплик одинаковая retry-логика без джиттера, они синхронизируются относительно любого общего внешнего события (в нашем случае — сбоя у партнёра) и бьют залпом. Это классический thundering herd, только направленный не на собственную инфраструктуру, а вовне.
Что изменили после
Первым делом переписали retry-логику на экспоненциальный backoff с полным джиттером (full jitter) — вместо фиксированной задержки берём случайное значение в диапазоне от нуля до экспоненциально растущей верхней границы:
import random
def get_retry_delay(attempt: int, base_ms: int = 500, cap_ms: int = 8000) -> float:
upper_bound = min(cap_ms, base_ms * (2 ** attempt))
return random.uniform(0, upper_bound) / 1000 # секунды
С таким подходом даже если 500 инстансов получили ошибку в одну миллисекунду, их ретраи размажутся по времени, а не выстрелят одним залпом.
Дальше добавили circuit breaker вокруг клиента к партнёрскому API — используем библиотеку с реализацией паттерна (в Python-сервисах — pybreaker, в Node — opossum). Логика простая: если процент ошибок за скользящее окно превышает порог (у нас настроено 50% ошибок из последних 20 запросов), breaker переходит в состояние open и на 15 секунд вообще перестаёт слать запросы к партнёру, сразу отдавая ошибку вызывающему коду. Это не даёт сервису добивать и без того лежащий апстрим — вместо потока ретраев мы просто ждём и раз в 15 секунд пробуем один "пробный" запрос (состояние half-open).
Отдельно ограничили конкурентность исходящих запросов к партнёрскому API — раньше ничего не мешало всем 12 инстансам одновременно слать сколько угодно параллельных запросов. Добавили клиентский rate limiter на уровне HTTP-клиента (token bucket, ~60 запросов в секунду суммарно на весь кластер через общий Redis-счётчик), чтобы даже в худшем случае поток к партнёру не мог физически превысить его штатную ёмкость.
Наконец, завели отдельный алерт именно на паттерн синхронных ретраев — сравниваем стандартное отклонение интервалов между исходящими запросами к внешним API за скользящее окно 5 минут. Если запросы идут слишком ровными пачками (низкая дисперсия интервалов при высокой общей частоте) — это подозрительно похоже на retry storm, даже если общий RPS ещё не критичный. Алерт срабатывает раньше, чем нагрузка успевает вырасти до уровня, на котором партнёр реально ляжет.
Как проверить, есть ли у вас та же проблема
Если у вас есть исходящие интеграции с внешними API и retry-логика — стоит проверить несколько вещей заранее, а не после инцидента:
- Посмотрите конфиг HTTP-клиента: если
retry_delayилиbackoff— фиксированное число без джиттера, это потенциальная бомба замедленного действия при масштабировании на несколько инстансов. - Постройте график исходящих запросов к каждому внешнему API за последние несколько инцидентов (даже мелких) — если видите равномерные пики через одинаковые интервалы, это тот же паттерн.
- Проверьте, есть ли верхний предел конкурентности запросов к каждому конкретному внешнему сервису отдельно от общего пула соединений сервиса.
- Спросите себя: что произойдёт, если этот внешний API ляжет на минуту прямо сейчас, а у вас 20 инстансов сервиса? Если ответ "не уверен" — стоит смоделировать это на стейджинге, отключив доступ к моку API на 30-60 секунд под нагрузкой, близкой к боевой.
Похожая механика "самим себе устроили нагрузку" встречалась нам и раньше — например, когда отвалившийся один микросервис утянул за собой ещё пять из-за похожей цепной реакции внутри кластера, или когда собственный скрипт мониторинга положил сервер, потому что не учитывал нагрузку от самих проверок. Общий урок один: короткий внешний сбой опасен не сам по себе, а тем, как на него отреагирует ваша инфраструктура.
Отдельно стоит понимать разницу между этим паттерном и настоящей DDoS-атакой, которую видно на графиках — внешне метрики похожи (резкий рост нагрузки, деградация ответов), но источник и, соответственно, лечение принципиально разные: атаку останавливают на периметре, а retry storm — только изменением логики клиента.
Если вы проектируете новую интеграцию с нуля, полезно заранее разобраться, как работает rate limiting и на какой стороне его имеет смысл ставить — иногда правильнее ограничивать не входящий трафик к себе, а исходящий поток к чужому API, который вы не контролируете и о ёмкости которого можете только догадываться.
Для нас этот инцидент стал поводом ещё раз посмотреть, на каком железе крутится критичная инфраструктура. Часть нагрузочного тестирования retry-логики (искусственно роняли мок API под нагрузкой, сравнивали поведение до и после fix) мы гоняли на отдельном выделенном сервере, чтобы не мешать боевому окружению и получить стабильные, повторяемые результаты без шума от соседей по железу.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Почему джиттер вообще решает проблему, если retries всё равно происходят?
Джиттер не убирает ретраи, он размазывает их во времени. Вместо одного пика в N тысяч запросов в момент времени T вы получаете плавно распределённый поток за несколько секунд — апстрим успевает его переварить, потому что нагрузка не превышает его штатную пропускную способность в моменте.
Достаточно ли только джиттера, или нужен ещё и circuit breaker?
Джиттер снижает вероятность синхронного залпа, но не защищает от случая, когда внешний сервис лежит долго (минуты, а не секунды) — тогда ретраи продолжат идти волнами и всё равно создавать заметную нагрузку. Circuit breaker останавливает поток запросов полностью на время, пока сервис явно не в порядке, и это дополняет джиттер, а не заменяет.
Как понять, какой порог выставить для circuit breaker, если нет исторических данных?
Начните консервативно — например, 50% ошибок из последних 20-30 запросов как порог перехода в open, и 10-15 секунд паузы перед пробным запросом. Дальше калибруйте по факту: если breaker слишком часто открывается на штатных кратковременных сбоях — увеличьте окно; если слишком долго ждёте перед восстановлением — уменьшите паузу.
Нужно ли делать то же самое для внутренних сервисов, не только для внешних API?
Да, тот же принцип работает для вызовов между вашими собственными микросервисами. Thundering herd не различает, чужой сервис или свой — единственное, что имеет значение, это несколько инстансов-клиентов с синхронизированной retry-логикой против одного restart-нуждающегося апстрима.
Как быстро отличить retry storm от настоящей DDoS-атаки в момент инцидента?
Первым делом смотрите источник трафика: если все всплески идут с ваших собственных внутренних IP-адресов (инстансов ваших же сервисов), а не с внешних адресов — это почти наверняка ваш собственный retry storm, а не атака извне.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →