В этом руководстве вы познакомитесь с основами 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, &eth);
    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, &eth);
    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, &eth);
    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 на удаленном сервере». Записаться

Больше бесплатных уроков августа смотрите в дайджесте.

Комментарии (0)