Перейти до вмісту

A6. Зворотний проксі й балансувальник

просунутийспирається на модуль 11, модуль 14

Перед кожним помітним сайтом стоїть зворотний проксі (reverse proxy): nginx, HAProxy, Envoy або хмарний балансувальник. Він приймає клієнтські з’єднання, вибирає бекенд, пересилає запит і повертає відповідь. Поки всі бекенди живі, завдання здається тривіальним. Справжня робота починається, коли один бекенд убито, інший завис, а третій відповідає, але віддає лише помилки. Модуль 14 пояснює, як HTTP/1.1 розмежовує запити й відповіді у відкритому з’єднанні (і чому без цього проксі не обійтися), а модуль 11 пояснює, що бачить TCP, коли процес на іншому кінці помер.

Після цієї роботи ви зможете:

  • написати HTTP/1.1-проксі, що правильно відділяє запити один від одного через Content-Length і тримає з’єднання з клієнтом відкритим;
  • реалізувати round-robin і активну перевірку здоров’я, що виключає бекенди, які лежать чи віддають помилки, і повертає їх після одужання;
  • відрізнити, коли запит безпечно повторити на іншому бекенді (GET до першого байта відповіді), а коли ні (POST);
  • поставити таймаути так, щоб завислий бекенд давав клієнтові 504, а не вічне очікування, і щоб повільний клієнт не зупиняв інших;
  • пояснити різницю між 502, 503 і 504.

Ці самі рішення приймає кожен, хто налаштовує upstream у nginx або backend-сервіс в HAProxy, а помилки в них тягнуть за собою інцидент виду «один вузол упав і поклав усе».

Написати програму lb: на C або на Python, за вибором. Чекер запускає виконуваний файл, тому для Python потрібен shebang і chmod +x. Готові проксі й HTTP-бібліотеки рівня сервера й клієнта, що роблять усю роботу за вас (http.server як основа проксі, requests, aiohttp, libcurl), заборонені: пересилання, розбір заголовків і таймаути мають бути вашими. Сокети, select, poll, epoll, потоки й розбір рядків дозволені.

Terminal window
./lb 8080 127.0.0.1:9001 127.0.0.1:9002 127.0.0.1:9003

Перший аргумент — порт, що слухає балансувальник, решта — бекенди у вигляді адреса:порт.

Що має вміти:

  • Проксі. Приймати HTTP/1.1-запити (тіло запиту — лише з Content-Length; Transfer-Encoding у запиті можна відхилити з 501), пересилати їх бекенду й повертати відповідь клієнтові байт у байт. Відповіді бекенда можуть мати Content-Length, бути chunked або закінчуватися закриттям з’єднання; 1 МіБ тіла відповіді й 300 КБ тіла запиту мають пройти цілими.
  • Заголовки. Не пересилати заголовки «від вузла до вузла» (Connection, Keep-Alive, Proxy-Connection, TE, Trailer, Transfer-Encoding, Upgrade). Додавати X-Forwarded-For з адресою клієнта (дописувати в кінець, якщо він уже є). Host лишати таким, яким його прислав клієнт.
  • З’єднання з клієнтом. Тримати його відкритим між запитами (keep-alive), закривати після відповіді, коли клієнт просив Connection: close або надіслав HTTP/1.0.
  • Розподіл. Round-robin по справних бекендах: 30 запитів на трьох бекендах дають по 10, строго по колу.
  • Здоров’я. Щонайменше раз на секунду на кожен бекенд GET /health; відповідь 200 означає «справний», усе інше (інший статус, відмова в з’єднанні, відсутність відповіді за секунду) означає «несправний». Бекенд, що лежить або нездоровий, має випасти з ротації за 4 с, а одужавши, повернутися за 5 с. Перемикання не повинно вимагати перезапуску балансувальника.
  • Повтор. Якщо до початку відповіді бекенд недоступний (відмова в з’єднанні, обрив), GET без тіла повторюється на наступному справному бекенді, і клієнт не бачить помилки. Запит із тілом (POST) автоматично не повторюється: клієнт отримує 502. Бекенд, з яким не вдалося з’єднатися або який обірвав з’єднання, одразу позначається несправним, не чекаючи наступної перевірки.
  • Таймаути. Бекенд, що не надіслав заголовків відповіді за 3 секунди, дає клієнтові 504 (допускається 502), а інші клієнти тим часом обслуговуються. Клієнт, що надіслав половину запиту й замовк, не блокує нікого.
  • Немає жодного справного бекенда. 503 за обмежений час, без падіння; коли бекенди повернулися, балансувальник сам відновлює роботу.
  • Стійкість. Сміття замість запиту, заголовок на 100 КБ, клієнт, що розірвав з’єднання посеред відповіді, не валять процес.

Чого робити не треба. HTTPS, HTTP/2, WebSocket, кешування, вагу бекендів, липкі сесії, пересилання chunked-запитів, конфігураційні файли. Підтримку keep-alive між проксі й бекендом теж не вимагає чекер: це завдання для заміру на останньому етапі.

Готово, коли:

  • ./check.sh ./lb проходить усі перевірки;
  • у звіті є відповіді на запитання з етапів 3, 5 і 6 і порівняння пропускної здатності з прямим бекендом та через балансувальник.
  • Прочитайте в модулі 14, як HTTP/1.1 визначає межі повідомлення (Content-Length, chunked, закриття з’єднання), що таке keep-alive і які заголовки не передаються через проксі. У модулі 11 подивіться, як поводиться TCP, коли інший кінець не відповідає (таймаут) і коли відмовляє (RST).
  • Розпакуйте архів курсу: чекер лежить у labs/a6-load-balancer/check.sh, тестовий бекенд у backend.py.
  • Знадобляться gcc чи python3, для вимірювань curl та ab або wrk. Права root не потрібні, усе відбувається на 127.0.0.1.
  1. Проксі для одного бекенда.

    Поки без round-robin і здоров’я. Прочитайте запит до порожнього рядка, розберіть рядок запиту й заголовки, з’єднайтеся з бекендом, перешліть запит, прочитайте заголовки відповіді й перешліть разом із тілом. Для власних дослідів запустіть тестові бекенди:

    Terminal window
    python3 backend.py 9001 a &
    python3 backend.py 9002 b &
    curl -si localhost:9001/ # прямо
    curl -si localhost:8080/ # через ваш проксі

    Бекенд віддає / (ім’я в тілі й в заголовку X-Backend), /health, /hang (мовчить 30 с), /big (1 МіБ), /headers (показує, що дійшло) і POST /upload (довжина й sha256 тіла).

  2. Межі повідомлень.

    Тіло запиту з Content-Length пересилайте рівно стільки байтів. Тіло відповіді: за Content-Length, за chunked або до закриття. Рядок запиту може прийти частинами, а разом із заголовками в буфері опиниться і початок тіла: його не можна загубити.

  3. Keep-alive з клієнтом.

    Після відповіді чекайте наступного запиту в тому самому з’єднанні. Запишіть у звіт: що станеться, якщо проксі відповість без Content-Length, не закриваючи з’єднання, і як клієнт дізнається, що відповідь закінчилась.

  4. Round-robin.

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

  5. Здоров’я й повтор.

    Окремий потік або таймер: кожні півсекунди GET /health із таймаутом. Дві невдачі підряд — бекенд поза ротацією; одна вдача — повертається. Окремо: якщо connect до бекенда не вдався, а запит безпечний, позначте його несправним і спробуйте наступного.

    Запишіть у звіт: чому одного лише повтору без перевірки здоров’я мало (подумайте про бекенд, який відповідає 500 на кожен запит) і чому одна лише перевірка без повтору теж дає помилки клієнтам (скільки запитів пролетить між падінням бекенда й наступною перевіркою).

  6. Таймаути.

    Таймаут на з’єднання з бекендом (секунда) і на очікування заголовків відповіді (три секунди). Завислий /hang має дати 504, а інші запити тим часом мають проходити. Повільний клієнт не повинен займати ні потік, який нікому не дістанеться, ні цикл подій.

    Запишіть у звіт: які таймаути ви поставили й чому саме такі, і що буде з бекендом, якщо ставити їх у десять разів більшими.

  7. Заміри (за бажанням).

    Порівняйте запити за секунду напряму до бекенда й через балансувальник (ab -n 5000 -c 50 http://127.0.0.1:8080/), а потім те саме з повторним використанням з’єднань з бекендами (keep-alive між проксі й бекендом): скільки затримки забирає кожне нове з’єднання.

Terminal window
cd labs/a6-load-balancer
./check.sh ./lb

Чекер запускає три тестові бекенди backend.py на вільних портах і ваш балансувальник перед ними, а потім проходить сценарій. Повний прогін триває близько хвилини й робить такі перевірки:

  • проксі: GET, POST на 300 КБ, відповідь на 1 МіБ (за sha256), X-Forwarded-For і Host, три запити в одному з’єднанні, Connection: close;
  • розподіл: 30 послідовних запитів по колу, 300 у десять потоків у межах ±20%;
  • повільний клієнт із половиною запиту; /hang і 504 за 3 с при тому, що решта запитів проходить;
  • падіння бекенда (SIGKILL): перші 12 запитів після цього без жодної помилки, розподіл між двома живими;
  • «хворий» бекенд, що живий, але / віддає 500, а /health — 503: виключення з ротації за 4 с; одужання й повернення за 5 с; перезапуск убитого;
  • усі бекенди вбито: 503 і живий процес; відновлення після повернення;
  • сміття замість запиту, заголовок на 100 КБ, клієнт, що пішов посеред відповіді.

Чекер не залежить від мови, моделі вводу-виводу (потоки, epoll, корутини), від того, як часто ви перевіряєте здоров’я (у межах указаних часів), від кількості з’єднань з бекендами і від того, як ви розбираєте заголовки.

Запит розбирається одним recv. Рядок запиту чи тіло приходять частинами. Читайте до \r\n\r\n, а що лишилося в буфері після заголовків, належить тілу або наступному запитові.

Пересилаються заголовки Connection і Transfer-Encoding. Вони описують одне з’єднання, а не запит. Якщо переслати Connection: close від клієнта на бекенд, бекенд закриє з’єднання, а проксі подумає, що відповідь закінчилась достроково.

Content-Length не збігається з тілом. Проксі віддає клієнту лише частину тіла, а решту клієнт прочитає як початок наступної відповіді.

Повторюється POST. Якщо бекенд встиг обробити запит і впав, не надіславши відповіді, повтор створить замовлення вдруге. Повторювати можна тільки те, що безпечно.

Здоров’я лише за TCP-з’єднанням. Бекенд може приймати з’єднання, але віддавати 500. Перевіряйте відповідь, а не сам факт з’єднання.

Вузол лишається «несправним» назавжди. Після виключення перевірка має продовжувати його опитувати, інакше одужати він не зможе.

Таймаут на весь запит замість таймауту на мовчання. Завантаження великого файла за три секунди теж закінчиться 504. Рахуйте час очікування заголовків і паузи між шматками, а не сумарну тривалість.

Один потік на все і блокуючий connect. Недоступний бекенд, що не відповідає на SYN, зупинить усіх клієнтів на час таймауту з’єднання.

Додати keep-alive між проксі й бекендом (пул з’єднань, повтор, коли з’єднання з пулу виявилось мертвим), а також ваги бекендів і алгоритм «найменше активних запитів». Порівняти з nginx у ролі балансувальника і побачити, яких можливостей йому бракує без платної версії (активна перевірка здоров’я). Додати Retry-After на 503, обмеження кількості запитів з однієї адреси й метрики у вигляді простої сторінки стану.