fix(namespace): executor/router/buildermgr RemoveNamespace + per-NS informer lifecycle
- Add RemoveNamespace(ctx, ns) to executortype.ExecutorType interface - Implement RemoveNamespace in poolmgr, newdeploy, container executor types - Add per-namespace context cancellation (nsCancels map) in all three types so informer factories are stopped when namespace is removed (fixes goroutine leak) - Add PoolPodController.RemoveNamespace to clear envLister/podLister maps - Add deregisterNamespace() in executor multitenant subscriber - Switch executor/router/buildermgr watcher strategy from TrackOnly to DispatchRemove so RemoveFunc is called when fission.io/managed label is removed - Add RemoveFunc to executor/router/buildermgr namespace subscribers - Add RemoveNamespace to environmentWatcher and packageWatcher with per-NS cancel - Add RemoveNamespace to HTTPTriggerSet: cancels informers, removes from maps, calls syncTriggers - Fix ns_watcher_test.go fakeExecutorType to implement new RemoveNamespace method Fixes: - Executor dedup gap: re-added namespace was silently skipped (envLister/deplLister still present) - Goroutine/FD leak: old informer factories ran forever after namespace removal - Router stale routes: HTTPTriggers for removed namespace stayed in routing table
This commit is contained in:
@@ -0,0 +1,385 @@
|
||||
# Forensic Architecture Audit: Fission Fork (feature/multitenant, May 2026)
|
||||
|
||||
---
|
||||
|
||||
## 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**: Каждый компонент (executor, router, buildermgr) подписывается как subscriber и реализует свою логику инициализации/чистки ресурсов при появлении/удалении namespace.
|
||||
- **Backward Compatibility**: Сохраняется поддержка статического списка через env (`FISSION_RESOURCE_NAMESPACES`), но теперь он расширяется динамически.
|
||||
- **No-Restart Onboarding**: Добавление нового tenant не требует рестарта pod-ов — watcher реагирует на label, триггерит регистрацию во всех подсистемах.
|
||||
- **RBAC/SA Provisioning**: Автоматическое создание service account и RBAC для новых namespace (см. `EnsureNamespaceSA`).
|
||||
- **Informer Factories Per Namespace**: Для каждого нового namespace создаются отдельные informer factory для CRD и core-ресурсов.
|
||||
- **Explicit Namespace Removal Strategy**: Поддержка двух стратегий удаления: track-only (по умолчанию) и dispatch-remove (с вызовом OnNamespaceRemove у подписчиков).
|
||||
|
||||
## Core Complexity Centers
|
||||
- **NamespaceManager & Watcher**: Центр всей динамики — сложная координация событий, фаз, подписчиков, race-conditions.
|
||||
- **ExecutorType Subsystems**: Poolmgr, NewDeploy, Container — каждый хранит собственное состояние, кэш, логику adoption и reaping.
|
||||
- **Informer Lifecycle**: Динамическое создание/удаление informer-ов на лету для каждого namespace.
|
||||
- **FunctionServiceCache**: Кэширование и lifecycle function pod-ов, синхронизация с событиями из разных источников.
|
||||
|
||||
## Hidden Coupling & Accidental Complexity
|
||||
- **Implicit Contract**: Все компоненты обязаны корректно реализовать NamespaceSubscriber — нарушение приводит к silent drift.
|
||||
- **Global vs Local State**: Есть глобальный NamespaceResolver и локальные состояния в каждом executor type — возможны рассинхронизации.
|
||||
- **Deduplication Responsibility**: Deduplication namespace размазан между глобальным резолвером и локальными структурами.
|
||||
- **Event Handler Ordering**: Порядок подписчиков влияет на фазу и side-effects, но не гарантируется явно.
|
||||
- **RBAC Drift**: Provisioning SA/RBAC делается в одном месте, но cleanup — в другом, возможны dangling ресурсы.
|
||||
|
||||
## Iterative Growth
|
||||
- **Layered Refactor**: Ветка развивается через серию малых шагов (см. doc/thinking/2026-04-26-namespace-manager-step*.md), каждый шаг — отдельный инвариант.
|
||||
- **Hybrid Model**: Некоторое время coexist старый статический и новый динамический pipeline, с явным разделением путей.
|
||||
- **Feature Flags via Env**: Многое управляется через env-переменные, что позволяет поэтапно включать/выключать новые механики.
|
||||
|
||||
## Workaround-Driven Decisions
|
||||
- **Track-Only Removal**: По умолчанию удаление namespace не вызывает cleanup в подписчиках — workaround против race-condition при массовых удалениях.
|
||||
- **Manual Adoption**: При старте executor-ы делают adopt orphaned ресурсов (pods, deployments) — workaround для несовершенного lifecycle.
|
||||
- **Explicit Reaper Loops**: Для чистки orphaned объектов используются отдельные циклы (object reaper), а не event-driven подход.
|
||||
|
||||
## Fragile Operational Components
|
||||
- **Informer Factory Lifecycle**: Ошибки в динамическом создании/удалении informer-ов приводят к memory leak или stale watchers.
|
||||
- **RBAC/SA Drift**: Неконсистентность между созданием и удалением сервисных аккаунтов и ролей.
|
||||
- **Cache Invalidation**: FunctionServiceCache может рассинхронизироваться при сбоях в event flow.
|
||||
- **Adoption Loops**: AdoptExistingResources может не покрыть все edge-case, особенно при race между startup и watcher.
|
||||
|
||||
## Poor Scalability Risks
|
||||
- **Informer Explosion**: На сотнях/тысячах namespace число informer-ов и goroutine растёт линейно, возможен memory/FD exhaustion.
|
||||
- **Synchronous Dispatch**: Все подписчики вызываются синхронно, при долгой инициализации одного — блокируются остальные.
|
||||
- **Centralized Locking**: NamespaceManager держит глобальный mutex на все операции — bottleneck при высокой churn rate.
|
||||
- **No Sharding**: Нет горизонтального масштабирования NamespaceManager — всё в одном процессе.
|
||||
|
||||
## Future Maintenance Problems
|
||||
- **Hidden State Machines**: Фазы namespace и частей (part state) реализованы неявно, без явной state machine — сложно дебажить stuck state.
|
||||
- **Implicit Error Handling**: Ошибки в подписчиках часто логируются, но не эскалируются — возможна silent failure.
|
||||
- **Contract Drift**: Любое изменение интерфейса NamespaceSubscriber требует синхронного обновления всех компонентов.
|
||||
- **Complex Test Surface**: Много интеграционных точек, сложно покрыть тестами все сценарии гонок и отказов.
|
||||
|
||||
## Deepest Upstream Divergence
|
||||
- **Полная замена статической модели discovery на динамическую через watcher и NamespaceManager.**
|
||||
- **Весь lifecycle tenant-namespace теперь event-driven, а не env-driven.**
|
||||
- **Введён централизованный интерфейс подписки на события namespace для всех core-компонентов.**
|
||||
- **Механика adopt orphaned ресурсов и явная поддержка rollback/cleanup.**
|
||||
|
||||
## Surprisingly Mature Parts
|
||||
- **Интерфейс NamespaceManager**: Чётко выделен, покрыт тестами, поддерживает snapshot, summary, phase tracking.
|
||||
- **Event Handler Abstraction**: Все watcher-ы используют единый event handler contract, легко расширять.
|
||||
- **Backward Compatibility Layer**: Старый pipeline не сломан, coexist с новым.
|
||||
- **Документация и коммиты**: Подробные шаги, объяснения, reasoning — видно зрелый инженерный подход.
|
||||
|
||||
## Risky / Hard-to-Maintain Decisions
|
||||
- **Informer Lifecycle Management**: Очень сложно гарантировать отсутствие leak/stale при динамике.
|
||||
- **Centralized Mutex**: Один mutex на NamespaceManager — риск блокировок.
|
||||
- **Manual Adoption**: AdoptExistingResources — временное решение, не покрывает все сценарии.
|
||||
- **No Explicit State Machine**: Фазы и переходы не формализованы, возможны stuck state.
|
||||
- **Eventual Consistency**: Нет гарантии моментальной консистентности между компонентами.
|
||||
|
||||
---
|
||||
|
||||
## Multi-Tenancy, Isolation, Orchestration, Lifecycle, State, Reconciliation
|
||||
- **Multi-Tenancy**: Реализовано через label-based discovery, каждый tenant — отдельный namespace, все ресурсы изолированы на уровне k8s.
|
||||
- **Isolation Model**: Namespace-level isolation, автоматическое создание SA/RBAC, informer-ы и кэш на каждый tenant.
|
||||
- **Orchestration**: NamespaceManager + подписчики — централизованный event bus для всех core-компонентов.
|
||||
- **Lifecycle Management**: Поддержка всех фаз (discovered, registering, active, deregistering, removed, failed), но state machine неявная.
|
||||
- **State Handling**: Гибрид глобального и локального состояния, возможны рассинхронизации.
|
||||
- **Reconciliation Logic**: Каждый компонент реализует свою reconcile-логику через подписку на события.
|
||||
- **Controller Complexity**: Высокая, много слоёв абстракции, много точек гонок.
|
||||
- **Deployment Reproducibility**: Helm-чарты поддерживают все новые env, backward compatibility сохранён.
|
||||
- **Operational Burden**: Высокий — требуется мониторинг leak, race, orphaned ресурсов, ручной контроль за adoption.
|
||||
|
||||
---
|
||||
|
||||
## Engineering Maturity, Complexity, Maintainability Horizon
|
||||
- **Maturity**: Архитектурно зрелый, хорошо документированный, с явным reasoning и поэтапным внедрением.
|
||||
- **Complexity**: Высокая, особенно в динамике и синхронизации между компонентами.
|
||||
- **Maintainability**: Среднесрочная — без явной state machine и горизонтального масштабирования возможны проблемы при росте нагрузки.
|
||||
- **Production-Grade**: Ближе к production-grade platform engineering, чем к эксперименту, но требует доработки по масштабированию и явной формализации state transitions.
|
||||
|
||||
---
|
||||
|
||||
## Architectural Drift / Entropy / Hazards
|
||||
- **Drift**: Возможен drift между глобальным и локальным состоянием, если подписчики реализованы несимметрично.
|
||||
- **Entropy**: Много точек входа, implicit contract, нет явной state machine — сложность будет расти.
|
||||
- **Hazards**: Memory leak, race-condition, orphaned ресурсы, silent failure при ошибках в подписчиках.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
Этот форк — зрелая попытка перевести Fission на event-driven multi-tenant архитектуру с динамическим discovery и централизованным lifecycle management. Основные сложности и риски — в управлении состоянием, синхронизации и масштабируемости. Требует дальнейшей формализации state machine, горизонтального масштабирования и усиления тестового покрытия для production-grade эксплуатации.
|
||||
|
||||
---
|
||||
|
||||
# Deep Risk Analysis (May 2026)
|
||||
|
||||
> Конкретные сценарии отказа, оценка при 50–100 tenant, предложения по исправлению.
|
||||
|
||||
---
|
||||
|
||||
## 1. Сценарии отказа для каждого "Risky Decision"
|
||||
|
||||
### 1.1 Informer Lifecycle Management
|
||||
|
||||
**Сценарий: повторная регистрация namespace через relabel**
|
||||
|
||||
1. Оператор снимает label `fission.io/managed=true` с namespace `tenant-42`.
|
||||
2. Namespace-watcher вызывает `HandleWatcherNamespaceRemoval()`. Стратегия `TrackOnly`: NamespaceManager помечает запись как `removed` и **не вызывает** `OnNamespaceRemove` у подписчиков.
|
||||
3. Informer-ы executor (gpm, newdeploy) и router продолжают работать — pool для tenant-42 жив, функции маршрутизируются.
|
||||
4. Оператор возвращает label — kubernetes генерирует `MODIFIED`-событие.
|
||||
5. `RunManagedNamespaceWatcher` (resync 30 мин) может не вызвать Add снова для уже известного NS.
|
||||
6. **Router**: `AddNamespace` вызывает `DefaultNSResolver().AddNamespace(ns)`. Глобальный resolver уже содержит tenant-42 (его никто не удалял из-за track-only) → возвращает `false` → router делает **early return без создания новых informer-ов** (строка 460 `httpTriggers.go`). Router считает namespace активным (старые informer-ы ещё работают) — но если они были остановлены контекстом — тихое 404.
|
||||
7. **Executor**: `gpm.AddNamespace` проверяет `poolPodC.envLister[ns]` — если старый lister жив, возвращает nil сразу (дедупликация). Всё выглядит нормально, но фактически используются **устаревшие informer-ы** с застрявшим кэшем.
|
||||
|
||||
**Итог**: relabel-цикл создаёт phantom-состояние: компоненты думают что NS активен, но его lifecycle разорван.
|
||||
|
||||
---
|
||||
|
||||
### 1.2 Centralized Mutex
|
||||
|
||||
**Сценарий: высокая churn + concurrent Snapshot**
|
||||
|
||||
`dispatch()` снимает write-lock перед вызовом каждого subscriber-а, затем берёт его снова для следующего. Структура:
|
||||
|
||||
```
|
||||
mu.Lock() → читаем список subs →
|
||||
mu.Unlock() → вызываем handler(sub1) [k8s API call, может занять сотни мс]
|
||||
mu.Lock() → читаем следующий sub →
|
||||
mu.Unlock() → вызываем handler(sub2)
|
||||
```
|
||||
|
||||
Параллельно: router каждые 20 мс делает `syncTriggers()` → `updateRouter()` → итерирует `snapshotFuncInformers()` → берёт `informerMu.RLock`. Это другой mutex, но `DefaultNSResolver().Snapshot()` вызывается из `idleObjectReaper` каждые 5 сек под глобальным `RWMutex` NamespaceManager.
|
||||
|
||||
При 100 tenant с churn 10 ns/час: в среднем каждые 6 мин добавляется namespace. Само по себе безвредно. Но при пике (батч-онбординг 10 tenant за 1 минуту): `dispatch()` держит write-lock с паузами на unlock/relock для каждого subscriber × 10 параллельных dispatch → конкуренция за mutex возрастает. `Snapshot()` в `idleObjectReaper` (каждые 5 сек) и в `AdoptExistingResources` (каждый рестарт) будут ждать.
|
||||
|
||||
**Итог**: не deadlock, но latency spike на Snapshot на старте и при батч-онбординге — 200–500 мс при 10+ concurrent dispatch.
|
||||
|
||||
---
|
||||
|
||||
### 1.3 Manual Adoption (AdoptExistingResources)
|
||||
|
||||
**Сценарий: гонка adoption vs watcher**
|
||||
|
||||
1. Executor стартует. `AdoptExistingResources` запускается, берёт `DefaultNSResolver().Snapshot()` — snapshot содержит только статические NS из `FISSION_RESOURCE_NAMESPACES`.
|
||||
2. Параллельно запускается `RunManagedNamespaceWatcher`. Watcher вызывает `BootstrapAndDispatch()`, который регистрирует managed NS и вызывает `registerNamespace()` у executor-подписчика.
|
||||
3. `registerNamespace()` вызывает `DefaultNSResolver().AddNamespace(ns)` (глобальный guard), затем `gpm.AddNamespace()`.
|
||||
4. **Но `AdoptExistingResources` уже завершила свой loop** — managed NS не попал в snapshot. Orphaned pods в tenant NS не приняты.
|
||||
5. Функции в этих pod-ах будут вызываться ещё раз через cold start — лишний latency spike и потеря статуса `instanceID` у подов (старый instanceID в annotation не перезаписан → `CleanupOldExecutorObjects` сочтёт их orphaned → удалит).
|
||||
|
||||
**Hardcoded 30s timeout**: `AdoptExistingResources` в poolmgr не имеет явного timeout, но `k8sCache.WaitForCacheSync` в `Run()` блокирует до готовности — только после этого запускается `service()`. Если namespace watcher опередил, poolmgr получит env-события до того как `AdoptExistingResources` завершится → гонка на `gpm.pools` map (не защищена mutex вне `service()` goroutine).
|
||||
|
||||
---
|
||||
|
||||
### 1.4 No Explicit State Machine
|
||||
|
||||
**Сценарий: stuck в `failed` без auto-recovery**
|
||||
|
||||
1. Namespace `tenant-99` помечен `fission.io/managed=true`.
|
||||
2. `registerNamespace()` вызывает `EnsureNamespaceSA()` — Kubernetes API momentarily unavailable (503).
|
||||
3. `EnsureNamespaceSA()` возвращает ошибку → вызывающий код (предположительно) пишет в лог и помечает часть как `NamespacePartStateFailed`.
|
||||
4. `deriveNamespacePhase()` выставляет namespace в `NamespacePhaseFailed`.
|
||||
5. **Нет reconcile-цикла**: нет горутины, которая периодически проверяет failed namespace и пытается повторить. Phase останется `failed` до рестарта процесса.
|
||||
6. Router был вызван следующим в цепочке dispatch. Т.к. dispatch вызывается подписчики последовательно без barrier, router **уже создал свои informer-ы** до того как executor завершился с ошибкой.
|
||||
7. **Dirty state**: router видит `tenant-99` как активный (informer-ы есть), executor — нет (SA/RBAC не создан). Любой вызов функции из tenant-99 → executor не может специализировать pod (нет fetcher SA) → 503.
|
||||
|
||||
Лог покажет ошибку, но namespace останется в `failed` навсегда (до рестарта). Оператор не получит никакого k8s-статуса — ни condition на Namespace объекте, ни event.
|
||||
|
||||
---
|
||||
|
||||
### 1.5 Eventual Consistency
|
||||
|
||||
**Сценарий: HTTPTrigger создан в окне до ready informer**
|
||||
|
||||
1. Tenant создаёт namespace с label → namespace добавляется в NamespaceManager.
|
||||
2. `dispatch()` вызывает router subscriber → `AddNamespace()`:
|
||||
```go
|
||||
k8sCache.WaitForCacheSync(ctx.Done(), triggerInf.HasSynced, funcInf.HasSynced)
|
||||
ts.syncTriggers()
|
||||
```
|
||||
Router ждёт sync и перестраивает роутинг. Это занимает несколько секунд.
|
||||
3. Tenant **немедленно** после создания namespace создаёт HTTPTrigger через API.
|
||||
4. Если trigger создан **до** завершения `WaitForCacheSync` в router → informer ещё не синхронизирован, но trigger уже в etcd.
|
||||
5. После sync informer получит это событие через `AddFunc` → `syncTriggers()`. Это нормально.
|
||||
6. **Проблема в другом**: `dispatch()` вызывает подписчиков **последовательно**. Если executor (первый в списке) занимается `EnsureNamespaceSA` + `registerExecutorTypes` (10–30 сек при медленном API) → router subscriber не вызывается всё это время. HTTPTrigger, созданный в этом окне, попадёт в informer, но router ещё не начал слушать → `AddFunc` для этого trigger не вызовется никогда (resync через 30 мин).
|
||||
7. Результат: trigger существует в etcd, но **отсутствует в роутере 30 минут**.
|
||||
|
||||
---
|
||||
|
||||
## 2. Анализ при 50–100 tenant с churn 10 ns/час
|
||||
|
||||
### Informer Explosion
|
||||
|
||||
При 100 активных tenant:
|
||||
- **Executor (poolmgr)**: 1 `SharedInformerFactory` (Fission CRD) + 1 `SharedInformerFactory` (k8s pods/RS) на NS = 200 factory. Каждая factory запускает горутины на каждый informer (~3–5 горутин). **~600–1000 goroutine** только от poolmgr.
|
||||
- **Executor (newdeploy)**: аналогично — ещё 200 factory, ~600 goroutин.
|
||||
- **Router**: 1 factory на NS = 100 factory, ~200 goroutин.
|
||||
- **buildermgr**: 1 factory на NS = 100 goroutин.
|
||||
|
||||
Итого: **~1500–2000 goroutine** только от informer-ов. При пике churn (10 ns/час) — каждые 6 минут добавляется NS, создаётся ~20 новых горутин, они не убираются при track-only removal.
|
||||
|
||||
При **100 NS × 30 мин resync**: каждые 30 мин каждый informer делает LIST всех объектов в своём NS. 100 × 5 informer-типов × LIST = **500 concurrent LIST-запросов** к Kubernetes API раз в 30 минут — возможный thundering herd.
|
||||
|
||||
### Stuck Failed State
|
||||
|
||||
10 ns/час churn с 1% API error rate = ~2.4 failed namespace/сутки. Каждый остаётся в `failed` навсегда. За 30 дней = ~72 "мёртвых" записи в NamespaceManager. `Snapshot()` возвращает их в `idleObjectReaper` → лишние LIST к k8s API для несуществующих/неактивных NS → ошибки, логи, load.
|
||||
|
||||
### AdoptExistingResources Race
|
||||
|
||||
Каждый рестарт executor-а — race. При rolling update в k8s (новый pod стартует, старый ещё жив): оба executor-а параллельно делают `AdoptExistingResources` → оба патчат `instanceID` на одних и тех же pod-ах → `CleanupOldExecutorObjects` нового экземпляра удаляет pod-ы старого (ожидаемо), но при race может удалить pod, который новый экземпляр уже adoptировал.
|
||||
|
||||
### Router Dedup Gap — критический сценарий при рестарте
|
||||
|
||||
При рестарте executor + router одновременно:
|
||||
1. `FISSION_RESOURCE_NAMESPACES` содержит `fission-fn` (статический NS).
|
||||
2. `namespace.go` `init()` добавляет его в `DefaultNSResolver`.
|
||||
3. `BootstrapAndDispatch()` в NamespaceManager вызывает dispatch для всех managed NS, включая `fission-fn`.
|
||||
4. **Router** `AddNamespace("fission-fn")` → `DefaultNSResolver().AddNamespace("fission-fn")` → **false** (уже добавлен в `init()`!) → **early return, informer для fission-fn НЕ создан**.
|
||||
5. Executor (gpm, newdeploy) — используют own dedup (envLister/deplLister), `fission-fn` там нет → создают informer.
|
||||
6. Router слеп к HTTPTrigger и Function событиям из `fission-fn` при динамическом пути. Спасает только то, что `GetInformersForNamespaces` вызывается в `MakeHTTPTriggerSet` при старте — но только для NS из env.
|
||||
|
||||
**Вывод**: если `fission-fn` включён в `FISSION_RESOURCE_NAMESPACES` И помечен `fission.io/managed=true` — возможна ситуация, когда после рестарта router использует startup-informer, а executor использует watcher-informer с другим lifecycle → рассинхронизация при следующем relabel-цикле.
|
||||
|
||||
---
|
||||
|
||||
## 3. Минимальное изменение: явная state machine без полного рефакторинга
|
||||
|
||||
Текущая проблема: `failed` namespace остаётся в `failed` навсегда — нет retry.
|
||||
|
||||
**Изменение**: добавить reconcile-очередь в `inMemoryNamespaceManager` без изменения публичного интерфейса.
|
||||
|
||||
```go
|
||||
// В inMemoryNamespaceManager добавить:
|
||||
type reconcileRequest struct {
|
||||
ns string
|
||||
attempt int
|
||||
}
|
||||
|
||||
reconcileQueue chan reconcileRequest // небуферизованный или с буфером 64
|
||||
|
||||
// В MarkPartFailed (или в dispatch при возврате ошибки от subscriber):
|
||||
func (m *inMemoryNamespaceManager) enqueueReconcile(ns string, attempt int) {
|
||||
select {
|
||||
case m.reconcileQueue <- reconcileRequest{ns: ns, attempt: attempt}:
|
||||
default: // уже в очереди, skip
|
||||
}
|
||||
}
|
||||
|
||||
// Новая горутина, запускается в BootstrapAndDispatch или отдельным методом:
|
||||
func (m *inMemoryNamespaceManager) RunReconciler(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case req := <-m.reconcileQueue:
|
||||
if req.attempt >= 5 { // max retries
|
||||
m.logger.Error("namespace reconcile exhausted", zap.String("ns", req.ns))
|
||||
continue
|
||||
}
|
||||
backoff := time.Duration(1<<req.attempt) * time.Second // 1, 2, 4, 8, 16 сек
|
||||
time.AfterFunc(backoff, func() {
|
||||
// Повторить dispatch только для failed-частей:
|
||||
m.mu.RLock()
|
||||
rec, ok := m.records[req.ns]
|
||||
m.mu.RUnlock()
|
||||
if !ok || rec.Phase != NamespacePhaseFailed {
|
||||
return // уже исправлено или удалено
|
||||
}
|
||||
// Вызвать только тех подписчиков, у кого часть в FailedState:
|
||||
m.dispatchRetry(ctx, req.ns, req.attempt+1)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Изменения интерфейса**: `NamespaceManager` получает метод `RunReconciler(ctx)` — добавляется в интерфейс, но не breaking change для существующих вызывающих (можно добавить как опциональный метод или вызвать из `BootstrapAndDispatch`).
|
||||
|
||||
**Что НЕ меняется**: `NamespaceSubscriber`, `NamespaceRecord`, публичные методы `Upsert`/`Snapshot`/`Subscribe` — всё прежнее.
|
||||
|
||||
---
|
||||
|
||||
## 4. Track-Only Removal: скрытые допущения и dirty state
|
||||
|
||||
### Допущение 1: `DefaultNSResolver` — только append
|
||||
|
||||
`pkg/utils/namespace.go`: метод `AddNamespace` добавляет NS в глобальный map, метода `RemoveNamespace` не существует. Последствия:
|
||||
|
||||
- Namespace, удалённый через label-снятие, **навсегда остаётся** в глобальном resolver-е.
|
||||
- `idleObjectReaper` в poolmgr и newdeploy делает `DefaultNSResolver().Snapshot()` → итерирует удалённые NS → делает LIST Environments/Functions в уже несуществующем (или чужом) namespace → получает k8s 403/404 → логирует ошибку → возвращает из reaper-а (!) — `return` на ошибке прерывает весь цикл reaper-а для текущей итерации.
|
||||
|
||||
### Допущение 2: Informer-ы продолжают работать
|
||||
|
||||
После track-only removal informer-ы executor-а и router-а **не останавливаются**. Для poolmgr: env-events из удалённого namespace продолжают триггерить создание пулов. Пулы создаются в k8s (или пытаются) — для namespace, который более не является managed. RBAC мог быть уже удалён оператором → pod-ы не могут pull fetcher image → CrashLoopBackOff в "удалённом" namespace.
|
||||
|
||||
### Допущение 3: FunctionServiceCache не очищается
|
||||
|
||||
`fsCache` (в gpm и newdeploy) содержит записи с `Function.Namespace = "tenant-42"`. После track-only removal записи не удаляются. `idleObjectReaper` находит их через `fsCache.ListOldForPool()` → пытается найти pod в `gpm.podLister["tenant-42"]` → lister ещё жив (informer работает) → pod может быть найден → считается "valid" → не reaped → запись в кэше живёт вечно.
|
||||
|
||||
### Допущение 4 (критическое): повторное добавление того же NS → router слеп
|
||||
|
||||
Последовательность:
|
||||
1. NS `tenant-42` добавлен → `DefaultNSResolver().AddNamespace("tenant-42")` → **true** → router создаёт informer.
|
||||
2. NS удалён (track-only) → resolver не очищен → informer router-а продолжает работать.
|
||||
3. NS добавлен снова (новый tenant с тем же именем, например после namespace-переименования).
|
||||
4. `AddNamespace("tenant-42")` на router-е → `DefaultNSResolver().AddNamespace("tenant-42")` → **false** (уже в map!) → **early return**.
|
||||
5. Router **не создаёт новый informer** — считает что уже обслуживает namespace. Но старый informer работает с **кэшем от предыдущего tenants** — старые Function и HTTPTrigger объекты (с другими UID) видны в `funcInformer.GetStore()`.
|
||||
6. Executor (gpm): `poolPodC.envLister["tenant-42"]` тоже существует → own dedup → early return → executor тоже не создаёт новый informer.
|
||||
7. Новые HTTPTrigger-ы нового tenant-42 **никогда не попадут в router** (resync через 30 мин принесёт их, но с кэшем старого tenanta!).
|
||||
|
||||
**Результат**: dirty state — оба компонента убеждены что всё нормально, но фактически обслуживают кэш несуществующего tenant с объектами с устаревшими UID. Вызовы функций нового tenant → 404 или выполнение **функций старого tenant** если имена совпадают.
|
||||
|
||||
---
|
||||
|
||||
## 5. Оценка замены centralized mutex на sharded lock
|
||||
|
||||
### Техническая реализация (feasible)
|
||||
|
||||
```go
|
||||
const numShards = 16
|
||||
|
||||
type shardedNamespaceManager struct {
|
||||
shards [numShards]nsShard
|
||||
subsMu sync.RWMutex
|
||||
subs map[string]NamespaceSubscriber
|
||||
// ... остальные поля
|
||||
}
|
||||
|
||||
type nsShard struct {
|
||||
mu sync.RWMutex
|
||||
records map[string]NamespaceRecord // только NS принадлежащие этому шарду
|
||||
}
|
||||
|
||||
func shardIndex(ns string) int {
|
||||
h := fnv.New32a()
|
||||
h.Write([]byte(ns))
|
||||
return int(h.Sum32()) % numShards
|
||||
}
|
||||
```
|
||||
|
||||
`Upsert(ns, ...)` → берёт lock только шарда `shardIndex(ns)`.
|
||||
`Get(ns)` → RLock только нужного шарда.
|
||||
`Snapshot()` → **последовательно** берёт RLock каждого шарда, копирует, освобождает, переходит к следующему. N=16 последовательных lock-acquisitions.
|
||||
|
||||
### Сохранение интерфейса
|
||||
|
||||
Публичный интерфейс `NamespaceManager` (Upsert, Get, Snapshot, Subscribe, Dispatch) не меняется. Подписчики (`NamespaceSubscriber`) не меняются.
|
||||
|
||||
### Анализ выгоды
|
||||
|
||||
При 10 ns/час churn: **одно upsert каждые 6 минут**. Текущий bottleneck — не mutex, а:
|
||||
1. Synchronous subscriber dispatch (каждый делает k8s API calls)
|
||||
2. Informer resync thundering herd
|
||||
3. AdoptExistingResources race
|
||||
|
||||
Sharded lock убирает конкуренцию за mutex при **параллельных per-namespace операциях**. Но `dispatch()` сам снимает/берёт lock несколько раз — sharding не помогает здесь (dispatch по одному NS всегда один шард).
|
||||
|
||||
`Snapshot()` становится чуть медленнее (16 lock-acquisitions вместо 1 RLock) при маленьком числе NS, и сопоставима при большом.
|
||||
|
||||
### Вердикт
|
||||
|
||||
**Технически реализуемо с сохранением интерфейса. Не оправдано при текущей нагрузке.**
|
||||
|
||||
Sharded mutex даст реальный выигрыш только если `Upsert` и `Get` вызываются **параллельно для разных NS** с частотой > 100 ops/sec. При 10 ns/час это недостижимо. Реальные bottleneck-и — в subscriber dispatch и informer lifecycle, не в mutex.
|
||||
|
||||
Приоритет вместо sharding:
|
||||
1. Сделать subscriber dispatch **параллельным** (goroutine per subscriber с errgroup) — немедленное ускорение онбординга.
|
||||
2. Добавить `RemoveNamespace` в `DefaultNSResolver` — закрывает класс dirty-state багов.
|
||||
3. Добавить reconcile-очередь (см. п. 3) — закрывает stuck-failed.
|
||||
|
||||
Sharded lock — в backlog, актуально при > 500 concurrent tenant с > 1 onboarding/sec.
|
||||
@@ -0,0 +1,72 @@
|
||||
# Console ↔ fission-src multitenant: compatibility check (2026-05-15)
|
||||
|
||||
## Что изменилось в fission-src (feature/multitenant)
|
||||
|
||||
| Изменение | Файл |
|
||||
|---|---|
|
||||
| Удалён старый partial RBAC | `deploy/executor-ns-watcher-rbac.yaml` |
|
||||
| Добавлен полный RBAC для NSWatcher | `deploy/multitenant/rbac.yaml` |
|
||||
| Добавлен `EnsureNamespaceSA` | `pkg/utils/serviceaccount.go` |
|
||||
| NSWatcher вызывает `EnsureNamespaceSA` при обнаружении NS с `fission.io/managed=true` | `pkg/executor/multitenant/ns_watcher.go` |
|
||||
| Добавлен тест NSWatcher с fake k8s | `pkg/utils/namespace_manager_test.go` |
|
||||
|
||||
---
|
||||
|
||||
## Что делает консоль при создании namespace
|
||||
|
||||
`SetupFissionNamespace` в `console/internal/fission/namespace.go`:
|
||||
|
||||
1. Создаёт Namespace с лейблами:
|
||||
- `managed-by=fission-console`
|
||||
- `fission.io/managed=true` ← триггер для NSWatcher
|
||||
|
||||
2. Создаёт ServiceAccounts: `fission-fetcher`, `fission-builder`
|
||||
|
||||
3. Создаёт RoleBindings с `cluster-admin` ClusterRole для всех Fission SA:
|
||||
- `fission-executor`, `fission-router`, `fission-buildermgr`, `fission-kubewatcher`, `fission-timer`
|
||||
- `fission-fetcher` (из fission NS + локально в user NS)
|
||||
- `fission-builder` (из fission NS + локально в user NS)
|
||||
|
||||
---
|
||||
|
||||
## Взаимодействие с EnsureNamespaceSA
|
||||
|
||||
`EnsureNamespaceSA` вызывается NSWatcher **после** того как консоль создала NS.
|
||||
Логика (в `setupSAAndRoleBindings`):
|
||||
|
||||
1. Создаёт/получает SA `fission-fetcher` → SA уже существует → `IsAlreadyExists` → OK
|
||||
2. Для каждого permission из `fetcherCheck` вызывает `checkPermission` через `localsubjectaccessreviews`
|
||||
3. Поскольку у `fission-fetcher` уже есть `cluster-admin` RoleBinding (создан консолью) →
|
||||
**все проверки возвращают `exists=true`** → `rules` остаётся пустым → Role и RoleBinding **не создаются**
|
||||
|
||||
**Итог: EnsureNamespaceSA является no-op если консоль уже настроила namespace. Никаких конфликтов.**
|
||||
|
||||
---
|
||||
|
||||
## Что нужно на кластере
|
||||
|
||||
Для работы NSWatcher нужен `deploy/multitenant/rbac.yaml` применён **один раз**:
|
||||
```bash
|
||||
kubectl apply -f ~/terra/fission-src/deploy/multitenant/rbac.yaml
|
||||
```
|
||||
|
||||
Это даёт:
|
||||
- `fission-executor` → `list/watch namespaces` (NSWatcher)
|
||||
- `fission-router` → `list/watch namespaces` (NSWatcher)
|
||||
- `fission-executor` → `create SA/Role/RoleBinding` в user NS (`fission-executor-sa-provisioner`)
|
||||
|
||||
Без этого RBAC `EnsureNamespaceSA` будет падать с Forbidden, но **консоль продолжит работать** — она создаёт SA/RoleBindings сама и не зависит от NSWatcher.
|
||||
|
||||
---
|
||||
|
||||
## Вердикт
|
||||
|
||||
| Сценарий | Статус |
|
||||
|---|---|
|
||||
| Новый NS создаётся через консоль | ✅ работает как раньше |
|
||||
| NSWatcher обнаруживает NS по `fission.io/managed=true` | ✅ совместимо |
|
||||
| `EnsureNamespaceSA` вызывается в уже настроенном NS | ✅ no-op, нет конфликтов |
|
||||
| Старый NS (без нового fission-bundle) | ✅ консоль не зависит от NSWatcher |
|
||||
| Сборка консоли (`go build ./...`) | ✅ BUILD OK |
|
||||
|
||||
**Код консоли менять не нужно.** Нужно только применить `deploy/multitenant/rbac.yaml` при деплое нового fission-bundle.
|
||||
Reference in New Issue
Block a user