MAATRIX / Блог / DDoS на API: почему первой падает база, а не канал

DDoS на API: почему первой падает база, а не канал

MAATRIX

Дежурный смотрит на графики трафика и видит норму: канал загружен на 10-15%, ни намёка на волюметрическую атаку, никаких признаков забитого аплинка. А API при этом не отвечает — RPS на публичных эндпоинтах упал до нуля, в логах приложения сплошные таймауты, пул соединений к базе исчерпан. Это не парадокс: атака на прикладной уровень не обязана быть объёмной. Ей достаточно бить в дорогие операции — сложные выборки, агрегации, генерацию отчётов, — и десятков запросов в секунду хватает, чтобы уложить бэкенд, пока сеть отдыхает. Разберём этот класс атак, как заранее найти свои дорогие эндпоинты и что от этого реально защищает.

Почему канал свободен, а бэкенд уже лежит

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

Атака на API работает иначе. HTTP-запрос к /api/report?from=2026-01-01&to=2026-08-31 весит от силы пару сотен байт вместе с заголовками. Тысяча таких запросов в секунду — это доли мегабита трафика, ваш канал на 100 Мбит/с или 1 Гбит/с их даже не заметит. Но если за этим запросом стоит агрегация по миллионам строк без подходящего индекса, каждый такой запрос может занимать ядро процессора базы на секунды. Десять параллельных клиентов, честно дёргающих такой эндпоинт раз в секунду, способны занять весь пул соединений и все ядра СУБД — и это без единого признака атаки на сетевом уровне.

Ключевая асимметрия в том, что стоимость запроса для атакующего и для вас — не одно и то же число. Отправить HTTP-запрос стоит атакующему считанные миллисекунды CPU-времени на его стороне и почти ничего в трафике. Обработать этот запрос может стоить вам секунды работы БД, диска, памяти. Коэффициент усиления (amplification) здесь измеряется не в байтах, как при DNS/NTP-амплификации, а в отношении «время атакующего к времени сервера» — и оно может быть 1 к 100 или 1 к 1000 на одном неудачно спроектированном эндпоинте.

Из чего складывается цена одного запроса к API

Прежде чем защищаться, полезно честно разложить, откуда берётся дороговизна конкретного запроса. Обычно это одна или несколько причин:

  • Тяжёлый запрос к БД: JOIN больших таблиц без индексов на условиях соединения, GROUP BY/агрегация по всей таблице, LIKE '%слово%' без полнотекстового индекса, сортировка большого результата в памяти.
  • N+1 запросы: эндпоинт возвращает список из 50 элементов, а на каждый делает отдельный запрос к БД — 50 round-trip'ов вместо одного джойна, классика ORM без eager loading.
  • Пагинация без разумных границ: клиент указывает ?limit=100000 или ?page=99999 с OFFSET, который заставляет БД пересчитать и отбросить сотни тысяч строк.
  • Внешние вызовы: эндпоинт дергает платёжный шлюз, геокодер или другой API на каждый вызов — задержка чужого сервиса становится вашей проблемой.
  • Вычисления на лету: генерация PDF/Excel-отчёта, ресайз изображений, хеширование с высоким cost-фактором (bcrypt/argon2), рендер графиков.
  • Блокировки: запрос держит транзакцию открытой дольше необходимого и блокирует другие запросы — под нагрузкой это множит эффект в разы (подробнее о механике таких заторов — в разборе блокировок в базе).

Обычно один-два эндпоинта в приложении дают 80% совокупной стоимости запроса к серверу — это подтверждается почти на любом проекте, который профилировали хоть раз. Найти их до атаки — отдельная задача, и именно ей посвящена бо́льшая часть этой статьи.

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

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

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

Low-and-slow: почему стандартная защита от DDoS не срабатывает

Термин «low and slow» изначально относился к атакам вроде Slowloris — медленное открытие множества TCP-соединений, которые никогда не закрываются, что исчерпывает пул воркеров веб-сервера. Класс атак на дорогие операции — концептуально то же самое, только на уровень выше: атакующему не нужен объём, ему нужна низкая, но постоянная частота запросов к самым дорогим точкам API.

Проблема в том, что почти вся инфраструктура защиты от DDoS настроена на детекцию по объёму: порогам pps (пакетов в секунду) и полосы в bit/s. Scrubbing-центр провайдера или CDN отфильтрует волюметрическую атаку и SYN-флуд, но 20 запросов в секунду на /api/export с валидными заголовками, честным TCP-хендшейком и настоящим TLS выглядят для сетевого уровня совершенно легитимно — потому что они и есть легитимный трафик по форме, просто вредоносный по содержанию своей стоимости.

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

Профилирование: как найти дорогие эндпоинты заранее

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

1. Статистика самой БД. Для PostgreSQL — расширение pg_stat_statements, которое агрегирует все выполненные запросы и их стоимость:

# postgresql.conf
shared_preload_libraries = 'pg_stat_statements'
pg_stat_statements.track = all

После перезапуска и CREATE EXTENSION pg_stat_statements; можно вытащить топ по суммарному времени:

SELECT query, calls, total_exec_time, mean_exec_time, rows
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;

Здесь важна именно total_exec_time (суммарное время), а не mean_exec_time (среднее) — запрос с mean 5 мс, но 2 миллионами вызовов в час, съедает больше ресурсов, чем запрос с mean 500 мс и 100 вызовами. Для MySQL аналог — slow_query_log и performance_schema.events_statements_summary_by_digest.

2. EXPLAIN (ANALYZE, BUFFERS) на подозрительных запросах — покажет, читает ли запрос данные через индекс или сканирует таблицу целиком (Seq Scan на большой таблице — почти всегда тревожный сигнал), сколько буферов реально прочитано с диска, а не из кэша.

3. Профилирование приложения на проде — время в БД в общем времени ответа не равно 100%, часть стоимости может сидеть в сериализации, шаблонизации, внешних вызовах. Это отдельная дисциплина со своими инструментами и нюансами, подробно разобранная в статье про профилирование приложения на проде — там про то, как замерять без искажения самого измеряемого процесса.

Результат этой работы — простая таблица-карта стоимости эндпоинтов, которая дальше ложится в основу лимитов:

КатегорияТипичное время ответаПримерыЧто делать
Дешёвые< 20 мсhealth-check, чтение по первичному ключу, статикалимитировать мягко или не лимитировать
Средние20-150 мссписок с фильтром по индексу, простой JOINстандартный per-IP лимит
Дорогие> 150 мс или внешний вызовотчёты, полнотекстовый поиск, экспорт, агрегацииотдельная строгая квота, кэш, часто — только для авторизованных

Ориентиры по времени условны и зависят от вашего железа и данных — на своём проекте эти границы стоит выставить по фактическому профилю, а не копировать чужие цифры.

Rate limiting не на весь API, а на дорогие операции

Общая механика rate limiting — счётчик запросов на источник за окно времени — подробно разобрана в статье как работает rate limiting, включая её главный подвох: наивный лимит по IP бьёт по офисам за NAT сильнее, чем по ботнету. Для защиты от атак на дорогие операции к этой механике добавляется ключевая идея: лимит должен быть не единым на весь API, а привязанным к стоимости конкретного эндпоинта.

На уровне nginx это делается разными зонами limit_req_zone под разные группы маршрутов:

# дешёвые и средние эндпоинты — щедрый лимит
limit_req_zone $binary_remote_addr zone=api_cheap:10m rate=20r/s;

# дорогие эндпоинты — жёсткий лимит
limit_req_zone $binary_remote_addr zone=api_expensive:10m rate=2r/s;

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

    location /api/reports/export {
        limit_req zone=api_expensive burst=3 nodelay;
        proxy_pass http://backend;
    }
}

Это первый рубеж, но у него есть ограничение: счётчик локален для одного nginx-воркера. При нескольких инстансах приложения за балансировщиком точный общий лимит нужно считать в общем хранилище — обычно в Redis, через token bucket или sliding window. На уровне приложения это middleware, который перед дорогим хендлером проверяет и списывает «бюджет» у ключа user_id или ip:

// пример на express + rate-limiter-flexible (Redis-backed)
const { RateLimiterRedis } = require('rate-limiter-flexible');

const expensiveLimiter = new RateLimiterRedis({
  storeClient: redisClient,
  keyPrefix: 'rl_expensive',
  points: 5,       // 5 дорогих операций
  duration: 60,     // за 60 секунд
});

app.get('/api/reports/export', async (req, res, next) => {
  try {
    await expensiveLimiter.consume(req.user?.id || req.ip);
    next();
  } catch {
    res.status(429).json({ error: 'Слишком много запросов к дорогому эндпоинту' });
  }
});

Более зрелый вариант — лимитировать не по числу запросов, а по «очкам стоимости»: каждому эндпоинту присваивается вес (1 очко для чтения, 20 для отчёта), и у клиента есть общий бюджет очков в минуту вместо отдельного счётчика на каждый маршрут. Точнее отражает реальную нагрузку, но требует держать карту весов актуальной — то есть возвращает к профилированию из предыдущего раздела.

Кэширование результатов: снизить цену повторного запроса

Если дорогой запрос идёт с одинаковыми или похожими параметрами многократно — а на атаках это почти всегда так, потому что скрипту проще долбить один и тот же URL, чем изобретать разнообразие, — кэш снимает нагрузку эффективнее любого лимита, потому что вообще не даёт запросу дойти до БД.

Схема cache-aside на Redis для дорогого эндпоинта:

async function getReport(params) {
  const key = `report:${hashParams(params)}`;
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);

  const result = await db.heavyAggregationQuery(params);
  await redis.set(key, JSON.stringify(result), 'EX', 300); // TTL 5 минут
  return result;
}

Здесь есть классическая ловушка: когда TTL популярного ключа истекает, несколько параллельных запросов одновременно видят промах кэша и одновременно бьют в БД тем же тяжёлым запросом — эффект стада (cache stampede), который атакующий может провоцировать намеренно, синхронизируя запросы к моменту истечения TTL. Рабочие способы его избежать — блокировка на пересчёт (single-flight), джиттер в TTL, отдача устаревшего значения на время пересчёта (stale-while-revalidate) — разобраны в статье кэш прогрелся и уронил базу.

Важное ограничение: не всё стоит кэшировать бездумно. Персонализированные данные требуют аккуратного построения ключа (иначе один пользователь может увидеть кэш другого), а платёжные и мутирующие операции кэшировать вообще нельзя. Кэш закрывает дыру для read-тяжёлых дорогих эндпоинтов — отчётов, поиска, агрегаций, витрин — и именно они чаще всего и становятся целью атаки, потому что не требуют аутентификации и доступны любому.

Отдельные лимиты для anonymous и authenticated запросов

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

Практический подход:

  • Анонимные запросы к дорогим эндпоинтам — либо не разрешать вовсе (если бизнес-логика позволяет закрыть отчёты/экспорт авторизацией — это часто самое дешёвое и надёжное решение), либо давать очень скромную квоту по IP с коротким окном.
  • Авторизованные запросы — лимит по user_id/api_key, а не по IP (за одним корпоративным IP могут сидеть сотни легитимных сотрудников). Квота может быть заметно щедрее, потому что злоупотребление конкретным аккаунтом обратимо — можно понизить лимит точечно или заблокировать ключ, не задевая остальных.
  • Разные лимиты по тарифному плану — если у вас есть платные тарифы, для дорогих операций это естественная точка монетизации: бесплатный план — 10 отчётов в час, платный — 500. Это одновременно защита и продуктовое решение.

На уровне API-шлюза это обычно ветвление по наличию и типу токена ещё до основного обработчика:

app.use('/api/reports', (req, res, next) => {
  const limiter = req.user ? authenticatedLimiter : anonymousLimiter;
  const key = req.user ? req.user.id : req.ip;
  limiter.consume(key).then(() => next()).catch(() => res.sendStatus(429));
});

Такое разделение решает и побочную проблему обычного per-IP лимитирования, которую разбирает статья как работает rate limiting: единый жёсткий лимит по IP наказывает целый офис за NAT сильнее, чем распределённого атакующего с пулом адресов. Разделив анонимный и авторизованный трафик, вы получаете лимит, соразмерный реальной идентичности запроса, а не случайной сетевой топологии клиента.

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

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

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

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

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

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

Разве DDoS — это не всегда про полосу канала?

Нет, это только один из уровней атак — волюметрический (L3/L4). Атаки на прикладной уровень (L7) бьют по вычислительной стоимости обработки запроса и могут укладывать сервис при трафике, который канал даже не заметит.

Как быстро понять на дежурстве, что атака идёт именно на дорогие операции, а не объёмная?

Смотрите не на график трафика на границе сети, а на метрики приложения и БД: загрузку CPU базы, длину очереди соединений, время ответа по конкретным маршрутам. Если канал свободен, а БД в потолке — это прикладной уровень.

Можно ли просто поставить общий rate limit на весь API и не разбираться дальше?

Можно, но это либо слишком жёстко для дешёвых эндпоинтов, либо слишком мягко для дорогих. Лимит, привязанный к измеренной стоимости конкретного маршрута, эффективнее одинакового порога на всё.

WAF или CDN перед сервером спасёт от такой атаки?

Частично. WAF отфильтрует явно вредоносные паттерны и известных ботов, CDN закэширует статику. Но легитимный по форме запрос к дорогому эндпоинту с валидной сессией они пропустят — здесь решают лимиты и кэш на уровне приложения, а не периметр.

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

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

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