docker run и docker-compose up ещё вчера казались достаточными. Потом пришла настоящая нагрузка. Один контейнер задыхается. Запускаешь второй, третий. Возникает вопрос, который Docker сам не закрывает: кому отдать следующий запрос и что делать, если один из них уже мёртв.
Балансировщик: прокси, который принимает входящие запросы и раскладывает их по копиям приложения.
Это не «когда-нибудь пригодится». Это ответ на 502 и простой на деплое.
Три случая, когда без него уже нельзя
Магазин. В будни 100 RPS, один контейнер жив. В пятницу вечером 800 RPS, тот же контейнер ложится. Пять копий за балансировщиком держат примерно 700 RPS, обновление идёт без окна.
Обновление без него: остановил контейнер, выкатил новый, поднял. Сайт лежит 30-60 секунд. С ним: поднял новые, проверил, переключил трафик, убил старые.
Один контейнер умер от OOM или сетевого сбоя. Без балансировщика пользователь видит 502. С ним запросы идут только на живые инстансы.
Как он решает
Проверяет здоровье бэкендов. Не отдаёт десять заказов одному, пока остальные стоят. При необходимости помнит, с кем клиент уже разговаривал.
Пользователь → Load Balancer (порт 80/443) → Backend серверы (контейнеры)
↓
Health checks каждые N секунд
↓
Маршрутизация по алгоритму
Какой алгоритм
Round robin: по кругу. Все контейнеры одинаковые, запросы примерно одной тяжести. Мобильное API, где каждый вызов стоит примерно одинаково.
Least connections: новый запрос туда, где меньше живых соединений. GET за 50 ms соседствует с POST на пять секунд.
IP hash: один IP всегда на один контейнер. Имеет смысл только если состояние ещё в памяти процесса. Это костыль. Сессии лучше унести в Redis или Postgres.
Если sticky sessions нужны, сначала спросите, нельзя ли вынести состояние.
Weighted round robin: одним контейнерам больше запросов, чем другим. Разное железо или канарейка: новая версия получает 10%, старая 90%.
Nginx, HAProxy, Traefik
Nginx: HTTP-сервер, прокси, статика, кеш, SSL. Конфиг в файлах, чтобы применить: nginx -s reload. Дефолт для большинства задач.
upstream backend {
server app1:8000;
server app2:8000;
server app3:8000;
}
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}HAProxy: 100k+ RPS на одной машине, TCP и HTTP, можно балансировать Postgres и Redis, метрики из коробки. Статику и SSL он умеет, но это не его работа. Синтаксис специфичный.
frontend http_front
bind *:80
default_backend app_servers
backend app_servers
balance roundrobin
option httpchk GET /health
server app1 app1:8000 check
server app2 app2:8000 check
server app3 app3:8000 checkTraefik: сам находит сервисы в Docker и Kubernetes, сам берёт сертификаты Let's Encrypt, конфиг через labels. Тяжелее Nginx и HAProxy, для одного сервиса избыточен.
version: "3"
services:
traefik:
image: traefik:v2.10
command:
- "--providers.docker=true"
- "--entrypoints.web.address=:80"
ports:
- "80:80"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
app:
image: myapp:latest
labels:
- "traefik.enable=true"
- "traefik.http.routers.app.rule=Host(`example.com`)"
deploy:
replicas: 3Три копии за Nginx за пять минут
version: "3.8"
services:
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- app1
- app2
- app3
app1:
image: hashicorp/http-echo
command: ["-text", "Hello from app1"]
expose:
- "5678"
app2:
image: hashicorp/http-echo
command: ["-text", "Hello from app2"]
expose:
- "5678"
app3:
image: hashicorp/http-echo
command: ["-text", "Hello from app3"]
expose:
- "5678"events {
worker_connections 1024;
}
http {
upstream backend {
server app1:5678;
server app2:5678;
server app3:5678;
}
server {
listen 80;
location / {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /health {
access_log off;
return 200 "OK\n";
add_header Content-Type text/plain;
}
}
}docker-compose up# Делаем несколько запросов
curl http://localhost
# Hello from app1
curl http://localhost
# Hello from app2
curl http://localhost
# Hello from app3
curl http://localhost
# Hello from app1 (цикл пошёл заново)Тридцать строк. Один контейнер можно убить: два других продолжат отвечать.
Health check
Балансировщик не экстрасенс. Ему нужен эндпоинт, который врёт только если приложение реально готово: база отвечает, Redis жив, критичные зависимости на месте. Голый 200 OK отправит трафик на процесс, который уже не обслуживает людей.
from fastapi import FastAPI
app = FastAPI()
@app.get("/health")
async def health():
# Здесь можно проверить БД, Redis и т.д.
return {"status": "ok"}upstream backend {
server app1:8000 max_fails=3 fail_timeout=30s;
server app2:8000 max_fails=3 fail_timeout=30s;
}backend app_servers
option httpchk GET /health
http-check expect status 200
server app1 app1:8000 check inter 5s fall 3 rise 2
server app2 app2:8000 check inter 5s fall 3 rise 2check inter 5s: раз в пять секунд. fall 3: после трёх провалов сервер мёртв. rise 2: после двух успехов снова жив.
Типичные дыры
Пользователь залогинился на app1, следующий запрос ушёл на app2, сессии там нет. Либо sticky по cookie / IP, либо (правильнее) сессии в Redis.
Приложение видит IP балансировщика, аналитика врёт.
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;Приложение должно читать X-Real-IP или X-Forwarded-For.
Сам балансировщик тоже падает. Смотрю его доступность, число живых бэкендов, latency, error rate, RPS. Prometheus и Grafana, в облаке ещё и то, что уже оплачено.
Не нужен, когда один контейнер и нагрузка ровная, когда это локальная разработка, когда балансировать нечего: один Postgres, один Redis master. Read-реплики уже другой разговор.
Что это открывает
Сейчас адреса пишутся руками: app1, app2, app3. В Kubernetes это replicas: 3, сервисы находят друг друга сами, rolling update одна команда. Те же идеи, другой масштаб.
См. также: