Компания Variti специализируется на защите от ботов и DDoS-атак, а также проводит стресс- и нагрузочное тестирование. Поскольку мы работаем как международный сервис, нам крайне важно обеспечить бесперебойный обмен информацией между серверами и кластерами в режиме реального времени. На конференции Saint HighLoad++ 2019 разработчик Variti Антон Барабанов рассказал, как мы используем UDP и Tarantool, почему взяли именно такую связку, и как нам пришлось переписывать модуль Tarantool с Lua на C.
По ссылке можно также почитать тезисы доклада, а ниже под спойлером — посмотреть видео.
Видео доклада
Когда мы начали делать сервис фильтрации трафика, мы сразу решили не заниматься IP-транзитом, а защищать HTTP, API и игровые сервисы. Таким образом, мы терминируем трафик на уровне L7 в протоколе TCP и передаем его дальше. Защита на L3&4 при этом происходит автоматически. На схеме ниже представлена схема сервиса: запросы от людей проходят через кластер, то есть серверы и сетевое оборудование, а боты (показаны в виде привидения) фильтруются.
Для фильтрации необходимо разбивать трафик на отдельные запросы, точно и быстро анализировать сессии и, так как мы не блокируем по IP-адресам, внутри соединения с одного IP-адреса определять ботов и людей.
Что происходит внутри кластера
Внутри кластера у нас расположены независимые ноды фильтрации, то есть каждая нода работает сама по себе и только со своим куском трафика. Между нодами трафик распределяется случайным образом: если от одного пользователя поступает, например, 10 соединений, то все они расходятся по разным серверам.
У нас очень жесткие требования к производительности, поскольку наши клиенты находятся в разных странах. И если, к примеру, пользователь из Швейцарии заходит на французский сайт, то он уже сталкивается с 15 миллисекундами сетевой задержки из-за увеличения маршрута трафика. Поэтому мы не вправе добавлять еще 15-20 миллисекунд внутри своего центра обработки — запрос будет идти уже критично долго. К тому же, если мы будем 15-20 миллисекунд обрабатывать каждый HTTP-запрос, то простая атака объемом 20 тысяч RPS сложит весь кластер. Это, естественно, недопустимо.
Еще одним требованием для нас было не просто отслеживание запроса, но и понимание контекста. Допустим, пользователь открывает веб-страницу и отправляет запрос на слэш. После этого грузится страница, и если это HTTP/1.1, то браузер открывает 10 соединений к бэкенду и в 10 потоков запрашивает статику и динамику, делает ajax-запросы и подзапросы. Если в процессе отдачи страницы вы вместо проксирования подзапроса начнете взаимодействие с браузером и попытаетесь отдать ему, скажем, JS Challenge на подзапрос, то скорее всего сломаете страницу. На самый же первый запрос можно отдавать CAPTCHA (хотя это плохо) или JS челленджи, делать редирект, и тогда любой браузер все корректно обработает. После тестирования необходимо по всем кластерам распространить информацию о том, что сессия легитимна. Если же обмена информацией между кластерами не будет, то другие ноды получат сессию с середины и не будут знать, пропускать ее или нет.
Также важно оперативно реагировать на все скачки нагрузки и изменения в трафике. Если на одной ноде что-то скакнуло, значит, через 50-100 миллисекунд скачок произойдет и на всех остальных нодах. Поэтому лучше, если ноды будут знать об изменениях заранее и заранее выставят параметры защиты, чтобы на всех остальных нодах скачка не случилось.
Дополнительным сервисом к защите от ботов у нас стал сервис пост-разметки: мы ставим пиксель на сайт, записываем информацию бот/человек и отдаем эти данные по API. Эти вердикты надо где-то сохранять. То есть если раньше мы говорили о синхронизации внутри кластера, то сейчас мы добавляем синхронизацию информации еще и между кластерами. Ниже показываем схему работы сервиса на уровне L7.
Между кластерами
После того, как мы сделали кластер, мы начали масштабироваться. Мы работаем через BGP anycast, то есть наши подсети анонсируются со всех кластеров и трафик приходит на ближайший из них. Проще говоря, из Франции запрос отправляется на кластер во Франкфурте, а из Петербурга на кластер в Москве. Кластеры при этом должны быть независимы. Сетевые потоки же допустимо независимы.
Почему это важно? Предположим, человек едет на машине, работает с сайтом с мобильного интернета и пересекает определенный рубикон, после которого трафик внезапно переключается на другой кластер. Или другой кейс: маршрут трафика перестроился, потому что где-то сгорел switch или роутер, что-то упало, отключился сегмент сети. В этом случае мы снабжаем браузер (например, в куках) достаточной информацией, чтобы при переходе на другой кластер можно было сообщить необходимые параметры о пройденных или непройденных проверках.
Кроме того, необходимо синхронизировать между кластерами режим защиты. Это важно в случае low volume атак, которые чаще всего проводятся под прикрытием флуда. Поскольку атаки идут параллельно, люди думают, что им сайт ломает флуд и не видят low volume атаку. Для того случая, когда low volume приходит на один кластер, а флуд на другой, и необходима синхронизация режима защиты.
И как уже упоминалось, мы синхронизируем между кластерами те самые вердикты, которые накапливаются и отдаются по API. При этом вердиктов может быть много и их необходимо синхронизировать надежно. В режиме защиты можно потерять что-то внутри кластера, но никак не между кластерами.
Стоит отметить, что между кластерами большой latency: в случае с Москвой и Франкфуртом это 20 миллисекунд. Здесь нельзя делать синхронные запросы, все взаимодействие должно идти в асинхронном режиме.
Ниже показываем взаимодействие между кластерами. M, l, p — это некие технические параметры для обмена. U1, u2 — это разметка пользователей на нелегитимных и легитимных.
Внутреннее взаимодействие между нодами
Изначально, когда мы делали сервис, фильтрация на уровне L7 была запущена всего на одной ноде. Это хорошо работало для двух клиентов, но не более. При масштабировании мы хотели достичь максимальной оперативности и минимальной latency.
Было важно минимизировать и затрачиваемые ресурсы CPU на обработку пакетов, так что не подошло бы взаимодействие через, например, HTTP. Также надо было обеспечить минимальный накладной расход не только вычислительных ресурсов, но и пакетрейта. Все же мы говорим о фильтрации атак, а это ситуации, в которых заведомо не хватает производительности. Обычно при построении веб-проекта достаточно х3 или х4 к нагрузке, но у нас всегда х1, поскольку всегда может прийти масштабная атака.
Еще одно требование к интерфейсу взаимодействия — это наличие места, куда мы будем писать информацию и откуда потом сможем считать, в каком сейчас состоянии находимся. Не секрет, что для разработки систем фильтрации зачастую используется C++. Но к сожалению, программы, написанные на C++, иногда падают в корку. Иногда такие программы надо перезапускать, чтобы обновить, или, к примеру, потому что конфигурация не перечиталась. И если мы перезапускаем ноду под атакой, то необходимо где-то взять контекст, в котором эта нода существовала. То есть сервис должен быть не stateless, он должен помнить, что есть некое множество людей, которых мы заблокировали, которых мы проверяем. Должна быть та самая внутренняя коммуникация, чтобы сервис мог получить первичный сет информации. У нас были мысли поставить рядом некую базу данных, например, SQLite, но мы такое решение быстро отбросили, потому что странно на каждом сервере писать Input-Output, это будет плохо работать в памяти.
Фактически мы работаем всего с тремя операциями. Первая функция “отправить”, причем на все ноды. Это касается, например, сообщений по синхронизации текущей нагрузки: каждая нода должна знать общую нагрузку на ресурс в рамках кластера, чтобы отслеживать пики. Вторая операция — “сохранить”, она касается вердиктов проверки. А третья операция — это комбинация “отправить всем” и “сохранить”. Здесь речь идет о сообщениях по изменению состояния, которые мы отправляем на все ноды и потом сохраняем, чтобы иметь возможность вычитать. Ниже представлена получившаяся схема взаимодействия, в которую мы должны будем добавить параметры для сохранения.
Варианты и результат
Какие варианты для сохранения вердиктов мы смотрели? Во-первых, мы думали о классике, RabbitMQ, RedisMQ и собственном TCP-based-сервисе. Эти решения мы отбросили, потому что они медленно работают. Тот же TCP добавляет х2 к пакетрейту. Кроме того, если мы с одной ноды отправляем сообщение на все остальные, то нам либо надо иметь очень много нод отправки, либо эта нода сможет отравлять 1/16 от тех сообщений, которые 16 машин могут отправить в нее. Понятно, что это недопустимо.
В итоге, мы взяли UDP multicast, поскольку в этом случае центром отправки выступает сетевое оборудование, которое не ограничено в производительности и позволяет полностью решить проблемы со скоростью отправки и приема. Понятно, что в случае с UDP мы не думаем о текстовых форматах, а отправляем бинарные данные.
Кроме того, мы сразу добавили пакетирование и базу данных. Мы взяли Tarantool, потому что, во-первых, у всех трех основателей компании был опыт работы с этой базой данных, а во-вторых, она максимально гибкая, то есть это еще и некий application сервис. Кроме того, у Tarantool есть CAPI, а возможность писать на C для нас принципиальный момент ввиду того, что для защиты от DDoS необходима максимальная производительность. Ни один же интерпретируемый язык не может обеспечить достаточную производительность, в отличие от C.
На схеме ниже добавили внутрь кластера базу данных, в которой и хранятся состояния для внутренней коммуникации.
Добавляем базу данных
В базе данных мы храним состояние в виде лога обращений. Когда мы придумывали, как сохранить информацию, было два варианта. Можно было хранить некоторое состояние с постоянным обновлением и изменением, но это довольно сложно имплементировать. Поэтому мы использовали другой подход.
Дело в том, что структура отправляемых по UDP данных унифицирована: есть тайминг, какой-то код, три-четыре поля данных. Так что мы эту структуру начали писать в space Tarantool и добавили туда запись TTL, по которой понятно, что структура устарела и ее надо удалить. Таким образом, в Tarantool накапливается лог сообщений, который мы зачищаем с заданным таймингом. Чтобы удалять старые данные, мы изначально взяли expirationd. Впоследствии нам пришлось от него отказаться, поскольку он вызывал определенные проблемы, о которых расскажем ниже. Пока что схема: на ней к нашей структуре добавились две базы данных.
Как мы уже упоминали, помимо хранения состояний кластера необходимо синхронизировать еще и вердикты. Вердикты мы синхронизируем межкластерно. Соответственно надо было добавить дополнительную инсталляцию Tarantool. Использовать другое решение было бы странно, потому что Tarantool уже есть и он идеально подходит для нашего сервиса. В новую инсталляцию мы стали писать вердикты и реплицировать их с другими кластерами. При этом мы используем не master/slave, а master/master. Сейчас в Tarantool только асинхронный master/master, что для многих случаев не подходит, но для нас такая модель оптимальна. При минимальной latency между кластерами синхронная репликация помешала бы, асинхронная же проблем не вызывает.
Проблемы
Но проблем у нас было много. Первый блок сложностей связан с UDP: не секрет, что протокол умеет бить и терять пакеты. Мы эти проблемы решили методом страуса, то есть просто спрятали голову в песок. Все же повреждения пакетов и перестановка их местами у нас невозможны, поскольку коммуникация идет в рамках одного switch, и нет нестабильных соединений и нестабильного сетевого оборудования.
Возможна проблема потери пакетов, если зависла машина, где-то возник Input-Output или перегрузилась нода. Если такое зависание произошло на небольшой период времени, скажем, на 50 миллисекунд, то это ужасно, но решается увеличенными очередями sysctl. То есть берем sysctl, настраиваем размер очередей и получаем буфер, в котором все лежит, пока нода не заработает снова. Если случится более долгое зависание, то проблемой будет потеря не связности, а части трафика, который уходит на ноду. У нас пока таких случаев просто не было.
Гораздо сложнее были проблемы с асинхронной репликацией Tarantool. Изначально мы взяли не master/master, а более традиционную модель для эксплуатации master/slave. И все работало ровно до того момента, пока slave не взял на себя на длительное время нагрузку master. В итоге expirationd работал и удалял данные на master, а на slave его не было. Соответственно когда мы несколько раз переключились с master на slave и обратно, на slave накопилось столько данных, что в какой-то момент все сломалось. Так что для полноценной отказоустойчивости пришлось переключиться на асинхронную репликацию master/master.
И здесь снова возникли сложности. Во-первых, ключи могут пересекаться между разными репликами. Предположим, в рамках кластера мы записали данные на один master, в этот момент соединение оборвалось, мы записали все на второй master, а после, того как произвели асинхронную репликацию, оказалось, что в space одинаковые primary key, и репликация рассыпается.
Решили мы эту проблему просто: взяли модель, при которой primary key обязательно содержит имя ноды Tarantool, в которую мы пишем. Благодаря этому конфликты перестали возникать, но стала возможной ситуация, когда дублируются пользовательские данные. Это крайне редкий случай, поэтому мы им опять же просто пренебрегли. Если дублирование будет происходить часто, то у Tarantool много разных индексов, так что всегда можно сделать дедупликацию.
Другая проблема касается сохранения вердиктов и возникает, когда данные, записанные на одном master, еще не оказались на другом, а на первый master уже пришел запрос. Мы, если честно, пока этот вопрос не решили и просто задерживаем вердикт. Если это недопустимо, то мы организуем некий пуш о готовности данных. Примерно так мы и справились с master/master репликацией и ее проблемами.
Был блок проблем, связанных непосредственно с Tarantool, его драйверами и модулем expirationd. Спустя некоторое время после запуска, атаки к нам стали приходить каждый день, соответственно количество сообщений, которое мы сохраняем в базе для синхронизации и хранения контекста, стало очень большим. И при зачистке стало удаляться настолько много данных, что перестал справляться garbage collector. Мы эту проблему решили, написав на языке C свой модуль expirationd, который назвали IExpire.
Впрочем, с expirationd осталась еще одна сложность, с которой мы пока не справились и которая заключается в том, что expirationd работает только на одном master. И если нода с expirationd упадет, то кластер потеряет критичную функциональность. Допустим, мы чистим все данные старше одного часа — понятно, что если нода пролежит, скажем, пять часов, то количество данных будет x5 к обычному. И если в этот момент придет крупная атака, то есть совпадут два плохих кейса, то кластер упадет. Мы пока не знаем, как с этим бороться.
Наконец, оставались сложности с драйвером Tarantool для C. Когда у нас ломался сервис (например, из-за race condition), то на поиск причины и отладку уходило много времени. Поэтому мы просто написали свой драйвер Tarantool. На имплементирование протокола вместе с тестированием, отладкой и запуском в продакшене у нас ушло пять дней, но у нас уже был готов свой код для работы с сетью.
Проблемы снаружи
Напомним, что у нас уже готова репликация Tarantool, мы уже умеем синхронизировать вердикты, но инфраструктуры для передачи между кластерами сообщений об атаках или проблемах пока нет.
По поводу инфраструктуры у нас было много разных мыслей, в том числе мы думали про написание своего TCP-сервиса. Но все же есть модуль Tarantool Queue от команды Tarantool. К тому же, у нас уже был Tarantool c межкластерной репликацией, были подкручены “дырки”, то есть не надо было ходить к админам и просить открыть порты или прогонять трафик. Опять же, была готова интеграция в софт фильтрации.
Оставалась сложность с принимающей нодой. Допустим, есть n независимых нод внутри кластера и надо выбрать ту, которая будет взаимодействовать с очередью на запись. Потому что иначе будет отправляться 16 сообщений или 16 раз будет вычитываться из очереди одно и то же сообщение. Эту проблему мы решили просто: мы прописываем в space Tarantool ответственную ноду, и если нода сгорает, то мы просто меняем space, если не забудем. Но если забудем, то это проблема, которую мы тоже хотим в будущем решить.
Ниже представлена уже детальная схема кластера с интерфейсом взаимодействия.
Что хочется улучшить и добавить
Во-первых, мы хотим выложить в open source IExpire. Нам кажется, это полезный модуль, потому что он позволяет делать все то же самое, что и expirationd, но с практически нулевым overhead. Туда стоит добавить некий сортировочный индекс, чтобы удалять только самые старые tuple. Пока что мы этого не сделали, поскольку основная операция в Tarantool для нас — это “запись”, и лишний индекс повлечет лишнюю нагрузку из-за его поддержки. Мы также хотим переписать большинство методов на CAPI, чтобы избежать складывания базы данных.
Остается вопрос с выбором логического мастера, но кажется, эту проблему решить полностью невозможно. То есть в случае, если нода с expirationd упадет, остается только вручную выбирать другую ноду и запускать expirationd на ней. Автоматически это делать вряд ли получится, поскольку репликация асинхронная. Хотя мы, наверное, посоветуемся на этот счет с командой Tarantool.
В случае экспоненциального роста кластеров нам также придется просить помощи у команды Tarantool. Дело в том, что для Tarantool Queue и межкластерного сохранения вердиктов используется репликация “все-ко-всем”. Это хорошо работает, пока кластеров, например, три, но когда их становится 100, то количество соединений, за которыми надо следить, будет невероятно большим, и постоянно будет что-то ломаться. Во-вторых, не факт, что Tarantool такую нагрузку выдержит.
Выводы
Первые выводы касаются UDP multicast и Tarantool. Multicast не надо его бояться, его использование внутри кластера — это хорошо, правильно и быстро. Есть много кейсов, когда идет постоянная синхронизация состояний, и через 50 миллисекунд уже неважно, что произошло ранее. И в этом случае, скорее всего, потеря одного состояния проблемой не будет. Так что использование UDP multicast оправданно, поскольку вы не ограничиваете производительность и получаете оптимальный пакетрейт.
Второй момент — Tarantool. Если у вас сервис на go, php и так далее, то скорее всего Tarantool применим как есть. Но если у вас большие нагрузки, то понадобится напильник. Но будем честны, напильник в таком случае нужен вообще для всего: и для Oracle, и для PostgeSQL.
Конечно, есть мнение, что не надо изобретать велосипед, и если у вас маленькая команда, то стоит брать готовое решение: Redis для синхронизации, стандартный go, python и так далее. Это неправда. Если вы уверены, что новое решение вам необходимо, если вы поработали с open source, выяснили, что вам ничего не подходит, или точно знаете заранее, что даже нет смысла пробовать, то свое решение пилить полезно. Другой разговор, что важно вовремя остановиться. То есть не надо писать свой Tarantool, не надо реализовывать свой обмен сообщениями, и если вам просто нужен брокер, возьмите уже Redis, и будет вам счастье.