Как пакет проходит через ядро Linux

Как пакет проходит через ядро Linux


Большинство инженеров знают: XDP - это быстро, а nftables - «та самая штука-файрвол». Но мало кто понимает, почему под нагрузкой они ведут себя так по-разному. Ответ не в инструментах, а в том, где в ядре каждый из них работает.

Чтобы рассуждать о том, куда вешать хук, об ошибках верификатора, дизайне map или о скорости обработки пакетов, вам нужна одна вещь: ясная картина того, как пакет на самом деле идёт через ядро Linux.

Этот пост про то, как такую картину собрать.

Термины

Несколько терминов, которые будут повторяться:

  • NAPI - цикл опроса пакетов в ядре. Собирает работу по приёму в пачки, вместо отдельного прерывания на каждый пакет.
  • softirq - контекст ядра, в котором этот опрос обычно и крутится.
  • skb/sk_buff - обёртка пакета. Появляется, когда стек начинает с ним работать.
  • XDP - самый ранний хук обработки пакетов, ещё до skb.
  • conntrack - трекер состояния потоков в ядре. Им пользуется Netfilter.
  • FIB - поиск в таблице форвардинга. Решает, пакет локальный или его надо переслать.

Общая картина

Вот весь путь. У каждой стрелки есть цена. К этим ценам вернёмся в конце, но пока читаете, держите их в голове.

Шаг 1: NIC и DMA

Пакет приходит на вашу сетевую карту (NIC). NIC не дёргает CPU прерыванием на каждый пакет, иначе на высокой скорости, скажем 10 Гбит/с, это была бы катастрофа. Вместо этого она через DMA (Direct Memory Access) пишет входящие кадры сразу в область RAM, которую ядро выделило заранее. Эта область называется RING BUFFER. Её ещё называют RX ring или descriptor ring.

Что такое RING BUFFER? Кольцевой массив дескрипторов памяти. Каждый дескриптор указывает на кусок памяти: область данных sk_buff, а в случае XDP - сырую страницу. NIC заполняет эти слоты сама, потом поднимает прерывание и говорит CPU: «Тут пакеты, загляни».

Прерывание срабатывает раз на пачку, не на каждый пакет. В этом весь смысл NAPI. Без этого линия на 10 Гбит/с порождала бы миллионы прерываний в секунду, и CPU тратил бы больше времени на прерывания, чем на пакеты или на что-нибудь полезное.

Шаг 2: цикл опроса NAPI

Когда с NIC прилетает прерывание, ядро отключает дальнейшие прерывания с карты и планирует poll-функцию. Это NAPI, New API: механизм Linux для смягчения прерываний.

Poll-функция драйвера работает в контексте softirq и вычитывает ring buffer в цикле: до budget пакетов (по умолчанию 64), потом уступает процессор. Если пакеты ещё ждут, она планирует себя снова. Прерывания NIC включаются обратно только когда кольцо пустое.

Поэтому на высоком packet rate одно ядро CPU может сидеть в softirq на 100%. Оно непрерывно крутит цикл опроса NAPI и уже не засыпает.

Упрощённый опрос NAPI:

while (budget-- > 0) {
    pkt = ring_buffer_next();
    if (!pkt) {
        napi_complete();   // кольцо пустое, снова включаем прерывания
        break;
    }
    process_packet(pkt);   // здесь срабатывает хук XDP, ещё до SKB
}

Вот где живёт XDP. Прямо здесь, внутри этого цикла, до того как с пакетом случится что-то ещё.

Шаг 3: хук XDP, ещё до SKB

XDP (eXpress Data Path) - хук eBPF, который выполняется внутри цикла опроса NAPI, до того как ядро выделит пакету socket buffer.

Вот эта деталь, «ещё до socket buffer», и есть всё. Поэтому XDP такой быстрый.

На sk_buff уходит примерно 200-500 нс: выделить и проинициализировать, смотря что в кэше. На 100 тысячах пакетов/с это неважно. На 10 миллионах пакетов/с это уже 2-5 целых ядра CPU, и только на аллокацию. nftables эту драку не выиграет. К моменту, когда nftables видит пакет, SKB уже есть. XDP дропает пакет до рождения SKB.

Программе XDP достаётся совсем мало контекста, struct xdp_md:

struct xdp_md {
    __u32 data;           // указатель на начало данных пакета
    __u32 data_end;       // указатель на конец данных пакета
    __u32 data_meta;      // метаданные перед data
    __u32 ingress_ifindex;
    __u32 rx_queue_index;
};

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

Ваша XDP-программа возвращает один из пяти вердиктов:

  • XDP_DROP: сразу дропнуть пакет и освободить буфер
  • XDP_PASS: отдать пакет обычному сетевому стеку
  • XDP_TX: отправить пакет обратно в тот же интерфейс
  • XDP_REDIRECT: отправить на другой интерфейс или другое ядро CPU через bpf_redirect()
  • XDP_ABORTED: дроп плюс tracepoint (для ошибок)

Именно XDP_DROP делает XDP таким эффективным для митигации DDoS. Пакет так и не становится SKB, не касается TCP-стека, не доходит до netfilter и не создаёт запись в conntrack. Исчезает за ~100 нс, и у ядра не остаётся записи, что он вообще был.

Шаг 4: build_skb(), здесь рождается SKB

Если XDP пропустил пакет дальше по стеку, или если к драйверу XDP не прицеплен, ядро вызывает build_skb() и оборачивает сырой пакет в struct sk_buff.

Здесь пакет переходит черту: из «сырой DMA-памяти» он становится тем, с чем сетевой стек ядра умеет работать. Дальше TC, Netfilter и сокетные слои работают уже со SKB, не с сырыми байтами.

sk_buff - одна из самых важных структур сетевого стека Linux. Это не только данные пакета. Тут метаданные, состояние пакета и набор указателей, чтобы стек разбирал заголовки, не копируя байты.

skb_pull() снимает заголовок, skb_push() дописывает заголовок спереди. Обе просто двигают указатель data. Копирования памяти нет. Байты на месте, двигается указатель. Так стек проходит цепочку Ethernet + IP + TCP и ничего не копирует. Поэтому eBPF-программа, которая читает поля __sk_buff, безопасна: верификатор не даст прочитать дальше data_end. Это ровно та граница, которую build_skb() поставил, когда обернул DMA-буфер.

Шаг 5: хук TC, eBPF уже с полным SKB

Когда SKB уже есть, пакет входит в подсистему Traffic Control (TC). Ingress-хук TC - вторая крупная точка, куда цепляют eBPF.

В отличие от минимального xdp_md у XDP, программа TC получает struct __sk_buff. Это «тень» настоящего sk_buff: верификатор eBPF разрешает её читать, а часть полей можно и писать.

struct __sk_buff {
    __u32 len;
    __u32 pkt_type;
    __u32 mark;
    __u32 queue_mapping;
    __u32 protocol;        // ETH_P_IP, ETH_P_IPV6 и так далее
    __u32 vlan_present;
    __u32 vlan_tci;
    __u32 vlan_proto;
    __u32 priority;
    __u32 ingress_ifindex;
    __u32 ifindex;
    __u32 tc_index;
    __u32 cb[5];
    // ... и ещё поля
};

Программа TC возвращает TC_ACT_OK (пропустить), TC_ACT_SHOT (дроп), TC_ACT_REDIRECT, TC_ACT_PIPE.

Чего есть у TC и нет у XDP:

  • skb->protocol без ручного разбора заголовков
  • данные connection tracking через bpf_skb_load_bytes()
  • хуки и на ingress, и на egress
  • skb->mark, чтобы пометить пакет для обработки дальше по пути

Цена TC относительно XDP:

  • SKB уже выделен. Эту цену вы уже заплатили.
  • Вызов чуть дороже: контекст богаче.

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

Частая ошибка, которую я вижу: люди хватаются за XDP, хотя им нужен TC, потому что XDP звучит быстрее. Если программе нужно состояние соединения, skb->mark или оба направления, ingress и egress, XDP не поможет. За SKB вы уже заплатили. Берите TC: контекст богаче, доплачивать не за что.

Шаг 6: Netfilter PREROUTING и connection tracking

После TC пакет попадает в первый хук Netfilter, PREROUTING.

Здесь работает nf_conntrack. Он ищет 5-tuple пакета в хеш-таблице conntrack: либо находит существующий поток, либо заводит новую запись. Каждый пакет, который дошёл сюда, проходит через conntrack. Есть у вас правила nftables или нет, неважно.

Отсюда берутся состояния ESTABLISHED и RELATED. NAT тоже здесь: по состоянию потока переписывает адреса ещё до того, как маршрутизация увидит пакет.

Цена Netfilter ощутимая. Даже пустой набор правил nftables чего-то стоит, потому что conntrack всё равно работает. Меня это удивило, когда я первый раз замерил. Правил nftables было ноль, а на высоком packet rate расход на conntrack всё равно был виден. Таблица conntrack устроена как хеш, и на масштабе флуда она начинает молотить кэш CPU. Записи сыплются быстрее, чем истекают.

Поэтому митигацию и ставят на XDP, до Netfilter. Дело не только в цепочке правил. Флуд не должен попасть в conntrack вообще. Пакет, который дропнул XDP, запись в conntrack не создаёт.

Шаг 7: решение о маршруте, поиск в FIB

После PREROUTING ядро решает: пакет для этой машины или его надо переслать на другой хост?

Поиск идёт по FIB (Forwarding Information Base), таблице маршрутизации ядра. От результата зависит, какой хук Netfilter сработает дальше.

eBPF может обойти этот поиск целиком, через bpf_fib_lookup() из XDP или TC. Отдаёте IP назначения, обратно приходят next-hop и исходящий интерфейс. Вместе с XDP_REDIRECT получается плоскость форвардинга, которая не заходит в цепочку Netfilter. Так делают высокопроизводительные роутеры и балансировщики.

Шаг 8: Netfilter INPUT (или FORWARD)

Маршрут выбран, и пакет попадает во второй хук Netfilter:

  • INPUT: пакеты для локальных сокетов
  • FORWARD: пакеты на другой хост, дальше POSTROUTING, и только потом исходящий NIC

Сюда вешают большинство правил файрвола nftables и iptables. nft add rule inet filter input ... приземляется здесь. Conntrack к этому моменту пакет уже классифицировал, так что ct state established матчится дёшево.

Шаг 9: очередь приёма сокета и recvmsg()

Локальный пакет после Netfilter INPUT попадает в очередь приёма сокета.

Ядро ищет нужный сокет по source IP, dest IP, source port, dest port и протоколу, тот самый 5-tuple. SKB дописывается в sk->sk_receive_queue.

Ваше приложение (скорее всего) заблокировано в recvmsg(). Ядро его будит, копирует данные из SKB в память userspace и освобождает SKB.

Это копирование, из памяти ядра в память userspace, последняя цена на пути. В обычном стеке recvmsg() от неё не уйти. Поэтому и есть AF_XDP: пропустить всё, что выше, и замапить DMA-память прямо в ваш процесс. Без копирования, без SKB, без Netfilter, без стека.

Короткий путь через AF_XDP

AF_XDP - тип сокета, который обходит большую часть всего, что выше. Вместо полного прохода по сетевому стеку XDP редиректит пакеты сразу в ring buffer, замапленный в userspace. Буфер называется UMEM.

Никакого SKB. Никакого Netfilter. Никакой очереди сокета. Никакого копирования. Только DMA-память, замапленная в ваш процесс. Так на обычном eBPF в ядре получают обработку пакетов с пропускной способностью класса DPDK.

Почему важно, где висит хук

Дроп в XDP выходит примерно за 100 нс: до SKB, до conntrack, до любой цепочки правил. Дроп в Netfilter где-то за 1 мкс, и за всё это вы уже заплатили.

На обычном трафике разницы не видно. Когда packet rate долго держится высоким, эти микросекунды напрямую превращаются в ядра CPU. Таблица conntrack начинает молотить L3-кэш, записи сыплются быстрее, чем истекают. Пропускная способность обваливается.

Точка хука - архитектурное решение. Неправильный выбор в юнит-тестах не всплывёт. Всплывёт в проде, под нагрузкой.




Report Page