Всем привет! Меня зовут Константин. Моя карьера в сетевой разработке началась со времен Symbian OS, когда я участвовал в создании сетевого стека этой платформы. С 2010 года я работаю в «Лаборатории Касперского», разрабатывая мобильные и сетевые продукты, а последний год плотно погружен в проект NGFW. В мои задачи входит как проработка архитектурных решений, так и написание кода ключевых модулей.
В этой статье я хочу рассказать об архитектуре сетевого экрана следующего поколения (NGFW), над которым работаю. В частности, расскажу:
об архитектуре передающего слоя (data plane) нашего продукта, основанной на связке DPDK/VPP;
о пути сетевого пакета в рамках data plane NGFW;
о частых ошибках при разработке решений на базе VPP;
о разработке и сценариях встраивания в высокоскоростной конвейер обработки пакетов VPP некоторых из наших движков безопасности;
об истории создания наших собственных движков безопасности DPI и IDPS (хочу выразить благодарность за неоценимую помощь в подготовке материала для данного раздела коллегам из команды IDPS и лично Евгению Прусову);
об интеграции data plane с протоколами динамической маршрутизации.
Материал будет полезен архитекторам и разработчикам, участвующим в создании высокопроизводительных и отказоустойчивых сетевых решений.
Немного предыстории
Когда я присоединился к команде, DPDK и VPP уже использовались в качестве основы проекта data plane. Однако внутри команды сохранялся скепсис: действительно ли VPP — это тот инструмент, который нам нужен для NGFW?
Погрузившись в код, я убедился: многие части VPP блестяще оптимизированы. Но так ли он идеален для нашей задачи? Изначально задуманный как расширяемый пользовательский сетевой стек, VPP оброс огромным количеством функционала: плагины, API для удаленного вызова (VL API), поддержка десятков протоколов. Далеко не все из этого нужно в классическом NGFW.
Однако уже на тот момент я был уверен, что решение об использовании связки DPDK и VPP абсолютно верное. По мере погружения в код первоначальный скепсис команды также сменился пониманием его потенциала. Сейчас мы используем собственный, сильно доработанный под наши нужды форк VPP, избыточный для наших задач функционал мы также из него удалили. Но давайте по порядку.
Краткая справка для тех, кто не знаком с DPDK и VPP
DPDK (Data Plane Development Kit) — это open-source-проект, первоначально созданный Intel, который предоставляет набор библиотек и драйверов для высокопроизводительной обработки сетевого трафика.
Основная идея: перенести обработку пакетов из ядра операционной системы в пользовательское пространство (user-space). Это позволяет избежать накладных расходов на системные вызовы и переключения контекста, что критично для скоростей в десятки и сотни гигабит.
Как работает: DPDK использует небольшой модуль ядра (для Linux это igb_uio), который работает с механизмом UIO в Linux. Этот модуль отвечает за перенаправление пакетов напрямую из сетевой карты (NIC) в специальные кольцевые буферы (rings) в памяти пользовательского приложения.
Что делает: весь основной код обработки (драйверы устройств, стеки протоколов, алгоритмы) работает в пользовательском пространстве, что дает разработчику полный контроль над производительностью и логикой.
Изначально DPDK фокусировался на эффективном приеме/передаче и базовых операциях с пакетами. Со временем его функционал значительно расширился и теперь включает, помимо прочего, собственный стек протоколов и возможности для векторной (пакетной) обработки, т. е. его расширение сейчас происходит в сторону частичного дублирования функционала VPP.
VPP (Vector Packet Processing) — это open-source-фреймворк для векторной обработки пакетов, изначально разработанный в Cisco.

Основная идея: обрабатывать пакеты не по одному, а пакетами (векторами). Это улучшает предсказуемость кода для кэша процессора, чем значительно повышает производительность.
Как работает: VPP построен на концепции графа узлов. Каждый узел выполняет одну узкоспециализированную операцию (например, Ethernet input, IPv4 lookup, TCP checksum verify). Пакеты проходят через этот конвейер узлов, и каждое решение определяет следующий узел в графе.
Отношение к DPDK: VPP может использовать DPDK как один из источников пакетов (наравне с другими интерфейсами, например, tap/virtio). DPDK отвечает за быстрый прием и передачу, а VPP — за эффективную и гибкую обработку внутри своего графа.
Общая архитектура data plane
Когда я присоединился к проекту, основа на базе VPP уже была заложена: активно использовался его граф узлов, и команда также разработала ряд кастомных узлов для обработки трафика.
Изначальная архитектура: для обработки трафика на уровнях L4 и выше (см. схему TCP-прокси) использовался функционал локальных сокетов (local sockets) внутри VPP.
Чтобы добиться максимальной производительности и полностью исключить дорогостоящее копирование данных между процессами, было приняло решение: не выносить движки безопасности (IDPS, DPI, Antimalware и другие) в отдельные процессы. Вместо этого они являются нативными узлами внутри графа VPP. Сейчас, спустя время, можно с уверенностью сказать: этот подход полностью себя оправдал с точки зрения и производительности, и управляемости.
Принципы работы VPP: как это устроено изнутри
Режим работы: опрос (polling), а не прерывания
VPP для достижения максимальной производительности работает в режиме постоянного опроса (polling) графа узлов, полностью избегая механизма прерываний, как аппаратных (DPDK настраивается в режим постоянного опроса сетевой карты), так и ожиданий готовности узлов в рабочих потоках. Это позволяет минимизировать задержки и предсказуемо управлять нагрузкой на CPU.
Модель потоков: Workers и Main
Архитектура VPP построена вокруг двух типов потоков:
Рабочие потоки (Worker threads): обрабатывают трафик. Их количество — ключевой параметр настройки. Оно не должно превышать число физических/логических ядер CPU, выделенных под data plane.
Главный поток (Main thread): выполняет управляющие функции, обработку CLI, API и фоновые задачи. Обычно для него достаточно одного ядра.
Следуя документации VPP, мы не выделяем под рабочие потоки все доступные ядра. Несколько ядер резервируются для ОС, главного потока VPP, задач NGFW, не связанных с data plane и VPP напрямую (например, запросы в облако KSN), и других процессов ОС. Сбалансированность этого соотношения критично влияет на максимальную производительность всего решения.
Как работает главный поток
Как и рабочие потоки, главный поток работает в бесконечном цикле опроса. Однако, в отличие от них, он опрашивает другие типы узлов, которые называются process node. С помощью этих узлов VPP обрабатывает все, что не связано с data plane: команды от CLI, события от операционной системы (например, асинхронное чтение из сокетов ОС), вызовы VL API из других процессов и т. п.
Функции — обработчики узлов, работающих на главном потоке, VLIB_NODE_TYPE_PROCESS, должны быть написаны так, чтобы выполнять свою работу быстро и атомарно. Если обработчик займет слишком много времени (например, 100 мс), он заморозит всю управляющую плоскость VPP: CLI перестанет отзываться, API будет молчать, другие process-узлы не будут выполнять свою работу.
Кооперативная многозадачность на основе setjmp/longjmp
В отличие от рабочих потоков, занятых только обработкой трафика, главный поток должен выполнять множество разнородных задач. В основе этой реализации в VPP лежит мощный, но требующий аккуратного обращения механизм — setjmp и longjmp. На базе этих двух функций в VPP реализован полноценный механизм переключения контекста в рамках одного потока.
Историческая справка:
Любопытно, что подобный подход далеко не новинка. В мобильной ОС Symbian (в разработке сетевого стека которой я когда-то принимал участие) существовала очень похожая архитектура под названием Active Objects. Правда, причина ее появления была иной: в эпоху одноядерных процессоров с крайне ограниченной энергоэффективностью запуск большого количества потоков был неоправданной роскошью. Каждый дополнительный поток увеличивал нагрузку на планировщик задач ядра, приводя к более быстрому расходу заряда батареи. Active Objects позволяли организовать сложное асинхронное поведение в рамках одного потока, что было ключевым фактором для автономности устройств. Таким образом, мы видим преемственность архитектурных идей: принципы, доказавшие свою эффективность в решениях двадцатилетней давности, получают новое дыхание и мощное развитие в современных высокопроизводительных фреймворках, таких как VPP.
Принцип работы
Установка точки возврата (setjmp): в самом начале своего цикла главный поток с помощью setjmp(jmp_buf) сохраняет текущую точку исполнения (регистры и стек). Эта точка будет использована для «отката» на место ее установки позже.
Обработка с возможностью приостановки: далее главный поток начинает обрабатывать события из очереди и т. д.
Основная логика работы данного механизма реализуется в VPP-функциях vlib_process_startup, vlib_process_resume, vlib_process_suspend и нескольких вариантах vlib_process_wait_for_.
Практическая проблема: интеграция с AddressSanitizer (ASAN)
Этот элегантный механизм переключения контекста может изрядно попортить крови при подключении инструментации AddressSanitizer (ASAN), что было и в нашем случае. Использование ASAN необходимо для обнаружения ошибок памяти (use-after-free, buffer overflow) и является обязательным требованием для выполнения норм ФСТЭК при сертификации средств защиты информации.
В чем проблема:
ASAN для своей работы не только подменяет функции выделения и освобождения памяти, но и хранит собственную «тень» стека для каждого потока, чтобы отслеживать его корректность. Механизм setjmp/longjmp, по сути, осуществляет ручное переключение контекста стека, о котором ASAN по умолчанию не знает. Если его не оповестить, он продолжит отслеживать оригинальный стек, в то время как исполнение перешло на новый. Это приводит к ложным срабатываниям (false positives) и, что хуже, к пропуску реальных ошибок (false negatives), так как метаданные ASAN становятся некорректными.
Решение в VPP: кооперация с санитайзером
Разработчики VPP предусмотрели эту проблему. Платформа предоставляет специальные функции-обертки для переключения стека:
Их задача — явно уведомить ASAN о предстоящем манипулировании стеком, чтобы тот мог корректно обновить свои внутренние структуры и начать отслеживать новый контекст.
Важный нюанс для сборки:
Эти вызовы активируются только при наличии макроопределения препроцессора CLIB_SANITIZE_ADDR, которое обычно выставляется при компиляции с флагами -fsanitize=address.
Если ваш проект состоит из нескольких модулей (отдельные библиотеки, плагины), вы должны быть абсолютно уверены, что это определение:
Единообразно определено для всех модулей, которые используют API главного потока VPP (т. е. где создаются ваши узлы с типом VLIB_NODE_TYPE_PROCESS).
Явно объявлено во время сборки этих модулей.
Иначе вы получите ситуацию, когда часть кода (ядро VPP) скомпилирована с поддержкой переключения для ASAN, а другая часть — без поддержки. Это гарантированно приведет к падениям под ASAN, так как уведомление о переключении стека будет отправляться не всегда. В нашем проекте мы потратили немало времени, чтобы отследить и унифицировать этот флаг по всей кодовой базе.
Сердце VPP: рабочие потоки, цикл обработки и граф узлов
Каждый рабочий поток выполняет бесконечный цикл, в котором обходит граф узлов. Вот как это происходит:
Вектор на вход: на вход функции каждого узла подается не один пакет, а вектор (frame) пакетов. Это фундаментальный принцип высокой производительности VPP.
Обработка: функция — обработчик узла выполняет свою узкоспециализированную задачу (например, проверку ACL, парсинг заголовков) для каждого пакета в векторе.
Маршрутизация: основная задача узла — принять решение по каждому пакету и отправить его в очередь следующего узла в графе.
Размер вектора фиксирован (например, 256 пакетов). Если пакетов меньше, обрабатывается неполный вектор; если больше — worker обрабатывает их несколькими порциями.
Распределение нагрузки: RSS, хеширование и важность баланса
Одной из ключевых задач для достижения максимальной производительности в NGFW является равномерное распределение входящего трафика по рабочим потокам. Неравномерная нагрузка приводит к тому, что один поток оказывается перегружен, в то время как другие простаивают, образуя «узкое горлышко» и ограничивая общую пропускную способность системы.
Аппаратный RSS (Receive Side Scaling)
Современные сетевые карты (NIC) решают эту проблему аппаратно с помощью механизма RSS.
Принцип работы: NIC вычисляет хеш-сумму от ключевых полей входящего пакета, чаще всего от набора полей, однозначно идентифицирующего поток (flow), — так называемого tuple (4-tuple, 5-tuple). Например:
- IP src/dst + Port src/dst – 4-tuple
- IP src/dst + Port src/dst + Protocol – 5-tupleЦель: все пакеты одного и того же сетевого потока должны иметь одинаковый хеш. Это гарантирует, что они будут направлены в одну и ту же очередь (RX queue) и, следовательно, обработаны одним и тем же рабочим потоком. Это критически важно для сохранения порядка пакетов (packet ordering) в рамках одного соединения и для эффективности кэша процессора.
Распределение: младшие биты вычисленного хеша используются как индекс для выбора конкретной приемной очереди (RX queue), которая закреплена за определенным ядром CPU.
Алгоритм Тёплица (Toeplitz) и его роль
Алгоритм Тёплица является стандартным де-факто для вычисления RSS-хеша в Ethernet-адаптерах (примеры кода использования и настройки RSS offload сетевой карты на симметричный алгоритм Тёплица из кода драйвера ICE в DPDK — 1, 2).
Почему именно он: это симметричный алгоритм хеширования, который обеспечивает важное свойство: хеш от кортежа (IP-A, Port-A, IP-B, Port-B) будет равен хешу от ответного кортежа (IP-B, Port-B, IP-A, Port-A). Таким образом, пакеты входящего и исходящего трафика одного и того же соединения будут иметь одинаковый хеш и попадут на один и тот же поток обработки. Это упрощает состояние и синхронизацию для механизмов, отслеживающих соединения (stateful inspection), таких как firewall или NAT.
Программный RSS (Soft-RSS)
Поскольку наш NGFW может работать не только на железе, но и в рамках виртуальных сред, аппаратный расчет RSS может быть доступен не всегда. По ряду причин, в том числе из-за NAT, реализация программного расчета RSS, имеющаяся в VPP, нам не подошла, и мы сделали свою, учитывающую наши доработки в рамках графа узлов VPP.
Почему равномерность загрузки рабочих потоков так важна
Когда количество сессий велико, то выбранный алгоритм хеширования должен обеспечить статистически равномерное распределение сессий по рабочим потокам. В противном случае, если существенная часть трафика сконцентрируется на нескольких рабочих потоках, а остальные будут недозагружены, это создает ситуацию, при которой общая производительность системы упрется в мощность самых загруженных ядер/рабочих потоков.
Вывод: правильная настройка RSS — и выбор ключа хеширования (L2, L3, L4 поля) и используемого алгоритма — является не теоретической, а сугубо практической задачей тонкой настройки любого высокопроизводительного сетевого решения, включая NGFW. Неравномерное распределение пакетов по потокам — один из первых «кандидатов» на рассмотрение при возникновении проблем с производительностью.
Наши доработки в VPP для Stateful-обработки
Стандартные механизмы VPP хотя и позволяют добиться равномерного распределения пакетов, не содержат развитого функционала для создания и отслеживания сессий на всем протяжении data plane для форвард-трафика. Для stateless-маршрутизатора этого достаточно. Однако для NGFW данный функционал является критически важным.
В нашем решении мы сделали множество доработок в коде VPP в этой области. Нам потребовалось реализовать:
Единый механизм трекинга сессий, доступный и согласованный между всеми рабочими потоками.
Эффективную межпоточную синхронизацию для доступа к общим данным о сессиях из потоков вне data plane c минимальными блокировками.
Интеграцию механизмов сессий в граф обработки пакетов VPP для принятия stateful-решений (проверка политик FW, применение NAT, учет трафика).
Эти доработки позволили нам сохранить преимущества статистического распределения трафика через RSS, но при этом обеспечить консистентность и высокую производительность stateful-обработки, что и является сутью современного NGFW.
Путь пакета: от сетевой карты до наших движков безопасности
Опишу стандартный путь L2-пакета, принятого через DPDK, и где в этом пути интегрированы наши проверки. Но перед этим важно рассказать о еще одной важной особенности, которую необходимо учитывать при разработке высокоскоростной обработки пакетов в data plane.
Узел dpdk-input: задача данного узла — забрать пакеты у DPDK с сетевой карты. Если настроен и поддерживается аппаратный RSS, DPDK сразу выгрузит в текущий рабочий поток только подходящие по хешам пакеты. Если аппаратный RSS не работает, то нужен отдельный Handoff-узел, который рассчитает RSS-хеш программно и переложит пакеты с текущего рабочего потока в правильный. Это ресурсоемкая операция, и ее важно осуществлять максимально быстро.
L2/L3-узлы: пакет проходит стандартную обработку: ethernet-input, ip4-input/ip6-input и т. д. На этом этапе следует подумать о NAT, а также о сопоставлении пакета соответствующей ему сессии. В VPP есть стандартный плагин, реализующий поддержку с NAT, он не подошел под наши требования, и мы решили написать свою реализацию и интегрировать ее в код VPP. Здесь крайне важно учесть, что по правилам NAT-преобразований RSS хеш пакета может с высокой вероятностью поменяться. Важно учитывать данный момент, чтобы не дублировать логику перекладывания пакетов по рабочим потокам. Начиная с NAT и сопоставления пакетов с сессиями уже начинается наша магия по обработке трафика.
Однако самая большая «магия» заключается не только в самой логике обработки, но и в том, чтобы обеспечить ее высокую производительность и линейное масштабирование на многоядерных системах. Достичь этого можно, следуя нескольким ключевым принципам при написании кода для рабочих потоков, про которые я расскажу далее.
Практические рекомендации по написанию кода
Избегайте разделяемых потоками данных и атомарных операций
Как только появляется разделяемая между всеми потоками переменная, требующая атомарного доступа (через atomic, spinlock), система перестает масштабироваться. С каждым добавленным ядром борьба за доступ к этой переменной только возрастает.
Исторический пример: в статье на Хабре подробно разбирается, почему при переходе к C++11 из std::string был удален подход Copy-On-Write (CoW). Основная причина — CoW требовал использования атомарного счетчика ссылок, что не позволяло ему масштабироваться на многоядерных системах и убивало производительность при интенсивном использовании из нескольких потоков.
Решение: проектируйте архитектуру так, чтобы каждый рабочий поток взаимодействовал с собственным, неразделяемым набором данных (per-thread data).
Для редких обновлений (правила, политики, конфигурация) используйте встроенный в VPP механизм барьеров.
Этот механизм позволяет безопасно обновлять разделяемые данные из главного в рабочие потоки. Но надо учитывать, что его вызов может быть дорогим, т. к. он дожидается прихода всех рабочих потоков в точку синхронизации.
Как это работает: основная идея в том, что главный поток инициирует обновление, а рабочие потоки в определенной точке своего цикла (при обработке следующего вектора пакетов) синхронизируют кэш своего ядра. Далее, после барьера, они работают со своей копией этих данных и могут обращаться к ним без дополнительных накладных расходов на синхронизации.
Где смотреть в коде VPP: ключевые структуры и функции находятся в vlib/threads.h. Глобальные данные потока VPP объявлены в структуре vlib_worker_thread_t. В ней в том числе хранится информация о том, в каком состоянии находится поток. Данные из этой структуры могут быть полезны при отладке проблем с барьерами. Зная thread_id текущего потока, легко найти его экземпляр данной структуры в глобальном массиве vlib_worker_threads. Для захвата барьера, который нужно осуществлять на главном потоке VPP, можно использовать функцию-макрос vlib_worker_thread_barrier_sync. Далее следует провести операции, которые требуют синхронизации с рабочими потоками, после этого барьер можно отпустить с использованием функции vlib_worker_thread_barrier_release.
Отладка и подводные камни: штатная отладка кода под GDB затруднена. Остановка в отладчике приводит к тому, что поток не может достичь барьера. В этом случае срабатывает встроенный в VPP watchdog (можно найти по вызовам функции os_panic, в файле threads.c), который определяет «зависший» поток и аварийно завершает его. Штатной возможности отключить этот механизм в debug-сборках или через vppctl нет, хотя это было бы крайне полезно. Впрочем, реализовать ее самостоятельно (например, через флаг окружения или API) и сильно упростить жизнь разработчика достаточно просто.
Для оперативного обмена данными используйте встроенный в VPP механизм inter-process communication (IPC)/ RPC вызовов (например, через бинарный API vat2 или внутренние механизмы).
Однако по умолчанию он может быть избыточным для высокочастотных операций из-за накладных расходов на проверки, сериализацию и иногда излишнюю синхронизацию.
Наш опыт: в нашем решении мы переработали и упростили этот механизм для внутреннего межпоточного взаимодействия, убрав ненужные в нашем контексте проверки и гарантии. Это существенно снизило задержки и накладные расходы, сохранив при этом необходимую функциональность.
Минимизируйте обращения к ядру ОС (Syscalls)
VPP построен на парадигме полного контроля над пользовательским пространством. Рабочий поток должен проводить 99,9% времени, обрабатывая данные в user-space, и не должен блокироваться на операциях ядра.
Пример с временем: классические способы получения времени (std::chrono::steady_clock::now(), time(), gettimeofday()) — это syscall-ы. Каждый такой вызов — это дорогостоящее переключение контекста в ядро и обратно.
Решение в VPP: используйте оптимизированные внутренние функции фреймворка, например clib_time_now(). Его алгоритм работы:
Редкая синхронизация с ядром (по умолчанию раз в 16 секунд) для калибровки. Эта операция — единственный syscall, он предсказуем.
Быстрое получение текущего времени при вызове clib_time_now путем чтения счетчика тактов процессора (RDTSC или RDTSCP на x86) и прибавления к последнему синхронизированному значению. Эти операции выполняются в пользовательском пространстве и не требуют переключения контекста для получения данных из ядра ОС.
Примеры функций с syscall-ами:
Мьютексы в обычном режиме (не гибридные, см. PTHREAD_MUTEX_ADAPTIVE_NP) приводят к syscall-у. Гибридные мьютексы также приведут к syscall-у, если их не удастся захватить быстро.
Работа с файлами/сокетами (read/write/send/recv).
malloc/free, new стандартной библиотеки в ряде случаев (если требуется выделение новой страницы памяти) также производят syscall.
Многие другие функции libc.
Откажитесь от thread_local в пользу массива per-thread-данных
Проблема: ключевое слово thread_local может иметь существенные накладные расходы на доступ из-за использования механизма TLS (Thread-Local Storage).
Решение в VPP: используйте массивы для per-thread-данных (или вектора vec из библиотеки VPP). Идея проста:
Создается массив my_per_thread_data_t *data_by_thread_id.
Его размер равен максимальному количеству рабочих потоков.
Когда коду в контексте потока с индексом i нужны его данные, он моментально получает их,обращаясь к элементу массива data_by_thread_id[i]].
Преимущество: этот способ быстрее thread_local, так как сводится к простому вычислению адреса в массиве по известному индексу потока, который в VPP всегда доступен.
Избегайте «умных» указателей с атомарным подсчетом ссылок
В погоне за удобством и автоматическим управлением памятью легко совершить ошибку, использовав std::shared_ptr или аналогичные «умные» указатели с атомарным счетчиком ссылок (например, boost::intrusive_ptr с атомарным счетчиком) в коде, который выполняется на рабочих потоках.
Почему это опасно: каждое копирование и уничтожение такого указателя приводит к дорогостоящей атомарной операции (инкременту или декременту счетчика). Эти операции должны синхронизировать кэш-линии между ядрами процессора. В высокопроизводительном конвейере обработки пакетов, где объекты могут создаваться и уничтожаться тысячи или миллионы раз в секунду, эти, казалось бы, «небольшие» издержки накапливаются. Это приводит к значительному падению производительности и разрушает масштабируемость.
Проблема диагностики: найти замедление из-за такого кода может быть крайне сложно. Поскольку код копирования счетчиков таких указателей инлайнится, их не видно в профилировщике.
Используйте fast-path- и slow-path-подходы VPP для минимизации случаев cache miss
Одна из ключевых задач оптимизации кода data plane — обеспечить, чтобы наиболее часто выполняемый код (fast path) был как можно более компактным и предсказуемым для процессора. Резкое падение производительности часто происходит не из-за медленных инструкций, а из-за промахов кэша (cache miss), когда процессор вынужден сотни тактов ждать, пока данные подгрузятся из основной памяти.
Проблема: если код обработки каждого пакета вынужден обращаться к большому объему разрозненных данных (например, к глобальной сложной структуре правил фаервола), это приводит к постоянным промахам в кэше данных (D-cache) и инструкций (I-cache). Процессор простаивает, а пропускная способность падает.
Решение: четкое разделение на fast-path и slow-path во всех случаях, где это возможно.
fast-path — это узкоспециализированный узел-обработчик, обрабатывающий ~ 99% всех пакетов. Код данного узла должен быть компактным, линейным и детерминированным. Все данные, необходимые для его работы (например, правила ACL, состояние сессий), должны быть локализованы и предсказуемо размещены в памяти. Часто для этого используются заранее сформированные и отсортированные структуры (например, компактные массивы правил или Bloom-фильтры), которые целиком помещаются в кэш процессора. Цель: обработать пакет, обратившись только к кэшу, без походов в основную память.
slow-path — cюда относятся все исключительные и редко встречающиеся ситуации: создание новой сессии, обработка ICMP-сообщений, применение сложных правил, обновление конфигурации. Код slow-path может быть «медленным», сложным и может совершать много обращений к памяти, в отличие от кода fast-path.
Основная идея: вынести slow-path-операции за пределы основного цикла обработки пакетов.
Пример: при поступлении пакета, для которого не найдена сессия, fast-path просто помещает пакет в специальную очередь и немедленно переходит к следующему пакету. Отдельный slow-path-узел в пайплайне асинхронно обрабатывает эту очередь, принимает решение (разрешить/запретить) о создании сессии и т. д., в зависимости от назначения узла. Все последующие пакеты этой сессии будут обработаны уже в fast-path-узле.
Выгода: такой подход радикально уменьшает количество данных, к которым обращается fast-path. Это резко снижает вероятность промахов кэша, обеспечивая предсказуемо высокую производительность для основного трафика. Медленные операции не блокируют конвейер, а выполняются асинхронно, не влияя на задержки для большинства пакетов.
Преаллоцируйте все часто используемые объекты data plane
Динамическое выделение памяти (malloc, new) во время обработки каждого пакета — верный путь к катастрофе для производительности. Это не только потенциальный syscall, но и источник непредсказуемой задержки и фрагментации памяти.
Проблема: «нативная» работа с памятью через общие аллокаторы приводит к
Непредсказуемому времени выполнения: аллокатор может быть вынужден искать свободный блок или запрашивать новую память у ядра.
Конфликтам (memory contention): сценарий, в котором несколько потоков одновременно выделяют или освобождают память в одной куче и соревнуются за блокировки внутри аллокатора.
Промахам кэша: cтруктуры данных аллокатора разбросаны по памяти, что приводит к D-cache miss.
Решение: все объекты, жизненный цикл которых привязан к обработке пакетов (сессии, метаданные, временные структуры данных), должны быть преаллоцированы заранее.
Для аллокации на рабочих потоках используйте per-thread-кучи
Даже когда преаллокация в пулах невозможна, стандартный malloc (или его аналог в VPP) не должен использоваться в fast-path рабочих потоков. Проблема общих аллокаторов — блокировки. Когда несколько потоков одновременно выделяют или освобождают память, они соревнуются за эти блокировки, что приводит к простоям и разрушает масштабируемость.
Проблема «главной кучи» (main heap) в VPP: по умолчанию VPP для своих внутренних структур (vec, pool, hash, bihash, rb_tree_t) использует общую main heap. Этот аллокатор (адаптированная версия dlmalloc) использует блокировки для синхронизации доступа.
Смотрите в коде: макрос PREACTION в src/vppinfra/dlmalloc.c перед началом манипуляций с кучей захватывает мьютекс ACQUIRE_LOCK (&(M)->mutex), если куча создана с соответствующим флагом. Это узкое место для многопоточности.
Решение: кучи per-thread (private). VPP предоставляет механизм для создания отдельных куч для каждого рабочего потока. Это решает проблемы путем:
Устранения блокировок. Если поток выделяет память из своей собственной кучи, ему не нужно синхронизироваться с другими потоками. Это позволяет полностью отключить проверку и захват блокировок в аллокаторе для этой кучи.
Локализации памяти. Все выделения потока происходят в его собственном регионе памяти, что уменьшает фрагментацию данных в куче и снижает вероятность промахов кэша, вызванных работой аллокатора другого потока.
Текущее состояние и наш опыт: на момент написания статьи механизм per-thread-куч в VPP (включая master-ветку) реализован не до конца и не используется по умолчанию. Он требует дополнительной настройки и доработок для полной интеграции со всеми инфраструктурными типами данных (такими как rb_tree_t, hash, bihash и другими). Несмотря на это, сама архитектурная возможность заложена. В нашем решении мы используем и дорабатываем этот механизм, что дает существенный прирост производительности в сценариях, когда требуется аллокация небольших объектов, для которых предсказать количество на старте, чтобы их предаллоцировать, не представляется возможным.
Но вернемся снова к описанию пути пакета в data plane.
После классификации трафика на L3-уровне возникает ключевая задача: направить в движки безопасности (DPI, IDPS) пакеты, принадлежащие сессиям, подпадающим под правила проверки. В данной части data plane эти движки работают в пакетном режиме. На текущий момент в VPP нет готового механизма для реордеринга L4-трафика.
VPP реализует стандартный подход: разбор L4-трафика происходит только для пакетов в рамках соединений, завершающихся на том же хосте, где запущен сам VPP. Для forward-трафика подобного разбора не происходит. При этом, чтобы обеспечить качественную проверку трафика движками безопасности, необходимо упорядочить L4-пакеты для тех протоколов (например, TCP), где этот момент важен. Тут возникает необходимость написания собственной реализации механизма реордеринга. Чтобы этот механизм был максимально быстрым, он должен следовать подходу zero-copy, т. е. не копировать содержимое out of order пакетов. Хочу уточнить, что стандартная реализация разбора TCP-протокола в VPP предполагает копирование данных из TCP-пакетов в FIFO-очередь. Кроме того, код там написан исходя из той логики, что трафик на нем должен терминироваться. Поэтому для задачи упорядочивания пакетов перед их проверкой движками стандартная реализация в VPP «из коробки» не подходит. Наиболее производительным решением, которое мы и реализовали у себя, будет удержание vlib_buffer_t объектов, путем увеличения их ref_count-поля. Это позволит избежать попадания буфера обратно в пул свободных буферов после прохождения пайплайна. А значит, данные в нем не перетрутся следующими пакетами в dpdk input. Но это в теории. На практике в коде dpdk-плагина в VPP, включая master-ветку, есть ряд неприятных багов, которые этому мешают. Следовательно, их необходимо поправить, чтобы механизм удержания буферов на пайплайне работал правильно. После их исправления механизм удержания буферов на pipeline у нас заработал. Использование описанного подхода zero-copy для TCP-реордеринга позволило нам получить почти 8-кратное ускорение по сравнению с версией tcp reordering-а, копирующего содержимое пакетов.
IDPS: путь от Suricata к собственной разработке
Решение отказаться от готового движка Suricata и разработать собственный было одним из наиболее фундаментальных на ранних этапах проекта. Мы приняли его после анализа ограничений использования Suricata в нашем продукте.
Проблемы интеграции Suricata
-
Сложность поддержки и развития:
Внесение изменений: любые низкоуровневые оптимизации или исправления, специфичные для нашего стека (VPP/DPDK), пришлось бы согласовывать с upstream-сообществом Suricata. Большинство таких патчей были бы не нужны никому, кроме нас, и их принятие было бы под вопросом.
Форк: создание и поддержка собственного форка Suricata — это огромные операционные затраты без стратегических преимуществ. Ведь мы все равно были бы ограничены изначальной архитектурой и лицензией движка.
Избыточные абстракции: для мультиплатформенности и универсальности в Suricata есть собственная сложная система абстракций. Эти абстракции не являются «бесплатными». Они добавляют издержки, которые невозможно устранить в рамках ее архитектуры, в том числе из-за лицензионных ограничений.
-
GPL v2 лицензия:
Она не позволяла запускать движок Suricata непосредственно внутри процесса NGFW. В противном случае произошло бы распространение данной лицензии на основной код продукта.
-
Запуск Suricata в отдельном процессе, как способ обойти лицензионные ограничения, требовал либо копирования трафика, либо использования общей (shared) памяти для vlib_buffer_t с необходимостью синхронизации доступа к ней из двух процессов. Оба варианта вели как к значительному снижению производительности, так и к усложнению логики управления движком и трафиком в исходном коде продукта. Возникают критические вопросы:
Как перезапустить процесс Suricata в случае сбоя, не прерывая обработку трафика?
Что делать с пакетами, которые не были проверены из-за аварии процесса-изолята? Пропустить их (создав дыру в безопасности) или заблокировать (вызвав простои)? Необходимость постоянно отслеживать статус дочернего процесса, управлять его жизненным циклом и обрабатывать возможные отказы добавляет, по сути, систему в систему, создавая новые точки отказа.
Преимущества собственной разработки
Глубокая интеграция в data plane и возможность работы с данными трафика без механизмов синхронизации и копирования (zero-copy). Наш движок работает внутри графа узлов VPP, без копирования данных из vlib_buffer_t. Это обеспечивает максимально возможную производительность и низкую задержку.
Мы отказались от поддержки множества платформ и специфичных протоколов, не используемых в нашем продукте. Это позволило упростить и удешевить каждую операцию.
-
Мы смогли спроектировать движок исключительно под нужды нашего продукта, так что:
не тащим за собой абстракции для поддержки десятков ОС и сетевых драйверов;
aлгоритмы и структуры данных движка оптимизируются под конкретные виды сигнатур и паттернов, используемых нашими аналитиками, что дает выигрыш в скорости проверки.
Полный контроль над жизненным циклом. У нас нет внешней зависимости от команды Suricata в вопросах разработки, выпуска обновлений правил и исправлений. Накопленная в компании экспертиза и инфраструктура для тестирования и обновления баз правил позволили быстро наладить этот процесс.
Реализованные архитектурные решения отличаются большим потенциалом для улучшения поисковой логики. Уже сейчас мы расширили синтаксис используемых правил в формате Suricata. Это позволило повысить качество обнаружения и снизить количество ложных срабатываний при детектировании ряда классов угроз по сравнению с первоначальным синтаксисом правил Suricata.
Мы оставили совместимость с исходным форматом правил Suricata, сохранив таким образом ключевое преимущество — большую базу готовых правил.
На текущий момент можно сказать, что переход на собственный движок себя оправдал. Ряд внутренних тестов показал, что в интересующих нас сценариях обработки трафика и детектирования сетевых атак наше решение уже опережает вариант с интеграцией Suricata по производительности и качеству детектирования. Причины тут простые. Как я и описал выше, нам не нужны дополнительная синхронизация и копирование трафика под наш движок, у нас развязаны руки в плане оптимизации его работы для конкретных сигнатур. При проектировании наших IDPS- и DPI-движков мы заранее предусмотрели в их архитектуре широкий спектр возможностей для оптимизации и кастомизации функционала под возможные изменения требований к продукту.
Так, наряду с базовыми функциями анализа сетевого трафика, мы реализовали в нашем модуле IDPS возможность профилирования и семантического анализа детектирующих правил. Благодаря этому функционалу наши эксперты и аналитики получили в свое распоряжение инструментарий, позволяющий не только подготавливать для модуля IDPS качественные фиды, но и осуществлять их анализ и оптимизацию с точки зрения производительности на разных профилях сетевого трафика.
TCP-Proxy — разбор трафика на уровне L4–L7 и MITM
Пакеты, прошедшие первичную проверку и требующие более глубокого анализа, например для сессий, отнесенных к определенным зонам (сценарии и возможности настройки NGFW в данной статье я подробно не рассматриваю, их можно при желании найти в документации), направляются на следующий этап. Это L4-обработка и при необходимости инспекция на уровнях L7.Как я писал в начале статьи, в ранних реализациях для этих целей мы использовали функционал локальных сокетов из VPP. Для этого надо заставить VPP думать, что forward-трафик на самом деле предназначен для текущего хоста. В этом случае VPP отправляет его на подмножество TCP-/UDP-узлов. Этот функционал в VPP задуман для того, чтобы была возможность реализовывать приложения, работающие со стеком VPP через обычную абстракцию сокетов. Предоставлять доступ к локальному трафику необходимо как из открытых в разных процессах сокетов, так и из нескольких процессов одновременно. Поэтому в VPP этот функционал копирует данные из пакетов в отдельную очередь, расположенную в shared memory.
Для задач NGFW данный функционал избыточен по следующим причинам:
нарушается принцип zero-copy, т. к. данные (tcp payload) из vlib_buffer_t копируются в отдельную очередь;
возникают накладные расходы на синхронизацию. Очередь данных размещается в shared memory (компонент svm_fifo в VPP) и содержит механизмы синхронизации, так как к ней возможен доступ нескольких процессов;
socket api избыточен для проверки трафика движками и для реализации MITM.
Мы сильно переработали эту часть VPP и сейчас активно идем в направлении подхода zero-copy при анализе L4–L7-трафика. Отказ от абстракции socket api, удаление функционала application и упрощение сценариев работы FIFO-очереди уже дали нам возможность увеличить производительность по сравнению с первоначальной версией. Мы продолжаем работы в этом направлении и в ближайшей перспективе планируем полностью отказаться от копирования данных из vlib_buffer_t в рамках сценариев работы TCP-прокси.
После получения упорядоченного потока данных определяется наличие шифрования. Сессии, подпадающие под правила MITM (Man In The Middle), расшифровываются. Тут, к счастью, нам не пришлось разрабатывать парсеры протоколов L5–L7 и движки MITM с нуля — мы смогли использовать готовые, отработанные реализации из других продуктов компании. Это, с одной стороны, сильно облегчило нам задачу, т. к. не пришлось реализовывать парсеры самостоятельно, а с другой, оставило большое поле для доработок в плане улучшения производительности данных парсеров с учетом описанной специфики data plane VPP.
Настройка инфраструктуры MITM (включая распространение корневых сертификатов на конечные устройства внутри периметра заказчика) — это отдельная обширная тема, выходящая за рамки этой статьи. Подробную информацию по ней можно найти в официальной документации нашего продукта.
После завершения разбора протоколов (L4–L7) и, при необходимости, расшифровки трафика данные снова передаются на движки безопасности. Ключевое отличие на этом этапе:
Режим работы: движки (DPI, IDPS) теперь работают в потоковом режиме (streaming mode), имея доступ к полному, упорядоченному и расшифрованному потоку данных приложения.
-
Расширенный функционал: к проверкам добавляется антивирусный анализ:
Проверка URL на репутацию.
Анализ и сканирование передаваемого контента.
Выявление и проверка передаваемых в трафике файлов с помощью механизмов антивирусного сканирования.
Этот этап является финальным рубежом проверки, где применяются самые сложные и ресурсоемкие правила, требующие полного контекста сессии для принятия точного решения.
Control Plane, протоколы динамической маршрутизации и их интеграция с VPP
NGFW редко работает в изоляции. В реальных сетях его часто необходимо интегрировать в существующую инфраструктуру с динамической маршрутизацией (OSPF, BGP). Однако код VPP, в первую очередь предназначенный для создания высокопроизводительных data plane-решений, не содержит реализаций для этих сложных протоколов.
Для ускорения процесса разработки мы решили не портировать стеки протоколов динамической маршрутизации напрямую в VPP. Вместо этого мы использовали проверенный временем подход разделения обязанностей:
VPP (Data Plane): выполняет высокоскоростную обработку пакетов, применяет политики безопасности, ведет сессии.
Специализированный демон (Control Plane): работает в user space и отвечает за вычисление таблиц маршрутизации через протоколы BGP, OSPF и т. д.
Задача заключалась в том, чтобы организовать эффективный обмен информацией между ними.
Для решения этой задачи мы решили использовать Linux Control Plane plugin (LCP), входящий в состав VPP.
Взаимодействие плагина и control plane демона происходит следующим образом:
LCP-плагин создает в VPP интерфейсы типа TAP (vnet-tap) и соединяет их с их же виртуальными TAP-аналогами в пространстве ядра Linux (tap0, tap1 и т. д.).
Демон, работающий в том же пользовательском пространстве Linux, видит эти интерфейсы через стандартный Netlink-интерфейс ядра и через Netlink загружает для них в ядро маршруты, полученные и вычисленные в рамках протоколов динамической маршрутизации от соседей (peer) по сети.
LCP- плагин, будучи подписанным на Netlink-сообщения, перехватывает эти маршруты и загружает их в Forwarding information base (FIB) таблицу VPP.
Однако на этом пути мы выяснили, что LCP-плагин и код работы с TAP-интерфейсами в VPP — это одни из наименее стабильных и оптимизированных компонентов фреймворка. Уже на раннем этапе мы обнаружили в них критические проблемы, которые пришлось исправлять.
Множественные случаи приведения u8* к char* в коде (пример). Тут проблема в том, что VPP хранит имена интерфейсов и другие строковые литералы в виде vec -представлений (vec-данные в коде не имеют специального типа, а хранятся просто как u8-указатели). При таком хранении строковые литералы не обязаны завершаться нулевым символом. Поэтому его там часто и вовсе нет. Преобразование к char* без явной проверки на наличие нуля в конце строки ведет к чтению за границей выделенной памяти и/или неправильной логике работы.
Потери указателя на элемент из коллекции. Общий сценарий. Получили указатель (1) на элемент из коллекции (2). Вызвали функцию (3), которая в рамках своей работы вызывает подписчиков (например, функция добавления нового VPP-интерфейса). LCP-плагин является таким подписчиком и в обработчике меняет коллекцию (2). После возврата из функции (3) указатель (1) становится не валиден, если в обработчике случилась переаллокация коллекции (2).
-
Отсутствие барьера при переаллокации коллекции (пример). В этом примере при добавлении нового TAP-интерфейса может произойти переаллокация пула. При этом на главном потоке VPP указатель на объект пула изменится, а рабочие потоки могут (например, из-за некогерентного состояния кэша процессора, на котором исполняется текущий поток) продолжить работать со старым значением этого указателя, ссылающимся на уже освобожденную память.
Общий в VPP подход для такого случая:
u8 need_barrier_sync = pool_get_will_expand(пул); // проверяем, будет ли переаллокация
if (need_barrier_sync)
{
vlib_worker_thread_barrier_sync(vm); // захватываем барьер, только если будет переаллокация, т. е. указатель на пул поменяется
}
// получаем объект из пула
if (need_barrier_sync)
{
vlib_worker_thread_barrier_release(vm); // освобождаем барьер
}
В приведенном примере авторы кода забыли про этот момент. Если такая ситуация случилась и кто‑то еще успел занять и перетереть память от старого пула, происходит падение.
4. Для IPv4 в ядре Linux есть интересная бага/фича, когда ядро не оповещает об удалении маршрутов подписчиков через Netlink-сокет. Проблема известная, в ряде open source-проектов (FRR, pyroute2) есть баги, вызванные ею. Разработчики сетевой подсистемы ядра Linux исправлять эту проблему на стороне ядра отказались в 2013 году из опасений получить слишком много сообщений. При этом для IPv6 этой проблемы в ядре нет.
В LCP-плагине также есть комментарии и исправления на этот счет. Тем не менее там ошибка исправлена не полностью. А конкретно — нет исправления для сценария, когда происходит удаление IP-адреса на интерфейсе со стороны Linux. В этом случае в FIB-таблице в VPP остается некорректный маршрут, хотя он должен быть удален.
Подобные (1–3) ошибки попадались нам и в других местах в коде VPP. Но их концентрация была сильно выше конкретно в LCP-плагине и коде по работе с tap-интерфейсами.
Заключение
Разработка современного высокопроизводительного сетевого экрана следующего поколения (NGFW) — это сложная инженерная задача, требующая глубокой оптимизации на всех уровнях стека. Как показал наш опыт, использование таких фреймворков, как VPP и DPDK, является не просто опцией, а необходимостью для достижения требуемых показателей производительности на многогигабитных линиях связи.
Однако выбор в пользу этих технологий — это лишь начало пути. Их истинная сила раскрывается только через глубокую интеграцию, тонкую настройку и зачастую существенную доработку. Как мы убедились, стандартные универсальные механизмы VPP, такие как локальные сокеты для L4-обработки или использование Linux Control Plane для интеграции с решением по динамической маршрутизации, нередко несут в себе избыточные накладные расходы и скрытые проблемы. Это делает их непригодными для использования «из коробки» в коммерческом продукте.
На наш взгляд, ключевыми факторами, которые помогли оптимизировать процесс разработки NGFW, стали:
Неукоснительное следование принципам zero-copy и отказ от любых необязательных копирований данных между процессами или внутри конвейера.
Глубокая переработка критичных участков кода VPP с целью устранения узких мест и обеспечения реальной, а не теоретической производительности.
Создание собственных высокоспециализированных компонентов (таких как движки DPI/IDPS), которые лишены свойственной движку Suricata универсальности, выигрывают в скорости и эффективности за счет тесной интеграции с data plane.
Тщательный аудит и отладка всего стека, от аллокаторов памяти до межпоточной синхронизации, для обеспечения стабильности и отказоустойчивости в условиях высокой нагрузки.
Результатом этой работы стала не просто еще одна реализация NGFW, а платформа, способная линейно масштабироваться с ростом числа ядер и эффективно обрабатывать трафик в режиме, приближенном к пропускной способности физического канала, обеспечивая при этом весь комплекс функций безопасности — от классического фаервола и NAT до глубокого анализа содержимого и MITM-проверки зашифрованного трафика.
Наш опыт наглядно демонстрирует, что создание конкурентоспособных сетевых решений сегодня требует не только умения использовать готовые сторонние компоненты, но и готовности погружаться в их код, понимать их внутреннюю механику и смело переделывать их для своих нужд, балансируя между производительностью, функциональностью и сложностью поддержки.
bigger777
Очень крутая статья.
Получается, пакеты протоколов динамической маршрутизации инспектируются в одном из ваших модулей VPP и попадают сразу в control plane?