Мой обеденный кофе прервался. Начали приходить уведомления от мониторинга, что сайт и API не отвечают, а CloudFlare отдаёт 521-ю ошибку на все запросы. Спустя пять минут ко мне в личку пришли пользователи с жалобами на неработающие приложения. А ещё спустя пять позвонил сооснователь проекта и сказал, что от нас требуют $250 за остановку DDOS'a.
Ниже расскажу, как мы командой решали проблему, какие ошибки допустил я и чем всё закончилось.
Содержание:
-
Ошибка 1: слишком положился на Cloudflare и не настроил rate limit на уровне сервера
-
Ошибка 2: SSR запросы ходили в API через домен (а не локальную сеть)
-
Ошибка 3: GitLab находился на том же домене, что и основная система без white list'a
Что за проект?
Я отвечаю за разработку No Code платформы для создания Telegram Mini App'ов. У нас есть frontend на NextJS, backend на Java и Go. В качестве reverse proxy мы используем Traefik, а для балансировки нагрузки, SSL и DDOS защиты — CloudFlare. Всё это мониторится Prometheus'ом и выводится в Grafana.
У нас стоит несколько серверов за защитой от CloudFlare. На каждом сервере своя копия frontend, backend и Traefik'a. Запущено несколько серверов для исключения единой точки отказа и для запаса в х10-х15 по вертикальному масштабированию.
Kubernetes пока что не используем, потому что нет (точнее не было) регулярных пиков нагрузки и хватает ручного масштабирования.
Всё это в облаке, включая базу данных и кэш.
Схематично проект выглядит вот так:

API обслуживает:
-
личный кабинет (конструктор);
-
приложения клиентов (~95% нагрузки);
-
веб-хуки от Telegram'a;
-
веб-хуки от платёжных систем;
Из других важных моментов для понимания контекста:
-
Проекту ~7 месяцев.
-
Средний MAU (monthly active users) системы ~125 000 человек.
-
Постоянная команда разработки 5 человек (middle-senior), включая меня.
-
За CI CD и администрирование отвечаю я. DevOps'а у нас нет, потому что инфраструктурных задач очень мало (да и мы все-таки стартап на грани самоокупаемости).
-
Моя специализация — это full-stack разработка (и управление разработкой). С администрированием серверов дружу на уровне стандартного разработчика.
Для ориентира: настрою фаерволл по гайдам, заведу self hosted GitLab и CI CD, напишу bash-скрипты для сбора метрик, напечатаю конфиг Nginx'a по памяти. Но вот настраивать права доступа или точечно фильтровать трафик я буду долго.
-
Во время атаки решение проблемы искали всей командой. При этом были в довольно сильном стрессе и от самой атаки, и от града сообщений пользователей (как в публичном чате, так и в личках).
-
С DDOS'ом никто в нашей команде раньше не сталкивался.
Теперь, думаю, есть примерное понимание, какая у нас архитектура и контекст проекта. Если интересно, другие детали о развитии этого проекта и в целом о разработке я пишу в своем Telegram-канале.
Как появилась проблема?
Собственно, всё то, что описано в начале статьи началось с уведомлений об отказе API и недоступности страниц фронта:

Я сразу побежал в Grafana и увидел такую картину:

Обратите внимание: графики загрузки CPU и RAM с пробелами. В этот момент мониторинг прерывается, потому что сервера отказывают (причём сразу все). И я, разумеется, сразу пытался их перезагрузить (синие стрелки), что помогало на ~1-2 минуты.
Затем мне пересылают следующие сообщения:


Становится понятно: нас решили тактично пошантажировать с формулировкой "помощи в устранении уязвимостей". Вести переговоры мы были не готовы, потому что нет гарантий, что это поможет. Точнее наоборот мотивирует и дальше так делать с другими проектами.
Итак, мы пошли искать, как именно нас ломают и что не выдержатвает. В ходе поиска и устранения проблем, выявили следующие ошибки:
Ошибка 1: я слишком положился на Cloudflare и не настроил rate limit на уровне сервера
Наши сервера стоят за CloudFlare. В том числе из расчёта, что он закроет нас от DDOS атак. Я предполагал, что для защиты от DDOS'a мелко-среднего масштаба этого достаточно.
Уточню: сервера не светят публичные IP адреса (почту и т.д. мы не рассылаем). Весь трафик идёт исключительно через балансировщик нагрузки. Traefik работает через 80'й порт, а CloudFlare отвечает за SSL.
Когда всё упало, в мониторинге не было видно возросшего числа запросов:

Следовательно, я подумал, что засветились сервера и атака идёт по ним в обход CloudFlare. Поэтому первым решением было заблокировать всё через ufw
, кроме 22
и 80
порта. Но... не помогло. Через 10-20 секунд после перезапуска сервера запросы всё ещё отваливались.
В этот момент подтянулась статистика CloudFlare (оказалось, она приходит с небольшой задержкой):

Значит трафик всё-таки идёт через CloudFlare, но он его почему-то не фильтрует. При этом капча и режим "Under attack" были включены в первые минуты атаки.
Логи в Traefik показывали, что нам отправляют уйму запросов и не дожидаются их завершения (если я правильно помню, это называется connections flood):

Решение состояло из двух шагов:
1) Ограничить количество запросов в минуту кастомным правилом в CloudFlare даже для тех, кто прошел проверку на бота.
Оказалось, у CloudFlare есть отдельная настройка для ограничения requests per minute. Поправили её и ограничили до 100 запросов в минуту с одного IP. Этим правилом оказалось заблокировано ~2 млн запросов.

2) Ограничить количество запросов и параллельных подключений для каждого Traefik'a.
Каждый Traefik работает на своём сервере независимо от других. На случай, если CloudFlare все-таки пропустил спам-запросы, нужно ограничить их количество на стороне нашего прокси.
Мы выставили следующие лимиты:
-
максимум 10 запросов в секунду с одного IP;
-
максимум 10 параллельных подключений с одного IP (на случай, если идут долгие удерживающие запросы);
Это помогло. CPU и RAM какое-то время поборолись... и нагрузка спала. Дальше мы вышли на контракт с DDOS'ером и он уже не смог положить нас.
Правда поиск этих двух пунктов, настройка конфигов и попытки разобраться, что не так заняли ~2 часа. Все-таки делали мы это под некоторым стрессом и под градом сообщений от пользователей. Это заняло много времени.
Уже после всего DDOS'ер сказал, что его способ подразумевал обход CloudFlare. Насколько я понял, так реально делать. Но очень сложно делать массово. Следовательно, было ограниченное количество ботов, которые делают 90% запросов и блокировка способами выше помогла.
Ошибка 2: SSR запросы ходили в API через домен (а не локальную сеть)
Веб-часть нашей системы делает запросы в два шага:
-
На стороне SSR (server side rendering) берутся общие данные для всех приложений (в основном, закэшированные).
-
На клиентской части берутся данные конкретного пользователя отдельным запросом.
Схематично это выглядит так:

Но мы не поставили локальный IP адрес для SSR части. Получилось, что даже серверная часть фронтенда ходила в API через CloudFlare. Это никогда не мешало и не создавало задержек, поэтому и не замечали раньше.
Следовательно, когда мы включили режим защиты от DDOS атаки в CloudFlare, все наши серверные запросы к API отпали. Точнее CloudFlare показывал капчу и запросы не проходили:

Как результат: мы отбились от DDOS, но все наши клиентские приложения всё равно не работали. Причём мы, получается, положили их сами. DDOS только проявил проблему с нашей стороны.
Мы поправили все SSR запросы, чтобы они проходили через локальную сеть Docker'a. Но это заняло чуть больше времени, чем должно было бы, потому что возникла следующая проблема...
Ошибка 3: GitLab находился под тем же доменом, что и основная система без white list'a
Во время исправления проблем нам нужно было деплоить обновленные конфигурации Traefik'a и фронта. Но наш GitLab находился на поддомене основного домена, куда шла атака. CloudFlare начал показывать капчу всему, что обращается к GitLab. И под эту капчу попали GitLab runner'ы, которые собирают проект.
Для тех, кто не в курсе: GitLab runner — это сервис, который запускается отдельно от GitLab и подключается к нему, чтобы брать задачи на сборку в работу. Получается, мы пушим код в GitLab, раннер делает запрос в GitLab и берёт задачу в работу.
В итоге все наши билды встали (пример двух коммитов):

Дело было в спешке и тут я не сразу сообразил, что нужно просто добавить сервер GitLab в белый список IPшников. Поэтому последующие 30 минут мы дружно деплоили новые конфиги и фронт вручную... Напомню: ситуация стрессовая, параллельно решаем сразу несколько проблем и успокаеваем пользователей.
P.S. Со временем про белые списки вспомнил один из разработчиков, мы поправили и всё заработало в штатном режиме.
Ошибка 4: сервера не находились в приватной сети
Как подсказал мне по итогу мой знакомый СТО Иван Томилов, все сервера по-хорошему нужно прятать в приватную подсеть. Чтобы доступ к ним имел только балансировщик нагрузки, GitLab, мониторинг и сервер-бастион, через который мы подключаемся к серверам.
Тогда отпала бы проблема с определением причины: засветили ли мы сервера или был каким-то образом пробит CloudFlare. То есть сейчас архитектура такая:

А должно быть так:

Собственно, созданием приватной сети я и займусь на этой неделе. Пока что это не стало причиной проблем, но рано или поздно станет.
Заключение
По итогу, наша команда и лично я получили крайне наглядный опыт защиты проекта от DDOS атаки. Нас смогли вполне заслуженно положить в первую очередь из-за моих ошибок. Которые теперь я знаю, командой мы их исправили и держим в уме на будущее.
После устранения ошибок мы пообщались с DDOS'ером в формате "мы всё подчинили, сломай ещё, а если выйдет — будем общаться дальше". Мы справились:

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

Важные уроки, которые были вынесены из ситуации:
-
Нельзя полагаться на CloudFlare на 100%.
-
У CloudFlare есть правила для ручной настройки rate limit'a для тех, кто прошёл проверку на бота. Их нужно включать.
-
Всегда нужно настраивать rate limit на уровне сервера.
-
GitLab, мониторинг и другие сервисы нужно добавлять в белый список CloudFlare.
-
SSR запросы нужно отправлять через локальную сеть (если есть такая возможность). Так быстрее, не будет проблем в случае сбоев в сети или при появлении капчи CloudFlare.
-
Всю инфраструктуру нужно собирать в приватную сеть, чтобы исключить доступ к серверам в обход защиты (и не пытаться угадать, по ним ли идёт атака).
Планы на ближайшую неделю:
-
Закрыть все сервера приватной сетью с ограниченным доступом для CloudFlare, мониторинга и GitLab.
-
Взять консультацию и провести аудит основных инфраструктурных уязвимостей.
-
Вместе с СЕО извиниться, объяснить пользователям подробности ситуации и какие шаги предприняли, чтобы избежать такой же проблемы в будущем.
Для тех, кто разбирается в защите от таких ситуаций и видит, что чего-то нам не хватает — буду рад советам и замечаниям в комментариях или личке. Но напомню, что ресурсов у нас сильно меньше, чем у большой корпорации.
Если статья вам понравилась или оказалось полезной, поставьте, пожалуйста, лайк. Это мотивирует писать объемные статье и рассказывать конкретику из своего опыта.
Ну и, как полагается, у меня есть Telegram-канал, в котором я рассказываю про разработку, развитие SaaS-сервисов и управление IT проектами. В том числе о проблемах, которые возникают. Там же я выкладываю ссылки на новые статьи на Habr'e.
Автор: RostislavDugin