Гигабитный канал до соседнего дата-центра, а скорость передачи не поднимается выше тридцати мегабит в секунду. Процессор свободен, ошибок на интерфейсе нет, зато скорость стоит. Виноват обычно третий подозреваемый, невидимый и скромный: два числа в /proc/sys, которыми ядро обрезает TCP-окно задолго до того, как канал раскроется.
Дальше по порядку: устройство буферов в ядре Linux, расчёт размера по произведению пропускной способности и задержки, команды sysctl и setsockopt, проверка результата и настройка очередей сетевой карты.
Буферы сокетов в ядре Linux и их потолки по умолчанию
У каждого сокета есть два буфера, приёмный и передающий. Память под них ядро выделяет лениво, по мере прихода пакетов, поэтому максимум не расходуется сразу. Общесистемные границы задают четыре параметра каталога /proc/sys/net/core/: rmem_default определяет стартовый размер приёмного буфера, rmem_max ограничивает запросы SO_RCVBUF от приложений, wmem_default и wmem_max повторяют пару для передающего буфера.
Долгие годы дистрибутивы ставили все четыре значения по 212 992 байта, 208 КБ. В свежих ядрах потолки rmem_max и wmem_max уже подняты до 4 194 304 байт, то есть 4 МБ, а стартовые размеры остались 212 992. Проверка занимает секунды:
sysctl net.core.rmem_max net.core.wmem_max
sysctl net.core.rmem_default net.core.wmem_default
Для TCP этого мало. Протокол управляет размерами через тройки параметров tcp_rmem и tcp_wmem в каталоге /proc/sys/net/ipv4/. Формат одинаковый, три числа: минимум, среднее значение и максимум. Минимум равен размеру страницы, 4096 байт, и защищает соединения при нехватке памяти. Среднее определяет стартовый буфер нового сокета, исторически 87380 байт на приём и 16 384 на передачу. Максимум ядро вычисляет при загрузке из объёма памяти системы, для передачи по формуле max(65536, min(4 MB, tcp_mem[1]*PAGE_SIZE/128)). На реальной машине с ядром 7.0 и 3,7 ГБ ОЗУ sysctl вернул tcp_rmem = 4096 131072 30020352 и tcp_wmem = 4096 16384 4194304. Потолок приёма здесь около 28,6 МБ: формула в свежих ядрах щедрее.
Отдельно живёт tcp_mem, тройка low, pressure, high в страницах памяти, пределы всего TCP-стека. На той же машине значения 43974, 58633 и 87948 страниц, то есть примерно 180, 240 и 360 МБ. За high ядро начинает отказывать в выделении памяти. Пересчитывает tcp_mem само при загрузке, трогать его не нужно.
Теперь самая коварная деталь. Ядро удваивает значение, заданное через SO_RCVBUF или SO_SNDBUF: половина выделенной памяти уходит под служебные структуры, и по проводам уходит примерно половина того, что вернёт getsockopt. Под окно отходит не весь буфер: накладные расходы считает tcp_adv_win_scale как bytes/2^N. При значении 2 окну достаётся три четверти буфера, при значении 1 ровно половина. Историческое значение по умолчанию 2, в свежих ядрах встречается 1, фактическую величину показывает команда sysctl net.ipv4.tcp_adv_win_scale.
За автоподстройку отвечает tcp_moderate_rcvbuf, включённый по умолчанию с Linux 2.4.17. Приёмный буфер доращивается под пропускную способность пути, но не выше третьего числа tcp_rmem. Передающий буфер растёт вслед за окном перегрузки. А tcp_window_scaling, работающий с Linux 2.2, разрешает окнам превышать предел 65 535 байт: без него большие буферы бесполезны.
Произведение пропускной способности и задержки как расчётная база
Нужный размер подсказывает произведение пропускной способности канала на задержку, bandwidth-delay product, сокращённо BDP. Это объём данных, который одновременно "живёт" на пути: отправлен и ещё не подтверждён при полной загрузке канала. Пока окно меньше BDP, канал простаивает, как шоссе с одним открытым шлагбаумом на шесть полос.
Расчёт умещается в три строчки. Канал 1 Гбит/с при RTT 20 мс: 1 000 000 000 бит/с, делённые на восемь, дают 125 000 000 байт/с, а умножение на 0,02 с даёт 2 500 000 байт, около 2,4 МБ. Канал 100 Мбит/с через океан при RTT 100 мс: 12 500 000 байт/с на 0,1 с составляют 1 250 000 байт. Канал 10 Гбит/с между стойками при RTT 50 мс: 1 250 000 000 байт/с на 0,05 с, выходит 62 500 000 байт, целых 60 МБ.
Практическое правило: окно каждой стороны должно покрывать BDP, а с учётом служебной половины буфера потолки rmem_max и wmem_max задают вдвое больше произведения. Для океанского линка 100 Мбит/с это 2 500 000 байт на сторону, для гигабита с RTT 20 мс около 5 МБ, для 10 Гбит/с с большой задержкой все 125 МБ.
Теперь арифметика ограничений по умолчанию. Приёмный потолок 212 992 байта с tcp_adv_win_scale равным 2 оставляет окну 159 744 байта. Пропускная способность одного соединения не превышает окно, делённое на RTT. При RTT 100 мс выходит 1 597 440 байт/с, то есть около 12,8 Мбит/с. При RTT 20 мс уже 63,9 Мбит/с. Вот откуда вечная жалоба "гигабит есть, а скорости нет": iperf3 в один поток упирается в это число, а в восемь потоков показывает полную полосу.
Задержку берут из жизни: у живого соединения честнее поле rtt в выводе ss -ti, запись rtt:51.4/1.2 означает задержку 51,4 мс с отклонением 1,2 мс. Очереди на пути добавляют к RTT десятки миллисекунд, и расчёт по чистому пингу выходит оптимистичным.
Настройка sysctl от автотюнинга TCP к жёстким потолкам
Сначала взгляд на текущее состояние:
sysctl net.core.rmem_max net.core.wmem_max
sysctl net.ipv4.tcp_rmem net.ipv4.tcp_wmem net.ipv4.tcp_mem
Затем пробное изменение на лету:
sysctl -w net.core.rmem_max=16777216
sysctl -w net.core.wmem_max=16777216
sysctl -w net.ipv4.tcp_rmem="4096 131072 16777216"
sysctl -w net.ipv4.tcp_wmem="4096 16384 16777216"
Кавычки вокруг тройки обязательны, иначе оболочка разрежет её на аргументы. Значения в примере рассчитаны на линк с BDP до 16 МБ, для 10-гигабитного пути с большой задержкой задают и больше, вплоть до 125 МБ.
Чтобы пережить перезагрузку, параметры переносят в файл /etc/sysctl.d/99-socket-buffers.conf:
net.core.rmem_max = 16777216
net.core.wmem_max = 16777216
net.ipv4.tcp_rmem = 4096 131072 16777216
net.ipv4.tcp_wmem = 4096 16384 16777216
Применяет файл команда sysctl --system или sysctl -p /etc/sysctl.d/99-socket-buffers.conf.
Первые два числа тройки лучше не трогать: минимум 4096 и среднее 131072 экономят память на тысячах коротких соединений, расти должен только максимум, а буфер под конкретный путь доращивает автотюнинг при включённом tcp_moderate_rcvbuf со значением 1. Гигантский максимум не съедает память у простаивающих сокетов, но зависшие соединения держат её до закрытия, а осиротевший сокет забирает до 64 КБ несвопаемой памяти. Прикидка не помешает: 10 000 соединений со средним буфером 131 072 байта удерживают около 1,3 ГБ.
У UDP сюрприз: автотюнинга у него нет. Размер приёмного буфера задаёт rmem_default, либо приложение само выставляет SO_RCVBUF. Очередь переполнилась, пакеты выброшены, протокол молчит: подтверждений в его природе нет. Вспомнить об этом лучше заранее, а не после жалоб.
Проверка фактических размеров через ss и счётчики потерь
Настоящий размер буфера живого сокета показывает ss из пакета iproute2 с ключом -m:
ss -tm dst 203.0.113.10
В выводе появится строка вида skmem:(r0,rb212992,t0,tb212992,f0,w0,o0,bl0,d0). rb разрешённый этому сокету приёмный буфер, его личный потолок, r занятая сейчас часть. tb и t повторяют пару для передачи. f кэш памяти сокета, w очередь записи, o память под опции, bl очередь backlog, d число пакетов, отброшенных до передачи процессу.
Ключ -i добавляет внутренние параметры TCP:
ss -tmi dst 203.0.113.10
Здесь видны wscale с коэффициентами масштабирования окна и знакомое rtt. Диагностика сводится к одному наблюдению: под нагрузкой rb должен расти в сторону нового потолка tcp_rmem. Замер на 212992 при медленной передаче означает, что скорость режет старый rmem_max. После удачной правки rb спокойно переваливает за 4 000 000, и канал наконец дышит.
Потери считаются, а не угадываются. Поле d в skmem показывает дропы конкретного сокета. По системе в целом счётчики выдаёт nstat -az: UdpRcvbufErrors растёт при нехватке приёмного буфера, UdpInErrors при любых ошибках, те же числа лежат в /proc/net/snmp, строка Udp. Рост первого при исправном канале почти всегда указывает на тесный буфер, а не на сеть.
Управление буферами из приложения через setsockopt
Бывает, что править всю систему нельзя, а отдельному сервису нужен свой размер:
#include <sys/socket.h>
int buf = 4194304;
setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &buf, sizeof(buf));
setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &buf, sizeof(buf));
int real = 0;
socklen_t len = sizeof(real);
getsockopt(fd, SOL_SOCKET, SO_RCVBUF, &real, &len);
/* real окажется 8388608, ядро удвоило запрос */
Подводных камней три, каждый способен испортить день. Первый, уже знакомое удвоение: запросили 4 194 304, получили 8 388 608, а по проводам уйдёт около половины этого числа. Второй: запрос молча обрезается по rmem_max и wmem_max, без предупреждений в логах. У процесса с CAP_NET_ADMIN есть лазейка, опции SO_RCVBUFFORCE и SO_SNDBUFFORCE, доступные с Linux 2.6.14, они игнорируют системный потолок. Третий: размер задают до connect() или listen(), иначе он не вступит в силу. Минимумы после удвоения тоже зафиксированы: 256 байт для приёма и 2048 для передачи.
Явный SO_RCVBUF имеет побочный эффект: буфер фиксируется, автодоращивание отключается, ответственность переходит к приложению. Для UDP это единственный рычаг вообще. Рядом живёт лимит optmem_max, по документации ядра 128 КБ на сокет: он ограничивает вспомогательный буфер под control-сообщения, метки времени, IP_PKTINFO, структуры TCP zero-copy. Приложения, активно работающие с такими сообщениями, упираются в него раньше, чем в основной буфер.
Очереди драйвера сетевой карты и параметры канала
Сокетные буферы заканчиваются внутри ядра, а пакеты теряются и раньше. Принятый драйвером кадр ждёт обработки в очереди backlog, её длину ограничивает net.core.netdev_max_backlog, по умолчанию 1000 пакетов. На 10-гигабитном линке всплеск мелких пакетов превышает тысячу за мгновение, и ядро сбрасывает хвост. Диагностика спрятана в /proc/net/softnet_stat: каждая строка описывает ядро процессора, второй столбец считает сброшенные пакеты. Значение 134 в шестнадцатеричной записи означает 308 потерянных кадров. Лечение короткое:
sysctl -w net.core.netdev_max_backlog=25000
Второе место потерь, кольца драйвера, RX и TX ring, временная память самой карты. Команда ethtool -g eth0 печатает максимум и текущие настройки, типичная картина на виртуальной машине, RX: 256 и TX: 256. Подъём выглядит так:
ethtool -G eth0 rx 4096 tx 4096
Работает, если железо позволяет: большие кольца сглаживают всплески ценой чуть большей задержки и памяти. Счётчики конкретного драйвера показывает ethtool -S eth0, в них ищут строки со словом drop. Очередь передачи на уровне интерфейса регулирует txqueuelen: ip link set dev eth0 txqueuelen 5000, а текущее значение видно в выводе ip link show. Разгрузить процессоры помогает распределение пакетов по ядрам через RSS и RPS.
Типичные ошибки и порядок внедрения на практике
Порядок внедрения, который экономит вечер диагностики:
- измерить RTT до ключевых узлов командой ping или взять поле rtt из ss -ti;
- посчитать BDP и задать rmem_max и wmem_max с запасом вдвое;
- поднять только потолки tcp_rmem и tcp_wmem, оставив минимум и среднее прежними;
- закрепить значения в /etc/sysctl.d и проверить, что tcp_window_scaling и tcp_moderate_rcvbuf включены;
- под нагрузкой смотреть rb и tb в выводе ss -m и добиваться роста до нового потолка;
- при потерях поднимать netdev_max_backlog и кольца карты командой ethtool -G.
Частые ошибки просты. Поднятый rmem_default для всех сокетов подряд, включая локальные unix-сокеты, тратит память впустую: решает задачу потолок, а не стартовое значение. Надежда на SO_SNDBUF в потеряющем канале заканчивается разочарованием, буферы лечат переполнение очередей, а не плохую связь. Тест одним потоком вводит в заблуждение: одиночное соединение ограничено окном, параллельные потоки показывают полосу. Последний сюрприз: правка сделана только на отправителе, а окно ограничивает получатель, щедрость одной стороны не спасает.
Настройка буферов не делает канал быстрее, она снимает искусственный потолок, который ядро ставит из осторожности. Диагностика занимает минуты: две команды sysctl показывают потолки, одна ss -m размеры, счётчики вместо догадок. Когда rb под нагрузкой дорастает до рассчитанного по BDP значения, а счётчики потерь молчат, длинный канал наконец отдаёт мегабиты, за которые платят.