Микросервисы на .NET без своей платформы: кластер воркеров, общий дашборд и горячая замена модулей
x-db: &db ConnectionStrings__Postgres: "Host=$;Database=app;Username=..."
x-cluster: &cluster Tsak__Cluster__Enabled: "true" Tsak__Cluster__ClusterName: app Tsak__Cluster__GroupName: app
x-rmq: &rmq Tsak__Contexts__default__RabbitMq__Host: $ Tsak__Contexts__default__RabbitMq__Exchange: app.events
services: app-core: image: ghcr.io/redbase-app/redb-tsak-worker:3.7.2-net10 environment: <<: [*db, *cluster, *rmq] Tsak__Modules__AssemblyPaths__0: /app/modules Tsak__Cluster__NodeId: app-core-1 Tsak__Cluster__ApiEndpoint: http://app-core:9090 volumes: [ "./output/core:/app/modules" ]
app-api: image: ghcr.io/redbase-app/redb-tsak-worker:3.7.2-net10 environment: <<: [*db, *cluster, *rmq] Tsak__Modules__AssemblyPaths__0: /app/modules Tsak__Cluster__NodeId: app-api-1 Tsak__Cluster__ApiEndpoint: http://app-api:9090 volumes: [ "./output/api:/app/modules" ]
app-integration: image: ghcr.io/redbase-app/redb-tsak-worker:3.7.2-net10 environment: <<: [*db, *cluster, *rmq] Tsak__Modules__AssemblyPaths__0: /app/modules Tsak__Cluster__NodeId: app-integration-1 Tsak__Cluster__ApiEndpoint: http://app-integration:9090 volumes: [ "./output/integration:/app/modules" ]
Одна веб-консоль на все три воркера.
tsak-web: image: ghcr.io/redbase-app/redb-tsak-web:3.7.2-net10 environment: <<: [*db, *cluster] Tsak__Web__Mode: cluster Tsak__Web__ServiceApiKey: $
Обратите внимание на две вещи.
Во-первых, **образ у трёх воркеров один и тот же**. Официальный `redb-tsak-worker`, не собранный под проект. Различаются только смонтированная папка с модулями и `NodeId`. Собственный образ нужен, если хочется вшить модули внутрь, а не монтировать, но это выбор, а не обязанность.
Во-вторых, **веб-консоль не перечисляет ноды**. В режиме `cluster` она берёт список из топологии кластера, которая лежит в той же базе. Добавили четвёртый воркер, он появился в дашборде сам.
Кластер собирается вокруг общей базы: выборы лидера, регистрация нод, heartbeat и распределение модулей это данные в redb, а не отдельная инфраструктура. Ни ZooKeeper, ни etcd, ни Consul, ни Redis ставить не нужно. Один воркер с включённым кластером это уже рабочий кластер из одной ноды: он выбирает лидером себя и начинает раздавать маршруты.
## Почему микросервисами лучше
Монолитная раскладка честно работает, и для маленькой системы она проще. Но начиная с некоторого размера отдельные процессы дают то, чего изоляция внутри процесса дать не может.
**Отказ упирается в границу контейнера.** Изоляция по `AssemblyLoadContext` защищает от конфликта версий и от того, что модуль полезет в статику соседа. Она не защищает от `OutOfMemoryException`, от нативной библиотеки, которая уронила процесс, и от утечки, которая съела рабочий набор. Отдельный контейнер защищает.
**Лимиты выставляются на то, что их требует.** Интеграционный модуль, который тянет XML по сто мегабайт, и API-модуль, который отдаёт JSON, нуждаются в разной памяти. В одном процессе им приходится делить один потолок, и потолок ставится по худшему.
**Масштабируется то, что нагружено.** Три реплики API и одна реплика интеграции это нормальная картина. В монолите вы масштабируете всё вместе, включая то, что в этом не нуждается.
**Скорость релизов развязывается.** Обновление одного `.tpkg` не заставляет думать про остальные восемь.
**Границы владения совпадают с границами команд.** У каждой команды свой воркер, свой набор модулей, своя строка в дашборде и свой набор эндпоинтов.
**Честная цена.** При распиле бесплатные in-process вызовы через `direct-vm://` превращаются в сетевые. Это плата, и её стоит платить осознанно. Утешение в том, что в коде маршрута меняется адрес, а не логика: продюсер публикует в логический endpoint, а во что он резолвится (`seda:` внутри процесса или `rabbitmq:` между процессами) решает конфигурация. Разумная тактика: держать сильно связанные модули в одном воркере, а по границе, где общение и так асинхронное через брокер, резать спокойно.
## Микросервисы не отменяют горячую замену
Обычно распил на микросервисы отбирает hot-reload. Логика такая: сервис это контейнер, обновление это новый образ, новый образ это перезапуск пода. Хочешь быстрее, строй свою систему плагинов.
В Tsak горячая замена не зависит от топологии. Она живёт в воркере, а воркеров может быть сколько угодно.
```bash
# Обновление модуля на работающем воркере: замена одного файла.
cp ./output/Orders.tpkg /app/modules/
Дальше HotReloadService замечает изменение времени модификации и выполняет мягкую замену: поднимает новый AssemblyLoadContext, даёт ему устояться, дожидается, пока старый контекст доработает свои сообщения в полёте, останавливает его и отпускает. Ни одно сообщение не теряется, процесс не перезапускается, остальные модули этого не замечают.
Удаление файла это тоже штатная операция деплоя, а не аварийная ситуация: rm modules/Orders.tpkg останавливает все модули пакета атомарно, закрывает транспорты и соединения и освобождает изолированный контекст. Соседние пакеты продолжают работать.
В кластере включается RollingUpdate: ноды обновляются последовательно. Никогда не бывает момента, когда новую версию не крутит ни одна нода, и не бывает момента, когда сообщения в полёте теряются.
Ручки, которые обычно приходится крутить в проде:
| Ключ | По умолчанию | Что делает |
|---|---|---|
HotReload:ScanIntervalSeconds |
10 |
Как часто сканируются папки с модулями. |
HotReload:RollingUpdate |
true |
В кластере ноды обновляются по очереди, не одновременно. |
HotReload:StartupTimeoutSeconds |
60 |
Сколько ждать, пока новая версия устоится, прежде чем отпустить старую. |
HotReload:KeepVersions |
2 |
Сколько прошлых версий держать для отката одной командой. |
HotReload:RemovalDebounceScans |
2 |
Сколько сканов подряд файл должен отсутствовать, чтобы это считалось удалением. Защита от атомарной замены, при которой файл на мгновение пропадает. |
HotReload:AdditionStabilityScans |
2 |
Сколько сканов подряд новый файл должен сохранять размер и время, прежде чем его откроют. Защита от чтения недокопированного архива. |
HotReload:Collectible |
false |
Полная выгрузка контекста сборок. Выключено сознательно, объяснение ниже. |
Последние две ручки появились в 3.7.0 после разбора, почему большой архив, который оператор кладёт в папку руками, иногда подхватывался наполовину.
Вместе с горячей заменой работает и горячая перезагрузка конфигурации: правка context.json или {Module}.config.json вызывает пересборку слоёв и перезапуск затронутого контекста. Воркер не перезапускается.
Группы, ноды, назначения: изоляцию можно нарезать как угодно
Топология в Tsak это дерево из трёх уровней, и лежит оно в redb обычными объектами:
cluster:default схема _tsak_clusters
└── group:default:default схема _tsak_groups
├── node:default:worker-1 схема _tsak_nodes
├── node:default:worker-2
└── node:default:worker-3
Каждый уровень это граница, которую можно использовать.
Кластер разводит окружения и продукты. Разные ClusterName в одной базе не видят друг друга.
Группа изолирует выборы лидера, назначения и ребаланс. Внутри одного кластера можно держать группу edge для воркеров, смотрящих наружу, и группу batch для ночной обработки, и переизбрание лидера в одной не тронет другую.
Нода это воркер. Она регистрируется сама, шлёт heartbeat каждые 15 секунд и вычищается из реестра через 60 секунд молчания. Лидерский лок берётся на 30 секунд и продлевается; каждое изменение состояния помечено эпохой лидера, поэтому потерявший выборы лидер не может испортить состояние задним числом.
Контекст это ещё один уровень, уже внутри воркера. Именованный контекст объединяет несколько модулей общим набором свойств и общим жизненным циклом; безымянный создаётся сам на каждый модуль, который никуда не приписан.
{ "Tsak": { "Contexts": {
"api": { "Modules": ["Api.Orders", "Api.Catalog"], "AutoStart": true }
}}}
Из этого набора собирается почти любая раскладка: от одного воркера со всеми модулями до девяти воркеров по модулю на каждый, с группами по зонам ответственности.
Что даёт кластер поверх этого:
- Распределение модулей по нодам. Лидер раскидывает контексты по живым нодам и переназначает их, когда нода приходит или уходит. Стратегия сегодня round-robin, взвешенные в планах.
- Active-passive для маршрута-консьюмера из коробки. Маршрут, который читает Kafka или крутится по расписанию, работает ровно на одной ноде. Умерла нода, лок протух, соседняя перехватила. Ни дублей, ни простоя.
- Cordon и uncordon. Нода выводится из-под назначений без остановки:
tsak cluster cordon node-2, модули разъезжаются по остальным, ноду можно обслуживать. - Плановые задачи как синглтоны кластера. Встроенные ежедневные операции (чистка аудита и очереди недоставленных) отмечены
.Cluster(true)и выполняются на одной ноде, а не на каждой. - Сменные реализации координации.
ILeaderElection,IDistributedLock,INodeRegistry,IClusterCoordinator,IClusterBootstrap,IAssignmentManagerэто интерфейсы. Если координировать через базу не хочется, регистрируется своя реализация, например на Lease-объектах Kubernetes, одной строкой в DI передAddTsakCluster(). Остальной код не меняется.
Один дашборд на все воркеры
Веб-консоль это отдельный процесс Blazor Server, который в кластерном режиме сам находит ноды. Боковое меню делится на три группы: обзор всего кластера, разделы выбранной ноды и настройки. Это рабочее место оператора: из консоли не только смотрят, но и управляют.
Обзор кластера
| Раздел | Что там |
|---|---|
| Dashboard | Статусы нод, кольцевая диаграмма состояний, спарклайны метрик, сортируемая и фильтруемая таблица нод. |
| Cluster | Дерево топологии на три уровня, назначения модулей, здоровье каждой ноды, переход внутрь ноды. |
Внутри ноды, одиннадцать разделов, переключаются в том же меню без потери выбранной ноды:
| Раздел | Что там |
|---|---|
| Overview | Карточки процесса: CPU, рабочий набор, управляемая память, потоки, очередь пула потоков, сборки мусора по поколениям. |
| Contexts | Контексты маршрутов ноды со статусом и числом эндпоинтов, кнопки запуска, остановки и перезапуска. |
| Endpoints | Эндпоинты консьюмеров и продюсеров по маршрутам. |
| Routes | Все маршруты всех контекстов сразу: статус, счётчик сообщений, доля ошибок, переход внутрь. |
| Route (разбор) | Один маршрут целиком: определение, текущее состояние, сообщения в полёте прямо сейчас, свежая диагностика. |
| Watchdog | Подозрительные и зависшие маршруты с кнопками остановки и перезапуска. |
| Modules | Загруженные модули: имя, версия, статус, зависимости, описание. |
| Scheduler | Задания Quartz: группа, cron, состояние, время следующего запуска, пауза, возобновление, запуск сейчас. |
| Monitoring | Четыре живых графика на Chart.js: CPU, память, потоки, сборка мусора. Обновление раз в десять секунд, история за двенадцать часов. |
| Logs | Кольцевой буфер логов с поиском, фильтром по уровню и режимом хвоста. |
| Audit | Журнал административных действий, кто что нажал. |
| Dead-letter | Очередь недоставленных сообщений: просмотр, повтор, отбрасывание. |
Настройки
| Раздел | Что там |
|---|---|
| Auth & Users | API-ключи и учётные записи: создание, отзыв с подтверждением. |
Вход отдельной страницей: в кластерном режиме учётные записи берутся из redb, в автономном из конфигурации.
Четыре вещи в этом списке стоят отдельного слова.
Мониторинг по каждой ноде отдельно. Графики строятся из истории метрик самого воркера, а не из внешней системы. Prometheus и Grafana подключаются рядом и никуда не деваются, но чтобы посмотреть, что происходит с конкретной нодой прямо сейчас, разворачивать их не нужно.
Сообщения в полёте. Разбор маршрута показывает, какие именно обмены сидят в маршруте прямо сейчас. Когда очередь не разгребается, вопрос «оно висит или просто медленно» закрывается взглядом, а не подключением отладчика к проду.
Watchdog. Служба непрерывно классифицирует маршруты и отделяет подозрительные от зависших. При желании перезапускает их сама.
Повтор недоставленного. Кнопка повтора в разделе Dead-letter выполняет ровно один повтор, даже если её нажали двое операторов одновременно: заявка на запись берётся условным UPDATE, и база сама решает, кто из двоих победил.
Дизайн-система у консоли своя, без Bootstrap, MUI и Tailwind: переменные CSS, системные шрифты, тёмная и светлая темы, встроенные SVG-иконки.
Управление, а не только просмотр
Дашборд это одна из трёх голов. Под ними всеми лежит одно и то же REST API, 70 эндпоинтов в 16 контроллерах, и все они говорят JSON.
| Группа | Эндпоинтов | Про что |
|---|---|---|
/api/health |
3 | Пробы Kubernetes: startup, live, ready. Без авторизации. |
/api/system |
6 | Здоровье, метрики, история метрик, информация о процессе, эффективная конфигурация, список сборок. |
/api/contexts |
8 | Список, запуск, остановка, перезапуск, сброс состояний маршрутов, эндпоинты, удаление. |
/api/contexts/{ctx}/routes |
8 | Маршруты: старт, стоп, принудительная остановка, сообщения в полёте, метрики. |
/api/modules |
6 | Список, удаление, загрузка, проверка подписи, откат. |
/api/scheduler |
8 | Планировщик: статус, задания, выполняющиеся сейчас, пауза, возобновление, немедленный запуск. |
/api/cluster |
6 | Статус, ноды, ребаланс, удаление ноды, cordon, uncordon. |
/api/watchdog |
6 | Статус, оповещения, включение, тестовое оповещение. |
/api/exchanges |
3 | Очередь недоставленных: список, повтор, отбрасывание. |
/api/logs |
3 | Инкрементальный хвост, список файлов, скачивание. |
/api/diagnostics |
2 | Дамп по кластеру и по маршруту. |
/api/auth |
3 | API-ключи: создание, список, отзыв. |
/api/users |
5 | Пользователи. |
/api/audit |
1 | Журнал административных действий. |
/api/lifecycle |
1 | Лента событий жизненного цикла. |
/api/dashboard |
1 | Агрегированный снимок для консоли одним запросом. |
Само API устроено красиво: это обычный контекст маршрутов _system, один HTTP-слушатель, конвейер которого выглядит как «мост заголовков, авторизация, диспетчер контроллеров». То есть Tsak управляет собой тем же движком, которым обслуживает ваши маршруты.
Вторая голова это CLI: tsak, один бинарник, 57 команд, профили подключения, вывод таблицей для человека и JSON для CI.
tsak login http://prod-1:9090 --key $PROD_KEY --profile prod
tsak profile use prod
tsak context list # таблица
tsak context list --output json # для jq в пайплайне
tsak route force-stop orders route-1 # снять зависший маршрут
tsak route inflight orders route-1 # посмотреть, что в нём сидит
tsak dlq replay 42 # повторить недоставленное
tsak cluster cordon node-2 # вывести ноду из-под назначений
tsak module deploy ./Orders.tpkg # выложить модуль
tsak module rollback Orders # откатиться на прошлую версию
Третья голова это типизированный клиент на C# для тех, кто хочет автоматизировать эксплуатацию из своего кода:
services.AddTsakClient(o => { o.BaseUrl = "http://tsak-prod:9090"; o.ApiKey = key; });
public class Ops(ITsakApiClient tsak)
{
public async Task RestartFailedAsync(CancellationToken ct)
{
var contexts = await tsak.ListContextsAsync(ct);
foreach (var c in contexts.Where(c => c.Status == "Failed"))
await tsak.RestartContextAsync(c.Name, ct);
}
}
Про доступ: ключи хранятся хешем HMAC-SHA256, сырой ключ не сохраняется никогда, сравнение идёт за постоянное время, у ключа есть роли, срок и отзыв, а отозванный ключ перестаёт приниматься на всех нодах в пределах тридцати секунд. Межнодовые вызовы ходят по той же аутентификации: неявного доверия между нодами нет.
С версии 3.7.0 всё это ещё и закрыто по умолчанию. Управляющее API слушает 127.0.0.1, а не 0.0.0.0, так что выставить его наружу это осознанное действие. Ключ без ролей отклоняется, а не считается администраторским. У консоли настоящая серверная сессия на cookie, пароль сверяется с хешем BCrypt, а на вход навешен лимит попыток.
Kubernetes, Prometheus, Jaeger
Внешние системы наблюдения подключаются без прослойки, потому что Tsak с самого начала писался под контейнеры.
Три пробы, а не одна. Разделены по фазам жизненного цикла пода, все три без авторизации.
startupProbe: { httpGet: { path: /api/health/startup, port: 9090 } }
livenessProbe: { httpGet: { path: /api/health/live, port: 9090 } }
readinessProbe: { httpGet: { path: /api/health/ready, port: 9090 } }
Разница между liveness и readiness сделана сознательно. Liveness намеренно не проверяет здоровье модулей, иначе выкатка новой версии превращалась бы в цикл перезапусков. Readiness строже: любой контекст не в рабочем состоянии выводит под из балансировки, но не перезапускает его, а кластер тем временем перераспределяет назначения.
Prometheus без отдельного порта. При Tsak:Metrics:Prometheus:Enabled метрики отдаются на /metrics того же порта, что и API. Слушатель OpenTelemetry при этом сидит на loopback, а наружу его проксирует маршрут фасада. Отдельный порт открывать не нужно, и на Windows не нужен URL ACL: сокеты биндит Kestrel, а не HttpListener.
metadata:
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "9090"
prometheus.io/path: "/metrics"
Экспортёр при этом предполётно проверяется: если привязка к loopback не удалась, Tsak пишет предупреждение и работает без метрик. Необязательный экспортёр не имеет права ронять воркер.
Jaeger и любой OTLP-коллектор. Трассы уезжают через штатный экспортёр OTLP, Tsak:Tracing:Otlp:Enabled плюс адрес. В конвейер OpenTelemetry зарегистрирован ActivitySource из redb.Route, поэтому спаны, которые открывают процессоры и транспорты маршрутов, собираются сами. Имя сервиса в Jaeger задаётся Tsak:Tracing:ServiceName, и в микросервисной раскладке это ровно то, что нужно: у каждого воркера своё имя, а трасса сшивается через брокер.
Готовые артефакты. В репозитории лежат манифесты Kubernetes (Deployment с правильными пробами, Service, ServiceMonitor для Prometheus Operator), импортируемый дашборд Grafana и локальный стек Prometheus + Grafana + Jaeger одним docker compose.
Личность пода. В кластере важно, чтобы NodeId переживал перезапуск, иначе назначения разъезжаются. Через downward API он привязывается к имени пода:
env:
- name: Tsak__Cluster__NodeId
valueFrom: { fieldRef: { fieldPath: metadata.name } }
- name: POD_IP
valueFrom: { fieldRef: { fieldPath: status.podIP } }
- name: Tsak__Cluster__ApiEndpoint
value: http://$(POD_IP):9090
Мягкое завершение. Tsak:Shutdown:TimeoutSeconds ставится на пять секунд меньше, чем terminationGracePeriodSeconds, чтобы у снятия ноды с регистрации остался запас. Порядок такой: SIGTERM, снятие с регистрации в кластере, слив контекстов, остановка планировщика, сброс логов. До SIGKILL дело не доходит.
Кроме этого воркер сам считает: метрики процесса с историей на двенадцать часов при съёме раз в десять секунд (4320 точек), метрики по контекстам и маршрутам (сообщения в секунду, доля ошибок, сколько в полёте), кольцевой буфер логов на две тысячи записей с запросом через REST и консоль, и дампы диагностики по маршруту и по всему кластеру.
Identity приезжает пакетом в тот же воркер
Тот же формат модуля годится для законченного продукта. Полноценный сервер OAuth 2.1 и OpenID Connect поставляется как набор .tpkg.
Четыре пакета: redb.Identity.Core (сам сервер: схемы, хранилища, MFA, WebAuthn, федерация, аудит, ротация ключей), плюс три транспортных фасада, Http, Grpc и Soap. Фасады это тонкие мосты без бизнес-логики.
Раскладывается это как угодно, ровно по той же логике, что и ваши модули:
- Один воркер:
Core+Http, и у вас есть OP на своём порту. - Два воркера:
Core+Httpсмотрит наружу,Core+Grpcобслуживает межсервисные вызовы. Разные группы кластера, разные лимиты, разная доступность извне. - Внутри вашего же воркера: положили
redb.Identity.Coreрядом со своим модулем и зовёте его прямо из своего маршрута.
// Ни HTTP, ни сериализации, ни loopback: тот же обмен.
From("http:0.0.0.0:5090/api/login")
.To("direct-vm://identity-token");
Это в чистом виде выгода общего рантайма: два продукта, которые ничего друг о друге не знают, оказываются в одном процессе и общаются без сети, потому что оба разговаривают адресами маршрутов.
Наполнение проверенное и полноценно испытанное: OIDC Core, OAuth 2.1, интроспекция, динамическая регистрация клиентов, Device Code, PAR, JAR, DPoP, backchannel logout, SCIM 2.0, TOTP, OTP по SMS и почте, WebAuthn. Официальный набор тестов OpenID Foundation проходит с нулём отказов на профилях Config OP и Basic OP.
И наблюдается оно тем же дашбордом, что и всё остальное: маршруты Identity видны в общем списке, метрики в общих графиках, логи в общем буфере.
Свой воркер, если он нужен
Модуль остаётся вашим и работает где угодно. Точка входа это либо тип с ITsakModule, либо публичный статический класс InitRoute с методом main(IRouteContext). Второе это соглашение в духе Apache Camel, и вызвать его может кто угодно.
Отладочный хост целиком:
var services = new ServiceCollection();
services.AddLogging(b => b.AddSimpleConsole());
services.AddRedb(o => o.UseSqlite("Data Source=echo_demo.db"));
var sp = services.BuildServiceProvider();
await sp.GetRequiredService<IRedbService>().InitializeAsync(ensureCreated: true);
var ctx = new RouteContext(sp, contextId: "echo-worker");
ctx.AddService(typeof(ILoggerFactory), sp.GetRequiredService<ILoggerFactory>());
EchoModule.InitRoute.main(ctx); // ровно тот метод, который зовёт воркер Tsak
await ctx.Start();
Полсотни строк, и модуль работает под отладчиком в вашей IDE, с точками останова и шагами по коду. Это заметно удобнее, чем подключаться к горячо загруженному контексту сборок внутри работающего воркера.
Тот же приём годится, если хочется собственный хост: со своим DI, со своей конфигурацией, со своим сбором метрик. Исходники redb.Tsak открыты под Apache 2.0, пакеты опубликованы в NuGet, никакого закрытого рантайма в середине нет.
Пять слоёв конфигурации: почему один образ обслуживает девять воркеров
Это тот механизм, благодаря которому три сервиса из примера выше запускаются из одного образа.
Слой 1: Tsak:Contexts:default база для всех контекстов
Слой 2: Tsak:Contexts:{name} настройки конкретного контекста
Слой 3: modules/{Module}/context.json инфраструктурные умолчания модуля
Слой 4: modules/{Module}/{Module}.config.json бизнес-настройки модуля
Слой 5: Tsak:Contexts:{name}:Override последнее слово за эксплуатацией
Слои сливаются глубоко: вложенные объекты дополняют друг друга, а не затирают целиком. Модуль привозит разумные умолчания в своём архиве, эксплуатация переопределяет то, что должно отличаться в проде, и в коде модуля для этого ничего не предусмотрено заранее.
Практический вывод для секретов: пароли LDAP, SMTP и подписи JWT приезжают через слой Override из переменных окружения и попадают в именованные фабрики подключений. В .tpkg их нет, в URI эндпоинтов их нет, в логах и в дашборде их нет.
Дополнительно к этому в общем слое воркера уже лежат 28 коннекторов redb.Route: RabbitMQ, Kafka, AMQP, Azure Service Bus, IBM MQ, SQS, Redis, S3, Elasticsearch, SQL, gRPC, SOAP, AS2, SignalR, WebSocket, MQTT, TCP, почта, файлы, FTP, SFTP, LDAP, Telegram, Firebase, LLM и прочие. Модуль, который ходит в Kafka, не тащит с собой драйвер: он уже в воркере.
Что это стоит
Границы, которые честно стоит знать до внедрения.
Выгрузка контекстов сборок выключена по умолчанию. HotReload:Collectible = false, и это осознанно: Reflection.Emit (а его используют XmlSerializer, генераторы сериализации и скомпилированные регулярные выражения) не переживает выгрузку. Плата в том, что старые контексты сборок остаются в памяти до перезапуска процесса. Их количество вынесено метрикой LeakedAlcCount, так что за ним видно. Для воркера, который обновляется раз в неделю, это несущественно; для того, который обновляется двадцать раз в день, перезапуск раз в сутки решает вопрос.
Стратегия распределения одна. Round-robin. Взвешенные стратегии в планах, интерфейс IAssignmentManager под них уже выделен, свою реализацию можно подставить сегодня.
Координация живёт в базе. Выборы лидера, локи и реестр нод это строки в redb. Плюс в том, что не нужна отдельная инфраструктура мембершипа. Минус в том, что база становится участником координации. Если это не подходит, все шесть интерфейсов координации подменяются в DI, например на реализацию поверх Lease-объектов Kubernetes, и redb остаётся только хранилищем модулей и ключей.
Quartz в кластере требует настоящей БД. RAMJobStore для разработки, AdoJobStore для прода. Схема создаётся сама при первом старте, отдельных действий от DBA не требуется.
Как попробовать
Быстрее всего одним контейнером redb-tsak-stack: воркер и веб-консоль внутри одного образа, по образцу rabbitmq:management.
docker run -p 9090:9090 -p 8085:8085 \
-v ./modules:/app/worker/modules \
ghcr.io/redbase-app/redb-tsak-stack:3.7.2
API поднимется на 9090, дашборд на 8085. Кладём .tpkg в ./modules, и через десять секунд модуль в списке, а его маршруты на графиках. Ни базы, ни брокера для первого запуска не нужно: хранилище по умолчанию в памяти.
Когда дело дойдёт до раскладки по машинам, в ход идут отдельные образы redb-tsak-worker и redb-tsak-web, как в compose-файле выше. Готовые шаблоны на все четыре случая (только воркер, только консоль, стек, стек вместе с PostgreSQL) лежат в репозитории в publish/docker/.
Без Docker работает самодостаточный архив: распаковали, запустили, положили модули рядом. Это и есть монолитная раскладка одним файлом, с тем же дашбордом и тем же API. Подписи образов и архивов проверяются cosign.
Текущий номер линии 3.7.2, библиотеки таргетят net8.0, net9.0 и net10.0, приложения и образы собраны на .NET 10 и помечены тегом -net10. Pro остаётся проприетарным, но бесплатным и без ключа на всей линии 3.x, включая кластер: ограничения по числу нод нет. Основной набор модульных тестов воркера на этой линии проходит целиком, 647 из 647 на .NET 10.
- Исходники: github.com/redbase-app/redb-tsak
- Релизы и архивы: redb-tsak/releases
- Образы: стек одним контейнером, все образы в GHCR
- Пакеты NuGet: nuget.org/profiles/relikt
- Про экосистему целиком: redb.ru
Начать проще с монолитной раскладки, а на микросервисы перейти, когда для этого появится причина. Приятная часть в том, что переход обойдётся правкой compose-файла, а не переписыванием кода.
Если было полезно, ⭐ на GitHub поможет другим это найти.
Другие мои статьи — redb.ru/articles, ещё — на Хабре.