Есть два способа узнать, что вам пришло письмо. Первый — каждые тридцать секунд бегать к почтовому ящику и заглядывать внутрь. Второй — почтальон звонит в дверь, когда письмо реально есть.
Long Polling — это первый способ. Бот в бесконечном цикле долбит getUpdates на серверы Telegram: «есть что‑нибудь? а сейчас? а сейчас?». Telegram, надо отдать ему должное, держит соединение открытым и отвечает не мгновенно, а когда появляются апдейты — поэтому polling и называется long. Но суть не меняется: инициатор — вы, соединение висит постоянно, и весь этот механизм живёт внутри одного вашего процесса.
Webhook — это почтальон. Вы один раз говорите Telegram: «вот мой HTTPS‑адрес, стучись сюда». Дальше при каждом новом сообщении Telegram сам делает POST‑запрос на ваш эндпоинт с JSON‑апдейтом внутри. Нет сообщений — нет запросов. Нет запросов — нет расхода ресурсов.
Для пет‑проекта на пять пользователей разницы никакой, поллинг даже удобнее — не нужен домен и сертификат. Но как только бот становится частью нагруженной системы, вебхуки выигрывают по всем фронтам: апдейт прилетает мгновенно, а не в конце очередного цикла опроса; входящий трафик — это обычные HTTP‑запросы, которые можно балансировать между несколькими инстансами; и главное — бот превращается в обычный веб‑сервис, к которому применим весь стандартный инструментарий: Nginx, healthcheck’и, метрики, горизонтальное масштабирование.
Почему start_polling() — мина замедленного действия
Классический туториальный бот выглядит так: один процесс, один поток, внутри — вечный цикл поллинга. Проблемы начинаются не сразу, и в этом их коварство.
Один процесс — одна точка отказа. Поллинг принципиально нельзя масштабировать горизонтально: если два инстанса одновременно вызовут getUpdates с одним токеном, Telegram ответит ошибкой Conflict: terminated by other getUpdates request. Ваш бот навсегда останется в единственном экземпляре. Упал процесс — бот молчит, пока supervisor его не поднимет, а всё это время апдейты копятся в очереди на стороне Telegram.
Деплой с даунтаймом. Раз инстанс может быть только один, blue‑green деплой невозможен в принципе. Каждый релиз — это «убили старый процесс, подняли новый», и между этими событиями бот мёртв.
Цикл поллинга — это тоже код, и он тоже падает. Сетевой таймаут, кривой апдейт, необработанное исключение в недрах цикла — и вот бот формально жив (процесс висит), но апдейты больше не забирает. Такие «зомби» — классика жанра: мониторинг молчит, пользователи пишут в поддержку.
Ресурсы жгутся впустую. Постоянно висящее соединение и цикл опроса работают даже ночью, когда боту никто не пишет. Для одного бота — копейки. Для парка из тридцати ботов на одной машине — уже заметная строчка в htop.
Вебхуки снимают все четыре пункта разом: бот становится stateless HTTP‑сервисом, инстансов может быть сколько угодно, упавший под перезапускается без потери апдейтов (Telegram ретраит недоставленные запросы), а в простое сервис не делает вообще ничего.
Собираем: aiogram 3 + FastAPI
Стек: aiogram 3 (диспетчер и хэндлеры), FastAPI (приём POST‑запросов от Telegram), uvicorn (ASGI‑сервер). Идея простая: FastAPI ловит запрос на эндпоинт, валидирует его и скармливает апдейт диспетчеру aiogram. Хэндлеры при этом пишутся ровно так же, как при поллинге, — бизнес‑логика от транспорта не зависит.
Структура проекта:
tg-webhook-bot/├── app/│ ├── __init__.py│ ├── config.py # настройки из переменных окружения│ ├── handlers.py # хэндлеры aiogram│ └── main.py # FastAPI-приложение и lifespan├── requirements.txt├── Dockerfile└── docker-compose.yml
Конфиг
Все секреты — из окружения, никаких токенов в коде. Pydantic Settings сам подтянет переменные и упадёт на старте, если чего‑то не хватает, — лучше упасть при запуске, чем молча работать с пустым токеном.
# app/config.pyfrom pydantic_settings import BaseSettingsclass Settings(BaseSettings): bot_token: str webhook_host: str # https://bot.example.com webhook_path: str = "/webhook/telegram" webhook_secret: str # произвольная строка, наш "пароль" для Telegram @property def webhook_url(self) -> str: return f"{self.webhook_host}{self.webhook_path}"settings = Settings()
Хэндлеры
Ничего специфичного для вебхуков — обычный роутер aiogram. Именно поэтому миграция с поллинга безболезненна: этот файл при переезде не меняется вообще.
# app/handlers.pyfrom aiogram import Routerfrom aiogram.filters import CommandStartfrom aiogram.types import Messagerouter = Router()@router.message(CommandStart())async def cmd_start(message: Message) -> None: await message.answer( "Привет! Я живу на вебхуках и не дёргаю Telegram почём зря." )@router.message()async def echo(message: Message) -> None: await message.answer(f"Ты написал: {message.text}")
Ядро: FastAPI, lifespan и эндпоинт
Самое интересное. Три задачи:
-
При старте приложения — зарегистрировать вебхук через Bot API (
set_webhook). -
Принимать POST‑запросы от Telegram, проверять секрет и передавать апдейты в диспетчер.
-
При остановке — снять вебхук и корректно закрыть сессию бота.
Для старта/шатдауна в FastAPI есть механизм lifespan: асинхронный контекстный менеджер, где код до yield выполняется при запуске, а после — при остановке. Ровно то, что нужно.
# app/main.pyfrom contextlib import asynccontextmanagerfrom aiogram import Bot, Dispatcherfrom aiogram.client.default import DefaultBotPropertiesfrom aiogram.enums import ParseModefrom aiogram.types import Updatefrom fastapi import FastAPI, Request, Response, statusfrom app.config import settingsfrom app.handlers import routerbot = Bot( token=settings.bot_token, default=DefaultBotProperties(parse_mode=ParseMode.HTML),)dp = Dispatcher()dp.include_router(router)@asynccontextmanagerasync def lifespan(app: FastAPI): # Старт: регистрируем вебхук. # drop_pending_updates=True — не разгребать залежи апдейтов, # накопившиеся, пока бот был выключен. Для продовых ботов # с важными командами лучше поставить False. await bot.set_webhook( url=settings.webhook_url, secret_token=settings.webhook_secret, drop_pending_updates=True, allowed_updates=dp.resolve_used_update_types(), ) yield # Шатдаун: снимаем вебхук и закрываем HTTP-сессию бота. await bot.delete_webhook() await bot.session.close()app = FastAPI(lifespan=lifespan)@app.post(settings.webhook_path)async def telegram_webhook(request: Request) -> Response: # Telegram присылает секрет в заголовке. Всё, что пришло без него, — # не Telegram, а любопытные боты-сканеры. До свидания. secret = request.headers.get("X-Telegram-Bot-Api-Secret-Token") if secret != settings.webhook_secret: return Response(status_code=status.HTTP_403_FORBIDDEN) update = Update.model_validate( await request.json(), context={"bot": bot}, ) await dp.feed_update(bot, update) return Response(status_code=status.HTTP_200_OK)@app.get("/health")async def health() -> dict: return {"status": "ok"}
Пара неочевидных моментов:
-
secret_token— обязательная вещь на проде. Ваш эндпоинт торчит в интернет, и без проверки заголовка любой желающий сможет слать вам поддельные «апдейты». Секрет передаётся вset_webhook, и дальше Telegram подписывает им каждый запрос. -
allowed_updates=dp.resolve_used_update_types()— просим Telegram присылать только те типы апдейтов, на которые у нас реально есть хэндлеры. Меньше мусорного трафика. -
feed_update— точка стыковки миров: JSON от Telegram превращается в объектUpdateи уходит в диспетчер, дальше aiogram сам разрулит роутинг по хэндлерам. -
/health— копеечный эндпоинт, который окупится при первом же настроенном healthcheck’е в оркестраторе.
Зависимости
# requirements.txtaiogram==3.15.0fastapi==0.115.6uvicorn[standard]==0.34.0pydantic-settings==2.7.0
Версии пиньте всегда. «Поехавший» после pip install -U прод — жанр, в котором не хочется публиковаться.
Docker: одна команда — и бот в проде
Dockerfile — минимальный, но с уважением к best practices: slim‑база, слой с зависимостями кэшируется отдельно от кода, процесс работает не от рута.
# DockerfileFROM python:3.11-slimENV PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1WORKDIR /app# Зависимости — отдельным слоём: пока requirements.txt не меняется,# Docker переиспользует кэш, и пересборка занимает секунды.COPY requirements.txt .RUN pip install --no-cache-dir -r requirements.txtCOPY app/ ./app/# Контейнер от рута — дурной тон даже для бота-эхошкиRUN useradd --create-home appuserUSER appuserEXPOSE 8000CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
И compose, чтобы поднимать всё одной командой:
# docker-compose.ymlservices: bot: build: . restart: unless-stopped ports: - "8000:8000" environment: BOT_TOKEN: ${BOT_TOKEN} WEBHOOK_HOST: ${WEBHOOK_HOST} WEBHOOK_SECRET: ${WEBHOOK_SECRET} healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"] interval: 30s timeout: 5s retries: 3
Секреты кладём в .env рядом с compose‑файлом (и, разумеется, в .gitignore):
BOT_TOKEN=1234567890:AAE...ваш_токенWEBHOOK_HOST=https://bot.example.comWEBHOOK_SECRET=любая-длинная-случайная-строка
Запуск:
docker compose up -d --build
Всё. Контейнер поднялся, lifespan отработал, set_webhook улетел в Telegram — бот принимает апдейты. docker compose down — вебхук снялся, сессия закрылась, никаких висящих хвостов.
Нюансы деплоя: HTTPS обязателен
-
Telegram шлёт вебхуки только на HTTPS. Голый HTTP‑эндпоинт зарегистрировать не получится —
set_webhookпросто вернёт ошибку. Поддерживаемые порты: 443, 80, 88, 8443. -
На проде стандартная связка — Nginx (или Caddy/Traefik) в роли reverse proxy перед контейнером: он терминирует TLS с бесплатным сертификатом от Let’s Encrypt и проксирует запросы на
localhost:8000. Приложение при этом про SSL не знает вообще ничего — и это правильно. -
Для локальной разработки сертификат заводить не нужно:
ngrok http 8000выдаёт временный публичный HTTPS‑адрес, туннелирующий трафик на вашу машину. Подставляете его вWEBHOOK_HOST, перезапускаете контейнер — и дебажите вебхуки прямо с ноутбука.
Вывод
Вебхуки — это не «усложнение ради усложнения», а перевод бота в нормальную архитектурную парадигму: stateless HTTP‑сервис вместо вечного цикла в единственном процессе. Ресурсы тратятся только на реальные сообщения, апдейты приходят мгновенно, инстансов может быть сколько угодно, а деплой перестаёт означать даунтайм. Час на миграцию — и ваш бот из скрипта на VPS превращается в сервис, за который не стыдно.
ссылка на оригинал статьи https://habr.com/ru/articles/1057016/