Зачем нужен load balancer — scalability, redundancy, health checks
Вы написали отличный HTTP-сервер на Python. Он принимает запросы, дёргает базу, возвращает JSON. Один пользователь — 50 мс на запрос, всё летает. Десять пользователей — тоже норм. Сто одновременных — сервер начинает потеть. Тысяча — падает с timeout-ами, потому что Python-процесс просто не успевает.
Что вы сделаете? Скорее всего, инстинктивный ответ — «возьмём сервер помощнее». Это называется vertical scaling (вертикальное масштабирование): больше RAM, больше CPU, быстрее диск. Подход работает, но у него есть потолок: самый мощный сервер у AWS (m7i.metal-48xl) стоит $20+ в час и упирается в физику кремния — больше 192 vCPU вы не получите.
Альтернатива — horizontal scaling: вместо одного жирного сервера запустим десять обычных. Каждый обрабатывает свою долю запросов. Если нагрузка вырастет — добавим ещё. Если упадёт — уберём лишние. Но как клиент узнает, на какой из десяти серверов идти? Вот тут и появляется load balancer — сущность, которая стоит между клиентами и пулом серверов и распределяет запросы.
Три задачи, которые решает load balancer
В production load balancer всегда делает три вещи одновременно: распределяет нагрузку, обеспечивает отказоустойчивость и проверяет здоровье backend-ов. Любая одна задача без двух других — это пол-решения.
Заметьте: клиенты вообще не подозревают о существовании backend-ов. Для них единственная видимая сущность — адрес LB. Это даёт огромную гибкость: завтра вы можете заменить все десять серверов на двадцать, написать их на Go вместо Python, переехать в другой data-center — клиенты ничего не заметят.
Задача 1: Scalability (масштабирование)
Главная видимая фича LB — это распределение нагрузки. Если у вас десять серверов и тысяча запросов в секунду, каждому достанется около 100 RPS. Сервер с такой нагрузкой не страдает.
Важно понимать: LB сам по себе не делает ваше приложение быстрее. Один запрос всё равно обрабатывается одним backend-ом за те же 50 мс. LB меняет другое — пропускную способность системы в целом (throughput). Если один сервер тянет 200 RPS, то десять серверов за LB тянут ~2000 RPS, при условии что bottleneck не в общей БД или другой shared-ресурсе.
Один сервер: 1000 req/s -> 50% запросов timeout-ят, latency p99 = 8000ms
Один сервер: 200 req/s -> 0% timeout, latency p99 = 80ms (нормально)
С LB + 5 backend-ов: 1000 req/s -> по 200 каждому -> 0% timeout, p99 = 80ms
Это horizontal scaling, и его главное преимущество перед vertical — линейность. Чтобы удвоить throughput, удваиваем число backend-ов. Чтобы утроить — утраиваем. С vertical scaling вам пришлось бы покупать новый, в два раза более мощный сервер — и в какой-то момент таких просто не существует.
Linearity ломается, когда bottleneck не в backend-ах, а в общем ресурсе. Если все 10 серверов ходят в одну Postgres и БД захлёбывается — увеличение числа application-серверов проблему не решит. LB снимает нагрузку только с application tier. Дальше думаем про read replicas, sharding, кэширование — но это уже не про LB.
Задача 2: Redundancy (отказоустойчивость)
Второй мотив для LB — избавиться от single point of failure. Если у вас один сервер, и он упал в три часа ночи — весь сервис недоступен, пока вы его не поднимете. Это плохо: ваши пользователи злятся, SLA горит, дежурный инженер выгорает.
С LB и пулом серверов отказ одного backend-а — не катастрофа. LB просто перестанет на него отправлять трафик, а остальные подхватят. При этом мощность пула должна быть рассчитана с запасом (N+1 redundancy: если нужно 9 серверов для нагрузки, держим 10 — один может упасть без деградации).
Кроме планового отказа, LB защищает от менее очевидных проблем:
- Rolling deployment. Вы выкатываете новую версию приложения. Если в ней баг — упадёт не весь пул сразу, а тот инстанс, на который вы её только что задеплоили. LB его выкинет, остальные продолжат работать, дежурный спокойно откатит.
- Тяжёлый запрос. Один клиент прислал запрос, который на 5 минут занял Python-воркер. С одним сервером новые запросы стояли бы в очереди. С пулом из 10 серверов остальные 9 продолжают обслуживать как обычно.
- Memory leak. Один из инстансов медленно течёт по памяти, OOM-killer прибивает его. LB замечает, выкидывает, supervisord перезапускает процесс — клиенты ничего не видят.
Задача 3: Health checks (проверка здоровья)
Чтобы LB знал, какой backend живой, а какой нет, он периодически делает health check — проверочный запрос. Это не магия, а обычный HTTP-запрос на специальный endpoint (обычно /health или /healthz), который backend должен реализовать.
Простой health check на Python (FastAPI):
from fastapi import FastAPI
app = FastAPI()
@app.get('/healthz')
def healthz():
return {'status': 'ok'}
LB настраивается так: каждые 5 секунд стучаться на /healthz. Если 3 подряд проверки failed — помечаем backend unhealthy. Если 2 подряд OK — возвращаем в ротацию.
Есть два уровня health check:
- Liveness (живой ли процесс). Самый простой — если HTTP-сервер вообще отвечает, значит процесс живой. Тупо возвращаем 200 OK без всякой логики. Если процесс мёртв или зависший — TCP-connect упадёт или будет timeout.
- Readiness (готов ли обслуживать запросы). Более глубокий — проверяем, что backend подключён к БД, что миграции прошли, что внешние зависимости доступны. Если БД упала — backend жив, но запросы обслужить не сможет, поэтому из ротации лучше убрать.
import psycopg2
@app.get('/readyz')
def readyz():
try:
# Проверяем, что БД доступна
conn = psycopg2.connect(DSN)
conn.cursor().execute('SELECT 1')
conn.close()
return {'status': 'ready'}
except Exception as e:
# 503 говорит LB: не шли мне трафик, я не готов
return Response(status_code=503, content=f'db down: {e}')
Не делайте readiness check слишком тяжёлым. Если он делает запрос в БД — это лишняя нагрузка каждые 5 секунд от каждого LB. С десятью LB и пулом из 50 инстансов — это 5000 запросов в секунду на БД только из-за health check. Лучше кэшировать результат на 1-2 секунды или проверять простой ping.
Где живёт LB в реальности
LB бывают двух типов: hardware и software. Hardware LB (F5 BIG-IP, Citrix NetScaler) — это физические железки, которые стоят в дата-центре и обрабатывают трафик на уровне специализированных чипов. Дорого, надёжно, исторически используется в банках и telecom.
Software LB — это программы на обычных серверах. Самые популярные: NGINX, HAProxy, Envoy, Traefik. Для большинства задач — именно их вы и будете использовать. Они достаточно быстрые (один NGINX легко тянет 50000 RPS на одном CPU), бесплатные и гибкие.
В облаках есть managed LB-сервисы:
- AWS: Application Load Balancer (ALB, L7), Network Load Balancer (NLB, L4), Classic Load Balancer (legacy).
- GCP: Cloud Load Balancing — глобальный (anycast IP), регионный, internal.
- Cloudflare: Load Balancing — умеет geo-routing, failover между регионами.
Их вы платите по запросам/трафику, не настраиваете сервера, не следите за ними сами. На сервисах побольше всё равно используют свой NGINX/Envoy за облачным LB — слой за слоем.
Internet
|
v
Cloudflare (DDoS, WAF, кэширование)
|
v
AWS ALB (TLS termination, маршрутизация по path)
|
v
NGINX в Kubernetes (rate limiting, internal routing)
|
v
Application pods
Это типичная архитектура для production. На каждом уровне LB решает свои задачи.
DNS round-robin — LB бедного человека
Прежде чем появились нормальные LB, люди делали load balancing через DNS. Идея простая: в DNS-записи api.example.com указываете несколько IP-адресов, и резолвер каждый раз отдаёт их в разном порядке.
dig api.example.com +short
# 10.0.0.1
# 10.0.0.2
# 10.0.0.3
# Через 30 секунд:
dig api.example.com +short
# 10.0.0.2
# 10.0.0.3
# 10.0.0.1
Клиент берёт первый IP в списке и идёт туда. Получается распределение. Но у этого подхода куча проблем:
- DNS кэшируется. ОС, браузер, локальный резолвер — все кэшируют ответ на TTL (часто 60+ секунд). Распределение получается неравномерное.
- Нет health check. Если один из IP мёртв, DNS об этом не знает. Клиент будет получать его как ответ и падать с timeout-ами.
- Нет TLS termination. Каждый сервер сам должен иметь сертификат и принимать HTTPS.
- Нет sticky sessions. Если приложение хранит state в памяти — следующий запрос пользователя может попасть на другой сервер, и сессия потеряется.
Несмотря на это, DNS round-robin до сих пор используют — но не как основной LB, а как глобальное распределение между регионами. Например, у GitHub api.github.com резолвится в разные IP в зависимости от региона — это DNS-based geo-routing.
Что важно запомнить
Load balancer — это не «волшебная штука для масштабирования», а конкретное решение трёх задач:
- Scalability: распределить нагрузку по N серверам, чтобы каждому досталось 1/N.
- Redundancy: если один сервер упал — остальные продолжают работать. Single point of failure ликвидирован.
- Health checks: автоматически детектить, какие backend-ы живы, и роутить трафик только на здоровые.
Без всех трёх — это не LB, а пол-LB. DNS round-robin делает только первую задачу, и поэтому в production его одного недостаточно.
В следующих уроках разберём, чем отличаются LB на уровне L4 (transport, работает с TCP/UDP) и L7 (application, понимает HTTP), как настраивается reverse proxy на примере NGINX, и какие бывают алгоритмы выбора backend-а (round-robin — лишь один из многих).
Попробуй сам
Давайте посмотрим, как выглядит распределение нагрузки руками. Запустим два простых HTTP-сервера на разных портах и накидаем им запросов.
# Терминал 1: backend на порту 8001
python3 -m http.server 8001
# Терминал 2: backend на порту 8002
python3 -m http.server 8002
# Терминал 3: имитируем round-robin через скрипт
for i in {1..10}; do
if (( i % 2 == 0 )); then
curl -s http://localhost:8001/ -w 'backend=8001 status=%{http_code}\n' -o /dev/null
else
curl -s http://localhost:8002/ -w 'backend=8002 status=%{http_code}\n' -o /dev/null
fi
done
А теперь сделаем настоящий LB. Установите NGINX (на macOS — brew install nginx, на Ubuntu — apt install nginx) и создайте простой конфиг:
http {
upstream backend_pool {
server 127.0.0.1:8001;
server 127.0.0.1:8002;
}
server {
listen 8080;
location / {
proxy_pass http://backend_pool;
}
}
}
events {}
Запустите NGINX с этим конфигом и стрельните 10 раз в localhost:8080:
nginx -c /path/to/your/nginx.conf
for i in {1..10}; do
curl -s http://localhost:8080/ -o /dev/null -w 'request %{response_code}\n'
done
# Посмотрите логи: nginx будет чередовать backend-ы автоматически
tail -f /usr/local/var/log/nginx/access.log
Теперь убейте один из backend-ов (Ctrl+C в терминале 1 или 2) и сделайте ещё 10 запросов. Заметите: NGINX поймёт, что один upstream мёртв, и перестанет отправлять на него запросы. Это и есть базовый health check «по факту» — NGINX видит connection refused и временно выкидывает backend.