В этом руководстве вы познакомитесь с основами eBPF и XDP на практических примерах. Мы разберём код, который анализирует пакеты на разных уровнях протокольного стека, посмотрим, как работают действия XDP, и увидим, как из этих базовых компонентов строятся высокопроизводительные сетевые приложения.
Если заглянуть в код сетевого стека ядра Linux, станет видно, что он рассчитан на поддержку множества протоколов и возможностей: IPv4 и IPv6, TCP и UDP, VLAN, туннелей вроде VXLAN и GRE, а также QoS и управления трафиком.
Но каждый этап обработки пакета добавляет микросекунды задержки. Звучит незначительно, однако при миллионах пакетов в секунду эти накладные расходы быстро превращаются в узкое место, с которым с трудом справляется даже современное оборудование.
Поэтому существуют разные техники обхода сетевого стека ядра, например DPDK и PF_RING ZC. Они позволяют приложениям в пространстве пользователя получать пакеты напрямую от сетевого оборудования, не пропуская их через ядро.
Однако вместе с ядром такие фреймворки обходят и многие его сетевые возможности. Поэтому приложениям нередко приходится реализовывать их заново. Это усложняет разработку и ослабляет механизмы безопасности, которые обычно обеспечивает ядро.

Чтобы решить эти проблемы, был создан XDP (eXpress Data Path). Он позволяет обрабатывать пакеты внутри ядра и повторно использовать существующие сетевые механизмы и средства безопасности Linux, а не реализовывать их заново в пространстве пользователя.
Разумеется, это довольно грубое сравнение. DPDK зачастую обеспечивает более высокую производительность, а XDP обычно проще внедрить благодаря повторному использованию возможностей ядра. Эти технологии рассчитаны на разные условия применения, и у каждой есть свои сильные стороны и компромиссы.
В этом цикле материалов мы сосредоточимся только на следующих вопросах:
Как на самом деле работает XDP?
Для каких приложений он подходит лучше всего?
Когда стоит использовать другие типы eBPF-программ, подключаемые выше по сетевому стеку?
Это первое руководство в цикле. В нём вы на практике разберёте трафик IPv4 и IPv6, TCP и UDP, а также ICMP. В следующих материалах мы применим эти основы к более сложным задачам: ограничению частоты пакетов, фильтрации трафика и балансировке нагрузки.
Тип программы eBPF/XDP
XDP (eXpress Data Path) – это тип eBPF-программ, которые подключаются к точке подключения XDP в сетевом стеке ядра Linux. На данный момент доступны три варианта подключения XDP – точнее, три режима:
Универсальный режим (Generic mode) – программа выполняется в сетевом стеке ядра уже после выделения структуры
sk_buff. Доступен начиная с ядра Linux 4.8.Нативный режим, или режим драйвера (Native/Driver mode), – программа выполняется непосредственно в сетевом драйвере до выделения
sk_buff, что обеспечивает более высокую производительность.Режим аппаратной разгрузки (Hardware Offload mode) – программа выполняется непосредственно на самом сетевом адаптере (NIC). Этот режим обеспечивает максимальную производительность, но работает только на поддерживаемом оборудовании.
Примечание
? Большинство распространённых сетевых адаптеров, включая Intel i40e/ice/ixgbe и Mellanox mlx5, поддерживают XDP в нативном режиме, или режиме драйвера. Аппаратная разгрузка пока доступна только на SmartNIC-адаптерах Netronome.
Полный список поддерживаемых драйверов приведён в документации.
Песочница, в которой вы сейчас работаете, поддерживает только универсальный режим. Однако нетрудно представить, почему нативный режим и аппаратная разгрузка могут обеспечить более высокую производительность. Например, в межсетевом экране на базе eBPF чем раньше вы перехватите пакет, тем раньше сможете его отбросить. Это снижает общую нагрузку на систему и повышает пропускную способность.

Примечание
? В этой песочнице сравнить режимы работы на практике не получится. Тем не менее одна из самых дорогостоящих операций в тракте приёма пакетов ядра – копирование данных пакета из приёмного кольца
rx_ringв структуруsk_buff.
Именно поэтому при балансировке нагрузки или отбрасывании больших объёмов трафика переход с универсального режима на нативный, или режим драйвера, даёт заметный прирост производительности.
По этой же причине, если задачу можно решить с помощью XDP, его обычно предпочитают TC: программы TC выполняются уже после выделения структуры sk_buff.
В используемом нами фреймворке ebpf-go режим легко переключается с помощью параметра, который передаётся при подключении XDP-программы:
xdplink, err := link.AttachXDP(link.XDPOptions{ Program: objs.XdpProgram, Interface: iface.Index, Flags: link.XDPGenericMode, })
В качестве значения можно указать XDPGenericMode, XDPDriverMode или XDPOffloadMode, как описано в документации ebpf-go.
Примечание
? Важно и то, что XDP-программа подключается к конкретному сетевому интерфейсу.
iface, err := net.InterfaceByName(ifname) if err != nil { log.Fatalf("Getting interface %s: %s", ifname, err) } xdplink, err := link.AttachXDP(link.XDPOptions{ Program: objs.XdpProgram, Interface: iface.Index, Flags: link.XDPGenericMode, })
К разным сетевым интерфейсам можно подключать разные XDP-программы.
Более того, к одному сетевому интерфейсу можно подключить сразу несколько XDP-программ, но эту тему мы разберём в одном из следующих руководств. Тем, кто хочет разобраться заранее, стоит посмотреть на libxdp.
Независимо от выбранного режима большинство XDP-программ строятся по одной схеме:
Разобрать пакет – проанализировать заголовки уровней L2, L3 и L4 и извлечь поля, которые нужны приложению.
Обработать метаданные – использовать извлечённые поля, а также читать или обновлять eBPF-карты.
Изменить пакет – этот шаг встречается реже. Можно переписать заголовки или полезную нагрузку, а также скорректировать резерв перед данными или после них с помощью, например,
bpf_xdp_adjust_headиbpf_xdp_adjust_tail. Это требуется, в частности, для IPIP-инкапсуляции и декапсуляции.Вернуть решение о дальнейшей обработке пакета – завершить работу с кодом действия, который сообщает ядру, что делать с пакетом дальше.
Для последнего шага XDP поддерживает несколько действий – точнее, кодов возврата, – определяющих дальнейшее поведение программы:
XDP_PASS – пропустить пакет дальше по сетевому стеку в обычном режиме.
XDP_DROP – немедленно отбросить пакет.
XDP_TX – отправить пакет обратно через тот же интерфейс.
XDP_REDIRECT – перенаправить пакет на другой сетевой интерфейс, другой CPU или в специальный сокет пространства пользователя AF_XDP.
XDP_ABORTED – прервать выполнение программы из-за ошибки.
В следующих материалах цикла мы разберём, как эти действия и коды возврата используются в разных сценариях. Пока этот список приведён для справки.
Как XDP разбирает заголовки Ethernet, IP, IPv6, TCP, UDP и ICMP
Хватит теории. Цель этого руководства – разобраться, как получать доступ к метаданным сетевого пакета, на которых строится практически любое реальное сетевое приложение.
Для начала посмотрим, какую структуру контекста ядро передаёт нашей eBPF/XDP-программе:
struct xdp_md { __u32 data; __u32 data_end; __u32 data_meta; __u32 ingress_ifindex; __u32 rx_queue_index; __u32 egress_ifindex; };
Поля структуры:
data – указатель на начало данных пакета, то есть на начало доступной для чтения области памяти.
data_end – указатель на конец данных пакета, то есть на конец доступной для чтения области памяти.
data_meta – необязательная область перед data, в которой можно хранить пользовательские метаданные и передавать их из XDP на следующие этапы обработки, например в TC. Подробнее см. bpf_xdp_adjust_meta.
ingress_ifindex – индекс сетевого интерфейса, на котором был получен пакет.
rx_queue_index – индекс очереди приёма этого интерфейса, в которую попал пакет.
egress_ifindex – индекс сетевого интерфейса, через который был перенаправлен пакет. Поле заполняется только после перенаправления.
В большинстве случаев программы используют только указатели data и data_end. Они нужны не только для доступа к данным пакета, но и для прохождения проверки верификатора eBPF: программа должна доказать, что все обращения к памяти остаются в границах буфера пакета.
На мой взгляд, одна из самых сложных частей разработки XDP-программ – как раз добиться того, чтобы верификатор принял код.
Чтобы не усложнять пример, мы воспользуемся заголовочным файлом parse_helpers.h, изначально взятым из репозитория xdp-project/xdp-tutorial. Он берёт на себя значительную часть логики разбора пакетов.
Вкратце, этот файл содержит набор вспомогательных функций, которые безопасно обращаются к данным пакета и извлекают заголовки Ethernet, IPv4/IPv6 и TCP/UDP.
... #include "parse_helpers.h" SEC("xdp") int xdp_program(struct xdp_md *ctx) { void *data_end = (void *)(unsigned long long)ctx->data_end; void *data = (void *)(unsigned long long)ctx->data; struct hdr_cursor nh; nh.pos = data; int ip_type; struct ethhdr *eth; // Разбираем заголовок Ethernet int eth_type = parse_ethhdr(&nh, data_end, ð); if (eth_type == bpf_htons(ETH_P_IP)) { // Получен пакет IPv4 struct iphdr *ip; // Разбираем заголовок IPv4 ip_type = parse_iphdr(&nh, data_end, &ip); ... if (ip_type == IPPROTO_ICMP) { // Получен пакет ICMP struct icmphdr *icmp; // Разбираем заголовок ICMP int icmp_type = parse_icmphdr(&nh, data_end, &icmp); ... } } else if (eth_type == bpf_htons(ETH_P_IPV6)) { // Получен пакет IPv6 struct ipv6hdr *ipv6; // Разбираем заголовок IPv6 ip_type = parse_ip6hdr(&nh, data_end, &ipv6); ... if (ip_type == IPPROTO_ICMPV6) { // Получен пакет ICMPv6 struct icmp6hdr *icmp6; // Разбираем заголовок ICMPv6 int icmp6_type = parse_icmp6hdr(&nh, data_end, &icmp6); ... } } if (ip_type == IPPROTO_TCP) { // Получен пакет TCP struct tcphdr *tcp; // Разбираем заголовок TCP int tcp_type = parse_tcphdr(&nh, data_end, &tcp); ... } else if (ip_type == IPPROTO_UDP) { // Получен пакет UDP struct udphdr *udp; // Разбираем заголовок UDP int udp_type = parse_udphdr(&nh, data_end, &udp); ... } ...
Все эти вспомогательные функции принимают следующие аргументы:
struct hdr_cursor *nh– курсор разбора заголовков, который изначально указывает на начало данных пакета:nh.pos = data;. После успешного разбора очередного заголовка функция сдвигаетnh->posза его пределы, поэтому следующая функция начинает работу с нужной позиции.void *data_end– конец допустимой области данных пакета. Он используется для проверки границ буфера, чтобы программа не читала память за пределами пакета.<header> **out– выходной указатель на разобранный заголовок, напримерstruct ethhdrилиstruct iphdr.
Если рассмотреть каждый этап подробнее, то разбор заголовка Ethernet позволяет определить, содержит ли кадр пакет IPv4 или IPv6, а затем извлечь данные соответствующего заголовка.
SEC("xdp") int xdp_program(struct xdp_md *ctx) { void *data_end = (void *)(unsigned long long)ctx->data_end; void *data = (void *)(unsigned long long)ctx->data; struct hdr_cursor nh; nh.pos = data; int ip_type; // Разбираем заголовок Ethernet struct ethhdr *eth; int eth_type = parse_ethhdr(&nh, data_end, ð); if (eth_type == bpf_htons(ETH_P_IP)) { bpf_printk("We have captured an IPv4 packet"); struct iphdr *ip; // Разбираем заголовок IPv4 ip_type = parse_iphdr(&nh, data_end, &ip); ... __u32 src = bpf_ntohl(ip->saddr); __u32 dst = bpf_ntohl(ip->daddr); bpf_printk("IPv4 src: %d.%d.%d.%d", (src >> 24) & 0xFF, (src >> 16) & 0xFF, (src >> 8) & 0xFF, src & 0xFF); bpf_printk("IPv4 dst: %d.%d.%d.%d", (dst >> 24) & 0xFF, (dst >> 16) & 0xFF, (dst >> 8) & 0xFF, dst & 0xFF); ... } else if (eth_type == bpf_htons(ETH_P_IPV6)) { bpf_printk("We have captured an IPv6 packet"); struct ipv6hdr *ipv6; // Разбираем заголовок IPv6 ip_type = parse_ip6hdr(&nh, data_end, &ipv6); ... // Выводим в виде восьми 16-битных блоков в шестнадцатеричном формате bpf_printk("IPv6 src: %x:%x:%x:%x:%x:%x:%x:%x", bpf_ntohs(ipv6->saddr.in6_u.u6_addr16[0]), bpf_ntohs(ipv6->saddr.in6_u.u6_addr16[1]), bpf_ntohs(ipv6->saddr.in6_u.u6_addr16[2]), bpf_ntohs(ipv6->saddr.in6_u.u6_addr16[3]), bpf_ntohs(ipv6->saddr.in6_u.u6_addr16[4]), bpf_ntohs(ipv6->saddr.in6_u.u6_addr16[5]), bpf_ntohs(ipv6->saddr.in6_u.u6_addr16[6]), bpf_ntohs(ipv6->saddr.in6_u.u6_addr16[7])); bpf_printk("IPv6 dst: %x:%x:%x:%x:%x:%x:%x:%x", bpf_ntohs(ipv6->daddr.in6_u.u6_addr16[0]), bpf_ntohs(ipv6->daddr.in6_u.u6_addr16[1]), bpf_ntohs(ipv6->daddr.in6_u.u6_addr16[2]), bpf_ntohs(ipv6->daddr.in6_u.u6_addr16[3]), bpf_ntohs(ipv6->daddr.in6_u.u6_addr16[4]), bpf_ntohs(ipv6->daddr.in6_u.u6_addr16[5]), bpf_ntohs(ipv6->daddr.in6_u.u6_addr16[6]), bpf_ntohs(ipv6->daddr.in6_u.u6_addr16[7])); ... }
Примечание
? Если внимательно посмотреть на код, можно заметить, что мы используем вспомогательные функции eBPF, например bpf_ntohs(). Они преобразуют значения из сетевого порядка байтов в порядок байтов хоста, чтобы программе было проще с ними работать.
Подробнее об этих вспомогательных функциях eBPF и их использовании можно прочитать в официальной документации eBPF.
Как минимум теперь у нас есть доступ к адресам источника и назначения IPv4/IPv6. По ним можно идентифицировать клиентов и использовать эту информацию, например, для ограничения частоты запросов или фильтрации трафика.
Примечание
? IP-заголовок содержит и другие полезные поля: TTL, тип протокола TCP/UDP, смещение фрагмента и общую длину пакета. Их можно использовать для более глубокого анализа трафика.
Полный набор доступных полей заголовков пакетов можно посмотреть в структурах iphdr и ipv6hdr из файла lab/vmlinux.h.
Но это ещё не всё: наша XDP-программа также показывает, как разбирать протоколы TCP, UDP и ICMP.
SEC("xdp") int xdp_program(struct xdp_md *ctx) { void *data_end = (void *)(unsigned long long)ctx->data_end; void *data = (void *)(unsigned long long)ctx->data; struct hdr_cursor nh; nh.pos = data; int ip_type; // Разбираем заголовок Ethernet struct ethhdr *eth; int eth_type = parse_ethhdr(&nh, data_end, ð); if (eth_type == bpf_htons(ETH_P_IP)) { bpf_printk("We have captured an IPv4 packet"); struct iphdr *ip; // Разбираем заголовок IPv4 ip_type = parse_iphdr(&nh, data_end, &ip); ... if (ip_type == IPPROTO_ICMP) { bpf_printk("We have captured an ICMP packet"); // Разбираем заголовок ICMP struct icmphdr *icmp; int icmp_type = parse_icmphdr(&nh, data_end, &icmp); ... bpf_printk("Type: %d", icmp->type); bpf_printk("Code: %d", icmp->code); if (icmp->type == ICMP_ECHO || icmp->type == ICMP_ECHOREPLY) { bpf_printk("Echo id=%d seq=%d\n", bpf_ntohs(icmp->un.echo.id), bpf_ntohs(icmp->un.echo.sequence)); } } } else if (eth_type == bpf_htons(ETH_P_IPV6)) { bpf_printk("We have captured an IPv6 packet"); struct ipv6hdr *ipv6; // Разбираем заголовок IPv6 ip_type = parse_ip6hdr(&nh, data_end, &ipv6); ... if (ip_type == IPPROTO_ICMPV6) { bpf_printk("We have captured an ICMP packet"); struct icmp6hdr *icmp6; // Разбираем заголовок ICMP int icmp6_type = parse_icmp6hdr(&nh, data_end, &icmp6); ... bpf_printk("Type: %d", icmp6->icmp6_type); bpf_printk("Code: %d", icmp6->icmp6_code); if (icmp6->icmp6_type == ICMPV6_ECHO_REQUEST || icmp6->icmp6_type == ICMPV6_ECHO_REPLY) { bpf_printk("Echo id=%d seq=%d\n", bpf_ntohs(icmp6->icmp6_dataun.u_echo.identifier), bpf_ntohs(icmp6->icmp6_dataun.u_echo.sequence)); } } } if (ip_type == IPPROTO_TCP) { bpf_printk("We have captured a TCP packet"); struct tcphdr *tcp; // Разбираем заголовок TCP int tcp_type = parse_tcphdr(&nh, data_end, &tcp); ... bpf_printk("Source port: %d", bpf_ntohs(tcp->source)); bpf_printk("Destination port: %d", bpf_ntohs(tcp->dest)); bpf_printk("Sequence number: %d", bpf_ntohs(tcp->seq)); bpf_printk("Acknowledgment number: %d", bpf_ntohs(tcp->ack_seq)); bpf_printk("Flags: SYN=%d ACK=%d FIN=%d RST=%d PSH=%d URG=%d ECE=%d CWR=%d", tcp->syn, tcp->ack, tcp->fin, tcp->rst, tcp->psh, tcp->urg, tcp->ece, tcp->cwr); } else if (ip_type == IPPROTO_UDP) { bpf_printk("We have captured a UDP packet"); // Разбираем заголовок UDP struct udphdr *udp; int udp_type = parse_udphdr(&nh, data_end, &udp); ... bpf_printk("Source port: %d", bpf_ntohs(udp->source)); bpf_printk("Destination port: %d", bpf_ntohs(udp->dest)); bpf_printk("Length of the UDP datagram: %d", bpf_ntohs(udp->len)); bpf_printk("Checksum for error detection: %d", bpf_ntohs(udp->check)); } ...
Так мы можем обращаться к разным полям в зависимости от типа перехваченного сетевого пакета.
TCP
Извлекает порты источника и назначения, порядковый номер, номер подтверждения и флаги SYN, ACK, FIN, RST, PSH, URG, ECE и CWR.
Это полезно для отслеживания клиентских соединений, обнаружения SYN-флуда и анализа TCP-трафика, например RTT и повторных передач.
UDP
Извлекает порты источника и назначения, длину дейтаграммы и контрольную сумму.
Это полезно для мониторинга протоколов на базе UDP, например DNS и QUIC.
ICMP / ICMPv6
Извлекает тип и код сообщения, а для эхо-запросов и эхо-ответов – идентификатор и номер последовательности.
Это полезно для мониторинга состояния сети, обнаружения массового ICMP-сканирования и DoS-атак, а также диагностики проблем с подключением.
Теперь должно быть гораздо понятнее, как разбирать разные протоколы и направлять выполнение программы по отдельным веткам. В каждой из них можно использовать свои действия XDP и реализовывать приложения разных типов.
Посмотрим, как это работает, на примере простого HTTP-запроса от клиента к серверу.

Сначала запустите HTTP-сервер на вкладке server:
python3 -m http.server 8000
Затем откройте ещё одну вкладку server – нажмите + и выберите server. Перейдите в каталог lab, соберите и запустите XDP-программу:
go generate go build sudo ./xdp -i eth0
Для простоты программа выводит информацию о заголовках пакетов прямо в журнал трассировки eBPF. Эти данные можно было бы передавать в пространство пользователя, но пока не будем усложнять пример. Откройте ещё одну вкладку server и выполните:
sudo bpftool prog trace
Наконец, отправьте запрос к серверу со вкладки client:
curl http://172.16.0.10:8000/
Теперь XDP-программа должна в реальном времени выводить разобранные данные заголовков Ethernet, IP и TCP.
Вы заметите, что в журнал попадает много сетевого трафика, а не только запрос клиента, отправленный через curl. В качестве небольшого упражнения попробуйте изменить код так, чтобы программа выводила только пакеты, у которых порт назначения TCP равен 8000 – порту, на котором HTTP-сервер принимает соединения.
Можно поступить проще и отфильтровать вывод с помощью grep:
sudo bpftool prog trace | grep 8000 -A 4 -B 4
До сих пор мы рассматривали только заголовки. Но как быть с данными прикладного уровня – в нашем случае с HTTP?
XDP имеет доступ только к необработанным данным пакета, которые помещаются в один кадр размером не более MTU. Поэтому надёжно разбирать с его помощью протоколы прикладного уровня вроде HTTP получается не всегда. Для таких задач лучше подходят точки подключения eBPF выше по сетевому стеку, работающие с восстановленными потоками данных из нескольких пакетов.
Мы скоро их рассмотрим. Но именно из-за этого ограничения разработчики часто сочетают XDP с другими типами программ, хотя XDP обеспечивает максимальную производительность и на первый взгляд кажется идеальным решением для многих сетевых задач.
В следующих руководствах мы не ограничимся перечислением сценариев, а шаг за шагом создадим реальные eBPF-приложения на базе XDP: ограничитель частоты пакетов, межсетевой экран и балансировщик нагрузки.

Если хочется продолжить тему уже за пределами базового XDP-примера, полезно посмотреть на соседние уровни той же задачи: работу с ядром Linux, отказоустойчивость сетевой инфраструктуры и фильтрацию трафика. Это помогает лучше понять, где XDP действительно даёт выигрыш, а где разумнее использовать другие механизмы Linux. Разобраться помогут бесплатные уроки:
17 августа, 20:00. «Что такое модуль ядра. Как его написать, собрать, запустить». Записаться
18 августа, 19:00. «Готовим инфраструктуру к сбоям: отказоустойчивый кластер на базе VRRP и HAProxy». Записаться
26 августа, 19:00. «Nftables без потери SSH: безопасно настраиваем firewall на удаленном сервере». Записаться
Больше бесплатных уроков августа смотрите в дайджесте.