Ваш бот всё ещё дёргает Telegram каждую секунду? Чиним архитектуру: вебхуки на FastAPI в контейнере

от автора

Есть два способа узнать, что вам пришло письмо. Первый — каждые тридцать секунд бегать к почтовому ящику и заглядывать внутрь. Второй — почтальон звонит в дверь, когда письмо реально есть.

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 и эндпоинт

Самое интересное. Три задачи:

  1. При старте приложения — зарегистрировать вебхук через Bot API (set_webhook).

  2. Принимать POST‑запросы от Telegram, проверять секрет и передавать апдейты в диспетчер.

  3. При остановке — снять вебхук и корректно закрыть сессию бота.

Для старта/шатдауна в 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/