Архітектура мережевого балансувальника навантаження в Яндекс.Хмарі

Архітектура мережевого балансувальника навантаження в Яндекс.Хмарі
Привіт, я Сергій Єланцев, розробляю мережевий балансувальник навантаження в Яндекс.Хмарі. Раніше я керував розробкою L7-балансувальника порталу Яндекса – колеги жартують, що чим би я не займався, виходить балансувальник. Я розповім читачам Хабра, як потрібно керувати навантаженням у хмарній платформі, яким ми бачимо ідеальний інструмент досягнення цієї мети та як рухаємось до побудови цього інструменту.

Для початку введемо деякі терміни:

  • VIP (Virtual IP) - IP-адреса балансувальника
  • Сервер, бекенд, інстанс - віртуальна машина із запущеним додатком
  • RIP (Real IP) - IP-адреса сервера
  • Healthcheck – перевірка готовності сервера
  • Зона доступності, Availability Zone, AZ – ізольована інфраструктура в дата-центрі
  • Регіон - об'єднання різних AZ

Балансувальники навантаження вирішують три основні завдання: виконують саме балансування, покращують стійкість до відмови сервісу і спрощують його масштабування. Відмовостійкість забезпечується рахунок автоматичного управління трафиком: балансировщик стежить станом докладання і виключає з балансування інстанси, які пройшли перевірку жвавості. Масштабування забезпечується рівномірним розподілом навантаження за інстансами, а також оновленням списку інстансів на льоту. Якщо балансування буде недостатньо рівномірним, то деякі інстанси отримають навантаження, що перевищує їх межу працездатності, і сервіс стане менш надійним.

Балансувальник навантаження часто класифікують за рівнем протоколу моделі OSI, на якому він працює. Балансувальник Хмари працює на рівні TCP, що відповідає четвертому рівню, L4.

Перейдемо до огляду архітектури балансувальника Хмари. Поступово будемо підвищувати рівень деталізації. Ми ділимо компоненти балансувальника на три класи. Клас config plane відповідає за взаємодію з користувачем і зберігає цільовий стан системи. Control plane зберігає в собі актуальний стан системи та керує системами із класу data plane, які відповідають безпосередньо за доставку трафіку від клієнтів до ваших інстансів.

Data plane

Трафік потрапляє на дорогі устрою під назвою border routers. Для підвищення стійкості до відмови в одному дата-центрі одночасно працює кілька таких пристроїв. Далі трафік потрапляє на балансувальники, які для клієнтів анонсують будь-яку IP-адресу на всі AZ по BGP. 

Архітектура мережевого балансувальника навантаження в Яндекс.Хмарі

Трафік передається по ECMP - це стратегія маршрутизації, згідно з якою може існувати кілька однаково хороших маршрутів до мети (у нашому випадку метою буде destination IP-адреса) і пакети можна надсилати по кожному з них. Також ми підтримуємо роботу в кількох зонах доступності за наступною схемою: анонсуємо адресу в кожній із зон, трафік потрапляє до найближчої і вже за її межі не виходить. Далі в пості ми розглянемо докладніше про те, що відбувається з трафіком.

Config plane

 
Ключовим компонентом config plane є API, через який виконуються основні операції з балансувальниками: створення, видалення, зміна складу інстансів, отримання результатів healthchecks і т.д. gRPC, тому ми «перекладаємо» REST у gRPC і далі використовуємо лише gRPC. Будь-який запит призводить до створення серії асинхронних задач, які виконуються на загальному пулі воркерів Яндекс.Хмари. Завдання пишуться таким чином, що вони можуть бути в будь-який час припинені, а потім знову запущені. Це забезпечує масштабованість, повторюваність та логованість операцій.

Архітектура мережевого балансувальника навантаження в Яндекс.Хмарі

У результаті завдання з API зробить запит у сервіс-контролер балансувальників, написаний на Go. Він може додавати та видаляти балансувальники, змінювати склад бекендів та налаштування. 

Архітектура мережевого балансувальника навантаження в Яндекс.Хмарі

Сервіс зберігає свій стан у Yandex Database - розподіленій керованій БД, якою незабаром зможете користуватися і ви. В Яндекс.Хмарі, як ми вже розповідали, діє концепція dog food: якщо ми самі користуємось своїми сервісами, то й наші клієнти також будуть із задоволенням ними користуватися. Yandex Database – приклад втілення такої концепції. Ми зберігаємо в YDB усі свої дані, і нам не доводиться думати про обслуговування та масштабування бази: ці проблеми вирішені за нас, ми користуємося базою як сервісом.

Повертаємося до контролера балансувальника. Його завдання - зберегти інформацію про балансувальника, відправити завдання перевірки готовності віртуальної машини в healthcheck controller.

Healthcheck controller

Він отримує запити на зміну правил перевірок, зберігає їх у YDB, розподіляє завдання по healtcheck nodes і агрегує результати, які потім зберігаються в базу і відправляються в loadbalancer controller. Він, у свою чергу, надсилає запит на зміну складу кластера в data plane на loadbalancer-node, про який я розповім нижче.

Архітектура мережевого балансувальника навантаження в Яндекс.Хмарі

Поговоримо докладніше про healthchecks. Їх можна поділити на кілька класів. У перевірок бувають різні критерії успіху. TCP-перевіркам потрібно успішно встановити з'єднання за фіксований час. HTTP-перевірки вимагають успішного з'єднання, і отримання відповіді зі статус-кодом 200.

Також перевірки відрізняються за класом дії – вони бувають активні та пасивні. Пасивні перевірки просто стежать за тим, що відбувається з трафіком, не роблячи жодних спеціальних дій. Це не дуже добре працює на L4, оскільки залежить від логіки протоколів вищого рівня: на L4 немає інформації про те, скільки часу зайняла операція, і чи було завершення з'єднання хорошим чи поганим. Активні перевірки вимагають, щоб балансувальник надсилав запити до кожного інстансу сервера.

Більшість балансувальників навантаження виконує перевірки «живості» самостійно. Ми в Хмарі вирішили поділити ці частини системи для підвищення масштабованості. Такий підхід дозволить нам збільшувати кількість балансувальників, зберігаючи кількість healthcheck-запитів до сервісу. Перевірки виконуються окремими healthcheck nodes, за якими шардовані та репліковані цілі перевірок. Не можна робити перевірки з одного хоста, оскільки він може відмовити. Тоді ми не отримаємо стан перевірених інстансів. Ми виконуємо перевірки будь-якого з інстансів щонайменше з трьох healthcheck nodes. Цілі перевірок ми шардуємо між нодами за допомогою алгоритмів консистентного хешування.

Архітектура мережевого балансувальника навантаження в Яндекс.Хмарі

Поділ балансування та healthcheck може призводити до проблем. Якщо healthcheck node здійснює запити до інстансу, минаючи балансувальник (який на даний момент не обслуговує трафік), то виникає дивна ситуація: ресурс начебто живий, але трафік до нього не дійде. Цю проблему ми вирішуємо так: гарантовано заводимо healthcheck-трафік через балансувальники. Іншими словами, схема переміщення пакетів з трафіком від клієнтів та від healthchecks відрізняється мінімально: в обох випадках пакети потраплять на балансувальники, які доставлять їх до цільових ресурсів.

Відмінність у тому, що клієнти роблять запити на VIP, а healthchecks звертаються до кожного окремого RIP. Тут постає цікава проблема: нашим користувачам ми даємо можливість створювати ресурси в сірих IP-мережах. Уявимо, що є два різні власники хмар, які сховали свої послуги за балансувальники. У кожного з них є ресурси в підмережі 10.0.0.1/24, причому з однаковими адресами. Потрібно вміти якимось чином їх відрізняти, і тут треба поринути у пристрій віртуальної мережі Яндекс.Хмари. Подробиці краще дізнатися в відео з заходу about:cloud, нам зараз важливо, що мережа багатошарова і має тунелі, які можна розрізняти по id підмережі.

Healthcheck nodes звертаються до балансувальників за допомогою так званих квазі-IPv6-адрес. Квазіадреса — це IPv6-адреса, всередині якої зашитий IPv4-адресу та id підмережі користувача. Трафік потрапляє на балансувальник, той витягує з нього IPv4-адресу ресурсу, замінює IPv6 на IPv4 і відправляє пакет до мережі користувача.

Зворотний трафік йде так само: балансувальник бачить, що призначення - сіра мережа з healthcheckers, і перетворює IPv4 на IPv6.

VPP – серце data plane

Балансувальник реалізований на технології Vector Packet Processing (VPP) - фреймворку від Cisco для пакетної обробки мережевого трафіку. У нашому випадку фреймворк працює поверх бібліотеки user-space-управління мережевими пристроями Data Plane Development Kit (DPDK). Це забезпечує високу продуктивність обробки пакетів: в ядрі відбувається набагато менше переривань, немає перемикань контексту між kernel space та user space. 

VPP йде ще далі і вичавлює із системи ще більше продуктивності за рахунок об'єднання пакетів у батчі. Підвищення продуктивності відбувається завдяки агресивному використанню кешів сучасних процесорів. Використовуються як кеші даних (пакети обробляються «векторами», дані лежать близько один до одного), так і кеші інструкцій: VPP обробка пакетів слід за графом, у вузлах якого знаходяться функції, що виконують одне завдання.

Наприклад, обробка IP-пакетів у VPP відбувається в такому порядку: спочатку у вузлі розбору відбувається парсинг заголовків пакетів, а потім вони відправляються у вузол, який пересилає пакети далі згідно з таблицями маршрутизації.

Трохи хардкор. Автори VPP не терплять компромісів у використанні кешів процесора, тому типовий код обробки вектора пакетів містить у собі ручну векторизацію: є цикл обробки, в якому обробляється ситуація виду «у нас чотири пакети в черзі», потім те саме для двох, потім для одного. Часто використовуються інструкції prefetch, що завантажують дані в кеші для прискорення доступу до них на наступних ітераціях.

n_left_from = frame->n_vectors;
while (n_left_from > 0)
{
    vlib_get_next_frame (vm, node, next_index, to_next, n_left_to_next);
    // ...
    while (n_left_from >= 4 && n_left_to_next >= 2)
    {
        // processing multiple packets at once
        u32 next0 = SAMPLE_NEXT_INTERFACE_OUTPUT;
        u32 next1 = SAMPLE_NEXT_INTERFACE_OUTPUT;
        // ...
        /* Prefetch next iteration. */
        {
            vlib_buffer_t *p2, *p3;

            p2 = vlib_get_buffer (vm, from[2]);
            p3 = vlib_get_buffer (vm, from[3]);

            vlib_prefetch_buffer_header (p2, LOAD);
            vlib_prefetch_buffer_header (p3, LOAD);

            CLIB_PREFETCH (p2->data, CLIB_CACHE_LINE_BYTES, STORE);
            CLIB_PREFETCH (p3->data, CLIB_CACHE_LINE_BYTES, STORE);
        }
        // actually process data
        /* verify speculative enqueues, maybe switch current next frame */
        vlib_validate_buffer_enqueue_x2 (vm, node, next_index,
                to_next, n_left_to_next,
                bi0, bi1, next0, next1);
    }

    while (n_left_from > 0 && n_left_to_next > 0)
    {
        // processing packets by one
    }

    // processed batch
    vlib_put_next_frame (vm, node, next_index, n_left_to_next);
}

Отже, Healthchecks звертаються по IPv6 до VPP, який перетворює їх на IPv4. Цим займається вузол графа, який називаємо алгоритмічним NAT. Для зворотного трафіку (і перетворення з IPv6 в IPv4) є такий самий вузол алгоритмічного NAT.

Архітектура мережевого балансувальника навантаження в Яндекс.Хмарі

Прямий трафік від клієнтів балансувальника йде через вузли графа, які виконують саме балансування. 

Архітектура мережевого балансувальника навантаження в Яндекс.Хмарі

Перший вузол – sticky sessions. У ньому зберігається хеш від 5-кортежний для встановлених сесій. 5-tuple включає адресу та порт клієнта, з якого передається інформація, адреса та портів ресурсів, доступних для прийому трафіку, а також мережевий протокол. 

Хеш від 5-tuple допомагає нам виконувати менше обчислень у подальшому вузлі консистентного хешування, а також краще обробляти зміну списку ресурсів за балансувальником. Коли на балансувальник приходить пакет, для якого немає сесії, він відправляється у вузол consistent hashing. Ось і відбувається балансування з допомогою консистентного хешування: ми вибираємо ресурс зі списку доступних «живих» ресурсів. Далі пакети відправляються у вузол NAT, який проводить фактичну заміну адреси призначення та перерахунок контрольних сум. Як бачите, ми дотримуємося правил VPP - подібне до подібного, групуємо схожі обчислення для підвищення ефективності кешів процесора.

Консистентне хешування

Чому ми вибрали саме його та що це взагалі таке? Спочатку розглянемо колишнє завдання — вибору ресурсу зі списку. 

Архітектура мережевого балансувальника навантаження в Яндекс.Хмарі

При неконсистентному хешуванні обчислюють хеш від вхідного пакета, а ресурс вибирають зі списку залишку від розподілу цього хеш на кількість ресурсів. Поки список залишається незмінним, така схема працює добре: ми завжди відправляємо пакети з однаковим 5-tuple на той самий інстанс. Якщо ж, наприклад, якийсь ресурс перестав відповідати на healthchecks, то значної частини хешів вибір зміниться. У клієнта розірвуться TCP-з'єднання: пакет, який раніше потрапляв на інстанс А, може почати потрапляти на інстанс Б, який із сесією для цього пакета не знайомий.

Консистетне хешування вирішує описану проблему. Найпростіше пояснити цю концепцію так: уявіть, що у вас є кільце, на яке ви розподіляєте ресурси по хешу (наприклад, IP:port). Вибір ресурсу - це поворот колеса на кут, який визначається хешем від пакета.

Архітектура мережевого балансувальника навантаження в Яндекс.Хмарі

Тим самим мінімізується перерозподіл трафіку за зміни складу ресурсів. Видалення ресурсу вплине тільки на ту частину кільця консистентного хешування, де знаходився даний ресурс. Додавання ресурсу теж змінює розподіл, але ми маємо вузол sticky sessions, який дозволяє не перемикати вже встановлені сесії на нові ресурси.

Ми розглянули, що відбувається з прямим трафіком між балансувальником та ресурсами. Тепер давайте розберемося із зворотним трафіком. Він слідує за такою ж схемою, як і трафік перевірок через алгоритмічний NAT, тобто через зворотний NAT 44 для клієнтського трафіку і через NAT 46 для трафіку healthchecks. Ми дотримуємося своєї схеми: уніфікуємо трафік healthchecks і реальний трафік користувачів.

Loadbalancer-node та компоненти у зборі

Про склад балансувальників та ресурсів у VPP повідомляє локальний сервіс - loadbalancer-node. Він підписується на потік подій від loadbalancer-controller, вміє будувати різницю поточного стану VPP та цільового стану, отриманого від контролера. Ми отримуємо замкнуту систему: події з API приходять на контролер балансувальника, який ставить healthcheck-контролеру завдання на перевірку «живості» ресурсів. Той, своєю чергою, ставить завдання у healthcheck-node і агрегує результати, після чого віддає їх назад контролеру балансувальників. Loadbalancer-node підписується на події від контролера та змінює стан VPP. У такій системі кожен сервіс знає лише необхідне сусідні сервіси. Кількість зв'язків обмежена, і ми маємо можливість незалежно експлуатувати та масштабувати різні сегменти.

Архітектура мережевого балансувальника навантаження в Яндекс.Хмарі

Яких питань вдалося уникнути

Всі наші сервіси в control plane написані на Go і відрізняються хорошими характеристиками масштабування та надійності. У Go є багато опенсорсних бібліотек для побудови розподілених систем. Ми активно використовуємо GRPC, всі компоненти містять опенсорсну реалізацію service discovery — наші сервіси стежать за працездатністю один одного, можуть змінювати свій склад динамічно, і ми пров'язали це з GRPC-балансуванням. Для метрик ми також використовуємо опенсорне рішення. У data plane ми отримали гідну продуктивність і великий запас ресурсів: виявилося дуже непросто зібрати стенд, на якому можна було б упертися в продуктивність VPP, а не залізної мережевої карти.

Проблеми і рішення

Що спрацювало не дуже добре? У Go управління пам'яттю автоматичне, але витоку пам'яті все ж таки бувають. Найпростіший спосіб впоратися з ними - запускати горутини і не забувати їх завершувати. Висновок: слідкуйте за використанням пам'яті Go-програм. Часто хорошим індикатором є кількість горутин. У цій історії є й плюс: у Go легко отримати дані по runtime - по споживанню пам'яті, за кількістю запущених горутин і за багатьма іншими параметрами.

Крім того, Go – можливо, не найкращий вибір для функціональних тестів. Вони досить багатослівні, і стандартний підхід запустити все в CI пачкою для них не дуже підходить. Справа в тому, що функціональні тести більш вимогливі до ресурсів, з ними виникають справжні таймаути. Через це тести можуть завершуватись неуспішно, оскільки CPU зайнятий юніт-тестами. Висновок: по можливості виконуйте важкі тести окремо від юніт-тестів. 

Мікросервісна подієва архітектура складніша за моноліт: гріпати логи на десятках різних машин не дуже зручно. Висновок: якщо робите мікросервіси, одразу думайте про трейсинг.

Наші плани

Ми запустимо внутрішній балансувальник, IPv6-балансувальник, додамо підтримку сценаріїв Kubernetes, далі шардуватимемо наші сервіси (зараз шардовані тільки healthcheck-node і healthcheck-ctrl), додамо нові healthchecks, а також реалізуємо розумну агрегацію перевірок. Ми розглядаємо можливість зробити наші сервіси ще більш незалежними, щоб вони спілкувалися не безпосередньо між собою, а за допомогою черги повідомлень. В Хмарі нещодавно з'явився SQS-сумісний сервіс Yandex Message Queue.

Нещодавно відбувся громадський реліз Yandex Load Balancer. Вивчайте документацію до сервісу, керуйте балансувальниками зручним для вас способом і підвищуйте відмовостійкість своїх проектів!

Джерело: habr.com

Додати коментар або відгук