Перейти до вмісту

Мережа в ядрі Linux

Сервер на гарному залізі отримує потік UDP-датаграм, а програма бачить лише частину з них. У журналі порожньо, tcpdump показує, що всі пакети прийшли, мережа справна. Куди поділися решта? Або інша картина: tcpdump на відправнику показує пакети розміром 41 КБ у мережі з MTU 1500 і контрольні суми, які він називає «incorrect». Мережа, що так працює, здається зламаною, хоча все гаразд.

Щоб розібратися з обома, треба знати, що робить ядро з пакетом між кабелем і програмою. Уся ця машинерія прихована за викликами socket(), read() і write(), але в ній є кілька місць, де пакети чекають, склеюються, розрізаються й гублять, і в кожного з цих місць свій лічильник.

Передумови. Переривання, DMA й пам’ять (модуль 2 курсу «Операційні системи»), межа користувача й ядра та системні виклики (модуль 3), дескриптори й ввід-вивід (модуль 13). Тут ми їх не повторюємо, лише застосовуємо до мережі. З цього курсу потрібен модуль 1.

Програма починає з socket(), і повертається невелике ціле число, файловий дескриптор. Для програми сокет нічим не відрізняється від відкритого файлу: його можна передати в read(), write(), close(), poll() чи epoll (про цей механізм — модуль 13 курсу ОС). У таблиці дескрипторів процесу він лежить поруч з файлами:

Terminal window
ls -l /proc/self/fd

Для сокета замість шляху буде socket:[47867], де число — номер inode в спеціальній файловій системі сокетів. За цим дескриптором у ядрі стоять дві структури. Із загальною частиною, struct socket, працюють системні виклики. У протокольній, struct sock, живуть стан TCP, номери портів і, головне, дві черги буферів: чергу приймання і чергу відправлення. Їхні розміри обмежені (SO_RCVBUF і SO_SNDBUF, значення за замовчуванням видно в net.core.rmem_default). Коли черга приймання повна, нові пакети для цього сокета відкидають.

По суті, програма лише кладе байти в чергу відправлення й забирає їх з черги приймання, а все інше робить ядро.

Пакет у ядрі — це структура struct sk_buff (часто кажуть «skb»). Вона складається з метаданих (до якого сокета й інтерфейсу належить, чи обчислена контрольна сума, скільки сегментів) і вказівників на буфер із самими байтами.

Головна ідея skb — запас місця перед даними, headroom. Пакет, що відправляється, починає життя як самі дані програми в буфері, де спереду лишено порожнє місце. Кожен рівень, що опускається вниз, дописує свій заголовок, зсуваючи вказівник data ліворуч (функція skb_push). Байти навантаження ніхто не переписує. При прийманні навпаки: кожен рівень читає заголовок і зсуває data праворуч (skb_pull). Інкапсуляція з модуля 1 у ядрі виглядає як арифметика вказівників.

Буфер sk_buff у трьох станах: щойно створений, після додавання TCP, після IP і Ethernetпісля копіювання даних застосункуheadroom: вільне місце спередуданіtailroomTCP дописав заголовокheadroom: вільне місце спередуTCPданіIP і Ethernet дописали своїEthIPTCPданіheaddatatailendкожен рівень зсуває data ліворуч (skb_push) на розмір свого заголовка;при прийомі навпаки (skb_pull). Самі байти не переписуються.
Один буфер, три стани. Пунктиром позначено вільне місце, суцільними блоками заголовки й дані. Вказівники head і end обмежують виділену пам'ять, data і tail — зайняту частину.

skb можна клонувати, тобто мати кілька структур метаданих над одним буфером: так ядро тримає копію для повторної передачі TCP і водночас віддає пакет tcpdump. Крім того, дані необов’язково лежать одним шматком: великий skb може складатися з основного буфера і списку сторінок (scatter-gather), що дозволяє карті збирати пакет з частин без копіювання.

Рух від кабелю до read() складається зі стадій, кожна з яких має власне місце для накопичення й власні причини втрат.

Шлях вхідного пакета: кільце DMA, переривання, NAPI, стек, черга сокета, read()залізоядро: переривання й softirqядро: контекст процесукадр з кабелюDMA в буфер кільця RXdescriptor ringпереривання (одне на пачку)MSI-XNAPI: poll() драйверадо budget пакетів за разsk_buff + GROсклеювання сегментівIP: PREROUTING, маршрут, INPUTnetfilter — модуль 16TCP: пошук сокетау чергу прийманняread(): копія в буфер програмипроцес прокидаєтьсякільце повне: втрата в картіbudget вичерпано: time_squeezeсокет тримає чергу sk_buff; якщо вона переповнена, пакет відкидають у цьому місці
Вхідний пакет у трьох зонах: залізо, ядро в контексті переривання й softirq, ядро в контексті процесу. Червоним позначено два місця втрат до стеку.
  1. Кадр приходить у карту й ложиться в кільце DMA. Під час ініціалізації драйвер виділяє пам’ять і заповнює кільце дескрипторів приймання (RX ring): масив записів «сюди можна покласти кадр». Карта бере вільний дескриптор і через DMA пише кадр у пам’ять, не турбуючи процесор. Якщо вільних дескрипторів немає, кадр губиться в самій карті, а в лічильниках це видно як rx_missed або fifo. Розмір кільця переглядають ethtool -g і змінюють ethtool -G.

  2. Карта піднімає переривання. Але на швидкому потоці переривання на кожен пакет ядро б просто не витримало: увесь час процесора пішов би на вхід в обробники й вихід із них. Тому обробник переривання робить лише мінімум: вимикає переривання цієї черги карти й ставить у план виконання завдання NAPI.

  3. NAPI й softirq. У контексті програмного переривання NET_RX_SOFTIRQ ядро викликає функцію poll() драйвера. Та забирає пакети з кільця, поки не закінчаться або не вичерпається бюджет: net.core.netdev_budget (300 пакетів за замовчуванням) і net.core.netdev_budget_usecs (обмеження часу). Якщо кільце спорожніло, переривання вмикають знову. Якщо ні, ядро знову викличе poll(), не чекаючи переривання. Тож під навантаженням мережа працює в режимі опитування, а в спокої — за перериваннями: для кожної ситуації вибирають найдешевше. Якщо бюджет вичерпано, а пакети ще є, зростає лічильник time_squeeze.

  4. Драйвер збирає sk_buff із запису кільця й передає його в стек функцією на кшталт napi_gro_receive. Тут спрацьовує GRO (про нього нижче).

  5. Мережевий стек. Ядро дивиться на EtherType і передає пакет обробнику IP. Проходять хуки netfilter PREROUTING (докладно в модулі 16), потім рішення про маршрут: чи це пакет нам, чи треба переслати (модуль 7). Для локального пакета — хук INPUT, далі TCP чи UDP.

  6. Пошук сокета. За четвіркою (адреса й порт відправника, адреса й порт отримувача) протокол знаходить сокет, і skb потрапляє в його чергу приймання. Процес, який спав у read() чи в epoll_wait(), стає готовим. Якщо черга приймання заповнена, пакет відкидають, а лічильник зростає (для UDP це RcvbufErrors).

  7. read(). Планувальник повертає процес на процесор, і read() копіює байти з skb у буфер програми. Це єдине копіювання даних у вхідному напрямку. TCP також звільняє місце у вікні й за потреби надсилає підтвердження.

Відправлення влаштоване схоже, лише навпаки, і без переривань на старті.

  1. write() або send() потрапляє до TCP, який копіює дані програми в новий skb і ставить його в чергу відправлення сокета (єдина копія у вихідному напрямку).
  2. TCP вирішує, скільки можна відправити просто зараз (вікно, модуль 11).
  3. IP обирає маршрут, дописує заголовок, проходять хуки OUTPUT та POSTROUTING.
  4. Ядро знаходить MAC-адресу наступного вузла за таблицею сусідів (модуль 5) і дописує заголовок Ethernet.
  5. Пакет стає в чергу мережевого інтерфейсу (qdisc, модуль 12), а звідти до драйвера, який кладе його в кільце TX.
  6. Карта бере дані з пам’яті через DMA, передає їх у лінію й повідомляє про завершення перериванням. Тоді драйвер звільняє skb.

На цьому шляху дві черги, буфер сокета і qdisc, і кожна може наповнитися. Наслідки різні: для сокета write() блокується (або EAGAIN), для qdisc пакети відкидаються.

Offload-и: коли пакет більший за MTU

Section titled “Offload-и: коли пакет більший за MTU”

Накладні витрати ядра на пакет приблизно однакові незалежно від його розміру, тож дешевше обробити один пакет на 64 КБ, ніж сорок пакетів по 1500 Б. На цій арифметиці тримаються всі механізми нижче.

  • TSO (TCP segmentation offload). Стек віддає карті один великий сегмент (до 64 КБ), а карта сама розрізає його на кадри за MSS. Якщо карта цього не вміє, те саме робить GSO (generic segmentation offload) у ядрі, в останню мить перед драйвером: стеку від цього все одно легше, бо весь шлях до цієї точки пакет один.
  • GRO (generic receive offload). Обернена операція під час приймання: ядро збирає послідовні сегменти одного потоку в один великий skb, перш ніж передавати його стеку. Стек TCP обробляє один пакет замість десятка.
  • Контрольні суми в залізі. Карта сама обчислює контрольну суму TCP і IP під час відправлення й перевіряє під час приймання. Ядро лишає в полі лише заготовку.
  • LRO (large receive offload) робить те саме апаратно, але втрачає межі початкових сегментів, тому ламає маршрутизацію й мости; на серверах, що пересилають пакети, його вимикають.

Наслідок для діагностики: tcpdump перехоплює пакет до того, як карта розріже його і порахує суми, а під час приймання — після склеювання. Тому на машині з ввімкненими offload-ами ви побачите пакети більші за MTU й «некоректні» суми. Це нормально, і на дроті вже все правильно. Якщо потрібно побачити те, що справді пішло кабелем, знімайте трафік на іншій машині (або вимкніть offload через ethtool -K).

Де тут netfilter і що раніше

Section titled “Де тут netfilter і що раніше”

Хуки netfilter на шляху пакета: PREROUTING → маршрут → INPUT (локально) або FORWARD → POSTROUTING (для транзитного), OUTPUT → POSTROUTING (для вихідного). Правила nftables чіпляються до цих хуків. Докладно — у модулі 16. Ще раніше, до створення skb, у драйвері може спрацювати XDP: програма eBPF вирішує долю кадру, не витрачаючи на нього пам’яті під skb. Про це модуль 18.

Далі ми працюємо в трьох просторах імен: клієнт h1, маршрутизатор r і сервер h2 (цю топологію ви збираєте в B2). Усе запускайте від root. Просторів імен курс ОС (модуль 17) навчав окремо; тут вони лише інструмент.

Terminal window
for n in h1 r h2; do ip netns add $n; done
ip link add v1 type veth peer name r1
ip link add v2 type veth peer name r2
ip link set v1 netns h1; ip link set r1 netns r
ip link set r2 netns r; ip link set v2 netns h2
ip -n h1 addr add 10.0.1.2/24 dev v1; ip -n r addr add 10.0.1.1/24 dev r1
ip -n r addr add 10.0.2.1/24 dev r2; ip -n h2 addr add 10.0.2.2/24 dev v2
for n in h1 r h2; do ip -n $n link set lo up; done
ip -n h1 link set v1 up; ip -n r link set r1 up
ip -n r link set r2 up; ip -n h2 link set v2 up
ip netns exec r sysctl -qw net.ipv4.ip_forward=1
ip -n h1 route add default via 10.0.1.1
ip -n h2 route add default via 10.0.2.1
ip netns exec h2 python3 -m http.server 80 &

Перевірка: ip netns exec h1 ping -c1 10.0.2.2 має пройти. Прибирання наприкінці: ip netns del h1; ip netns del r; ip netns del h2 (пари veth зникають разом з просторами імен).

Сокет у системних викликах

Section titled “Сокет у системних викликах”
Terminal window
ip netns exec h1 strace -e trace=socket,connect,sendto,recvfrom,close python3 cli.py

Де cli.py відкриває з’єднання з 10.0.2.2:80, надсилає запит і читає відповідь:

import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("10.0.2.2", 80))
s.sendall(b"GET / HTTP/1.0\r\n\r\n")
print(len(s.recv(4096)))
s.close()
socket(AF_INET, SOCK_STREAM|SOCK_CLOEXEC, IPPROTO_IP) = 3
connect(3, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr("10.0.2.2")}, 16) = 0
sendto(3, "GET / HTTP/1.0\r\n\r\n", 18, 0, NULL, 0) = 18
recvfrom(3, "HTTP/1.0 200 OK\r\nServer: SimpleH"..., 4096, 0, NULL, NULL) = 157
close(3) = 0

Весь мережевий обмін — п’ять викликів, а за connect ховається ціле рукостискання з модуля 1. Дескриптор 3 — це і є сокет.

Terminal window
ip netns exec h2 ss -tlnp
ip netns exec h1 ss -tni
# скорочено; у стенді на h2 тоді працювали ще тестовий сервер на 9000 і iperf3
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0 5 0.0.0.0:9000 0.0.0.0:* users:(("python3",pid=11077,fd=3))
LISTEN 0 4096 0.0.0.0:5201 0.0.0.0:* users:(("iperf3",pid=9824,fd=3))
ESTAB 0 0 10.0.1.2:43742 10.0.2.2:9000
bbr wscale:10,10 rto:204 rtt:0.101/0.058 mss:1448 pmtu:1500 cwnd:11 ...

Стовпці Recv-Q і Send-Q означають різне для різних станів. Для ESTAB: Recv-Q — байти, які вже в черзі приймання, але програма ще не прочитала; Send-Q — байти, які ще не підтверджено. Для LISTEN: Recv-Q — довжина черги прийнятих з’єднань, яких accept() ще не забрав, Send-Q — її межа (backlog). Тож ненульовий Recv-Q на сокеті, що живе довго, означає: програма не встигає читати, а не мережа повільна. Опція -i показує внутрішній стан TCP: rtt, cwnd, mss, алгоритм керування перевантаженням.

Лічильники втрат: де саме зникли пакети

Section titled “Лічильники втрат: де саме зникли пакети”

Відтворімо втрату. Сервер відкриває UDP-сокет і не читає з нього, а клієнт надсилає 2000 датаграм по 1000 байтів:

# udpsink.py (в h2): сокет відкрито, читання немає
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind(("0.0.0.0", 7000)); time.sleep(6)
# udpsrc.py (в h1)
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
for i in range(2000):
s.sendto(b"x" * 1000, ("10.0.2.2", 7000))
Terminal window
ip netns exec h2 nstat -n # скинути лічильники
ip netns exec h1 python3 udpsrc.py # 2000 × sendto()
ip netns exec h2 nstat
ip netns exec h2 ss -uan
IpInReceives 2000 0.0
UdpRcvbufErrors 1908 0.0
State Recv-Q Send-Q Local Address:Port Peer Address:Port
UNCONN 211968 0 0.0.0.0:7000 0.0.0.0:*

Мережа й стек доставили всі 2000 пакетів (IpInReceives), але в буфер сокета поміщається трохи більше за 200 КБ (net.core.rmem_default = 212992), а це лише 92 датаграми, бо ядро рахує ще й службові накладні витрати на skb. Решта 1908 відкинуто на останній стадії, і про це повідомляє UdpRcvbufErrors. Recv-Q у ss показує майже повний буфер. tcpdump у цьому випадку покаже усі 2000 пакетів, і саме така картина збиває з пантелику: на дроті все добре, гублять у сокеті.

Де дивитися, залежно від стадії, на якій губляться пакети:

Стадія Лічильник
Кільце RX у карті ethtool -S eth0 (rx_missed, rx_fifo_errors), ip -s link (missed, dropped)
Бюджет NAPI третій стовпець /proc/net/softnet_stat (time_squeeze)
Черга backlog ядра (драйвери без NAPI, RPS) другий стовпець /proc/net/softnet_stat
Буфер сокета UDP nstat: UdpRcvbufErrors
Черга accept() у TCP nstat: TcpExtListenOverflows, TcpExtListenDrops
Правила файрвола лічильники правил nftables (модуль 16)
Terminal window
ip -s link show eth0
4: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1400 qdisc pfifo_fast state UP
RX: bytes packets errors dropped missed mcast
38011962 20781 0 6 0 0
TX: bytes packets errors dropped carrier collsns
21229574 20439 0 0 0 0

Лічильники кумулятивні від старту інтерфейсу, тому порівнюйте два заміри або використовуйте watch -d. Так само з nstat: перший запуск показує значення від завантаження, а наступні — лише зміну після попереднього.

Terminal window
ethtool -k eth0 | grep -E '^(rx-check|tx-check|scatter|tcp-seg|generic)'
rx-checksumming: on [fixed]
tx-checksumming: on
scatter-gather: on
tcp-segmentation-offload: on
generic-segmentation-offload: on
generic-receive-offload: on

Позначка [fixed] означає, що цей параметр драйвер зафіксував і змінити його через ethtool -K не вийде. Наслідок видно, якщо зняти трафік на відправнику під час iperf3 і відфільтрувати сегменти понад 4000 байтів:

Terminal window
ip netns exec h1 tcpdump -i v1 -n -c 4 'tcp and src 10.0.1.2 and greater 4000'
IP 10.0.1.2.55836 > 10.0.2.2.5201: Flags [P.], seq 7240:14480, ack 1, ..., length 7240
IP 10.0.1.2.55836 > 10.0.2.2.5201: Flags [P.], seq 15928:31856, ack 1, ..., length 15928
IP 10.0.1.2.55836 > 10.0.2.2.5201: Flags [P.], seq 31856:55024, ack 1, ..., length 23168
IP 10.0.1.2.55836 > 10.0.2.2.5201: Flags [P.], seq 55024:89776, ack 1, ..., length 34752

Інтерфейс v1 має MTU 1500, але довжини «пакетів» 7240, 15928, 23168 і 34752 байти: знімок зроблено до GSO. Між просторами імен такі пакети йдуть цілими, бо veth уміє їх не розрізати; на фізичному інтерфейсі карта або GSO нарізали б їх на кадри за MSS.

Про контрольні суми: режим -v для звичайного запиту показує

10.0.1.2.55620 > 10.0.2.2.80: Flags [S], cksum 0x1732 (incorrect -> 0x4461), ...

Контрольну суму ще не порахували, бо це відкладено для карти, тож помилки тут немає.

Переривання, softirq і поточні лічильники

Section titled “Переривання, softirq і поточні лічильники”
Terminal window
grep -E 'NET_RX|NET_TX' /proc/softirqs
NET_TX: 16555 14198 19532 43978
NET_RX: 75477 62616 48341 72041

Стовпці — ядра процесора. Якщо весь NET_RX на одному ядрі, а інші порожні, то карта має одну чергу або всі потоки потрапляють в одну: саме це ядро стане вузьким місцем. Розподіл пакетів між чергами (RSS) і ядрами налаштовують ethtool -l, ethtool -x і привʼязкою переривань (/proc/irq/*/smp_affinity).

Terminal window
cat /proc/net/softnet_stat

Кожен рядок — ядро, значення шістнадцяткові. Перший стовпець — скільки пакетів оброблено, другий — скільки відкинуто через переповнення черги ядра, третій — time_squeeze. Ненульові другий і третій стовпці під навантаженням означають, що ядро не встигає й бюджет NAPI замалий (net.core.netdev_budget).

Terminal window
cat /proc/net/dev | head -4
nstat -az | grep -E 'TcpActiveOpens|TcpInSegs|TcpOutSegs|TcpRetransSegs|IpInReceives'

У /proc/net/dev лежать сирі лічильники за інтерфейсами (з них складає свій вивід ip -s link). nstat друкує лічильники SNMP-стилю за протоколами: TcpRetransSegs корисний для оцінки здоров’я з’єднань, а nstat -az додає й нульові.

Типові помилки розуміння

Section titled “Типові помилки розуміння”

«Якщо tcpdump бачить пакет, програма його отримала». tcpdump перехоплює пакет на вході в стек, до файрвола й до черги сокета. Пакет, який потім відкинули (nftables, переповнений буфер, ListenOverflows), у знімку є, а в програмі нема. Саме так працює наш UDP-експеримент вище.

«Пакет обробляється процесом, який читає сокет». Більшу частину шляху (драйвер, IP, TCP) виконує ядро в контексті softirq, без участі програми. Якщо програма спить, TCP усе одно приймає дані, підтверджує їх і складає в буфер, доки той не заповниться.

«Кожен пакет викликає переривання». На повільному потоці так, а на швидкому ядро переходить у режим опитування (NAPI) і обробляє пачки. Якщо інтервал між пакетами менший за вартість переривання, окремі переривання були б самогубством.

«Кадр у tcpdump дорівнює кадру на дроті». Поки offload-и ввімкнені, це не так: на відправнику пакет ще не розрізано, суми не пораховані, на отримувачі сегменти можуть бути склеєні GRO. Ні розмір понад MTU у знімку, ні «incorrect cksum» на вихідних пакетах проблеми не означають.

«Більший буфер сокета лікує втрату». Більший буфер лише відтягує момент переповнення. Якщо програма читає повільніше, ніж надходить, будь-який буфер рано чи пізно заповниться, тож причина в програмі, а не в розмірі буфера.

Перевір себе

1. Сервер втрачає UDP-датаграми. tcpdump показує, що всі пакети приходять на інтерфейс, а nstat — зростання UdpRcvbufErrors. Де відкидають пакети?
2. Навіщо NAPI перемикається між перериваннями й опитуванням?
3. На відправнику tcpdump показує пакет TCP довжиною 34 КБ, а MTU інтерфейсу 1500. Що це означає?
4. tcpdump на вихідних пакетах пише cksum ... (incorrect -> ...). Чому мережа при цьому працює?
5. У ss -tn для сокета в стані ESTAB стовпець Recv-Q стабільно великий. Що це означає?
6. Де в ядрі відбувається єдине копіювання даних під час read() з TCP-сокета?

На матеріал цього модуля спирається A1: ви напишете TCP- і UDP-ехо, побачите частково прочитані повідомлення й переповнення буфера на власному прикладі. Вміння зчитувати лічильники знадобиться й у B6, коли буде потрібно пояснити, де саме губляться пакети під навантаженням.

  • Документація ядра: Documentation/networking/ (зокрема napi.rst, scaling.rst, segmentation-offloads.rst)
  • Rosen — Linux Kernel Networking: Implementation and Theory
  • Stevens, Fenner, Rudoff — UNIX Network Programming, Vol. 1
  • man 7 socket, man 7 tcp, man 7 udp, man 8 ss, man 8 ethtool, man 8 nstat, man 5 proc
  • Курс «Операційні системи»: модуль 2, модуль 3, модуль 13