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

Масштабирование Telegram-бота под нагрузку

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

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

Бот, который держит 100 пользователей в день, и бот, который держит 1 000 000, — это два разных бота по архитектуре. Разница не в коде, а в подходе к webhook, очередям, БД и кешам. Разберём, как масштабировать поэтапно, не переделывая всё с нуля: от одной VPS до Kubernetes-кластера с собственным Bot API server.

Стадии роста: ориентиры по нагрузке

Прежде чем выбирать архитектуру, сверьтесь с табличкой. Это не догма, но в 90% случаев совпадает с реальностью.

MAUНагрузкаАрхитектураСтек
до 1k1–5 upd/secSingle instance, polling1 VPS (1 vCPU, 1 GB)
до 10k10–50 upd/secSingle instance, webhook1 VPS + SQLite/Postgres локально
до 100k100–500 upd/sec2–4 инстанса за LB + Redis2 VPS + managed Postgres + Redis
до 1M1k–5k upd/secWebhook pool + worker pool + queueK8s, Postgres-кластер, Redis-cluster
1M+5k+ upd/secМикросервисы, gateway → workersK8s + self-hosted Bot API + S3 + Kafka

Главное правило: не лезьте на следующий уровень, пока не упёрлись в текущий. Каждый шаг добавляет операционной сложности на порядок.

Polling против webhook

Polling (getUpdates) — простой режим: процесс долбит Telegram длинным запросом и забирает апдейты. Удобно для разработки и для маленьких ботов до ~10k MAU. У polling одна архитектурная катастрофа: нельзя запустить два процесса с одним токеном одновременно. Telegram отдаст апдейт только одному, и вы получите гонку, дубли, рассинхрон FSM.

Webhook — Telegram сам шлёт POST на ваш URL. Поддерживает любое количество приёмников за балансировщиком, потому что HTTPS-эндпоинт stateless. Для горизонтального масштабирования webhook обязателен — другого пути нет.

Минимальный webhook на FastAPI с моментальным ACK:

from fastapi import FastAPI, Request
import redis.asyncio as redis

app = FastAPI()
r = redis.from_url("redis://redis:6379/0")

@app.post("/webhook/{secret}")
async def webhook(secret: str, request: Request):
    if secret != WEBHOOK_SECRET:
        return {"ok": False}
    update = await request.json()
    await r.xadd("updates", {"data": json.dumps(update)}, maxlen=100_000)
    return {"ok": True}

Handler должен укладываться в 1–2 секунды. Telegram считает webhook повисшим через ~60 секунд и шлёт ретрай — это уже путь к дублирующейся обработке.

Идемпотентность и update_id

Telegram гарантирует доставку, но не уникальность. После 5xx или таймаута вы получите тот же апдейт повторно. Решение — дедупликация по update_id в Redis с TTL около суток.

async def is_duplicate(update_id: int) -> bool:
    key = f"upd:{update_id}"
    added = await r.set(key, "1", nx=True, ex=86400)
    return added is None

Если бот уже обрабатывал апдейт — пропускаем. Без этой строки массовая ретрансляция при сбое БД превращается в дубли сообщений пользователю.

Webhook за Nginx: конфиг для пула

Когда инстансов несколько, перед ними ставится балансировщик. Sticky sessions не нужны — бот должен быть stateless (всё состояние в Redis/Postgres), любой инстанс обработает любой апдейт.

upstream bot_webhook {
    least_conn;
    server bot-1:8000 max_fails=3 fail_timeout=10s;
    server bot-2:8000 max_fails=3 fail_timeout=10s;
    server bot-3:8000 max_fails=3 fail_timeout=10s;
    keepalive 64;
}

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

    location /webhook/ {
        proxy_pass http://bot_webhook;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_read_timeout 5s;
        proxy_connect_timeout 2s;
        client_max_body_size 50m;

        limit_req zone=tg_webhook burst=200 nodelay;
    }
}

least_conn лучше round-robin, когда апдейты неравномерны по времени обработки. keepalive 64 экономит TCP-handshakes между Nginx и бэкендом.

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

Антипаттерн номер один при переходе на несколько инстансов — оставить FSM или сессии в памяти процесса. Пользователь нажмёт кнопку, попадёт на другой инстанс, бот «забудет» контекст.

Правильное распределение:

  • Redis — FSM (текущий шаг диалога), сессии Mini App, rate-limit счётчики, кеш профиля, очередь апдейтов.
  • PostgreSQL — заявки, заказы, логи действий, биллинг, любая бизнес-доменная история.
  • S3 / object storage — медиа, документы, экспорты, аватары.
  • Никогда — process memory, локальный диск конкретного инстанса, sqlite в контейнере.

При таком разделении можно убить любой инстанс — пользователь не заметит.

Очереди: webhook отдельно, обработка отдельно

Самый дешёвый способ выдержать всплеск нагрузки — разорвать приём и обработку. Webhook принимает апдейт, кладёт в очередь, отвечает 200. Воркер тянет из очереди и работает столько, сколько нужно.

Пример с arq (Redis-based) — задача рассылки в фоне:

from arq import create_pool
from arq.connections import RedisSettings

async def send_broadcast(ctx, user_ids: list[int], text: str):
    bot = ctx["bot"]
    for uid in user_ids:
        try:
            await bot.send_message(uid, text)
        except FloodWait as e:
            await asyncio.sleep(e.retry_after)
        await asyncio.sleep(0.04)  # 25 msg/sec — под лимитом

class WorkerSettings:
    functions = [send_broadcast]
    redis_settings = RedisSettings(host="redis", port=6379)
    max_jobs = 50
    job_timeout = 3600

Те же роли у Celery, RQ, Dramatiq, RabbitMQ + aio-pika, Kafka + faust. Выбор стека — вкусовщина, разница в эксплуатации, не в идее.

Telegram API: лимиты и батчинг

Bot API ограничивает исходящие сообщения:

  • ~30 сообщений в секунду глобально на бота;
  • 1 сообщение в секунду на чат;
  • 20 сообщений в минуту на группу;
  • sendMediaGroup считается за одно сообщение, но не больше 10 элементов.

Если превышаете — приходит 429 с retry_after. Стандартный паттерн — централизованный rate-limiter в очереди отправки, через который проходят все исходящие. Самописный leaky-bucket на Redis или библиотека вроде aiolimiter — оба варианта рабочие.

Для рассылки на 1M пользователей при 25 msg/sec уйдёт ~11 часов. Не пытайтесь обогнать лимит, разбейте на шарды по часовым поясам или выводите в notification worker pool.

База данных: индексы, пул, репликация

С ростом нагрузки БД — первое узкое место. Порядок действий:

  1. Индексы на chat_id, user_id, created_at, FK. Самая частая ошибка — забыть индекс на foreign key, и каждый JOIN превращается в seq scan.
  2. Connection pool через PgBouncer в transaction mode. Без него на каждом инстансе 50 соединений, а Postgres держит 200 — упёрлись.
  3. Read replicas для аналитики и тяжёлых отчётов. Пишем в primary, читаем дашборды с реплики.
  4. Партиционирование по created_at для таблиц событий. Старые партиции — на холодный storage или в архив.
  5. Шардинг по user_id — последний шаг, когда вертикалка кончилась. Сложно, делайте только если действительно нужно.

PgBouncer-конфиг для Telegram-бота:

[databases]
botdb = host=postgres port=5432 dbname=botdb

[pgbouncer]
listen_port = 6432
listen_addr = 0.0.0.0
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
max_client_conn = 5000
default_pool_size = 25
reserve_pool_size = 10
server_idle_timeout = 60
query_wait_timeout = 30

Приложение коннектится к pgbouncer:6432, а не напрямую в Postgres. Так 5000 клиентских коннектов мультиплексируются в 25 серверных.

Кеширование

Не каждый запрос должен идти в БД. Read-through кеш в Redis закрывает 70–90% обращений:

  • профиль пользователя — TTL 5–60 минут;
  • каталог товаров — TTL до 1 часа;
  • настройки бота — до явной инвалидации;
  • ответы LLM на одинаковый промпт — TTL сутки (экономия токенов в разы);
  • статика Mini App — через CDN (Cloudflare, Bunny, Yandex Cloud CDN).

При записи — инвалидируйте ключ либо живите с короткими TTL. Не кешируйте чувствительные данные и не используйте кеш как primary storage.

Микросервисы: когда разбивать монолит

До 100k MAU монолит «webhook + worker + бизнес-логика» обычно проще и дешевле. После — начинают мешать общие деплои и общая БД. Тогда разбивают:

  • API gateway — принимает webhook, дедуплицирует, кладёт в очередь.
  • Update workers — берут апдейт, гоняют FSM, отвечают пользователю.
  • CRM workers — синхронизация с amoCRM, Bitrix24, 1C.
  • Notification workers — рассылки, напоминания, триггерные сообщения.
  • LLM workers — отдельный пул с GPU/прокси, со своим rate-limit.
  • Analytics consumer — читает топик в Kafka, пишет в ClickHouse.

Связь: gRPC для синхронных вызовов внутри кластера, очередь (Redis Streams, NATS, Kafka) для асинхронных, HTTP для внешних интеграций.

Kubernetes: deployment и автоскейлинг

K8s имеет смысл с момента, когда инстансов становится больше 4–5 и нужны нормальные rolling updates, healthchecks и HPA. Для 2–3 инстансов хватит docker-compose на одной VPS.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: bot-webhook
spec:
  replicas: 3
  selector:
    matchLabels: { app: bot-webhook }
  template:
    metadata:
      labels: { app: bot-webhook }
    spec:
      containers:
        - name: bot
          image: registry.example.ru/bot:1.42.0
          ports: [{ containerPort: 8000 }]
          env:
            - name: REDIS_URL
              value: redis://redis-master:6379/0
            - name: BOT_TOKEN
              valueFrom:
                secretKeyRef: { name: bot, key: token }
          resources:
            requests: { cpu: "200m", memory: "256Mi" }
            limits:   { cpu: "1000m", memory: "512Mi" }
          readinessProbe:
            httpGet: { path: /health, port: 8000 }
            periodSeconds: 5
          livenessProbe:
            httpGet: { path: /health, port: 8000 }
            periodSeconds: 30
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: bot-webhook
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: bot-webhook
  minReplicas: 3
  maxReplicas: 20
  metrics:
    - type: Resource
      resource:
        name: cpu
        target: { type: Utilization, averageUtilization: 60 }

Воркеры обычно скейлятся не по CPU, а по длине очереди — через KEDA c триггером redis-streams. Это правильнее: важна не загрузка CPU, а отставание от притока апдейтов.

Helm-values фрагмент для базового бота:

bot:
  image:
    repository: registry.example.ru/bot
    tag: "1.42.0"
  webhook:
    replicas: 3
    autoscaling:
      enabled: true
      minReplicas: 3
      maxReplicas: 20
  workers:
    replicas: 5
    autoscaling:
      enabled: true
      trigger: redis-streams
      streamName: updates
      pendingThreshold: 100

redis:
  architecture: replication
  auth: { enabled: true }

postgresql:
  architecture: replication
  primary:
    persistence: { size: 50Gi }
  readReplicas:
    replicaCount: 2

Self-hosted Bot API server

Когда упираетесь в лимит на размер файла (20 MB на загрузку через bot.api.telegram.org), поднимайте собственный Bot API server — официальный бинарь от Telegram. Что меняется:

  • лимит на загрузку файлов до 2 GB;
  • меньше latency (сервер можно поставить рядом с воркерами);
  • нет внешнего rate-limit на ваш токен (но Telegram Core лимиты остаются);
  • webhook можно делать на localhost.

Запуск через docker:

services:
  telegram-bot-api:
    image: aiogram/telegram-bot-api:latest
    environment:
      TELEGRAM_API_ID: "${TG_API_ID}"
      TELEGRAM_API_HASH: "${TG_API_HASH}"
      TELEGRAM_LOCAL: "1"
    volumes:
      - bot-api-data:/var/lib/telegram-bot-api
    ports:
      - "8081:8081"

Бот указывает base_url="http://telegram-bot-api:8081" вместо api.telegram.org. Дальше всё как обычно.

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

Telegram держит ботовые DC преимущественно в Амстердаме (DC2/DC4/DC5) и Майами. Для российской аудитории это даёт ~30–60 ms RTT из Москвы и ~80–120 ms из Сибири. Что отсюда следует:

  • VPS под webhook лучше держать в Европе или европейской части России (Москва, Санкт-Петербург, Финляндия) — это снижает latency на ответы Telegram.
  • Бэкенд для Mini App — рядом с пользователями (Москва, Екатеринбург), потому что общается напрямую с браузером.
  • Multi-region для бота как такового пока имеет смысл редко — Telegram всё равно один.

Observability при росте

На монолите хватает tail -f. На 10 инстансах — нет. Минимальный стек:

  • Метрики в Prometheus: updates_total, update_duration_seconds, queue_length, tg_api_errors_total{code=...}, db_pool_in_use.
  • Логи централизованно: Loki, ELK, ClickHouse + Vector. Структурированные JSON, обязательно update_id, user_id, chat_id, request_id.
  • Distributed tracing: OpenTelemetry. Span от webhook → queue → worker → DB → tg_api. Без трейсов на микросервисах вы слепые.
  • Алерты: на длину очереди, на рост 5xx, на падение update_duration_seconds_p99, на 429 от Telegram. Не на «ответил медленно один раз».

Cost optimization

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

  • Spot/preemptible для воркеров — они переживают рестарт, потому что задачи в очереди.
  • TTL на старые данные в PostgreSQL и Redis — таблица events за 2 года вам не нужна, выгружайте в холодный storage.
  • TimescaleDB или ClickHouse для time-series — экономят место в разы.
  • Сжатие старых партиций (pg_repack, CLUSTER).
  • Serverless (Yandex Cloud Functions, AWS Lambda) для редких эндпоинтов — платите только за вызовы.
  • Кеш LLM-ответов — самый быстрый способ уменьшить счёт за OpenAI/Anthropic.

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

Чек-лист «как не надо», собран по граблям:

  • Shared mutable state в process memorysessions = {} в модуле, словарь FSM в глобале. Работает на одном инстансе, ломается на двух.
  • Polling после 10k MAU — упираетесь в один процесс и забываете, что получили Conflict: terminated by other getUpdates request при попытке завести второй.
  • Синхронный LLM-вызов в webhook handler — 5 секунд на ответ модели, Telegram ретраит, пользователь получает три одинаковых ответа.
  • Один Postgres-коннект на инстанс без пула — БД отвечает «too many connections» уже на 50 инстансах.
  • COUNT(*) на горячем пути — вместо этого денормализуйте счётчики в отдельную таблицу или Redis.
  • Логирование initData Mini App целиком — вечный bypass auth для всех, у кого доступ к логам.
  • Один глобальный rate-limiter в памяти инстанса — на 10 инстансах разрешите x10 от лимита Telegram.

Кейсы: что вытягивает Telegram-инфра

Чтобы оценить, где находится потолок:

  • Notcoin — около 35M+ MAU на пике, классический webhook + worker pool + Postgres + Redis.
  • Hamster Kombat — 300M+ зарегистрированных, сотни миллионов активных. Микросервисы, Kafka, шардированный Postgres, CDN под Mini App, собственные Bot API серверы.
  • TON Wallet (внутри Telegram) — Mini App с банковской нагрузкой, отдельные изолированные кластеры под транзакции.

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

Сравнение архитектурных уровней

УровеньPatternThroughputСлабое местоСложность ops
L1polling, monolithдо 50 upd/secодин процесснизкая
L2webhook, monolith + nginxдо 500 upd/secБД и кешсредняя
L3webhook pool + workers + queueдо 5k upd/secсеть и Redisвысокая
L4микросервисы + K8s + self-hosted Bot API5k+ upd/secкоординация и стоимостьочень высокая

Прыгать сразу на L4 «потому что красиво» — выстрел в ногу. Большинству ботов нужен L2 на одной VPS с PgBouncer, и они проживут так годами.

Итого

Масштабирование Telegram-бота — это не «переписать на Go и добавить Kubernetes», а поэтапная замена узких мест. Webhook вместо polling — обязателен для горизонтали. Состояние в Redis/Postgres, не в памяти. Очередь между webhook и обработкой, чтобы handler укладывался в секунду. Дедупликация по update_id. Connection pool через PgBouncer. Read replicas для отчётов. Централизованный rate-limiter под лимиты Telegram. На больших объёмах — собственный Bot API server, микросервисы, K8s с HPA по длине очереди. Observability — длина очереди и p99, а не «среднее время ответа». Не оптимизируйте до измерений и не лезьте на следующий уровень, пока на текущем всё работает.

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

С какого момента переходить с polling на webhook?

Polling удобен для разработки и для маленьких ботов до примерно 10k MAU. У него одна архитектурная катастрофа: нельзя запустить два процесса с одним токеном одновременно — Telegram отдаст апдейт только одному и вы получите гонку, дубли и рассинхрон FSM. Если хотите горизонтальное масштабирование, webhook обязателен — другого пути нет. Webhook за Nginx или HAProxy раскидывает апдейты по нескольким stateless-инстансам, любой из них обработает любой апдейт. Бот должен ответить на webhook за 60 секунд (на практике закладывайте 1–2), иначе Telegram считает запрос повисшим и шлёт ретрай — отсюда требование класть апдейт в очередь и сразу отвечать 200.

Зачем дедуплицировать update_id и как это сделать?

Telegram гарантирует доставку апдейта, но не его уникальность. После 5xx, таймаута или просто ретрая вы получите тот же апдейт повторно — пользователь увидит дубли сообщений. Решение — дедупликация по update_id в Redis с TTL около суток через SET NX EX. Если ключ уже существует, значит, апдейт обрабатывался — пропускаем. Без этой защиты массовая ретрансляция при сбое БД или временной недоступности воркера превращается в шквал дублирующихся ответов. Дедупликация на уровне БД через UNIQUE-индекс на update_id тоже работает, но дороже по latency.

Как организовать стадии роста инфраструктуры Telegram-бота?

Ориентировочные пороги. До 1k MAU — одна VPS, polling, SQLite или Postgres локально. До 10k MAU — одна VPS, webhook, Postgres, Redis для FSM. До 100k MAU — два-четыре инстанса за Nginx-балансировщиком, managed Postgres с PgBouncer, общий Redis. До 1M MAU — пул webhook-инстансов, отдельный пул воркеров, очередь между ними, Postgres с репликами, Redis-cluster, всё в Kubernetes с HPA. Свыше 1M — микросервисы (gateway, update workers, notification workers, LLM workers, analytics consumer), self-hosted Bot API server, Kafka, ClickHouse под аналитику. Главное правило: не лезьте на следующий уровень, пока не упёрлись в текущий — каждый шаг добавляет операционной сложности на порядок.

Какую очередь выбрать между webhook и воркерами?

Идея у всех очередей одинаковая: webhook принимает апдейт, кладёт в очередь, отвечает 200; воркер тянет и работает столько, сколько нужно. Различия только в эксплуатации. Redis Streams через arq или dramatiq — самое простое и быстрое для среднего бота, не требует отдельной инфраструктуры. Celery + RabbitMQ — классика Python-мира, хорошо для распределённых задач и сложного роутинга. Kafka — когда апдейты становятся бизнес-событиями и их потребляют несколько систем (бот, CRM, аналитика). NATS — лёгкий, быстрый, без особых гарантий. Для Telegram-бота до 1M MAU Redis Streams почти всегда достаточно, не усложняйте раньше времени.

Как обходить лимиты Bot API при массовых рассылках?

Лимиты Telegram: около 30 сообщений в секунду глобально на бота, 1 сообщение в секунду на чат, 20 сообщений в минуту на группу. При превышении приходит 429 с retry_after — обязательно уважайте этот заголовок. Стандартный паттерн — централизованный rate-limiter в очереди отправки, через который проходят все исходящие сообщения. Самописный leaky-bucket на Redis или библиотека вроде aiolimiter — оба варианта рабочие. Для рассылки на 1M пользователей при 25 msg/sec уйдёт около 11 часов; разбивайте на шарды по часовым поясам или выводите в отдельный notification worker pool. Не пытайтесь обогнать лимит — Telegram ужесточит ограничения именно для вашего бота.

Когда поднимать собственный Bot API server?

Self-hosted Bot API server — это официальный бинарь от Telegram, который вы запускаете у себя. Имеет смысл в трёх случаях. Первый — нужно работать с файлами больше 20 MB (лимит api.telegram.org); собственный сервер поднимает его до 2 GB на загрузку. Второй — критична latency между ботом и Bot API: ставите сервер рядом с воркерами и экономите десятки миллисекунд на каждом вызове. Третий — webhook на localhost вместо публичного HTTPS, удобно для закрытой инфраструктуры. Базовый запуск через aiogram/telegram-bot-api в docker, бот указывает base_url на собственный хост вместо api.telegram.org. Сами лимиты Telegram Core при этом остаются — это не способ их обойти.

Какие самые частые антипаттерны при масштабировании Telegram-бота?

Семь главных граблей. Shared mutable state в процессе — словарь FSM в глобале, работает на одном инстансе, ломается на двух. Polling после 10k MAU — невозможно завести второй процесс на тот же токен. Синхронный LLM-вызов в webhook handler — 5 секунд ответа модели, Telegram ретраит, пользователь получает три одинаковых ответа. Один Postgres-коннект на инстанс без пула — БД отвечает «too many connections» уже на 50 инстансах. COUNT(*) на горячем пути — денормализуйте счётчики в отдельную таблицу или Redis. Логирование initData Mini App целиком — вечный bypass auth. Один rate-limiter в памяти каждого инстанса вместо общего в Redis — на 10 инстансах разрешите десятикратное превышение лимита Telegram.