Case study · backend and platform engineering Разбор проекта · бэкенд и платформенная разработка

POS Integrations

A multi-tenant, event-driven ordering platform for restaurants. Guests order in the restaurant’s own Telegram bot; staff run the order from a Telegram group or a web panel. Мультитенантная событийная платформа заказов для ресторанов. Гости заказывают в собственном Telegram-боте ресторана, а персонал ведёт заказ из группы в Telegram или из веб-панели.

I designed and built all of it myself, from scratch: the architecture, eleven backend services, the custom frameworks under them, the infrastructure, the telemetry and the CI/CD. Я сам спроектировал и написал всё это с нуля: архитектуру, одиннадцать бэкенд-сервисов, собственные фреймворки под ними, инфраструктуру, телеметрию и CI/CD.

  • In production В продакшене
  • Python 3.13
  • FastAPI
  • Kafka
  • PostgreSQL
  • OpenTelemetry
  • Docker Compose
  • David Alkamyan · 2024 – present David Alkamyan · с 2024 года

01

What a restaurant gets Что получает ресторан

Each restaurant is a tenant with its own Telegram bot. Once the bot is connected, the platform carries the whole order: the conversation with the guest, the work of the staff, the payment and every notification in between. Каждый ресторан — отдельный тенант со своим Telegram-ботом. После подключения бота платформа ведёт заказ целиком: диалог с гостем, работу персонала, оплату и все уведомления по пути.

Guests Гости

An ordering bot Бот для заказов

A menu with photos and modifiers, a cart, delivery or takeout, an address taken from a shared location, discounts, and payment in cash, by terminal or online through Payme. In Russian, Uzbek and English. Меню с фотографиями и модификаторами, корзина, доставка или самовывоз, адрес по отправленной геолокации, скидки, оплата наличными, через терминал или онлайн через Payme. На русском, узбекском и английском.

Staff Персонал

A Telegram group as the order desk Группа в Telegram вместо пульта заказов

Every order arrives as a card with action buttons: accept, preparing, ready, complete, cancel. The card redraws itself on every change, no matter who made it or where. Каждый заказ приходит карточкой с кнопками: принять, готовится, готов, выдан, отменить. Карточка перерисовывается при любом изменении, кто бы и откуда его ни сделал.

Owner Владелец

A web admin panel Веб-панель администратора

Orders, the menu, branches, discounts, clients, the bot connection and payment methods in one place, on a desktop or a phone. Sign-in with a password or straight from the Poster back office. Заказы, меню, филиалы, скидки, клиенты, подключение бота и способы оплаты в одном месте, с компьютера или с телефона. Вход по паролю или прямо из бэк-офиса Poster.

The ordering bot Бот для заказов

The ordering bot in Telegram: a dish card with a photo, a description and modifier buttons
A dish. A photo, a description and the modifiers as buttons. Блюдо. Фотография, описание и модификаторы кнопками.
The ordering bot in Telegram: a drink card with a quantity selector
Quantity. One message, edited in place as the guest taps. Количество. Одно сообщение, которое редактируется на месте по нажатиям гостя.
The ordering bot in Telegram: the order summary with the branch, the items, the total and the buttons to confirm or edit
Checkout. The order before it is sent: branch, pickup or delivery, time, payment. Оформление. Заказ перед отправкой: филиал, самовывоз или доставка, время, оплата.

The bot in Russian, on demo data. It also speaks Uzbek and English. Бот на русском, данные демонстрационные. Ещё он говорит на узбекском и английском.

The admin panel Админ-панель

Admin panel: the list of active orders with their statuses and actions Админ-панель: список активных заказов со статусами и действиями
Orders. The live order desk, with the same statuses and actions as the staff group. Заказы. Живой пульт заказов: те же статусы и действия, что и в группе персонала.
Admin panel: one order with the client, the delivery, the items and the status history Админ-панель: заказ с клиентом, доставкой, позициями и историей статусов
One order. The delivery, the items, the discount and who changed the status, from where and when. Заказ. Доставка, позиции, скидка и история: кто, откуда и когда менял статус.
Admin panel: the menu by category with prices and translation coverage Админ-панель: меню по категориям с ценами и полнотой переводов
Menu. Categories, prices per branch and how complete the texts are in each language. Меню. Категории, цены по филиалам и полнота текстов на каждом языке.
Admin panel: the dashboard with the setup checklist and the orders that need action Админ-панель: дашборд с состоянием настройки и заказами, требующими действия
Dashboard. What still needs setting up, what needs action now and today’s numbers. Дашборд. Что ещё нужно настроить, что требует действия сейчас и цифры за сегодня.

The panel on demo data. Click a screen to open it full size. Панель на демонстрационных данных. Нажмите на экран, чтобы открыть его в полном размере.

Integrations today Интеграции сейчас

  • Telegramordering channel and staff deskканал заказов и рабочее место персонала
  • Postersign-in from the POS accountвход из аккаунта POS-системы
  • Paymeonline payments over its Merchant APIонлайн-оплата по Merchant API
  • Google Mapsaddress lookupопределение адреса

It grows at the edges: a new ordering channel talks to the same orders API and listens to the same events, and a new POS system or payment provider is an SDK behind an existing service. Платформа растёт по краям: новый канал заказов работает с тем же API заказов и слушает те же события, а новая POS-система или платёжный провайдер — это SDK за уже существующим сервисом.

02

Architecture Архитектура

Eleven services, two Kafka topics and three public routes. The map shows what talks to what; the decisions below say why. Одиннадцать сервисов, два топика Kafka и три публичных маршрута. На карте — кто с кем общается, ниже — почему так.

Admin panel Админ-панель SPA · Poster sign-in SPA · вход из Poster Payme payment provider платёжный провайдер Telegram a bot per restaurant свой бот у ресторана guest chats чаты гостей staff groups группы персонала nginx TLS 3 routes 3 маршрута webhooks вебхуки Services · one runtime, one process shape Сервисы · один рантайм, одна форма процесса admin-gateway sessions · proxy сессии · прокси tg-gateway verify · enqueue проверка · очередь Payme JSON-RPC proxy прокси Domain services · a database each Доменные сервисы · у каждого своя БД menu users customers payments orders discounts transactional outbox, транзакционный outbox, an event per transition событие на каждый переход Kafka telegram.updates.raw orders.events.v1 raw update · key tenant:chat сырой апдейт · ключ чата snapshot · schema-checked · DLQ снимок · проверка схемы · DLQ tg-order-bot ordering dialog диалог заказа tg-order-management-bot order cards for staff карточки для персонала tg-user-communications-bot statuses and the pay link статусы и ссылка на оплату HTTP · generated clients HTTP · сгенерированные клиенты Bot API · menus, order cards, statuses Bot API · меню, карточки, статусы Stores Хранилища PostgreSQL one DB per service по базе на сервис Redis sessions · FSM сессии · FSM MinIO menu photos фото меню OpenBao tenant secrets секреты тенантов

Swipe sideways to see the whole diagram. Листайте вбок, чтобы увидеть схему целиком.

  • HTTP callHTTP-вызов
  • Kafka recordзапись Kafka
  • outside the platformвне платформы
The system map. In service names, customers are the tenants (restaurants) and users are their guests. Bots are services like any other: the same runtime, with Kafka handlers as entry points. Карта системы. В названиях сервисов customers — это тенанты (рестораны), а users — их гости. Боты — такие же сервисы, как остальные: тот же рантайм, только входные точки у них — обработчики Kafka.

The edge only verifies and enqueuesШлюз только проверяет и кладёт в очередь

The Telegram gateway checks a per-restaurant secret and writes the update to Kafka untouched. It calls no other service, so a slow bot or a restart never turns into a failed webhook.Telegram-шлюз проверяет секрет конкретного ресторана и пишет апдейт в Kafka как есть. Других сервисов он не вызывает, поэтому медленный бот или рестарт не превращаются в упавший вебхук.

Multi-tenant from the edge inwardМультитенантность от входа и вглубь

Each restaurant has its own bot, token and settings, and one gateway serves them all. Every request, Kafka record and secret carries the tenant, and every service scopes its data by it.У каждого ресторана свой бот, токен и настройки, а шлюз на всех один. Тенант указан в каждом запросе, записи Kafka и секрете, и каждый сервис ограничивает им свои данные.

A database per serviceУ каждого сервиса своя база

Nine Postgres databases and no shared tables. The gateways own no data at all.Девять баз в Postgres и ни одной общей таблицы. У шлюзов своих данных нет вовсе.

One owner of order stateУ состояния заказа один владелец

Every status change is one HTTP call into orders and one event out of it. The staff group and the admin panel are two channels onto the same transitions, and nothing ever calls a bot directly: the bots redraw their messages from the events.Каждая смена статуса — один HTTP-вызов в сервис заказов и одно событие из него. Группа персонала и админ-панель — два канала к одним и тем же переходам, а ботов никто не вызывает напрямую: они перерисовывают свои сообщения по событиям.

Order per chat, parallel across chatsПорядок внутри чата, параллельность между чатами

Telegram updates are keyed by restaurant and chat, so one conversation is always handled in order while different conversations spread over the partitions.Ключ апдейта Telegram — ресторан и чат, поэтому один диалог всегда обрабатывается по порядку, а разные диалоги расходятся по партициям.

Hard rules for dataЖёсткие правила для данных

Instants are UTC everywhere, and a time zone is a display choice read from the tenant. Money is NUMERIC(14, 2) in the database and a fixed-point string on the wire.Моменты времени везде в UTC, а часовой пояс — способ отображения, который берётся из данных тенанта. Деньги — NUMERIC(14, 2) в базе и строка с фиксированной точкой в API и событиях.

03

The strongest parts Сильные стороны

Six things I would show another engineer first: three custom frameworks and three rules the services live by. Шесть вещей, которые я показал бы другому инженеру в первую очередь: три собственных фреймворка и три правила, по которым живут сервисы.

3.1

One custom runtime for every serviceОдин собственный рантайм для всех сервисов

Every service is the same process: a FastAPI app whose lifespan starts, by role, a Kafka consumer and a scheduler. A service declares its routers, Kafka handlers and jobs; an environment variable decides which of them a container runs. Health probes, typed API errors, single-flight jobs and telemetry come from the runtime, so a new service is operable and instrumented by construction. Каждый сервис — один и тот же процесс: приложение FastAPI, в lifespan которого по ролям запускаются Kafka-консьюмер и планировщик. Сервис объявляет свои роутеры, обработчики Kafka и задачи, а переменная окружения решает, что из этого поднимет контейнер. Health-пробы, типизированные ошибки API, защиту задач от параллельного запуска и телеметрию даёт рантайм, поэтому новый сервис с первого дня эксплуатируется и наблюдается так же, как остальные.

The package layout is fixed too (entry points → services → infrastructure, dependencies pointing one way), so adding a service is a scaffold and a checklist. Структура пакета тоже зафиксирована (входные точки → сервисы → инфраструктура, зависимости в одну сторону), поэтому новый сервис — это шаблон и чек-лист.

A whole service, declared Весь сервис одним объявлением
app = ServiceApp(
    service_name=settings.SERVICE_NAME,
    lifespan=lifespan,                  # yields the deps every entry point shares
    routers=[v1_internal_router],
    kafka=KafkaConfig(
        topic_configs={
            TOPIC: HandlerConfig(handler=handle_event, retry_policy=[0.1, 0.5]),
        },
        group_id=settings.KAFKA_GROUP_ID,
    ),
    jobs=[Job('purge_old_rows', every=timedelta(hours=6), run=purge_old_rows)],
    readiness_checks=[check_postgres],
    job_lock=lambda deps: PostgresAdvisoryLock(deps.engine),
).build()

3.2

A custom framework on top of aiokafkaСобственный фреймворк поверх aiokafka

Why. aiokafka is a client, not an application: ordering, retries, dead letters, rebalances, shutdown and health checks are left to whoever writes the consumer. I wrote that part once, as a framework. A bot declares a handler and a retry policy per topic; the rest is the framework’s job. Зачем. aiokafka — это клиент, а не приложение: порядок, повторы, dead-letter-очередь, ребалансы, остановка и health-проверки остаются на том, кто пишет консьюмер. Я написал эту часть один раз, как фреймворк. Бот объявляет обработчик и политику повторов для топика, а остальное делает фреймворк.

How. One worker per partition gives strict order inside a partition and full concurrency across them. A failing handler retries on its policy, then the record goes to a dead-letter topic owned by that consumer group. The DLQ send is awaited before the offset commit, so a record is either in the DLQ or still unconsumed, never lost in between. Как. Один воркер на партицию даёт строгий порядок внутри партиции и полную конкурентность между ними. Упавший обработчик повторяется по своей политике, после чего запись уходит в dead-letter-топик, принадлежащий этой группе потребителей. Отправка в DLQ дожидается подтверждения брокера до коммита оффсета, поэтому запись либо уже в DLQ, либо ещё не вычитана — потеряться между этими состояниями она не может.

  • A full partition queue pauses only that partition; the poll loop never blocks. Переполненная очередь партиции ставит на паузу только её; цикл опроса не блокируется никогда.
  • A rebalance lets the record in flight finish, then commits only what was completed. При ребалансе обработка текущей записи доводится до конца, после чего коммитится только завершённое.
  • Records too old to matter (a tap from an hour ago) are discarded, and every discard shows in logs and metrics. Записи, потерявшие смысл от времени (нажатие часовой давности), отбрасываются, и каждый такой случай виден в логах и метриках.
  • A dead poll loop or a stuck rebalance fails the liveness probe, and the process restarts. Умерший цикл опроса или зависший ребаланс роняют liveness-пробу, и процесс перезапускается.

3.3

A custom UI library on top of aiogramСобственная UI-библиотека поверх aiogram

Why. Plain aiogram leaves every flow to decide when to send, edit or delete a message and how to match a pressed reply button. aiogram-dialog answers that with its own widget layer and implicit wiring: more than the bots needed, and harder to debug. aiogram_windows keeps aiogram’s own keyboards, media and middlewares and adds only the missing rule. Зачем. Чистый aiogram оставляет каждому сценарию самому решать, когда отправить, отредактировать или удалить сообщение и как сопоставить нажатую reply-кнопку. aiogram-dialog отвечает на это собственным слоем виджетов и неявным связыванием: ботам столько было не нужно, а отлаживать это труднее. aiogram_windows оставляет родные клавиатуры, медиа и middleware aiogram и добавляет только недостающее правило.

How. An FSM state is a window: a getter renders the screen, a handler reacts to the next update, and the engine then renders whatever state the handler left. There is one live screen per chat: inline screens are edited in place, reply-keyboard screens are sent anew, stale ones are cleaned up. Как. Состояние FSM — это окно: getter рисует экран, handler обрабатывает следующий апдейт, после чего движок рисует то состояние, в котором handler оставил диалог. В чате один живой экран: inline-экраны редактируются на месте, экраны с reply-клавиатурой отправляются заново, устаревшие убираются.

  • Registration is explicit: Window(state, getter, handler), no decorators and no hidden wiring. Регистрация только явная: Window(state, getter, handler), без декораторов и скрытого связывания.
  • A failed turn persists nothing, and a press that arrives in an expired state is replayed, not lost. Неудачный ход ничего не сохраняет, а нажатие, пришедшее в истёкшем состоянии, воспроизводится, а не теряется.
  • It ships its own test kit: a recording bot and update factories. В комплекте свой тестовый набор: записывающий бот и фабрики апдейтов.
A flow is a list of windows Сценарий — это список окон
async def language_getter(manager: WindowManager) -> View:
    return View(text='Choose your language', reply_markup=language_keyboard())


async def language_handler(manager: WindowManager, button_id: str | None) -> None:
    if button_id is not None:                 # a language button was pressed
        manager.data['language'] = button_id
        manager.goto(Onboarding.ask_phone)


onboarding = WindowGroup(
    Window(Onboarding.ask_language, language_getter, language_handler),
    Window(Onboarding.ask_phone, phone_getter, phone_handler),
    name='onboarding',
)

3.4

Events that can be trustedСобытия, которым можно доверять

The order event is written to an outbox table in the transaction that changes the order: a rollback leaves no event, and a commit always gets its event out. Events carry a full snapshot, are keyed by order id and are checked against a Schema Registry in BACKWARD mode. Consumers are written to see the same event twice. Событие заказа пишется в outbox-таблицу в той же транзакции, что меняет заказ: откат не оставляет события, а после коммита оно обязательно уйдёт. Событие несёт полный снимок заказа, ключом служит id заказа, а схема проверяется в Schema Registry в режиме BACKWARD. Потребители рассчитаны на то, что одно и то же событие придёт дважды.

3.5

Contracts, not importsКонтракты вместо импортов

No service imports another. OpenAPI schemas are committed next to the code, and every call between services goes through a typed client generated from them. The admin gateway is a transparent proxy: it signs the person in, resolves the tenant and forwards to the service that owns the data. It merges the services’ admin schemas into one document, from which the React panel generates its types, so a new admin endpoint never touches the gateway. Ни один сервис не импортирует другой. OpenAPI-схемы хранятся в репозитории рядом с кодом, и любой вызов между сервисами идёт через сгенерированный по ним типизированный клиент. Админ-шлюз — прозрачный прокси: он аутентифицирует человека, определяет тенанта и пересылает запрос сервису, которому принадлежат данные. Админ-схемы сервисов он склеивает в один документ, из которого React-панель генерирует свои типы, поэтому новый админ-эндпоинт не требует ни строчки в шлюзе.

3.6

Tests that decide what shipsТесты, которые решают, что уедет в прод

Over 2,100 automated tests: about half of the Python code is tests. Service suites run against real Postgres, Kafka and Redis in testcontainers, with shared pytest plugins for faking neighbour services, asserting on Kafka and freezing time. Above them sits a full-flow suite: ten services in one pytest process, driven only through the Telegram webhook, the staff group and the Payme callback. Scenarios assert by translation key, so rewording a message breaks nothing and renaming a key fails loudly. Больше 2100 автотестов: примерно половина кода на Python — тесты. Тесты сервисов работают с настоящими Postgres, Kafka и Redis в testcontainers; общие pytest-плагины подменяют соседние сервисы, проверяют сообщения Kafka и замораживают время. Над ними — сквозной набор: десять сервисов в одном процессе pytest, а управлять ими можно только через вебхук Telegram, группу персонала и колбэк Payme. Сценарии проверяют сообщения по ключу перевода: переформулировка текста ничего не ломает, а переименование ключа сразу роняет тест.

A full-flow scenario: from /start to an accepted order Сквозной сценарий: от /start до принятого заказа
user = make_user(language=Language.RU)
await flows.register_through_bot(user)        # /start, language, phone, the menu
await flows.start_order(user)
await flows.share_new_location(
    user, flows.NEAR_SPOT, resolved_address=fake_geocoding.expected_address,
)
await flows.choose_delivery(user, menu)
await flows.add_configured_item(user, menu.burger, choices=[('Sauce', ['Ketchup'])])
cart, order_id = await flows.checkout(user, operator, payment_method=PaymentMethod.CASH)
await flows.staff_accepts(staff_group, order_id, payment_pending=False)
await user.expect(
    'order_status_notification/order_{id}.awaiting_preparation', id=order_id,
)

04

Telemetry Телеметрия

How a request, a Kafka record or a failing container becomes something you can see. Three facts carry the whole design. Как запрос, запись Kafka или упавший контейнер превращаются в то, что можно увидеть. Вся конструкция держится на трёх фактах.

Services do nothing specialСервисы ничего особенного не делают

The runtime configures OpenTelemetry once per process. FastAPI, aiohttp, SQLAlchemy, Redis and aiokafka are instrumented automatically, and logs are JSON on stdout. A service built the normal way is fully instrumented.Рантайм настраивает OpenTelemetry один раз на процесс. FastAPI, aiohttp, SQLAlchemy, Redis и aiokafka инструментируются автоматически, логи пишутся в stdout в JSON. Сервис, собранный обычным способом, инструментирован полностью.

One collector is the only agentОдин коллектор — единственный агент

It receives traces and metrics over OTLP, reads every container’s log file and polls Docker, the host, Postgres, Redis, Kafka and the readiness endpoints. Nothing else scrapes anything.Он принимает трейсы и метрики по OTLP, читает файл логов каждого контейнера и опрашивает Docker, хост, Postgres, Redis, Kafka и readiness-эндпоинты. Больше никто ничего не собирает.

Three stores, joined by idsТри хранилища, связанные идентификаторами

Tempo keeps traces, Loki keeps logs, Prometheus keeps metrics, and they never see each other. Grafana joins them: the trace id in every log line links a log to its trace and back, and the service name and the route link both to the metrics.Tempo хранит трейсы, Loki — логи, Prometheus — метрики, и друг о друге они не знают. Связывает их Grafana: trace id в каждой строке лога ведёт от лога к трейсу и обратно, а имя сервиса и маршрут связывают оба сигнала с метриками.

11 services 11 сервисов instrumented by the runtime инструментирует рантайм traces: FastAPI, aiohttp, SQL, трейсы: FastAPI, aiohttp, SQL, Redis, Kafka, consumer spans Redis, Kafka, спаны консьюмера metrics: HTTP by route, метрики: HTTP по маршрутам, consumer lag, job runs лаг консьюмера, запуски задач logs: JSON on stdout, логи: JSON в stdout, trace_id in every line trace_id в каждой строке Infrastructure Инфраструктура no agents of its own своих агентов нет Docker · the host · Postgres Docker · хост · Postgres Redis · Kafka Redis · Kafka readiness endpoints readiness-эндпоинты Container log files Файлы логов контейнеров stdout of every container stdout каждого контейнера OpenTelemetry Collector the only agent единственный агент traces OTLP in → Tempo приём OTLP → Tempo metrics OTLP in → endpoint for Prometheus приём OTLP → эндпоинт для Prometheus metrics/infra six pull receivers → Prometheus шесть pull-ресиверов → Prometheus Services do not ship their logs: Сервисы не отправляют логи сами: the collector reads them from Docker коллектор читает их из файлов Docker and links every line to its trace. и связывает каждую строку с трейсом. Its own health is a metric too. Его собственное состояние — тоже метрика. logs reads files → trace context → Loki файлы → контекст трейса → Loki OTLP OTLP polls опрос reads читает Tempo traces · kept 72 h трейсы · хранятся 72 ч Prometheus metrics · every 15 s метрики · раз в 15 с labels: service, route, метки: сервис, маршрут, container, job, outcome контейнер, задача, исход Loki logs · kept 7 days логи · хранятся 7 дней push scrapes забирает push Grafana provisioned from files настроена файлами DASHBOARDS ДАШБОРДЫ Fleet health Resources Trace investigation ALERTING АЛЕРТИНГ 22 rules, every minute 22 правила, раз в минуту availability 4 · errors 6 доступность 4 · ошибки 6 saturation 8 · telemetry 4 насыщение 8 · телеметрия 4 events are sent once, события отправляются with no “resolved” один раз, без «resolved» TraceQL PromQL LogQL Telegram ops chat рабочий чат alerts алерты

Swipe sideways to see the whole diagram. Листайте вбок, чтобы увидеть схему целиком.

  • pushотправка (push)
  • pull, arrowhead on the side being readопрос (pull), стрелка указывает на то, что читают
The telemetry pipeline, from what emits a signal to the alert in a Telegram chat. Конвейер телеметрии: от источника сигнала до алерта в чате Telegram.

One update, three signals Один апдейт — три сигнала

time → время → one trace_id from the first span to the last один trace_id от первого спана до последнего tg-gateway Kafka tg-order-bot orders menu where it lands куда попадает POST webhook · server span POST webhook · серверный спан send → Kafka a log line and a request counter строка лога и счётчик запросов writes traceparent into the record headers пишет traceparent в заголовки записи telegram.updates.raw key tenant:chat · header traceparent ключ тенант:чат · заголовок traceparent the body is the update as Telegram sent it тело — апдейт в том виде, в каком его прислал Telegram enqueue message_processing handler_attempt · one dialog step handler_attempt · один шаг диалога customers POST → orders Bot API parent = context from the record headers родитель — контекст из заголовков записи every log line carries the same trace_id в каждой строке лога тот же trace_id client spans are automatic (aiohttp) клиентские спаны автоматические (aiohttp) server span серверный спан SQL → menu traceparent arrives in the HTTP headers traceparent приходит в HTTP-заголовках SQL and Redis spans need no code in the service спаны SQL и Redis не требуют кода в сервисе server сервер the chain ends where the last instrumented call returns цепочка заканчивается последним инструментированным вызовом Tempo the whole tree under one trace_id; всё дерево под одним trace_id; any log line links to it в него ведёт любая строка лога Loki {service_name="orders"} | trace_id="…" logs join by id, not by time логи связаны по id, а не по времени Prometheus one count per hop, by service and route, счётчик на каждый хоп: сервис и маршрут, never by trace но не трейс

Swipe sideways to see the whole diagram. Листайте вбок, чтобы увидеть схему целиком.

  • server or consumer spanсерверный спан или спан консьюмера
  • client spanклиентский спан
  • log lineстрока лога
One Telegram update as telemetry sees it: a single trace across two transports and four processes. The gateway’s send span writes traceparent into the record headers and the consumer opens its span with that header as the parent, so the trace survives Kafka. Один апдейт Telegram глазами телеметрии: единый трейс через два транспорта и четыре процесса. Спан отправки на шлюзе пишет traceparent в заголовки записи, а консьюмер открывает свой спан с этим заголовком в роли родителя, поэтому трейс переживает Kafka.

Three dashboards Три дашборда

  • Fleet health. Readiness, HTTP rate, errors and latency of every service on shared charts, Kafka consumers, scheduled jobs, datastores, containers. Fleet health. Готовность, частота запросов, ошибки и задержки всех сервисов на общих графиках, Kafka-консьюмеры, задачи по расписанию, хранилища, контейнеры.
  • Resources. The host, every container, Postgres, Redis, Kafka and the collector’s own health. Resources. Хост, каждый контейнер, Postgres, Redis, Kafka и состояние самого коллектора.
  • Trace investigation. The logs of one trace across all services. Trace investigation. Логи одного трейса по всем сервисам.

Dashboards, alert rules and their routing are files in the repository. Retention is short on purpose: traces for 72 hours, logs for 7 days. Telemetry is for the incident, not for the archive. Дашборды, правила алертов и их маршрутизация — файлы в репозитории. Срок хранения намеренно короткий: трейсы живут 72 часа, логи — 7 дней. Телеметрия нужна для инцидента, а не для архива.

22 alert rules, delivered to Telegram 22 правила алертов с доставкой в Telegram

  • Availability · 4. A failing readiness probe, an unhealthy or crash-restarted container, a scrape target that is down. Доступность · 4. Упавшая readiness-проба, нездоровый или аварийно перезапущенный контейнер, недоступная цель сбора метрик.
  • Errors · 6. Application errors, an order event in the DLQ, 5xx at the edge, rejected webhooks, consumer lag that does not drain, a failed job. Ошибки · 6. Ошибки приложений, событие заказа в DLQ, 5xx на входе, отклонённые вебхуки, лаг консьюмера, который не убывает, упавшая задача.
  • Saturation · 8. Host CPU, memory and disk, container memory, Postgres connections and deadlocks, Redis memory and rejected connections. Насыщение · 8. CPU, память и диск хоста, память контейнеров, соединения и дедлоки Postgres, память Redis и отклонённые им соединения.
  • Telemetry itself · 4. The collector failing to export or refusing data, its queue filling up, Grafana failing to deliver a notification. Сама телеметрия · 4. Коллектор не может экспортировать или отказывается принимать данные, его очередь переполняется, Grafana не может доставить уведомление.

Rules are evaluated every minute. One-shot events (a crash, a dead-lettered record, a failed job) are sent once, with no “resolved” message, and a storm is grouped into one. Правила вычисляются раз в минуту. Одноразовые события (аварийный рестарт, запись в DLQ, упавшая задача) отправляются один раз, без сообщения «resolved», а шторм алертов группируется в одно сообщение.

05

Delivery and operations Доставка и эксплуатация

How code reaches production, where secrets live and what production actually is. Как код попадает в продакшен, где живут секреты и что такое продакшен на самом деле.

5.1 · CI/CD

Each service ships on its own verdictКаждый сервис выкатывается по собственному вердикту

One GitHub Actions run builds, tests, migrates and deploys any selection of services as matrix jobs. A red suite or a failed migration holds back only its own service, which keeps its previous image while the others ship. Migrations run before any restart and are additive by rule, so the previous image always works on the new schema. The deploy refuses to restart a service whose database is not at its image’s Alembic head, and rollback is one more workflow. Один запуск GitHub Actions собирает, тестирует, мигрирует и деплоит любой набор сервисов матричными джобами. Красные тесты или упавшая миграция задерживают только свой сервис: он остаётся на прежнем образе, пока остальные выкатываются. Миграции выполняются до любого рестарта и по правилу только добавляют, поэтому предыдущий образ всегда работает на новой схеме. Деплой откажется перезапускать сервис, чья база не на Alembic-head его образа, а откат — отдельный workflow.

prepareresolve the selected servicesкакие сервисы выбраны
build & pushan image per serviceобраз на каждый сервис
testa suite per serviceтесты каждого сервиса
full-flowthe whole backendвесь бэкенд целиком
plandecide who shipsкто выкатывается
migratebefore any restartдо любого рестарта
deployservice by serviceсервис за сервисом
The production workflow. The builds, the per-service suites and the full-flow suite run side by side; migrate and deploy are one job per service. Продакшен-workflow. Сборка, тесты сервисов и сквозной набор идут параллельно; миграция и деплой — по одной джобе на сервис.

5.2 · Secrets5.2 · Секреты

Secure storage for dynamic secrets: OpenBaoБезопасное хранилище динамических секретов: OpenBao

A restaurant’s bot token and its payment credentials are not known at deploy time: the owner enters them in the admin panel while the system is running. The service that owns such a secret writes it to OpenBao and reads it from there. It never lands in a database row, an env file or an event. Токен бота ресторана и его платёжные реквизиты неизвестны в момент деплоя: владелец вводит их в админ-панели, когда система уже работает. Сервис, которому принадлежит такой секрет, пишет его в OpenBao и оттуда же читает. В строку базы, env-файл или событие он не попадает никогда.

  • Each service signs in with its own AppRole and can read only its own class of paths. Каждый сервис входит со своим AppRole и читает только свой класс путей.
  • Webhook secrets are derived, not stored: an HMAC of the tenant and a generation number under a versioned signing key. One tenant’s webhook can be revoked alone, the whole fleet’s by rotating the key. Секреты вебхуков не хранятся, а выводятся: это HMAC от тенанта и номера поколения на версионируемом ключе подписи. Вебхук одного тенанта можно отозвать отдельно, а всего парка — ротацией ключа.
  • Admin passwords are hashed with argon2id; sessions are opaque tokens in Redis and can be revoked at once. Пароли администраторов хешируются argon2id, сессии — непрозрачные токены в Redis, которые отзываются мгновенно.

5.3 · Deployment5.3 · Развёртывание

One server, Docker Compose, nothing managedОдин сервер, Docker Compose и ничего управляемого

Production is a single VDS with 4 vCPU and 8 GB of RAM. Everything runs on it under Docker Compose: the eleven services, PostgreSQL, Kafka with its Schema Registry, Redis, MinIO, OpenBao, nginx and the whole telemetry stack. Продакшен — это один VDS с 4 vCPU и 8 ГБ памяти. Под Docker Compose на нём работает всё: одиннадцать сервисов, PostgreSQL, Kafka со Schema Registry, Redis, MinIO, OpenBao, nginx и весь стек телеметрии.

No Kubernetes, no managed database and no managed broker. That is a deliberate choice to cut costs: the load fits one machine, and everything the product needs from an orchestrator at this scale (health checks, restarts, one-service deploys, rollback) is covered by Compose and the pipeline above. The price is a single failure domain, accepted at this scale. Ни Kubernetes, ни управляемой базы, ни управляемого брокера. Это сознательное решение ради экономии: нагрузка помещается на одну машину, а всё, что продукту на таком масштабе нужно от оркестратора (health-проверки, рестарты, выкатка одного сервиса, откат), закрывают Compose и конвейер выше. Цена — единая точка отказа, на таком масштабе она принята осознанно.

  • Seven Compose fragments behind one entry point; Postgres, Redis, Kafka, the telemetry stack and the edge each sit on their own network. Семь фрагментов Compose за одной точкой входа; Postgres, Redis, Kafka, стек телеметрии и входной nginx живут каждый в своей сети.
  • Only nginx faces the internet. Grafana is reached through an SSH tunnel. В интернет смотрит только nginx. До Grafana можно добраться только через SSH-туннель.
  • Images are built in CI, tagged by commit and pulled from the registry. A deploy recreates exactly one container and records every attempt, so a rollback knows where to go back to. Образы собираются в CI, помечаются коммитом и забираются из реестра. Деплой пересоздаёт ровно один контейнер и записывает каждую попытку, поэтому откат знает, куда возвращаться.
  • The dev stack mirrors production: the same Compose fragments, and a self-hosted tunnel gives it stable public hostnames for Telegram and Payme webhooks. Dev-стенд повторяет продакшен: те же фрагменты Compose, а собственный туннель даёт ему постоянные публичные адреса для вебхуков Telegram и Payme.
  • Ten runbooks cover the rest: provisioning, TLS, the secret store, alerting, webhooks, smoke tests. Остальное описано в десяти ранбуках: подготовка сервера, TLS, хранилище секретов, алертинг, вебхуки, смоук-тесты.
  • The door stays open: services take their role from an environment variable and expose startup, liveness and readiness probes, so moving to an orchestrator later changes the deployment, not the code. Дверь остаётся открытой: сервис берёт роль из переменной окружения и отдаёт startup-, liveness- и readiness-пробы, так что переезд на оркестратор изменит развёртывание, а не код.

06

Stack Стек

BackendБэкенд
Python 3.13FastAPIPydantic 2SQLAlchemy 2 (async)AlembicasyncpgaiohttpPoetry
MessagingОбмен сообщениями
Apache KafkaaiokafkaSchema Registrytransactional outboxDLQ
DataДанные
PostgreSQLPostGISRedisMinIO (S3)
Telegram
aiogram 3Babel (gettext)
TelemetryТелеметрия
OpenTelemetryOTel CollectorTempoLokiPrometheusGrafanastructlog
SecurityБезопасность
OpenBaoAppRoleHMACargon2id
DeliveryДоставка
Docker ComposenginxGitHub ActionsGHCR
QualityКачество
pytesttestcontainerspyrightruff
FrontendФронтенд
React 19TypeScriptViteTanStack QueryTanStack Routeri18nextVitestPlaywright