Legan Studio
Все статьи
~ 15 мин чтения

Масштабирование бота в MAX под высокую нагрузку

Как масштабировать бота MAX под десятки тысяч одновременных пользователей: горизонтальное масштабирование, очереди, кеши, шардирование БД.

  • MAX
  • архитектура
  • производительность

Бот, который писался под 1000 пользователей в день, не выдерживает 100 000. Архитектура должна быть готова к росту с самого начала, иначе на пиках придётся переписывать заново. Разберём, как масштабировать бота MAX без боли — со стадиями роста, конкретными конфигами и паттернами, которые мы используем в продакшене.

MAX как мессенджер VK Tech даёт стандартный API с polling и webhook, но физика та же, что у любого другого бота: принять апдейт, обработать, ответить. Все принципы масштабирования, наработанные в индустрии, применимы один в один — с поправкой на то, что MAX живёт в одном регионе (РФ), а значит инфраструктуру тоже разумно держать рядом.

Стадии роста бота MAX

Не все боты должны быть K8s-кластером с микросервисами с первого дня. Архитектура должна соответствовать масштабу — иначе вы платите за сложность, которая не нужна. Грубая шкала по нашему опыту:

СтадияАудиторияИнфраструктураАрхитектура
MVPдо 100 MAU1 VPS (1 vCPU, 1 ГБ)polling, SQLite
Ростдо 10k MAU1 VPS (2 vCPU, 4 ГБ) + Rediswebhook, Postgres
Зрелостьдо 100k MAU2-3 VPS + LB + managed Redis/PGhorizontal scaling
Масштабдо 1M MAUK8s, replicas, очередиshared state, async
Highload1M+ MAUМикросервисы, шардированиеevent-driven

Цифры условные — много зависит от тяжести логики. Простой FAQ-бот на одной VPS вытягивает и 50k MAU; бот с LLM-обработкой каждого сообщения упирается уже на 5k. Но порядок такой.

Polling vs webhook: первый архитектурный выбор

На MVP polling удобен — не нужен HTTPS, домен, сертификат. Поднял процесс на VPS и работает. Но polling не масштабируется горизонтально: если запустить два инстанса с одним токеном, они начнут конкурировать за апдейты, дубли гарантированы.

Webhook — обязательное условие horizontal scaling. MAX отправляет POST на ваш URL, балансировщик распределяет по инстансам, каждый обрабатывает свою часть. Мигрировать с polling на webhook стоит до первой волны нагрузки, не во время.

upstream max_bot {
    least_conn;
    server bot1.internal:8080 max_fails=3 fail_timeout=30s;
    server bot2.internal:8080 max_fails=3 fail_timeout=30s;
    server bot3.internal:8080 max_fails=3 fail_timeout=30s;
    keepalive 64;
}

server {
    listen 443 ssl http2;
    server_name bot.example.ru;

    location /webhook {
        proxy_pass http://max_bot;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_read_timeout 30s;
        client_max_body_size 20M;
    }
}

Sticky sessions включать не нужно — бот должен быть stateless, любой инстанс должен уметь обработать любой апдейт. least_conn лучше round_robin, потому что распределяет нагрузку по фактической занятости.

Горизонтальное масштабирование webhook

Условия, без которых масштабирование не работает:

  • Приложение stateless — никакого состояния в памяти процесса.
  • FSM-состояние — в Redis или Postgres, не в локальной памяти.
  • Сессии — по chat_id, без привязки к инстансу.
  • Деплой через Docker, оркестрация — Compose или Kubernetes.
  • Конфиг и секреты — через env, не через файлы на конкретной машине.

При такой архитектуре добавить пятый инстанс — копия Docker-контейнера за тем же Nginx. Webhook масштабируется линейно до десятков RPS на инстанс.

Идемпотентность обработки апдейтов

При нескольких инстансах и retry на стороне MAX один и тот же update_id может прийти дважды (сетевая ошибка, таймаут, рестарт пода). Если обработчик не идемпотентен, пользователь получит дубль ответа, дубль платежа, дубль записи в CRM.

Простой паттерн — дедупликация через Redis с TTL:

async def handle_update(update: dict) -> None:
    update_id = update["update_id"]
    key = f"max:update:{update_id}"
    # SET NX — атомарно, гарантирует exactly-once в окне TTL
    is_new = await redis.set(key, "1", ex=3600, nx=True)
    if not is_new:
        return  # уже обработали
    await process(update)

Для критичных операций (платежи, изменение баланса) — дополнительно идемпотентность на уровне БД через уникальные индексы по бизнес-ключу.

Состояние во внешнем хранилище

Ноль состояния в памяти процесса — главное правило stateless-архитектуры. Раскладка по хранилищам:

  • Redis — FSM-состояние диалога, краткосрочные сессии, кеш, rate-limit-счётчики, очереди задач.
  • PostgreSQL — бизнес-сущности (пользователи, заказы, события), долговременные данные, аналитика.
  • S3 / Yandex Object Storage — файлы пользователей, медиа, сгенерированные документы, бэкапы.

Если процесс перезагрузится, любой другой инстанс должен подхватить разговор с того же места. Это и есть критерий правильной архитектуры: убил под, диалог продолжается.

Очереди и фоновая обработка

Тяжёлые задачи (рассылки, LLM-обработка, экспорт отчётов, интеграции с медленными CRM) выносятся в воркеры через очередь. Webhook принимает апдейт, кладёт задачу, сразу отвечает 200 — бот реактивный, MAX счастлив. Воркер обрабатывает в фоне.

Варианты брокеров:

  • Redis Streams / RQ — простой и быстрый, хватает для большинства случаев.
  • Celery + Redis — классика Python-мира, много готовых паттернов.
  • arq — современный async-Celery, дружит с asyncio.
  • RabbitMQ — для проектов с разнообразными типами задач, routing keys, приоритеты.
  • NATS JetStream / Kafka — для high-throughput и event-driven архитектур.

Пример Celery-задачи для рассылки:

from celery import Celery

app = Celery("max_bot", broker="redis://redis:6379/1")

@app.task(
    bind=True,
    autoretry_for=(ConnectionError, TimeoutError),
    retry_backoff=True,
    retry_backoff_max=600,
    retry_jitter=True,
    max_retries=5,
)
def send_broadcast_chunk(self, chat_ids: list[int], text: str) -> dict:
    sent, failed = 0, 0
    for chat_id in chat_ids:
        try:
            max_api.send_message(chat_id=chat_id, text=text)
            sent += 1
        except RateLimitError as exc:
            raise self.retry(exc=exc, countdown=exc.retry_after)
        except UserBlockedError:
            failed += 1
    return {"sent": sent, "failed": failed}

Webhook просто публикует задачу и возвращает 200 пользователю — никаких блокировок на медленных операциях.

Балансировщик нагрузки

Перед инстансами бота ставится L7-балансер. Варианты:

  • Nginx — самый универсальный, дешёвый, гибкий. Подходит для большинства случаев.
  • HAProxy — когда нужно много connection-ориентированной логики.
  • Cloudflare — даёт WAF и DDoS-защиту, но трафик идёт через зарубежные точки (для MAX в РФ — учитывайте задержку и регуляторные риски).
  • Yandex Application Load Balancer — managed, нативно интегрирован с Cloud DNS и Compute.

Sticky sessions включать не нужно: бот stateless, любой инстанс обрабатывает любой chat_id. Health-check эндпоинт /healthz обязателен — балансер исключит мёртвый инстанс из ротации.

PostgreSQL: где затыкается и как лечить

Postgres держит десятки тысяч RPS, если правильно его готовить:

  • Индексы на горячие поля — chat_id, status, created_at. Без них таблица в 10 млн записей становится медленной за неделю.
  • Партиционирование больших таблиц по дате (события, сообщения, логи). PostgreSQL поддерживает декларативное партиционирование с PG10+.
  • Шардирование по user_id — когда уже не помещается в одну машину.
  • Connection pooling через PgBouncer — иначе соединения закончатся быстрее, чем нагрузка.
  • Read-replica для аналитических запросов и дашбордов.
  • VACUUM / ANALYZE — настраиваются под нагрузку, иначе bloat съест производительность.

PgBouncer-конфиг под высокую нагрузку:

[databases]
maxbot = host=pg-primary.internal port=5432 dbname=maxbot

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt

pool_mode = transaction
max_client_conn = 2000
default_pool_size = 50
reserve_pool_size = 10
reserve_pool_timeout = 3
server_idle_timeout = 600
server_lifetime = 3600
query_wait_timeout = 30

ignore_startup_parameters = extra_float_digits

pool_mode = transaction критичен: он позволяет 2000 клиентских соединений мультиплексировать через 50 фактических соединений к Postgres. Бот видит «много коннектов», а БД — реальную нагрузку.

Кеши и оптимизация Redis

Redis в боте обычно делает три задачи: FSM, кеш данных, очередь. При росте нагрузки:

  • Разделяем по назначению — отдельные базы или отдельные инстансы для FSM, кеша, очередей. Упрощает мониторинг и снижает риск каскадного отказа.
  • TTL обязательны — всё, что может протухнуть, должно иметь срок жизни.
  • Размер значений — не пихайте огромные JSON. Много мелких ключей лучше, чем мало больших.
  • Read-through кеш — единый паттерн доступа: «попробовать Redis, при miss — Postgres + положить в Redis».
  • Кеш ответов LLM — по хешу промпта, экономит огромные деньги.
  • Redis Cluster / Sentinel — при необходимости горизонтального масштабирования и HA.

Часто узкое место — не сам Redis, а сетевые задержки между приложением и Redis. Размещайте их в одном дата-центре, желательно в одной зоне.

Rate limiting на разных уровнях

Защита от перегрузок строится в несколько слоёв:

  1. Nginx — ограничение по IP на уровне фронта (limit_req_zone).
  2. Application — лимиты по chat_id (в Redis-counter с TTL).
  3. Database — connection limits через PgBouncer.
  4. External API — token bucket перед вызовами CRM/платежей/SMS.

Пример Nginx rate-limit:

limit_req_zone $binary_remote_addr zone=webhook:10m rate=100r/s;

server {
    location /webhook {
        limit_req zone=webhook burst=200 nodelay;
        proxy_pass http://max_bot;
    }
}

В коде — обёртка через Redis:

async def check_user_rate_limit(chat_id: int) -> bool:
    key = f"rl:user:{chat_id}"
    count = await redis.incr(key)
    if count == 1:
        await redis.expire(key, 60)
    return count <= 30  # 30 запросов в минуту

MAX API limits и обход ограничений

У MAX, как и у любого мессенджера, есть лимиты на отправку сообщений (примерно 30 сообщений в секунду одному получателю и около 30 разным — точные числа уточняйте в актуальной документации). Обходить их нельзя, но можно жить с ними:

  • Батчинг рассылок — не отправлять 100k сообщений в один проход. Чанками по 25-30 в секунду через очередь.
  • Очередь с приоритетами — транзакционные сообщения (подтверждения, OTP) идут вперёд, маркетинг — в очередь.
  • Retry с уважением к retry_after — если получили 429, ждём столько, сколько просит API.
  • Идемпотентность отправки — на стороне бота, чтобы не дублировать при retry.

Vertical vs horizontal scaling

Вертикальное (больше CPU/RAM на одну машину) проще и часто эффективнее на ранних стадиях:

  • Меньше сетевых хопов.
  • Не нужна синхронизация состояния.
  • Дешевле, чем кластер с тем же total CPU.
  • Apache/Postgres scale-up хорошо работает до определённого предела.

Горизонтальное (больше машин) необходимо, когда:

  • Одна машина уже не помещается по CPU/RAM/IOPS.
  • Нужна отказоустойчивость (одна машина упала — другие работают).
  • Нагрузка пиковая, нужно auto-scaling.
  • Деплой без downtime обязателен (rolling update между инстансами).

Правило большого пальца: масштабируйте вертикально пока можете, а горизонтально — когда вынуждены.

Микросервисы: когда и зачем

Монолит хорошо работает до примерно 100k-500k MAU. Дальше начинаются проблемы: один деплой ломает всё, разработчики мешают друг другу, разная нагрузка на разные части системы. Тогда имеет смысл делить:

  • API gateway — принимает webhook, валидирует, маршрутизирует.
  • Handler workers — диалоговая логика, FSM, отправка сообщений.
  • CRM workers — синхронизация с amoCRM/Bitrix.
  • Notification workers — рассылки, push, email.
  • Analytics service — агрегация событий, отчёты.

Между ними — очередь (Kafka, NATS, RabbitMQ) и общая БД через сервисные API. Это сложнее, но каждый сервис масштабируется независимо.

Kubernetes deployment для бота MAX

Когда инстансов становится 5+ и нужен auto-scaling, Compose уже неудобен — пора в K8s. Минимальный deployment:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: max-bot
  namespace: production
spec:
  replicas: 3
  selector:
    matchLabels:
      app: max-bot
  template:
    metadata:
      labels:
        app: max-bot
    spec:
      containers:
        - name: bot
          image: registry.example.ru/max-bot:1.4.2
          ports:
            - containerPort: 8080
          envFrom:
            - secretRef:
                name: max-bot-secrets
          resources:
            requests:
              cpu: 200m
              memory: 256Mi
            limits:
              cpu: 1000m
              memory: 512Mi
          readinessProbe:
            httpGet:
              path: /healthz
              port: 8080
            periodSeconds: 5
          livenessProbe:
            httpGet:
              path: /healthz
              port: 8080
            periodSeconds: 30
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: max-bot-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: max-bot
  minReplicas: 3
  maxReplicas: 20
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70

HPA при росте CPU выше 70% автоматически добавит поды, при падении — уберёт. Главное условие — приложение действительно stateless.

Helm-values для типичного бота

Если используете Helm, выносим окружение-специфичные параметры:

image:
  repository: registry.example.ru/max-bot
  tag: 1.4.2
  pullPolicy: IfNotPresent

replicaCount: 3

autoscaling:
  enabled: true
  minReplicas: 3
  maxReplicas: 20
  targetCPUUtilizationPercentage: 70

resources:
  requests:
    cpu: 200m
    memory: 256Mi
  limits:
    cpu: 1000m
    memory: 512Mi

ingress:
  enabled: true
  className: nginx
  hosts:
    - host: bot.example.ru
      paths:
        - path: /webhook
          pathType: Prefix
  tls:
    - hosts:
        - bot.example.ru
      secretName: max-bot-tls

env:
  REDIS_URL: redis://redis-master:6379/0
  POSTGRES_DSN_FROM_SECRET: max-bot-secrets
  LOG_LEVEL: info

Это даёт переиспользуемый шаблон: dev/staging/prod отличаются только values-файлом.

Геораспределённость и регион

MAX живёт в РФ — VK Tech как платформа размещает API в российских дата-центрах. Соответственно:

  • VPS и K8s-кластер держим в РФ (Yandex Cloud, VK Cloud, Selectel, Timeweb Cloud).
  • Postgres и Redis — в той же зоне доступности, что и бот, чтобы минимизировать latency.
  • Cloudflare как балансер — работает, но трафик идёт через зарубежные точки, добавляет 30-100мс. Для рассылок нормально, для интерактивных диалогов — заметно.
  • Резервный регион — для DR имеет смысл иметь backup в другой зоне того же провайдера.

Геораспределённость с активной репликацией между регионами для бота MAX обычно избыточна — пользователи всё равно в одной стране.

Auto-scaling: K8s HPA и serverless

Два рабочих подхода:

  • K8s HPA — для предсказуемой долгой нагрузки. Настроили min/max реплик и таргет по CPU/памяти/RPS, кластер сам разруливает.
  • Yandex Cloud Functions / Serverless Containers — для всплесков и редко используемых фич. Платите только за фактическое выполнение, scale to zero.

Гибрид: основной поток на K8s, тяжёлые редкие задачи (генерация PDF, обработка видео) — на Cloud Functions через очередь.

Observability при росте

При 1 инстансе хватает tail -f. При 20 — нет. Минимум для масштабируемого бота:

  • Distributed tracing — OpenTelemetry, Jaeger или Tempo. Видишь весь путь запроса через сервисы.
  • Centralized logs — Loki, ELK, Yandex Cloud Logging. Все логи в одном месте, поиск по trace_id.
  • Metrics — Prometheus + Grafana. RPS, latency p50/p95/p99, error rate, размер очередей, состояние пулов.
  • Alerting — Alertmanager + Telegram/MAX/Slack. Ключевые алерты: error rate > 1%, p99 > 2s, очередь не разгребается.
  • Synthetic monitoring — внешний пинг бота раз в минуту через тестовый сценарий.

Без observability вы узнаете о проблеме от пользователей. С observability — за 30 секунд до того, как они её заметят.

Cost optimization

Когда счёт за инфраструктуру вырастает в десятки раз, начинаются разговоры про экономию. Что реально работает:

  • Spot/preemptible instances для воркеров — на 60-80% дешевле обычных, переживают рестарты благодаря очередям.
  • Right-sizing — мониторинг показывает реальное потребление, режем избыток.
  • TTL на старые данные — логи диалогов старше 90 дней в холодное хранилище или удалить.
  • Сжатие БДpg_repack, партиционирование с архивацией старых партиций в S3.
  • Кеш LLM-ответов — повторяющиеся вопросы дают огромную экономию на токенах.
  • CDN для статики Mini App — Cloudflare/Yandex CDN, не гоняем картинки с приложения.

Цель — линейный рост стоимости при нелинейном росте аудитории.

Антипаттерны масштабирования

Что мы видим в чужих проектах и сами не делаем:

  • Shared mutable state в process memory — словарик users = \{\} в модуле, который при втором инстансе ломается молча.
  • Синхронные тяжёлые вызовы LLM в webhook — 30-секундный запрос блокирует обработчик, MAX отваливается по таймауту.
  • Одна Redis-инстанция на всё — FSM, кеш, очередь, pubsub — упадёт одно, упадёт всё.
  • SELECT * FROM users WHERE chat_id = ? без индекса — на 10k записей работает, на 10M — нет.
  • Polling в продакшене — экономия 5 минут на настройке webhook стоит невозможности горизонтального масштабирования.
  • Логи в файл на диск пода — после рестарта или scale-down всё пропадёт.
  • Секреты в коде или git — рано или поздно утечёт.
  • Деплой без health-check — балансер шлёт трафик в умерший инстанс.

Итого

Масштабирование бота MAX строится на простых принципах: stateless-приложение, состояние во внешнем хранилище, fastpath через очередь, наблюдаемость с первого дня. Под средние нагрузки (до 10k MAU) хватает одной VPS с Postgres и Redis; на сотнях тысяч подключаются репликация, партиционирование, кластер Redis и Kubernetes; на миллионах — микросервисы, шардирование, event-driven архитектура.

Главное — закладывать архитектуру под рост заранее, а не переписывать на пиках. Webhook вместо polling, идемпотентность с первого дня, очереди для всего тяжёлого, observability с первого дня — этих четырёх вещей достаточно, чтобы дойти от MVP до 100k MAU без масштабного переписывания.

Частые вопросы

Какие узкие места появляются у бота MAX под нагрузкой?

Четыре типичных места, которые сдают первыми. Webhook — один процесс на одной машине упирается в CPU и количество TCP-соединений. PostgreSQL — блокировки при горячих апдейтах, медленные запросы при росте таблиц. Redis — исчерпание памяти и серийная задержка при большом числе операций. Внешние API (CRM, платежи, SMS) — упираются в свои rate-limit и блокируют весь поток. Хорошая архитектура изолирует каждое узкое место, чтобы их можно было масштабировать независимо. Дополнительно: пулы соединений к БД (без PgBouncer соединения кончаются раньше CPU) и сетевая латентность между приложением и хранилищами.

Как сделать горизонтальное масштабирование бота MAX?

Через несколько stateless-инстансов за балансировщиком (Nginx, HAProxy или Yandex Application Load Balancer). Условия: приложение не хранит ничего в памяти процесса (FSM-состояние в Redis или PostgreSQL), сессии привязаны к chat_id без зависимости от инстанса, деплой через Docker, оркестрация Compose или Kubernetes. Тогда добавить пятый инстанс — это копия контейнера за тем же балансером. Webhook масштабируется линейно до десятков RPS на инстанс, а через 10 инстансов уже сотни RPS. Sticky sessions включать не нужно — любой инстанс должен уметь обработать любой апдейт.

Polling или webhook для масштабирования бота MAX?

Только webhook. Polling не масштабируется горизонтально: если запустить два инстанса с одним токеном, они будут конкурировать за апдейты, дубли гарантированы. На MVP polling удобен (не нужен HTTPS и домен), но мигрировать на webhook стоит до первой волны нагрузки, не во время. Webhook отправляет POST на ваш URL, балансировщик распределяет по инстансам, каждый обрабатывает свою часть. Это единственный способ дойти до тысяч RPS.

Сколько пользователей выдержит бот MAX на одной VPS?

До 100 MAU — хватит 1 vCPU и 1 ГБ ОЗУ, polling и SQLite. До 10k MAU — 2 vCPU, 4 ГБ ОЗУ, webhook и Postgres+Redis на той же машине. До 100k MAU — несколько инстансов webhook за балансировщиком, отдельный сервер Postgres с репликой, выделенный Redis. До 1M MAU — Kubernetes, шардирование БД, специализированные кеши. Свыше 1M — микросервисы, event-driven архитектура. Цифры условные: простой FAQ-бот вытягивает 50k MAU на одной VPS, бот с LLM-обработкой каждого сообщения упирается уже на 5k.

Зачем нужны очереди в боте MAX?

Для разделения быстрого приёма webhook и тяжёлой обработки. Бот принимает апдейт, кладёт задачу в очередь и сразу отвечает 200 (за время менее секунды). Воркер забирает задачу, обрабатывает (рассылка, LLM-модель, экспорт отчёта, интеграция с медленной CRM) и отправляет пользователю результат. Это снимает нагрузку с webhook (он не блокируется на тяжёлых операциях) и даёт независимое масштабирование воркеров. Варианты: Redis Streams и RQ (простой), Celery (классика Python), arq (async-Celery), RabbitMQ (универсальный с routing keys), NATS JetStream и Kafka (high-throughput).

Как ускорить PostgreSQL в боте MAX под высокой нагрузкой?

Шесть рабочих практик. Индексы на горячие поля — chat_id, status, created_at. Без них таблица в 10 млн записей становится медленной за неделю. Партиционирование больших таблиц по дате (события, сообщения, логи) — PostgreSQL поддерживает декларативное с PG10+. Шардирование по user_id — когда база уже не помещается в одну машину. Connection pooling через PgBouncer в режиме pool_mode = transaction — позволяет 2000 клиентских соединений мультиплексировать через 50 фактических к Postgres. Read-replica для аналитических запросов и дашбордов. Настройка VACUUM и ANALYZE под нагрузку, иначе bloat съест производительность.

Когда переходить на Kubernetes и микросервисы для бота MAX?

Kubernetes имеет смысл, когда инстансов 5 и больше, нужен auto-scaling и rolling update без downtime. До этого Docker Compose на одной-двух VPS проще и дешевле. Микросервисы — после 100-500k MAU, когда монолит начинает мешать: один деплой ломает всё, разработчики конкурируют за код, разная нагрузка на разные части. Делим на API gateway (приём webhook), handler workers (диалоги), CRM workers (интеграции), notification workers (рассылки), analytics service. Между ними — очередь (Kafka, NATS, RabbitMQ) и общая БД через сервисные API. Каждый сервис масштабируется независимо.