Архитектура Telegram Mini App с ИИ-аватарами: разбор стека на 250+ пользователей
Глубокий технический разбор production-архитектуры Telegram Mini App: authentication через WebApp.initData, интеграция T-Bank API для платежей, работа с лимитами WebView, docker-compose деплой с zero-downtime и обработка 250+ активных пользователей на FastAPI + React стеке.
Введение: почему Mini App, а не классический бот
Когда перед нами встала задача создать сервис генерации ИИ-аватаров с возможностью оплаты и управления заказами, первый вопрос звучал так: делать классического Telegram-бота с inline-кнопками или сразу идти в Mini App? Классический бот ограничен UI: кнопки, текст, максимум — встроенный веб-вью через InlineKeyboardButton с url. Для сложных форм, галерей изображений, drag-and-drop загрузки фото это неудобно.
Telegram Mini Apps (ранее Web Apps) решают эту проблему: полноценное SPA-приложение внутри мессенджера, доступ к Telegram SDK для получения user ID, theme, viewport. При этом сохраняется нативный UX: пользователь не покидает Telegram, не переходит в браузер. По статистике конверсия в оплату в Mini App на 18-22% выше, чем при редиректе на внешний сайт — пользователь остаётся в привычной среде.
Наш сервис обрабатывает загрузку 10-15 фотографий пользователя, отправку их в ML-пайплайн (Stable Diffusion fine-tuning + inference), генерацию 50-100 аватаров и доставку через Telegram. За три месяца работы мы обслужили 250+ уникальных пользователей, обработали 3800+ изображений, провели 180+ успешных транзакций. Средний RPS в пиковые часы — 12-15 запросов в секунду, p95 latency API — 340 мс.
Выбор технологического стека и обоснование
Стек выбирался под критерии: быстрая разработка, простота деплоя, возможность горизонтального масштабирования, нативная поддержка async для работы с Telegram Bot API и внешними ML-сервисами.
| Компонент | Технология | Обоснование |
|---|---|---|
| Backend API | FastAPI 0.104 | Async из коробки, автогенерация OpenAPI, Pydantic для валидации, скорость разработки |
| ORM | SQLAlchemy 2.0 (async) | Зрелая экосистема, поддержка async с asyncpg драйвером |
| БД | PostgreSQL 15 | JSONB для хранения метаданных изображений, полнотекстовый поиск, надёжность |
| Кэш/очереди | Redis 7.2 | Сессии пользователей, rate limiting, Celery broker для фоновых задач |
| Task queue | Celery 5.3 | Обработка ML-задач (fine-tuning занимает 8-12 минут), retry логика |
| Frontend | React 18 + Vite | Быстрый dev-сервер, HMR, лёгкая интеграция с Telegram WebApp SDK |
| Telegram SDK | @twa-dev/sdk | Типизированная обёртка над window.Telegram.WebApp |
| Деплой | Docker Compose | Простота для MVP, volume mounts для zero-downtime, готовность к миграции на K8s |
Альтернативы рассматривались: Django + DRF (отказались из-за overhead и меньшей async-зрелости на момент старта), Node.js + Express (команда сильнее в Python, ML-пайплайн на PyTorch), Go (время разработки выше, меньше готовых библиотек для ML).
Authentication flow через WebApp.initData
Ключевая особенность Mini App — аутентификация без паролей. Telegram передаёт в приложение строку initData, содержащую подписанные данные пользователя. Схема:
- Пользователь открывает Mini App через бота (команда /start с параметром или inline-кнопка с web_app).
- Telegram WebView инициализируется, в
window.Telegram.WebApp.initDataлежит строка видаquery_id=AAHdF...&user=%7B%22id%22%3A12345...&auth_date=1703001234&hash=abc123... - Frontend отправляет эту строку на backend endpoint
POST /api/auth/telegram. - Backend валидирует подпись через HMAC-SHA256 с секретом
HMAC(bot_token, "WebAppData"). - Если подпись валидна и
auth_dateне старше 86400 секунд — создаём JWT-токен, возвращаем клиенту. - Все последующие запросы идут с
Authorization: Bearer <JWT>.
Код валидации на FastAPI:
import hmac
import hashlib
from urllib.parse import parse_qsl
from datetime import datetime, timedelta
from fastapi import HTTPException, status
def validate_telegram_init_data(init_data: str, bot_token: str) -> dict:
"""
Валидирует initData от Telegram WebApp.
Возвращает распарсенные данные пользователя или выбрасывает 401.
Алгоритм:
1. Парсим query string в словарь
2. Извлекаем hash, остальные пары сортируем по ключу
3. Формируем data_check_string (key=value\nkey=value...)
4. Вычисляем secret_key = HMAC_SHA256(bot_token, "WebAppData")
5. Вычисляем hash = HMAC_SHA256(secret_key, data_check_string)
6. Сравниваем с переданным hash
"""
parsed = dict(parse_qsl(init_data))
received_hash = parsed.pop('hash', None)
if not received_hash:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing hash in initData"
)
# Проверка auth_date (не старше 24 часов)
auth_date = int(parsed.get('auth_date', 0))
if datetime.utcnow() - datetime.utcfromtimestamp(auth_date) > timedelta(hours=24):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="initData expired"
)
# Формируем data_check_string
data_check_string = '\n'.join(
f"{k}={v}" for k, v in sorted(parsed.items())
)
# Вычисляем secret_key
secret_key = hmac.new(
key=b"WebAppData",
msg=bot_token.encode(),
digestmod=hashlib.sha256
).digest()
# Вычисляем hash
calculated_hash = hmac.new(
key=secret_key,
msg=data_check_string.encode(),
digestmod=hashlib.sha256
).hexdigest()
# Constant-time comparison для защиты от timing attacks
if not hmac.compare_digest(calculated_hash, received_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid hash signature"
)
# Парсим user JSON
import json
user_data = json.loads(parsed['user'])
return {
'telegram_id': user_data['id'],
'first_name': user_data.get('first_name', ''),
'last_name': user_data.get('last_name', ''),
'username': user_data.get('username'),
'language_code': user_data.get('language_code', 'en'),
}Важные моменты:
- Используем
hmac.compare_digestвместо обычного==для защиты от timing attacks. - Проверяем
auth_date— Telegram рекомендует не принимать данные старше 24 часов. - Не храним
bot_tokenв коде — читаем из переменной окруженияTELEGRAM_BOT_TOKEN. - После валидации создаём пользователя в БД (если новый) или обновляем
last_seen.
JWT генерируем с помощью python-jose, payload содержит telegram_id и exp (время жизни 7 дней). Refresh-токены не используем — при истечении пользователь просто переоткрывает Mini App, получаем новый initData.
Интеграция платежей T-Bank внутри Mini App
Telegram Bot API поддерживает встроенные платежи через провайдеров (Stripe, ЮKassa и другие), но для российского рынка мы выбрали T-Bank (ex-Тинькофф) API напрямую. Причины: lower комиссия (2.5% vs 3.5-4% через Telegram Payments), больше контроля над flow, возможность сохранять карты для рекуррентных платежей.
Схема оплаты:
- Пользователь выбирает пакет (например, 50 аватаров за 599 рублей), нажимает «Оплатить».
- Frontend отправляет
POST /api/ordersсpackage_id. - Backend создаёт запись в таблице
ordersсо статусомpending, генерируетorder_id. - Вызываем T-Bank API
Init(создание платежа), получаемPaymentURL. - Возвращаем клиенту
payment_url. - Frontend открывает
payment_urlчерезwindow.Telegram.WebApp.openLink(url)— открывается in-app browser. - Пользователь вводит данные карты, подтверждает 3DS.
- T-Bank редиректит на наш
SuccessURL(например,https://example.com/payment/success?orderId=...). - Одновременно T-Bank отправляет webhook на
NotificationURLс финальным статусом. - Backend обрабатывает webhook, обновляет статус заказа на
paid, запускает Celery-задачу генерации аватаров.
Код инициализации платежа:
import httpx
from decimal import Decimal
class TBankPaymentService:
def __init__(self, terminal_key: str, secret_key: str):
self.terminal_key = terminal_key
self.secret_key = secret_key
self.api_url = "https://securepay.tinkoff.ru/v2/"
def _generate_token(self, params: dict) -> str:
"""
Генерирует токен для подписи запроса.
T-Bank требует: сортируем параметры + Password, конкатенируем, SHA256.
"""
import hashlib
# Добавляем Password (secret_key) и TerminalKey
token_params = {**params, 'Password': self.secret_key}
# Сортируем по ключу, конкатенируем значения
concatenated = ''.join(
str(token_params[k]) for k in sorted(token_params.keys())
)
return hashlib.sha256(concatenated.encode()).hexdigest()
async def init_payment(
self,
order_id: str,
amount: Decimal,
description: str,
success_url: str,
notification_url: str
) -> dict:
"""
Инициализирует платёж в T-Bank.
Возвращает PaymentURL для редиректа пользователя.
amount передаётся в копейках (599 руб = 59900 коп).
"""
amount_cents = int(amount * 100)
params = {
'TerminalKey': self.terminal_key,
'Amount': amount_cents,
'OrderId': order_id,
'Description': description,
'SuccessURL': success_url,
'NotificationURL': notification_url,
}
# Генерируем токен
params['Token'] = self._generate_token(params)
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.api_url}Init",
json=params,
timeout=10.0
)
response.raise_for_status()
data = response.json()
if not data.get('Success'):
raise Exception(f"T-Bank Init failed: {data.get('Message')}")
return {
'payment_id': data['PaymentId'],
'payment_url': data['PaymentURL'],
'status': data['Status'],
}
def validate_notification(self, notification: dict) -> bool:
"""
Валидирует webhook от T-Bank.
Проверяем Token в уведомлении.
"""
received_token = notification.pop('Token', None)
calculated_token = self._generate_token(notification)
return received_token == calculated_tokenВажные детали:
- T-Bank требует amount в копейках (целое число), поэтому умножаем на 100.
- Токен генерируется из всех параметров +
Password(секретный ключ терминала). - В webhook обязательно валидируем
Token— иначе злоумышленник может отправить фейковое уведомление об оплате. NotificationURLдолжен быть доступен извне (не localhost), используем ngrok для разработки или публичный домен для прода.
После успешной оплаты обновляем статус заказа и отправляем пользователю сообщение через Telegram Bot API: «Оплата получена, начинаем генерацию аватаров. Это займёт 10-15 минут».
Лимиты и особенности Telegram WebView
Telegram WebView — это не полноценный браузер, а встроенный компонент на базе WebKit (iOS) и Chromium (Android/Desktop). Есть ряд ограничений, которые нужно учитывать:
Cookies и localStorage
WebView поддерживает cookies и localStorage, но с нюансами:
- На iOS cookies могут сбрасываться при закрытии Mini App (зависит от версии Telegram).
- localStorage работает стабильно, но ограничен 5-10 МБ.
- Рекомендация: не храним критичные данные в localStorage, используем его только для кэша UI-состояния (выбранная тема, язык). Токен JWT храним в памяти React-компонента или в sessionStorage (очищается при закрытии вкладки).
Viewport и Safe Area
Telegram добавляет свои UI-элементы (header с названием бота, bottom bar с кнопками). Реальная высота viewport меньше, чем window.innerHeight. Telegram SDK предоставляет:
window.Telegram.WebApp.viewportHeight— реальная высота доступной области.window.Telegram.WebApp.viewportStableHeight— высота без учёта появляющейся клавиатуры.window.Telegram.WebApp.safeAreaInset— отступы для iOS (notch, home indicator).
Мы используем CSS-переменные для адаптации:
:root {
--tg-viewport-height: 100vh;
--tg-viewport-stable-height: 100vh;
--tg-safe-area-inset-top: 0px;
--tg-safe-area-inset-bottom: 0px;
}
.app-container {
height: var(--tg-viewport-stable-height);
padding-top: var(--tg-safe-area-inset-top);
padding-bottom: var(--tg-safe-area-inset-bottom);
overflow-y: auto;
}В React при инициализации обновляем эти переменные:
useEffect(() => {
const tg = window.Telegram.WebApp;
const updateViewport = () => {
document.documentElement.style.setProperty(
'--tg-viewport-height',
`${tg.viewportHeight}px`
);
document.documentElement.style.setProperty(
'--tg-viewport-stable-height',
`${tg.viewportStableHeight}px`
);
document.documentElement.style.setProperty(
'--tg-safe-area-inset-bottom',
`${tg.safeAreaInset.bottom}px`
);
};
updateViewport();
tg.onEvent('viewportChanged', updateViewport);
return () => tg.offEvent('viewportChanged', updateViewport);
}, []);Ограничения по размеру файлов
Telegram WebView имеет лимит на размер загружаемых файлов через <input type="file"> — около 20 МБ на файл (зависит от платформы). Для загрузки 10-15 фотографий пользователя мы:
- Сжимаем изображения на клиенте с помощью
browser-image-compressionдо 1920x1920, quality 0.85. - Отправляем по одному файлу через
FormData+fetch, показываем прогресс. - На бэкенде дополнительно валидируем MIME-type и размер (не более 5 МБ после сжатия).
Отсутствие поддержки некоторых Web API
WebView не поддерживает:
- Service Workers (нет офлайн-режима).
- Push Notifications (используем Telegram Bot API для уведомлений).
- WebRTC (если нужно — придётся открывать внешний браузер).
- Clipboard API частично (на iOS
navigator.clipboard.writeTextможет не работать без user gesture).
Архитектура бэкенда: FastAPI + PostgreSQL + Redis
Бэкенд состоит из трёх основных сервисов:
API Service (FastAPI)
Основной HTTP API на FastAPI. Структура проекта:
app/
├── main.py # Точка входа, создание FastAPI app
├── config.py # Настройки из env (pydantic BaseSettings)
├── dependencies.py # Dependency injection (DB session, current user)
├── models/ # SQLAlchemy модели
│ ├── user.py
│ ├── order.py
│ └── avatar.py
├── schemas/ # Pydantic схемы (request/response)
├── routers/ # API endpoints
│ ├── auth.py # POST /auth/telegram
│ ├── orders.py # CRUD заказов
│ ├── avatars.py # Получение сгенерированных аватаров
│ └── webhooks.py # POST /webhooks/tbank
├── services/ # Бизнес-логика
│ ├── auth_service.py
│ ├── payment_service.py
│ └── avatar_service.py
└── tasks/ # Celery задачи
└── generate_avatars.pyИспользуем async SQLAlchemy 2.0 с asyncpg драйвером. Пример модели Order:
from sqlalchemy import Column, Integer, String, Numeric, DateTime, ForeignKey, Enum
from sqlalchemy.orm import relationship
from datetime import datetime
import enum
class OrderStatus(str, enum.Enum):
pending = "pending"
paid = "paid"
processing = "processing"
completed = "completed"
failed = "failed"
class Order(Base):
__tablename__ = "orders"
id = Column(Integer, primary_key=True)
order_id = Column(String(64), unique=True, index=True) # UUID для T-Bank
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
package_id = Column(Integer, ForeignKey("packages.id"), nullable=False)
amount = Column(Numeric(10, 2), nullable=False) # В рублях
status = Column(Enum(OrderStatus), default=OrderStatus.pending, index=True)
payment_id = Column(String(64), nullable=True) # PaymentId от T-Bank
payment_url = Column(String(512), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, index=True)
paid_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True)
# Relationships
user = relationship("User", back_populates="orders")
package = relationship("Package")
avatars = relationship("Avatar", back_populates="order")Для rate limiting используем Redis + middleware:
from fastapi import Request, HTTPException, status
from redis.asyncio import Redis
import time
class RateLimitMiddleware:
def __init__(self, redis: Redis, max_requests: int = 100, window: int = 60):
self.redis = redis
self.max_requests = max_requests
self.window = window
async def __call__(self, request: Request, call_next):
# Получаем telegram_id из JWT (если авторизован)
user_id = getattr(request.state, 'user_id', None)
if not user_id:
# Для неавторизованных — по IP
user_id = request.client.host
key = f"rate_limit:{user_id}"
current = int(time.time())
# Используем sorted set для хранения timestamps запросов
pipe = self.redis.pipeline()
pipe.zremrangebyscore(key, 0, current - self.window)
pipe.zadd(key, {str(current): current})
pipe.zcard(key)
pipe.expire(key, self.window)
results = await pipe.execute()
request_count = results[2]
if request_count > self.max_requests:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"Rate limit exceeded: {self.max_requests} requests per {self.window}s"
)
response = await call_next(request)
return responseCelery Worker
Обрабатывает долгие задачи (генерация аватаров). Конфигурация:
- Broker: Redis (очередь задач).
- Backend: PostgreSQL (результаты задач сохраняем в таблицу
celery_taskmeta). - Concurrency: 4 worker процесса (на сервере 8 CPU cores, оставляем половину для API).
- Task timeout: 1800 секунд (30 минут на генерацию, если ML-сервис перегружен).
Пример задачи:
from celery import Celery, Task
from app.services.ml_service import MLService
from app.models import Order, Avatar
from sqlalchemy.ext.asyncio import AsyncSession
celery_app = Celery('tasks', broker='redis://redis:6379/0')
class DatabaseTask(Task):
"""Base task с async DB session"""
_session = None
@property
def session(self) -> AsyncSession:
if self._session is None:
from app.database import async_session_maker
self._session = async_session_maker()
return self._session
@celery_app.task(base=DatabaseTask, bind=True, max_retries=3)
def generate_avatars_task(self, order_id: int):
"""
Генерирует аватары для заказа.
1. Получает фото пользователя из S3
2. Отправляет в ML-сервис (fine-tuning + inference)
3. Сохраняет результаты в БД и S3
4. Отправляет уведомление в Telegram
"""
import asyncio
async def _generate():
async with self.session as session:
order = await session.get(Order, order_id)
if not order:
raise ValueError(f"Order {order_id} not found")
order.status = OrderStatus.processing
await session.commit()
try:
ml_service = MLService()
# Скачиваем фото пользователя
user_photos = await ml_service.download_user_photos(order.user_id)
# Fine-tuning (8-12 минут)
model_path = await ml_service.fine_tune(user_photos)
# Inference (50 аватаров, 3-5 минут)
generated_images = await ml_service.generate_avatars(
model_path,
count=order.package.avatar_count
)
# Сохраняем в S3 и БД
for idx, image_data in enumerate(generated_images):
s3_url = await ml_service.upload_to_s3(
image_data,
f"avatars/{order.user_id}/{order.order_id}/{idx}.jpg"
)
avatar = Avatar(
order_id=order.id,
image_url=s3_url,
position=idx
)
session.add(avatar)
order.status = OrderStatus.completed
order.completed_at = datetime.utcnow()
await session.commit()
# Отправляем уведомление
from app.services.telegram_service import send_completion_message
await send_completion_message(order.user.telegram_id, order.id)
except Exception as e:
order.status = OrderStatus.failed
await session.commit()
raise self.retry(exc=e, countdown=300) # Retry через 5 минут
asyncio.run(_generate())PostgreSQL
Схема БД включает 6 основных таблиц:
users— пользователи (telegram_id, username, created_at, last_seen).packages— тарифные пакеты (название, цена, количество аватаров).orders— заказы (связь user + package, статус, payment_id).avatars— сгенерированные аватары (order_id, image_url, position).user_photos— загруженные фото пользователя (user_id, s3_url, uploaded_at).celery_taskmeta— результаты Celery задач (task_id, status, result).
Индексы:
users.telegram_id— уникальный, для быстрого поиска при аутентификации.orders.status, orders.created_at— композитный, для выборки активных заказов.avatars.order_id— для получения всех аватаров заказа.
Используем Alembic для миграций. Пример миграции добавления поля payment_id:
"""add payment_id to orders
Revision ID: a1b2c3d4e5f6
Revises: previous_revision
Create Date: 2024-01-15 10:30:00
"""
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column('orders', sa.Column('payment_id', sa.String(64), nullable=True))
op.create_index('ix_orders_payment_id', 'orders', ['payment_id'])
def downgrade():
op.drop_index('ix_orders_payment_id', 'orders')
op.drop_column('orders', 'payment_id')Frontend на React: взаимодействие с Telegram SDK
Frontend — SPA на React 18 с React Router для навигации. Сборка через Vite, деплой статики на Nginx.
Инициализация Telegram WebApp
В корневом компоненте инициализируем SDK:
import { useEffect } from 'react';
import { useTelegram } from './hooks/useTelegram';
function App() {
const { tg, user } = useTelegram();
useEffect(() => {
// Разворачиваем WebApp на весь экран
tg.expand();
// Включаем закрывающую кнопку
tg.BackButton.show();
tg.BackButton.onClick(() => {
window.history.back();
});
// Устанавливаем цвета темы
document.documentElement.style.setProperty(
'--tg-theme-bg-color',
tg.themeParams.bg_color || '#ffffff'
);
document.documentElement.style.setProperty(
'--tg-theme-text-color',
tg.themeParams.text_color || '#000000'
);
// Уведомляем Telegram, что приложение готово
tg.ready();
}, [tg]);
return (
{/* Router и компоненты */}
);
}Хук useTelegram:
export function useTelegram() {
const tg = window.Telegram.WebApp;
return {
tg,
user: tg.initDataUnsafe?.user,
queryId: tg.initDataUnsafe?.query_id,
initData: tg.initData,
};
}Аутентификация и API-клиент
Создаём axios instance с interceptor для добавления JWT:
import axios from 'axios';
import { useTelegram } from './useTelegram';
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL,
timeout: 30000,
});
// Interceptor для добавления токена
api.interceptors.request.use((config) => {
const token = sessionStorage.getItem('jwt_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Interceptor для обработки 401
api.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
// Токен истёк, переаутентифицируемся
const { initData } = useTelegram();
const { data } = await axios.post(
`${import.meta.env.VITE_API_URL}/api/auth/telegram`,
{ init_data: initData }
);
sessionStorage.setItem('jwt_token', data.access_token);
// Повторяем исходный запрос
error.config.headers.Authorization = `Bearer ${data.access_token}`;
return axios.request(error.config);
}
return Promise.reject(error);
}
);
export default api;Загрузка фотографий
Компонент загрузки с прогрессом и сжатием:
import { useState } from 'react';
import imageCompression from 'browser-image-compression';
import api from '../api';
function PhotoUploader() {
const [files, setFiles] = useState([]);
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const handleFileChange = async (e) => {
const selectedFiles = Array.from(e.target.files);
// Сжимаем изображения
const compressed = await Promise.all(
selectedFiles.map(file =>
imageCompression(file, {
maxSizeMB: 2,
maxWidthOrHeight: 1920,
useWebWorker: true,
})
)
);
setFiles(compressed);
};
const handleUpload = async () => {
setUploading(true);
setProgress(0);
for (let i = 0; i < files.length; i++) {
const formData = new FormData();
formData.append('photo', files[i]);
await api.post('/api/photos/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
setProgress(((i + 1) / files.length) * 100);
}
setUploading(false);
// Переходим к выбору пакета
};
return (
Выбрано фото: {files.length}
{uploading &&
);
}Деплой через docker-compose с zero-downtime
Используем docker-compose для оркестрации сервисов. Структура:
version: '3.8'
services:
postgres:
image: postgres:15-alpine
environment:
POSTGRES_DB: avatarbox
POSTGRES_USER: avatarbox
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U avatarbox"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7.2-alpine
command: redis-server --appendonly yes
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
api:
build:
context: ./backend
dockerfile: Dockerfile
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
environment:
DATABASE_URL: postgresql+asyncpg://avatarbox:${DB_PASSWORD}@postgres:5432/avatarbox
REDIS_URL: redis://redis:6379/0
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
TBANK_TERMINAL_KEY: ${TBANK_TERMINAL_KEY}
TBANK_SECRET_KEY: ${TBANK_SECRET_KEY}
volumes:
- ./backend/app:/app/app:ro # Read-only для безопасности
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
deploy:
replicas: 2 # Для zero-downtime
update_config:
parallelism: 1
delay: 10s
order: start-first # Запускаем новый контейнер до остановки старого
celery:
build:
context: ./backend
dockerfile: Dockerfile
command: celery -A app.tasks.celery_app worker --loglevel=info --concurrency=4
environment:
DATABASE_URL: postgresql+asyncpg://avatarbox:${DB_PASSWORD}@postgres:5432/avatarbox
REDIS_URL: redis://redis:6379/0
S3_BUCKET: ${S3_BUCKET}
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY}
volumes:
- ./backend/app:/app/app:ro
depends_on:
- postgres
- redis
nginx:
image: nginx:1.25-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./frontend/dist:/usr/share/nginx/html:ro
- ./certbot/conf:/etc/letsencrypt:ro
- ./certbot/www:/var/www/certbot:ro
depends_on:
- api
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/health"]
interval: 30s
timeout: 5s
retries: 3
volumes:
postgres_data:
redis_data:Zero-downtime deployment
Ключевые моменты:
- Healthchecks: каждый сервис имеет healthcheck. Docker Compose не направляет трафик на контейнер, пока он не healthy.
- Rolling update:
deploy.update_config.order: start-first— сначала запускается новый контейнер, проходит healthcheck, только потом останавливается старый. - Graceful shutdown: в FastAPI добавляем обработчик SIGTERM для завершения активных запросов перед остановкой.
Пример graceful shutdown в main.py:
import signal
import asyncio
from fastapi import FastAPI
app = FastAPI()
# Флаг для graceful shutdown
shutdown_event = asyncio.Event()
@app.on_event("startup")
async def startup():
# Регистрируем обработчик SIGTERM
loop = asyncio.get_event_loop()
loop.add_signal_handler(signal.SIGTERM, lambda: shutdown_event.set())
@app.on_event("shutdown")
async def shutdown():
# Ждём завершения активных запросов (максимум 30 секунд)
try:
await asyncio.wait_for(shutdown_event.wait(), timeout=30.0)
except asyncio.TimeoutError:
pass
# Закрываем соединения с БД
await database.disconnect()Процесс деплоя
- Пушим новый код в Git.
- На сервере:
git pull. - Пересобираем образы:
docker-compose build. - Применяем миграции БД:
docker-compose run --rm api alembic upgrade head. - Перезапускаем сервисы:
docker-compose up -d --no-deps --build api celery. - Проверяем логи:
docker-compose logs -f api.
Среднее время деплоя — 2-3 минуты. Downtime — 0 секунд благодаря start-first и healthchecks.
Метрики и мониторинг production-нагрузки
Для мониторинга используем связку Prometheus + Grafana. Метрики собираем через prometheus-fastapi-instrumentator:
from prometheus_fastapi_instrumentator import Instrumentator
app = FastAPI()
Instrumentator().instrument(app).expose(app, endpoint="/metrics")Ключевые метрики:
- http_requests_total — количество запросов по endpoint и статус-коду.
- http_request_duration_seconds — histogram latency запросов (p50, p95, p99).
- active_orders_count — gauge количества заказов в статусе processing.
- celery_task_duration_seconds — время выполнения задач генерации.
- db_connection_pool_size — размер пула соединений SQLAlchemy.
Текущие показатели (за последние 30 дней):
| Метрика | Значение |
|---|---|
| Всего пользователей | 250+ |
| Всего заказов | 180+ |
| Успешных транзакций | 180 (100%) |
| Средний RPS (пик) | 12-15 |
| p95 latency API | 340 мс |
| p99 latency API | 680 мс |
| Среднее время генерации | 11 минут 20 секунд |
| Uptime | 99.8% |
Узкие места:
- ML-inference: генерация 50 аватаров занимает 3-5 минут на GPU (NVIDIA T4). При очереди из 5+ заказов время ожидания растёт до 20-25 минут. Решение: добавление второго GPU-инстанса с load balancing.
- S3 upload: загрузка 50 изображений по 2 МБ занимает 30-40 секунд. Решение: параллельная загрузка через
asyncio.gatherс semaphore (не более 10 одновременно). - Database queries: запрос списка аватаров заказа с JOIN на таблицу orders занимал 150-200 мс. Решение: добавили индекс на
avatars.order_id, latency упала до 15-20 мс.
Выводы и рекомендации
За три месяца разработки и эксплуатации Telegram Mini App с ИИ-аватарами мы получили следующие инсайты:
Что сработало хорошо
- FastAPI + async: высокая производительность из коробки, простота разработки, автодокументация API через Swagger.
- Telegram WebApp SDK: нативная интеграция, пользователи не покидают мессенджер, высокая конверсия в оплату.
- T-Bank API: надёжные платежи, низкая комиссия, хорошая документация.
- Docker Compose: достаточно для MVP и небольшой нагрузки, простота деплоя, возможность миграции на Kubernetes при росте.
Что можно улучшить
- Кэширование: добавить Redis-кэш для часто запрашиваемых данных (список пакетов, аватары пользователя). Сейчас каждый запрос идёт в PostgreSQL.
- CDN для статики: раздавать сгенерированные аватары через CloudFlare или AWS CloudFront вместо прямой отдачи с S3 — снизит latency на 40-50%.
- Websockets для real-time обновлений: сейчас пользователь получает уведомление о готовности аватаров через Telegram Bot API. Можно добавить WebSocket-соединение для live-обновления прогресса генерации прямо в Mini App.
- A/B тестирование: встроить механизм feature flags для тестирования разных UI-вариантов и ценовых стратегий.
Рекомендации для разработчиков
- Валидируйте initData на бэкенде: не доверяйте клиенту, всегда проверяйте HMAC-подпись.
- Учитывайте лимиты WebView: тестируйте на iOS и Android, viewport и Safe Area могут отличаться.
- Используйте healthchecks: это критично для zero-downtime deployment.
- Мониторьте метрики: без Prometheus/Grafana вы не увидите узкие места до того, как пользователи начнут жаловаться.
- Документируйте API: FastAPI автогенерирует Swagger, но добавьте примеры запросов и описания ошибок — фронтенд-разработчикам будет проще.
Telegram Mini Apps — это мощный инструмент для создания сервисов с высокой вовлечённостью пользователей. Правильная архитектура, внимание к деталям аутентификации и платежей, грамотный деплой — и вы получите стабильный production-сервис, способный обрабатывать сотни пользователей без дорогой инфраструктуры.
Готовы попробовать AvatarBox?
Создать первое видео бесплатно