
Когда переход на VXLAN в облачных сетях грозил нарушить работу системы анализа трафика, нам нужно было найти решение, позволяющее сохранить точный сбор статистики при экстремальных нагрузках и измененной структуре заголовков пакетов. Я — Александр Шишебаров, старший разработчик в команде сетевых функций облака Selectel. Разрабатываю все, что связано с сетью: балансировщики, виртуальные роутеры, сети, глобальный роутер и так далее.
В этой статье рассказываю о том, как мы использовали eBPF для перехвата и декапсуляции VXLAN-пакетов прямо в ядре, обеспечив корректный сбор статистики без значительных изменений в архитектуре системы. Разберем, какие требования привели нас к этому решению, как его внедряли и каких результатов удалось достичь. Также расскажу, что такое eBPF, как работает технология, как начать с ней работать и на каких этапах сетевого стека Linux можно перехватывать пакеты с ее помощью. Подробности под катом!
Сеть Selectel в облаке
Рассмотрим, как устроена наша сеть в облаке:

В нашем облаке есть серверы с гипервизорами, запускающими виртуалки клиентов, которым необходимо предоставить доступ в интернет. Для этих целей в каждом регионе есть несколько железных роутеров — два как минимум. Чтобы дать гипервизорам доступ в интернет, между ними и роутерами организована физическая сеть. Как правило, она построена по топологии Clos, хотя может использоваться и другая архитектура. Изначально связность между виртуалками и этими роутерами была организована по технологии VLAN.
Проблемы сети при работе с VLAN
Одна из самых старых услуг, запущенных с момента релиза первого облака, называется «Публичные сети». Она позволяет присвоить виртуальной машине IP-адрес напрямую, используя наши физические роутеры в обход виртуальных. Для этой услуги изначально была выбрана технология VLAN — она была наиболее простой и хорошо вписывалась в архитектуру сети того времени.
VLAN — это технология построения виртуальных L2-сетей. Она позволяет на уровне оборудования объединить некоторые серверы в виртуальные L2-домены, которые не пересекаются друг с другом. По сути, это расширение протокола Ethernet, добавляющее к стандартным заголовкам специальный тег.
Покажу, как это выглядит на практике.
Рассмотрим формат Ethernet-пакета, генерируемого виртуальными машинами в облаке.

Этот клиентский пакет состоит из payload (полезной нагрузки) и заголовков уровня L4, L3 и L2. Мы перехватываем его в своей SDN-сети, добавляем QinQ-тег (двойной тег) и отправляем в физическую сеть.

Пока облако было небольшим — условно, десятки compute node (гипервизоров) — проблем не наблюдалось. Однако с ростом, когда число нод увеличилось до сотен, а порой и до тысяч, ситуация изменилась.
Например, виртуальная машина на гипервизоре 1 генерирует пакет для отправки в интернет.

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

Физическая сеть должна доставить этот пакет всем получателям в в этом vlan на уровне L2. Но из-за того, что «верхний» qinq VLAN, как правило, распространяется на все гипервизоры и роутеры, физическая сеть копирует этот пакет и амплифицирует его по всей сети.

Особенно это заметно на бродкастных пакетах, например ARP-запросах, когда пакет, предназначенный от гипервизора 1 к роутеру 1, амплифицируется на всю сеть облака. В результате все гипервизоры и роутеры вынуждены обрабатывать эти пакеты, даже если они им не предназначены.
В процессе роста облака начались проблемы именно с физической сетью, потому что коммутаторам, на базе которых она построена, приходилось заносить все MAC-адреса всех виртуалок во всем облаке к себе в FDB-таблицы. В результате все тормозило. А физическую сеть становилось тяжело обслуживать из-за растянутого VLAN. В итоге мы приняли решение переписать SDN, чтобы избежать этих проблем, и перейти на технологию VXLAN.
Переходим на VXLAN
VXLAN — это технология для создания overlay-сетей поверх физических, в которой используются туннели на основе UDP. В исходный пакет добавляются заголовки L2, L3 и L4. Таким образом, строится туннель от источника к назначенным получателям, и пакет отправляется только тем, кому действительно нужен.
В SDN-сети мы перехватываем исходный пакет, генерируемый клиентской виртуальной машиной, и добавляем к нему следующие заголовки:
- VXLAN;
- L4 заголовок — UDP с портом 4789 (стандартный порт по RFC, закрепленный за VXLAN);
- L3 заголовок;
- L2 заголовок.
Вот как выглядит структура пакетов:

После этого пакет отправляется в физическую сеть и это происходит иначе.
Допустим, на гипервизоре 1 клиентская виртуальная машина хочет выполнить ARP-запрос к роутеру 1. Вместо того, чтобы рассылать бродкастный пакет по всей сети, как это было с VLAN, наша SDN-сеть строит туннель напрямую от гипервизора до маршрутизатора и отправляет «юникастный» пакет, который не амплифицируется и доходит только до роутера.

Мы подготовили и протестировали это решение, после чего перешли на эту технологию в одном из наших стейджинговых регионов, включив все возможные проверки, чтобы отловить как можно больше ошибок.
Переход прошел гладко, и мы уже готовились внедрить VXLAN в прод. Однако перед самым переходом к нам обратилась смежная команда, занимающаяся анализом сетевого трафика. Они показали график, на котором наблюдалась существенная просадка получаемой ими статистики. Она совпадала по времени с нашим переходом на VXLAN в стейджинговом регионе.
Статистика с тестовой ноды выглядела так: трафик со статистикой упал с 30 до 15 мегабайт в секунду.

Поиск причины просадки трафика
Мы решили разобраться в ситуации и помочь коллегам выяснить, куда пропала часть статистики по трафику. Напомню, как выглядели пакеты до и после:

Видно, что во втором случае мы закрываем клиентский пакет внешними заголовками. А вот как устроена система сбора статистики:

В нашей инфраструктуре есть маршрутизаторы, о которых я упоминал ранее, и наша команда выяснила, что в облаке существуют так называемые сервис-ноды, куда зеркалируется весь трафик, направляемый в интернет и обратно. На этих сервис-нодах запущен софт, который собирает трафик и статистику по протоколу NetFlow. Это протокол, разработанный компанией Cisco, который предназначен для сбора информации о сетевом трафике. Он позволяет определить, сколько байт прошло между конкретными источниками и получателями, с какого порта на какой, и передает эту статистику в нашу систему анализа.
Устройство сервис-ноды
Изучив работу сервис-ноды изнутри, мы обнаружили, что на каждой ноде установлено минимум четыре двухпортовых 40-гигабитных сетевых карт, которые работают в promiscuous-режиме.

Из этих карт ядро Linux перехватывает пакеты с помощью системы netfilter (iptables) и передает их специальному модулю ipt_NETFLOW, который предназначен для сбора статистики и экспорта NetFlow в Linux-системах.
Передача происходит так: в таблице raw в цепочке PREROUTING пакеты с определенных интерфейсов направляются в модуль ipt_NETFLOW. Этот модуль обрабатывает пакеты и отправляет полученную статистику в систему анализа трафика.

Стало ясно, что виновник проблемы — модуль ipt_NETFLOW, который начал пропускать те самые пакеты после того, как мы добавили сервисные заголовки VXLAN.

В итоге наша задача стала выглядеть так:
1. Поддержать новый формат трафика. Мы уже достаточно долго проектировали данный переход на VXLAN и саму сеть, поэтому отказаться уже не могли.
2. Разработать решение в короткие сроки. Работы по переходу уже были запланированы, и многие команды ориентировались на эти сроки.
3. Сохранить текущий стек анализа сетевых пакетов и по возможности обойтись без добавления оборудования. Стек был собран уже давно — нас попросили его не трогать. Вообще, в идеале нужно было обойтись только той сервисной нодой, которая запущена в конкретных регионах.
Придумываем решение
Мы внимательно проанализировали нагрузку на сервис-ноде: на нее прилетали миллионы пакетов в секунду. При такой интенсивной нагрузке первым вариантом, который пришел в голову, стало использование kernel bypass — DPDK, Netmap или PF_RING.
Главный плюс этих решений — в их скорости: kernel bypass работает очень быстро. Но есть минусы: сложность реализации, поддержка драйверов и то, что технология работает в userspace и забирает пакеты напрямую из сетевых карт. В этом случае нам нужно было бы возвращать пакеты обратно в ядро, что не очень хорошо. Решили не использовать этот подход.
Дальше рассмотрели возможность доработки модуля ipt_NETFLOW. При использовании этого решения не добавляется новых компонентов в стек анализа трафика, что хорошо. В качестве теста удалось даже захардкодить в этот модуль декапсуляцию. Но тут возникли две проблемы.
- Сам модуль написан в крайне нечитабельном виде. Все собрано в одном файле на 6 000 строк.
- В процессе доработки этого модуля несколько раз ловил баг, из-за которого сервер периодически падал с Kernel Panic. Кроме этих минусов обнаружились еще необходимость поддержки своего форка ipt-NETFLOW и адаптация его для новых версий ядра.
В итоге решили не рисковать и пойти другим путем — eBPF.
Основные плюсы eBPF
- Встроен в ядро — ничего не нужно патчить.
- Защита от ошибок — предотвращает повреждение работы ядра при использовании.
- Высокая производительность (по крайней мере, по документации).
- У меня уже был небольшой опыт работы с eBPF, что позволяло быстро начать разработку.
Единственный существенный минус — неопределенность, есть ли возможность при помощи eBPF перехватить сетевой пакет до того, как его обработает ipt_NETFLOW, тот самый ядерный модуль.
Прикручиваем eBPF
Мы имеем дело с подсистемой ядра Linux, которую иногда называют виртуальной машиной. eBPF обладает собственным набором команд, напоминающих ассемблерный код для x86-процессоров, а также набором виртуальных регистров. Подсистема позволяет запускать пользовательский код из userspace в контексте ядра, при этом проверяя его на безопасность и валидность. Также eBPF обеспечивает обмен данными между userspace и kernel-space программой — например, при помощи ebpf maps или ring buffer.
В ядре eBPF может вызываться по множеству событий — полный список можно найти в документации к BPF. Нас интересовали именно те события, которые можно перехватить на уровне сети.
eBPF network hooks
Было три основных варианта eBPF-хуков для перехвата сетевых пакетов.
1. eBPF eXpress Data Path (XDP). Первый вариант, попавшийся в документации, XDP — это hook для перехвата ingress-пакетов, то есть пакетов, которые прилетают на сервер. Он поддерживает три режима работы.
- Offload — eBPF-программу можно загрузить непосредственно в сетевую карту. На момент тестов, по информации из интернета, этот режим поддерживает только карта BlueField, которой у меня не было для тестов, поэтому его сразу решили не использовать.
- Native(driver) XDP — запуск XDP-программ внутри драйвера сетевых карт.
- GenericXDP — запуск XDP-программ на уровне стека Linux.
2. Traffic Control (TC). Здесь используются хуки в подсистеме TC. Такая утилита позволяет, например, навешивать cost policy на интерфейсы. Можно привязать eBPF-программу в качестве TC-фильтра и перехватывать как ingress, так и egress-пакеты и выполнять с ними различные действия.
3. Обработчики на уровне сокетов: Socket selection, Socket filter и TCP congestion control callbacks. Они нам не подошли, потому что явно ipt-NETFLOW перехватывает пакеты раньше чем вызовутся обработчики на уровне сокетов.
Итак, мы нашли два способа, которые можем использовать — это XDP и TC-хуки. Но было непонятно, можно ли с их помощью перехватить пакеты раньше ipt_NETFLOW. Поэтому решили глубже заглянуть в сетевой стек и посмотреть, на каких этапах pipeline-обработки пакетов происходит перехват событий.
Обработка пакетов ядром Linux
Когда сетевой пакет попадает на сетевую карту, устройство сначала его анализирует: проверяет CRC-суммы и валидность. После этого пакет копируется в заранее выделенные регионы памяти, организованные в виде кольцевых буферов. С помощью технологии DMA (Direct Memory Access) сетевая карта напрямую загружает пакеты в оперативную память, минуя центральный процессор, а также формирует специальные дескрипторы, указывающие на расположение данных в кольцевых буферах. При этом каждая сетевая карта использует свой формат дескрипторов.
Аппаратные прерывания
После этого сетевая карта возбуждает прерывание у центрального процессора. В ответ на это активируется обработчик аппаратного прерывания — его загружает сетевая карта в момент инициализации. Это тоже часть драйвера сетевой карты.

Здесь проблема в том, что нужно отработать как можно быстрее, потому что в процессе, пока CPU отрабатывает аппаратное прерывание, он не может выполнять никакой другой код. Более того, он не может принимать другие аппаратные прерывания, например, от сетевых карт или других устройств. Поэтому обработчики аппаратных прерываний делают максимально легковесными: они планируют так называемое отложенное прерывание, помещая в ядро специализированную структуру с описанием предстоящих действий, а затем завершают работу. Такой цикл повторяется для каждого входящего пакета.
Отложенные прерывания
В современных версиях ядра Linux система отложенных прерываний работает в режиме NAPI (New API). Принцип ее работы такой:

В структуре NAPI-struct, которую драйвер сетевой карты регистрирует в ядре, содержится ссылка на Polling-функцию.
- Задача NAPI — запустить эту функцию, предоставив ей определенный промежуток времени на работу. Одновременно с запуском Polling-функции NAPI отключает аппаратные прерывания от сетевой карты.
- Задача Polling-функции — собрать как можно больше пакетов за выделенное время, пока прерывания отключены, а затем сконструировать для них структуры sk_buff (Socket Buffer) и передать их в стек ядра Linux в максимально возможном количестве за отведенное время.
На этом этапе можно перехватить пакеты при помощи eBPF в XDP hook в режиме драйвера. То есть внутри Polling-функции драйверов сетевых карт можно перехватить пакеты и передать их системе eBPF на обработку.
Пример реализации можно увидеть в драйвере для 10Gb сетевых карт Intel, где присутствует вызов bpf_prog_run_xdp с передачей подготовленного ею пакета:

Если для какого-то драйвера отсутствует документация, можно проверить, поддерживает он или нет, если грепнуть по запуску этого хука — он везде одинаковый.
Обработка пакета в стеке ядра
После доставки пакета к ядру начинается его обработка. На самом деле, есть еще много промежуточных этапов, связанных с маршрутизацией этих пакетов между ядрами CPU. Но пакет так или иначе доходит до функции netif_receive_skb_core.
Функция начинает обрабатывать пакет
1. Сначала функция удовлетворяет packet taps — обработчики пакетов, такие как tcpdump, зарегистрированные на уровне Ethernet. Именно здесь, согласно исходному коду, модуль ipt_NETFLOW перехватывает пакеты.
2. После обработки packet taps пакет доставляется в систему Linux TC, после которой в пакете анализируются заголовки третьего уровня и ищется подходящий обработчик протоколов третьего уровня, например IP или IPv6.
3. Внутри этих обработчиков происходит обработка заголовков L3, проверка чек-суммы, если необходимо.
4. Далее вызываются таблицы Iptables (PREROUTING, INPUT и т. д) и выполняется маршрутизация. Если Linux-сервер работает в качестве маршрутизатора, этот пакет может быть перенаправлен в исходящую очередь другой сетевой карты на основании правил маршрутизации. Если же пакет предоставлен этому серверу, то L3-заголовок снимается, дальше анализируется заголовок L4 и происходит поиск протоколов, которые себя зарегистрировали как L4-протоколы, например TCP или UDP.

5. Для TCP/UDP проверяются заголовки, вычисляются контрольные суммы при необходимости. Для TCP также проверяется, установлено ли соединение — является ли пакет промежуточным в соединении TCP. Если соединение найдено, происходит поиск открытого сокета, и пакет помещается в очередь сокета, откуда его может прочитать приложение.
Здесь при помощи eBPF можно перехватить трафик именно до packet taps, используя XDP generic hook.

Важно, что если пакет будет изменен на этом уровне, то с помощью tcpdump это уже не отследить, потому что packet taps обрабатываются позже.
Альтернативный вариант — использовать Linux TC. Здесь сразу видно, что TC нам не подошел, потому что модуль ipt_NETFLOW перехватывает пакет раньше по pipeline обработки.
Пример вызова XDP в ядре Linux
Далее примеры исходного кода функции netif_receive_skb_core, с описанием этапов обработки пакетов. На иллюстрации — листинг из ядра шестой версии, где троеточием обозначены упущенные листинги, но порядок вызова функций, выделенных красным, сохраняется.
Сначала вызываем xdp_generic-режим:

Далее пакет доставляем в Packet Taps, итерируемся по глобальному списку зарегистрированных обработчиков и при помощи функции deliver_skb передаем этим обработчикам пакеты:

После этого срабатывает система TC, отвечающая за планирование (Scheduling) и QoS (Quality of Service):

Затем происходит поиск обработчиков пакетов третьего уровня — и пакет доставляется им:

В итоге приходим к тому, что Hook TC нам не подходит — он перехватывает пакеты позже, чем их успевает забрать ipt_NETFLOW. Остается только eBPF XDP Hook.

Разработка eBPF
Как вообще происходит разработка программ с использованием eBPF? Независимо от выбранного типа хука, каждая eBPF-программа состоит из двух частей: kernel-space и userspace.
Kernel-space
Ядерная часть пишется на подмножестве языка C, которое часто называют «C Limited». Это ограниченная версия языка, которая накладывает несколько строгих правил.
- Нельзя использовать бесконечные циклы. В ранних версиях циклы вообще нельзя было использовать, их приходилось разворачивать вручную при помощи специальных прагм компилятора.
- Нельзя динамически выделять память. Можно выделять память только на виртуальном стеке BPF. Для хранения данных между запусками этой программы можно использовать специальные структуры — bpf-мапы.
- Запрещён выход за пределы контекста. У каждой ядерной части BPF-программы есть тип. В зависимости от типа, в программу предоставляется ограничивающий контекст. Если хочется получить дополнительную информацию, можно воспользоваться различными bpf-helper’ами.
- Все части ядерной программы должны быть достижимыми. И verifier это проверит. Если обнаружится, что какая-либо ветка никогда не выполняется, программа не будет принята в ядро.
- Количество eBPF-инструкций не должно превышать 1М. Это те самые ассемблерные инструкции, в которые компилируется программа.
- Программа обязательно должна завершаться. И verifier это строго проверит.
В качестве примера можно привести XDP-программу, которая практически ничего не делает:

Здесь «инклюдятся» необходимые заголовочные файлы для успешной компиляции. А самой программе передается контекст xdp_md, содержащий информацию о начале и конце пакета, а также о номере интерфейса. За пределы этого контекста в программе XDP выходить нельзя. Иными словами, вы получаете набор байтов, которые можно анализировать, изменять и на основе их содержимого принимать решение.
При этом программа должна вернуть целочисленное значение, которое сигнализирует системе BPF, что делать с пакетом дальше: перенаправить его на другой интерфейс, дропнуть или просто передать дальше по сетевому стеку.
Цикл разработки и запуска eBPF
Когда kernel-space программа готова и скомпилирована, например, с помощью Clang, на выходе получается объектный файл в формате ELF. В его секциях прописаны XDP-программы и bpf-мапы. Следующий шаг — загрузить эту программу в ядро.

Для этого выбираете любимый язык программирования, удовлетворяющий двум требованиям.
- Умеет читать ELF-файлы и парсить их секции.
- Умеет выполнять системные вызовы, в частности — системный вызов bpf, с помощью которого можно загрузить программу в ядро.
Рассмотрим процесс.
1. Берете файл, парсите и затем, при помощи системного вызова bpf, программа пытается загрузиться в ядро.
Первым шагом система eBPF проверит программу при помощи verifier на все ограничения, что программа завершается, в ней нет ошибок и она безопасна.

Пропустить эту проверку нельзя.
2. Следующий шаг, частично опциональный, — когда сработает JIT-компилятор для eBPF:

Система eBPF может скомпилировать вашу программу под архитектуру процессора, на котором запущено ядро Linux. Если JIT отключить, подсистема bpf будет каждый раз в режиме интерпретатора обрабатывать ваши инструкции.
3. В ответ вы получаете файловые дескрипторы, указывающие на загруженную программу и bpf-мапы, с которыми можно работать дальше.

Например, для XDP-программ нужно уметь делать вызов bpf типа BPF_LINK_CREATE с передачей дополнительных аргументов и указать, к какому интерфейсу программа должна быть привязана.

После этого программа цепляется к соответствующему хуку. И каждый пакет, поступающий на интерфейс, к которому ее прилинковали, будет обрабатываться eBPF-программой каждый раз, без исключения.
Библиотеки для разработки eBPF-программ
Чтобы не реализовывать системные вызовы в «сыром» виде, для userspace программ уже разработан ряд удобных библиотек.
- libbpf (написана на C) — основная библиотека для разработки на C. Все, что появляется в BPF, первым реализуется именно здесь.
- BCC — библиотека для Python. Частично написана на Python, частично на C. Здесь уже реализован ряд готовых программ для мониторинга, трейсинга и других задач. Можно писать собственные программы прямо из Python и компилировать их «на лету».
- Cilium ebpf — библиотека для Go, используется в одноимённом CNI-плагине для Kubernetes, где используется eBPF для сетевой плоскости. Библиотека написана на чистом Go, в ней нет вставок на C. Крайне удобная в использовании.
- libbpfgo — обертка над libbpf.
- Libbpf-rs, Aya — библиотеки для разработки на Rust.
Перейдем непосредственно к тому, что нам приходилось делать.
Анализ и декапсуляция сетевых пакетов
Напомню, что в XDP-программе, сетевой пакет передается в виде последовательности байт. Задача — интерпретировать эту последовательность, провести валидацию и принять решение о дальнейших действиях.
Первый этап — найти смещения заголовка L2.

Здесь основная проблема — в динамическом размере заголовка. Наши сетевики периодически могут навешивать служебные VLAN'ы, а порой даже стек VLAN'ов, поэтому необходимо правильно распарсить VLAN-заголовки, чтобы аккуратно эти VLAN'ы снимать, не удалив лишних данных.
После того, когда нашли смещение L2-заголовка в байтах, мы перемещаемся к заголовку L3, чтобы проверить IP-адреса относительно фильтров BPF-программы.

Мы проверяем, наш ли это служебный трафик, потому что не можем декапсулировать все пакеты. Причина этого этапа в том, что туда летят, в том числе, пакеты, не инкапсулированные в VXLAN. Для этого мы проверяем их по заранее определенным спискам. Если совпадают, проверяем следующий заголовок — это UDP.

Убеждаемся, что порт назначения или источника равен 4789. Если это так, значит это наш клиент.

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

Для этого есть helper — bpf_xdp_adjust_head, смещающий указатель в начало сетевого пакета на количество байт, которое ему передано, в большую или меньшую сторону. Если смещение происходит в сторону уменьшения, пакет декапсулируется. В итоге исходный пакет, отправленный клиентом, передается стеку ядра Linux, где его перехватывает и анализирует модуль ipt_NETFLOW.
Результаты: что получилось
Оптимизировали код
Вся логика декапсуляции уместилась в 205 строк кода eBPF — это именно kernel-space программы. Изначально программа насчитывала свыше 900 строк, поскольку в ней реализовывалась обширная логика, добавляющая различный функционал. Но оказалось, что когда прилетает огромное количество — буквально миллионы пакетов в секунду — приходится бороться за каждый такт процессора. Поэтому после тестов мы максимально оптимизировали программу, а логику, подсчет статистики и сложные вычисления вынесли из ядра в userspace.
Программа в kernel-space просто условно декапсулировала пакеты и обновляла ряд счетчиков.
Реализовали часть Userspace на Go
Мы использовали библиотеку Cilium eBPF. Она обеспечивает управление kernel-программой, позволяет отслеживать ее состояние и делать замену «на ходу» Также в userspace был реализован контроль за ее работой. А в случае, если что-то идет не так, система «алертинга» сигнализирует об этом.
Провели нагрузочное тестирование
В синтетических тестах с TRex при максимальной нагрузке в десятки миллионов пакетов в секунду утилизация CPU на обработку прерываний (IRQ) скачкообразно увеличивалась на 7-10%. Этот результат получили в худшем сценарии, когда каждый пакет проходил полный путь обработки. Для примера: в продакшене прирост утилизации CPU составил около 3%. В час-пик один сервер с eBPF обрабатывает 6 MP/Sec (35-38 GB/sec) при суммарной нагрузке на CPU порядка 20%.
Развернули сервис во всех регионах облака
В течение года утилита без проблем обрабатывает трафик, ни разу не приходилось сталкиваться с тем, что в ней что-то не работало.
Выводы и рекомендации
Относительно результатов могу дать совет: максимально выносите всю сложную логику в userspace, оставляя в kernel-space лишь базовую функциональность, так как любые дополнительные вычисления в ядре для таких высоконагруженных систем могут значительно повысить нагрузку на CPU.
Более того, использование xdp в драйверном режиме «забирает» процессорное время у polling-функции. Это приведет к тому, что функция будет собирать меньше пакетов из очередей драйвера — значит, вырастет количество прерываний (IRQ), обрабатываемых CPU. Итог: драматически упадет производительность.
Не стоит воспринимать eBPF как «серебряную пулю»: в eBPF на текущий момент множество ограничений. Основное ограничение — требование уложиться в один миллион инструкций eBPF. А для событий которые происходят достаточно часто, например получение сетевого пакета, eBPF может значительно увеличить нагрузку на CPU, поэтому логика программы должна быть максимально лаконичной.