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 закрыт)
280 lines
23 KiB
Markdown
280 lines
23 KiB
Markdown
# 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.
|