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

280 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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**: ~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. `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 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 исправлена во всей цепочке: `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.