Сетевой стек ядра

Устройства и драйверы | Управление памятью ядра

Драйвер диска работает на запрос процесса: процесс вызвал read() — драйвер поднял данные с устройства. Сетевая карта устроена иначе. Пакет приходит, когда его никто не ждал: веб-сервер спит в [[linux/programming/io-multiplexing#epoll-регистрация-и-готовность|epoll_wait()]], соединений нет, а на другом конце планеты браузер отправляет HTTP-запрос. Ethernet-кадр долетает до сетевой карты (NIC, Network Interface Card) сервера — и с этой секунды ядро обязано дотащить байты до процесса и разбудить его. Между электрическим сигналом на NIC и возвратом из epoll_wait() проходит 5–15 микросекунд. Чтобы понять, из чего они складываются — и почему на перегруженном сервере превращаются в сотни микросекунд, — пройдём весь путь шаг за шагом: DMA в кольцо, прерывание и NAPI, sk_buff вверх по стеку, IP, netfilter, TCP, передача эстафеты из softirq процессу.

NIC, DMA и кольцевой буфер

Первое звено уже знакомо по устройствам и драйверам и прерываниям: NIC сидит на шине PCIe и кладёт входящие кадры в RAM через DMA, не отвлекая процессор. Механизм передачи — кольцо дескрипторов, разделённое между картой и драйвером (NIC продвигает head, драйвер возвращает слоты через tail). Взглянем на этот обмен с точностью, которой в общих разделах не было — именно на этом уровне живут тонкие параметры, от которых потом зависит всё: сколько слотов в кольце, как они устроены, кто страдает, когда кольцо переполняется.

Драйвер при инициализации выделяет в памяти массив дескрипторов фиксированного размера — обычно от 256 до 4096 записей (зависит от модели NIC; ethtool -g eth0 показывает текущее и максимальное значения). Каждый дескриптор — это маленькая структура с физическим адресом заранее выделенного буфера в RAM, полем длины и полем статуса.

Ring buffer (N дескрипторов)
+-------+-------+-------+-------+-------+-------+
|  [0]  |  [1]  |  [2]  |  [3]  | ..... | [N-1] |
+-------+-------+-------+-------+-------+-------+
    ^                       ^
    |                       |
   head                   tail
  (NIC                   (драйвер
  пишет                  возвращает
  сюда)                  слоты)

Дескриптор:
+------------------+--------+------+
| phys_addr (буфер)| length | status|
+------------------+--------+------+

Когда приходит кадр, карта берёт дескриптор по head, записывает данные по физическому адресу из дескриптора, обновляет length и статус «готово», сдвигает head. Пока драйвер успевает возвращать слоты через tail, всё хорошо. Как только head догоняет tail — свободных слотов нет, и NIC начинает отбрасывать пакеты; счётчик rx_dropped в /proc/net/dev растёт. На загруженном сервере это первый симптом, который стоит проверять: либо кольцо мало для пиков (решается ethtool -G eth0 rx 4096, если аппаратура позволяет), либо ядро опрашивает кольцо недостаточно часто — тогда нужно смотреть выше по стеку, в softirq.

Одно кольцо быстро упирается в одно ядро: на скорости 10 Gbit/s один CPU не вытягивает обработку порядка 14,8 миллионов мелких пакетов в секунду. Распараллелить можно, только если пакеты попадают в независимые кольца — значит, нужно несколько параллельных ring buffer’ов, по одному на CPU. Так работают multi-queue NIC: типичная серверная карта на 10–25 Gbit/s имеет 8–64 независимых пар (rx+tx). Но тогда встаёт новый вопрос: в какое именно кольцо положить очередной кадр, чтобы не перемешать порядок внутри одного TCP-соединения и не гонять одну и ту же кеш-линию между ядрами? Ответ — RSS (Receive Side Scaling): карта вычисляет хеш Toeplitz от 4-tuple пакета (src IP, src port, dst IP, dst port), старшие биты выбирают запись в indirection table, а та указывает на номер очереди. Каждая очередь привязана своим прерыванием к конкретному ядру через smp_affinity — в итоге все пакеты одного соединения гарантированно попадают на одно ядро, а разные соединения распределены равномерно.

От прерывания к sk_buff

NIC положил кадр в кольцо — ядро об этом ещё не знает. Дальнейшее — штатный путь через NAPI, и он уже подробно разобран в заметке про прерывания: top half подтверждает IRQ и выключает его на этой NIC, планирует [[linux/kernel/interrupts#softirq-параллельно-lock-free-без-права-спать|softirq NET_RX_SOFTIRQ]], тот дёргает napi_poll() драйвера, napi_poll() вычитывает пачкой до NAPI weight дескрипторов (обычно 64), исчерпывает суммарный net.core.netdev_budget (300) и, если не успевает, работу подхватывает [[linux/kernel/interrupts#ksoftirqd-предохранитель-от-голодания|ksoftirqd/N]]. Для нас здесь важен один специфический момент: что именно драйвер создаёт для каждого готового дескриптора и куда пакет уходит дальше. Драйвер строит ядерный объект пакетаsk_buff — и пропихивает его в сетевой стек.

Перед этим срабатывает ещё одна оптимизация, которой не было в «общем» NAPI, — GRO (Generic Receive Offload). Если несколько последовательных дескрипторов в кольце принадлежат одному TCP-потоку, драйвер склеивает их в один большой sk_buff (до 64 КБ — legacy-лимит GRO_LEGACY_MAX_SIZE; BIG TCP в ядрах 5.19+ снимает это ограничение, и реальный потолок gro_max_size зависит от устройства) ещё до передачи наверх. Выигрыш прямой: вместо того чтобы прогонять через IP, netfilter и TCP каждый из тридцати пакетов по 1500 байт, стек обрабатывает один объединённый объект. На бенчмарках GRO даёт +20–40% пропускной способности при потоковой передаче. Но склейка имеет цену — задержку на накопление и потерю видимости отдельных пакетов, — поэтому GRO выключают для форвардинга через роутер-Linux и для latency-чувствительных нагрузок (ethtool -K eth0 gro off). Текущее состояние — ethtool -k eth0 | grep generic-receive-offload.

sk_buff: главная структура пакета

Пакет, созданный драйвером, должен подняться через IP, netfilter и TCP — и на каждом уровне у него будет «сниматься» очередной заголовок. Копировать данные между уровнями нельзя: на 1 миллионе пакетов в секунду memcpy по полтора килобайта на каждый шаг съест CPU быстрее, чем сам пакет долетит до сокета. Значит нужен объект, который передаётся по указателю и позволяет дёшево двигать «окно видимых данных» — обрезать сверху заголовок, не трогая байты. Этот объект — sk_buff (socket buffer, struct sk_buff в include/linux/skbuff.h): небольшой блок метаданных, в котором четыре указателя описывают границы текущего содержимого, а сами данные лежат отдельным куском в памяти.

Весит такая структура порядка 230–250 байт и выделяется не через общий kmalloc, а из специализированного slab-кеша skbuff_cache: на горячем пути аллокация и освобождение sk_buff случаются миллионы раз в секунду, и ходить в общий аллокатор за каждой структурой было бы слишком дорого. Slab держит эти объекты «горячими» — уже разрезанными на слоты нужного размера — и кладёт освобождённые обратно в кеш, экономя циклы и снижая фрагментацию.

Ключевые поля — четыре указателя, определяющие границы данных:

sk_buff
+---------+
| head  ---------> +========================+
| data  -------->  | заголовки (L2, L3, L4) |
| tail  -------->  | полезная нагрузка      |
| end   ---------> +========================+
|         |
| dev     |   <-- указатель на struct net_device (eth0)
| sk      |   <-- указатель на struct sock (если известен сокет)
| tstamp  |   <-- временная метка прихода пакета
| mark    |   <-- метка для netfilter/routing
+---------+

L2, L3, L4 — канальный, сетевой и транспортный уровни сетевой модели: Ethernet-заголовок, IP-заголовок, TCP/UDP-заголовок соответственно. head указывает на начало выделенного буфера, data — на начало текущих данных, tail — на конец данных, end — на конец буфера. По мере подъёма пакета вверх по стеку data сдвигается вперёд: после обработки Ethernet-заголовка смотрит на IP-заголовок, после IP — на TCP. Операция skb_pull(skb, len) и сдвигает этот указатель на len байт — «снимает» обработанный заголовок, не трогая сами байты. В этом и состоит трюк zero-copy между уровнями: меняются только указатели, данные остаются на месте.

Поле sk — это ссылка на struct sock, ядерное представление сокета (в include/net/sock.h): там лежит состояние соединения, оба буфера (приёма и отправки) в виде цепочек sk_buff и очередь ожидания процессов, которые спят на этом сокете. На уровне драйвера это поле ещё пустое; его проставит TCP-уровень, когда найдёт владельца пакета по 4-tuple. Остальные метаданные — через какой интерфейс пришёл пакет (dev), когда пришёл (tstamp), VLAN-тег (Virtual Local Area Network), приоритет и метка для маршрутизации (mark).

IP-уровень: мой ли пакет?

sk_buff поднялся к функции ip_rcv() в net/ipv4/ip_input.c. На этом этапе data уже показывает на IP-заголовок — Ethernet сняли ещё у драйвера. Теперь IP-уровень должен ответить на один главный вопрос: адресован этот пакет этой машине или кому-то другому? От ответа зависит, пойдёт ли sk_buff наверх к TCP, уедет ли через другой интерфейс дальше по сети или окажется в мусоре.

Перед самим вопросом — базовая гигиена: проверить, что заголовок не испорчен. Версия должна быть 4, длина заголовка (IHL, Internet Header Length) не меньше 20 байт, контрольная сумма должна сойтись, общая длина не должна превышать MTU (Maximum Transmission Unit) интерфейса. Всё, что не прошло проверку, отбрасывается молча; счётчик растёт в /proc/net/snmp строкой Ip: InHdrErrors. Если в проде этот счётчик начинает ползти — где-то между машиной и отправителем что-то сломано на L2.

Заголовок цел — теперь решение по адресу. IP сверяет destination с локальными адресами интерфейсов; совпало — пакет идёт в ip_local_deliver() и дальше к TCP или UDP. Не совпало, и машина включена как маршрутизатор (/proc/sys/net/ipv4/ip_forward = 1), — ip_forward() ищет путь дальше в FIB (Forwarding Information Base), декрементирует TTL, пересчитывает контрольную сумму и шлёт sk_buff в очередь на отправку через исходящий интерфейс. TTL вышел в ноль — пакет уничтожен, отправителю возвращается ICMP (Internet Control Message Protocol) Time Exceeded (это, в частности, то, что видит traceroute).

FIB внутри — LC-trie (Level-Compressed trie), префиксное дерево, оптимизированное для longest prefix match: маршрут выбирается по самому длинному совпадающему префиксу IP-адреса назначения. На типичном сервере с одним шлюзом по умолчанию дерево мизерное и поиск тривиален. На полноценном пограничном маршрутизаторе с полной таблицей BGP (более миллиона IPv4-префиксов по данным CIDR Report 2026) структура FIB — узкое место. Глобальный route cache удалён ещё в Linux 3.6 (он не масштабировался и был уязвим к атакам с рандомизированными адресами), но dst_entry по-прежнему привязывается к сокету: для установленного соединения маршрут ищется один раз и кешируется в struct sock, а не в глобальной таблице.

Отдельный путь — фрагментация. Если пришедший пакет оказался фрагментом (флаг MF, More Fragments, установлен или fragment offset ненулевой — только последний фрагмент идёт с MF=0 и ненулевым offset’ом), ip_rcv() направит его в ip_defrag(). Тот складывает фрагменты во временную хеш-таблицу по ключу (src IP, dst IP, protocol, identification) и ждёт остальных. Собрались все за 30 секунд (net.ipv4.ipfrag_time) — выходит один целый sk_buff, который идёт дальше как обычный. Не собрались — всё, что успело прийти, уничтожается. Для нашего потока обычно это боковая ветвь: HTTPS-запросы пролезают в MTU и не фрагментируются.

Netfilter и правила фильтрации

IP-уровень решает «мой пакет или чужой» по адресу. Но администратор машины хочет большего: пускать соединения только с определённых подсетей, подменять source-адрес при выходе через NAT, автоматически разрешать ответы на уже установленные сессии и блокировать всё прочее. Встроить эту политику в сам ip_rcv() нельзя — IP отвечает за маршрутизацию, а не за безопасность; смешивать эти роли — путь к нечитаемому коду и трудноотлаживаемым багам. Linux выносит политику в отдельную подсистему, которую IP-уровень вызывает в нескольких фиксированных точках через callback-функции. Эта подсистема — netfilter. Она не сидит «между IP и TCP» отдельным слоем, а встроена в ip_rcv(), ip_forward() и ip_output() через пять hook-точек:

                    маршрутизация
                         |
                   +-----+-----+
                   |           |
                   v           v
входящий      PREROUTING --> INPUT ----> локальный процесс
пакет              |                          |
                   |                       OUTPUT
                   v                          |
              FORWARD                    POSTROUTING --> исходящий
                   |                          ^           пакет
                   +------> POSTROUTING ------+

Пакет, пришедший извне и предназначенный локальному процессу, проходит цепочки PREROUTING и INPUT. Транзитный пакет — PREROUTING, FORWARD, POSTROUTING. Пакет, отправленный локальным процессом, — OUTPUT и POSTROUTING. Каждая цепочка хранит упорядоченный список правил вида «match → target», и сама эта модель (фильтрация по полям пакета плюс действия ACCEPT/DROP/REJECT/DNAT/SNAT/LOG) — общая для любого packet filter’а. Специфика Linux — в двух фронтендах, которые компилируют правила в эти hook-точки, и в их разной вычислительной стоимости.

Исторический iptables хранит правила в виде линейного массива внутри цепочки: при 10 000 правил каждый пакет в худшем случае сравнивается с каждым из них — O(N) на пакет, и это заметно при трафике в сотни тысяч пакетов в секунду. Более новый nftables (nft) транслирует то же множество правил в компактный байткод виртуальной машины и поддерживает sets и maps — хеш-таблицы и интервальные деревья, в которых поиск по IP/порту идёт за O(1) или O(log n). На Kubernetes-нодах, где каждому Service соответствует пачка правил, разница между линейным iptables и хеш-таблицами nftables (или eBPF-программ Cilium) — порядок величины по задержке и десятки процентов CPU в idle.

Отслеживание соединений

Сравнения по полю-за-полем мало: реальные политики формулируются не про отдельный пакет, а про соединение целиком. «Пропустить обратный трафик уже установленной сессии», «подменить source-адрес и запомнить отображение, чтобы ответ уметь развернуть обратно» — такие правила принципиально stateful, их нельзя решить на одном пакете. Это идея stateful firewall’а; в Linux она реализована модулем nf_conntrack (connection tracking), который запоминает каждое соединение в хеш-таблице по ключу (протокол, src IP, src port, dst IP, dst port) и для TCP отслеживает состояние — NEW (первый SYN), ESTABLISHED (после ответа), RELATED (связанное соединение, как data channel в FTP), INVALID (не попало ни в одну известную сессию).

Стандартный пример, зачем это нужно на практике: веб-сервер слушает 443. Клиент открыл соединение с эфемерного порта 52837 на порт 443. Ответный трафик сервера уходит с 443 на тот же 52837. Без conntrack файрвол пришлось бы конфигурировать так: «разрешить входящие на 443» и «разрешить исходящие со всех эфемерных портов на все удалённые порты» — последнее фактически означает «всё наружу разрешено». С conntrack политика пишется одной строкой -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT плюс явное правило на входящий 443: всё остальное блокируется по умолчанию. NAT целиком стоит на conntrack: при SNAT/MASQUERADE ядро запоминает отображение (внутренний IP:порт → внешний IP:порт) в той же таблице и по входящим ответам восстанавливает оригинальный адрес контейнера.

Цена — память и CPU. Каждая запись занимает около 300–400 байт, и при 500 000 одновременных соединений таблица потребляет ~150–200 МБ. Поиск в хеш-таблице выполняется для каждого проходящего пакета и добавляет ~100–200 нс к обработке. Максимальное число записей задаёт net.netfilter.nf_conntrack_max; значение считается ядром от объёма RAM при загрузке и типично равно 262 144 для серверов с ≥4 ГБ памяти (на маленьких машинах падает до 65 536) — это не константа дистрибутива, а функция доступной памяти. Размер хеш-таблицы — net.netfilter.nf_conntrack_buckets; отношение записей к бакетам (load factor) желательно держать в районе 4–8. Когда таблица переполняется, новые соединения отбрасываются и в dmesg появляется nf_conntrack: table full, dropping packet — частая поломка на балансировщиках нагрузки и NAT-шлюзах с десятками тысяч одновременных сессий.

TCP: от сегмента к данным

После netfilter пакет приходит в tcp_v4_rcv() (net/ipv4/tcp_ipv4.c) — и это всё ещё просто блок байтов. Никто на предыдущих уровнях не знал, какому соединению он принадлежит: Ethernet видел MAC-адреса, IP — IP-адреса, но соединение задаётся четвёркой полей (4-tuple): src IP, src port, dst IP, dst port. TCP-уровень должен эту четвёрку посчитать из заголовка и найти владельца в хеш-таблице inet_hashtable. Поиск идёт в два прохода: сначала ehash для установленных соединений, потом lhash для сокетов в состоянии LISTEN. На сервере с сотнями тысяч соединений первый шаг — O(1) при хорошем распределении хешей. Владельца не нашлось и ни один сокет не слушает этот порт — отправителю улетает TCP RST, а sk_buff отбрасывается.

Владелец найден — теперь надо убедиться, что данные целы. Формально TCP обязан пересчитать контрольную сумму по заголовку и полезной нагрузке, но на практике современные NIC делают это аппаратно (checksum offload): ядро просто читает флаг в sk_buff и экономит ~100 нс на пакет. Если сегмент пришёл в ожидаемом порядке, его данные помещаются в receive buffer сокета — цепочку sk_buff внутри struct sock. Если сегмент прилетел не по порядку — из-за того, что промежуточный роутер переставил пакеты или один сегмент потерялся и ретранслирован позже, — он отправляется в out-of-order queue, где ждёт заполнения дыры. Ядро держит эту очередь в красно-чёрном дереве (BST, самобалансирующийся вариант), отсортированном по sequence number — переход на rb-tree случился в Linux 4.9 (до этого был линейный список, который взрывался при сильном переупорядочивании). Когда недостающий сегмент наконец приходит, весь накопленный хвост одним проходом переносится в receive buffer. Параметр net.ipv4.tcp_max_reordering задаёт не literal предел длины очереди, а то, насколько сильное переупорядочивание TCP готов терпеть, прежде чем считать сегмент потерянным и запускать loss recovery.

Теоретическая часть flow control через окно приёма разобрана отдельно; здесь важна её ядерная реализация. Receive buffer сокета — память ядра, и её размер ограничен. Диапазон задаёт net.ipv4.tcp_rmem тремя числами в байтах: минимум, начальное значение, максимум (на современных ядрах: min = 4 КБ, default = 128 КБ, max — динамический, от 128 КБ до 32 МБ в зависимости от объёма RAM). Ядро растит буфер от начального к максимуму по мере того, как соединение доказывает, что канал быстрый и толстый. В заголовке каждого ACK TCP анонсирует отправителю текущий свободный размер окна. Заполнился буфер — окно схлопывается до нуля, и отправитель останавливается сам (zero window). Процесс-получатель, который медленно читает, не теряет пакеты — просто перестаёт их получать. Крутить tcp_rmem вверх имеет смысл, когда канал действительно толстый и длинный: BDP (bandwidth-delay product — пропускная способность × RTT, т.е. «сколько байтов одновременно помещается в пути») велик, и стандартных 6 МБ не хватает, чтобы держать «трубу заполненной». Это трансконтинентальные линки с большим RTT и жирные приёмники с массивом дисков; для локального гигабита дефолт избыточен.

ACK отправляется не сразу: на входящий сегмент ядро обычно ставит delayed ACK, надеясь совместить его с данными ответа от процесса и сэкономить один отдельный пакет (delayed ACK в TCP tuning разобран отдельно). В Linux это управляется флагом quickack и состоянием соединения; опция сокета TCP_QUICKACK не делает соединение «навсегда без delayed ACK», а просит ядро временно переключиться в быстрый режим — ядро потом само из него выйдет. Если процесс успел вызвать write() до истечения таймера, ACK уедет поверх ответа (piggybacking — совмещение подтверждения с полезной нагрузкой). Не успел — отправится чистый ACK без данных.

Передача эстафеты: от softirq к процессу

Всё, что происходило до сих пор, шло в контексте прерывания и softirq — без участия процесса. Веб-сервер, отправивший epoll_wait() и уснувший в самом первом абзаце, до сих пор спит и не знает, что его данные уже в ядре. Теперь нужно замкнуть круг: передать управление из softirq обратно тому процессу, который эти данные ждал. Это последний шаг пути пакета — и, пожалуй, самый нетривиальный, потому что softirq не может «вызвать» процесс, а должен как-то договориться с планировщиком.

Механизм такой. Процесс, когда-то вызвавший epoll_wait(), сейчас спит в очереди ожидания epoll-инстанса (epoll wait queue). А сам сокет имеет свою, отдельную, очередь ожидания (socket wait queue), и при регистрации через [[linux/programming/io-multiplexing#epoll-регистрация-и-готовность|epoll_ctl()]] ядро положило в неё специальный callback — не сам процесс, а функцию, которая знает, к какому epoll относится сокет. Когда TCP-уровень добавил данные в receive buffer и вызвал sk_data_ready(), callback из socket wait queue срабатывает: он не будит процесс напрямую, а перемещает fd сокета в ready list epoll’а (список fd, по которым есть события). И уже epoll, обнаружив непустой ready list, будит процесс, спящий в своей epoll wait queue. Пробудившись, процесс видит свой fd в списке готовых и зовёт read() или recv() — только тогда ядро вызывает copy_to_user(), копируя байты из sk_buff в пользовательский буфер.

Это единственное копирование на всём пути (не считая самого DMA-записи): от ring buffer через IP, netfilter и TCP данные передавались через указатели внутри sk_buff, без перекладывания байтов. Копирование в userspace обходится в ~200–500 нс на типичный 1500-байтный пакет и ограничивается пропускной способностью кеша L1/L2. Здесь и замыкается круг: электрический сигнал на NIC окончательно превратился в байты в буфере процесса. Обойти это последнее копирование можно через MSG_ZEROCOPY (на отправке) и io_uring с режимом fixed buffers, но для приёма TCP-данных общего механизма zero-copy пока нет — из kernel space в user space всё равно копируется.

Полный путь одного пакета от NIC до процесса:

NIC                     Ядро                                 Процесс
 |                       |                                      |
 |---DMA-записал-------->|                                      |
 |   в ring buffer       |                                      |
 |                       |                                      |
 |---IRQ---------------->|                                      |
 |                    top half:                                  |
 |                    ACK + disable IRQ                          |
 |                    + schedule NAPI                            |
 |                       |                                      |
 |                    softirq:                                   |
 |                    napi_poll() ->                             |
 |                    sk_buff x N                                |
 |                       |                                      |
 |                    ip_rcv():                                  |
 |                    validate + route                           |
 |                       |                                      |
 |                    netfilter:                                 |
 |                    PREROUTING -> INPUT                        |
 |                       |                                      |
 |                    tcp_v4_rcv():                              |
 |                    4-tuple lookup ->                          |
 |                    receive buffer                             |
 |                       |                                      |
 |                    sk_data_ready()                            |
 |                    -> epoll ready list --wake up------------->|
 |                       |                                      |
 |                       |<-----read()/recv()-------------------|
 |                       |---данные в userspace buf------------>|
 |                       |                                      |
                   ~5-15 мкс от DMA до wake up

Эти 5-15 микросекунд — время при тёплых кешах и без конкуренции за CPU. Примерная раскладка по этапам: DMA-запись занимает ~1 мкс, top half прерывания — ~0.1 мкс, NAPI poll и создание sk_buff — ~1-2 мкс, IP + netfilter — ~1-3 мкс, TCP-обработка — ~1-3 мкс, пробуждение процесса и переключение контекста — ~1-3 мкс. Основная доля времени уходит на обработку в стеке (IP, netfilter, TCP), а не на DMA или аппаратное прерывание. При холодных кешах L3 или высокой нагрузке на CPU время может вырасти до 20-50 мкс. Для приложений, где даже 5 мкс — слишком много (высокочастотный трейдинг, телеком), существует kernel bypass (обход ядра): библиотеки DPDK (Data Plane Development Kit) и XDP (eXpress Data Path) перехватывают пакеты на уровне NIC или драйвера, минуя весь стек ядра и снижая задержку до 1-2 мкс ценой потери удобств TCP/IP-стека.

Один стек или много: network namespace

Всё выше описано в предположении, что на машине один сетевой стек: один набор интерфейсов, одна FIB, одна таблица conntrack. Но на сервере с контейнерами стек не один — у каждого контейнера свой полный экземпляр: свои интерфейсы, своя маршрутизация, свой netfilter и свой nf_conntrack. Механика изоляции (CLONE_NEWNET, veth-пары, bridge docker0/cni0, MASQUERADE в POSTROUTING, CNI-плагины вроде Calico/Cilium) подробно разобрана в пространствах имён. Важно, что путь пакета из прошлых секций при этом не меняется — он просто проигрывается заново внутри нужного namespace, и каждый переход через veth добавляет 1–3 мкс по сравнению с прямым физическим интерфейсом. А два контейнера на одной машине не могут общаться через 127.0.0.1: у каждого свой lo — только через общий bridge или через host-сеть (--network=host в Docker).

Обратный путь: от процесса к NIC

На приёме инициатор — железо: пакет прилетает, softirq тащит его наверх, процесс в конце цепочки просто просыпается. На отправке всё симметрично наоборот: начинает процесс, а железо — конечный исполнитель. Это меняет, кто открывает цепочку и кто за неё платит (синхронный syscall вместо асинхронного прерывания), но ниже TCP всё зеркально. Процесс вызывает write() или send() — данные копируются из пользовательского буфера в send buffer сокета; TCP формирует сегмент с порядковым номером и checksum (если нет аппаратного offload); ip_queue_xmit() добавляет IP-заголовок и через FIB определяет исходящий интерфейс; netfilter дёргается на цепочках OUTPUT и POSTROUTING (и здесь SNAT/MASQUERADE может подменить source IP — ровно тот контрагент, с которым conntrack потом будет восстанавливать ответ). Дальше — драйвер кладёт sk_buff в TX ring buffer, NIC забирает данные через DMA, отправляет кадр и поднимает TX-completion, по которому ядро освобождает sk_buff.

Send buffer ограничен параметрами net.ipv4.tcp_wmem (min = 4 КБ, default = 16 КБ, max — динамический, от 64 КБ до 4 МБ в зависимости от RAM). Если процесс пишет быстрее, чем сеть успевает отправлять, буфер заполняется: в блокирующем режиме write() засыпает, в неблокирующем возвращает EAGAIN. Суммарная память, занятая TCP-буферами всех соединений, ограничена net.ipv4.tcp_mem — три значения в страницах: ниже первого порога ядро вообще не давит, между первым и вторым — переключает соединения в memory pressure (начинает сжимать окна), выше третьего — отбрасывает новые данные.

На выходе работает оптимизация, симметричная GRO, — TSO (TCP Segmentation Offload). Ядру отдавать в ip_output() 44 пакета по MTU вместо одного большого — дорого: каждый проходит через qdisc, netfilter и драйвер отдельно. Вместо этого ядро передаёт NIC один «суперсегмент» (legacy-лимит 64 КБ; BIG TCP снимает это ограничение), а карта сама режет его на честные MTU-сегменты и выставляет правильные sequence numbers. Побочный эффект: в tcpdump на локальном интерфейсе видны «большие» сегменты, которых в реальной сети никогда не было — частая ловушка при диагностике.

Наблюдаемость: где искать потерянные пакеты

Когда в продакшне пакеты теряются, приложение видит только таймаут или ECONNRESET. Ему всё равно, где именно произошёл отброс — в ring buffer перегруженной NIC, в softirq, который не уложился в бюджет, в переполненной таблице conntrack, в backlog listening-сокета или на уровне сети между клиентом и сервером. Все эти симптомы с точки зрения приложения идентичны. Чтобы понять, где именно сломалось, нужны счётчики на каждом уровне пути, который мы только что прошли — они есть, и Linux выставляет их через /proc и пачку утилит. Если строить диагностику снизу вверх по стеку, каждый следующий шаг добавляет ровно одну проверку.

1. Ring buffer на NIC. ethtool -S eth0 | grep -i drop и rx_dropped в /proc/net/dev — растут, если кольцо переполняется раньше, чем драйвер успевает его разгребать. Симптом: буфер мал или napi_poll пробуксовывает. Лекарство — ethtool -G eth0 rx <N> для увеличения кольца и/или перераспределение прерываний через smp_affinity. Параллельно rx_errors там же сигнализирует об аппаратных проблемах — CRC (Cyclic Redundancy Check), кадры не по формату.

2. Softirq и NAPI. /proc/net/softnet_stat — построчно по каждому CPU; из трёх первых колонок важны две. Колонка 2 (dropped) — пакеты, отброшенные из-за переполнения backlog-очереди (это путь netif_rx/RPS, не основной NAPI-путь, но он важен, если включён Receive Packet Steering). Колонка 3 (time_squeeze) — именно тот счётчик, который означает «NAPI не успевает»: он инкрементируется каждый раз, когда net_rx_action исчерпал netdev_budget (300 по умолчанию) или 2 мс на один цикл softirq, не опустошив все кольца. Рост колонки 3 — сигнал крутить net.core.netdev_budget вверх или проверять, не заняты ли CPU под ksoftirqd чем-то ещё.

3. Conntrack. dmesg | grep conntrack на предмет nf_conntrack: table full, dropping packet; conntrack -L | wc -l против net.netfilter.nf_conntrack_max. На балансировщиках и NAT-шлюзах это частая причина «соединения ловят RST непонятно откуда».

4. TCP-уровень. ss -tnp | grep -c ESTAB — общее число установленных соединений против /proc/sys/fs/file-max и лимитов сокета; ss -tnp state listen плюс TcpExt: ListenOverflows в /proc/net/netstat показывают, не переполнен ли backlog слушающего сокета (частая причина странных SYN drops у только что перезагруженных сервисов).

5. Потери на уровне сети. grep Tcp /proc/net/snmpRetransSegs пополз вверх и оба направления трафика нормальные: значит пакеты теряются или переставляются где-то между машинами. Сюда же — InHdrErrors (поврежденные IP-заголовки) и InCsumErrors (плохие TCP/UDP checksum’ы, если checksum offload не скрыл их).

6. Если счётчиков не хватает. tcpdump через AF_PACKET — специальный тип сокета (socket(AF_PACKET, SOCK_RAW, …)), который получает копию каждого кадра на указанном интерфейсе. Важный нюанс: перехват происходит до netfilter на входе и после netfilter на выходе, поэтому в tcpdump виден трафик таким, каким его «видит сеть», а не таким, каким его получает приложение после правил iptables. bpftrace и perf цепляются к tracepoints сетевого стека (net:net_dev_queue, net:netif_receive_skb, tcp:tcp_rcv_established) и позволяют измерить задержку каждого отдельного этапа обработки без пересборки ядра.

Параметры стека, которые упоминались выше по тексту, живут в /proc/sys/net/: core/netdev_budget, ipv4/tcp_rmem, ipv4/tcp_wmem, ipv4/ip_forward, netfilter/nf_conntrack_max. Меняются через sysctl -w или записью в файл (сбрасываются при перезагрузке); для постоянного применения — в /etc/sysctl.d/*.conf.

Sources


Устройства и драйверы | Управление памятью ядра