Бот, который держит 100 пользователей в день, и бот, который держит 1 000 000, — это два разных бота по архитектуре. Разница не в коде, а в подходе к webhook, очередям, БД и кешам. Разберём, как масштабировать поэтапно, не переделывая всё с нуля: от одной VPS до Kubernetes-кластера с собственным Bot API server.
Стадии роста: ориентиры по нагрузке
Прежде чем выбирать архитектуру, сверьтесь с табличкой. Это не догма, но в 90% случаев совпадает с реальностью.
| MAU | Нагрузка | Архитектура | Стек |
|---|---|---|---|
| до 1k | 1–5 upd/sec | Single instance, polling | 1 VPS (1 vCPU, 1 GB) |
| до 10k | 10–50 upd/sec | Single instance, webhook | 1 VPS + SQLite/Postgres локально |
| до 100k | 100–500 upd/sec | 2–4 инстанса за LB + Redis | 2 VPS + managed Postgres + Redis |
| до 1M | 1k–5k upd/sec | Webhook pool + worker pool + queue | K8s, Postgres-кластер, Redis-cluster |
| 1M+ | 5k+ upd/sec | Микросервисы, gateway → workers | K8s + 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.
База данных: индексы, пул, репликация
С ростом нагрузки БД — первое узкое место. Порядок действий:
- Индексы на
chat_id,user_id,created_at, FK. Самая частая ошибка — забыть индекс на foreign key, и каждый JOIN превращается в seq scan. - Connection pool через PgBouncer в
transactionmode. Без него на каждом инстансе 50 соединений, а Postgres держит 200 — упёрлись. - Read replicas для аналитики и тяжёлых отчётов. Пишем в primary, читаем дашборды с реплики.
- Партиционирование по
created_atдля таблиц событий. Старые партиции — на холодный storage или в архив. - Шардинг по
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 memory —
sessions = {}в модуле, словарь FSM в глобале. Работает на одном инстансе, ломается на двух. - Polling после 10k MAU — упираетесь в один процесс и забываете, что получили
Conflict: terminated by other getUpdates requestпри попытке завести второй. - Синхронный LLM-вызов в webhook handler — 5 секунд на ответ модели, Telegram ретраит, пользователь получает три одинаковых ответа.
- Один Postgres-коннект на инстанс без пула — БД отвечает «too many connections» уже на 50 инстансах.
COUNT(*)на горячем пути — вместо этого денормализуйте счётчики в отдельную таблицу или Redis.- Логирование
initDataMini 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 с банковской нагрузкой, отдельные изолированные кластеры под транзакции.
Никакой магии в этих архитектурах нет — те же примитивы из этой статьи, только умноженные на масштаб и операционную дисциплину.
Сравнение архитектурных уровней
| Уровень | Pattern | Throughput | Слабое место | Сложность ops |
|---|---|---|---|---|
| L1 | polling, monolith | до 50 upd/sec | один процесс | низкая |
| L2 | webhook, monolith + nginx | до 500 upd/sec | БД и кеш | средняя |
| L3 | webhook pool + workers + queue | до 5k upd/sec | сеть и Redis | высокая |
| L4 | микросервисы + K8s + self-hosted Bot API | 5k+ 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.