Как мы оптимизировали сетевой шейпер Linux в облаке с помощью eBPF
Меня зовут Леонид Талалаев, я занимаюсь разработкой внутреннего облака Одноклассников one-cloud, про которое уже рассказывали на Хабре.
Одноклассники – высоконагруженная социальная сеть, и оптимизировать под высокие нагрузки нам нужно не только сервисы, но и инфраструктуру, на которой они работают. Нередко «узким горлышком» становится сама операционная система и, в частности, механизмы распределения ресурсов ядра Linux.
В облаке на одном физическом сервере могут одновременно работать десятки контейнеров, конкурирующих за ресурсы. Чтобы обеспечить надежную и эффективную работу, необходимо управлять распределением ресурсов между контейнерами.
Для управления сетевым трафиком до недавнего времени мы использовали решение на основе дисциплины Hierarchical Fair Service Queue из Linux Traffic Control. Сегодня пойдет речь про проблему масштабирования в Linux Traffic Control, известную как root qdisc locking. И про то, как нам удалось ее решить, переделав управление сетевым трафиком с использованием eBPF.
Приоритеты задач в облаке
В облаке one-cloud трафик можно поделить на:
prod трафик: приоритетный трафик задач с низкой задержкой – фронты, базы данных, сервисы, обрабатывающие пользовательские запросы;
nonprod трафик: весь остальной трафик – фоновые расчеты, миграция данных и др.
Гарантии для prod и nonprod трафика разные:
Пакеты prod задач должны отправляться максимально быстро, чтобы минимизировать сетевую задержку. Для них определяется квота – максимальная пропускная способность, которую задаче разрешено утилизировать (исходящий и входящий трафик считаются независимо).
Для nonprod задач важна только средняя пропускная способность сети, поэтому пакеты таких задач могут отправляться с задержкой, после пакетов prod задач.
Среднее потребление prod-задач, как правило, намного ниже их квоты: у нас соотношение составляет в среднем 6%. Поэтому совмещение prod и nonprod задач приводит к значительной экономии ресурсов за счет овераллокации: nonprod задачи могут потреблять всю свободную пропускную способность, не использованную prod задачами.
Контейнеры и классы трафика
Один prod контейнер может генерировать разные виды трафика – трафик, относящийся к обработке пользовательских запросов и трафик, относящийся к различным фоновым процессам. Таким, как перебалансировка данных в системе хранения блобов, repair и node bootstrap в apache cassandra, и другим процессам, которые не занимаются непосредственно обработкой запросов пользователей. Такой фоновый трафик непостоянен и в пике может превышать «обычный», создаваемый пользовательской активностью. Если не разделять эти виды трафика, то фоновые операции могут начать влиять на сетевую задержку обычных запросов. Поэтому, скорость фоновых операций пришлось бы ограничивать и/или резервировать под них дополнительную пропускную способность, ухудшая утилизацию оборудования.
В one-cloud фоновый трафик prod контейнеров может помечаться как nonprod и шейпиться независимо от остального трафика контейнера. Это позволяет нам не включать такой трафик в квоту контейнера. При этом мы можем не ограничивать скорость фоновых операций, позволяя им использовать всю свободную пропускную способность сети. Помимо трафика фоновых операций, мы приоритизируем также CPU и дисковый ввод-вывод с помощью шедулеров CFS и BFQ – об этом мы расскажем в следующих статьях.
Итого каждому prod контейнеру соответствует два класса трафика – prod и nonprod. Также есть отдельный nonprod класс для трафика, не относящегося к контейнерам (загрузка образов из реестра, например). Поэтому правильнее было бы говорить не о трафике контейнеров, а о классах трафика.
Но чтобы не усложнять изложение излишними деталями, далее по тексту будем подразумевать, что контейнер = задача = один класс трафика.
Требования к сетевому шейперу
Мы хотим, чтобы задержка prod задачи как можно меньше зависела от наличия других задач на том же хосте – prod или nonprod.
Для выполнения этих требований нам нужны следующие возможности по управлению трафиком:
Приоритизация – prod получает ресурс сетевой карты до nonprod.
Квотирование (rate limit) – трафик каждой prod задачи можно ограничить сверху. Это означает, что на сервере с 10 Гбит/с свободной полосы можно разместить 10 prod задач с квотой 1 Гбит/с так, чтобы они не мешали друг другу.
Разделение полосы (link sharing) – у каждой nonprod задачи есть вес, который определяет в какой пропорции она получает свободную полосу трафика, не использованную prod задачами.
Дисциплины и фильтры в Linux Traffic Control
Перечисленные выше требования можно удовлетворить с помощью Linux Traffic Control – это часть сетевой подсистемы Linux, которая позволяет настраивать на сетевом интерфейсе различные дисциплины очередей (queueing discipline, сокращенно – qdisc). Исходящие пакеты сначала попадают в дисциплину, а только потом на сетевой интерфейс. Дисциплина, в зависимости от её алгоритма, может делать с пакетами различные действия: складывать в очереди, приоритизировать, модифицировать, в некоторых случаях – дропать.
Classfull дисциплины распределяют пакеты по классам, для каждого из которых настраивается дочерний qdisc со своей логикой. В отличие от простых (classless) дисциплин, они оперируют классами, а не пакетами. Т.е. их алгоритмы определяют выбор класса, из которого в данный момент отправлять пакеты. Приоритизация пакетов внутри классов определяется дочерними qdisc.
Задавать распределение пакетов по классам в classfull дисциплинах можно с помощью фильтров. Фильтр содержит условие и значение класса (flowid), который назначается пакетам, удовлетворяющим условию.
Возможности фильтров шире, чем просто классификация пакетов для qdisc. К ним можно привязывать действия, которые будут применены к пакетам, попадающим под фильтр. Например, изменить пакет, вызвать BPF программу, перенаправить на другой сетевой интерфейс или дропнуть.
Существуют две специальные псевдо-дисциплины – ingress и clsact. «Псевдо» – потому что они не являются дисциплинами в чистом виде, т.е. не реализуют никакой логики по обработке пакетов. Их можно настраивать на сетевом интерфейсе параллельно с основной дисциплиной. Единственное их назначение – возможность вешать фильтры на входящие (ingress) или исходящие (egress) пакеты. Дисциплина clsact включает в себя ingress и позволяет вешать фильтры на оба направления.
С помощью clsact можно добавлять фильтры для исходящих пакетов в тех случаях, когда основная дисциплина не поддерживает добавление фильтров напрямую (classless и multiqueue дисциплины, про последние речь пойдет чуть позже).
В предыдущем решении для управления трафиком мы использовали дисциплину hfsc (Hierarchical Fair Service Curve) с дочерними дисциплинами fq. Подробности настройки этого решения можно найти в этой статье. Для понимания текущей статьи детали настроек не имеют принципиального значения. Проблема, о которой пойдет речь, актуальна не только для hfsc, но и любой другой дисциплины (про исключения мы тоже поговорим).
Проблема блокировок Linux Traffic Control
За несколько лет производительность используемых нами серверов существенно выросла: с 8-и ядерных с 128-и Гб памяти и сетью 2 Гбит/с до 128-и ядерных с 1 Тб памяти и 20 Гбит/с сетью. Соответственно, выросло число контейнеров и общая нагрузка, которая приходится на отдельный сервер. Все чаще мы стали сталкиваться с парадоксальной ситуацией: после переноса контейнера на более мощный сервер, он иногда начинал работать хуже. При увеличении числа сетевых пакетов примерно свыше 200К/сек начинался заметный рост сетевой задержки prod задач. При нагрузке 600K пакетов/сек – потребление CPU вырастало в 1.5–2 раза, сетевая задержка задач вырастала в 5–6 раз. Чем мощнее сервер, тем чаще проявлялась данная проблема.
При этом, CPU и сеть были загружены на 30–50%, т.е. дело было не в нехватке ресурсов. По perf top
аномально высокое время процессор проводил в методе native_queued_spin_lock_slowpath
, а анализ cpu flamegraph с помощью async-profiler показал, что корни ведут в сетевой стек:
Оранжевым цветом отмечены фреймы, относящиеся к ядру Linux. Как легко догадаться из названия, метод native_queued_spin_lock_slowpath
– это ожидание получения блокировки spinlock.
Чтобы разобраться, в какой момент берется блокировка, посмотрим на верхушку flamegraph:
Метод __dev_queue_xmit
относится к сетевому стеку – через него проходят все пакеты, отправляемые с сетевого интерфейса. По коду __dev_queue_xmit можно понять, что блокировка берется внутри __dev_xmit_skb (который является inline методом, поэтому отсутствует в стеке вызовов):
spinlock_t *root_lock = qdisc_lock(q);
...
spin_lock(root_lock)
... //обработка пакета
spin_unlock(root_lock)
В данном коде q
– это корневая сетевая дисциплина, настроенная для сетевого устройства, а qdisc_lock(q)
– объект блокировки, связанный с ней.
Проблема масштабирования Linux Traffic Control на больших нагрузках известна как root qdisc locking. Например, вот слайд про нее из доклада Jesper Dangaard Brouer, Principal Kernel Engineer из Red Hat:
На слайде root_lock – это глобальная блокировка корневой qdisc дисциплины, разделяемая всеми процессами. И на каждый пакет эту блокировку нужно взять дважды. Проблема усугубляется тем, что это spinlock – во время ожидания процессорное время тратится впустую. Что в случае prod задач может вести к превышению квоты по cpu и, как следствие, к троттлингу.
Чем мощнее сервер, тем больше контейнеров там можно разместить, тем больше они создадут потоков и тем больше будет конкуренция за root qdisc lock. И рано или поздно наличие блокировки станет узким местом. У нас наиболее заметно проблема стала проявляться на серверах со 128 ядрами и полосой 20 Гбит/с – настолько, что нам пришлось отказаться от их эксплуатации до решения данной проблемы.
Lockless и multiqueue дисциплины
Из всех сетевых дисциплин Linux Traffic Control не имеют глобальной блокировки следующие (в последней версии ядра, на момент написания статьи – 5.13):
noqueue – отсутствие qdisc
lockless qdiscs, которые используют lock-free алгоритмы вместо блокировки. Единственная такая дисциплина – pfifo_fast, которая умеет приоритизировать, но не ограничивать скорость или делать link sharing. Возможно, в будущем появятся lockless реализации других дисциплин. Например, предпринимались попытки сделать lockless реализацию HTB.
multiqueue qdisc: mq и mqprio (mq не надо путать с multiq, у которой есть глобальная блокировка).
Поскольку нам нужно уметь ограничивать скорость контейнеров, варианты 1 и 2 нам не подходят. Остаются только варианты с multiqueue qdisc.
На данный момент практически все сетевые карты серверного уровня имеют несколько очередей, которые позволяют распределить нагрузку по обработке пакетов между несколькими ядрами процессора. Дисциплина mq позволяет на каждую очередь сетевой карты назначать свой дочерний qdisc.
Классификация пакета (определение в какой дочерний qdisc он попадет) происходит неблокирующим образом. В дисциплине mq очередь выбирается как хэш flowid пакета по модулю числа очередей. Где flowid – набор из protocol, src ip, dst ip, src port, dst port.
Использование flowid позволяет избежать изменения порядка пакетов (reordering) из одного соединения вследствие отправки пакетов через разные очереди сетевой карты. Для TCP и многих UDP протоколов reordering может привести к ненужным пересылкам пакетов и снижению производительности.
У каждой очереди сетевой карты свой qdisc, и они друг с другом никак не взаимодействуют. Обработка пакетов в дочерних qdisc происходит параллельно без глобальной синхронизации, что увеличивает производительность, но ограничивает возможности по управлению трафиком рамками одной подочереди.
Если трафик контейнера будет распределяться по разным очередям, то его нельзя будет ограничить. Поэтому, чтобы использовать mq с ограничением скорости трафика контейнеров, трафик каждого контейнера нужно загнать в одну очередь сетевой карты. Для очереди настроить qdisc, лимитирующий скорость этого контейнера.
Переопределить выбор очереди для mq (и других дисциплин) можно, устанавливая у пакета атрибут queue_mapping
до того, как он попадет в qdisc. Один из вариантов, как это сделать – фильтр пакетов с действием skbedit. Дисциплина mq будет использовать значение queue_mapping
как номер очереди вместо хэша от flowid.
Хотя такое решение будет работать, в нем есть несколько недостатков. Во-первых, оно приведёт к деградации производительности, так как обработка пакетов одного контейнера будет производиться одним ядром процессора. Во-вторых, число контейнеров будет ограничено числом очередей сетевой карты, что не всегда приемлемо (часть карт в наших ДЦ имеют всего по 8 очередей).
В-третьих, вместо глобальной блокировки мы получили бы блокировку на каждый контейнер, что конечно сильно лучше, но не исключит проблему для контейнеров, генерирующих много трафика. Поэтому мы стали искать другое решение.
Earliest Departure Time и дисциплина fq
В Linux Kernel было внесено изменение (оно присутствует в ядрах 4.20 и выше), которое позволило задавать для каждого исходящего пакета самое ранее время отправки (earliest departure time или EDT). Это время затем учитывается дисциплиной fq как время, раньше которого пакет нельзя отправлять на сетевую карту.
Дисциплина fq хранит неотправленные пакеты в виде набора FIFO очередей. Одна FIFO-очередь соответствует одному flow. Как и в случае с mq, flow определяется набором из protocol, src ip, dst ip, src port, dst port. Пакеты одного flow отсылаются всегда в порядке поступления. Предполагается, что в рамках одного flow EDT пакетов не уменьшается – в противном случае соблюдение EDT не будет гарантировано. Но это требование вполне логичное, так как с точки зрения приоритизации сетевого трафика пакеты одного соединения относятся к одному классу и нет необходимости менять их порядок.
Те flow, время отправки пакетов в которых еще не наступило, хранятся в дереве ожидающих отправки flows. В нем flows отсортированы по EDT первого пакета в flow. Как только EDT наступает, flow переносится в связанный список готовых к отправке flows. В этом списке flow обрабатываются в порядке round-robin по циклам (dequeue rounds). За каждый такой цикл из одного flow отправляется пакетов суммарной длиной не более quantum байт (задается параметром fq). После чего цикл переходит к следующему flow. Если очередной пакет из flow имеет EDT в будущем, то этот flow переносится в дерево ожидающих отправки flows.
Независимо от того, в какую очередь и, соответственно, дочернюю дисциплину fq попал пакет, он будет отправлен в соответствии с установленным в нем EDT. Т.е. больше нет требования иметь ровно одну дисциплину fq на контейнер. Например, можно распределять пакеты дисциплиной mq по дочерним fq и получить очень хороший параллелизм – у 10G карт Intel серий X500 и X700, которые мы используем, по 64 очереди, а на некоторых серверах стоит несколько таких карт.
Поделив длину пакета на скорость, мы получим минимальное время, через которое мы можем отправить следующий пакет, не превышая заданную скорость. Таким образом, получаем следующую формулу для расчета EDT следующего пакета:
EDT = время + длина_пакета / скорость
Остается вопрос – как проставлять EDT?
BPF спешит на помощь
Тут на помощь приходит технология eBPF (или просто BPF). Тем, кто не знаком с ней, рекомендую почитать вводную статью на Хабре. Программа на BPF типа BPF_PROG_TYPE_SCHED_CLS может перехватывать все исходящие пакеты до того, как они поступят в qdisc. Программа получает на вход указатель на структуру __sk_buff, описывающую пакет, которая содержит в том числе:
wire_len
: длина пакета в байтахtstamp
: собственно, Earliest Departure Time
Получается, можно реализовать BPF шейпер, который будет проставлять пакетам EDT. Далее они будут поступать в mq и затем в соответствующий fq qdisc, который будет отправлять пакет в заданное время:
Данный подход применяет Google для шейпинга исходящего во внешний мир трафика. Также он реализован как часть Cilium – сетевого data-plane для Kubernetes на базе BPF.
Упрощенный пример расчета EDT внутри BPF кода:
skb->tstamp = max(now, t_next);
t_next = skb->tstamp + skb->wire_len * NSEC_PER_SEC / upper_limit_bps;
Здесь now
– текущее время, t_next
– EDT в наносекундах следующего пакета, которое мы запоминаем в состоянии программы, upper_limit_bps
– скорость в байтах/секунду.
Защита от переполнения очереди
Чтобы очередь не переполнилась, нужно ограничивать добавление новых пакетов в случаях, когда трафик превышает лимит в течение долгого времени. Заметим, что у fq уже есть понятие drop horizon – дисциплина дропает пакеты, EDT которых выходит далеко в будущее. Но нам придётся реализовать drop horizon самим для того, чтобы правильно учесть факт дропа при расчете t_next
. Для этого из BPF программы достаточно вернуть код TC_ACT_SHOT
(в нормальной ситуации возвращаем TC_ACT_OK
):
#define DROP_HORIZON 1000000000ULL // 1 секунда
skb->tstamp = max(now, t_next);
if (skb->tstamp - now > DROP_HORIZON) return TC_ACT_SHOT;
t_next = skb->tstamp + skb->wire_len * NSEC_PER_SEC / upper_limit_bps;
return TC_ACT_OK;
Bursting
Сила BPF — в возможности реализовывать произвольную логику, а не ограничиваться возможностями встроенных сетевых дисциплин. Например, можно реализовать bursting – разрешать превышать лимит в течение короткого интервала времени. Поскольку bursting не возникает одновременно на всех контейнерах, это можно сделать достаточно безопасно. Мы нашли оптимальным включение burst в течение 5мс для prod задач – это позволило снизить сетевую задержку для некоторых сервисов до 30%. Добавить bursting в наш BPF шейпер очень просто:
#define DROP_HORIZON 1000000000ULL // 1 секунда
#define BURST 5000000ULL // 5 мс
skb->tstamp = max(now - BURST, t_next);
if (t_next - now > DROP_HORIZON) return TC_ACT_SHOT;
t_next = skb->tstamp + skb->wire_len * NSEC_PER_SEC / upper_limit_bps;
return TC_ACT_OK;
Доводим BPF шейпер до ума
Для реализации полноценного шейпера на BPF предстоит сделать еще несколько вещей:
Хранение состояния
t_next
и настроекupper_limit_bps
для каждого класса трафика. Это делается с помощьюBPF_MAP_TYPE_ARRAY
. Про BPF maps можно почитать в документации BPF на сайте Cilium.Классификация пакета – каждому классу соответствует свое состояние
t_next
и настройкаupper_limit_bps
. Для классификации мы используем поля с IP адресом и TOS из заголовка пакета. Отображение IP+TOS на класс трафика хранится вBPF_MAP_TYPE_HASH
. Парсинг заголовков пакетов нужно реализовывать самим. Это, наверное, самая сложная часть нашего BPF шейпера. С другой стороны, в интернете есть масса примеров, как это можно сделать, поэтому не будем это подробно разбирать.Синхронизация. Поскольку доступ к
t_next
делается из многих потоков, он должен быть синхронизирован. Но для этого можно обойтись без блокировок – достаточно объявитьt_next
какvolatile
, чтобы обеспечить синхронизацию процессорных кэшей при операциях чтения и записи. Вероятность того, что другой поток успеет прочитать устаревшее значение, достатоточно мала, и для данной задачи ей можно пренебречь. Именно так и поступает Cilium (код тут и тут).Control plane. Заполнением перечисленных выше BPF maps занимается user space демон, который у нас называется «миньон». Он написан на Java, для доступа к возможностям BPF из Java мы используем библиотеку one-nio.
Статистика. В BPF мы считаем число отправленных/дропнутых пакетов, их длину в байтах, гистограмму задержки EDT. Статистика складывается в
BPF_MAP_TYPE_PERCPU_ARRAY
и читается раз в секунду из user space демона, который пересылает ее в агрегированном виде в подсистему статистики.
Пока наш шейпер умеет только ограничивать скорость, но нам нужно удовлетворить два других требования – приоритизацию и link sharing.
Приоритизация дисциплиной mqprio
Требование приоритизации означает, что все пакеты prod контейнеров должны быть отправлены на сетевую карту до того, как начнётся отправка nonprod пакетов (при условии, что их EDT уже наступило). В этом нам может помочь дисциплина mqprio.
Дисциплина mqprio расширяет mq возможностью определить набор классов (traffic class), а также и каждому из них назначить диапазон очередей сетевой карты. При отправке через дисциплину пакеты делятся сначала по классам, затем – по соответствующим этому классу очередям на основании хэша от flowid пакета.
Нам нужно настроить в mqprio два traffic class – для prod и для nonprod трафика, а также поделить очереди сетевой карты между ними. Классификация пакетов происходит на основе поля priority сетевого пакета, которое может принимать значения 0–15. При настройке mqprio передается параметр map, в котором перечисляются traffic class для каждого возможного значения priority.
Изначально Linux заполняет поле priority пакета на основании поля ToS IP заголовка пакета. Как именно он это делает, можно почитать тут. Для нас это особой роли не играет, так как мы это поле выставляем сами в нашей BPF программе. Для prod трафика (traffic class 0) мы используем приоритеты TC_PRIO_INTERACTIVE=6
и TC_PRIO_CONTROL=7
, остальной трафик считаем nonprod (traffic class 1). Получаем значение параметра map 1 1 1 1 1 1 0 0 1 1 1 1 1 1 1 1
После классификации пакета BPF программа выставляет skb->priority = TC_PRIO_INTERACTIVE
для prod трафика иTC_PRIO_BESTEFFORT
для nonprod. Дисциплина mqprio на основе приоритета определяет traffic class и направляет пакет в соответствующую очередь:
Приоритизация на сетевой карте
Дисциплина mqprio также умеет настраивать поддержку приоритизации со стороны сетевой карты (hardware QoS), которая включается параметром hw 1. Это позволяет добиться практически идеальной приоритизации – по результатам тестов влияние nonprod на prod полностью отсутствовало.
Однако с этим возникли сложности:
Во-первых, часть карт старых моделей не поддерживало данную возможность.
Во-вторых, на картах Intel X700 возник конфликт настроек.
Подробности под катом
для поддержки HW QoS нужно было включить DCB в firmware карты, который включался только вместе со встроенным в карту LLDP, а он в свою очередь мешал работе SW реализации LLDP, необходимой нам для интеграции с другими системами.
В-третьих, на Intel картах поддержку hardware QoS нельзя включить одновременно с XDP native mode, который мы планируем использовать для других задач (балансировщика нагрузки).
Получалось, что включение hardware QoS в датацентрах потребовало бы заменить сетевые карты на 30% машин и отказаться от возможности в будущем использовать XDP.
Забегая немного вперёд – помимо исходящего трафика нам нужно шейпить также и входящий. А на виртуальном интерфейсе ifb поддержка hardware приоритизации не работает в принципе.
Поэтому мы стали искать способ приоритизации, который сможет работать без hardware QoS.
Приоритизация без поддержки со стороны карты
Заметим, что даже без hardware QoS сам факт разделения nonprod и prod трафика по разным очередям сетевой карты снижает влияние nonprod на prod. В случае, если они не разделены по очередям (как было в нашем старом решении), после того как пакеты отправлены в сетевую карту, там могут возникнуть свои задержки из-за кратковременной заполненности полосы или по другим причинам. В результате получается ситуация, когда в одной очереди карты перемешаны пакеты разных приоритетов, а отправка их происходит в том порядке, в котором они были получены, т.к. повторной приоритизации в этой очереди нет, что влечёт рост задержки prod задач.
В итоге мы пришли к следующему решению: делить очереди для prod и nonprod трафика в пропорции 7:1. Т.е. даем prod трафику в 7 раз больше очередей. Например, если на сетевой карте восемь очередей, даем семь prod и одну – nonprod трафику. Это позволяет снизить вероятность конкуренции prod и nonprod пакетов за физическую полосу карты, т.к. отправкой prod пакетов занимается в 7 раз больше очередей. В такой конфигурации в нагрузочных тестах с использованием iperf максимальное влияние nonprod трафика на задержку prod задач составило не более 15%.
Дополнительно мы ограничиваем лимит nonprod задач так, чтобы общая утилизация сетевой карты не превышала 80%, потому что согласно нашим наблюдениям, при превышении этого порога начинается деградация latency даже на серверах без шейпинга. Это позволило ещё сильнее уменьшить влияние – на реальных задачах оно не превышает 5%, что нас вполне устраивает. И это решение оставляет возможность включить hardware QoS, если это когда-то будет необходимо.
Настройка сетевой подсистемы
Для полноты приведем команды для настройки BPF шейпера. Для этого нам понадобятся утилиты ethtool и tc.
Определим число очередей на сетевой карте с помощью ethtool:
ethtool -l eth1
Channel parameters for eth1:
Pre-set maximums:
RX: 0
TX: 0
Other: 1
Combined: 63
Current hardware settings:
RX: 0
TX: 0
Other: 1
Combined: 56
На карте настроено 56 очередей (смотрим значение Combined). Настраиваем mqprio, отдав 49 очередей prod трафику, 7 – nonprod:
tc qdisc add dev eth1 handle 1 root mqprio num_tc 2 map 1 1 1 1 1 1 0 0 1 1 1 1 1 1 1 1 queues 49@0 7@49
Заменяем дочерние qdisc (которые по умолчанию – pfifo_fast) на fq. Поднимаем лимиты числа пакетов, так как защита от переполнения очереди уже есть в BPF:
tc qdisc replace dev eth1 parent 1:0x1 fq limit 100000 flow_limit 100000
tc qdisc replace dev eth1 parent 1:0x2 fq limit 100000 flow_limit 100000
...
tc qdisc replace dev eth1 parent 1:0x38 fq limit 100000 flow_limit 100000
Добавляем clsact qdisc для возможности вешать tc фильтры на исходящие пакеты:
tc qdisc add dev eth1 clsact
Загружаем скомпилированную в ELF файл BPF программу как фильтр исходящих пакетов:
tc filter add dev eth1 egress bpf obj net-shaper.bpf.o sec tc_out da
Параметр da (direct-action) нужен для возможности дропать пакеты: результат BPF программы интерпретируется как действие над пакетом.
Разделение полосы nonprod
Контейнеры с уровнем изоляции nonprod могут использовать всю свободную полосу вплоть до 80% пропускной способности сетевой карты за вычетом среднего потребления prod задач. В случае, если nonprod контейнеров несколько, для разделения полосы между ними мы используем алгоритм max-min fair share.
Идея алгоритма следующая. Представим, что нам нужно поделить между тремя задачами – A, B и C полосу 900 Мбит/с. Самое простое – поделить поровну: получаем разбиение 300+300+300. Теперь представим, что нам известна желаемая доля трафика каждой задачи, и ей нет смысла давать больше. Допустим, желаемая доля A – 200 Мбит/с. Нам нужно уменьшить долю задачи A с 300 до 200, поделив разницу в 100 Мбит/с между B и C – получаем разбиение 200+350+350. Продолжаем процесс перераспределения долей, пока доля каждой задачи не будет меньше или равна ее желаемой доле. Если по окончанию у нас осталась неиспользованная доля трафика (т.е. сумма желаемых долей меньше полосы), то делим остаток между всеми задачами поровну.
Теперь немного усложним этот алгоритм – добавим для каждой задачи вес и будем делить трафик пропорционально весам. Весом для nonprod задачи мы считаем её минимальный трафик, задаваемый в манифесте.
Алгоритм выполняется в user space демоне и рассчитывает лимит каждой nonprod задачи на следующий квант времени (1 секунда). В качестве желаемой доли трафика задачи берется ее среднее потребление за предыдущий квант времени.
Отложенное применение лимита nonprod
У задач с низким потреблением трафика рассчитанная по алгоритму доля будет низкой. Но, если в следующую секунду задача захочет потребить больше, то упрется в лимит, и мы не узнаем ее реальное желаемое потребление. Получается проблема «курицы и яйца»: лимит зависит от потребления задачи, которое ограничено лимитом. Чтобы разорвать этот круг, мы не применяем рассчитанный лимит к задаче, пока её утилизация ниже 80% от лимита. Таким образом, задачи со скачкообразным изменением трафика могут сразу получить свою честную долю.
С другой стороны, это означает, что суммарное потребление всех задач может кратковременно превысить порог в 80% от пропускной способности сетевой карты, которого мы хотим придерживаться. Но поскольку мы разделили prod и nonprod трафик по разным очередям, существенного влияния на задержку prod задач это не оказывает.
Шейпинг входящего трафика
До этого речь была только про исходящий трафик. Но входящий трафик тоже нужно шейпить. Для входящего трафика мы используем точно такой же подход, как для исходящего. Отличие состоит лишь в том, что сетевые дисциплины и фильтр с BPF программой настраиваются не на сетевом интерфейсе, а на виртуальном интерфейсе ifbX, создаваемом модулем ifb, на который переадресуются входящие пакеты.
Входящий и исходящий трафик шейпятся отдельными BPF программами, алгоритм max-min fair share и статистика по ним считаются независимо.
Не обошлось без подводных камней – после включения шейпинга входящего трафика перестала работать синхронизация времени через ntpd. Как выяснилось, реализация протокола NTP использует поле skb->tstamp
и не очень хорошо относится к его модификации. В BPF программу была добавлена проверка, чтобы не менять tstamp пакетов, относящихся к NTP (UDP пакеты с src и dst портом 123). После чего синхронизация снова заработала.
Заключение
Новый шейпер развернут в наших дата-центрах на более чем 5 тыс. серверов. Через него проходит трафик всех основных сервисов Одноклассников. Полный переход на новое решение занял примерно месяц и был завершен 3 месяца назад.
Проблем деградации производительности при росте числа сетевых пакетов больше не наблюдалось. На серверах с 10-и гигабитными картами при нагрузке 2 млн пакетов/сек рост задержки prod задач остается в пределах 15% (на предыдущей версии шейпера деградация свыше 15% начиналась после 200К пакетов/сек). Также улучшилось latency prod задач – у отдельных задач до 30%.
Идею использовать EDT+fq для ограничения скорости мы взяли из статьи от Google и доработали для возможности приоритизации трафика и разделения полосы.
Разработка заняла 4 месяца силами одного человека вместе с исследованиями и экспериментами.
Ссылки
Статья на Хабре о нашем облаке: One-cloud — ОС уровня дата-центра в Одноклассниках
Статья, видео и слайды доклада про оптимизацию шейпера в Google: Replacing HTB with EDT and BPF
Вводная статья про BPF на Хабре: BPF для самых маленьких, часть первая
Введение в BPF и XDP на сайте Cilium: BPF and XDP Reference Guide
Статья про использование BPF для классификации пакетов: On getting tc classifier fully programmable with cls bpf
Еще одна статья про особенности использования eBPF для сетевых задач: Creating Complex Network Services with eBPF: Experience and Lessons Learned