Перейти к содержимому

Балансировщик после Docker

Константин Потапов
12 мин

Второй контейнер уже запущен. Кто получит следующий запрос и что будет, если один упадёт.

docker run и docker-compose up ещё вчера казались достаточными. Потом пришла настоящая нагрузка. Один контейнер задыхается. Запускаешь второй, третий. Возникает вопрос, который Docker сам не закрывает: кому отдать следующий запрос и что делать, если один из них уже мёртв.

Балансировщик: прокси, который принимает входящие запросы и раскладывает их по копиям приложения.

Это не «когда-нибудь пригодится». Это ответ на 502 и простой на деплое.

Три случая, когда без него уже нельзя

Магазин. В будни 100 RPS, один контейнер жив. В пятницу вечером 800 RPS, тот же контейнер ложится. Пять копий за балансировщиком держат примерно 700 RPS, обновление идёт без окна.

Без балансировщика
С балансировщиком
Инстансов
1 контейнер
5 контейнеров
400%
Max RPS
~150 (потом падает)
~700 (линейно)
367%
Простой при обновлении
Да, 30-60 сек
Нет (rolling update)

Обновление без него: остановил контейнер, выкатил новый, поднял. Сайт лежит 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. Дефолт для большинства задач.

NginxDockerLet's Encrypt
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 check

Traefik: сам находит сервисы в 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 2

check 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 одна команда. Те же идеи, другой масштаб.

См. также:

Похожие материалы

·10 мин

ИИ и 152-ФЗ: инженерный разбор вместо юридического

Про LLM и персональные данные пишут юристы и облачные провайдеры. Разбираю то, о чём они не пишут: три архитектурные развилки, обезличивание, которое не делается регулярками, и персональные данные, оседающие в логах вокруг модели.

·11 мин

Демо работало, прод не поехал: что ломается между пилотом ИИ и рабочей системой

Исследование MIT NANDA: 95% корпоративных ИИ-пилотов не дают измеримого эффекта. Разбираю инженерную половину проблемы — шесть вещей, которых нет в демо и без которых контур не живёт дольше трёх недель.

·9 мин

Сколько стоит внедрение ИИ: разбор счёта по пяти статьям

Токены — самая маленькая строка в счёте, и именно её все считают. Разбираю пять статей расходов на реальных цифрах: от 0,065 ₽ за тысячу токенов до пяти миллионов за свою модель, и почему подрядчики не называют цену.