doc: add namespace manager target design

This commit is contained in:
Naeel
2026-04-26 09:53:06 +03:00
parent 1f53bc1fb7
commit 97b13a13c2
@@ -0,0 +1,697 @@
# 2026-04-26 — Target design: полноценный NamespaceManager для Layer 1
## Зачем нужен ещё один документ
Уже есть подробный документ про сделанные шаги 1-6.
Но этого недостаточно для следующего этапа, потому что:
1. История исправлений не равна целевой архитектуре.
2. Локальные фиксы уже уменьшили риск, но не дали единого lifecycle contract.
3. Следующий этап уже нельзя начинать как серию хаотичных патчей по месту.
Нужен отдельный документ, который отвечает на вопрос:
какой именно Layer 1 мы хотим получить в результате bounded rewrite.
---
## Коротко: что именно строим
Нужен не просто thread-safe registry namespace-ов, а orchestration layer с явным lifecycle.
То есть не объект вида:
- `map[string]string` + `AddNamespace()`
а объект вида:
- обнаружение namespace;
- нормализация состояния;
- единый жизненный цикл add/remove/reconcile;
- подписка компонентов на события;
- безопасный snapshot для background loops;
- backfill existing namespaces on startup;
- восстановление после restart.
Рабочее имя этой сущности: `NamespaceManager`.
---
## Какую проблему он решает
Сейчас логика размазана по нескольким слоям одновременно:
1. `NamespaceResolver` хранит registry.
2. watcher-ы executor/router/buildermgr сами решают, как регистрировать namespace.
3. components сами придумывают свой dedup.
4. часть background loops читают namespace snapshot.
5. provisioning SA/RBAC живёт как side effect watcher-а.
Из-за этого нет одного ответа на вопросы:
1. Когда namespace считается «принятым» системой?
2. Когда он считается «удалённым»?
3. Что должно происходить при restart компонента?
4. Кто отвечает за cleanup?
5. Кто отвечает за reconcile при расхождении локального и фактического состояния?
`NamespaceManager` нужен именно для того, чтобы эти вопросы получили один общий ответ.
---
## Какие свойства должны быть у новой подсистемы
### 1. Один вход для namespace lifecycle
Все namespace-ы, независимо от того, пришли они:
- из env на старте;
- из уже существующих labeled namespaces;
- из нового namespace event;
- из relabel existing namespace;
должны проходить через один и тот же pipeline.
### 2. Явный state machine
Нельзя больше жить в модели «namespace либо есть в map, либо нет». Нужны как минимум фазы:
- discovered;
- registering;
- active;
- deregistering;
- removed;
- failed.
Не обязательно все эти фазы сразу экспонировать наружу, но внутренняя модель должна понимать, на каком этапе lifecycle находится namespace.
### 3. Разделение ответственности
Нужно развести по слоям:
1. Discovery — кто узнал о namespace.
2. Registry — текущее состояние namespace в памяти процесса.
3. Reconcile — как довести локальное состояние до желаемого.
4. Subscription — как сообщить executor/router/buildermgr о событии.
5. Provisioning — отдельные side effects вроде SA/RBAC.
### 4. Thread-safe чтение и запись
Любой компонент должен иметь один безопасный способ получить:
- snapshot namespace-ов;
- текущее состояние конкретного namespace;
- stream событий.
### 5. Symmetry startup vs runtime
Если namespace был известен на старте или пришёл позже, конечный набор действий должен быть одинаковым.
Именно этот пункт был нарушен в `newdeploy`, и именно он должен стать жёстким архитектурным правилом нового дизайна.
---
## Что не должно быть в новой модели
### 1. Прямых чтений глобальной map из произвольных мест
Любой код, который напрямую читает внутреннюю структуру namespace registry, должен считаться legacy и подлежать выносу.
### 2. Глобального dedup вместо локального lifecycle
Global registry отвечает только на вопрос «namespace известен системе». Он не должен автоматически означать «каждый компонент уже подключил все свои informers».
### 3. Неявных side effects в watcher callback
Watcher должен сообщать о факте, а не выполнять пол-процесса orchestration сам по себе.
### 4. Скрытой зависимости от порядка вызовов
Сейчас уже был пойман дефект, когда второй компонент не регистрировался, потому что первый успел пометить namespace как «уже обработанный». Новая модель должна быть инвариантна к порядку subscriber-ов.
---
## Предлагаемая модель данных
Ниже не обязательно точный конечный код, но это целевая форма.
```go
type NamespacePhase string
const (
NamespacePhaseDiscovered NamespacePhase = "discovered"
NamespacePhaseRegistering NamespacePhase = "registering"
NamespacePhaseActive NamespacePhase = "active"
NamespacePhaseDeregistering NamespacePhase = "deregistering"
NamespacePhaseRemoved NamespacePhase = "removed"
NamespacePhaseFailed NamespacePhase = "failed"
)
type NamespaceRecord struct {
Name string
Source NamespaceSource
Labels map[string]string
Phase NamespacePhase
LastError string
Generation int64
UpdatedAt time.Time
RegisteredParts map[string]NamespacePartState
}
type NamespacePartState struct {
State string
LastError string
UpdatedAt time.Time
}
```
Важная идея: manager должен знать не только список namespace-ов, но и состояние регистрации по частям.
Например:
- executor.poolmgr: active
- executor.newdeploy: active
- router: active
- buildermgr.env: active
- buildermgr.pkg: failed
- provisioning.fetcher-sa: active
Это критично для reconcile. Иначе при частичном падении система знает только «namespace есть», но не знает, что именно недорегистрировано.
---
## Источники namespace-ов
Нужен явный тип источника, чтобы не смешивать namespace-ы с разным происхождением.
```go
type NamespaceSource string
const (
NamespaceSourceEnv NamespaceSource = "env"
NamespaceSourceWatcher NamespaceSource = "watcher"
NamespaceSourceBackfill NamespaceSource = "backfill"
)
```
Почему это важно:
1. Проще расследовать состояние системы.
2. Проще логировать, откуда namespace попал в менеджер.
3. Проще понять, что именно должно переживать restart и что должно исчезать при relabel/delete.
---
## Предлагаемый API NamespaceManager
Ниже не «идеальный forever API», а минимально полезный контракт.
```go
type NamespaceManager interface {
Snapshot() []string
SnapshotRecords() []NamespaceRecord
Get(name string) (NamespaceRecord, bool)
RegisterDesired(ctx context.Context, event NamespaceEvent) error
DeregisterDesired(ctx context.Context, name string, reason string) error
Subscribe(name string, subscriber NamespaceSubscriber)
Start(ctx context.Context)
}
```
И ещё важнее — не только sync API, но и события.
```go
type NamespaceEventType string
const (
NamespaceEventAdd NamespaceEventType = "add"
NamespaceEventUpdate NamespaceEventType = "update"
NamespaceEventRemove NamespaceEventType = "remove"
NamespaceEventResync NamespaceEventType = "resync"
)
type NamespaceEvent struct {
Type NamespaceEventType
Name string
Labels map[string]string
Source NamespaceSource
ObservedAt time.Time
}
type NamespaceSubscriber interface {
Name() string
OnNamespaceAdd(ctx context.Context, ns NamespaceRecord) error
OnNamespaceRemove(ctx context.Context, ns NamespaceRecord) error
OnNamespaceResync(ctx context.Context, ns NamespaceRecord) error
}
```
---
## Как должен работать startup
Это один из самых важных разделов. Сейчас именно startup/runtime symmetry остаётся центральным требованием.
### Текущий анти-pattern
Сначала что-то строится по env namespaces, потом dynamic path делает другой набор действий отдельно.
### Целевой startup
При старте процесса manager должен:
1. Собрать namespaces из env.
2. Сделать backfill всех существующих namespaces с label `fission.io/managed=true`.
3. Нормализовать список без дублей.
4. Сформировать initial desired set.
5. Пропустить весь этот set через тот же reconcile pipeline, что и поздние события.
6. Только потом считать manager готовым.
Иначе говоря:
startup — это просто массовый initial reconcile, а не отдельная логика «в обход».
---
## Как должен работать runtime add
Когда watcher видит новый namespace или relabel в `managed=true`, он не должен сам лезть во все компоненты.
Он должен только отправить event в manager:
```go
RegisterDesired(NamespaceEvent{Type: Add, Name: ns, Source: Watcher, Labels: ...})
```
Дальше manager:
1. Обновляет/создаёт `NamespaceRecord`.
2. Ставит phase `registering`.
3. По подписчикам запускает reconcile `OnNamespaceAdd`.
4. Фиксирует state каждой части.
5. Если все обязательные части успешны, переводит namespace в `active`.
6. Если часть упала, переводит в `failed` с возможностью повторной reconcile.
Это важно: add должен быть idempotent и retry-friendly.
---
## Как должен работать runtime remove
Это следующий большой пробел в текущем Layer 1.
Нужен единый remove path для двух случаев:
1. namespace удалён;
2. label `fission.io/managed=true` снят.
Пайплайн должен быть таким:
1. Watcher сообщает `remove` event.
2. Manager помечает namespace как `deregistering`.
3. Вызывает `OnNamespaceRemove` у подписчиков.
4. Каждый подписчик:
- останавливает локальные informers;
- удаляет namespace из локальных lister maps;
- очищает связанный cache state.
5. После успешного снятия подписок manager переводит namespace в `removed` или удаляет запись полностью.
Главная причина делать это централизованно:
если remove semantics будут разъезжаться по компонентам, получится новая версия текущей проблемы, только уже в lifecycle удаления.
---
## Как должен работать reconcile
Remove/add недостаточно. Нужен ещё reconcile.
Причины:
1. Компонент мог стартовать позже manager-а.
2. Подписчик мог упасть на середине регистрации namespace.
3. Restart процесса может привести к тому, что локальная память пуста, а кластерное состояние уже существует.
Поэтому manager должен уметь периодически или по событию заново прогонять namespace через subscriber-ов.
Например:
```go
OnNamespaceResync(ctx, ns)
```
Или через тот же `OnNamespaceAdd`, если он строго idempotent.
Инженерно я бы предпочёл следующее правило:
1. `OnNamespaceAdd` и `OnNamespaceResync` могут быть одной реализацией.
2. Но семантически различать их всё равно полезно для логов и метрик.
---
## Кто должен быть subscriber-ами
### 1. Executor subscriber
Внутри него можно уже вызывать внутренние add/remove/resync по типам:
- poolmgr
- newdeploy
- container
Но для manager это один subscriber уровня executor.
Почему это лучше:
1. Manager не должен знать детали каждого executor type.
2. Executor сам лучше знает, что для него является complete registration.
### 2. Router subscriber
Отвечает за:
- func informer;
- trigger informer;
- resolver informer registry;
- invalidate/rebuild path.
### 3. BuilderMgr subscriber
Но внутри него стоит сделать внутреннее разделение частей:
- env watcher part;
- pkg watcher part.
Именно потому, что на этом месте уже был пойман баг локального dedup.
### 4. Provisioning subscriber
Отдельный subscriber для:
- `fission-fetcher` SA;
- возможно builder SA;
- связанных Role/RoleBinding path.
Почему это должен быть отдельный subscriber:
сейчас provisioning встроен как side effect watcher-а, а это делает sequencing слишком хрупким и плохо наблюдаемым.
---
## Почему provisioning нужно вынести отдельно
Сейчас логика «namespace зарегистрирован» и логика «в namespace создан нужный service account + RBAC» слишком слеплены.
Это вредно по нескольким причинам:
1. Трудно диагностировать, что именно сломалось: discovery, informer wiring или RBAC provisioning.
2. Нельзя отдельно повторить provisioning без повторного полного namespace registration.
3. Нельзя нормально отслеживать частичный success.
Целевой дизайн:
- manager знает, что provisioning — это отдельная обязательная или полуобязательная часть namespace lifecycle;
- provisioning subscriber отдаёт свой статус отдельно;
- при необходимости его можно повторно reconcile без переинициализации router/executor/buildermgr.
---
## Нужен ли новый объект вместо NamespaceResolver
Да, но не обязательно удалять `NamespaceResolver` в один момент.
Реалистичная стратегия:
### Этап A
Сделать `NamespaceResolver` внутренней реализацией snapshot/compat layer.
### Этап B
Поверх него построить `NamespaceManager` как orchestration layer.
### Этап C
Постепенно вычистить прямые зависимости компонентов от `NamespaceResolver` и перевести их на manager/subscriber contract.
Почему так, а не сразу delete old resolver:
1. Слишком много мест уже используют текущие helper-ы.
2. Нужен период совместного существования старого snapshot API и нового orchestration API.
3. Иначе blast radius снова станет слишком большим.
---
## Минимальный состав внутренних методов manager-а
Ниже не внешний API, а то, что почти наверняка понадобится внутри.
```go
func (m *manager) upsertRecord(event NamespaceEvent) NamespaceRecord
func (m *manager) markPartState(ns string, subscriber string, state NamespacePartState)
func (m *manager) markPhase(ns string, phase NamespacePhase, err error)
func (m *manager) snapshotActiveNamespaces() []string
func (m *manager) emit(event internalEvent)
func (m *manager) reconcileNamespace(ctx context.Context, name string)
func (m *manager) removeNamespace(ctx context.Context, name string)
```
Причина: если manager не умеет хранить part-level state, он снова выродится в glorified map.
---
## Какой порядок вызовов нужен при add
Не просто «вызвать всех subscriber-ов подряд». Нужна осознанная последовательность.
Один из возможных вариантов:
1. Provisioning subscriber
2. BuilderMgr subscriber
3. Executor subscriber
4. Router subscriber
Но это не единственный вариант. Важно другое: порядок должен быть явным и объяснимым.
Почему provisioning логично раньше:
если namespace ещё не имеет нужного service account, часть runtime path может не подняться корректно.
Почему router можно позже:
он меньше зависит от SA provisioning, чем runtime execution path.
Но я бы не жёстко кодировал этот порядок как случайную последовательность callback-ов. Лучше иметь явно заданную subscriber order policy.
---
## Как manager должен вести себя при частичном падении
Это одна из самых важных деталей, потому что сейчас система часто мыслит бинарно: success/fail.
Нужно поведение такого типа:
1. Executor зарегистрировался успешно.
2. Router зарегистрировался успешно.
3. BuilderMgr не зарегистрировался.
4. Namespace получает phase `failed` или `active-with-errors`.
5. В record фиксируется, что именно сломалось.
6. Reconcile можно повторить только для buildermgr part.
Именно это позволит избегать режимов «namespace вроде есть, но реально не полностью обслуживается, а система этого не видит».
---
## Метрики и логирование
Без этого новый manager будет трудно отлаживать.
Нужно как минимум:
### Метрики
- число active namespaces;
- число failed namespaces;
- число reconcile attempts;
- число add/remove events;
- количество ошибок по subscriber-ам.
### Логи
На каждое важное событие должны быть логи такого класса:
- namespace discovered;
- namespace registration started;
- subscriber registration succeeded;
- subscriber registration failed;
- namespace active;
- namespace deregistering;
- namespace removed;
- resync started/completed.
Без этого следующая стадия дебага снова упрётся в разрозненные логи компонентов.
---
## Тестовая стратегия для нового этапа
Нельзя ограничиться только unit tests отдельных helper-ов.
Нужны как минимум четыре слоя проверок.
### 1. Unit tests manager state machine
- add нового namespace;
- повторный add идемпотентен;
- remove переводит в нужную фазу;
- partial failure отражается в part states.
### 2. Unit tests subscriber ordering / reconcile
- add вызывает всех нужных subscriber-ов;
- failure одного subscriber-а не портит состояние других;
- повторный resync догоняет незарегистрированную часть.
### 3. Component tests
- buildermgr add/remove;
- router add/remove;
- newdeploy add parity;
- executor resync.
### 4. End-to-end tests
- startup with existing managed namespaces;
- late namespace add;
- relabel add;
- label removal;
- namespace delete;
- process restart;
- burst onboarding.
---
## Как бы я разбил реализацию следующего этапа на коммиты
Это очень важно: не повторять ошибку большого rewrite.
### Commit A
Добавить скелет `NamespaceManager` и in-memory record model без подключения компонентов.
Цель:
- новый тип существует;
- есть unit tests state model;
- legacy path ещё не тронут.
### Commit B
Подключить discovery path: env + namespace watcher events начинают идти в manager.
Но subscribers пока можно ограничить одним compatibility subscriber.
### Commit C
Сделать provisioning отдельным subscriber-ом.
### Commit D
Перевести buildermgr на manager/subscriber contract.
Почему именно buildermgr первым:
там уже был пойман реальный dedup defect, и логика явно просит более чистый lifecycle.
### Commit E
Перевести router на manager/subscriber contract.
### Commit F
Перевести executor subscriber.
### Commit G
Добавить remove/relabel/delete lifecycle.
### Commit H
Вычистить legacy прямые обращения к resolver там, где это уже возможно.
---
## Что можно оставить совместимым на переходный период
Не всё нужно ломать сразу.
Можно временно оставить:
1. `Snapshot()` API у `NamespaceResolver` как compatibility layer.
2. Часть существующих helper-ов для informer factory creation.
3. Отдельные component-specific `AddNamespace()` методы, но вызывать их уже через manager subscriber.
Это позволит переподключать компоненты последовательно.
---
## Какие риски у самого NamespaceManager rewrite
Нужно честно фиксировать и риски новой архитектуры.
### 1. Over-centralization
Если сделать manager слишком умным, он начнёт знать внутренности каждого компонента, и получится новый монолит уже поверх старого.
Поэтому manager должен оркестрировать lifecycle, но не содержать доменную логику executor/router/buildermgr.
### 2. Deadlocks или долгие lock sections
Если state manager будет держать lock во время вызова subscriber-ов, это плохой дизайн.
Нужно правило:
- lock только на обновление внутреннего state;
- вызовы subscriber-ов делать вне глобального lock.
### 3. Excessive retries
Если reconcile не ограничить и не сделать наблюдаемым, можно получить noisy system с бесконечными повторными попытками.
### 4. Confused ownership
Если не определить, кто отвечает за remove/reconcile конкретной части, получится новая версия старой размазанной логики.
---
## Что я считаю правильным следующим шагом после этого документа
Не сразу кодить full manager.
Сначала нужен ещё один маленький подготовительный шаг:
1. Добавить новый package или файл со skeleton model `NamespaceRecord`, `NamespacePhase`, `NamespaceEvent`.
2. Покрыть его unit tests.
3. Не подключать пока к production lifecycle.
Почему:
это даст опорную модель данных, вокруг которой уже можно строить manager, не смешивая сразу storage, watchers и subscribers.
---
## Итог
Целевой `NamespaceManager` для Layer 1 — это не «один общий namespace» и не «ещё один helper над map`ой`».
Это должен быть orchestration слой с пятью обязательными свойствами:
1. единый lifecycle add/remove/resync;
2. state model с phase и part-level status;
3. подписчики-компоненты вместо хаотичных side effects;
4. symmetry startup и runtime onboarding;
5. безопасный reconcile после ошибок и restart.
Только после этого можно сказать, что Layer 1 действительно перестал быть монопользовательским Fission с набором динамических заплаток и стал многопользовательским control-plane слоем с понятным жизненным циклом.