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

Привет, Хабр! Меня зовут Сергей Черкашин, и я — руководитель команды по эксплуатации систем и защиты от DDoS-атак в Wildberries & Russ.

Только в начале 2025 года Cloudflare зафиксировал столько же атак, сколько за весь 2024-й:

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

О каком DDoS пойдёт речь?

Раз уж мы настраиваем Nginx под DDoS, определимся, от чего именно защищаемся. Существуют разные классификации DDoS-атак:

  • По уровню модели OCI;

  • По протоколам;

  • По используемым механизмам срабатывания.

Разные сервисы, обещающие защиту от DDoS, вводят свою классификацию. Давайте сделаем точно так же и введём свою.

  • По точке отказа.

Предлагаю разделить атаки на группы по тому, где именно срабатывает механизм атаки.

Виды DDoS-атак по точке отказа

Схематично изобразим нашу инфраструктуру следующим образом:

Предположим, Ваш условный сервис живёт в дата центре. Если Ваш pet-проект развёрнут на домашнем ПК, то мы считаем, что Вы тоже живёте в дата-центре =).

Итак, есть глобальная сеть, с которой дата-центр соединен неким сетевым каналом. Он подключен к border-роутеру. Далее трафик доходит до реверс-прокси, которым является наш Nginx. С него запрос уже балансируется на инстанс нашего бэкенда. Давайте исходить из того, что Nginx развёрнут на bare metal, а бэкенд может быть на чём угодно — на железе, в кубере, или чём-то ещё.

Есть несколько точек, которые могут быть атакованы:

  1. Канал

Волюметрические атаки забивают канал так, что легитимный трафик туда уже не вмещается.

2. Сетевое оборудование

Исчерпываются ресурсы сетевого оборудования как вашего, так и провайдера. Чаще всего такие атаки подразумевают большое количество фрагментированных пакетов.

Если каким-то образом получилось, что ваш свитч уже давно не торт, то он может не справиться с таким количеством входящих пакетов.

Но если ваш свитч — торт, то DDoS ему уже не страшен =)

3. Реверс-прокси, то есть Nginx

4.Бэкенд-приложение

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

Сегодня представим, что имеем власть только над точкой 3, то есть над инсталляцией Nginx. Поскольку у нас нет власти над точками 1 и 2, то организовать оборону там будут уже другие люди. Наша задача — выдержать атаку на точку 3 и не пропустить её дальше — в точку 4, то есть на бэкенд. Про другие угрозы сейчас говорить не будем.

С чем можно столкнуться на сервере Nginx? SYN Flood и SYN cookies

Начнём с легендарного SYN Flood, с которого стартуют все мамкины хакеры.

Атака нацелена на исчерпание пула памяти, который выделен под полуоткрытые соединения. Организовать такую атаку довольно просто. Каждый на своей кухне может провести её с помощью простого советского hping3:

hping3 -S --flood --rand-source -p 80 1.2.3.4

Эта программа позволит запустить SYN Flood пакетами, при этом подменяя в пакетах адрес источника на случайный.

Чтобы защититься, используют SYN куки.

Суть этих кук в том, что сервер на своей стороне не хранит информацию о полуоткрытом соединении, а делегирует это клиенту. Всю информацию он записывает в sequence number пакета SYN-ACK, отправляет его и забывает про то, что это произошло. Потом, когда от клиента приходит пакет с третьим рукопожатием, сервер снова вычисляет куку, сравнивает с тем, что пришло в ACK-номере. Проверяет время, чтобы не работать с просроченными куки.

Информация, которая нужна серверу:

  1. 5 бит — метка времени. Округлённое до 64 секунд время создания куки.

  2. 3 бита — кодированное значение MSS. Это одно из восьми возможных значений Maximum Segment Size.

  3. 24 бита — hash24bit (src addr, dst addr, src port, dst port, time, server secret). По ним сервер удостоверяется, что клиент ничего не подделал.

  4. MSS (Maximum Segment Size) — сколько байт в одном пакете, не считая заголовков. Но при создании соединения сервер не может не сохранить MSS, так как это обязательная часть протокола TCP, поэтому сохраняет его в куке. Правда, теперь там только 8 параметров, что не очень гибко. Зато работает.

А вот всевозможные опции сервер уже не может сохранить. Речь про TCP Options:

  • WSOPT — масштабирование окна приёма;

  • SACK — выборочное подтверждение;

  • TSOPT — поддержка Timestamp в пакетах;

  • ECN — возможность уведомления о перегрузке;

  • UTO — User timeout.

Особенно нас огорчает отсутствие масштабирования окна приёма. Получается, если соединение создаётся с помощью куки, то по производительности оно будет как TCP-соединение из 80-х. А если бы не куки, то и вовсе как из 50-х, то есть его бы уже просто не было.

Включение SYN cookies в sysctl выглядит так:

net.ipv4.tcp_syncookies = 1

Есть три возможных значения для этого параметра:

  • 0 — syncookies всегда выключены.

  • 1 — syncookies включаются только тогда, когда буфер полуоткрытых соединений переполнен (то, что нам нужно).

  • 2 — syncookies отправляются всегда, даже при нормальной нагрузке.

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

Размер буфера полуоткрытых соединений настраивается так:

net.ipv4.tcp_max_syn_backlog = 524288

Задать параметр sysctl можно как единоразово до перезагрузки системы, так и постоянно. Задавать конкретно этот параметр стоит исходя из того, сколько памяти у вас установлено. Одна запись о полуоткрытом соединении занимает примерно 256 байт, в зависимости от версии ядра. Предложенный вариант настройки займет от 128 до 256 МБ.

Некоторые советуют уменьшить количество попыток повторной отправки synack.

net.ipv4.tcp_synack_retries = 2 # default 5

В некоторых статьях предлагается оставить только одну попытку. Но поскольку мы можем себе позволить отправить лишних полмиллиона пакетов ради клиента, который потерял наш synack в какой-нибудь глухой деревне, мы оставили этот параметр равным 2.

TCP-connection exhaustion

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

Расход памяти на поддержание одного установленного TCP-соединения сильно зависит от настроек sysctl и версии ядра, но в целом это не так уж и много:

1 соединение = struct sock + min tcp_wmem + min tcp_rmem + доп. расходы (таймеры, хэш-таблицы) ≈ 12 КБ

100к соединений = 12 ГБ памяти

Все считают по-разному, в разных статьях пишут от 8 до 64 КБ, но я насчитал 12. На поддержание 100 тысяч соединений при таком раскладе мы тратим примерно 12 ГБ памяти. Казалось бы, не очень много, но это если у вас 256 ГБ оперативки.

  • net.ipv4.tcp_syn_retries = 2

  • net.ipv4.tcp_tw_reuse = 1

А вот если 8, то уже не поместится. А если при этом у вас настроены минимальные размеры буферов на чтение и запись больше 4 КБ, то расход памяти вырастет.

Давайте починим sysctl.

  1. Режем keepalive:

  • net.ipv4.tcp_keepalive_time = 60 # default: 7200 (2 hour)

  • net.ipv4.tcp_keepalive_probes = 6 # default: 9

  • net.ipv4.tcp_keepalive_intvl = 5 # default 75 seconds

Чтобы быстрее освобождать TCP-соединение после последнего пакета с данными, сократим время отправки первого keepalive-пакета до одной минуты, количество таких пакетов до 6, а интервал между ними сделаем до 5 секунд. Таким образом, время поддержания мёртвого TCP-соединения с 2 часов 11 минут уменьшится до 1,5 минут.

  1. Уменьшаем количество ретраев:

  • net.ipv4.tcp_synack_retries = 2 # default 5

  • net.ipv4.tcp_retries2 = 8 # default 15

  • net.ipv4.tcp_fin_timeout = 5 # default 60 seconds

Ещё можно сократить количество попыток повторной отправки synack до 2.

tcp_retries2 (вторая строка) — отвечает за количество повторных отправок пакета в случае, если не было подтверждено получение пакета.

tcp_fin_timeout задаёт время ожидания после того, как сервис решил закрыть соединение и ждёт того же самого от клиента.

  1. Оставляем дефолтные значения:

  • net.ipv4.tcp_rmem[0]

  • net.ipv4.tcp_wmem[0]

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

  1. (Offtop) Если много исходящих соединений: 

  • net.ipv4.tcp_syn_retries = 2 

  • net.ipv4.tcp_tw_reuse = 1

Если сервис создаёт много исходящих соединений, это не влияет на защиту от DDoS. В этом случае можно сократить количество попыток установить соединения (tcp_syn_retries) и включить переиспользование сокетов, которые находятся в состоянии time_wait. В результате, прежнее соединение закрылось, но мы на всякий случай ждём, вдруг ещё что-то прилетит. Можем переиспользовать этот сокет, если вдруг захотим открыть соединение с тем же адресом и портом.

На этом предлагаю закрыть тему тюнинга TCP и вообще L4, потому что самое интересное находится на пару уровней выше, например, на уровне HTTP Flood.

HTTP Flood

Современные сети — очень широкие. Хостинги готовы предоставить вам гигабитный канал, даже если вы арендуете виртуалку всего с  одним ядром CPU. Часто проще нагрузить процессор, чем сеть. Для этого особенно хорошо подходит достаточно эффективная и популярная на сегодняшний день атака HTTP Flood.

Посмотрим, как она работает.

1. Установка TCP-соединения

Чтобы выполнить HTTP-запрос, нужно установить TCP-соединение. Или QUIC-соединение, если включен HTTP/3. Конкретно в этом кейсе разницы почти нет. Поддержание соединения расходует ресурсы атакующего. Весь трафик, которым ответит сервер на запрос, прилетит атакующему. В результате, трафик забьёт его канал, загрузит сетевой стек и прочее. Фокус со спуфингом, когда атакующий подменяет свой IP-адрес, уже не сработает, потому что соединение тогда просто не установится.

Далее происходит подмена понятий. Мы ожидаем поток HTTP-запросов, а вместо этого нас закидывают HTTPS, потому что никто не хочет DDoS’ить 80-й порт, чтобы просто получить пачку 301-х response-кодов.

2. Установление TLS-сессии

После установления TCP-соединения происходит рукопожатие TLS. Сделаем лёгкий оффтоп и заглянем в эти дебри. Ведь несмотря на то, что мы сейчас говорим про тюнинг Nginx, и хотя это не поможет вам защититься от DDoS, но существенно ускорит связь с вашими настоящими клиентами. А это, я считаю, полезно.

TLS-сессия устанавливается по-разному в разных версиях протокола. Достаточно сказать, что TLS 1.3 на два шага быстрее, поэтому используйте её.

Рассмотрим, как она работает.

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

После второго шага, когда сервер передал свой сертификат, клиент должен этот сертификат проверить. Делается это банальной сверкой подписей всех полученных сертификатов по цепочке, начиная с корневого. Корневой сертификат у клиента должен быть изначально, таковы правила, а всё остальное — только что передал сервер. И вроде бы всё просто, но не совсем.

Может быть так, что сертификат был когда-то валиден, а теперь отозван. Существует несколько вариантов это проверить. Первый — загружать списки всех отозв��нных сертификатов.

  • CRL – Certificate Revocation List

Это была отличная идея, пока сайтов в сети было относительно немного. Но когда их количество перевалило за миллион, начались проблемы, потому что список отозванных сертификатов стал слишком раздуваться. В него стали записывать только самые часто посещаемые, а для всех остальных придумали OCSP.

  • OCSP – Online Certificate Status Protocol

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

Как вы понимаете, есть способ обойти эти минусы.

  • OCSP Stapling

Эта оптимизация TLS позволяет серверу самостоятельно заранее сходить и получить подтверждение, что сертификат хорош и валиден. Сервер получает условную справку, с печатью и заверенную, только не нотариусом, а центром сертификации. Сервер передаёт эту справку вместе с сертификатом клиенту. Клиенту достаточно проверить всё это на своей стороне. Это заметно ускоряет установление TLS-сессии.

В Nginx по умолчанию эта опция выключена.

Syntax: ssl_stapling on | off;
Default: ssl_stapling off;
Context: http, server

Настоятельно рекомендую её включить, если вы включили TLS 1.3. и ваша инфраструктура позволяет устанавливать исходящее соединение из Nginx.

Оптимизации TLS (offtop)

Есть ещё несколько расширений TLS 1.3, позволяющих быстрее начать обмен данными на L7, предварительно согласовав необходимое шифрование и аутентификацию на L6: 

1. OCSP Stapling

Некоторые из них включены в Nginx по умолчанию, например:

2. TLS False Start

Про неё рассказывать не буду, потому что в конфиге Nginx вы это расширение не сможете даже выключить.

Есть классный механизм возобновление TLS-сессии:

3. Session Cache & Session Tickets

Суть его понятна из названия. Nginx умеет хранить параметры сессии, и если клиент успел вернуться до того, как кэш протух и передал свой session_id, сессия возобновляется. Чтобы серверу не приходилось хранить кучу данных в кэше, в TLS-1.3 придумали Session Tickets. Суть та же, но теперь параметры возобновляемой сессии хранятся на стороне клиента. Nginx хранит кэш только для TLS-1.2.

Директива для включения тикетов выглядит так:

Syntax: ssl_session_tickets on | off;
Default: ssl_session_tickets on;
Context: http, server

Есть ещё несколько дополнительных директив, их можно найти самостоятельно в документации.

  1. Early Data

Оптимизация позволяет сразу отправлять все HTTP-данные при возобновлении сессии. Но есть минус. Если Man-in-the-Middle перехватит такой пакет, то может отправить его несколько раз, и каждый раз этот запрос будет выполняться. В целом для идемпотентных запросов так делать можно. Ну, исполнится несколько раз GET — не проблема. Но если там POST с оплатой какого-нибудь товара, будет неприятно.

Директива для включения выглядит так:

Syntax: ssl_early_data on | off;
Default: ssl_early_data off;
Context: http, server

Продолжаем оффтоп. Теперь я напомню про сжатие Accept-Encoding header — вдруг вы случайно забыли его включить.

Если клиент в заголовке запроса сообщает, что поддерживает сжатые ответы, стоит его поблагодарить, пожать ему руку, и контент ответа тоже пожать.

Gzip поддерживает все браузеры, но он не очень быстродействующий. Первый уровень сжатия уже даст сильную просадку по производительности.

Тут мы сжимали 44 КБ html, взятый с нашего сайта. Отправляли запросы с помощью wrk в один поток и 10 соединений, и получили в два раза меньше RPS по сравнению с выключенным сжатием. Объём трафика у нас тоже упал в 7 раз. В итоге, выгода в сжатии составила примерно в 3,5 раза.

А Brotli на уровне 1 нам почти не снизил RPS, но зато в 3,5 раза снизил объём передаваемого трафика. То есть степень сжатия такая же, а по производительности — гораздо выше.

Brotli поддерживает почти все современные сайты. Включайте, если хотите, включайте, даже если не хотите.

А ещё есть интересный алгоритм сжатия ZSTD. Он даёт примерно такую же производительность, как и Brotli.

Правда, в Nginx он считается экспериментальной фичей. Но кто я такой, чтобы запрещать вам попробовать.

И вот мы подобрались к точке, где HTTP Flood делает больнее всего.

3. Обработка запроса

Там может быть какая угодно нагрузка — бэкенд ходит в СУБД, проводит вычисления. Каким бы оптимизированным ни был ваш бэкенд, он все равно делает много работы. Атакующий всё равно не оценит результатов вашего труда и будет даже рад заставить вас поработать. Как же защищаться?

Самое банальное и первое, что приходит в голову — это ограничение RPS.

Никакой нормальный пользователь не будет делать более 100 запросов в секунду в большинстве ситуаций. А после этого ему нужно ещё несколько секунд, чтобы нажать на кнопочку на сайте или в приложении и выполнить еще пачку запросов. У нас получается всплескообразный трафик, и за это у нас отвечает опция burst в rate-limit. Мы можем настроить ограничение количества запросов, которое позволит выполнять запросы с всплесками, и при этом в среднем нормальному пользователю должно хватить.

limit_req_zone $binary_remote_addr zone=mylimit:10m rate=30r/s

server {
    location /api_with_auth/ {
        limit_req zone=mylimit burst=10 nodelay;
        proxy_pass http://my_upstream;
    }
}

О том, как всё это работает и настраивается, есть уйма статей. Я оставил ссылки на два источника:

https://blog.nginx.org/blog/rate-limiting-nginx

https://blog.rnds.pro/052-nginx-rate-limiting

Важен ещё один вопрос — как отличить одного пользователя от другого? 

По IP-адресу способ вполне рабочий, если бы не NAT. За одним IP-адресом может быть как один человек, так и тысяча. А самое обидное, если за одним адресом будет тысяча человек и один атакующий.

Согласитесь, не хотелось бы ограничивать столько легитимных пользователей из-за одного вредного бота. Следовательно, стоит подумать, как идентифицировать пользователей более гранулярно. Один из вариантов — по авторизационному токену.

limit_req_zone $http_your_auth_token zone=mylimit:10m rate=30r/s

server {
    location /api_with_auth/ {
        limit_req zone=mylimit burst=10 nodelay;
        proxy_pass http://my_upstream;
    }
}

Наш конфиг для rate-limit остался почти прежним. Но теперь в качестве ключа rate-limit мы используем не IP адрес, а авторизационный токен. Его можно взять, например, из заголовка authorization.

Но тут всплывают нюансы — что делать с неавторизованными клиентами? Ведь для того, чтобы зайти на сайт, им нужно сначала авторизоваться, а для того чтобы авторизоваться, нужно сначала зайти на сайт. В придачу, атакующий может накидать рандомных данных в тот самый ключ, по которому мы могли бы rate-limit’ить, а значит наш rate-limit не сработает.

 Очень часто в качестве авторизационного токена используется JWT.

limit_req_zone $http_authorization zone=mylimit:10m rate=30r/s
server {
    location /api_with_auth/ {
        auth_jwt_enabled on;
        auth_jwt_algorithm 'put_algo_here';
        auth_jwt_key 'put_key_here';
        auth_jwt_location 'HEADER=authorization';
        auth_jwt_redirect on;
        auth_jwt_loginurl 'put_login_url_here';
        limit_req zone=mylimit burst=10 nodelay;
        proxy_pass http://my_upstream;
    }
}

Подробнее здесь: https://github.com/TeslaGov/ngx-http-auth-jwt-module

Прелесть в том, что такой токен можно валидировать в Nginx. В таком случае, если токен подделан на клиенте или там есть рандомные данные, то Nginx сразу вернёт 403. А если валиден, то мы ограничиваем клиента по rate-limit, что в общем не решает проблемы с путями, где не требуется авторизация. Значит, нужно что-то ещё. Тут на выручку приходит TLS fingerprinting.

JA4 Fingerprint 

Клиент и сервер обмениваются настройками для шифрования. На основе этих данных строится fingerprint. Есть целый фреймворк для этого — JA4 Fingerprint.

JA4 Fingerprint создаётся во время установления TLS-сессии. В Fingerprint отражается информация о том, пришёл клиент по TCP или использовал QUIC, SNI сообщал доменное имя или IP-адрес, и пр. Основное — на него влияют наборы расширения и параметров шифрования. Всё это позволяет относительно уникально идентифицировать набор софта, который использовал клиент для установления TLS-сессии. То есть, если у вас есть два пользователя с Windows и Google Chrome последней версии, для них Fingerprint будет одинаковым. А вот Firefox уже будет выглядеть иначе.

В наших данных фигурирует около 3000 разных значений JA4, в среднем по два на IP-адрес, максимально до 50. Согласитесь, это не очень гранулярно идентифицирует клиента. Но вся прелесть в том, что и у легитимного пользователя и у атакующего чаще всего эти JA4 будут не совпадать, потому что никто не использует Google Chrome для генерации HTTP-флуда. Он так себе генерирует нагрузку на сервер, зато отлично генерирует нагрузку на клиента. Нам это на руку.

Теперь мы можем выставить rate-limit из тех же соображений, что и просто по IP-адресу, но теперь будем чуть более уверены, что атакующий не исчерпает лимиты обычных пользователей.

limit_req_zone $binary_remote_addr|$http_ssl_ja4

Включить всё это в Nginx не сложно, для этого есть специальный модуль, который вычисляет JA4 Fingerprint. Однако для того, чтобы он работал, придётся собрать Nginx из исходников, предварительно применив к ним патч. Подробности можно найти по ссылкам:

https://github.com/FoxIO-LLC/ja4/blob/main/technical_details/README.md

https://github.com/FoxIO-LLC/ja4-nginx-module

Но не всё так гладко с Rate-limit. 

Воркер Nginx представляет отдельные процессы, а зона rate-limit — это разделяемая память. Поскольку эту память нужно синхронизировать между процессами, там стоит мьютекс, что снижает производительность при обращении к ней. Для теста мы развернули Nginx на сервере с 96 ядрами, рядом в качестве upstream развернули ещё один такой же, который отдаёт 200 ОК прямо из памяти. Сам апстрим выдаёт больше 4 миллионов RPS.

Получается, что походов в upstream по сети мы можем сделать чуть больше, чем запросов с rate-limit без похода по сети в upstream. Но это всё равно стоит применять, потому, что вряд ли ваш upstream даёт 200 прямо из памяти. Скорее всего, там что-то более тяжелое, соответственно, производительность rate-limit будет гораздо выше, чем производительность вашего бэкенда.

Cache 

Все о нём знают, хотелось бы сказать, что все используют, но, увы, это не всегда так.

С точки зрения защиты от HTTP-флуда, кэш нас интересует в том аспекте, что запросы до приложения будут доходить крайне редко. Легитимный клиент и атакующий клиент будут получать ответы от кэширующего сервера. Мы, конечно, можем организовать кэширование прямо в бэкенде, но сейчас мы говорим про Nginx.

Разберёмся, как договориться с Nginx о том, чтобы он кэш��ровал ваш контент. Разумеется, всё это делается через заголовок Cache Control, который бэкенд должен добавить в ответ.

Простой пример заголовка, который скажет Nginx кэшировать содержимое на один час:

Cache-Control: public, max-age=3600

Если по каким-то соображениям кэширование на час для вас слишком много, то кэшируйте на 3 секунды, этого будет достаточно:

Cache-Control: public, max-age=3

Распространённый сценарий HTTP flood:

wrk -t96 -c10000 -d30s https://your.awesome.site/

Атакующий получит столько ответов, сколько способен выдать Nginx из кэша, а ваш бэкенд при этом ответит всего на один запрос (или на 10, если вы кэшировали на 3 секунды вместо одного часа).

Так сколько способен выдать кэш Nginx?

Как видно выше, производительность кэша ещё меньше, чем производительность rate-limiter. Тем не менее, чаще всего это всё равно лучше похода в бэкенд. Впрочем, вы можете сами сравнить производительность своего бэкенда и кэша, и выбрать то, что вам больше всего подходит. Моё дело лишь напомнить вам о такой возможности.

Теперь поговорим о метриках и о том, как их собирать.

Метрики

Метрики полезны. С их помощью мы можем узнать, как работает наш сервис, есть ли бутылочные горлышки, и прилетал ли DDoS. Готовых решений хватает, но мы с вами рассмотрим только два популярных решения.

VTS

Модуль: https://github.com/vozlt/nginx-module-vts

Дашборд: https://grafana.com/grafana/dashboards/9785-nginx-vts

В модуле VTS есть куча полезных метрик. Не буду рассказывать, как ими пользоваться, в документации всё описано. В целом всё стандартно: есть метрики, Prometheus, который их собирает и хранит, Grafana, рисующая красивые графики по этим метрикам, и несколько строк, которые вам нужно добавить в конфиг.

vhost_traffic_status_zone shared:vhost_traffic_status:128m;
vhost_traffic_status_filter_check_duplicate off;
vhost_traffic_status_limit_check_duplicate off;
vhost_traffic_status_limit off;
vhost_traffic_status_filter off;

Angie 

А ещё есть Angie, форк Nginx, о котором многие из вас слышали.

https://angie.software/

https://grafana.com/grafana/dashboards/20719-angie-dashboard/

Тут даже модуль подключать не нужно, все метрики работают из коробки. В целом, метрики почти те же самые, кроме времени ответа upstream. Это очень полезный показатель, и в целом кажется, что VTS на одну метрику приятнее и поэтому выигрывает. Но есть нюанс...

Модуль VTS так сильно снижает производительность, что мы решили от него отказаться полностью, даже несмотря на то, что время ответа upstream для нас действительно важно.

Алерты

Самый простой способ узнать о том, что прилетел DDoS, — сделать в Grafana алерт на превышение количества запросов.

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

Метрики — это хорошо. Метрики - это надёжно. Но что, если вы хотите узнать о векторе атаки подробнее, например, о том, какие IP-адреса участвовали в атаке, какие юзер-агенты использовались, на какие пути обращались и т.д. и т.п. Полезно узнать, что вектор атаки пришелся, например, на конкретную ручку API, которая оказалась не настолько производительной, как все остальные. Для этого нам пригодится Access log.

Access log 

В спорах том, как собирать логи, сломано множество копий. Кто-то предпочитает ELK stack, кто-то его производную OpenSearch, кто-то связку Vector – ClickHouse – Grafana, кто-то — самописные решения.

Я считаю, что до тех пор, пока у вас сравнительно небольшая нагрузка, вам подойдёт любое решение. Но помните, что во время DDoS вы всё равно не сохраните 100% логов, разве что если не зададитесь этой целью специально.

Мы задались. Поэтому используем ClickHouse для хранения логов. Он отлично их сжимает и на нём быстро работают сложные агрегационные аналитические запросы. Для отображения графиков по этим запросам мы используем Grafana. А для доставки логов от Nginx до ClickHouse мы использовали самописный коллектор логов. Тут столкнулись с несколькими проблемами:

  • Если разворачивать коллектор на одном сервере с Nginx, это забирает ценнейшие ресурсы CPU, особенно во время атаки. Нам это не подходит.

  • Если разворачивать коллектор на другом сервере, то нужно как-то доставлять логи от Nginx до коллектора, а это уже забивает сетевой канал. Тем более, что access log в несжатом виде часто висит в байтах больше, чем сам HTTP-запрос.

Мы просто обслуживаем трафик через один сетевой канал, а отправляем логи через второй — и волки сыты, и все «овцы» долетают до коллектора.

Одна из причин, почему мы используем самописный коллектор вместо Vector —  на момент написания этого доклада у него не было возможности масштабировать чтение из UDP, используя опцию reuseport. Vector создан, чтобы использоваться в Кубере и масштабируется его средствами, а нам такое не подходит по субъективным причинам.

Мы уделили столько внимания access logs, потому что на них строится наша защита от DDoS-атак. У нас сложные аналитические сервисы. А в этой статье мы говорим о том, как сделать всё быстро, дешево и на коленке. 

Что можно сделать, используя access logs? Например, посмотреть в них, кто нас атакует. А потом забанить его на Nginx.

geo $blocked_ip {
    default 0;
    include /etc/nginx/blocked_ips.conf; # Список IP: `1.2.3.4 1;`
}
# Чёрный список User-Agent (регулярное выражение)
map $http_user_agent $blocked_ua {
    default 0;
    "~*(bot|crawler|spider|scraper|python|curl|wget)" 1; # Любой User-Agent с этими словами
}
# Блокировка по IP или User-Agent
if ($blocked_ip) {
    return 403;
}
if ($blocked_ua) {
    return 403;
}

Если прилетает алерт, то человек, который занимается обслуживанием всего ресурса, тут же может полезть в access logs, сделав SQL-запрос — из ClickHouse (это удобно). Если он находит IP-адреса и юзер-агенты, которые ведут себя плохо, то вписывает их в конфигу. Да, это не очень оперативно, и не супернадёжно, но лучше иметь хоть что-то, чем не иметь ничего, потому что не все могут себе позволить супер-классную защиту от Cloudflare.

Итак, мы разобрались, как сделать защиту от DDoS на коленке и бесплатно. Кэш и rate-limit вам в помощь. Если хотите что-то более надёжное, придётся либо потратить больше времени, либо подключить фильтрацию у поставщиков.

Скрытый текст

Обменяться опытом и узнать больше о масштабировании и высоконагруженных системах можно на следующем SaintHighload++ 2026 в июне. Регистрируйтесь на конференции и присоединяйтесь к сообществу профессионалов!

Комментарии (2)


  1. evgeniymx
    17.11.2025 09:13

    Про VTS стоит отметить то, что чем больше метрик он пишет, тем сильнее идет оверхед по памяти и процессору, и 1 воркер nginx может начать кушать до 10гб памяти очень легко


  1. korn3r
    17.11.2025 09:13

    Спасибо. Информативно.

    OCSP Stapling сам хотел себе включить, но оказалось что LetsEncrypt отключил такую возможность для своих сертификатов.