# Forensic Architecture Audit: Fission Fork (multitenant, May 2026) > Актуальная редакция. Легаси-версия: `FORENSIC_ARCHITECTURE_AUDIT_LEGACY_2026-05.md` > Обновлено: 2026-05-18 после реализации namespace lifecycle hardening. --- ## Статус исправлений | Риск | Статус | Коммит | |------|--------|--------| | Informer goroutine/FD leak при TrackOnly removal | ✅ ЗАКРЫТ | `4eedf95f` | | Router stale routes при повторном добавлении NS | ✅ ЗАКРЫТ | `4eedf95f` | | Executor dedup dirty state при re-add NS | ✅ ЗАКРЫТ | `4eedf95f` | | Синхронный subscriber dispatch (onboarding latency) | ✅ ЗАКРЫТ | предыдущая сессия | | `DefaultNSResolver` только append (нет RemoveNamespace) | ✅ ЗАКРЫТ | предыдущая сессия | | Stuck-failed namespace без auto-recovery | ✅ ЗАКРЫТ | `919e8439` | | AdoptExistingResources race при rolling update | ✅ ЗАКРЫТ | `2a7d6101` | | No Explicit State Machine (implicit phase transitions) | ⚠️ СМЯГЧЕНО | `919e8439` | | Sharded mutex (bottleneck при >500 concurrent tenant) | ⏳ BACKLOG | не актуально при текущей нагрузке | --- ## Architectural Decisions (реально принятые) - **Dynamic Namespace Discovery**: Механизм динамического обнаружения и подключения tenant-namespace через label `fission.io/managed=true` (`pkg/utils/namespace_manager.go`, `pkg/executor/multitenant/ns_watcher.go`). - **Namespace Lifecycle Management**: Жизненный цикл namespace централизован через интерфейс `NamespaceManager` с подписчиками (executor, router, buildermgr). - **Decoupled Registration**: Каждый компонент подписывается как `NamespaceSubscriber` и реализует свою логику инициализации/чистки ресурсов. - **Backward Compatibility**: Поддержка статического списка через env (`FISSION_RESOURCE_NAMESPACES`) с динамическим расширением. - **No-Restart Onboarding**: Добавление tenant не требует рестарта pod-ов. - **RBAC/SA Provisioning**: Автоматическое создание SA и RBAC для новых namespace (`EnsureNamespaceSA`). - **Informer Factories Per Namespace**: Отдельная informer factory для каждого NS, с per-NS context cancellation. - **Explicit Namespace Removal Strategy**: `DispatchRemove` — при удалении NS вызываются `RemoveFunc` у всех подписчиков, останавливаются informer-ы через `context.CancelFunc`. - **Parallel Subscriber Dispatch**: Подписчики вызываются параллельно через `errgroup` — onboarding не блокируется медленным SA provisioning. --- ## Core Complexity Centers - **NamespaceManager & Watcher**: Центр всей динамики — координация событий, фаз, подписчиков. - **ExecutorType Subsystems**: Poolmgr, NewDeploy, Container — каждый хранит собственный per-NS кэш, lister-ы, логику adoption и reaping. - **Informer Lifecycle**: Динамическое создание/остановка informer-ов через per-NS `context.CancelFunc`. Чистка `envLister[ns]`/`deplLister[ns]`/`triggerInformer[ns]` при `RemoveNamespace`. - **FunctionServiceCache**: Кэширование и lifecycle function pod-ов, синхронизация с событиями из разных источников. **Не очищается при RemoveNamespace** — `idleObjectReaper` убирает устаревшие записи через `IsValid()` check. --- ## Hidden Coupling & Accidental Complexity - **Implicit Contract**: Все компоненты обязаны реализовывать `NamespaceSubscriber` симметрично (и `AddFunc`, и `RemoveFunc`). Нарушение → silent drift. - **Global vs Local State**: Глобальный `DefaultNSResolver` + локальные lister-ы в каждом executor type. `RemoveNamespace` в NSResolver и в каждом executor type должны быть вызваны согласованно. - **Deduplication Responsibility**: `AddNamespace` дедупликация — через `DefaultNSResolver().AddNamespace()` возвращающий `bool`, и через проверку локального lister-а (`envLister[ns] != nil`). После `RemoveNamespace` оба guard сбрасываются → re-add корректно создаёт новые informer-ы. - **Event Handler Ordering**: Порядок подписчиков в `Subscribe` влияет на side-effects, но `errgroup` делает их параллельными — ordering больше не определяет latency, но всё ещё влияет на приоритет ошибок. - **RBAC Drift**: Provisioning SA/RBAC в `registerNamespace`, cleanup — в `deregisterNamespace`. При сбое cleanup — dangling SA/ClusterRoleBinding. --- ## Workaround-Driven Decisions - ~~**Track-Only Removal**~~ → **ЗАМЕНЕНО** на `DispatchRemove` — cleanup вызывается всегда. - **Manual Adoption**: При старте executor-ы делают adopt orphaned ресурсов. Закрыто: `PreRegisterManagedNamespaces` обеспечивает полный NS snapshot до adopt/cleanup (§1.3). - **Explicit Reaper Loops**: `idleObjectReaper` чистит `FunctionServiceCache` вместо event-driven подхода. Приемлемо: `IsValid()` check достаточен при корректной работе per-NS informer-ов. --- ## Fragile Operational Components - **RBAC/SA Drift**: Неконсистентность между созданием и удалением SA/ролей при сбое в `deregisterNamespace`. - **Cache Invalidation**: `FunctionServiceCache` не очищается при `RemoveNamespace` — расчёт на `idleObjectReaper`. При высоком churn rate может накапливать stale записи быстрее, чем reaper убирает. - **Adoption Race**: ~~`AdoptExistingResources` vs `namespace_subscriber` — активная проблема~~ — закрыто: `PreRegisterManagedNamespaces` перед adopt/cleanup (`2a7d6101`). - **Stuck Failed Phase**: ~~Namespace в `failed` не восстанавливается без рестарта~~ — закрыто: `RunReconciler` + полная цепочка error propagation (§1.4). --- ## Poor Scalability Risks - **Informer Explosion**: ~1500–2000 goroutine при 100 tenant (см. §2). **Частично смягчено**: goroutine-ы корректно останавливаются при `RemoveNamespace` — нет накопления при churn. Но в steady-state 100 NS — линейный рост горутин остаётся. - ~~**Synchronous Dispatch**~~ → **ИСПРАВЛЕНО**: параллельный dispatch через `errgroup`. - **Centralized Locking**: Глобальный mutex на NamespaceManager. При текущей нагрузке (<50 ns) — не узкое место. При >500 concurrent tenant — backlog (sharded mutex, §5). - **Thundering Herd на resync**: 100 NS × 5 informer-типов × LIST каждые 30 мин — 500 concurrent LIST к API. --- ## Future Maintenance Problems - **Hidden State Machines**: Фазы namespace реализованы неявно — сложно дебажить stuck state. Нет формализованной машины состояний с explicit transitions. - **Implicit Error Handling**: Ошибки в `deregisterNamespace` логируются, но NS может остаться в некорректном состоянии. Нет `NamespaceCondition` на k8s-объекте. - **Contract Drift**: Изменение интерфейса `NamespaceSubscriber` (например, добавление `ResyncFunc`) требует синхронного обновления всех компонентов. - **FunctionServiceCache без per-NS cleanup**: если `idleObjectReaper` будет отключён/изменён — stale cache может накапливаться. --- ## Risky / Hard-to-Maintain Decisions | Решение | Статус | Примечание | |---------|--------|------------| | Informer Lifecycle Management | ✅ Hardened | per-NS context cancel + RemoveNamespace во всех компонентах | | Centralized Mutex | ⚠️ Приемлемо | sharding в backlog, не актуально до >500 NS | | Manual Adoption | ✅ Закрыто | race при rolling update (`2a7d6101`) | | No Explicit State Machine | ✅ Частично закрыто | stuck-failed закрыт (`919e8439`); явная state machine в backlog | | Eventual Consistency | ⚠️ Смягчено | параллельный dispatch уменьшает окно, но не устраняет | --- ## Multi-Tenancy, Isolation, Orchestration, Lifecycle, State, Reconciliation - **Multi-Tenancy**: Label-based discovery, каждый tenant — отдельный namespace, изоляция на уровне k8s. - **Isolation Model**: Namespace-level isolation, per-NS SA/RBAC, per-NS informer factory. - **Lifecycle Management**: Фазы (discovered → registering → active → deregistering → removed / failed) реализованы. Auto-recovery из failed работает через `RunReconciler`. Явная state machine в backlog. - **State Handling**: Глобальный `DefaultNSResolver` + локальные lister-ы. После `RemoveNamespace` — оба синхронизованы. После re-add — оба корректно инициализируются заново. - **Reconciliation Logic**: Каждый компонент через subscribe. Отсутствует reconcile-очередь для failed state. - **Operational Burden**: Средний — goroutine leak устранён, stale informer устранён, stuck-failed закрыт. Требуется мониторинг: orphaned SA/RBAC при неудачном deregister. --- ## Engineering Maturity - **Maturity**: Архитектурно зрелый, хорошо документированный, с явным reasoning и поэтапным внедрением. - **Complexity**: Высокая в синхронизации и lifecycle. Снижена за счёт формализации `RemoveNamespace` контракта. - **Maintainability**: Среднесрочная — без явной state machine сложность будет расти. Auto-recovery из failed работает. - **Production-Grade**: Близко — informer lifecycle корректен, dispatch параллелен, cleanup симметричен, stuck-failed закрыт, AdoptExistingResources race закрыт. --- # Deep Risk Analysis (актуальная, May 2026) --- ## 1. Сценарии отказа ### 1.1 Informer Lifecycle Management — ✅ ЗАКРЫТ **Что было:** relabel-цикл NS создавал phantom-состояние: informer-ы не останавливались при track-only removal, `DefaultNSResolver` не очищал запись → re-add возвращал `false` → новые informer-ы не создавались. **Что сделано (коммит `4eedf95f`):** - `RemoveNamespace(ns)` добавлен в интерфейс `ExecutorType` и реализован в poolmgr, newdeploy, container. - В каждом executor type: per-NS context cancel (`nsCancels map[string]context.CancelFunc`). `AddNamespace` создаёт `nsCtx, nsCancel := context.WithCancel(ctx)`, передаёт `nsCtx` в `factory.Start()`. `RemoveNamespace` вызывает `nsCancel()` и удаляет lister-ы из карт. - Router: `HTTPTriggerSet.RemoveNamespace()` отменяет per-NS ctx, удаляет `triggerInformer[ns]`/`funcInformer[ns]` под `informerMu.Lock()`, вызывает `syncTriggers()`. - Buildermgr: `envWatcher.RemoveNamespace()` и `pkgWatcher.RemoveNamespace()` — аналогично. - `DefaultNSResolver.RemoveNamespace(ns)` удаляет NS из глобального map → re-add корректно проходит guard. - Стратегия `DispatchRemove` во всех 3 компонентах → `RemoveFunc` вызывается при удалении NS. **Текущий статус:** informer goroutine/FD корректно останавливаются; re-add NS создаёт чистые informer-ы; router не видит stale routes. --- ### 1.2 Centralized Mutex — ⚠️ ПРИЕМЛЕМО **Сценарий:** высокая churn + concurrent Snapshot. `dispatch()` отпускает mutex перед вызовом каждого subscriber, берёт снова для следующего. При батч-онбординге 10+ NS параллельно: конкуренция за mutex, latency spike на `Snapshot()` в `idleObjectReaper`. **Смягчено:** `dispatch()` теперь параллельный (errgroup) — подписчики не вызываются последовательно, время блокировки mutex между подписчиками устранено. `Snapshot()` конкурирует только с `Upsert` — при текущей нагрузке (<50 NS) практически нет. **Остаётся:** при >500 concurrent tenant с >1 onboarding/sec — sharded mutex даст выигрыш. В backlog. --- ### 1.3 Manual Adoption (AdoptExistingResources) — ✅ ЗАКРЫТ (коммит `2a7d6101`) **Сценарий: гонка adoption vs watcher при старте** **Что было:** `AdoptExistingResources` и `CleanupOldExecutorObjects` запускались до `StartNSWatcher`. `DefaultNSResolver().Snapshot()` возвращал только статические NS из `FISSION_RESOURCE_NAMESPACES` → managed NS не покрывались: - Pods от предыдущего executor в managed NS не adoptировались (сохраняли старый `instanceID`) → poolmgr создавал новые pool pods → cold start. - Старые RS/deployments в managed NS не чистились → накапливались. **Что сделано:** `multitenant.PreRegisterManagedNamespaces(ctx, logger, kubernetesClient)` — синхронный `Namespaces.List` с label `fission.io/managed=true` вызывается в `executor.go` **до** goroutines adopt+cleanup. Добавляет все managed NS в `DefaultNSResolver`. Идемпотентен с последующим `AddFunc` из watcher. Не ломает при ошибке API (warn + proceed). **End-to-end после фикса:** 1. `PreRegisterManagedNamespaces` → `DefaultNSResolver` содержит static + managed NS 2. `AdoptExistingResources` → патчит pods в managed NS с новым `instanceID` 3. `CleanupOldExecutorObjects` / `GetReaperNamespace()` → видит managed NS → чистит стale объекты 4. `StartNSWatcher` → `AddFunc` срабатывает для тех же NS — `DefaultNSResolver().AddNamespace()` idempotent, `AddNamespace` executor types dedup-protected --- ### 1.4 No Explicit State Machine — ✅ ЗАКРЫТ (коммит `919e8439`) **Сценарий: stuck в `failed` без auto-recovery** **Что было:** `EnsureNamespaceSA` и `registerNamespace` были void-функциями — ошибки только логировались, до `MarkPartFailed` не доходили. Executor subscriber всегда возвращал nil → namespace никогда не попадал в `NamespacePhaseFailed` → `RunReconciler` для executor был мёртвым кодом. **Что сделано:** - `setupSAAndRoleBindings` → возвращает `error` - `EnsureNamespaceSA` → возвращает `error`, пробрасывает - `registerNamespace` → возвращает `error` (SA + executorTypes) с `fmt.Errorf` wrapping - Executor `AddFunc`/`ResyncFunc` → пробрасывают ошибку вместо `return nil` - `RunReconciler` → принимает `*zap.Logger`, логирует каждый retry и исход **End-to-end flow:** 1. `EnsureNamespaceSA` fails (k8s 503) → `registerNamespace` returns error 2. Executor AddFunc returns error → `dispatch()` → `MarkPartFailed("executor")` 3. `deriveNamespacePhase` → `NamespacePhaseFailed` 4. `RunReconciler` tick (30s) находит namespace → `DispatchResync` → retry 5. Если API восстановился: `MarkPartActive` → `NamespacePhaseActive` → лог `resync succeeded` **Накопление при churn:** ликвидировано — failed NS автоматически выходят из этой фазы при восстановлении API. **Ограничение:** нет max-retries. Namespace, у которого SA создать принципиально невозможно (например, удалённый k8s namespace), будет ретраиться вечно. Приемлемо на текущем масштабе. --- ### 1.5 Eventual Consistency — ⚠️ СМЯГЧЕНО **Сценарий:** HTTPTrigger создан в окне до готовности informer. **Было:** последовательный dispatch → если executor делал SA provisioning 10–30 сек, router не начинал `WaitForCacheSync`. Trigger, созданный в этом окне, пропускался до следующего resync (30 мин). **Смягчено:** параллельный dispatch через errgroup → router и executor стартуют `AddNamespace` одновременно. Окно уязвимости = время `WaitForCacheSync` в router (~2–5 сек), а не время SA provisioning (~30 сек). **Остаётся:** trigger, созданный за 2–5 сек до `WaitForCacheSync` в router → нормально обрабатывается через `AddFunc` после sync. Фактически проблема устранена для практических сценариев. --- ## 2. Анализ при 50–100 tenant с churn 10 ns/час ### Informer Count (steady-state) При 100 активных tenant: - **Poolmgr**: 2 factory × 100 NS × ~3–5 goroutine = **600–1000 goroutine** - **NewDeploy**: аналогично ~600–1000 goroutine - **Router**: 1 factory × 100 NS × ~2 goroutine = **200 goroutine** - **Buildermgr**: ~200 goroutine Итого: **~1600–2400 goroutine** от informer-ов. **Линейный рост с числом NS — неизбежен при текущей архитектуре.** **Что изменилось после hardening:** при churn goroutine-ы корректно останавливаются при `RemoveNamespace` — нет накопления мёртвых goroutine. Steady-state = ~O(active_NS), а не O(total_NS_ever_seen). ### Thundering Herd на resync 100 NS × 5 informer-типов × LIST каждые 30 мин = **500 concurrent LIST** к Kubernetes API. Не изменилось, не исправлено. ### Stuck Failed Accumulation — ✅ ЗАКРЫТ Failed NS автоматически ретраятся `RunReconciler` каждые 30с и выходят из `failed` при восстановлении API. Накопления больше не происходит. ### AdoptExistingResources Race — ✅ ЗАКРЫТ `PreRegisterManagedNamespaces` синхронно добавляет managed NS в `DefaultNSResolver` до adopt/cleanup. Старые pods adoptируются, stale объекты чистятся. Подробно — §1.3. --- ## 3. Рекомендации (приоритизированные) ### P1 — Reconcile-очередь для failed NS — ✅ ЗАКРЫТ (`919e8439`) Error propagation исправлена во всей цепочке: `setupSAAndRoleBindings` → `EnsureNamespaceSA` → `registerNamespace` → executor subscriber. `RunReconciler` логирует retry и исход. ### P2 — AdoptExistingResources после BootstrapAndDispatch — ✅ ЗАКРЫТ (`2a7d6101`) `PreRegisterManagedNamespaces` вызывается синхронно до adopt/cleanup. Делает один `Namespaces.List(label=fission.io/managed=true)` → добавляет все managed NS в `DefaultNSResolver`. После этого adopt и cleanup покрывают полный tenant NS set. **Влияние:** устранены orphaned pods при холодном старте и resource leak (stale RS/deployments). ### P3 — NamespaceCondition на k8s Namespace объекте Пометить Namespace через `kubectl annotate` или через status subresource при failed phase → оператор видит причину без чтения логов. ### Backlog — Sharded mutex Актуально при >500 concurrent tenant с >1 onboarding/sec. Технически feasible без breaking interface change (см. `FORENSIC_ARCHITECTURE_AUDIT_LEGACY_2026-05.md §5`). --- ## 4. FunctionServiceCache — текущий инвариант `FunctionServiceCache` (`fsCache` в gpm и newdeploy) **не очищается** при `RemoveNamespace`. Это осознанное решение: - `idleObjectReaper` периодически вызывает `fsCache.ListOldForPool()` → для каждой записи проверяет `podLister[ns]` → если NS удалён, `podLister[ns]` == nil → pod не найден → запись считается expired → `fsCache.DeleteEntry()`. - Временной лаг = интервал reaper-а (по умолчанию ~1 мин). При высоком churn возможно накопление stale записей, но они не вызывают функциональных ошибок (только небольшой overhead на reaper iteration). **Когда станет проблемой:** при отключении/изменении reaper-а или при >10 000 stale записей (O(n) iteration). --- ## 5. Sharded Mutex — вердикт **Технически реализуемо** без breaking interface change. Полный код — в `FORENSIC_ARCHITECTURE_AUDIT_LEGACY_2026-05.md §5`. **Вердикт:** не оправдано при текущей нагрузке. Реальный bottleneck — AdoptExistingResources race — закрыт (`2a7d6101`). Sharded mutex — в backlog, актуально при >500 concurrent tenant с >1 onboarding/sec.