Задача Егора
This commit is contained in:
@@ -78,11 +78,18 @@ sequenceDiagram
|
||||
|-------|-----------|
|
||||
| `TenantInterceptor` | Извлекает поддомен из заголовка `Host` и определяет тенант |
|
||||
| `TenantContext` | `ThreadLocal`-хранилище имени текущего тенанта |
|
||||
| `TenantRoutingDataSource` | Наследует `AbstractRoutingDataSource`, маршрутизирует запросы к нужной БД |
|
||||
| `TenantRoutingDataSource` | Маршрутизирует запросы и атомарно публикует неизменяемый снимок `TenantConfig + DataSource` |
|
||||
| `TenantDataSourceConfig` | Загружает конфигурацию тенантов из JSON-файла, создаёт HikariCP пулы |
|
||||
| `TenantWebMvcConfig` | Регистрирует `TenantInterceptor` и `AuthorizationInterceptor` в MVC-слое |
|
||||
| `TenantConfigWatcher` | Периодически (каждые 30 сек) перечитывает `tenants.json`, синхронизирует тенантов |
|
||||
| `KubernetesTenantSecretUpdater` | Обновляет Kubernetes Secret с tenant-конфигурацией через API, проверяя TLS по service-account CA |
|
||||
| `TenantConfigStore` | Задаёт явные атомарные операции upsert/remove и условную компенсацию persisted-конфигурации |
|
||||
| `KubernetesTenantSecretUpdater` | Реализует `TenantConfigStore` через GET и условный PUT Kubernetes Secret по `resourceVersion`, проверяя TLS по service-account CA |
|
||||
| `TenantLifecycleService` | Сериализует prepare/validate/migrate/persist/swap/remove и выполняет компенсацию при отказе |
|
||||
| `TenantDatabaseMigrationService` | Запускает Flyway для подготовленного, ещё не опубликованного pool |
|
||||
| `RetiredTenantPoolService` | После swap дожидается завершения активных подключений старого pool и закрывает его |
|
||||
| `TenantReadinessRegistry` | Хранит атомарный снимок обязательных tenant, миграций и свежести проверок соединения |
|
||||
| `TenantDatabaseHealthMonitor` | Ограниченно-параллельно проверяет соединения с обязательными tenant-БД в фоне |
|
||||
| `TenantReadinessHealthIndicator` | Включает агрегированную готовность tenant-БД в Actuator readiness group |
|
||||
| `TenantConfig` | POJO с параметрами тенанта: `name`, `domain`, `url`, `username`, `password` |
|
||||
|
||||
### Определение тенанта
|
||||
@@ -121,16 +128,84 @@ sequenceDiagram
|
||||
|
||||
### Жизненный цикл тенанта
|
||||
|
||||
1. **Добавление через API:** `POST /api/database/tenants` → создаёт HikariCP пул → запускает Flyway миграции → обновляет `tenants-secret`
|
||||
2. **Синхронизация подов:** `TenantConfigWatcher` каждые 30 сек проверяет смонтированный `tenants.json` → добавляет новые / удаляет отсутствующие тенанты
|
||||
3. **Удаление:** `DELETE /api/database/tenants/{domain}` → закрывает пул → обновляет `tenants-secret`
|
||||
1. **Добавление или обновление через API:** `TenantLifecycleService` нормализует конфигурацию,
|
||||
создаёт непубликуемый HikariCP candidate, проверяет реальное соединение и выполняет
|
||||
Flyway. Затем `TenantConfigStore` читает актуальный Secret и применяет к нему только
|
||||
upsert запрошенного домена.
|
||||
2. **Межподовая запись:** `KubernetesTenantSecretUpdater` выполняет `GET` текущего
|
||||
`tenants-secret`, нормализует и сортирует список, после чего отправляет полный объект
|
||||
условным `PUT` с прочитанным `metadata.resourceVersion`. При `409 Conflict` актуальное
|
||||
состояние читается заново, а собственная мутация повторно применяется к нему. Число
|
||||
попыток ограничено тремя, задержка между попытками возрастает; идемпотентная операция
|
||||
не отправляет лишний `PUT`.
|
||||
3. **Атомарная локальная публикация:** только после успешной или подтверждённой повторным
|
||||
чтением персистенции одной публикацией заменяется связка `TenantConfig + DataSource`.
|
||||
4. **Безопасный отказ и компенсация:** ошибка credentials, соединения, Flyway или Secret
|
||||
закрывает candidate, не меняя действующий route. Компенсация прежним снимком разрешена
|
||||
только для подтверждённой записи и только пока текущий Secret сохраняет выданный ей
|
||||
`resourceVersion`. Если другой pod уже записал более новую версию, автоматический откат
|
||||
пропускается. Неопределённый сетевой результат сначала сверяется повторным `GET` и не
|
||||
создаёт квитанцию для небезопасной компенсации. HTTP-граница получает русское безопасное
|
||||
сообщение без JDBC/Flyway details.
|
||||
5. **Drain старого pool:** после успешного swap новые запросы сразу используют candidate,
|
||||
а прежний Hikari pool закрывается после завершения активных подключений либо по истечении
|
||||
настраиваемого grace timeout.
|
||||
6. **Синхронизация подов:** `TenantConfigWatcher` каждые 30 секунд проверяет смонтированный
|
||||
`tenants.json` и применяет добавление/удаление под тем же локальным lifecycle-monitor.
|
||||
7. **Удаление:** `DELETE /api/database/tenants/{domain}` применяет к актуальному Secret только
|
||||
удаление указанного домена через тот же условный `PUT`, затем атомарно исключает tenant
|
||||
из маршрутизации и передаёт pool на drain. Для отказа локального удаления действуют те же
|
||||
ограничения компенсации по `resourceVersion`.
|
||||
|
||||
Сериализация lifecycle и атомарный снимок защищают запросы внутри одного backend-процесса.
|
||||
Между pod потерянное обновление предотвращает optimistic locking Kubernetes: каждый конфликт
|
||||
заставляет заново прочитать Secret и повторно применить только свою доменную мутацию. Локальный
|
||||
monitor по-прежнему не считается межподовой блокировкой.
|
||||
|
||||
Вне Kubernetes `KubernetesTenantSecretUpdater` работает как runtime-only адаптер: Secret и
|
||||
локальный `tenants.json` не изменяются. Постоянная локальная конфигурация задаётся файлом до
|
||||
запуска приложения.
|
||||
|
||||
Для обращения к Kubernetes API backend загружает
|
||||
`/var/run/secrets/kubernetes.io/serviceaccount/ca.crt`, использует стандартную PKIX-проверку
|
||||
цепочки и обязательную проверку hostname. Trust-all fallback отсутствует: ошибка CA или TLS
|
||||
завершает персистенцию безопасным отказом.
|
||||
|
||||
`TenantConfigWatcher` сравнивает содержимое `tenants.json` по SHA-256, а не по `String.hashCode()`, чтобы изменение ConfigMap не пропускалось из-за 32-битной коллизии.
|
||||
`TenantConfigWatcher` сравнивает содержимое смонтированного из Secret `tenants.json` по
|
||||
SHA-256, а не по `String.hashCode()`, чтобы изменение файла не пропускалось из-за
|
||||
32-битной коллизии. На текущем этапе watcher активирует новые домены и удаляет отсутствующие,
|
||||
но ещё не заменяет подключение существующего домена при изменении его URL или credentials.
|
||||
Полная синхронизация такого изменения относится к проблеме №13; до её выполнения новое
|
||||
значение сбрасывает readiness этого tenant в состояние ожидания, а pod не должен считаться
|
||||
готовым по старому подключению.
|
||||
|
||||
### Liveness и readiness
|
||||
|
||||
Spring Boot Actuator публикует две независимые служебные группы:
|
||||
|
||||
- `GET /actuator/health/liveness` включает только `livenessState` приложения. Отказ любой
|
||||
tenant-БД не переводит процесс в liveness failure и не создаёт restart storm;
|
||||
- `GET /actuator/health/readiness` включает `readinessState` и `tenantReadiness`. Ответ имеет
|
||||
статус `UP` только если задан непустой набор обязательных tenant, миграция каждого успешно
|
||||
завершена и для каждого есть свежая успешная проверка соединения.
|
||||
|
||||
`TenantDataSourceConfig` регистрирует обязательный набор до startup-активации и сохраняет
|
||||
неуспешную миграцию как readiness failure. H2-заглушка позволяет инициализировать Spring JPA,
|
||||
но никогда не делает приложение готовым к production-трафику. Ошибка чтения обязательного
|
||||
`tenants.json` также фиксируется отдельно от состояния процесса.
|
||||
|
||||
`TenantDatabaseHealthMonitor` по умолчанию запускается через секунду после старта, затем
|
||||
проверяет соединения каждые 10 секунд пакетами до четырёх параллельных задач. Таймаут пакета
|
||||
составляет 6 секунд: по его истечении отменяются только незавершённые задачи, а уже полученные
|
||||
результаты сохраняются с фактическим временем завершения проверки. Результат старше 30 секунд
|
||||
считается просроченным. Запоздалый результат привязан к HMAC-fingerprint конкретной конфигурации
|
||||
и не может отметить новую конфигурацию готовой; исходные credentials в fingerprint и
|
||||
health-ответ не попадают. Планировщик использует два потока, поэтому ожидающий health-pass не
|
||||
задерживает периодическое чтение tenant Secret.
|
||||
|
||||
Tenant interceptor исключает `/actuator/**`, а authorization interceptor применяется только
|
||||
к `/api/**`. При этом наружу опубликован только endpoint `health`, его details и components
|
||||
скрыты: kubelet получает минимальный `UP`/`DOWN` без доменов и реквизитов tenant-БД.
|
||||
|
||||
### Fallback при отсутствии тенантов
|
||||
|
||||
@@ -138,6 +213,9 @@ sequenceDiagram
|
||||
1. Проверяется наличие `spring.datasource.url` → создаётся тенант `default`
|
||||
2. Если datasource тоже нет → создаётся H2 in-memory заглушка для инициализации Spring JPA
|
||||
|
||||
Для production обязательность смонтированной конфигурации задаётся
|
||||
`TENANTS_CONFIG_REQUIRED=true`; отсутствие или ошибка чтения файла снимают readiness.
|
||||
|
||||
`TenantContext` хранит имя текущего тенанта в `ThreadLocal` и очищается интерцептором после завершения запроса. Виртуальные потоки в приложении не включены; если их включать в будущем, нужно отдельно проверить перенос и очистку tenant/auth context на асинхронных участках.
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user