Files
fission-src/doc/FORENSIC_ARCHITECTURE_AUDIT.md
T

21 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 ОТКРЫТ
AdoptExistingResources race при rolling update ОТКРЫТ
No explicit state machine (implicit phase transitions) ОТКРЫТ
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 ресурсов — workaround для несовершенного lifecycle. Активная проблема (см. §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 — активная проблема (§1.3).
  • Stuck Failed Phase: Namespace в failed не восстанавливается без рестарта — активная проблема (§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
No Explicit State Machine Активная проблема stuck-failed без retry
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) реализованы, но без явной state machine и без auto-recovery из failed.
  • 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 возможны stuck state при API нестабильности.
  • Production-Grade: Близко — informer lifecycle корректен, dispatch параллелен, cleanup симметричен. Основной gap: 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) — АКТИВНАЯ ПРОБЛЕМА

Сценарий: гонка adoption vs watcher при старте

  1. Executor стартует. AdoptExistingResources берёт DefaultNSResolver().Snapshot() — только статические NS из env.
  2. Параллельно: RunManagedNamespaceWatcherBootstrapAndDispatch()registerNamespace() добавляет managed NS.
  3. AdoptExistingResources уже завершила loop — managed NS не попал в snapshot. Orphaned pods в tenant NS не приняты.
  4. CleanupOldExecutorObjects сочтёт старые pod-ы orphaned → удалит → cold start для всех функций.

При rolling update: два executor-а параллельно патчат instanceID на одних pod-ах → race.

Статус: не исправлено. Требует либо задержки AdoptExistingResources до завершения первого BootstrapAndDispatch, либо включения managed NS в Snapshot() на момент adoption.


1.4 No Explicit State Machine — АКТИВНАЯ ПРОБЛЕМА

Сценарий: stuck в failed без auto-recovery

  1. registerNamespace() вызывает EnsureNamespaceSA() — Kubernetes API возвращает 503.
  2. Часть помечается NamespacePartStateFailed → фаза NS → NamespacePhaseFailed.
  3. Нет reconcile-цикла: фаза остаётся failed до рестарта процесса.
  4. Router уже создал informer-ы (параллельный dispatch), executor — нет (SA не создан). Dirty state: router видит NS активным, executor — нет. Вызовы → 503.

Накопление при churn: 10 ns/час × 1% API error rate = ~2.4 failed NS/сутки. За 30 дней = ~72 "мёртвых" записи. Snapshot() возвращает их в idleObjectReaper → лишние LIST запросы к k8s.

Предложение (не реализовано): reconcile-очередь в inMemoryNamespaceManager — см. FORENSIC_ARCHITECTURE_AUDIT_LEGACY_2026-05.md §3 для кода.

Статус: не исправлено.


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

10 ns/час × 1% API error rate = ~2.4 failed NS/сутки. ~72 за 30 дней. Не исправлено (§1.4).

AdoptExistingResources Race

При rolling update — race на instanceID патч. Не исправлено (§1.3).


3. Рекомендации (приоритизированные)

P1 — Reconcile-очередь для failed NS

Минимальное изменение без breaking interface change. Полный код — в FORENSIC_ARCHITECTURE_AUDIT_LEGACY_2026-05.md §3. Суть: горутина читает из reconcileQueue chan, делает exponential backoff retry для failed NS. Max 5 попыток.

Влияние: устраняет stuck-failed накопление, dirty state между router и executor.

P2 — AdoptExistingResources после BootstrapAndDispatch

Либо: подождать первый BootstrapAndDispatch() через канал-сигнал, затем запускать AdoptExistingResources. Либо: в AdoptExistingResources использовать NamespaceManager.Snapshot() вместо DefaultNSResolver().Snapshot() (managed NS уже добавлены к этому моменту через BootstrapAndDispatch).

Влияние: устраняет orphaned pods при холодном старте и rolling update.

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 и stuck-failed, не mutex. Sharded mutex — в backlog, актуально при >500 concurrent tenant с >1 onboarding/sec.