ProHoster > Блог > адміністрування > Балансування навантаження та масштабування довготривалих з'єднань у Kubernetes
Балансування навантаження та масштабування довготривалих з'єднань у Kubernetes
Ця стаття, яка допоможе розібратися в тому, як влаштовано балансування навантаження в Kubernetes, що відбувається при масштабуванні довготривалих з'єднань і чому варто розглядати балансування на стороні клієнта, якщо ви використовуєте HTTP/2, gRPC, RSockets, AMQP або інші довготривалі протоколи.
Трохи про те, як перерозподіляється трафік у Kubernetes
Kubernetes надає дві зручні абстракції для викочування додатків: сервіси (Services) та розгортання (Deployments).
Розгортання описують, яким чином і скільки копій вашої програми має бути запущено будь-якої миті часу. Кожна програма розгортається як під (Pod) і йому призначається IP-адреса.
Сервіси з функцій схожі на балансувальник навантаження. Вони призначені для розподілу трафіку по безлічі подів.
Подивимося, як це виглядає.
На діаграмі нижче ви бачите три екземпляри однієї програми та балансувальник навантаження:
Балансувальник навантаження називається сервіс (Service), йому надано IP-адресу. Будь-який вхідний запит перенаправляється до одного з подів:
Сценарій розгортання визначає кількість екземплярів програми. Вам практично ніколи не доведеться розвертати безпосередньо під:
Кожному поду присвоюється свою IP-адресу:
Корисно розглядати послуги як набір IP-адрес. Щоразу, коли ви звертаєтеся до сервісу, одна з IP-адрес вибирається зі списку та використовується як адреса призначення.
Це виглядає так.
Надходить запит curl 10.96.45.152 до сервісу:
Сервіс обирає одну з трьох адрес подів як пункт призначення:
Трафік перенаправляється до конкретного поду:
Якщо ваш додаток складається з фронтенду та бекенду, то у вас буде і сервіс, і розгортання для кожного.
Коли фронтенд виконує запит до бекенду, йому не потрібно знати, скільки саме подов обслуговує бекенд: їх може бути і один, і десять, і сто.
Також фронтенд нічого не знає про адреси подів, які обслуговують бекенд.
Коли фронтенд виконує запит до бекенду, він використовує IP-адресу сервісу бекенда, який не змінюється.
Ось як це виглядає.
Під 1 запитує внутрішній компонент бекенда. Замість того, щоб вибрати конкретний під бекенда, він виконує запит до сервісу:
Сервіс вибирає один з подів бекенда як адреса призначення:
Трафік йде від пода 1 до пода 5, обраному сервісом:
Під 1 не знає, скільки саме таких подів, як під 5, заховано за сервісом:
Але як саме сервіс розподіляє запити? Начебто використовується балансування round-robin? Давайте розумітися.
Балансування у сервісах Kubernetes
Сервісів Kubernetes немає. Для сервісу не існує процесу, якому виділено IP-адресу та порт.
Ви можете переконатись у цьому, зайшовши на будь-яку ноду кластера та виконавши команду netstat -ntlp.
Ви навіть не зможете знайти IP-адресу, виділену сервісу.
IP-адреса сервісу розміщена в шарі управління, в контролері, і записана в базу даних - etcd. Ця ж адреса використовується ще одним компонентом – kube-proxy.
Kube-proxy отримує список IP-адрес для всіх сервісів і формує набір правил iptables на кожній ноді кластера.
Ці правила кажуть: «Якщо ми бачимо IP-адресу сервісу, потрібно модифікувати адресу призначення запиту та відправити його на один із подів».
IP-адреса сервісу використовується тільки як точка входу і не обслуговується будь-яким процесом, що слухає цю ip-адресу та порт.
Погляньмо на це.
Розглянемо кластер із трьох нід. На кожній ноді присутні поди:
Пов'язані поди, забарвлені бежевим кольором, - це частина сервісу. Оскільки сервіс не існує як процес, він зображений сірим кольором:
Перший під запитує сервіс і повинен потрапити на один із пов'язаних подів:
Але сервіс не існує, процесу немає. Як це працює?
Перед тим, як запит покине ноду, він проходить через правила iptables:
Правила iptables знають, що сервісу немає, і замінюють його IP-адресу однією з IP-адрес подов, пов'язаних з цим сервісом:
Запит отримує діючу IP-адресу як адресу призначення та нормально обробляється:
Залежно від мережевої топології, запит у результаті досягає пода:
Чи вміють iptables балансувати навантаження?
Ні, iptables використовуються для фільтрації та не проектувалися для балансування.
Однак є можливість написати набір правил, які працюють як псевдобалансер.
І саме це реалізовано у Kubernetes.
Якщо у вас є три поди, kube-proxy напише наступні правила:
Вибрати перший з ймовірністю 33%, інакше перейти до наступного правила.
Вибрати другий під ймовірністю 50%, інакше перейти до наступного правила.
Вибрати третій під.
Така система призводить до того, що кожен вибирається з ймовірністю 33%.
І немає жодної гарантії, що під 2 буде обрано наступним після пода 1.
Примітка: iptables використовує статистичний модуль із випадковим розподілом. Отже, алгоритм балансування виходить з випадковому виборі.
Тепер, коли ви розумієте, як працюють сервіси, погляньмо на більш цікаві сценарії роботи.
Довгоживучі з'єднання в Kubernetes не масштабуються за умовчанням
Кожен HTTP-запит від фронтенду до бекенду обслуговується окремим TCP-з'єднанням, яке відкривається та закривається.
Якщо фронтенд надсилає 100 запитів на секунду бекенду, то відкривається та закривається 100 різних TCP-з'єднань.
Можна зменшити час обробки запиту та знизити навантаження, якщо відкрити одне TCP-з'єднання та використовувати його для всіх наступних HTTP-запитів.
У HTTP-протокол закладена можливість, звана HTTP keep-alive, або повторне використання з'єднання. У цьому випадку одне TCP-з'єднання використовується для надсилання та отримання безлічі HTTP-запитів та відповідей:
Ця можливість не ввімкнена за замовчуванням: і сервер, і клієнт повинні бути налаштовані відповідним чином.
Саме по собі налаштування просте і доступне для більшості мов програмування та середовищ.
Що станеться, якщо ми будемо використовувати keep-alive у сервісі Kubernetes?
Давайте вважатимемо, що і фронтенд, і бекенд підтримують keep-alive.
У нас одна копія фронтенду та три екземпляри бекенди. Фронтенд робить перший запит та відкриває TCP-з'єднання до бекенду. Запит досягає сервісу, одне з подов бекенда вибирається як адресу призначення. Під бекенда відправляє відповідь, і фронтенд її отримує.
На відміну від звичайної ситуації, коли після отримання відповіді TCP-з'єднання закривається, зараз воно підтримується відкритим для наступних запитів HTTP.
Що станеться, якщо фронтенд надішле ще запити на бекенд?
Для пересилання цих запитів буде задіяно відкрите TCP-з'єднання, всі запити потраплять на той самий під бекенда, куди потрапив перший запит.
Хіба iptables не повинен перерозподілити трафік?
Чи не в цьому випадку.
Коли створюється TCP-з'єднання, воно проходить через правила iptables, які і вибирають конкретний під бекенда, куди потрапить трафік.
Оскільки всі наступні запити йдуть по вже відкритому з'єднанню TCP, правила iptables більше не викликаються.
Подивимося, як це виглядає.
Перший під надсилає запит до сервісу:
Ви вже знаєте, що буде далі. Сервісу не існує, але є правила iptables, які оброблять запит:
Один з подів бекенда буде обраний як адреса призначення:
Запит досягає пода. У цей момент постійне TCP-з'єднання між двома подами буде встановлено:
Будь-який наступний запит від першого пода буде йти по вже встановленому з'єднанню:
В результаті ви отримали швидший відгук і більш високу пропускну здатність, але втратили можливість масштабування бекенда.
Навіть якщо у вас у бекенді два поди, при постійному з'єднанні трафік весь час потраплятиме на один із них.
Чи можна це виправити?
Оскільки Kubernetes не знає, як балансувати постійні з'єднання, це завдання покладається на вас.
Сервіси — це набір IP-адрес та портів, які називають кінцевими точками.
Ваша програма може отримати список кінцевих точок з сервісу і вирішити, як розподіляти запити між ними. Можна відкрити постійне з'єднання з кожним подом і балансувати запити між цими з'єднаннями за допомогою round-robin.
Код на стороні клієнта, який відповідає за балансування, повинен дотримуватися такої логіки:
Отримати список кінцевих точок із сервісу.
Для кожної кінцевої точки відкрити постійне з'єднання.
Коли потрібно зробити запит, використовувати одне з відкритих з'єднань.
Регулярно оновлювати список кінцевих точок, створювати нові або закривати старі постійні з'єднання у разі зміни списку.
Ось як це виглядатиме.
Замість того, щоб перший під відправляв запит у сервіс, ви можете балансувати запити на стороні клієнта:
Потрібно написати код, який запитує, які поди є частиною сервісу:
Як тільки отримаєте список, збережіть його на стороні клієнта та використовуйте для з'єднання з подами:
Ви самі відповідаєте за алгоритм балансування навантаження:
Тепер постало питання: чи ця проблема стосується лише HTTP keep-alive?
Балансування навантаження на стороні клієнта
HTTP — це не єдиний протокол, який може використовувати постійні з'єднання TCP.
Якщо ваш додаток використовує базу даних, то TCP-з'єднання не відкривається щоразу, коли вам потрібно виловити запит або отримати документ з бази даних.
Натомість відкривається та використовується постійне TCP-з'єднання до бази даних.
Якщо база даних розгорнута в Kubernetes і доступ надається у вигляді сервісу, то ви зіткнетеся з тими ж проблемами, що описані в попередньому розділі.
Одна репліка бази даних буде навантажена більше, ніж інші. Kube-proxy та Kubernetes не допоможуть балансувати з'єднання. Ви повинні подбати про балансування запитів до бази даних.
Залежно від того, яку бібліотеку ви використовуєте для підключення до бази даних, у вас можуть бути різні варіанти вирішення цієї проблеми.
Нижче наведено приклад доступу до кластера БД MySQL з Node.js:
var mysql = require('mysql');
var poolCluster = mysql.createPoolCluster();
var endpoints = /* retrieve endpoints from the Service */
for (var [index, endpoint] of endpoints) {
poolCluster.add(`mysql-replica-${index}`, endpoint);
}
// Make queries to the clustered MySQL database
Існує безліч інших протоколів, що використовують постійні TCP-з'єднання:
WebSockets and secured WebSockets
HTTP / 2
gRPC
RSockets
AMQP
Ви повинні бути вже знайомі з більшістю цих протоколів.
Але якщо ці протоколи такі популярні, чому немає стандартизованого рішення для балансування? Чому потрібна зміна логіки клієнта? Чи існує нативне рішення Kubernetes?
Kube-proxy та iptables створені, щоб закрити більшість стандартних сценаріїв використання при розгортанні Kubernetes. Це зроблено для зручності.
Якщо ви використовуєте веб-сервіс, який надає REST API, вам пощастило — у цьому випадку постійні з'єднання не використовуються, ви можете використовувати будь-який сервіс Kubernetes.
Але як тільки ви почнете використовувати постійні TCP-з'єднання, доведеться розбиратися, як рівномірно розподілити навантаження на бекенди. Kubernetes не містить готових рішень на цей випадок.
Однак, звичайно, існують варіанти, які можуть допомогти.
Балансування довготривалих з'єднань у Kubernetes
У Kubernetes існує чотири типи сервісів:
ClusterIP
Порт вузла
LoadBalancer
Безголовий
Перші три сервіси працюють на базі віртуальної IP-адреси, яка використовується kube-proxy для побудови правил iptables. Але фундаментальна основа всіх сервісів це сервіс типу headless.
З сервісом headless не пов'язана жодна IP-адреса і вона тільки надає механізм отримання списку IP-адрес і портів пов'язаних з ним подів (кінцеві точки).
Всі послуги базуються на сервісі headless.
Сервіс ClusterIP - це headless сервіс з деякими доповненнями:
Шар управління призначає IP-адресу.
Kube-proxy формує необхідні правила iptables.
Таким чином, ви можете ігнорувати kube-proxy і безпосередньо використовувати список кінцевих точок, отриманих із сервісу headless для балансування навантаження у вашому додатку.
Але як додати подібну логіку до всіх програм, розгорнутих у кластері?
Якщо ваш додаток вже розгорнутий, то таке завдання може здатися нездійсненним. Проте є альтернативний варіант.
Service Mesh вам допоможе
Ви, напевно, вже помітили, що стратегія балансування навантаження на стороні клієнта є цілком стандартною.
Коли програма стартує, вона:
Отримує список IP-адрес із сервісу.
Відкриває та підтримує пул з'єднань.
Періодично оновлює пул, додаючи або забираючи кінцеві точки.
Як тільки додаток хоче зробити запит, він:
Вибирає доступне з'єднання, використовуючи будь-яку логіку (наприклад, round-robin).
Виконує запит.
Ці кроки працюють і для з'єднань WebSockets, і для gRPC, і AMQP.
Ви можете виділити цю логіку в окрему бібліотеку та використовувати її у ваших програмах.
Однак замість цього можна використовувати сервісні сітки, наприклад Istio або Linkerd.
Service Mesh доповнює вашу програму процесом, який:
Автоматично шукає IP-адреси сервісів.
Перевіряє з'єднання, такі як WebSockets та gRPC.
Балансує запити, використовуючи правильний протокол.
Service Mesh допомагає керувати трафіком усередині кластера, але він досить ресурсомісткий. Інші варіанти - це використання сторонніх бібліотек, наприклад Netflix Ribbon, або програмованих проксі, наприклад Envoy.
Що буде, якщо ігнорувати питання балансування?
Ви можете не використовувати балансування навантаження і при цьому не помітити жодних змін. Погляньмо на кілька сценаріїв роботи.
Якщо у вас більше клієнтів, аніж серверів, це не така велика проблема.
Припустимо, є п'ять клієнтів, які коннектяться до двох серверів. Навіть якщо немає балансування, обидва сервери використовуватимуться:
З'єднання можуть бути розподілені нерівномірно: можливо, чотири клієнти підключилися до одного і того ж серверу, але є хороший шанс, що обидва сервери будуть використані.
Що більш проблематично, то це протилежний сценарій.
Якщо у вас менше клієнтів і більше серверів, ваші ресурси можуть недостатньо використовуватись, і з'явиться потенційне вузьке місце.
Припустимо, є два клієнти та п'ять серверів. У найкращому разі буде два постійних з'єднання до двох серверів із п'яти.
Інші сервери простоюватимуть:
Якщо ці два сервери не можуть впоратися з обробкою запитів клієнтів, горизонтальне масштабування не допоможе.
Висновок
Сервіси Kubernetes створені для роботи у більшості стандартних сценаріїв веб-додатків.
Однак, як тільки ви починаєте працювати з протоколами додатків, які використовують постійні з'єднання TCP, такі як бази даних, gRPC або WebSockets, сервіси не підходять. Kubernetes не надає внутрішніх механізмів балансування постійних TCP-з'єднань.
Це означає, що ви повинні писати програми з урахуванням можливості балансування на стороні клієнта.