Привет, Хабр!
Я работаю сетевым инженером в компании, которая занимается разработкой софта. В этой статье расскажу о том, как мы собираем статистику сетевого трафика, и о трудностях, с которыми столкнулись и успешно справились.
Когда речь идёт о расследовании инцидентов, связанных с безопасностью своих ресурсов, «приблизительной» статистики недостаточно — нужны подробности. Однако, встроенные в сетевое оборудование решения (вроде sFlow/NetFlow) с этим, как правило, не справляются:
попытки передать полную телеметрию слишком сильно нагружают сетевое «железо»;
семплирование sFlow не позволяет получить наблюдаемость на уровне каждой сессии;
экспорт специфических полей данных часто невозможен в принципе.
Поэтому мы реализовали собственную систему сбора данных. Она позволяет «видеть» не только трафик на границе сети, но и весь жизненный цикл каждой сессии.
За основу взяли eBPF/XDP и NetFlow. Почему именно их? Потому что у нас на BPF построена целая платформа для обработки трафика со своими нюансами и особенностями. NetFlow — это лишь один её элемент.
ИТ-инфраструктуру нашей компании можно условно разделить на 3 типа: физические серверы, виртуализация и Kubernetes-кластеры. У каждого из этих типов есть свои особенности сбора статистики, о которых расскажу далее.
Пару слов о eBPF
На первый взгляд, сетевику программирование ядра ОС с помощью eBPF может показаться магией. На деле — это технология, которая позволяет безопасно запускать программы прямо в ядре ОС. Для сетевого мониторинга это подходит идеально: благодаря высокой скорости обработки данных с минимальными задержками, можно собирать подробную информацию о сетевом трафике в реальном времени без изменений ядра и установки дополнительных модулей.
Пару слов о нашей платформе обработки трафика
Весь трафик (прямой и обратный) проходит через один интерфейс (как в «router on a stick»). Это позволяет использовать eBPF в режиме XDP не смотря на ограничение данной технологии — возможность работы только с входящим трафиком.
Сбор статистики с физических (baremetal) серверов
Принцип сбора состоит из нескольких простых шагов:
eBPF-программа ловит пакеты через XDP, собирает информацию о потоке (5-tuple, сколько байт и пакетов) и записывает её в eBPF-карту.
Программа-сенсор в пространстве пользователя читает данные из карт, готовит пакет в формате NetFlow и отправляет его в коллектор.
Выглядит это так:

Тип BPF-карт
В eBPF для «общения» между пространствами пользователя и ядра используются BPF-карты, в которых и хранится статистика. Карта — это, по сути, таблица типа «ключ-значение», где ключ — это набор L3/L4-заголовков пакета, а значение — это количество таких пакетов. При передаче новых наборов 5-tuple кол-во элементов в карте увеличивается.
Сначала нужно было выяснить особенности работы BPF-карт разных типов и выбрать из них подходящий. Для этого мы провели несколько тестов.
На первом этапе использовали карты типа BPF_MAP_TYPE_HASH, добавляя их под новые метрики. Чем больше таких карт мы использовали в программе, тем сильнее снижалась производительность. Проблема кроется в механизме синхронизации: когда несколько ядер одновременно пытаются обновить один и тот же счётчик, возникает конкуренция (lock contention), и процессоры вынуждены выстраиваться в очередь за право записать свое значение. Также мы обнаружили, что итоговые значения счётчиков не соответствуют реальному количеству трафика. Причина — race condition, когда несколько ядер одновременно считывают и перезаписывают одно и то же значение. Чтобы обеспечить корректность данных, мы добавили макрос атомарного сложения __sync_fetch_and_add. Проблема ушла, но производительность упала ещё сильнее из-за нагрузки на шину памяти при межъядерной синхронизации.
Переход на карты BPF_MAP_TYPE_PERCPU_HASH убрал конкуренцию. В таком варианте для каждого процессорного ядра создаётся отдельный экземпляр карты, и данные записываются только в свою локальную копию без блокировок и состояния гонки. Итоговое суммирование данных выполняется уже в пространстве пользователя при чтении карты.
Для сравнения производительности между BPF_MAP_TYPE_HASH и BPF_MAP_TYPE_PERCPU_HASH мы провели эксперимент: на сервере выделили 100 ядер под маршрутизацию и записывали метрики с различной интенсивностью сетевой нагрузки.

График показывает, что производительность при использовании PERCPU-карт значительно выше, чем при использовании HASH-карт.
Это отлично работает, если количество элементов в карте фиксировано и известно заранее. В ситуациях, когда объём данных непредсказуем, на помощь приходят карты типа BPF_MAP_TYPE_LRU_PERCPU_HASH. Они умеют автоматически вытеснять старые записи при переполнении карты, чтобы добавить новые данные.
Важно учитывать, что интенсивный процесс вытеснения снижает производительность. Если актуальность данных критична, и вы успеваете поэтапно считывать данные, то карты LRU станут хорошим выбором. Но, на наш взгляд, если данные можно получить за один системный вызов, обычная HASH-карта выглядит предпочтительнее, так как лишена дополнительных накладных расходов на пересчёт записей и удаление.
Мы провели сравнительный тест, который это подтвердил. Используя карту размерностью 60 тыс. записей, направили трафик в 600 тыс. уникальных 5-tuple на той же аппаратной платформе.

При полной утилизации карт (HASH/LRU overlimit) накладные расходы для LRU-карт снижают производительность. Но в ситуации, когда карта не заполнена (HASH/LRU w/o overlimit), скорость работы системы остаётся на высоком уровне.
Механизм шардирования BPF-карт
Итак, мы выбрали карты типа PERCPU_HASH. Дальше требовалось определить размер.
Сначала создали одну большую карту на 1 миллион записей. С ростом объёма уникального трафика её пришлось увеличить до 10 миллионов. Хотя работа с единой большой картой лишь незначительно увеличила расходы на добавление записей (около 3-5%), основным препятствием стал однопоточный экспортер в пространстве пользователя, который не успевал вычитывать всю карту за приемлемый интервал.
А зачем вообще писать всё в одну карту? Её можно разбить на несколько маленьких. У нас при таком подходе экспортер стал работать в многопоточном режиме, считывая карты параллельно. В общем случае время работы уменьшается с ростом числа выделенных ядер, в нашем случае время снизилось в 3 раза. Также выросла скорость обработки трафика.

Теперь это выглядит так:

А вот так это реализовано в коде:
c // Определяем, в какую карту писать #define SHARD_COUNT 25 // количество шардов map_hash_idx = ctx->rx_queue_index % SHARD_COUNT + shard_offset;
где rx_queue_index — это номер аппаратной очереди сетевого адаптера. RSS (Receive Side Scaling) распределяет трафик по этим очередям. Таким образом, информация о трафике равномерно распределяется по всем картам.
Плюсы такого подхода:
можно легко добавлять карты;
сенсор может одновременно читать несколько карт;
маленькой карте проще поместиться в кэш процессора.
Сенсор
BPF-карты заполнены. Теперь нужна программа-сенсор, запущенная в пространстве пользователя, которая прочитает данные и отправит их по сети в формате NetFlow в коллектор.
Ничего подходящего из существующих вариантов нам найти не удалось, поэтому решили реализовать этот функционал самостоятельно.
Для работы с BPF-картами в пространстве пользователя можно использовать различные библиотеки в зависимости от используемого языка. Наш выбор пал на набор Go-библиотек для работы с BPF, которые использует Cilium. Go поддерживает конкурентность и эффективен в управлении памятью, а это важно для парсинга и отправки большого объёма данных.

Помня, что HASH-карты могут переполниться, для чтения данных использовали syscall BPF_MAP_LOOKUP_AND_DELETE_BATCH, который после прочтения карты очищает её. Чтобы не потерять данные, «обходим» все карты раз в минуту.
Семплирование
Итак, имеем модуль, который быстро распределяет данные по картам и программу-сенсор, которая отправляет эти данные в коллектор.
Сбор статистики в нашей системе обработки трафика — не самая приоритетная задача. В первую очередь, нужно передавать сетевые пакеты без задержек (в том числе и во время DDoS). Однако и полностью отключать статистику в моменты пиковых нагрузок не хочется. Здесь на помощь приходит механизм семплирования пакетов.

Режим семплирования, как защитный механизм, должен быть активирован, когда один или несколько шардов достигли установленного порога. Но постоянно анализировать данные в нескольких шардах и каждый раз их все пересчитывать нецелесообразно.
Как вариант, можно создать дополнительную HASH-карту, в качестве её размерности указать количество шардов (ядер CPU при настроенном affinity). Ключом карты сделать номер шарда, а значением — обычный счётчик. Так каждый CPU будет инкрементировать свой счётчик, не вызывая блокировок.
struct { __uint(type, BPF_MAP_TYPE_HASH); __uint(max_entries, 128); // Максимальное количество шардов __type(key, __u32); // Номер шарда __type(value, __u64); // Счетчики пакетов/байт __uint(pinning, LIBBPF_PIN_BY_NAME); // Не забываем запинить карту } stats_map SEC(".maps");
Запись данных в карту — это первый шаг. Следующая задача — оперативно получить эти данные, рассчитать скорость поступления уникальных пакетов и принять решение о включении режима семплирования.
Логично было бы проводить все эти вычисления прямо в пространстве ядра, чтобы избежать накладных расходов на передачу данных в пространство пользователя. Однако, здесь возникает сложность: XDP-программа выполняется контекстно, в момент обработки каждого отдельного пакета. Для точного расчёта скорости нам необходим доступ к измерениям за определённый промежуток времени (например, количество пакетов за последнюю секунду). Мы выбрали механизм, предусмотренный самими разработчиками BPF — eBPF таймеры (timers).
eBPF таймеры
eBPF-таймеры позволяют запланировать выполнение определённой функции в ядре через заданные промежутки времени и приходят на помощь в случаях, когда нужно периодически выполнять вычисления (например, расчёт скорости, агрегация статистики, сброс счётчиков) за пределами контекста обработки пакета в самостоятельной фоновой задаче.
Основная идея в том, чтобы переложить запуск eBPF-кода на планировщика событий ядра. Это позволяет программе работать автономно, не дожидаясь внешних событий, будь то приход сетевого пакета или выполнение системного вызова. Это избавляет от необходимости переносить расчёт скорости в пространство пользователя и минимизирует задержки реакции на изменение трафика.
Это подходит, в основном, для задач внутренней аналитики, т. к. механизм таймеров не позволяет менять структуру BPF-карт, допуская лишь модификацию существующих значений в карте.
Более детально с работой таймеров можно ознакомится в официальной документации.
Алгоритм программы-таймера в рамках нашей задачи достаточно прост:
считать из карты счётчики, накопленные за прошедший интервал;
рассчитать текущую скорость;
сравнить её с заданным порогом;
принять решение о включении режима семплирования.
Активация режима семплирования происходит путём переключения флага в отдельной BPF-карте.
Для мониторинга состояния самих таймеров мы используем дополнительную BPF-карту, в которой просто наполняем счётчик работоспособности конкретной программы. Теперь осталось только прочитать карту и вывести на данные на дашборд.

Сбор статистики с виртуальных машин
Виртуальное окружение (KVM, облачные инстансы, контейнерные сети), как правило, не использует XDP, потому что работает в generic-режиме и не даёт существенного прироста в производительности.
Мы начали искать готовые решения, подходящие под наши задачи. В идеале хотелось видеть универсальный механизм, не привязанный к технологии виртуализации или версии ядра операционной системы. В итоге нашли и решили протестировать несколько вариантов.
Для тестов использовался стенд из трёх виртуальных машин: источник трафика, промежуточный маршрутизатор, с которого снимаем статистику, и хост-приёмник трафика.

Тестовая среда была развёрнута поверх VMWare на железе Intel Xeon E5-2620v2. Конфигурация ВМ маршрутизатора: 4 ядра ЦП + 4 ГБ ОЗУ. Параметры сенсора мы меняли в зависимости профиля потока данных.
Начальный нагрузочный тест включал 600k уникальных потоков в течении одной минуты (10kpps). С увеличением скорости в рамках данного количества туплей мы снимали статистику с CPU, контролировали корректность переданных данных, наличие потерь на принимающей стороне и отсутствие ошибок в работе самих сенсоров.
Первый кандидат — PacketBeat в режиме AF_PACKET, сконфигурированный для отправки данных в формате NetFlow. Это агент сетевого мониторинга, который перехватывает трафик, преобразуя пакеты в структурированные транзакции. По своей природе это stateful-анализатор. Даже в режиме flows он пытается сопоставлять пакеты друг с другом, чтобы создать «событие» сессии. Для этого он вынужден держать в памяти хэш-таблицу состояний. На самом деле он больше подходит для анализа L7-заголовков, сборки потоков и анализа состояний, но мы всё же решили его испробовать.
С небольшим трафиком и оптимизацией параметров под наш профиль PacketBeat работал стабильно. Но с ростом нагрузки резко увеличил потребление ресурсов до максимума. Причина этого — в механизме работы режима AF_PACKET. Для того, чтобы приложение могло проанализировать трафик, ядро ОС делает копию пакета. Чем больше трафика, тем больше копий нужно создавать, и тем сильнее нагружается процессор. Помимо этого, он сериализует данные в тяжёлый текстовый JSON-документ и отправляет его в коллектор.
Вторым кандидатом выступил ipt-netflow. Он интегрируется непосредственно в стек Netfilter. Когда пакет проходит через цепочки iptables, модуль обновляет внутреннюю таблицу потоков прямо в пространстве ядра, исключая дорогостоящее копирование данных в пространство пользователя. Это даёт значительный выигрыш в производительности.
Работа ipt-netflow напрямую зависит от того, как настроено потребление памяти. Здесь есть два главных рычага: лимит на общее количество записей (maxflows) и количество хеш-корзин (hashsize). На последнем стоит остановиться подробнее, так как именно здесь кроется секрет производительности.
Скрытый текст
Представьте хэш-таблицу как огромный стеллаж с ящиками (корзинами). Когда приходит пакет, модуль ipt-netflow вычисляет для него номер ящика и кладёт в него данные. В идеале в одном ящике должен лежать один листок с данными — тогда процессор находит его мгновенно.
Проблемы начинаются, когда трафика становится слишком много, а ящиков мало. Возникают коллизии: в один и тот же ящик приходится складывать данные от разных сетевых потоков, выстраивая их в очередь (связанный список). Процессору приходится каждый раз перебирать этот список, чтобы найти нужную запись. При потоке в сотни тысяч пакетов в секунду этот перебор превращается в колоссальную нагрузку. В итоге мы видим, как одно ядро CPU уходит в 100% — оно просто «тонет» в бесконечных поисках внутри переполненных корзин.
Может показаться логичным приравнять количество корзин (hashsize) к лимиту записей (maxflows), но на практике это приведёт к серьёзной потере производительности, т. к. распределение данных по хэш-таблицам не бывает идеальным.
Чтобы поиск оставался быстрым, количество корзин должно быть в 2–3 раза больше, чем реальное число активных потоков. Тогда в подавляющем большинстве случаев в корзине будет лежать только одна запись. Как только мы уравниваем эти параметры, плотность заполнения таблицы растёт, цепочки внутри корзин удлиняются, и процессор снова начинает тратить время на их перебор, возвращая нас к аномально высокой нагрузке на ядро CPU. Именно поэтому понимание и прогнозирование профиля трафика становится фундаментом правильной настройки.
Основное неудобство применения ipt-netflow заключается в том, что он не входит в состав ядра Linux, а выполнен в виде отдельного модуля, который нужно собрать в бинарный файл, совместимый с конкретной версией ядра и установить, что усложняет процесс эксплуатации и обновления системы.
В итоге, оба кандидата вроде и справились с задачей, но, всё-таки, нам требовалось более универсальное решение по производительности, сравнимой с ipt-netflow, но без жёсткой привязки к версии ядра. И такое решение мы нашли прямо у себя под носом.
На физических серверах мы успешно применяем eBPF/XDP, но для виртуальных машин этот метод не применим из-за специфики работы XDP только с входящим трафиком. Однако существует возможность подключения той же самой программы на уровне TC (Traffic Control) в Linux. Для этого программу подключают на TC фильтр ingress, обрабатывая входящий трафик, и egress, перехватывая исходящий. Алгоритм обработки данных для NetFlow такой же, как для XDP, разница лишь в указателе на структуру, которая передаётся на вход программе. В первом случае — xdp_md, а во втором — sk_buff. Чтением карт и отправкой в коллектор занимается та же программа-сенсор на Golang, ведь ей совершенно без разницы, как данные попали в карту. Такое решение не зависит от версии ядра и работает на всех современных платформах.

Вариант на базе BPF-TC наиболее приемлемый по производительности и совместимости с современными ядрами. Но к нему необходимо самостоятельно реализовывать механизмы сбора и экспорта данных.
Хочется выделить одну важную особенность в работе программ BPF-TC в среде VMware ESXi. Если на виртуальной машине активирована функция hot-plug cpu add, то PERCPU-карта хранит 128 (по умолчанию) значений, что не соответствует актуальному количеству ядер. Почему так происходит:
ядро для per-CPU данных ориентируется на cpu_possible_mask: «CPU, которые когда-либо могут появиться в системе», и именно по нему делается размерность per-CPU аллокаций;
в виртуальных машинах с включённым CPU Hot Add гипервизор (через ACPI/SMBIOS) может объявлять большое число возможных CPU (например, 128 или 256), чтобы их можно было добавлять без перезагрузки.
В целях эффективного управления памятью данный функционал у нас выключен.
Сбор статистики в Kubernetes
В кластерах Kubernetes нам нужно иметь полную информацию обо всех соединениях между сервисами. Однако, решить эту задачу с помощью программ eBPF на узле не позволяют фундаментальные ограничения:
XDP перехватывает только входящий трафик, поэтому часть информации о сетевой сессии будет потеряна;
сбор данных с помощью BPF-TC крайне затруднён из-за ограничений CNI-плагина.
В нашей Kubernetes-платформе в качестве CNI-плагина используется Cilium. Он монопольно занимает наивысший приоритет (pref 0) на ingress/egress-хуках сетевых интерфейсов, чтобы реализовать собственные политики управления трафиком. Это делает невозможным подключение сторонних eBPF-программ до того, как трафик попадёт в Cilium.
Первое время мы брали сетевую статистику из Cilium, а точнее из его компонента — Hubble. Но вскоре отказались от него:
он предоставляет данные только в real-time, а нам нужно хранить историю для долгосрочного анализа;
собирает информацию в собственном формате, а нам нужен NetFlow;
в комьюнити версии очень прожорлив по ресурсам, поэтому держать его включённым на постоянной основе очень накладно.
Тогда мы решили пойти другим путём. Cilium в своей работе также использует BPF-карты. В них он сохраняет служебную информацию о всех активных подключениях, включая данные для отслеживания соединений (connection tracking). Чтобы получить эти данные, мы собрали новый сенсор. Он извлекает данные из карт CNI-плагина, преобразует их в формат NetFlow и отправляет в коллектор.
Вот список карт, которые интересны для анализа:
cilium_ct4_global — информация о TCP соединениях;
cilium_ct_any4_global — весь остальной трафик (UDP, ICMP и пр.);
cilium_snat_v4_external — дополнительная информация для трафика с NAT.
Схематично процесс сбора можно представить так:

Сенсор на Go (запущен через DaemonSet):
Читает BPF-карты Cilium;
Преобразует полученные значения в определённый формат;
Обогащает метаданными из Kubernetes API (какой pod, namespace, labels);
Формирует NetFlow-записи и отправляет в коллектор.
Дорабатывая программу-сенсор, нам в итоге пришлось существенно её изменить по сравнению с не-Kubernetes версией, и вот почему.
Воодушевившись полученным опытом, мы подумали, что всё будет просто: прочитать карты CT Cilium, привести данные к формату NetFlow и отправить их в коллектор. Мы реализовали сенсор, точно такими же методами прочитали BPF-карты, доставили данные в коллектор. Выкатили это решение на пару K8s-кластеров, и поначалу всё отлично работало.

И вот в один прекрасный день на одной K8s-ноде флапнула BGP-сессия. Ситуация не фантастическая, это может произойти по разным причинам. Для того, чтобы быстро фиксировать проблемы связности между пирами, у нас используется протокол BFD, который и разрывал BGP-сессию с соседом. Мы не придали этому значения, но на следующий день проблема повторилась: BGP-сессии флапнули в другом кластере с низкой сетевой нагрузкой. Тут мы уже насторожились, поскольку как раз на этих нодах и был развёрнут новоявленный NetFlow-сенсор.
Причиной оказались сетевые задержки, вызванные чтением BPF-карты. Они возникали на том ядре процессора, которое выполняло соответствующий системный вызов BPF_MAP_LOOKUP_AND_DELETE_BATCH. В случаях, когда это ядро также обрабатывало BFD-трафик, происходил разрыв BFD-сессии. В целях тестирования мы отправляли запросы к адресу ноды каждые 200 мс. Наблюдалась следующая картина:

В случае не-Kubernetes версии сенсора мы явно разделили ядра control-plane и data-plane на сервере, поэтому такая проблема и не возникала — ядра ЦП, которые читали карту, совсем не обрабатывали трафик. В наших K8s-кластерах такая возможность не предусмотрена.
Первым вариантом решения пришла идея сократить 1 большой системный вызов в ядро на несколько мелких и получать данные порционно. В итоге проблема с разрывом сессий ушла, и задержки выглядели уже примерно вот так:

Увеличение количества системных вызовов снизило задержки сетевого трафика, но увеличило время опроса BPF-карты. При этом, полностью проблему с задержками этот вариант не решил, поэтому мы продолжили экспериментировать.
Эксперименты с runtime Go показал интересную особенность. Если полностью отключить garbage collector (GC), то даже один большой системный вызов не вносит никакой задержки в сетевой трафик. Но различные варианты оптимизации GC не принесли никаких положительных изменений. Попробовали выводить объявленные в Go структуры, которые мы отправляли в batch_lookup для наполнения ядром, использовать арены (на тот момент в GOEXPERIMENT), механики повторного использования данных, чтобы повлиять на работу GC. Но на задержки это никак не влияло. Варианты же с ручным контролем памяти типа malloc категорически не хотелось внедрять.
Основная сложность заключалась в том, что когда поток уходит в системный вызов ядра, повлиять на него из пользовательского пространства проблематично. Тогда мы подумали: раз с отключённым GC нет проблем, так почему бы не добавить программу (на схеме выше это sidecar-exporter), которая прочитает карты с выключенным GC, передаст данные основной программе и завершится, тем самым заставив ядро ОС выступать в роли нашего GC. Способов передать данные в соседнюю программу несколько, мы выбрали gRPC с protobuf. В запасе держали вариант использования shared memory в случае, если сериализация будет занимать много времени. Но gRPC прекрасно справляется с задачей и не вносит каких-то задержек в работу программы. Таким образом, задержки, которые возникали на первоначальном этапе, полностью ушли. На самом деле, все изыскания по этому вопросу тянут на отдельную статью, поэтому не будем останавливаться на этом более подробно.
С тех пор прошло 2 года. Недавно (перед написанием этой статьи) мы решили воспроизвести проблему с включённым GC без дополнительной программы. К величайшему удивлению, нам это сделать не удалось, даже на версиях библиотек тех времён. Оставалось только проверить ядро ОС на причастность к этой проблеме. Мы откатились до старой версии ядра, и проблема вернулись. Беглый анализ отличий между версиями ядер выявил много исправлений, которые могли повлиять на это.
Заключение
Система на базе XDP позволила нам решить главную задачу — получить 100% видимость сессий на границе сети без снижения производительности. В нашем распоряжении полные метаданные каждой сессии, что помогает расследовать инциденты и анализировать DDoS-атаки. Нет ограничений flow-таблиц на сетевом оборудовании, что позволяет гибко настраивать состав экспортируемых метрик. Обработка трафика на уровне ядра положительно влияет на утилизацию ресурсов даже при пиковых нагрузках.
При выборе XDP для решения задач наблюдаемости нужно осознавать, что это не «коробочное» решение. Успех внедрения зависит от понимания архитектуры памяти (eBPF maps), умения работать с ограничениями верификатора и готовности выстраивать гибкие цепочки обработки данных. Но для задач, где критична скорость и работа «на переднем крае» сетевого стека, на сегодняшний день это один из самых эффективных инструментов в арсенале инженера.
