Сервер отвечает мгновенно, но только первым десяти: очереди на сетевой карте
Десять параллельных клиентов — сервер отвечает мгновенно, задержки ровные у всех. Пятьдесят клиентов — большинство по-прежнему получает ответ быстро, но заметная часть запросов вдруг начинает тормозить в разы, и это не одни и те же клиенты каждый раз. Общая загрузка CPU при этом в top выглядит скромно, диск не при делах, приложение ничего подозрительного не логирует. Разбираемся, почему при росте параллельных соединений сервер не «медленнее для всех», а «быстрый для одних и медленный для других одновременно» — и при чём тут то, как сетевая карта раскладывает пакеты по очередям и ядрам CPU.
Содержание
Симптом: не общая деградация, а расслоение
Это важное отличие от классической картины перегруженной сети. Когда сетевая карта или её драйвер годами работают с одной-единственной очередью приёма, все пакеты обслуживает одно ядро, и при достаточном потоке это ядро упирается в потолок целиком — тогда тормозит буквально всё сразу, а остальные ядра сервера откровенно простаивают. Разбор именно такого случая, вплоть до конкретных команд восстановления multiqueue, есть в статье ksoftirqd на 100%: все прерывания сети сидели на одном ядре — но это не единственный сценарий, и часто не тот, с которым сталкиваются на практике на уже настроенном сервере.
Здесь ситуация тоньше: несколько очередей приёма реально работают, прерывания реально размазаны по нескольким ядрам, irqbalance включён — то есть на первый взгляд всё сделано правильно. И тем не менее при росте числа параллельных соединений часть запросов начинает получать заметно худший отклик, чем остальные, причём одновременно с ними, на том же сервере, в тот же момент времени. Средняя загрузка CPU по серверу почти не двигается — потому что в среднем действительно всё в порядке, просто не по всем ядрам сразу. Проблема не в объёме нагрузки, а в том, как конкретные соединения распределились по конкретным очередям.
Multi-queue и RSS: коротко о механике
Современная серверная сетевая карта — физическая или виртуальная, вроде virtio-net с включённым multiqueue — умеет принимать пакеты не в одну очередь, а сразу в несколько, обычно по числу ядер или их части. Технология, которая решает, в какую именно очередь положить конкретный пакет, называется RSS (Receive Side Scaling): карта считает хеш от полей заголовка — как правило, это исходный и целевой IP-адрес плюс исходный и целевой порт, так называемый 5-tuple — и по значению хеша выбирает очередь. У каждой очереди своя линия прерывания, и прерывания от разных очередей штатно приходят на разные ядра.
Принципиальное свойство этой схемы: все пакеты одного TCP-соединения хешируются одинаково и поэтому всегда попадают в одну и ту же очередь — иначе они приходили бы на разные ядра вразнобой и путались бы местами при сборке в поток. Значит, конкретное соединение на весь срок своей жизни привязано к одной очереди и, как следствие, преимущественно к одному ядру CPU, которое эту очередь обслуживает. Полный разбор механики — от аппаратного прерывания до softirq и от ethtool -l до IRQ affinity — есть в статье прерывания и почему сетевая карта отбирает у вас целое ядро; здесь эту механику не повторяем, а отталкиваемся от неё, чтобы разобрать именно эффект неравномерности.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверПочему хеш не гарантирует равномерность
Хеш-функция распределяет входные значения равномерно только в статистическом смысле, на большом количестве разнообразных входов. У неё нет памяти о текущей загрузке очередей: она не знает, что на очередь 2 уже приходится втрое больше активных соединений, чем на очередь 5, и физически не может это учесть — вычисление хеша от 5-tuple детерминировано и никак не зависит от состояния сервера в момент вычисления.
Отсюда несколько практических следствий, которые в сумме и дают симптом из заголовка:
- Малое число одновременных соединений плохо усредняется. Если очередей восемь, а активных долгоживущих соединений тоже около десяти, по закону больших чисел равномерного распределения ждать не стоит — вполне может получиться, что на одну-две очереди случайно легло сразу три-четыре «тяжёлых» соединения, а на другие — ни одного. При десяти запросах суммарная нагрузка на любую отдельную очередь всё равно маленькая, задержка незаметна. При росте параллелизма то же самое неравномерное распределение начинает давать заметный перекос: очереди, на которые традиционно ложится больше соединений, упираются в потолок раньше остальных.
- Не все соединения одинаково "тяжёлые". Пара долгоживущих стримов или соединений с интенсивным обменом мелкими пакетами создают на своей очереди нагрузку, несопоставимую с обычным коротким HTTP-запросом. Если такие соединения по хешу столпились на одной-двух очередях, эти очереди будут перегружены вне зависимости от общего числа клиентов.
- NAT, прокси и балансировщики перед сервером снижают энтропию входных данных для хеша. Если много клиентских соединений на сервер приходят через один и тот же прокси или NAT-шлюз, у части трафика исходный IP и диапазон портов оказываются куда менее разнообразными, чем у прямых клиентских подключений — а значит, и хеш от 5-tuple такого трафика чаще попадает в один и тот же узкий набор значений, чем ожидалось бы от полностью случайного распределения.
- Долгоживущие соединения не переразмещаются. Если хеш один раз положил TCP-сессию в конкретную очередь, она там и останется на весь срок жизни соединения — переезда на менее загруженную очередь по ходу дела не происходит. Кратковременный всплеск на одной очереди из-за нескольких «неудачно» захешированных долгих соединений может держаться часами, пока эти соединения не закроются.
Итог — тот самый заголовок: первые десять соединений действительно быстрые, потому что даже перекошенное распределение на десяти соединениях не создаёт заметной нагрузки ни на одной отдельной очереди. По мере роста параллелизма перекос никуда не девается (хеш детерминирован), но нагрузка на «переполненные по случайности» очереди растёт быстрее, чем на остальные, — и именно эта часть трафика начинает страдать первой, пока остальной трафик на том же сервере обслуживается без замедлений.
Среднее по серверу маскирует проблему
Это тот же эффект, что и при полном коллапсе на одну очередь, только слабее выраженный и оттого более коварный: агрегированные метрики врут именно потому, что усредняют разное поведение разных ядер в одно число. top в режиме по умолчанию или htop без разбивки по CPU показывают средний процент загрузки — и если у сервера, скажем, шестнадцать ядер, а перегружены реально два-три, среднее по-прежнему выглядит вполне терпимым.
Смотреть нужно на загрузку по каждому ядру отдельно, причём именно на долю %si (обработка софтверных прерываний) — она обычно и выдаёт хот-споты сетевой обработки раньше, чем %usr приложения:
mpstat -P ALL 1
Признак проблемы — не общий рост загрузки, а разброс между ядрами: одно-два ядра держат %si в районе 60-90%, пока соседние сидят на единицах процентов, и это соотношение стабильно воспроизводится под нагрузкой, а не мигает случайно. В top то же самое видно, если переключиться в постолбцовый режим по ядрам клавишей 1.
Полезно смотреть эту картину не разово, а в динамике за период, когда часть запросов уже жаловалась на задержки — если пик расхождения между самым занятым и самым свободным ядром по времени совпадает с ростом p95/p99 задержек на уровне приложения, это довольно однозначное подтверждение именно этой причины, а не что-то ещё в стеке.
/proc/interrupts: ищем, куда реально оседают прерывания
Загрузка ядра по %si показывает симптом, но не саму причину — источник нужно смотреть на уровне прерываний конкретных очередей сетевой карты:
watch -n1 'grep -E "CPU|eth0" /proc/interrupts'
На многоядерном сервере с настроенным multiqueue вывод должен содержать несколько строк вида eth0-TxRx-0, eth0-TxRx-1 и так далее — по одной на очередь, каждая приписана к своему ядру. В отличие от сценария с единственной очередью (там растёт ровно одна строка, ровно в одной колонке), здесь растут все строки — вопрос в том, насколько равномерно.
Чтобы увидеть именно скорость роста, а не абсолютные значения, счётчики стоит снять дважды с интервалом в несколько секунд и посчитать разницу — абсолютное число прерываний с момента загрузки сервера само по себе мало о чём говорит, а вот прирост за равный интервал времени по разным очередям сравнивать уже осмысленно. Если один-два столбца растут в разы быстрее остальных — это и есть перегруженные очереди, и по номеру IRQ в первой колонке можно сопоставить их с конкретным ядром через /proc/irq/<номер>/smp_affinity_list.
Дальше полезно свериться с ethtool, действительно ли карта использует все доступные очереди:
ethtool -l eth0
Если Current hardware settings меньше, чем Pre-set maximums, часть потенциальной ёмкости для распределения нагрузки просто не задействована — и тогда перекос по немногим активным очередям закономерен, увеличение числа очередей (в пределах поддержки драйвера) — первое, что стоит попробовать, прежде чем разбираться с более тонкими настройками.
RPS/RFS: программная подстраховка, а не готовое решение
Там, где аппаратных очередей физически не хватает — карта или виртуальный адаптер поддерживает меньше очередей, чем есть ядер, — или где перекос сохраняется даже при полностью задействованном RSS, у Linux есть программный аналог: RPS (Receive Packet Steering) и RFS (Receive Flow Steering).
Идея RPS в том, что softirq-обработчик, уже получив пакет на одном ядре после аппаратного прерывания, может сам передать дальнейшую обработку на другое ядро — по тому же принципу хеширования, но уже средствами ядра ОС, а не сетевой карты. Это не убирает нагрузку с самого приёма пакета (она всё равно ложится на ядро, обслуживающее исходную очередь), но перераспределяет более тяжёлую часть работы — разбор пакета, продвижение по стеку — на менее занятые ядра. RFS идёт дальше и старается учитывать, на каком ядре реально выполняется процесс-получатель данных, снижая накладные расходы на перескакивание между кэшами CPU. Управляется это через битовые маски в /sys/class/net/eth0/queues/rx-N/rps_cpus — по одной маске на очередь.
Здесь важна честная оговорка: RPS и RFS — это не переключатель «включил и забыл», а дополнительный уровень программной балансировки, который сам стоит некоторых тактов CPU на межъядерную сигнализацию, и не всегда даёт выигрыш, соразмерный этим накладным расходам. Эффект сильно зависит от профиля трафика — числа одновременных соединений, соотношения долгоживущих и коротких сессий, размера пакетов — и того, что уже даёт сама аппаратная RSS без вмешательства. Правильная настройка масок для конкретного сервера — это не рецепт из одной команды, а итеративная работа: включить, нагрузочно протестировать именно тем профилем трафика, который реально даёт сервис (а не синтетическим тестом с идеальными равномерными потоками), сравнить распределение %si по ядрам и p95/p99 задержки до и после, и только по результатам решать, оставлять ли настройку. То же касается ручной перепривязки IRQ affinity под конкретный NUMA-узел или выделения отдельного пула ядер под сетевую обработку — эти рычаги существуют и работают, но требуют понимания топологии конкретного сервера и измерений на конкретной нагрузке, а не копирования чужого конфига. Слепое копирование параметров тюнинга ядра с чужого сервера — известная ловушка сама по себе, ей посвящён отдельный разбор в блоге про антипаттерн настройки по чужому конфигу.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Чем это отличается от ситуации, когда все прерывания идут на одно ядро?
Там всего одна очередь и один явный узкий столбец в /proc/interrupts — деградация резкая и заметна сразу всем клиентам одновременно, как только поток пакетов превысил возможности единственного ядра. Здесь очередей несколько, они реально задействованы, но хеш-распределение конкретных соединений по ним получилось неравномерным — деградация частичная и растёт постепенно вместе с числом параллельных соединений, задевая часть трафика раньше остальной.
Можно ли заранее посчитать, сколько параллельных соединений сервер выдержит без перекоса?
Точную цифру дать нельзя — она зависит от числа реальных очередей, характера трафика (короткие запросы или долгие соединения, размер пакетов, есть ли перед сервером NAT или прокси, снижающие разнообразие адресов) и от того, что ещё делает каждое ядро помимо сетевой обработки. Ориентир получают только нагрузочным тестированием конкретного сервиса на конкретном сервере, а не по общей формуле.
Если увеличить число очередей RSS, перекос гарантированно исчезнет?
Не гарантированно, но обычно становится заметно менее вероятным: чем больше очередей, тем меньше шанс, что несколько «тяжёлых» соединений случайно захешируются в одну и ту же. Полностью исключить перекос это не может — статистическая природа хеширования никуда не девается, — но заметно снижает его амплитуду при том же числе соединений.
Стоит ли включать RPS/RFS "на всякий случай" даже при полностью рабочем RSS?
Как минимум это не бесплатно — дополнительная межъядерная сигнализация стоит тактов CPU, и если аппаратная RSS уже распределяет нагрузку приемлемо, ощутимого выигрыша можно не увидеть, а небольшие накладные расходы всё равно появятся. Включать стоит осознанно, после того как измерения по mpstat -P ALL и /proc/interrupts показали устойчивый перекос, который RSS сама не выравнивает.
Это вообще актуально для VPS с небольшим числом ядер?
Эффект неравномерности между очередями требует, чтобы очередей и ядер было больше одной-двух — иначе распределять физически особо не между чем. На серверах с четырьмя и более ядрами, где под сетевую нагрузку реально задействовано несколько очередей, ловушка вполне реальна; на минимальных тарифах в одно-два ядра актуальнее скорее общий лимит по PPS, чем перекос между очередями — про это отдельно написано в статье про сетевые лимиты виртуалки и PPS.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →