Мережа в ядрі Linux
Навіщо це
Section titled “Навіщо це”Сервер на гарному залізі отримує потік UDP-датаграм, а програма бачить лише
частину з них. У журналі порожньо, tcpdump показує, що всі пакети прийшли, мережа
справна. Куди поділися решта? Або інша картина: tcpdump на відправнику показує
пакети розміром 41 КБ у мережі з MTU 1500 і контрольні суми, які він називає
«incorrect». Мережа, що так працює, здається зламаною, хоча все гаразд.
Щоб розібратися з обома, треба знати, що робить ядро з пакетом між
кабелем і програмою. Уся ця машинерія прихована за викликами socket(),
read() і write(), але в ній є кілька місць, де пакети чекають, склеюються,
розрізаються й гублять, і в кожного з цих місць свій лічильник.
Передумови. Переривання, DMA й пам’ять (модуль 2 курсу «Операційні системи»), межа користувача й ядра та системні виклики (модуль 3), дескриптори й ввід-вивід (модуль 13). Тут ми їх не повторюємо, лише застосовуємо до мережі. З цього курсу потрібен модуль 1.
Сокет як дескриптор
Section titled “Сокет як дескриптор”Програма починає з socket(), і повертається невелике ціле число, файловий
дескриптор. Для програми сокет нічим не відрізняється від відкритого файлу:
його можна передати в read(), write(), close(), poll() чи epoll
(про цей механізм — модуль 13 курсу ОС).
У таблиці дескрипторів процесу він лежить поруч з файлами:
ls -l /proc/self/fdДля сокета замість шляху буде socket:[47867], де число — номер inode
в спеціальній файловій системі сокетів. За цим дескриптором у ядрі стоять
дві структури. Із загальною частиною, struct socket, працюють системні виклики.
У протокольній, struct sock, живуть стан TCP, номери портів
і, головне, дві черги буферів: чергу приймання і чергу відправлення. Їхні
розміри обмежені (SO_RCVBUF і SO_SNDBUF, значення за замовчуванням
видно в net.core.rmem_default). Коли черга приймання повна, нові пакети
для цього сокета відкидають.
По суті, програма лише кладе байти в чергу відправлення й забирає їх з черги приймання, а все інше робить ядро.
sk_buff: один пакет у ядрі
Section titled “sk_buff: один пакет у ядрі”Пакет у ядрі — це структура struct sk_buff (часто кажуть «skb»). Вона складається
з метаданих (до якого сокета й інтерфейсу належить, чи обчислена контрольна сума,
скільки сегментів) і вказівників на буфер із самими байтами.
Головна ідея skb — запас місця перед даними, headroom. Пакет, що відправляється,
починає життя як самі дані програми в буфері, де спереду лишено порожнє місце.
Кожен рівень, що опускається вниз, дописує свій заголовок, зсуваючи вказівник
data ліворуч (функція skb_push). Байти навантаження ніхто не переписує.
При прийманні навпаки: кожен рівень читає заголовок і зсуває data праворуч
(skb_pull). Інкапсуляція з модуля 1 у ядрі
виглядає як арифметика вказівників.
skb можна клонувати, тобто мати кілька структур
метаданих над одним буфером: так ядро тримає копію для повторної передачі
TCP і водночас віддає пакет tcpdump. Крім того, дані необов’язково лежать одним
шматком: великий skb може складатися з основного буфера і списку сторінок
(scatter-gather), що дозволяє карті збирати пакет з частин без копіювання.
Шлях пакета вгору
Section titled “Шлях пакета вгору”Рух від кабелю до read() складається зі стадій, кожна з яких має власне
місце для накопичення й власні причини втрат.
-
Кадр приходить у карту й ложиться в кільце DMA. Під час ініціалізації драйвер виділяє пам’ять і заповнює кільце дескрипторів приймання (RX ring): масив записів «сюди можна покласти кадр». Карта бере вільний дескриптор і через DMA пише кадр у пам’ять, не турбуючи процесор. Якщо вільних дескрипторів немає, кадр губиться в самій карті, а в лічильниках це видно як
rx_missedабоfifo. Розмір кільця переглядаютьethtool -gі змінюютьethtool -G. -
Карта піднімає переривання. Але на швидкому потоці переривання на кожен пакет ядро б просто не витримало: увесь час процесора пішов би на вхід в обробники й вихід із них. Тому обробник переривання робить лише мінімум: вимикає переривання цієї черги карти й ставить у план виконання завдання NAPI.
-
NAPI й softirq. У контексті програмного переривання
NET_RX_SOFTIRQядро викликає функціюpoll()драйвера. Та забирає пакети з кільця, поки не закінчаться або не вичерпається бюджет:net.core.netdev_budget(300 пакетів за замовчуванням) іnet.core.netdev_budget_usecs(обмеження часу). Якщо кільце спорожніло, переривання вмикають знову. Якщо ні, ядро знову викличеpoll(), не чекаючи переривання. Тож під навантаженням мережа працює в режимі опитування, а в спокої — за перериваннями: для кожної ситуації вибирають найдешевше. Якщо бюджет вичерпано, а пакети ще є, зростає лічильникtime_squeeze. -
Драйвер збирає
sk_buffіз запису кільця й передає його в стек функцією на кшталтnapi_gro_receive. Тут спрацьовує GRO (про нього нижче). -
Мережевий стек. Ядро дивиться на
EtherTypeі передає пакет обробнику IP. Проходять хуки netfilterPREROUTING(докладно в модулі 16), потім рішення про маршрут: чи це пакет нам, чи треба переслати (модуль 7). Для локального пакета — хукINPUT, далі TCP чи UDP. -
Пошук сокета. За четвіркою (адреса й порт відправника, адреса й порт отримувача) протокол знаходить сокет, і skb потрапляє в його чергу приймання. Процес, який спав у
read()чи вepoll_wait(), стає готовим. Якщо черга приймання заповнена, пакет відкидають, а лічильник зростає (для UDP цеRcvbufErrors). -
read(). Планувальник повертає процес на процесор, іread()копіює байти з skb у буфер програми. Це єдине копіювання даних у вхідному напрямку. TCP також звільняє місце у вікні й за потреби надсилає підтвердження.
Шлях вниз
Section titled “Шлях вниз”Відправлення влаштоване схоже, лише навпаки, і без переривань на старті.
write()абоsend()потрапляє до TCP, який копіює дані програми в новий skb і ставить його в чергу відправлення сокета (єдина копія у вихідному напрямку).- TCP вирішує, скільки можна відправити просто зараз (вікно, модуль 11).
- IP обирає маршрут, дописує заголовок, проходять хуки
OUTPUTтаPOSTROUTING. - Ядро знаходить MAC-адресу наступного вузла за таблицею сусідів (модуль 5) і дописує заголовок Ethernet.
- Пакет стає в чергу мережевого інтерфейсу (qdisc, модуль 12), а звідти до драйвера, який кладе його в кільце TX.
- Карта бере дані з пам’яті через 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.
Мережа з нуля
Section titled “Мережа з нуля”Далі ми працюємо в трьох просторах імен: клієнт h1, маршрутизатор r і сервер
h2 (цю топологію ви збираєте в B2). Усе
запускайте від root. Просторів імен курс ОС (модуль 17)
навчав окремо; тут вони лише інструмент.
for n in h1 r h2; do ip netns add $n; doneip link add v1 type veth peer name r1ip link add v2 type veth peer name r2ip link set v1 netns h1; ip link set r1 netns rip link set r2 netns r; ip link set v2 netns h2ip -n h1 addr add 10.0.1.2/24 dev v1; ip -n r addr add 10.0.1.1/24 dev r1ip -n r addr add 10.0.2.1/24 dev r2; ip -n h2 addr add 10.0.2.2/24 dev v2for n in h1 r h2; do ip -n $n link set lo up; doneip -n h1 link set v1 up; ip -n r link set r1 upip -n r link set r2 up; ip -n h2 link set v2 upip netns exec r sysctl -qw net.ipv4.ip_forward=1ip -n h1 route add default via 10.0.1.1ip -n h2 route add default via 10.0.2.1ip 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 зникають разом
з просторами імен).
Як це насправді в Linux
Section titled “Як це насправді в Linux”Сокет у системних викликах
Section titled “Сокет у системних викликах”ip netns exec h1 strace -e trace=socket,connect,sendto,recvfrom,close python3 cli.pyДе cli.py відкриває з’єднання з 10.0.2.2:80, надсилає запит і читає
відповідь:
import sockets = 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) = 3connect(3, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr("10.0.2.2")}, 16) = 0sendto(3, "GET / HTTP/1.0\r\n\r\n", 18, 0, NULL, 0) = 18recvfrom(3, "HTTP/1.0 200 OK\r\nServer: SimpleH"..., 4096, 0, NULL, NULL) = 157close(3) = 0Весь мережевий обмін — п’ять викликів, а за connect ховається ціле
рукостискання з модуля 1. Дескриптор 3 —
це і є сокет.
Стан сокетів: ss
Section titled “Стан сокетів: ss”ip netns exec h2 ss -tlnpip netns exec h1 ss -tni# скорочено; у стенді на h2 тоді працювали ще тестовий сервер на 9000 і iperf3State Recv-Q Send-Q Local Address:Port Peer Address:Port ProcessLISTEN 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))ip netns exec h2 nstat -n # скинути лічильникиip netns exec h1 python3 udpsrc.py # 2000 × sendto()ip netns exec h2 nstatip netns exec h2 ss -uanIpInReceives 2000 0.0UdpRcvbufErrors 1908 0.0
State Recv-Q Send-Q Local Address:Port Peer Address:PortUNCONN 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) |
ip -s link show eth04: 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: перший запуск показує значення
від завантаження, а наступні — лише зміну після попереднього.
Offload-и: ethtool -k
Section titled “Offload-и: ethtool -k”ethtool -k eth0 | grep -E '^(rx-check|tx-check|scatter|tcp-seg|generic)'rx-checksumming: on [fixed]tx-checksumming: onscatter-gather: ontcp-segmentation-offload: ongeneric-segmentation-offload: ongeneric-receive-offload: onПозначка [fixed] означає, що цей параметр драйвер зафіксував і змінити його через ethtool -K не вийде. Наслідок
видно, якщо зняти трафік на відправнику під час iperf3 і відфільтрувати сегменти
понад 4000 байтів:
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 7240IP 10.0.1.2.55836 > 10.0.2.2.5201: Flags [P.], seq 15928:31856, ack 1, ..., length 15928IP 10.0.1.2.55836 > 10.0.2.2.5201: Flags [P.], seq 31856:55024, ack 1, ..., length 23168IP 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 і поточні лічильники”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).
cat /proc/net/softnet_statКожен рядок — ядро, значення шістнадцяткові. Перший стовпець — скільки пакетів
оброблено, другий — скільки відкинуто через переповнення черги ядра, третій —
time_squeeze. Ненульові другий і третій стовпці під навантаженням означають,
що ядро не встигає й бюджет NAPI замалий (net.core.netdev_budget).
cat /proc/net/dev | head -4nstat -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» на вихідних пакетах проблеми не означають.
«Більший буфер сокета лікує втрату». Більший буфер лише відтягує момент переповнення. Якщо програма читає повільніше, ніж надходить, будь-який буфер рано чи пізно заповниться, тож причина в програмі, а не в розмірі буфера.
Перевір себе
Лабораторна
Section titled “Лабораторна”На матеріал цього модуля спирається A1: ви напишете TCP- і UDP-ехо, побачите частково прочитані повідомлення й переповнення буфера на власному прикладі. Вміння зчитувати лічильники знадобиться й у B6, коли буде потрібно пояснити, де саме губляться пакети під навантаженням.
Джерела
Section titled “Джерела”- Документація ядра:
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