golang

Погружение в eBPF и XDP вместе с Go

  • среда, 25 февраля 2026 г. в 00:00:12
https://habr.com/ru/companies/oleg-bunin/articles/991074/

Технология eBPF у всех на слуху, но написать свой инструмент и получить все выгоды от eBPF не так просто из-за недостатка информации. 

Привет, Хабр! Я — Дмитрий Самохвалов, архитектор в компании К2Тех. Помимо этого пишу на GO и на Rust, и стараюсь делать жизнь наших инженеров и разработчиков интереснее и проще. В этой статье по мотивам доклада с Golang Conf я расскажу, как мы пришли к работе с eBPF и покажу на примере написания своего XDP-фильтра, как начать работу с eBPF, используя Go. 

Также мы поговорим об используемых библиотеках, тестировании и запуске программ на eBPF. Поделюсь полезными советами и набитыми шишками на этом пути.

За время многолетней практики мы обнаружили, что мы в целом больше любим и больше привыкли примерно к такому миру, который считаем нормальным. Обычно у разработчиков есть некоторое количество микросервисов, которые крутятся в Kubernetes-кластерах или в Kubernetes-friendly окружении. Для этого есть множество инструментов, подходов, сообщество и open-source. Но у наших заказчиков все не так радужно и чаще всего рядом с этим стоит ещё нечто — назовём это монолитом. На самом деле, это может быть что-то похуже — старое окружение legacy enterprise, которое мы либо держим рядом, либо стараемся переписать на более правильные современные рельсы. И в таких кейсах мы сталкиваемся с рядом проблем.

Real world – проблемы

  • Отсутствие автоматизации 

Речь идёт о тех, которые могут хорошо работать на Kubernetes-окружении, например, но не могут на legacy и наоборот. Можно купить какую-то плату или проприетарное решение, но нашим заказчикам не всегда это интересно в основном из-за проблем с лицензированием, вендерлоками и всем сопутствующим.

  • Непригодные инструменты разработки, деплоя и сопровождения

Если в условно привычных условиях мы всё это можем контролировать и есть целый ряд инструментов специалистов, то в enterprise legacy окружении такого чаще всего либо нет, либо оно непереносимо в обратную сторону между реальным и «условно нормальным» мирами. Часто это усугубляется особыми требованиями, которые мы должны соблюдать.

  • Особые требования 

Это могут быть требования ГОСТа, требования стандартов безопасности типа PCI DSS и всего прочего. От нас либо требуют их соблюдать в привычном нам мире, либо переносить их между двумя мирами относительно безболезненно, что в принципе не так просто, а то и вовсе невозможно в наших реалиях.

  • Нет auto-discovery и IaC 

Но мы ребята современные, очень любим Infrastructure-as-Code и auto-discovery, любим покрывать все мониторингом, что не всегда есть в энтерпрайзе.

Мы пробовали разные подходы и жили на костылях, пока не пришли к  использованию технологии eBPF.

eBPF

Аббревиатура eBPF расшифровывается как extended Berkeley Packet Filter (расширенный фильтр пакетов) — изначально она появилась как фильтр пакетов. Особую популярность eBPF получила в последние годы и активно развивается. Мы сегодня сосредоточимся на работе с сетевым стеком, с сетевыми инструментами.

Разберёмся, как вообще это все устроено.

У нас есть user space, где крутится наше приложение. Чаще всего у нас они крутятся в Kubernetes, но на самом деле это не обязательно. То есть eBPF может работать вне Куба спокойно.

Есть некая логика, написанная на наших языках программирования, загруженная в user space, и есть пространство ядра, где происходит основная магия. Наша eBPF программа подключается к определённым хукам внутри ядра. Она исполняется на виртуальной машине eBPF и перехватывает события, которые этим типом программ поддерживаются. Она может перехватывать сетевые пакеты, какие-то события контекста — например, открытие файла.

Есть мостик между User Space и Kernel Space в виде eBPF map. О них поговорим чуть подробнее. 

Этот кусок, связанный с сетевым стеком интересен тем, что мы можем при помощи eBPF-программ перехватывать сетевые пакеты до того момента, как они попадут на сетевой интерфейс. 

Есть несколько типов eBPF программ. Мы используем чаще всего XDP, но на самом деле это не единственный тип программ, которые могут работать с сетью. Например, есть TC (Traffic Control). 

Traffic Control интересен тем, что в нём как раз весь сетевой стек Linux заключается в структуре sk_buff(). Если XDP может обрабатывать только входящие пакеты — то есть работает только как ingress, — то TC может обрабатывать как входящие, так и исходящие пакеты. Конечно, это менее производительно — он тащит за собой весь сетевой стек Linux. Но если нужно именно строить какую-то логику на исходящий трафик, без Traffic Control не обойтись.

Вообще eBPF может работать с сокетами и есть программы, которые это поддерживают. Но мы в своей практике этого обычно не применяем.

Kernel programs

Как устроены под капотом kernel-программы:

  • Limited C | Rust 

Чаще они пишутся на C. Он называется Limited C, потому что действительно сильно ограничен по функциональности использования внутри eBPF. С новых версий ядра можно использовать Rust. Мы сейчас на новых версиях ядра так и делаем там, где это допустимо. То есть часть программ у нас на C, часть на Rust. 

Я рекомендую начать знакомство с разработкой под eBPF именно с языка C. Так вы лучше поймете, как это устроено под капотом в целом, и больше будет понятна структура Linux, как устроен сам Linux. На Rust это не всегда очевидно. 

  • eBPF bytecode 

Эти программы транслируются в eBPF bytecode, который исполняется на виртуальной машине eBPF. 

  • Machine code 

При желании через эту виртуальную машину можно исполнять и некие машинные инструкции. Такое тоже нечасто, но бывает. Большая часть eBPF bytecode работает на виртуальной машине eBPF. 

Возникает вопрос: почему limited и какие есть ограничения на kernel программы.

Kernel programs limitations

  • Размер стека не больше 512 байт 

Размер стека сильно ограничен, потому что эти программы не должны быть очень большими. 

  • Нет динамического выделения памяти 

Мы не должны своим каким-то обращением, например, к недействительной памяти или чем-нибудь ещё положить ядро. Большинство выстрелов в ногу, которые мы делаем на C, обычно связаны с динамическим выделением памяти. Такого не допускается. 

  • Нет бесконечных циклов 

Программа должна выполниться за конечное время, чтобы не допустить проблем на уровне ядра. Отсюда же возникает следующее ограничение.

  • Ограниченное количество исполнений (зависит от версии ядра) 

Понятно, что с каждой новой версией ядра количество итераций увеличивается. Поэтому здесь нужно внимательно следить допустимость их количеств в вашей версии ядра. 

  • Доступ к структурам ядра ограничен типом программы

К этому ещё вернёмся. Пока скажу, что в зависимости от типа вашей программы будут доступны не все структуры ядра. 

User space программы

Эти программы пишутся на высокоуровневых языках и от них требуется уметь делать системный вызов BPF, чтобы загрузить вашу программу в ядро и присоединить её в какой-то определённый хук (например, в нашем случае XDP), а также уметь читать и писать в BPF maps.

Программы user space пишутся на знакомых нам языках — Java, Go, Python или даже на JavaScript.

Когда программа загружается в ядро, она проходит этап, который называется verifier. Происходит верифицирование типа программы: что ей доступно, нет ли у вас бесконечных циклов, недопустимых инструкций, не превышает ли она размер и так далее. Пропустить этот шаг нельзя — так устроен еBPF.

В дальнейшем открывается доступ к BPF maps для обмена информацией между Kernel и user space. Теперь пора  уже что-то написать.

Пишем свой XDP-фильтр

Покажу, как написать простенький, но функциональный XDP-фильтр.

У нас есть user space, в который мы пишем программу на Go, используя библиотеку Cilium и eBPF. Она будет брать некий white list и грузить из него через определённую map с типом Ring buffer эти white lists нашему фильтру XDP, который мы напишем на C с использованием библиотеки libbpf.c. Это не единственная существующая библиотека, но чаще всего мы используем именно libbpf.c. 

Для более чёткого понимания, что мы будем делать, как вообще устроен XDP-фильтр:

Это упрощённая версия для понимания, что мы будем делать и как устроен сетевой пакет верхнеуровнево.

У нас есть ряд заголовков (метадаты пакета):

  • Ethernet header, который в библиотеке libbpf.c представлен такой структурой. 

  • IP заголовок, который тоже мапится на определённую структуру внутри этой библиотеки. 

  • TCP/UDP/ICMP packet — он в данном случае не очень интересен.

Kernel часть пишем на C. Поначалу синтаксис C может немного пугать, но к нему легко привыкнуть. Кроме того, сам eBPF даёт меньше возможностей выстрелить себе в ногу.

Комментарий вверху go:build ignore мы ставим для того, чтобы генератор кода, который будет генерить нам Go код из C-структур, понимал, что это язык C, а не Go. Об этом тоже расскажу чуть подробнее. 

Для начала мы определяем нашу BPF map с типом ring buffer. Вообще BPF maps сильно больше — необязательно делать ring buffer, но мы чаще работаем с этим типом map. Задаём имя, тип и определяем её в секции, которые называются maps.

Сам фильтр объявляем в секции под названием XDP. 

Эти макросы SEC нужны для того, чтобы eBPF-программа, eBPF bytecode делился на секции, понятные виртуальной машине. В самой программе доступен контекст вызова, который перехвачен eBPF-хуком. Он называется XDP метаданные. 

Первым делом нам нужно получить указатели на начало и конец пакета. 

Не бойтесь синтаксиса C: берём сырые байтовые данные и кастим из них указатели на начало и на конец нашего пакета.

Дальше парсим первую часть. Идём слева направо по пакету и парсим сначала заголовки Ethernet.

Затем определяем, что мы не вышли за границу нашего пакета. 

Если у нас пакет битый, мы должны его отбросить с ошибкой, вернуть статус XDP_ABORTED. Сам Ethernet Header нам не очень интересен — лучше найти IP-заголовок. 

Мы должны определить, что этот пакет является IP-пакетом. Если нет, мы его пропускаем. Если же это IP-пакет, то извлекаем IP-header и смотрим, чтобы он был не битым. Затем получаем IP адрес источника — пакет пришёл.

Есть нюансы, когда пакет ещё помечен VLAN тегами, но это уже более сложные программы. Также есть сложности, связанные с типом вашего сетевого окружения. 

Далее делаем простую операцию. 

С помощью встроенного хелпера определяем, находятся ли данный адрес в нашем списке. Если да — пропускаем дальше, если нет — исключаем. 

Последняя строчка — это указание лицензии. 

Почему это важно? Как я уже сказал, Kernel Verifier проверяет программу на соответствие типу лицензии, который допустим для того или иного типа программы. Не все типы программ находятся под свободной лицензией. Если у вас хелпер не прошёл, то он вам эту программу не загрузит. 

XDP return коды, которые нам возвращает XDP программа:

  • XDP_ABORTED — отбросить пакет с ошибкой, вернув ошибку.

  • XDP_PASS — пропустить пакет дальше.

  • XDP_DROP — отбросить пакет без каких-либо ошибок.

Разница между ABORTED и DROP в том, что DROP откидывает пакет немедленно, ничего об этом не сообщая. Это важно, когда вы, например, делаете какую-то митигацию DDoS-атак, и вам нужно отрезать мусорные пакеты от основного стека, не засоряя логи. 

Есть ещё два типа return-кодов, которые часто используются в балансировщиках нагрузки:

  • XDP_TX — вернуть на тот же интерфейс, с которого он пришёл.

  • XDP_REDIRECT — переправить на другой интерфейс.

Теперь по user space части.

User space part – Cilium lib 

Есть замечательная библиотека Cilium eBPF.

// Установка модуля go get 

githum.com/cilium/ebpf 

// Установка bpf2go (опционально) 

go get githum.com/cilium/ebpf/cmd/bp2go 

 // Генерация Go-кода 

go generate 

Библиотека доступна на GitHub и ставится просто как отдельный модуль. В зависимости от вашего окружения разработки, иногда нужно ещё поставить тулзу bpf2go, которая генерирует Go структуру из C-кода. Её можно поставить отдельно из этого же пакета и запустить генерацию  того кода, который будет доступен внутри нашей Go программы. 

Как это сделать:

Чтобы указать тулзе bpf2go, какой у нас C таргет, нужно прописать в файл main.go таргет, указать, что filter.c является исходником наших Go-структур. После этого сгенерятся два файла с байт-кодом и с Go-структурами на основе файла filter.c, который указан в main.go. 

Выглядит это так:

У нас есть программа под названием filter и кусок сгенерённого кода. Его не надо писать руками. Он берёт наш фильтр — filter, —генерирует ему Go-объекты, называемые filterObjects, загружает их ядро, приаттачивает их к хуку под названием XDP. 

Дальше необходимо написать нужную логику для добавления maps в белый список.

Здесь простой код. Можно сделать его сложнее при желании, но я пошёл самым простым путём — указал адрес и добавил его вручную в map.

Нюансы разработки. Unit testing

На мой взгляд, разработки без юнит-тестирования нет места в жизни. Но если user space программы на Go мы можем покрывать юнит-тестами, то юнит-тестирования kernel-программ как такового нет. Сам eBPF предоставляет функцию BPF_PROG_RUN, которая позволяет в тестовом режиме выполнить программу на виртуальной машине и вернуть стек в плюс-минус считаемой форме. Но не все типы программ поддерживают этот вызов. Например, XDP поддерживает, а Traffic Control нет. 

Отдельная боль, когда мы готовим моки для юнит-тестов на языке C.

Это очень маленький фрагмент наших моков, потому что сложно замокать все структуры, которые участвуют в сетевом стеке. Кроме того, язык C достаточно многословный, каждый параметр нужно в явном виде определить. Наши тестировщики ругаются — им приходится всё постоянно таскать из одного теста в другой.

Готовых инструментов для такой генерации моков я не находил.

Какие были проблемы

Kernel programs CPU

Хотя kernel-программы экономичнее по процессору, чем стандартные программы, которые работают с сетевым стеком, они не бесплатные. Особенно когда мы работали в привычном нам Go-стиле: некое количество наших программ, которые на какие-то сетевые вызовы генерят горутины, хотят делать операции с пакетами, мы столкнулись с тем, что когда это делаем в привычном стиле через обновление Ring Buffer map, ловим достаточно серьёзную просадку по процессору. 

Это связано с тем, что вообще eBPF maps ограничены по размеру. Когда мы пытаемся эти ограничения перелимитить, мы начинаем генерить горутины, внутри eBPF-maps начинают работать их собственные встроенные хитрые механизмы либо по распределению, либо по отбрасыванию того, что в них пытаются засунуть больше. Это сильно утилизирует процессор.

BPF maps issues

  • Размер имеет значение — стек не более 512 байт.

  • Каждый тип под конкретные задачи.

Каждый тип map заточен под конкретную задачу. Есть maps, которые заточены как Ring Buffer под задачу, когда у нас есть много, например, продюсеров, которым нужно писать что-то в потоке. Также есть maps, которые определяют просто хэши и так далее. Нужно всегда очень внимательно следить, какой тип maps вы используете при работе с вашей eBPF программой. 

  • Синхронизация и concurrency — это боль.

Вообще в новых версиях ядра завезли атомарные операции.

Есть так называемый BPF_FETCH. atomic, но это не всегда доступно, потому что не все программы запускаются на самых новых версиях ядра. Это не всегда работает, когда, например, из user space вы запускаете несколько kernel-программ, но пытаетесь обратиться конкурентно к одной и той же map.

Есть maps, которые не допускают такого обращения, но бывают и исключения. Мы с таким столкнулись. 

Есть инструмент bpf_spin_lock — это простой семафор, который лочит map и отдает её после изменения нужного нам параметра. Опять-таки не на всех версиях ядра такие инструменты есть и не все типы eBPF-программ такую операцию поддерживают.

Раз заговорили про maps, надо упомянуть про циклы eBPF. 

eBPF loops

Вообще до версии ядра 5.3 они были запрещены. Сейчас, даже по прошествии времени, циклы в eBPF не приветствуются. Было промежуточное решение — это цикл с лимитом (не более 1М итераций), но начиная с версии ядра 5.17 появился хелпер bpf_loop(), у которого уже количество итераций увеличено. 

С каждой новой версии ядра количество доступных итераций и исполнений eBPF-программ увеличивается. Но пока считается, что использовать циклы, даже bpf_loop(), в eBPF-программах не очень хорошо.

Переносимость eBPF-программ

Отдельная история про переносимость eBPF-программ. Вообще сами по себе eBPF-программы переносятся достаточно хорошо. Хотя бывают исключения, когда, например, на одной версии ядра мы пытаемся использовать eBPF-хелперы, которые на этой версии недоступны. Но в целом сам код eBPF переносится неплохо. Проблемы возникают тогда, когда начинается обращение к структурам ядра. 

Простой пример — кусочек из структуры TCP-хедера. 

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

Есть несколько подходов, как с этим жить. 

BTF & CO-RE 

Мы используем подход CO-RE (Compile Once – Run Everywhere). Он работает через релокацию (определённые структуры для компилятора). То есть, мы можем выгрузить информацию для переносимости в файлик vmlinux.h:

bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h 

По сути, там длинный набор констант, которые определяют нужные параметры в зависимости от нашего окружения. В момент компиляции он берёт нужные данные о структурах ядра из этого длинного файлика, своими собственными определениями их мапит в нужные структуры. Если ядро поддерживает тип BTF — всё хорошо.

Tips

Подведём итоги, суммируя наш опыт с eBPF:

  • Анализируйте окружение

Обязательно перед тем, как начинать разрабатывать, проанализируйте, в каком окружении вы будете запускаться. От среды и архитектуры, под какие версии ядра будете свою программу разрабатывать и поддерживать, зависит многое. 

  • Делайте программы stateless (по возможности)

Если вам не нужно хранить постоянно какой-то state и синхронизировать его между вашими программами, проблем будет меньше.

  • Больше логики в user space

Лучше всего большую часть логики по возможности переносить из kernel space в user space, чтобы иметь больше возможностей для расширения узких мест в user space, которые теоретически могут возникнуть. Делать это в kernel space проблематичнее, чем в user space. 

  • Меньше используйте структуры ядра (по возможности)

Если вам доступен BTF на вашей версии ядра, то используйте BTF и подход Compile Once — Run Everywhere. Если недоступен, смотрите, какие структуры ядра вы используете. 

  • Смотрите исходники проектов, которые работают на eBPF, особенно Cilium

Мы очень много идей почерпнули от Selium. Сейчас, особенно на инструментах, которые работают с Kubernetes и его сетью, активно внедряется eBPF. Там есть очень много интересных решений и есть большой шанс, что кто-то ваши грабли уже словил до вас. Поэтому надо обязательно исследовать эти исходники. 

В целом смотрите за тем, что есть в GitHub по запросам на eBPF. Документация на eBPF в последнее время стала получше.

Скрытый текст

А чтобы узнать ещё больше полезного из мира работы с Go, приходите на GolangConf 2026!

Конференция развития пройдёт в апреле в Москве. На ней традиционно соберутся ведущие Go-разработчики с последними практиками. Вас ждёт много нетворкинга и полезных знаний, а ещё много практики!