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

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

Веб-сервер вызвал epoll_wait() и спит — соединений нет. В этот момент на другом конце планеты браузер отправляет HTTP-запрос. Ethernet-кадр долетает до сетевой карты (NIC, Network Interface Card) сервера. Между приходом электрического сигнала на NIC и моментом, когда epoll_wait() вернёт управление процессу, проходит 5-15 микросекунд. За это время пакет пересекает шесть этапов: NIC кладёт данные в кольцевой буфер через DMA (Direct Memory Access), аппаратное прерывание подтверждает приход, NAPI вычитывает пачку пакетов в виде структур sk_buff, IP-уровень разбирает заголовок, netfilter проверяет правила фильтрации, TCP извлекает полезную нагрузку и кладёт её в receive buffer сокета — после чего ядро будит процесс.

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

Сетевая карта принимает Ethernet-кадр из сети. Но NIC — это периферийное устройство на шине PCIe (PCI Express), а данные нужны процессору в оперативной памяти. Копировать побайтно через CPU было бы расточительно: на скорости 10 Gbit/s (примерно 14.8 млн пакетов в секунду при минимальном размере кадра 64 байта) процессор не успевал бы ничего больше делать. Поэтому NIC использует DMA — механизм, позволяющий устройству писать напрямую в оперативную память, минуя процессор.

Для этого драйвер сетевой карты при инициализации выделяет в памяти кольцевой буфер (ring buffer) — массив дескрипторов фиксированного размера, обычно 256-4096 записей. Каждый дескриптор содержит физический адрес заранее выделенного буфера в RAM и поле статуса. Драйвер заполняет дескрипторы адресами свободных буферов и сообщает NIC базовый адрес кольца. Структура кольца напоминает идею io_uring: есть область, разделяемая между двумя сторонами (здесь NIC и драйвер), с указателями head и tail, которые движутся по кругу.

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

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

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

Современные NIC поддерживают несколько кольцевых буферов одновременно — по одному на каждую очередь (multi-queue NIC). Типичная серверная карта на 10-25 Gbit/s имеет 8-64 очередей. NIC распределяет входящие пакеты по очередям через RSS (Receive Side Scaling, масштабирование обработки на стороне приёма): хеш от полей пакета (обычно 4-tuple для TCP) определяет номер очереди. Каждая очередь привязана к своему прерыванию, а прерывание — к конкретному ядру CPU через smp_affinity. В результате пакеты одного TCP-соединения всегда обрабатываются одним ядром (кеш-локальность), а разные соединения распределяются по нескольким ядрам параллельно. Без RSS все пакеты шли бы через одно ядро, и на 10 Gbit/s оно стало бы узким местом задолго до насыщения канала.

Прерывание и NAPI

NIC записал данные в память — но ядро об этом не знает. Карта генерирует аппаратное прерывание (IRQ, Interrupt Request), которое попадает в обработчик верхней половины (top half). Top half работает с отключёнными прерываниями на этом IRQ, поэтому должен завершиться максимально быстро — за десятки наносекунд. Он делает ровно три вещи: подтверждает прерывание на уровне железа, отключает дальнейшие прерывания от этой NIC и планирует обработку в нижней половине (bottom half) через механизм NAPI (New API).

Зачем отключать прерывания? На скорости 10 Gbit/s пакеты приходят каждые 67 наносекунд при минимальном размере кадра. Если каждый пакет вызывает прерывание — возникает interrupt storm: процессор тратит всё время на сохранение/восстановление регистров и переключение контекста между обработчиками прерываний, не успевая обрабатывать сами данные. До появления NAPI (Linux 2.4) именно interrupt storm был главной проблемой высоконагруженных сетевых серверов. NAPI решает её переключением из режима прерываний в режим опроса (polling).

После того как top half запланировал NAPI, обработка продолжается в контексте softirq. Softirq NET_RX_SOFTIRQ выполняется либо сразу после возврата из аппаратного прерывания (если нет более приоритетных задач), либо потоком ksoftirqd/N (по одному на каждое ядро CPU). Если softirq не успевает обработать все запланированные задачи за 2 мс или 10 итераций (ограничение __do_softirq), оставшаяся работа передаётся ksoftirqd — обычному потоку ядра с дефолтным приоритетом (SCHED_OTHER, nice 0). Это разгружает interrupt context для обработки новых прерываний, а пакеты продолжают обрабатываться через планировщик наравне с пользовательскими задачами. Высокая загрузка ksoftirqd в top — индикатор того, что сетевая подсистема перегружена.

Функция napi_poll() зарегистрированного драйвера вызывается из контекста softirq. Эта функция читает из кольцевого буфера не один пакет, а пачку — до NAPI weight штук за один вызов (обычно 64 — задаётся драйвером при регистрации через netif_napi_add()). Отдельно существует net.core.netdev_budget (по умолчанию 300) — суммарный бюджет на один цикл NET_RX_SOFTIRQ для всех NAPI-источников на данном CPU. Для каждого дескриптора со статусом «готово» драйвер создаёт структуру sk_buff, копирует в неё метаданные и передаёт пакет вверх по сетевому стеку. Когда кольцо опустошено или бюджет исчерпан, NAPI снова включает прерывания от NIC.

Цикл выглядит так: прерывание отключить прерывания опрос пачкой кольцо пусто включить прерывания ждать следующего прерывания. При высокой нагрузке NAPI может не успевать опустошать кольцо за один вызов и вызывается повторно без включения прерываний — фактически работая в чистом режиме polling, что эффективнее при потоке в сотни тысяч пакетов в секунду.

На этом же этапе работает GRO (Generic Receive Offload). Если несколько последовательных пакетов принадлежат одному TCP-потоку, GRO склеивает их в один большой sk_buff — до 64 КБ — прежде чем передать вверх по стеку. Выигрыш существенный: вместо того чтобы прогонять через IP, netfilter и TCP каждый из тридцати пакетов по 1500 байт, стек обрабатывает один объединённый пакет. На бенчмарках GRO повышает пропускную способность на 20-40% при потоковой передаче данных. GRO включён по умолчанию; состояние проверяется через ethtool -k eth0 | grep generic-receive-offload.

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

Каждый сетевой пакет внутри ядра представлен структурой sk_buff (socket buffer, struct sk_buff в include/linux/skbuff.h). Это не просто блок данных — это метаданные пакета плюс указатели на его содержимое. Структура весит около 240 байт и выделяется из slab-кеша (пул заранее нарезанных объектов одного размера — ядро держит такие объекты горячими) для каждого входящего пакета.

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

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-заголовка data указывает на IP-заголовок, после IP — на TCP-заголовок. Операция skb_pull(skb, len) сдвигает data на len байт, «снимая» обработанный заголовок. Это позволяет избежать копирования данных при переходе между уровнями стека — меняются только указатели.

Помимо указателей на данные, sk_buff несёт метаданные: через какой сетевой интерфейс (dev) пришёл пакет, к какому сокету он относится (sk, если уже определён), временную метку (tstamp), VLAN-тег (VLAN, Virtual Local Area Network), приоритет и метку для маршрутизации (mark).

Аллокация и освобождение sk_buff — горячий путь: на скорости 1 Mpps (миллион пакетов в секунду) ядро создаёт и уничтожает миллион этих структур каждую секунду. Поэтому sk_buff выделяется из специализированного slab-кеша (skbuff_head_cache), а буфер данных — из пула страниц. При освобождении структура возвращается в кеш, а не в общий аллокатор, что снижает фрагментацию и экономит циклы CPU на аллокации.

IP-уровень

Функция ip_rcv() получает sk_buff от драйвера. На этом этапе sk_buff->data указывает на начало IP-заголовка — Ethernet-заголовок уже был обработан и «снят» через skb_pull() на предыдущем уровне. Первое действие — валидация IP-заголовка: версия должна быть 4, длина заголовка (IHL, Internet Header Length) не меньше 20 байт, контрольная сумма должна сойтись, общая длина пакета не должна превышать MTU (Maximum Transmission Unit) интерфейса. Пакеты с некорректными заголовками отбрасываются — счётчик виден в /proc/net/snmp (строка Ip: InHdrErrors).

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

Таблица маршрутизации (FIB) устроена как trie-дерево, оптимизированное для longest prefix match. Для типичного сервера с одним шлюзом по умолчанию поиск тривиален, но на маршрутизаторе с полной таблицей BGP (Border Gateway Protocol) (~950 000 префиксов на 2025 год) структура FIB и кеширование маршрутов критичны для производительности. Результат поиска кешируется в структуре dst_entry, привязанной к sk_buff, чтобы повторный поиск для пакетов того же потока обходился за O(1).

Фрагментация добавляет ещё один шаг. Если входящий пакет фрагментирован (флаг MF — More Fragments — установлен или fragment offset ненулевой), ядро собирает фрагменты в один sk_buff через механизм реассемблирования (ip_defrag()). Фрагменты хранятся во временной хеш-таблице по ключу (src IP, dst IP, protocol, identification). Если не все фрагменты пришли за 30 секунд (настройка net.ipv4.ipfrag_time), частичный пакет уничтожается.

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

Между IP-уровнем и транспортным протоколом пакет проходит через netfilter — подсистему ядра для фильтрации, модификации и маршрутизации пакетов. Netfilter не является отдельным модулем, работающим до или после IP, — он встроен в IP-обработку через callback-функции (hooks), вызываемые в определённых точках ip_rcv(), ip_forward() и ip_output(). Netfilter определяет пять точек перехвата, в которых правила могут перехватить пакет:

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

Пакет, пришедший извне и предназначенный локальному процессу, проходит цепочки PREROUTING и INPUT. Пакет, проходящий транзитом через машину (routing), проходит PREROUTING, FORWARD, POSTROUTING. Пакет, отправленный локальным процессом, — OUTPUT и POSTROUTING.

Каждая цепочка содержит упорядоченный список правил. Правило состоит из условий (match) и действия (target). Условия проверяют поля пакета: IP-адрес источника или назначения, порт, протокол, состояние соединения, интерфейс. Действие определяет судьбу пакета: ACCEPT — пропустить дальше, DROP — молча уничтожить, REJECT — уничтожить и отправить ICMP-ответ, DNAT/SNAT — подменить адрес назначения или источника для NAT (Network Address Translation), LOG — записать в лог и продолжить.

Для управления правилами существуют два фронтенда: исторический iptables и более новый nftables (команда nft). Оба транслируют правила в структуры netfilter внутри ядра, но nftables использует компактный байткод виртуальной машины вместо цепочки if-else, что сокращает потребление памяти и ускоряет обработку при большом числе правил. В iptables 10 000 правил проверяются линейно — каждый пакет проходит все 10 000 сравнений в худшем случае. nftables поддерживает sets и maps — хеш-таблицы и интервальные деревья, где поиск за O(1) или O(log n). На Kubernetes-нодах с тысячами Service-правил разница между линейным iptables и хеш-таблицами nftables/eBPF (extended Berkeley Packet Filter, Cilium) составляет порядок величины по задержке.

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

Netfilter умеет не только проверять отдельные пакеты, но и отслеживать соединения целиком. Модуль nf_conntrack (connection tracking) запоминает каждое соединение в хеш-таблице по ключу (протокол, src IP, src port, dst IP, dst port). Для TCP состояние отражает фазу соединения: NEW (первый SYN), ESTABLISHED (после ответа), RELATED (связанное соединение, например FTP data channel), INVALID (не вписывается ни в какое известное соединение).

Connection tracking позволяет писать stateful-правила: «пропускать пакеты, относящиеся к уже установленным соединениям» (-m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT). Без этого пришлось бы разрешать каждый порт явно в обе стороны. NAT полностью зависит от conntrack: при SNAT/MASQUERADE ядро запоминает отображение (внутренний IP:порт внешний IP:порт) в таблице conntrack, и по входящим ответам восстанавливает оригинальный адрес.

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

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

После netfilter пакет попадает в tcp_v4_rcv(). TCP-уровень должен найти, к какому сокету относится этот пакет. Ключ поиска — четвёрка (4-tuple): IP-адрес источника, порт источника, IP-адрес назначения, порт назначения. Ядро ищет совпадение в хеш-таблице inet_hashtable, которая индексирована по этой четвёрке. Поиск проходит в два этапа: сначала проверяется таблица ehash (established connections) — для активных соединений, затем lhash (listening) — для сокетов в состоянии LISTEN. На сервере с 100 000 соединений поиск в ehash занимает O(1) при хорошем распределении хешей. Если совпадение не найдено и на целевом порту нет слушающего сокета — отправителю уходит TCP RST.

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

TCP управляет потоком данных через окно приёма (receive window). Receive buffer сокета ограничен — по умолчанию от 87 380 до 6 291 456 байт (настройки net.ipv4.tcp_rmem = 4096 87380 6291456: минимум, значение по умолчанию, максимум). В заголовке каждого ACK-сегмента TCP сообщает отправителю текущий размер свободного пространства в окне. Когда receive buffer заполняется, окно сжимается до нуля, и отправитель останавливается (zero window). Если процесс-получатель медленно вычитывает данные, пакеты не теряются — они просто перестают приходить, потому что отправитель видит нулевое окно.

После помещения данных в receive buffer TCP отправляет ACK-сегмент. Для экономии ядро часто откладывает ACK на короткое время, надеясь совместить его с данными ответа — один сегмент вместо двух. Конкретная задержка зависит от внутренней логики TCP: quickack mode, числа принятых сегментов, наличия данных для отправки и таймеров конкретной версии ядра. Опция сокета TCP_QUICKACK не делает соединение навсегда “без delayed ACK”, а просит ядро временно перейти в режим более быстрых подтверждений. Если за время таймера процесс вызвал write() на том же сокете, ACK уходит вместе с данными (piggybacking — совмещение подтверждения с полезной нагрузкой). Если нет — отправляется чистый ACK без данных.

Receive buffer и пробуждение процесса

Данные лежат в receive buffer сокета — связном списке sk_buff внутри структуры struct sock. Receive buffer — не непрерывный массив байтов, а цепочка sk_buff, каждый из которых содержит данные одного или нескольких TCP-сегментов (с учётом GRO-склейки). Когда процесс вызывает read(), ядро обходит цепочку, копирует данные в пользовательский буфер и освобождает отработанные sk_buff.

Процесс, вызвавший read() или epoll_wait() на этом сокете, спит в очереди ожидания сокета (socket wait queue). Когда TCP помещает данные в receive buffer, он вызывает sk_data_ready() — callback, который будит процесс.

Если процесс использует epoll, механизм работает так: при регистрации fd через epoll_ctl() ядро установило callback в wait queue сокета. Когда sk_data_ready() срабатывает, callback перемещает этот fd в ready list структуры epoll. Процесс, спящий в epoll_wait(), просыпается и получает список готовых fd. Далее процесс вызывает read() или recv() — системный вызов копирует данные из receive buffer ядра в пользовательский буфер (copy_to_user()). Это единственное копирование на всём пути (не считая 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-стека.

Сетевые пространства имён

До сих пор речь шла об одной машине с одним набором интерфейсов и одной таблицей маршрутизации. Контейнеры меняют картину. Каждый контейнер (Docker, Podman) работает в своём сетевом пространстве имён (network namespace) — изолированном экземпляре сетевого стека. Пространство имён содержит собственный набор интерфейсов, собственную таблицу маршрутизации, собственные правила netfilter и собственную таблицу conntrack.

При создании контейнера среда выполнения создаёт пару виртуальных интерфейсов veth (virtual Ethernet): один конец (eth0) помещается в пространство имён контейнера, другой (vethXXXX) остаётся в хостовом пространстве и подключается к программному мосту (bridge), обычно docker0 или cni0. Мост работает как виртуальный L2-коммутатор — он пересылает Ethernet-кадры между подключёнными veth-интерфейсами и физическим интерфейсом хоста.

Пакет от контейнера наружу проходит расширенный путь:

Контейнер A          Хост                         Сеть
  namespace            namespace
+----------+        +----------------------------+
| процесс  |        |                            |
|    |      |        |                            |
|  eth0     |<-veth->| vethAAA                    |
+----------+        |    |                        |
                     |  docker0 (bridge)           |
+----------+        |    |                        |
| процесс  |        |  iptables NAT              |
|    |      |        |  (MASQUERADE)              |
|  eth0     |<-veth->| vethBBB    |               |
+----------+        |           eth0 --> физ. сеть |
Контейнер B          +----------------------------+

MASQUERADE в цепочке POSTROUTING заменяет IP-адрес источника (172.17.0.2) на IP хоста (например, 10.0.0.5), а conntrack запоминает отображение. Когда приходит ответ, conntrack по записи восстанавливает исходный IP контейнера и направляет пакет через bridge в нужный veth. Каждый переход через veth пару добавляет 1-3 мкс задержки по сравнению с прямым доступом к физическому интерфейсу.

Пространства имён создаются вызовом unshare(CLONE_NEWNET) или командой ip netns add <name>. Между ними можно перемещать интерфейсы через ip link set eth0 netns <name>. Просмотр всех пространств — ip netns list, выполнение команды внутри — ip netns exec <name> <cmd>. Kubernetes использует тот же механизм, но вместо docker0 bridge применяет CNI-плагины (CNI, Container Network Interface) (Calico, Cilium, Flannel), которые могут заменять bridge на маршрутизацию через BGP или eBPF-программы, убирая слой L2 и сокращая задержку.

Важное следствие: каждое сетевое пространство имён имеет собственный loopback-интерфейс lo. Процесс внутри контейнера, обращающийся к 127.0.0.1, попадает в свой lo, а не в хостовый. Это означает, что два контейнера не могут общаться через localhost, даже если работают на одной физической машине — только через bridge-сеть или хостовую сеть (--network=host в Docker).

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

До сих пор рассматривался входящий пакет. Исходящий проходит стек в обратном порядке. Процесс вызывает write() или send() на сокете. TCP формирует сегмент: копирует данные из пользовательского буфера в send buffer (struct tcp_sock -> snd_buf), добавляет TCP-заголовок с порядковым номером и контрольной суммой (если нет аппаратного offload). Далее ip_queue_xmit() добавляет IP-заголовок и определяет через таблицу маршрутизации, на какой интерфейс отправить пакет. Пакет проходит netfilter-цепочки OUTPUT и POSTROUTING — здесь SNAT может подменить IP-адрес источника. Наконец, драйвер помещает sk_buff в TX ring buffer NIC. Карта забирает данные через DMA и отправляет кадр в сеть. После получения аппаратного подтверждения отправки ядро освобождает sk_buff.

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

На уровне TX ring buffer работает механизм, симметричный приёму: драйвер помещает дескрипторы с адресами sk_buff в кольцо, NIC забирает данные через DMA. Аппаратный offload TSO (TCP Segmentation Offload) позволяет ядру передать NIC один большой сегмент до 64 КБ, а карта сама разбивает его на пакеты по MTU — зеркальное отражение GRO на приёме.

Наблюдаемость

Сетевой стек предоставляет метрики через файловую систему /proc и утилиты.

/proc/net/dev показывает счётчики по каждому интерфейсу: количество принятых и отправленных пакетов, байтов, ошибок и отброшенных пакетов. Рост rx_dropped указывает на переполнение ring buffer или исчерпание памяти для sk_buff. Рост rx_errors — на аппаратные проблемы (CRC-ошибки — Cyclic Redundancy Check, слишком короткие или длинные кадры).

/proc/net/tcp содержит строку для каждого TCP-сокета: локальный и удалённый адреса в hex, состояние соединения, размер очередей отправки и приёма. Утилита ss -tuln отображает эти данные в читаемом формате; ss -s показывает сводную статистику по всем TCP-сокетам (число соединений в каждом состоянии).

/proc/net/snmp и /proc/net/netstat содержат счётчики протоколов: Tcp: RetransSegs — число ретрансмиссий (рост означает потери пакетов), Ip: InHdrErrors — пакеты с некорректными IP-заголовками, TcpExt: ListenOverflows — SYN-запросы, отброшенные из-за переполнения backlog слушающего сокета.

Настройки стека сосредоточены в /proc/sys/net/. Ключевые параметры: core/netdev_budget (число пакетов за один цикл NAPI), ipv4/tcp_rmem и ipv4/tcp_wmem (размеры receive и send буферов TCP), ipv4/ip_forward (маршрутизация пакетов между интерфейсами), netfilter/nf_conntrack_max (максимум записей connection tracking). Все значения в /proc/sys/net/ изменяются через sysctl -w или запись в файл, но сбрасываются при перезагрузке. Для постоянного применения параметры прописываются в /etc/sysctl.d/*.conf.

Для более глубокой диагностики существуют два инструмента уровня ядра. tcpdump (или его графический аналог Wireshark) перехватывает пакеты на уровне sk_buff через механизм AF_PACKET — сокет, который получает копию каждого пакета на указанном интерфейсе. Перехват происходит до netfilter на входе и после netfilter на выходе, что позволяет видеть пакеты такими, какими их видит сеть. bpftrace и perf через точки трассировки (tracepoints) в сетевом стеке — net:net_dev_queue, net:netif_receive_skb, tcp:tcp_rcv_established — позволяют измерить задержку на каждом этапе обработки без модификации кода.

Типичная последовательность при диагностике: ethtool -S eth0 показывает аппаратные счётчики NIC (rx_missed, rx_crc_errors), /proc/net/softnet_stat — статистику softirq по каждому CPU (второй столбец — число отбросов из-за исчерпания бюджета NAPI), ss -tnp — TCP-соединения с привязкой к процессам, а conntrack -L — текущие записи connection tracking.

Sources


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