270 lines
21 KiB
Markdown
270 lines
21 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 | ❌ ОТКРЫТ | — |
|
||
| 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-ов, синхронизация с событиями из разных источников. **Не очищается при 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 ресурсов — 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**: ~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 |
|
||
| 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. Параллельно: `RunManagedNamespaceWatcher` → `BootstrapAndDispatch()` → `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 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
|
||
|
||
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.
|