Нет повести печальнее на свете, чем повесть о потерянном пакете. А чтобы пакеты не терялись, нужно проводить нагрузочное тестирование и быть уверенным в своем продукте. Особенно когда речь идет о проксирующем балансировщике и UDP-трафике. Ведь UDP выглядит не как упорядоченный контролируемый процесс, а скорей как толпа школьников, бегущих после звонка в столовую, — часть может не добежать и потеряться по пути. Будет очень печально, если из-за кривого пола балансировщика наши дорогие школьники пакеты попадут в статистику потерь.

В этой статье я хочу рассказать о своем опыте тестирования проксирующего балансировщика с помощью генератора трафика TRex. И вроде бы уже есть статья на этот счет, но она про TCP, а при тестировании с UDP-трафиком возникли новые вводные. Плюс в ядре достаточно мало оптимизаций ускорений для UDP, что делает тесты более интересными, чем TCP. В итоге своим кейсом хочу поделиться с коллегами.

Зачем нужен был краш-тест

Мне необходимо было проверить, как выдерживает нагрузку один экземпляр балансировщика с разными конфигурациями, созданный на базе Envoy, узнать пределы пропускной способности UDP-трафика с MTU 1500 байт.

Когда команда разработки поставила мне эту задачу, я пошел в интернет искать способ, как провести тест софтверным генератором трафика в рамках существующей инфраструктуры. На Хабре наткнулся на прекрасную статью моего коллеги @IvanIVDyukov. Если вкратце, то Иван провел отличную работу по изучению существующих инструментов по генерации трафика, определил, что лучшим инструментом оказался open source генератор от Cisco TRex.

Помимо этого, Иван проверил балансировщик на прочность, определив его пределы в работе с TCP-трафиком, собрав основные метрики производительности устройства. Если кому интересно более глубоко погрузится в эту тему, а также узнать, почему спецификации оборудования не спасают при оценке производительности, рекомендую к прочтению.

После изучения статьи я сразу же приступил к повторению опыта моего коллеги. Ранее я не работал с TRex, в основном весь мой опыт нагрузочного тестирования был связан с большими «холодильниками» типа IXIA, Spirent и т.д. В самых простых кейсах использовал iperf3. Поэтому помимо статьи Ивана я нашел еще несколько, которые помогли мне установить и сделать первоначальную настройку генератора.

Однако через время я столкнулся с тем, что точь-в-точь опыт коллег повторить не получится. Во-первых, сама конфигурация стенда отличается: мне нужно провести тестирование в естественной среде для балансера, в статьях она более лабораторная. Во-вторых, сами профили трафика отличаются: мне нужно передавать поток UDP-трафика, плюс отправлять его в трех направлениях: от клиента к серверу, от сервера к клиенту и двунаправленно. Все эти кейсы вполне реальны в ежедневной работе сетей. В-третьих, во всех найденных статьях упор больше делался на TCP-трафик и нюансы работы с UDP не подсвечены. Ну и, в-четвертых, примеры профилей UDP-трафика есть как в файлах TRex, так и в интернете, но мне нужно было их переработать под себя. Пройдемся по отличиям подробнее.

Создание стенда для проверки балансировщика на прочность

Схема подключения TRex и тестового устройства желательно должна быть «закольцованной», то есть тестируемое устройство включается непосредственно к интерфейсам генератора — это нужно как для генерации клиентских виртуальных хостов и серверов внутри TRex, которые обмениваются трафиком через тестовое устройство, так и для снятия метрик, оценки переданного и полученного трафика, вычисления потерь и т.п.

В официальной документации генератора описана схема, когда клиентская часть развернута на одной виртуалке с TRex, а серверная часть — на другой с отдельным экземпляром TRex, и между ними тестируемое устройство — получается своего рода труба. Но как в таком случае будет собираться статистика и на сколько она будет точной? Для меня этот вопрос остался открытым.

В статье Ивана по тестированию балансировщика стенд был собран, грубо говоря, на одной железке. И клиент, и сервер, и сам балансировщик были виртуальными в рамках одного сервера, соответственно, добиться сетевой связности проще.

В официальной документации также упор делается на непосредственном подключении между устройствами. А если эти сущности находятся не на расстоянии прямого подключения, а раскиданы по всему ЦОД? Если клиент живет где-то там, в большой всемирной паутине, группа серверов — на одной стойке дата-центра, а балансировщик — на другой?

Чтобы решить эту загадку Жака Фреско проблему, я попытался собрать стенд и проверить, реально ли работает такой кейс. В итоге на вопрос «возможно ли использовать TRex для тестирования устройства без прямого физического подключения, а в рамках виртуальных сущностей в ЦОД» я отвечу: нужна L2 adjacency. У нас как раз под боком грела воздух EVPN-VXLAN фабрика, которая может предоставить растянутые L2 сети.

Получилась следующая схема в рамках одной VPC:

  • TRex живет на ВМ с тремя интерфейсами: клиентским, серверным и менеджментским для управления ВМ. Под каждый интерфейс создана своя подсеть.

  • У балансировщика два интерфейса, которые живут в отдельной подсети: один интерфейс общается с клиентской частью TRex, второй интерфейс обратного трафика нужен для общения с серверами.

  • Все это добро маршрутизирует виртуальный роутер в рамках VPC.

Графическая схема моего тестового стенда
Графическая схема моего тестового стенда

Для каждого устройства я специально создал свои подсети, чтобы правильно маршрутизировать трафик. Благодаря магии VXLAN-фабрики, удалось достичь сетевой связности на уровне Overlay.

Для успешного прохождения тестирование теперь нужно правильно настроить профиль трафика. В Destination IP я указал входной интерфейс балансировщика, иначе тема не взлетит и трафик уйдет в небытие. Для получения ответа от серверов в профиле трафика нужно указать, на какой source IP из запроса нужно отвечать. Про это расскажу подробнее далее.

Познаю профили трафика динозавров

Не буду кривить душой — профили трафика мне давались очень тяжело. Хоть я знаком с сетями, понимаю Python, но чтобы разобраться, как правильно делать профиль и добиться нужного результата, пришлось убить не один час.

В директории TRex уже есть с десяток UDP-профилей, воспроизводящих определенные сценарии. Часть из них базировалась на дампах реальных пакетов, в которых некоторые параметры менялись через yaml-файл.

Однако существующие профили либо не подходили под нужды моего тестирования, либо было вообще не понятно, что они делают и как с ними работать. Все усугублялось тем, что документация TRex не очень дружелюбна для новичка и смысл некоторых параметров, классов и методов мне до сих пор непонятен.

У меня есть задумка сделать практическое исследование, покрутив некоторые параметры, поймать дамп сгенерированного трафика и посмотреть влияние, но это потом. А сейчас, потратив некоторое время на тщетные попытки добиться поставленных целей, я решил написать свои профили на основании существующих из TRex-директорий и прочитанных статей. Пусть они будут простенькие, зато понятные и полезные для тестирования.

Немного теории

TRex генерирует два типа трафика: Stateless и Stateful.

Stateless — это режим генерации трафика без отслеживания состояния, когда определенное количество пакетов генерируются в поток и отправляются в сторону сервера. Согласно официальной документации:

Поддержка режима Stateless в TRex позволяет проводить базовое тестирование L2–L3, что особенно актуально для коммутаторов и маршрутизаторов. В режиме Stateless можно определить поток с шаблоном из одного пакета, определить программу для изменения любых полей в пакете и запустить поток в одном из следующих режимов: Continuous, Burst, Multi-burst. В режиме Stateful основным строительным блоком является поток или приложение, состоящее из множества пакетов. Режим Stateless гораздо более гибкий, позволяя определять любой тип пакета и создавать простую программу.

Stateless не поддерживает отправку двунаправленного трафика в рамках одного потока. Stateful, в свою очередь, отслеживает состояние соединения. В этом режиме есть возможность передавать трафик, когда тестируемое устройство завершает работу стека TCP, например сжатие или распаковка. В этом случае на каждой стороне находится разная сессия TCP, но данные уровня L7 практически одинаковые.

В контексте UDP TRex отслеживает потоки и, как в случае с TCP, возможно настроить ответную отправку трафика от сервера. При отправке однонаправленного трафика клиент> сервер или сервер> клиент подходит режим Stateless. Для двунаправленного трафика, естественно, нужен Stateful-режим.

Забегая вперед, скажу, что мне пришлось отдельно провести замеры однонаправленного трафика в Stateful-режиме. В процессе тестирования возникла проблема со Stateless-режимом, не давшая проверить направление сервер> клиент.

Параметры трафика

Пройдемся по основным параметрам, которые буду меняться в течение теста.

Пример профиля трафика в Stateful-режиме:

from trex.astf.api import *
import argparse

UDP_REQ_BASE = 'x'
UDP_RESP_BASE = 'y'
FCS = 4
ETHER_HEADER = 14
IP_HEADER = 20
UDP_HEADER = 8
FRAME_HEADERS = FCS + ETHER_HEADER + IP_HEADER + UDP_HEADER

class Prof1():
    def __init__(self):
        pass  # tunables

    def create_profile(self, flow_size, frame_size: int = 1500):
        # client commands
        udp_req = UDP_REQ_BASE * (frame_size - FRAME_HEADERS)
        prog_c = ASTFProgram(stream=False)
        for i in range(0, int(flow_size / 2)):
            prog_c.send_msg(udp_req)
            prog_c.recv_msg(1)

        udp_resp = UDP_RESP_BASE * (frame_size - FRAME_HEADERS)
        prog_s = ASTFProgram(stream=False)
        for i in range(0, int(flow_size / 2)):
            prog_s.recv_msg(1)
            prog_s.send_msg(udp_resp)

        # ip generator
        ip_gen_c = ASTFIPGenDist(ip_range=["10.0.0.0", "10.0.0.255"], distribution="seq")
        ip_gen_s = ASTFIPGenDist(ip_range=["192.168.1.12", "192.168.1.12"], distribution="seq")
        ip_gen = ASTFIPGen(glob=ASTFIPGenGlobal(ip_offset="1.0.0.0"),
                           dist_client=ip_gen_c,
                           dist_server=ip_gen_s)

        # template
        temp_c = ASTFTCPClientTemplate(program=prog_c,ip_gen=ip_gen, port=7888)
        temp_s = ASTFTCPServerTemplate(program=prog_s, assoc=ASTFAssociationRule(port=7888,
                                                                 ip_start="10.1.0.1",
                                                                 ip_end="10.1.0.254"))  # using default association
        template = ASTFTemplate(client_template=temp_c, server_template=temp_s)

        # profile
        profile = ASTFProfile(default_ip_gen=ip_gen, templates=template)
        return profile

    def get_profile(self, tunables, **kwargs):
        parser = argparse.ArgumentParser(description='Argparser for {}'.format(os.path.basename(__file__)),
                                         formatter_class=argparse.ArgumentDefaultsHelpFormatter)

        parser.add_argument('--flow_size',
                            type=int,
                            default=2,
                            help='Flow size (packets)')

        args = parser.parse_args(tunables)
        return self.create_profile(flow_size=args.flow_size)

def register():
    return Prof1()

В Stateful-режиме объем трафика управляется множителем и количеством пакетов в потоке. По сути, множитель — это количество сессий, которые создаются в секунду (cps). Базово генератор оправляет один UDP-датаграмм с клиентской стороны, серверная сторона принимает его, кидает датаграмм в ответ и поток закрывается. Чтобы увеличить количество пакетов в потоке, я добавил новую переменную flow_size, которая задает количество датаграмм для отправки в цикле в одном потоке.

Пример из профиля:

        for i in range(0, int(flow_size / 2)):
            prog_s.recv_msg(1)
            prog_s.send_msg(udp_resp)

В кейсе двунаправленного трафика половина от величины flow_size идет на отправку с клиента, другая половина — для отправки с сервера. Кстати, когда вводил кастомный параметр, я не сразу понял, как правильно его применять при запуске генерации. Для этого нужно использовать флаг -t и после него перечислить опции в формате опция1=значение1,опция2=значение2, и т.д.

В переменной ip_gen_s будет добавлен адрес входного интерфейса балансировщика, а в temp_s — параметры порта на стороне сервера и адреса, c которых серверы будут отвечать своими пакетами. Наполнение payload будет банальное — множество символов x и y в количестве, необходимом для достижения MTU 1500 байт в датаграмме.

С Stateless-режимом проще, количество трафика управляется множителем, при этом все это в рамках одного потока, который льется от клиента к серверу в заданное количество времени. Из изменяемых параметров здесь только адреса и порты клиентов и серверов да размер пакета.

Пример профиля трафика Stateless:

from trex_stl_lib.api import *

import argparse

class STLS1(object):

    def __init__ (self):

        self.mode=0;

        self.fsize=1500;

    def create_pkt_base (self):

        t=[

        Ether()/IP(src="10.0.0.1",dst="192.168.1.12")/UDP(dport=7888,sport=7888),

        ];

        return t[self.mode]

    def create_stream (self):

        # Create base packet and pad it to size

        size = self.fsize - 4; # HW will add 4 bytes ethernet FCS

        base_pkt = self.create_pkt_base ()

        pad = max(0, size - len(base_pkt)) * 'x'
  
        pkt = STLPktBuilder(pkt = base_pkt/pad,

                            vm = [])

        return STLStream(packet = pkt,
                         mode = STLTXCont())

    def get_streams (self, tunables, **kwargs):

        parser = argparse.ArgumentParser(description='Argparser for {}'.format(os.path.basename(__file__)), 

                                         formatter_class=argparse.ArgumentDefaultsHelpFormatter)


        args = parser.parse_args(tunables)

        # create 1 stream 

        return [ self.create_stream() ]

# dynamic load - used for trex console or simulator

def register():

    return STLS1()

При тестировании трафика от сервера к клиенту обнаружилась проблема. Весь трафик к проксирующему балансировщику идет от условных бэкендов через адреса обратного трафика. Их можно указать в профиле, но как трафик пойдет дальше? Балансер не понимает, для кого этот трафик, сессии от клиента не были установлены и все пакеты пропадут. Чтобы решить эту проблему, пришлось задействовать Statefull-режим: клиент посылает один пакет для установления сессии, а от сервера в обратную сторону летит количество пакетов из параметра flow_size. Можно представить это как кейс, когда некто посылает запрос к серверу на просмотр своего любимого китайского мультика.

Вкалывают T-рексы, а не человек

Несколько комментариев о самом ходе тестирования.

Вот главные метрики, которые я снимал во время теста:

  • пропускная способность в Мбит или Гбит,

  • максимальное количество активных сессий в секунду,

  • потери пакетов,

  • загрузка CPU и памяти,

  • задержки.

За весь цикл нагрузочного тестирования мне нужно было проверить 4 флейвора и определить, как меняется максимальная производительность в зависимости от количества ресурсов, выделенных на балансировщик. Шаг проверки сначала был 50 Мбит, но после 100500-го запуска трафика, я решил увеличить до 100 Мбит, особенно на более производительных конфигурациях, потому что разница между шагами была незначительная, а времени тратилось много.

Учитывая, что каждый шаг я делал по 3 раза для усреднения показателей, про 100500 раз я не пошутил. Благодаря такому подходу удалось определить, в какой момент какая конфигурация дала слабину, появились потери или мы начали упираться в потолок. После этого можно было посмотреть, как себя ведет на этих уровнях более производительная конфигурация, и увидеть степень зависимости пределов передачи трафика от дополнительных ресурсов.

Нужную величину трафика в секунду я получал балансированием количества сессий в секунду и количеством пакетов в потоке. К примеру, 84 cps и flow_size 100 пакетов — это примерно 100 Мбит трафика при MTU 1500.

Почему так было сделано? Представим, что мы управляем количеством трафика только с помощью множителя. Нам надо сгенерировать и пропустить через балансировщик 300 Мбит трафика, соответственно при MTU 1500 — это примерно множитель 25 200, т.е. (как я писал выше) сессий в секунду. Если бы речь шла о тестировании коммутатора или маршрутизатора, то проблемы бы не было, им все равно на сессии, но балансировщик — это устройство другого порядка.

Так как наш балансировщик — это Envoy с включенным netfilter, то он каждую сессию контролирует с помощью conntrack (Connection Tracking). Условно говоря, это таблица, куда записываются параметры TCP-соединения или UDP-потока. Количество записей ограничено и определяется параметром в конфигурации балансировщика, соответственно 25 200 cps забивает таблицу довольно быстро.

Еще одним параметром является тайм-аут очистки записи после закрытия потока. Для UDP-потока по дефолту это 120 секунд (можно проверить командой sysctl -a, найти параметр —net.netfilter.nf_conntrack_udp_timeout_stream). Этот тайм-аут конфигурируется в Envoy, но все равно очистка идет не моментально, и когда таблица забита, то начинается дроп новых потоков, и пакетов соответственно. По сути, мы DDoS-им наше тестовое устройство. Если же оставить небольшое количество потоков, но в рамках одного потока передавать последовательно много пакетов, то проблемы с исчерпанием conntrack не будет.

Важной частью тестирования является мониторинг. Без наличия настроенной Grafana или подобной системы сбора метрик и создания графиков я бы не бросался с ведром пакетов на балансировщик. У TRex довольно подробная статистика пакетов, потоков, потерь, бит и тд, но пропускную способность он отображает в моменте, а в финальной статистике нет показания пикового значения. Эту величину я брал с Grafana, как и загрузку CPU, памяти и задержки.

Одной из проблем, с которой я столкнулся, оказалась особенность работы TRex на виртуальной машине. В какой-то момент при генерации большого количества пакетов, начиная примерно с 150–200 Мбит, начал забиваться буфер самого генератора, что отражалось в статистике queue_full во время генерации трафика. К счастью, эти пакеты не терялись, а просто удлинялся цикл тестирования, пока все до единого пакетики не уйдут в сеть.

К примеру, один прогон длился не 60 секунд, а 90 секунд. Суть проблемы в том, что TRex на виртуальной машине может использовать только одно ядро. При запуске TRex есть флаг -c, после которого указывается число используемых ядер. Чем больше ядер, тем больше трафика можно сгенерировать. При попытке указать -c 2 на вируталке генератор выдает ошибку, что больше одного ядра нельзя:

«STF_BATCH mode does not support more than one core by dual interfaces. try with setting the number of cores to 1 (-c 1)».

Решается это, по информации из интернета и от коллег, включением режима Multi-queue в настройке гипервизора, но у меня не было к нему доступа, так что за решение ручаться не могу. Еще были рекомендации по увеличению RAM и настроек HugePages, но кардинально это вопрос не решило. Если кто-то сталкивался с этой проблемой и нашел решения, поделитесь пожалуйста.

Выводы

О чем хотелось поговорить в этой статье? Мне хотелось дополнить уже существующие статьи несколькими вещами, на которые я не смог найти ответ и рассказать о возникших проблемах при тестировании. В прочитанных мной статьях никак не понималась тема использования Trex, как устройства для тестирования в рамках виртуальных хостов, физически расположенных на существенных расстояниях.

Самый распространенный кейс, это когда генератор трафика и тестируемое устройство включены непосредственно друг в друга. Внимательный читатель может возразить, что тестирование в условиях удаленных друг от друга хостов неправильное, потому что Underlay-сеть может вносить потери и тогда не получится узнать возможности балансировщика. Однако по моему мнению, такая схема больше приближенна к штатной работе, можно проверить все устройства комплексно и найти слабые места не только в балансировщике, но и в инфраструктуре, от которой зависит наш продукт.

Уважаемые читатели, что думаете про такой подход и какие альтернативные способы тестирования проксирующего балансировщика вы можете посоветовать? Делитесь идеями в комментариях.


15 октября на конференции GoCloud Tech 2026 мой коллега расскажет про архитектуру балансировщиков нагрузки и почему у нас есть собственный CNI. Если интересно, регистрируйтесь на сайте и приходите онлайн или офлайн, мероприятие будет в Москве.

Комментарии (0)