Files
fission-src/doc/FORENSIC_ARCHITECTURE_AUDIT.md
T
“Naeel” 491f0aee43 doc(audit): mark AdoptExistingResources race as CLOSED (2a7d6101)
Updated FORENSIC_ARCHITECTURE_AUDIT.md:
- Status table: AdoptExistingResources race  ЗАКРЫТ (2a7d6101)
- §1.3: rewritten as ЗАКРЫТ with end-to-end fix description
- Fragile Components: Manual Adoption → закрыто
- Risky Decisions table: Manual Adoption 
- Production-Grade summary: AdoptExistingResources race закрыт
- §2 Stuck Failed Accumulation:  ЗАКРЫТ
- §2 AdoptExistingResources Race:  ЗАКРЫТ
- §3 P2:  ЗАКРЫТ
- §5 Sharded mutex verdict: updated (race закрыт)
2026-05-18 11:45:18 +04:00

23 KiB
Raw Blame History

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-ов, синхронизация с событиями из разных источников. Не очищается при RemoveNamespaceidleObjectReaper убирает устаревшие записи через 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: ~15002000 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. PreRegisterManagedNamespacesDefaultNSResolver содержит static + managed NS
  2. AdoptExistingResources → патчит pods в managed NS с новым instanceID
  3. CleanupOldExecutorObjects / GetReaperNamespace() → видит managed NS → чистит стale объекты
  4. StartNSWatcherAddFunc срабатывает для тех же 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 никогда не попадал в NamespacePhaseFailedRunReconciler для 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. deriveNamespacePhaseNamespacePhaseFailed
  4. RunReconciler tick (30s) находит namespace → DispatchResync → retry
  5. Если API восстановился: MarkPartActiveNamespacePhaseActive → лог resync succeeded

Накопление при churn: ликвидировано — failed NS автоматически выходят из этой фазы при восстановлении API.

Ограничение: нет max-retries. Namespace, у которого SA создать принципиально невозможно (например, удалённый k8s namespace), будет ретраиться вечно. Приемлемо на текущем масштабе.


1.5 Eventual Consistency — ⚠️ СМЯГЧЕНО

Сценарий: HTTPTrigger создан в окне до готовности informer.

Было: последовательный dispatch → если executor делал SA provisioning 1030 сек, 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. Анализ при 50100 tenant с churn 10 ns/час

Informer Count (steady-state)

При 100 активных tenant:

  • Poolmgr: 2 factory × 100 NS × ~35 goroutine = 6001000 goroutine
  • NewDeploy: аналогично ~6001000 goroutine
  • Router: 1 factory × 100 NS × ~2 goroutine = 200 goroutine
  • Buildermgr: ~200 goroutine

Итого: ~16002400 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 исправлена во всей цепочке: setupSAAndRoleBindingsEnsureNamespaceSAregisterNamespace → 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.