21 KiB
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:
AdoptExistingResourcesvsnamespace_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 при старте
- Executor стартует.
AdoptExistingResourcesберётDefaultNSResolver().Snapshot()— только статические NS из env. - Параллельно:
RunManagedNamespaceWatcher→BootstrapAndDispatch()→registerNamespace()добавляет managed NS. AdoptExistingResourcesуже завершила loop — managed NS не попал в snapshot. Orphaned pods в tenant NS не приняты.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
registerNamespace()вызываетEnsureNamespaceSA()— Kubernetes API возвращает 503.- Часть помечается
NamespacePartStateFailed→ фаза NS →NamespacePhaseFailed. - Нет reconcile-цикла: фаза остаётся
failedдо рестарта процесса. - 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.