Есть два способа узнать, что вам пришло письмо. Первый — каждые тридцать секунд бегать к почтовому ящику и заглядывать внутрь. Второй — почтальон звонит в дверь, когда письмо реально есть.
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.py from pydantic_settings import BaseSettings class 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.py from aiogram import Router from aiogram.filters import CommandStart from aiogram.types import Message router = 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.py from contextlib import asynccontextmanager from aiogram import Bot, Dispatcher from aiogram.client.default import DefaultBotProperties from aiogram.enums import ParseMode from aiogram.types import Update from fastapi import FastAPI, Request, Response, status from app.config import settings from app.handlers import router bot = Bot( token=settings.bot_token, default=DefaultBotProperties(parse_mode=ParseMode.HTML), ) dp = Dispatcher() dp.include_router(router) @asynccontextmanager async 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.txt aiogram==3.15.0 fastapi==0.115.6 uvicorn[standard]==0.34.0 pydantic-settings==2.7.0
Версии пиньте всегда. «Поехавший» после pip install -U прод — жанр, в котором не хочется публиковаться.
Docker: одна команда — и бот в проде
Dockerfile — минимальный, но с уважением к best practices: slim‑база, слой с зависимостями кэшируется отдельно от кода, процесс работает не от рута.
# Dockerfile FROM python:3.11-slim ENV PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 WORKDIR /app # Зависимости — отдельным слоём: пока requirements.txt не меняется, # Docker переиспользует кэш, и пересборка занимает секунды. COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app/ ./app/ # Контейнер от рута — дурной тон даже для бота-эхошки RUN useradd --create-home appuser USER appuser EXPOSE 8000 CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
И compose, чтобы поднимать всё одной командой:
# docker-compose.yml services: 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.com WEBHOOK_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 превращается в сервис, за который не стыдно.