Контекст
Omniflow устанавливает один оператор на один сервер, чтобы вести один VPN-сервис. Работа, которую он выполняет, охватывает поверхности, которые принято разделять: HTTP API, бот Telegram и фоновые задания. Разнести их по отдельно разворачиваемым сервисам — рефлекс, и он был отвергнут. Решающее ограничение в том, что покупка — это одна транзакция. Принять деньги, записать заказ, зафиксировать использование пробного периода, погасить промокод и зафиксировать задание, которое выдаст доступ в Remnawave, — это либо одно атомарное изменение, либо класс ошибок, о котором оператор узнаёт из жалоб. Разнесённая по сервисам, эта атомарность собирается заново из саг, компенсаций и брокера — механики, чьи режимы отказа строго хуже тех, что она заменяет, и за которую каждая установка платит эксплуатационную цену независимо от того, есть ли у неё оправдывающий это масштаб. Второе ограничение — кто это эксплуатирует. У целевой установки нет платформенной команды. Три контейнера, одна база и один.env — это система, в
которой один человек разберётся в три часа ночи.
Решение
Один модуль Go. Три точки входа —cmd/api, cmd/bot, cmd/worker, — которые
используют одни и те же доменные пакеты, одну базу данных и одну конфигурацию и
разворачиваются как три контейнера.
Модульность держат четыре правила, а не благие намерения, и пул-реквест,
нарушающий любое из них, неверен независимо от того, чего он достигает:
- Доменные пакеты не импортируют транспорт, генерацию доступа к базе, Telegram и пакеты конкретных провайдеров. Стрелки зависимостей направлены в одну сторону.
- Внешние системы находятся за небольшими интерфейсами, поэтому типы формы провайдера существуют ровно в одном пакете.
- Доменные события идут через транзакционный outbox. Брокер сообщений не вводится без записи, заменяющей эту.
- Сборка зависимостей явная: обычные конструкторы, вызываемые из каждого
main, а общая для всех трёх часть — коммерческое ядро, которое принимает деньги и ставит выдачу доступа в очередь, — собирается один раз вinternal/app, и его же использует интеграционная обвязка тестов. Никакого фреймворка внедрения зависимостей.
Последствия
Перезапуск одного процесса ради изменения кода означает выкатку одной и той же версии образа на все три, потому что это один артефакт. Выкатка исправления в API переустанавливает и бота. Горизонтальное масштабирование ограничено тем, что обслуживает один экземпляр PostgreSQL. Поддерживается сколько угодно репликapi за прокси и сколько
угодно реплик worker: проходы по всей таблице — это периодические задачи
River, которые выполняются один раз за интервал на всю установку, а не один раз
на процесс. bot работает ровно в одном экземпляре, потому что Telegram
доставляет обновления одному потребителю. Пул подключений каждой реплики
расходует max_connections PostgreSQL. Истории шардирования нет и не
планируется — см. Развёртывание.
Участник не может закрепить границу модуля сетевым вызовом, поэтому её держат
ревью и дисциплина импортов. Это слабее границы процесса и является ценой
атомарности выше.
Взамен изменение, охватывающее покупку, право на услугу и выдачу доступа, — это
одна транзакция и один тест, а не распределённый протокол, а обновление для
оператора — это docker compose pull, а не упорядоченная выкатка.
Когда пересматривать
- Один экземпляр PostgreSQL доказуемо является узким местом реальной установки, а не предполагаемой.
- Omniflow должен работать как управляющий слой над установками, которыми он не
владеет; тогда у outbox появляется потребитель вне этого репозитория — см.
пункты об управлении парком и NATS JetStream среди кандидатов после 1.0 в
ROADMAP.md. - Появляется возможность, которую действительно нельзя зафиксировать в одной транзакции с изменением состояния, которое её требует. Пока такой не было: ближе всех подошёл шлюз цифровых товаров, и он разрешён тем, что неоднозначная выдача откладывается для человека.
Смежное
- Обзор архитектуры — процессы и путь запроса.
- Технологический стек — чего намеренно нет и почему.
- Запись 0002 — правило надёжности, на которое опирается это решение.