Две недели в hot path: −40% CPU на запрос и p99 с 547 до 4,7 мс
Что мы нашли, когда профилировали путь HTTP-запроса, почему криптография оказалась ни при чём и как заблокированный трафик стал обрабатываться в пять раз быстрее.
Последние две недели мы не выпускали новых функций. Вместо этого разбирали hot path обработки веб-трафика и убирали из него всё, за что процессор платил зря. Цель была простая: сделать каждый HTTP-запрос дешевле, получить запас по производительности на той же инфраструктуре и, главное, добиться предсказуемого поведения под атакой, а не только на спокойном трафике.
Изменения затронули почти всю цепочку: конфигурацию сайтов, движок правил, fingerprinting, детектирование ботов, логирование, агрегацию событий и доставку оперативных блокировок. Итог в цифрах: пропускная способность на отдельных сценариях выросла на 30–70%, заблокированный трафик обрабатывается до пяти раз быстрее, а p99 самого тяжёлого сценария упал с 547 мс до 4,7 мс. Результат фильтрации при этом не изменился ни в одном тесте — это было условием для каждой правки.
Сначала профилировать, потом трогать
До начала работ полный путь одного запроса стоил около 80 мкс CPU, с логированием — порядка 100 мкс. Для сравнения: базовая обработка запроса веб-сервером без нашей логики — около 15 мкс. То есть основная цена была не в самом сервере, а во всём, что мы навесили вокруг: правила, построение контекста запроса, fingerprinting, логи, агрегация.
Первая гипотеза была очевидной — виновата криптография внутри антибот-проверок. Профилировщик её не подтвердил: на криптографические операции уходило меньше 0,3% времени. Дорогими оказались вещи, которые по отдельности выглядят бесплатными: работа со строками, временные структуры, обращения к переменным запроса, сборка мусора.
Это, пожалуй, главный вывод всей серии. В нагруженном L7-сервисе редко есть одна функция, которую можно переписать и получить двукратный прирост. Производительность утекает через десятки мелких операций, которые выполняются на каждом запросе, и собирать её приходится по частям.
Основной путь запроса
Не делать работу, которая не нужна. Часть конфигурации раньше вычислялась на каждом запросе независимо от того, использует ли сайт соответствующую функцию. Похожая история была в движке правил: контекст запроса собирался ещё до того, как выяснялось, что для этого сайта правил нет вообще. Мы добавили кеш подготовленной конфигурации и ранние выходы. На сайтах без активных правил стоимость обработки снизилась на 15–20%, пропускная способность выросла почти на 20%. Архитектурно правка крошечная, но на большом потоке разница между «проверить один флаг» и «собрать несколько структур и выбросить» видна сразу.
Известные боты без линейного перебора. Проверка IP-диапазонов делала заметно больше последовательных сравнений, чем нужно. Мы заменили этот путь на структуру поиска по префиксам и сократили число дополнительных ASN-проверок при промахе. Детектирование известных ботов стало дешевле на 15–17%, пропускная способность соответствующих сценариев выросла примерно на 20%. Заодно нашли избыточное логирование успешных совпадений и вынесли его из hot path.
Меньше мелких аллокаций. В одном из антибот-компонентов убрали промежуточную структуру запроса и лишнюю строку для одной проверки — около сотни байт временных данных на запрос. По CPU разница в пределах погрешности, и мы это честно признаём. Но чем меньше короткоживущих объектов в hot path, тем меньше давление на аллокатор и сборщик мусора на длинной дистанции, поэтому такие правки мы делаем всё равно.
После нескольких итераций мы повторили A/B-замеры. Стоимость основной обработки запроса снизилась на 25–40% в зависимости от сложности конфигурации; на самом насыщенном правилами профиле — со 100 до 60 мкс. Пропускная способность выросла на 40% для простых запросов, на 30% при небольшом наборе правил, на 50% в сценариях с детектированием ботов и до 70% на одном из самых тяжёлых профилей обработки правил. Функциональную эквивалентность проверили на нескольких тысячах тестовых случаев с разными действиями правил и bot detection — расхождений нет.
Логирование: дёшево на обычном трафике, дорого под атакой
Когда основной путь подешевел, стало хорошо видно, сколько CPU уходит уже после ответа — на обработку события. Часть нормализации логов выполнялась внутри Vector, и под большим потоком этот transform стоил около 37 мкс на событие. Мы перенесли простые операции ближе к месту формирования события: преобразование timestamp, сборку URL, нормализацию времени ответа и статуса. Vector получил уже готовый поток, и стоимость события упала с 37 до 16 мкс. Раньше при пиковой нагрузке часть событий терялась на переполненных очередях; после изменений счётчики drop и backpressure держатся на нуле. Попутно поймали редкий баг: отрицательное время ответа в одной записи приводило к отклонению целого батча в ClickHouse.
Дальше мы сделали то, что стоило сделать раньше: перестали мерить логгер только на нормальном трафике. На обычных запросах логирование выглядело недорогим — 10–15 мкс. Но когда мы воспроизвели профиль, похожий на настоящую атаку, — много заблокированных запросов с постоянно меняющимися IP, — картина изменилась радикально. Старая агрегация периодически проходила по большой общей таблице состояний, синхронно и под общим lock. При десятках тысяч записей время обработки запроса выросло с 50 до 110 мкс, пропускная способность упала примерно на 70%, а p99 дошёл до 547 мс. Почти треть времени запрос ждал доступа к общей структуре, а не обрабатывался. Код, который на спокойном трафике не виден в профиле, во время атаки может стать самой дорогой частью системы.
Горячую часть логирования — сериализацию, framing и передачу событий — мы вынесли в отдельный компонент на Rust. В микробенчмарке операция подешевела с 5 до 2 мкс на событие; в полном пути запроса выигрыш скромнее, около 40% CPU, приходившегося на сериализацию. На сценариях с большим числом уникальных заблокированных IP пропускная способность выросла ещё на 30–50%. Важнее другое: в стресс-тесте, где старая реализация начинала создавать backpressure и не успевала отдавать большую часть потока, новая обработала его целиком.
Следом в тот же компонент переехала агрегация массовых 403 и 429. Новая схема не делает глобальных сканов и не заставляет request path конкурировать за один lock: агрегация локальная, а память ограничена заранее заданным бюджетом. На синтетическом ботнете с уникальными IP для 403-сценария стоимость упала со 124 до 74 мкс на запрос, p99 — с 617 до 7,8 мс, пропускная способность выросла более чем вдвое. На другом профиле 403-трафика получили примерно −40% CPU и те же 2× по пропускной способности, для 429 — около −30% CPU и почти 2×. Переполнение, когда уникальных источников становится очень много, теперь обрабатывается за 37 мкс на запрос при p99 в 1–2 мс. Счётчики и метаданные совпадают со старой реализацией.
Последняя находка в лог-фазе была неожиданной: получение переменных запроса по одной. Каждый lookup стоит меньше микросекунды, но их несколько десятков на каждый логируемый запрос, и в сумме набегало около 20 мкс. Мы написали небольшой нативный модуль на C, который забирает нужные поля одним вызовом. Полный путь на обычном логируемом трафике подешевел с 60 до 40 мкс, на отдельных сценариях пропускная способность выросла более чем на 50%. Сверяли старую и новую реализацию на HTTP/1.0, HTTP/1.1 и HTTP/2, разных кодах ответа, proxy- и cache-сценариях, длинных User-Agent и query string — расхождений не нашли.
Если сложить всю серию: обычный логируемый запрос стал дешевле примерно на 40%. На заблокированном ботнете стоимость упала со 110 до 36 мкс на запрос, пропускная способность выросла в пять раз, а p99 — с 547 до 4,7 мс, то есть больше чем на два порядка. Для нас это важнее, чем цифра RPS: система защиты должна держать предсказуемое время обработки именно тогда, когда атакующий намеренно создаёт худший профиль нагрузки.
Путь блокировок
Последним крупным блоком стала доставка оперативных блокировок. Старая реализация появилась давно и со временем превратилась в многоступенчатую цепочку с несколькими переходами через userspace и Python-сервисом посередине. На обычной нагрузке она работала, проблемы начинались на всплесках.
В тесте со 100 000 уникальных IP старый pipeline терял более 80% входящих событий. Новый сервис на Rust обработал весь поток без потерь, а CPU-время демона при крупной серии блокировок снизилось в 4,6 раза. Ещё показательнее поведение при занятом процессоре: когда ядра были загружены основным HTTP-трафиком, задержка старого сервиса могла превышать 100 мс, у нового p95 держался около 15 мс. Путь блокировки практически перестал зависеть от загрузки слоя обработки запросов. Самые горячие блокировки теперь записываются напрямую через XDP/eBPF, а состояние, нужное другим компонентам, синхронизируется через Redis.
Что показала реальная атака
Во время одной из атак на защищаемый проект мы увидели проблему, которую синтетические тесты почти не показывали. Большая часть вредоносных запросов приходила с IP, которые уже были распознаны и заблокированы локально, но открытые HTTP/2-соединения позволяли источнику продолжать слать запросы и после решения о блокировке. На отдельных источниках счёт запросов после первого срабатывания шёл на десятки тысяч.
Мы поменяли несколько частей логики одновременно: эскалация блокировки больше не зависит от текущего измерения RPS сайта; время жизни долгих HTTP/2-соединений ограничено жёстче; оценка нагрузки не опирается только на неполную текущую секунду; массовые события раннего отклонения агрегируются, а не сериализуются по одному. Конкретные пороги и параметры мы намеренно не публикуем. Принцип простой: как только система уверенно определила источник как атакующий, каждый его следующий запрос должен стоить нам как можно меньше.
Что получилось
| Компонент | До | После |
|---|---|---|
| Основной путь, насыщенный правилами профиль | 100 мкс/запрос | 60 мкс/запрос |
| Пропускная способность по сценариям | — | +30…70% |
| Vector, одно событие | 37 мкс | 16 мкс |
| Логирование заблокированного ботнета | 110 мкс, p99 547 мс | 36 мкс, p99 4,7 мс |
| Агрегация 403, уникальные IP | 124 мкс, p99 617 мс | 74 мкс, p99 7,8 мс |
| Блокировки, burst 100 000 IP | потери >80% | потери 0% |
| Блокировки, задержка под нагрузкой | >100 мс | p95 ≈ 15 мс |
Для типового сложного логируемого запроса отдельные измерения дают снижение CPU примерно вдвое. Здесь нужна оговорка: эта цифра получена сложением результатов нескольких независимых A/B-серий, а не одним сквозным тестом, поэтому мы считаем её оценкой, а не точным результатом.
В DDoS-защите производительность — это не «больше запросов в секунду». Каждая лишняя микросекунда в hot path — это ядра, которые во время атаки приходится добирать инфраструктурой: 20 мкс на запрос при большом постоянном потоке превращаются в несколько полностью занятых CPU. Но важнее другое. После этих двух недель в системе стало заметно меньше мест, где нагрузка могла ухудшать поведение нелинейно: глобальные сканы, конкуренция за общее состояние, backpressure логов, зависимость сервиса блокировок от загрузки HTTP-слоя. Именно такие места обычно превращают высокую нагрузку в каскадную деградацию.
Часть компонентов мы переписали на Rust и C, часть алгоритмов переработали, а некоторые операции просто перестали выполнять там, где они не нужны. Самая быстрая инструкция процессора — та, которую не пришлось выполнять. Вопросы по этой работе можно задать в нашем Telegram-канале, а следить за состоянием своих проектов — в рабочем пространстве.