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

Webhook vs long polling в боте MAX: что выбрать

Сравниваем webhook и long polling для бота MAX: задержки, нагрузка, отладка, требования к инфраструктуре. Когда какой подход уместен.

  • MAX
  • архитектура
  • сравнение

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

Как устроен long polling

Long polling — pull-модель: бот сам периодически дёргает API MAX методом getUpdates, удерживая HTTP-соединение открытым на длительный таймаут (обычно 25–30 секунд). MAX держит запрос «висящим», и как только в очереди появляется хотя бы одно событие — отдаёт массив Update и закрывает соединение. Бот сразу шлёт следующий getUpdates — и так в цикле.

Поток выглядит примерно так:

бот → GET https://api.max.ru/bot<TOKEN>/getUpdates?offset=N&timeout=30
     [соединение висит до 30 с]
     ← 200 [{ update_id: N+1, ... }, { update_id: N+2, ... }]
бот → GET .../getUpdates?offset=N+3&timeout=30
     ...

Параметр offset — «подтверждение» обработанных апдейтов: передавая offset=last_update_id+1, бот говорит MAX «эти я забрал, можешь удалить из очереди». Если бот упал, не подтвердив, — MAX отдаст те же апдейты следующему getUpdates.

Вызов getUpdates curl-ом для отладки:

curl "https://api.max.ru/bot$BOT_TOKEN/getUpdates?timeout=30&allowed_updates=[\"message\",\"callback_query\"]"

Никакого публичного домена, SSL и открытых портов не нужно — нужен только исходящий HTTPS до api.max.ru. Инициатор соединения всегда бот, поэтому работает за NAT, в корпоративной сети, на ноутбуке разработчика.

Как устроен webhook

Webhook — push-модель: бот один раз говорит MAX «шли все апдейты POST-ом сюда», после чего MAX сам стучится на ваш URL при каждом новом событии.

Регистрация:

curl -X POST "https://api.max.ru/bot$BOT_TOKEN/setWebhook" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://bot.example.ru/max/webhook",
    "secret_token": "k7Hg2pQwR9zX5mN8vL3jY6tF1aB4cD0e",
    "allowed_updates": ["message", "callback_query", "inline_query"],
    "max_connections": 40,
    "drop_pending_updates": false
  }'

После этого MAX сам шлёт POST с JSON-телом Update на указанный URL, ожидая HTTP 200 в течение примерно 60 секунд. Если ответа нет или код не 2xx — апдейт уйдёт в очередь повторов с экспоненциальной задержкой.

Требования MAX к webhook URL:

  • HTTPS с валидным сертификатом — Let's Encrypt подходит, доверенным CA доверяют по умолчанию. Self-signed нужно явно прокидывать в setWebhook через параметр certificate.
  • Один из разрешённых портов: 443, 80, 88 или 8443. Произвольный порт API не примет.
  • Публичный IP. Если MAX публикует CIDR-подсети для исходящих POST-ов, их можно добавить в IP-allowlist на firewall, чтобы закрыть webhook от посторонних сканеров.
  • Доменное имя в URL (IP-адрес в URL не принимается).

Сравнительная таблица

ПараметрLong pollingWebhook
МодельPull, бот тянетPush, MAX шлёт
Latency доставки200–1500 мс30–150 мс
Throughput на инстансДо нескольких сообщений в секундуСотни сообщений в секунду
Публичный IPНе нуженОбязателен
HTTPS-сертификатНе нуженОбязателен (Let's Encrypt OK)
ПортыТолько исходящий 443Входящий 443 / 80 / 88 / 8443
Несколько инстансовНевозможно с одним токеномЧерез load balancer
ServerlessНе работаетРаботает естественно
Отладка локальноЗапустил — работаетНужен ngrok / cloudflared
Стоимость инфраструктурыМинимальнаяДомен + SSL + публичный хост
Перезапуск без потериЧерез offsetЧерез очередь повторов
Защита эндпоинтаНе требуетсяsecret_token + IP-allowlist

Когда выбирать long polling

Long polling — правильный выбор, когда:

  • MVP и прототип. Запустить бот за 10 минут на ноутбуке, без VPS, домена и SSL.
  • Локальная разработка. Проще отлаживать — не нужен ngrok или Cloudflare Tunnel, дебагер останавливает процесс без таймаутов от MAX.
  • Нет публичного IP. Корпоративная сеть, NAT, домашний интернет, бот на офисном сервере без проброса портов.
  • Малый трафик. До нескольких сообщений в секунду long polling справляется без напряга.
  • Однопроцессный бот. Не нужен балансировщик, shared-storage, синхронизация состояния между инстансами.
  • Простой деплой. Один контейнер, без nginx, без Let's Encrypt, без cron на обновление сертификата.

Главный архитектурный минус — невозможность запустить два инстанса с одним токеном. MAX отдаст апдейт первому подключившемуся getUpdates, и второй инстанс начнёт «воровать» сообщения у первого. На практике вы получите рандомное распределение апдейтов и сломанные FSM-состояния.

Когда выбирать webhook

Webhook — правильный выбор, когда:

  • Высокий трафик. Сотни сообщений в секунду, нужно горизонтальное масштабирование.
  • Серверлесс. Yandex Cloud Functions, AWS Lambda, Cloudflare Workers — long polling там не работает в принципе (нет долгоживущего процесса).
  • Минимальная задержка. Webhook доставляет апдейт за десятки миллисекунд против секунды у polling — критично для AI-ботов и интерактивных сценариев.
  • Несколько инстансов за балансировщиком. MAX сам распределит POST-запросы по живым нодам через ваш балансер.
  • Production-готовность. Webhook ровно интегрируется в обычный веб-стек: nginx → app server → handler, как любой другой HTTP API.
  • Экономия исходящего трафика. Не нужно держать сотни RPS впустую — MAX сам стучится только при наличии события.

secret_token и защита эндпоинта

Webhook URL рано или поздно попадёт в логи, в чужие сертификатные журналы (Certificate Transparency), в DNS-историю. Чтобы посторонний с угаданным URL не мог слать поддельные апдейты, в setWebhook передаётся secret_token — произвольная строка от 1 до 256 символов из алфавита A-Z a-z 0-9 _ -.

MAX при каждом POST добавляет заголовок X-Max-Bot-Api-Secret-Token со значением этого секрета. Сервер обязан проверять заголовок и отбрасывать запросы без него:

from fastapi import FastAPI, Request, Header, HTTPException
import os, hmac

app = FastAPI()
SECRET = os.environ["MAX_WEBHOOK_SECRET"]

@app.post("/max/webhook")
async def webhook(
    request: Request,
    x_max_bot_api_secret_token: str | None = Header(default=None),
):
    if not x_max_bot_api_secret_token or not hmac.compare_digest(
        x_max_bot_api_secret_token, SECRET
    ):
        raise HTTPException(status_code=403, detail="forbidden")
    update = await request.json()
    # быстро поставить в очередь и ответить 200
    await enqueue(update)
    return {"ok": True}

Сравнение через hmac.compare_digest (а не ==) защищает от timing-атак. Дополнительный слой защиты — IP-allowlist на nginx или firewall, если MAX публикует CIDR исходящих подсетей.

allowed_updates: оптимизация трафика

По умолчанию MAX шлёт боту все типы апдейтов. Это значит — даже если ваш бот не реагирует на edited_message или poll_answer, вы всё равно получаете их POST-ом и тратите CPU на парсинг.

Параметр allowed_updates в setWebhookgetUpdates) отсекает ненужные типы на стороне MAX:

curl -X POST "https://api.max.ru/bot$BOT_TOKEN/setWebhook" \
  -d "url=https://bot.example.ru/max/webhook" \
  -d 'allowed_updates=["message","callback_query"]'

Для типичного бота с командами и инлайн-кнопками достаточно message и callback_query. Если используете inline-режим — добавьте inline_query. Если есть платежи — pre_checkout_query и successful_payment (через message).

Пример: webhook на FastAPI + nginx

Минимальный продакшен-стек: nginx терминирует TLS и проксирует на FastAPI/Uvicorn, FastAPI быстро складывает апдейт в очередь (Redis/RabbitMQ/asyncio.Queue) и отвечает 200. Тяжёлая работа — в фоновом воркере.

nginx-конфиг:

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

    ssl_certificate     /etc/letsencrypt/live/bot.example.ru/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/bot.example.ru/privkey.pem;

    # MAX subnets (если опубликованы — заменить на актуальные CIDR)
    # allow 95.142.192.0/21;
    # deny all;

    location /max/webhook {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_read_timeout 60s;
        client_max_body_size 1m;
    }
}

FastAPI-handler из примера выше принимает POST, проверяет secret_token, кладёт апдейт в очередь и отвечает 200 за миллисекунды. Воркер в отдельном процессе разбирает очередь и вызывает обработчики. Аналогичный шаблон в aiohttp:

from aiohttp import web

async def handle_webhook(request: web.Request) -> web.Response:
    secret = request.headers.get("X-Max-Bot-Api-Secret-Token")
    if secret != WEBHOOK_SECRET:
        return web.Response(status=403, text="forbidden")
    update = await request.json()
    request.app["queue"].put_nowait(update)
    return web.json_response({"ok": True})

app = web.Application()
app["queue"] = asyncio.Queue()
app.router.add_post("/max/webhook", handle_webhook)
web.run_app(app, host="127.0.0.1", port=8000)

Пример: long polling на Python

Минимальный polling-цикл без фреймворка — для понимания, что происходит под капотом:

import os, time, requests

TOKEN = os.environ["BOT_TOKEN"]
API = f"https://api.max.ru/bot{TOKEN}"
offset = 0

while True:
    try:
        r = requests.get(
            f"{API}/getUpdates",
            params={"offset": offset, "timeout": 30,
                    "allowed_updates": '["message","callback_query"]'},
            timeout=35,
        )
        for upd in r.json().get("result", []):
            offset = upd["update_id"] + 1
            handle(upd)  # ваша обработка
    except requests.RequestException:
        time.sleep(2)  # экспоненциальный бэкофф в проде

timeout HTTP-клиента должен быть больше timeout long polling, иначе клиент будет рвать соединение раньше, чем MAX успеет ответить. На фреймворках всё это уже обёрнуто: aiogram-аналоги для MAX, max-bot-api-python и т. п.

Local-tunnel для разработки webhook

Если архитектурно вы уже на webhook, но хочется отладить обработчик локально с дебагером — нужен туннель, выставляющий ваш localhost:8000 наружу с HTTPS-URL.

Популярные варианты:

  • ngrok: ngrok http 8000 → выдаёт URL вида https://abcd-1-2-3-4.ngrok-free.app. Бесплатный план меняет URL при каждом старте.
  • cloudflared: cloudflared tunnel --url http://localhost:8000 → стабильный URL *.trycloudflare.com. Бесплатно, без лимитов на трафик.
  • localtunnel: lt --port 8000 → URL *.loca.lt. Простой, но нестабильный.
  • bore / frp: self-hosted решения, если есть свой VPS с доменом.

После запуска туннеля — setWebhook с полученным URL и тем же secret_token, что в локальном .env.

Миграция с polling на webhook без потери сообщений

Самый частый сценарий: бот рос на polling, теперь нужно мигрировать на webhook без даунтайма и потери апдейтов. Алгоритм:

  1. Подготовить webhook-инстанс, не запуская его. Развернуть HTTPS-эндпоинт, проверить, что отвечает 200 на тестовый POST.
  2. Не трогать polling-инстанс пока. Он продолжает работать.
  3. Остановить polling-процесс командой деплоя. Последний getUpdates вернёт пустой массив или висит до таймаута.
  4. Дренировать очередь: один раз вызвать getUpdates?offset=last+1&timeout=0 чтобы подтвердить все обработанные апдейты.
  5. Вызвать setWebhook с новым URL, secret_token и drop_pending_updates=false — чтобы накопившиеся за минуту переключения апдейты пришли webhook-у.
  6. Запустить webhook-инстанс, проверить через getWebhookInfo, что pending_update_count уменьшается до нуля.

Окно простоя — секунды. Если использовать drop_pending_updates=true, потеряете апдейты, накопившиеся между шагами 3 и 5.

Обратная миграция (webhook → polling) такая же: deleteWebhook → старт polling-цикла. getUpdates сразу подхватит pending-очередь, если её не сбросили.

Несколько инстансов: polling нельзя, webhook можно

Long polling с одним токеном на нескольких инстансах работать не будет — MAX отдаёт каждый апдейт ровно одному getUpdates-запросу, и это будет случайный инстанс. FSM-состояния между ними не синхронизируются, бот ведёт себя непредсказуемо.

Webhook масштабируется естественно: ставим N инстансов за nginx или HAProxy, балансер раздаёт POST-ы. Что важно учесть:

  • Идемпотентность по update_id. При сетевом сбое MAX может ретраить тот же update_id — храните обработанные ID (Redis SET с TTL 24 часа) и пропускайте дубли.
  • Shared FSM-storage. Состояния пользователей — в Redis или Postgres, не в памяти процесса.
  • Sticky sessions не нужны. Любой инстанс должен уметь обработать любой update_id.
  • Graceful shutdown. При раскатке нового релиза старый инстанс должен дообработать текущие POST-ы, а не убиваться SIGKILL.

Webhook в serverless

Serverless-платформы (Yandex Cloud Functions, AWS Lambda, Cloudflare Workers) подходят для webhook идеально: нет долгоживущего процесса, оплата за инвокацию, автоскейлинг из коробки. Но есть особенности:

  • Cold start. Первый POST после простоя может занять 200–2000 мс на инициализацию рантайма. MAX это переживёт, но пользователь почувствует задержку. У Cloudflare Workers и Yandex Cloud Functions cold start менее 100 мс — там терпимо.
  • Таймаут платформы. AWS Lambda — до 15 минут, Cloudflare Workers — 30 секунд CPU-time, Yandex Cloud Functions — 10 минут. Тяжёлые операции всё равно выносим в отдельную очередь (Yandex Message Queue, SQS, Cloudflare Queues).
  • Stateless. FSM и сессии — обязательно во внешнем хранилище. KV-store (Yandex YDB, Cloudflare KV) идеально для этого.
  • Логирование. У serverless-платформ свой стек логов; интеграция с Sentry или externals — отдельная настройка.
  • Лимиты на размер тела. MAX присылает апдейты до 1 МБ (фото-сообщения с длинной подписью). Большинство платформ это переваривают, но проверьте лимит.

Мониторинг webhook

Главная команда для диагностики webhook — getWebhookInfo:

curl "https://api.max.ru/bot$BOT_TOKEN/getWebhookInfo"

Ответ:

{
  "ok": true,
  "result": {
    "url": "https://bot.example.ru/max/webhook",
    "has_custom_certificate": false,
    "pending_update_count": 0,
    "max_connections": 40,
    "ip_address": "203.0.113.10",
    "last_error_date": 1709123456,
    "last_error_message": "SSL error: Hostname mismatch",
    "allowed_updates": ["message", "callback_query"]
  }
}

На что смотреть:

  • pending_update_count — сколько апдейтов в очереди MAX. Растёт — значит webhook не отвечает 200 или не успевает.
  • last_error_date / last_error_message — последняя ошибка доставки. Типичные: Wrong response from the webhook: 502, Connection timed out, SSL error.
  • ip_address — IP, на который MAX сейчас резолвит ваш домен. После переезда DNS должен обновиться в течение минут.
  • max_connections — лимит параллельных POST-ов от MAX (1–100, по умолчанию 40). Поднимаем при больших нагрузках.

В прод-мониторинг закладываем алерт на pending_update_count > 50 и любое непустое last_error_message.

Подводные камни webhook

Webhook кажется проще, но прячет неочевидные проблемы:

  1. Время ответа на POST. MAX считает webhook упавшим, если ответ не пришёл за ~60 секунд, и повторит апдейт. Тяжёлые операции выносим в фон, в HTTP-обработчике делаем только приём и постановку в очередь.
  2. Идемпотентность. MAX может прислать один апдейт повторно при сбоях — храните update_id обработанных апдейтов.
  3. Безопасность. Эндпоинт публичный — secret_token обязателен, IP-allowlist желателен.
  4. Сертификат. Self-signed нужно явно прокидывать в setWebhook. Проще получать Let's Encrypt — MAX доверяет ему по умолчанию, но не забудьте про автообновление (certbot renew).
  5. CORS не помогает. MAX шлёт server-to-server, никакие CORS-заголовки не защищают.

Подводные камни long polling

Long polling тоже не бесплатный:

  • При обрыве сети getUpdates нужно корректно перезапускать с экспоненциальным бэкоффом, иначе бот молчит до перезапуска. Фреймворки делают это сами, самописный цикл — нет.
  • При ошибке в обработке апдейта надо аккуратно обрабатывать исключения — иначе теряются последующие сообщения.
  • Несколько инстансов с одним токеном работать не будут.
  • При большой нагрузке getUpdates может вернуть до 100 апдейтов за раз — обработчик должен либо параллелить, либо не лагать на одном тяжёлом сообщении.

Гибрид и переключение

В жизненном цикле бота нормально переходить с long polling на webhook. Локальная разработка и dev-стенд — long polling, продакшен — webhook. Между ними переключаемся через переменную окружения и одну из двух веток запуска:

if os.getenv("BOT_MODE") == "webhook":
    await bot.set_webhook(
        url=os.environ["WEBHOOK_URL"],
        secret_token=os.environ["WEBHOOK_SECRET"],
        allowed_updates=["message", "callback_query"],
    )
    # запускаем aiohttp/FastAPI приложение
else:
    await bot.delete_webhook()
    await start_polling(bot)

Итого

Long polling — для прототипов, локальной разработки, ботов за NAT и малых сценариев с одним инстансом. Webhook — для продакшена, серверлесса, высоких нагрузок и горизонтального масштабирования. Большинство современных ботов в проде используют webhook, но переход на него имеет смысл, когда инфраструктура уже есть — для MVP старт с long polling нормален, миграция на webhook занимает несколько часов и проводится без потери апдейтов через корректный deleteWebhook → дренаж → setWebhook.

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

Что выбрать для бота MAX — webhook или long polling?

Long polling — для прототипа, MVP, ботов с небольшой аудиторией (десятки-сотни активных в час), внутренних корпоративных сценариев без публичного HTTPS. Webhook — для прода с тысячами событий в день, для задач с минимальной задержкой (платежи, real-time уведомления), при готовой инфраструктуре с балансировщиком и планах горизонтального масштабирования. Если есть сомнения, выбирайте webhook сразу — переезжать в проде дороже.

Чем webhook отличается от long polling в Bot API MAX?

Long polling — бот сам периодически опрашивает API «есть новые апдейты?», MAX держит соединение открытым до 25–30 секунд или появления событий. Webhook — бот регистрирует HTTPS-URL, MAX сам делает POST на этот URL при появлении события. Разница: webhook даёт минимальную задержку (десятки миллисекунд против секунд), естественно масштабируется через балансер, не создаёт холостых запросов. Long polling проще запустить локально — не нужен публичный HTTPS.

Что нужно для подключения webhook в боте MAX?

Публичный HTTPS-эндпоинт с валидным TLS-сертификатом (Let's Encrypt через Certbot закрывает 90% случаев, самоподписанный не подойдёт), быстрый ответ сервером (несколько секунд максимум — длинная обработка уходит в очередь, бот сразу отвечает 200), защита эндпоинта секретным токеном через заголовок X-Max-Bot-Api-Secret-Token и опционально IP-allowlist подсетей MAX, реализация идемпотентности через хранение update_id в Redis с коротким TTL. Регистрация — POST на api.max.ru/bot<TOKEN>/setWebhook.

Зачем нужен secret_token в setWebhook?

Webhook URL рано или поздно попадёт в логи, в Certificate Transparency, в DNS-историю. Чтобы посторонний с угаданным URL не мог слать поддельные апдейты, в setWebhook передаётся secret_token — произвольная строка от 1 до 256 символов. MAX при каждом POST добавляет заголовок X-Max-Bot-Api-Secret-Token со значением этого секрета. Сервер обязан проверять заголовок и отбрасывать запросы без него или с неверным значением — через constant-time сравнение (hmac.compare_digest), а не через обычное равенство, чтобы защититься от timing-атак.

Как мигрировать с polling на webhook без потери сообщений?

Алгоритм. Подготовить webhook-инстанс не запуская его — развернуть HTTPS-эндпоинт, проверить что отвечает 200. Не трогать polling-инстанс пока, он продолжает работать. Остановить polling-процесс командой деплоя. Дренировать очередь — один раз вызвать getUpdates с offset=last+1 и timeout=0, чтобы подтвердить все обработанные апдейты. Вызвать setWebhook с новым URL, secret_token и drop_pending_updates=false — чтобы накопившиеся за минуту переключения апдейты пришли webhook-у. Запустить webhook-инстанс, проверить через getWebhookInfo, что pending_update_count уменьшается до нуля. Окно простоя — секунды.

Можно ли разрабатывать бота с webhook локально?

Да, через туннели — Cloudflare Tunnel, frp, ngrok. Они проксируют локальный порт через публичный домен с валидным HTTPS, и MAX отправляет webhook туда же. Это даёт те же условия, что в проде, и не требует ломать архитектуру при деплое. Альтернатива — long polling для разработки и webhook для прода, но тогда есть риск ловить разное поведение в двух режимах. Лучше сразу делать webhook через туннель, прописав в локальном .env тот же secret_token, что в setWebhook.

Как мониторить здоровье webhook в MAX?

Главная команда — getWebhookInfo. Возвращает url, has_custom_certificate, pending_update_count (сколько апдейтов в очереди MAX, растёт — значит webhook не отвечает 200 или не успевает), last_error_date и last_error_message (последняя ошибка доставки: Wrong response from the webhook: 502, Connection timed out, SSL error), ip_address (IP куда MAX сейчас резолвит домен), max_connections (лимит параллельных POST-ов, 1–100, по умолчанию 40), allowed_updates. В прод-мониторинг закладываем алерт на pending_update_count > 50 и любое непустое last_error_message.