MAATRIX / Блог / Атака на 30 запросов в секунду, которая положила сайт

Атака на 30 запросов в секунду, которая положила сайт

MAATRIX

Тридцать запросов в секунду — это несерьёзно. Слабый VPS без напряга держит тысячи RPS на статике. Поэтому когда сайт лёг, а в логах — устойчивые 25-35 запросов в секунду на один и тот же URL, первая реакция обычно «это не могла быть атака, слишком мало». А сайт лежит. Разберём, почему абсолютное число запросов в секунду ничего не говорит о силе атаки без контекста стоимости каждого запроса, и как не попасть в эту ловушку на собственном проекте. Ниже — обобщённый разбор механики, без привязки к конкретному инциденту. Цифры условные, ситуация типовая: она регулярно случается на самых разных стеках, от WordPress-визиток до самописных API на Node.js или Django.

Почему RPS сам по себе ничего не значит

RPS ничего не говорит о том, сколько стоит обработать каждый запрос. А стоимость может отличаться на три-четыре порядка в зависимости от того, что происходит на сервере после того, как запрос пришёл.

Отдача статической страницы с диска через sendfile — доли миллисекунды CPU и почти никакой нагрузки на память. Запрос, который триггерит полнотекстовый поиск по большой таблице без индекса, генерацию PDF на лету, агрегирующий SQL с несколькими JOIN по неиндексированным колонкам или вызов внешнего API — это может быть сотни миллисекунд или секунды процессорного времени, плюс блокировка воркера на всё это время, плюс нагрузка на базу.

Грубая модель: 4 CPU-ядра под приложением (типичный тариф VPS), один воркер занимает одно ядро на время обработки запроса:

Тип запросаВремя обработкиДержат 4 ядра, RPSЧто нужно, чтобы уронить
Статика / кеш~2 мстысячидесятки тысяч RPS
Запрос с БД по индексу~20-30 мс~130-200сотни RPS
Тяжёлый запрос без индекса~500 мс - 2 с~2-810-30 RPS
Генерация отчёта/PDF1-5 с~1-4единицы-десятки RPS

Это ориентировочные числа для иллюстрации механики, а не измеренный бенчмарк — у вас соотношение будет своим. Суть в другом: если один запрос держит воркер секунду, а ядер четыре, сайт кладут не тысячи RPS, а пяток клиентов, повторяющих запрос раз в секунду каждый. Для сети это ничтожная величина, незаметная в любом отчёте про «мощность DDoS». А сайт при этом не отвечает никому — все воркеры заняты тяжёлыми запросами, и обычные посетители со статикой тоже встают в очередь.

Стоит развести два сценария:

  • Один запрос — один дорогой обработчик. URL сам по себе тяжёлый: сложный SQL, экспорт, рендер отчёта, поиск без индекса.
  • Один запрос — N подзапросов (проблема N+1). Обработчик выглядит невинно, но внутри на каждый входящий запрос делает 50-200 обращений к базе или внешнему API — классический N+1 из ORM, когда для списка из сотни элементов вместо одного JOIN выполняется отдельный запрос на каждый элемент. Снаружи — один «лёгкий» запрос, внутри — лавина.

Второй случай коварнее: на маленьком тестовом наборе данных разница между JOIN и N+1 не видна вообще, оба варианта отрабатывают за миллисекунды. Проблема проявляется только на проде, когда таблица разрастается до десятков тысяч строк, а разработчик про код давно забыл.

Почему такие места вообще появляются в архитектуре

Это не про «плохих программистов» — это системная ловушка, в которую попадает почти любой проект, если не следить специально.

Оптимизация под средний случай, а не под худший. Разработчик тестирует эндпоинт с обычными параметрами. Никто не пробует передать фильтр, который матчит миллион строк, или диапазон дат в десять лет вместо недели. В проде такой параметр может прислать не только злоумышленник, но и обычный пользователь.

Кеш есть не везде, где нужен. Кешируют то, что нагружает больше всего в фоновой нагрузке: главную, каталог, частые поисковые запросы. Редкие эндпоинты — персональный отчёт, экспорт в Excel, фильтр по нестандартным параметрам — остаются без кеша, потому что их редко дёргают в обычном режиме. Это ровно те места, куда бьёт направленная атака: не туда, где толпа пользователей, а туда, где защиты никто не ставил.

Рост данных меняет стоимость запроса без изменения кода. Запрос, который два года назад отрабатывал за 5 мс на таблице в 10 тысяч строк, сегодня на той же логике и без индекса может отрабатывать за 800 мс на 5 миллионах строк. Код не менялся, риск вырос сам, вместе с базой.

Разделение фронтенда и бэкенда размывает ответственность. Когда SPA дёргает REST/GraphQL API, легко упустить, что один клик на фронте («показать все заказы за год с разбивкой по товарам») превращается в тяжёлый агрегирующий запрос на бэкенде.

Похожий класс проблем — когда один неудачно написанный запрос кладёт инфраструктуру без всякой внешней атаки — разбирали на примере запроса без LIMIT, который положил реплику базы и все отчёты: механика та же, просто без злого умысла снаружи.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Как отличить такую атаку от обычной DDoS-волны

Первый сигнал — несоответствие между нагрузкой на сеть/CPU в среднем и деградацией сервиса. Классический объёмный DDoS виден по резкому росту трафика, pps, числа соединений в conntrack. Здесь картина другая:

  • Входящий трафик почти не растёт — запросов мало, они маленькие по размеру.
  • CPU веб-сервера или базы данных уходит в полку, при этом число соединений скромное.
  • В top или SHOW PROCESSLIST / pg_stat_activity висят несколько одинаковых долгих запросов, часто с одним URL.
  • Очередь воркеров nginx/PHP-FPM растёт, хотя число входящих соединений в секунду скромное.

Смотрим топ URL по логам nginx:

tail -n 5000 /var/log/nginx/access.log | awk '{print $7}' | sort | uniq -c | sort -rn | head -20

Если один URL резко выделяется на фоне скромного общего трафика — это наводка. Дальше смотрим время ответа именно по нему (нужен $request_time в log_format):

grep "GET /heavy-report" /var/log/nginx/access.log | awk '{print $2}' | sort -n | tail -20

Если request_time для этого URL стабильно секунды — вот он, дорогой эндпоинт. Параллельно смотрим активные запросы в БД:

-- PostgreSQL: запросы, выполняющиеся дольше 1 секунды прямо сейчас
SELECT pid, now() - query_start AS duration, state, query
FROM pg_stat_activity
WHERE state = 'active' AND now() - query_start > interval '1 second'
ORDER BY duration DESC;

Если видите десяток одинаковых по тексту запросов, выполняющихся параллельно и подолгу — это точка отказа. Дальше уже неважно, 30 это RPS или 300: важно, что запросов больше, чем сервер способен переварить на этом пути. Отличить целенаправленную атаку от органического всплеска (например, ссылку на отчёт разослали по рассылке) помогает то же самое: источники (один IP/подсеть или широкий разброс), регулярность интервалов между запросами и легитимность параметров.

Как найти свои дорогие эндпоинты заранее

Ждать инцидента — плохая стратегия. Найти слабые места нужно заранее, целенаправленным нагрузочным тестированием конкретных операций, а не общим «прогоном сайта» ради красивой цифры RPS.

Шаг 1. Составьте список кандидатов. Пройдитесь по коду и логам БД, отметьте эндпоинты, у которых:

  • в обработчике SQL с JOIN по нескольким таблицам, GROUP BY, ORDER BY по вычисляемому полю или полнотекстовым поиском (LIKE '%...%', to_tsquery) без готового индекса;
  • есть цикл с запросом к БД или внешнему сервису на каждой итерации (N+1) — паттерн «получили список — прошлись циклом — на каждый элемент отдельный запрос»;
  • генерируется файл (PDF, Excel) или картинка на лету;
  • эндпоинт принимает произвольные фильтры/диапазоны без ограничения объёма выборки;
  • нет лимита на глубину пагинации (?page=99999&per_page=10000).

Шаг 2. Включите медленный лог на БД, если ещё не включён — он сам покажет дорогие запросы:

-- PostgreSQL: логировать всё, что выполняется дольше 300 мс
ALTER SYSTEM SET log_min_duration_statement = 300;
SELECT pg_reload_conf();
# MySQL/MariaDB, в my.cnf
slow_query_log = 1
long_query_time = 0.3

Через несколько дней у вас будет реальный список запросов-кандидатов с фактическим временем выполнения.

Шаг 3. Нагрузите именно эти URL точечно, не весь сайт. Тестируйте адресно, инструментом вроде wrk или hey:

# hey: 10 клиентов, лимит 3 запроса в секунду каждый — итого 30 RPS
hey -z 30s -c 10 -q 3 https://example.com/api/reports/heavy

Это не тысяча запросов в секунду — 10 клиентов по 3 RPS каждый, ровно профиль из заголовка статьи. Смотрите на p99 задержки и на CPU/память БД в этот момент, а не только на среднее — оно маскирует именно те хвостовые запросы, которые интересны. Как спланировать нагрузочный тест, чтобы он не превратился в грабли (например, случайно не задеть прод вместо стенда), разбирали в статье про нагрузочный скрипт, который три недели незаметно бил по боевому серверу.

Шаг 4. Зафиксируйте порог отказа для каждого найденного эндпоинта — при каком числе параллельных запросов задержка деградирует нелинейно. Обычно это происходит резко: до какого-то числа клиентов задержка растёт плавно, дальше — обвал, потому что воркеры/соединения к БД заканчиваются. Этот порог и есть ваш реальный лимит для защиты — не абстрактные «500 RPS выдержим», а «этот эндпоинт ложится после 8 параллельных запросов».

Базовая защита: кеш там, где он реально нужен

Кеш убирает саму причину дороговизны — повторное выполнение тяжёлой операции.

Кешируйте результат, а не запрос. Если отчёт не обязан быть актуальным посекундно, кешируйте готовый результат на разумный TTL:

import redis, json, hashlib

r = redis.Redis()

def get_heavy_report(params):
    cache_key = "report:" + hashlib.md5(json.dumps(params, sort_keys=True).encode()).hexdigest()
    cached = r.get(cache_key)
    if cached:
        return json.loads(cached)
    result = run_expensive_query(params)  # тот самый тяжёлый SQL
    r.setex(cache_key, 60, json.dumps(result))  # TTL 60 секунд
    return result

Учитывайте «эффект стада»: если TTL истекает и одновременно прилетает пачка запросов, все они промахиваются мимо кеша разом и бьют в тяжёлый запрос одновременно — кеш перестаёт защищать именно тогда, когда нужен сильнее всего. Решается блокировкой на пересчёт: подробно разбирали в статье про то, как прогревшийся кеш уронил базу через эффект стада.

Кешируйте на уровне nginx, если ответ можно отдавать одинаковым для одинаковых параметров:

proxy_cache_path /var/cache/nginx/reports levels=1:2 keys_zone=reports_cache:10m max_size=1g inactive=10m;

location /api/reports/ {
    proxy_cache reports_cache;
    proxy_cache_valid 200 60s;
    proxy_cache_key "$request_uri";
    proxy_cache_lock on;          # гасит эффект стада на уровне nginx
    proxy_cache_use_stale updating;
    proxy_pass http://backend;
}

proxy_cache_lock on — ключевая директива: не даёт нескольким одновременным промахам уйти в апстрим параллельно, пускает туда только первый запрос, остальные ждут результата.

Базовая защита: лимиты именно на дорогие операции

Общий rate limit «100 запросов в минуту с IP на весь сайт» почти бесполезен против точечной атаки — злоумышленник просто уложится в лимит, продолжая бить в единственный дорогой URL. Лимитировать нужно адресно, отдельной, более строгой зоной:

http {
    limit_req_zone $binary_remote_addr zone=general:10m rate=20r/s;
    limit_req_zone $binary_remote_addr zone=heavy:10m rate=1r/s;
}

server {
    location / {
        limit_req zone=general burst=40 nodelay;
        proxy_pass http://backend;
    }

    location ~ ^/(api/reports|api/export|search) {
        limit_req zone=heavy burst=3 nodelay;
        proxy_pass http://backend;
    }
}

Дополните лимитом на уровне приложения — числом одновременно выполняющихся тяжёлых операций, а не запросов в секунду:

import threading

heavy_semaphore = threading.Semaphore(3)  # не больше 3 тяжёлых отчётов одновременно

def handle_heavy_report(request):
    if not heavy_semaphore.acquire(blocking=False):
        return error_response(429, "Слишком много отчётов формируется, попробуйте через минуту")
    try:
        return generate_report(request.params)
    finally:
        heavy_semaphore.release()

Такой семафор — грубый, но рабочий предохранитель: даже если лимит по RPS обойдён (запросы идут с разных IP), сервер физически не возьмёт в работу больше N тяжёлых задач одновременно.

Дополнительно стоит:

  • Ограничить входные параметры: максимальный диапазон дат, per_page, глубину пагинации, длину поисковой строки.
  • Вынести тяжёлые операции в очередь (Celery, Sidekiq, BullMQ) вместо синхронной обработки в HTTP-воркере — пользователь получает «отчёт формируется» сразу, воркеры не блокируются.
  • Индексировать то, что реально фильтруется и сортируется — банально, но именно отсутствие индекса чаще всего превращает лёгкий запрос в тяжёлый.

Общий механизм rate limiting, включая то, почему наивная настройка иногда бьёт по обычным пользователям за NAT сильнее, чем по атакующим, разобран в статье как работает rate limiting.

Что делать, если это уже происходит

Если сайт лежит, а RPS в логах выглядит смешным:

  1. Найдите виновный URL через awk/sort/uniq -c по логу nginx — займёт секунды.
  2. Проверьте активные запросы в БД (pg_stat_activity / SHOW PROCESSLIST) — десяток одинаковых долгих запросов подтвердит гипотезу.
  3. Временно зарежьте найденный эндпоинт: верните 503, пока разбираетесь, или задайте limit_req zone=heavy rate=1r/m — жёстко, но лучше, чем весь сайт лежит для всех.
  4. Добавьте кеш «на скорую руку»: даже грубый кеш на 30-60 секунд через Redis или proxy_cache снимает бóльшую часть остроты за минуты.
  5. После инцидента заведите постоянную защиту: индекс, кеш с нормальным TTL, лимит на параллельность, ограничения параметров — уже не в панике, а спокойно.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Если сайт держит десятки тысяч RPS без проблем — значит, дорогих эндпоинтов у меня нет?

Не обязательно. Высокий RPS на статике ничего не говорит про редко используемые, но тяжёлые пути — отчёты, экспорты, сложный поиск. Это разные профили нагрузки.

Можно ли найти слабые места одним общим нагрузочным тестом?

Частично — общий тест покажет деградацию под равномерной нагрузкой, но вряд ли зацепит редкие тяжёлые эндпоинты, потому что обычно ходит пропорционально по популярным страницам. Точечное тестирование дорогих операций — обязательное дополнение, а не замена.

Достаточно ли CDN вроде Cloudflare, чтобы закрыть эту проблему?

CDN и WAF хорошо защищают от объёмных атак на статику, но динамический или POST-запрос с уникальными параметрами обычно не кешируется на CDN и всё равно доходит до сервера. Это полезный дополнительный слой, но не замена собственному кешу и лимитам на уровне приложения.

Как понять, что у меня N+1, если я работаю через ORM?

Включите логирование SQL в дебаг-режиме (в Django — django-debug-toolbar, в Rails — bullet, в большинстве Node ORM — logging: true) и откройте страницу со списком из нескольких десятков элементов. Десятки одинаковых по структуре запросов вместо одного-двух — это она.

Стоит ли лимитировать по IP, если атака идёт с разных адресов?

Лимит по IP тут слабее, но семафор на уровне приложения ограничивает занятость ресурса независимо от источника — работает и при одном IP, и при тысяче разных.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →