Files
fission-src/doc/thinking/2026-04-26-namespace-manager-target-design.md
T

27 KiB
Raw Blame History

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-ов.


Предлагаемая модель данных

Ниже не обязательно точный конечный код, но это целевая форма.

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-ы с разным происхождением.

type NamespaceSource string

const (
    NamespaceSourceEnv      NamespaceSource = "env"
    NamespaceSourceWatcher  NamespaceSource = "watcher"
    NamespaceSourceBackfill NamespaceSource = "backfill"
)

Почему это важно:

  1. Проще расследовать состояние системы.
  2. Проще логировать, откуда namespace попал в менеджер.
  3. Проще понять, что именно должно переживать restart и что должно исчезать при relabel/delete.

Предлагаемый API NamespaceManager

Ниже не «идеальный forever API», а минимально полезный контракт.

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, но и события.

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:

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-ов.

Например:

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, а то, что почти наверняка понадобится внутри.

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 слоем с понятным жизненным циклом.