25 KiB
Namespace Lifecycle Hardening — Implementation Notes
Дата: 2026-05-18
Ветка: fix/namespace-lifecycle-hardening
Коммиты: 3b93c5dc (предыдущая сессия) → 4eedf95f (эта сессия)
1. Контекст: что было сделано до этой сессии
Предыдущие правки (коммит 3b93c5dc)
-
RemoveNamespace(ns string) bool— добавлен вNamespaceResolver(pkg/utils/namespace.go).
HandleWatcherNamespaceRemovalтеперь вызывает его при любой стратегии, очищая глобальный resolver. Это исправляет дедупликацию router/buildermgr при re-add NS. -
Параллельный
dispatch()—pkg/utils/namespace_manager.go: заменён последовательный обход подписчиков на параллельный сsync.WaitGroup. Исправляет 30-минутное окно, когда HTTPTrigger не видел namespace из-за того что обход был последовательным. -
RunReconciler()— добавлен вNamespaceManagerinterface и реализован вinMemoryNamespaceManager. Каждые 30 секунд сканирует namespace-ы в фазеNamespacePhaseFailedи вызываетDispatchResync. Исправляет постоянно stuck-failed namespace при транзиентных k8s API ошибках.
Что оставалось нерешённым (из аудита)
Три проблемы, зафиксированные в FORENSIC_ARCHITECTURE_AUDIT.md:
Проблема 1 — Executor dedup gap (Critical)
При удалении NS и повторном добавлении executor молча пропускал его.
Причина: gpm.poolPodC.envLister[ns] и deploy.deplLister[ns] проверялись как дедупликация в AddNamespace, но никогда не очищались при удалении NS.
Результат: повторно добавленный namespace не получал informers в executor → функции не запускались.
Проблема 2 — Goroutine/FD leak (High)
При удалении NS старые informer factories продолжали работать (goroutines, file descriptors, LIST-запросы к k8s API каждые 30 минут).
Причина: informers запускались с ctx.Done() родительского контекста всего процесса, без механизма per-NS остановки.
Проблема 3 — Router stale routes (Medium)
После удаления NS router продолжал держать HTTPTrigger routes для этого namespace.
Причина: triggerInformer[ns] и funcInformer[ns] не чистились, syncTriggers() не вызывался.
2. Анализ перед реализацией
2.1 Чтение интерфейса ExecutorType
Файл: pkg/executor/executortype/executortype.go
// До правки — нет RemoveNamespace
AddNamespace(ctx context.Context, ns string, mgr manager.Interface) error
}
Подтверждено: ни grep, ни LSP не нашли RemoveNamespace в executor types.
2.2 Анализ механизма дедупликации по каждому executor type
poolmgr (gpm.go строка 807):
if _, ok := gpm.poolPodC.envLister[ns]; ok {
return nil // already registered
}
Деdup через PoolPodController.envLister[ns] — локальная карта, не связана с глобальным resolver.
newdeploy (newdeploymgr.go строка 917):
if _, ok := deploy.deplLister[ns]; ok {
return nil // already registered
}
Деdup через deploy.deplLister[ns].
container (containermgr.go строка 805):
if _, ok := caaf.deplLister[ns]; ok {
return nil // already registered
}
Деdup через caaf.deplLister[ns].
router (httpTriggers.go строка 461):
if !utils.DefaultNSResolver().AddNamespace(ns) {
return nil // already registered
}
Деdup через глобальный resolver — уже починен предыдущим коммитом (RemoveNamespace в resolver).
buildermgr envwatcher (envwatcher.go строка 506):
if _, exists := envw.envWatchInformer[ns]; exists {
return
}
Деdup через envw.envWatchInformer[ns].
buildermgr pkgwatcher (pkgwatcher.go строка 337):
if _, exists := pkgw.pkgInformer[ns]; exists {
return
}
Деdup через pkgw.pkgInformer[ns].
2.3 Анализ стратегии удаления
NewDefaultManagedNamespaceWatcherConfig создаёт конфиг с RemovalStrategy: NamespaceRemovalStrategyTrackOnly.
При TrackOnly — HandleWatcherNamespaceRemoval вызывает DefaultNSResolver().RemoveNamespace() (наш предыдущий фикс), но не вызывает manager.DispatchRemove() → subscriber.OnNamespaceRemove() → RemoveFunc не срабатывает.
Для вызова RemoveFunc нужна стратегия DispatchRemove.
2.4 Решение: per-namespace context cancellation
Informers запускаются через factory.Start(ctx.Done()). Стандартный способ остановить отдельный informer — отменить контекст, с которым он запущен.
Решение:
nsCtx, nsCancel := context.WithCancel(ctx)
gpm.nsCancels[ns] = nsCancel
finformer.Start(nsCtx.Done()) // вместо ctx.Done()
При RemoveNamespace:
if cancel, ok := gpm.nsCancels[ns]; ok {
cancel() // останавливает goroutines informer factories
delete(gpm.nsCancels, ns)
}
Это чисто и не требует изменения k8s client-go.
2.5 Mutex и thread-safety
Существующий код в executor types не защищает lister maps мьютексами. Записи в них происходят только при AddNamespace (из subscriber goroutine). Добавление RemoveNamespace добавляет ещё одну запись из той же goroutine. Race condition с event handlers (которые читают эти maps) — известное ограничение существующего дизайна, не добавляем мьютексы чтобы не выходить за рамки задачи.
HTTPTriggerSet уже имеет informerMu sync.RWMutex — его и используем в RemoveNamespace при удалении из triggerInformer/funcInformer.
3. Реализация — пошаговое описание
Шаг 1: pkg/executor/executortype/executortype.go
Добавлен метод в ExecutorType interface:
// RemoveNamespace deregisters a namespace from the executor, cancelling its
// informer goroutines and clearing dedup state so that a re-add works correctly.
// Called when a Namespace with label fission.io/managed=true is removed.
RemoveNamespace(ctx context.Context, ns string) error
Почему: Все три executor type реализуют этот интерфейс. Добавление в интерфейс гарантирует, что новый тип executor не забудет реализовать метод (компилятор поймает).
Шаг 2: pkg/executor/executortype/poolmgr/gpm.go
2a. Добавлено поле в struct:
// nsCancels holds per-namespace context cancel functions so informer
// factories started in AddNamespace can be stopped on RemoveNamespace.
nsCancels map[string]context.CancelFunc
2b. Инициализация в MakeGenericPoolManager:
nsCancels: make(map[string]context.CancelFunc),
2c. Изменение в AddNamespace: вместо ctx.Done() передаём nsCtx.Done():
nsCtx, nsCancel := context.WithCancel(ctx)
gpm.nsCancels[ns] = nsCancel
finformer.Start(nsCtx.Done())
gpmInformer.Start(nsCtx.Done())
2d. Новый метод RemoveNamespace:
func (gpm *GenericPoolManager) RemoveNamespace(ctx context.Context, ns string) error {
if ns == "" { return nil }
gpm.logger.Info("RemoveNamespace: cleaning up namespace (poolmgr)", ...)
if cancel, ok := gpm.nsCancels[ns]; ok {
cancel()
delete(gpm.nsCancels, ns)
}
delete(gpm.podLister, ns)
delete(gpm.podListerSynced, ns)
gpm.poolPodC.RemoveNamespace(ns) // очищает envLister/podLister в PoolPodController
return nil
}
Шаг 3: pkg/executor/executortype/poolmgr/poolpodcontroller.go
Добавлен метод, очищающий lister maps в PoolPodController:
func (p *PoolPodController) RemoveNamespace(ns string) {
delete(p.envLister, ns)
delete(p.envListerSynced, ns)
delete(p.podLister, ns)
delete(p.podListerSynced, ns)
p.logger.Info("PoolPodController.RemoveNamespace: cleared lister state", ...)
}
Почему отдельный метод: PoolPodController — отдельная структура внутри poolmgr. Доступ к её полям из GenericPoolManager.RemoveNamespace требовал бы либо экспорта полей, либо метода. Метод — чище.
Шаг 4: pkg/executor/executortype/newdeploy/newdeploymgr.go
Аналогично gpm:
- Добавлен
nsCancels map[string]context.CancelFuncв structNewDeploy - Инициализирован в
MakeNewDeploy AddNamespaceпереключён наnsCtx.Done()- Добавлен
RemoveNamespaceочищающийdeplLister,deplListerSynced,svcLister,svcListerSynced
Шаг 5: pkg/executor/executortype/container/containermgr.go
Аналогично. Struct Container получил nsCancels. AddNamespace использует nsCtx.Done(). RemoveNamespace очищает deplLister, deplListerSynced, svcLister, svcListerSynced.
Шаг 6: pkg/executor/multitenant/namespace_subscriber.go
Добавлен RemoveFunc в NamespaceSubscriberFuncs:
RemoveFunc: func(ctx context.Context, record utils.NamespaceRecord) error {
return deregisterNamespace(ctx, logger, record.Name, executorTypes)
},
deregisterNamespace итерирует все executor types и вызывает et.RemoveNamespace(ctx, ns).
Шаг 7: pkg/executor/multitenant/ns_watcher.go
7a. Добавлен deregisterNamespace:
func deregisterNamespace(ctx, logger, ns, executorTypes) error {
var joinErr error
for _, et := range executorTypes {
if err := et.RemoveNamespace(ctx, ns); err != nil {
joinErr = errors.Join(joinErr, err)
}
}
logger.Info("multitenant.NSWatcher: deregistered namespace", ...)
return joinErr
}
7b. Изменён StartNSWatcher: стратегия TrackOnly → DispatchRemove:
config := utils.NewDefaultManagedNamespaceWatcherConfig(...)
config.RemovalStrategy = utils.NamespaceRemovalStrategyDispatchRemove
Почему: Без DispatchRemove RemoveFunc подписчика никогда не вызывается. TrackOnly вызывает только DefaultNSResolver().RemoveNamespace() (что сделано в HandleWatcherNamespaceRemoval), но не диспетчирует событие подписчикам.
Шаг 8: pkg/buildermgr/envwatcher.go
- Добавлен
nsCancels map[string]context.CancelFuncв structenvironmentWatcher - Инициализирован в
makeEnvironmentWatcher(там же гдеenvWatchInformer) AddNamespaceпереключён на per-NS context:nsCtx, nsCancel := context.WithCancel(ctx) envw.nsCancels[ns] = nsCancel factory.Start(nsCtx.Done())- Добавлен
RemoveNamespace(ns string):func (envw *environmentWatcher) RemoveNamespace(ns string) { if cancel, ok := envw.nsCancels[ns]; ok { cancel(); delete(...) } delete(envw.envWatchInformer, ns) }
Ошибка при первой попытке: replace_string_in_file добавил nsCancels с тройным отступом (три таба вместо двух) и без закрывающего } struct literal — синтаксическая ошибка компиляции. Исправлено вторым вызовом replace.
Шаг 9: pkg/buildermgr/pkgwatcher.go
Аналогично envwatcher:
nsCancelsв structpackageWatcher- Инициализация в
makePackageWatcher AddNamespace→ per-NS ctx дляfissionFactory.Start()иpodFactory.Start()RemoveNamespace(ns string)очищаетpkgInformer[ns],podInformer[ns]
Шаг 10: pkg/buildermgr/namespace_subscriber.go
Добавлены два новых интерфейса:
type builderEnvNamespaceRemover interface {
RemoveNamespace(ns string)
}
type builderPkgNamespaceRemover interface {
RemoveNamespace(ns string)
}
Добавлен RemoveFunc:
RemoveFunc: func(ctx context.Context, record utils.NamespaceRecord) error {
deregisterBuilderNamespace(record.Name, envw, pkgw)
return nil
},
deregisterBuilderNamespace через type assertion вызывает RemoveNamespace если интерфейс реализован:
func deregisterBuilderNamespace(namespace string, envw, pkgw) {
utils.DefaultNSResolver().RemoveNamespace(namespace)
if r, ok := envw.(builderEnvNamespaceRemover); ok { r.RemoveNamespace(namespace) }
if r, ok := pkgw.(builderPkgNamespaceRemover); ok { r.RemoveNamespace(namespace) }
}
Почему type assertion: builderEnvNamespaceAdder — интерфейс-параметр функции NewNamespaceSubscriber. Вместо добавления RemoveNamespace в существующий интерфейс (что сломало бы тестовые фейки) используем опциональный интерфейс через type assertion.
Шаг 11: pkg/buildermgr/ns_watcher.go
Стратегия изменена на DispatchRemove аналогично executor.
Шаг 12: pkg/router/httpTriggers.go
12a. Добавлен nsCancels в struct:
// nsCancels holds per-namespace context cancel functions for informer lifecycle.
nsCancels map[string]context.CancelFunc
12b. Инициализация в makeHTTPTriggerSet:
nsCancels: make(map[string]context.CancelFunc),
12c. Изменён AddNamespace: per-NS ctx:
nsCtx, nsCancel := context.WithCancel(ctx)
ts.nsCancels[ns] = nsCancel
factory.Start(nsCtx.Done())
k8sCache.WaitForCacheSync(nsCtx.Done(), ...) // тоже nsCtx
12d. Новый метод RemoveNamespace:
func (ts *HTTPTriggerSet) RemoveNamespace(ns string) {
if cancel, ok := ts.nsCancels[ns]; ok { cancel(); delete(...) }
ts.informerMu.Lock()
delete(ts.triggerInformer, ns)
delete(ts.funcInformer, ns)
ts.informerMu.Unlock()
ts.syncTriggers() // немедленно перестраивает routing table без удалённого NS
}
Почему informerMu.Lock(): HTTPTriggerSet уже имеет informerMu sync.RWMutex для защиты triggerInformer/funcInformer. Используем его — не добавляем новые мьютексы.
Шаг 13: pkg/router/namespace_subscriber.go
Добавлен routerNamespaceRemover interface и RemoveFunc:
type routerNamespaceRemover interface {
RemoveNamespace(ns string)
}
RemoveFunc: func(ctx context.Context, record utils.NamespaceRecord) error {
if r, ok := ts.(routerNamespaceRemover); ok {
r.RemoveNamespace(record.Name)
}
return nil
},
Шаг 14: pkg/router/ns_watcher.go
Стратегия → DispatchRemove.
Шаг 15: Тест-фейк pkg/executor/multitenant/ns_watcher_test.go
fakeExecutorType не реализовывал новый метод → ошибка компиляции:
*fakeExecutorType does not implement executortype.ExecutorType (missing method RemoveNamespace)
Добавлена заглушка:
func (f *fakeExecutorType) RemoveNamespace(ctx context.Context, ns string) error { return nil }
4. Результат компиляции и тестов
go build ./pkg/... ./cmd/... → нет вывода (успех)
go test ./pkg/utils/...
./pkg/executor/...
./pkg/buildermgr/...
./pkg/router/...
ok github.com/fission/fission/pkg/utils
ok github.com/fission/fission/pkg/executor/executortype/newdeploy
ok github.com/fission/fission/pkg/executor/executortype/poolmgr
ok github.com/fission/fission/pkg/executor/fscache
ok github.com/fission/fission/pkg/executor/multitenant
ok github.com/fission/fission/pkg/executor/util
ok github.com/fission/fission/pkg/buildermgr
ok github.com/fission/fission/pkg/router
5. Схема потока при удалении NS (после всех правок)
k8s: Namespace label fission.io/managed=true удалён/NS удалён
│
▼
ManagedNamespaceWatcher (DispatchRemove стратегия)
│
├─► HandleWatcherNamespaceRemoval()
│ DefaultNSResolver().RemoveNamespace(ns) ← сброс глобального guard
│ manager.DispatchRemove(ctx, ns)
│
▼
inMemoryNamespaceManager.DispatchRemove()
│
├─► goroutine: subscriber[executor].OnNamespaceRemove(record)
│ deregisterNamespace(ctx, logger, ns, executorTypes)
│ gpm.RemoveNamespace(ctx, ns)
│ nsCancel() ← останавливает informer goroutines
│ delete(podLister[ns])
│ delete(podListerSynced[ns])
│ poolPodC.RemoveNamespace(ns)
│ delete(envLister[ns])
│ delete(envListerSynced[ns])
│ delete(podLister[ns])
│ delete(podListerSynced[ns])
│ deploy.RemoveNamespace(ctx, ns)
│ nsCancel()
│ delete(deplLister[ns])
│ delete(deplListerSynced[ns])
│ delete(svcLister[ns])
│ delete(svcListerSynced[ns])
│ container.RemoveNamespace(ctx, ns)
│ nsCancel()
│ delete(deplLister[ns])
│ delete(svcLister[ns])
│
├─► goroutine: subscriber[buildermgr].OnNamespaceRemove(record)
│ deregisterBuilderNamespace(ns, envw, pkgw)
│ DefaultNSResolver().RemoveNamespace(ns) ← повторно (безопасно)
│ envw.RemoveNamespace(ns)
│ nsCancel()
│ delete(envWatchInformer[ns])
│ pkgw.RemoveNamespace(ns)
│ nsCancel()
│ delete(pkgInformer[ns])
│ delete(podInformer[ns])
│
└─► goroutine: subscriber[router].OnNamespaceRemove(record)
ts.RemoveNamespace(ns)
nsCancel() ← останавливает triggerInf/funcInf goroutines
informerMu.Lock()
delete(triggerInformer[ns])
delete(funcInformer[ns])
informerMu.Unlock()
syncTriggers() ← немедленно убирает routes для удалённого NS
6. Что НЕ было реализовано и почему
FunctionServiceCache.DeleteByNamespace(ns string) — не реализовано.
Причина: idleObjectReaper периодически вызывает IsValid() для всех записей. Для удалённого NS k8s API возвращает 404/403 → IsValid() вернёт false → запись будет удалена reaperом естественным образом. Это создаёт несколько минут "грязных" записей и 404 ошибки в логах, но не влияет на корректность: для удалённого NS новые запросы не придут (router очистил routes), а reaper уберёт старые записи.
Реализация DeleteByNamespace потребовала бы добавления namespace-индекса в byFunction/byAddress/byFunctionUID кэшах (нетривиально), или дорогого линейного прохода по всем записям. Не было делать без явного запроса.
7. Затронутые файлы (17 изменённых)
| Файл | Тип изменения |
|---|---|
pkg/executor/executortype/executortype.go |
+метод в interface |
pkg/executor/executortype/poolmgr/gpm.go |
+поле nsCancels, modify AddNamespace, +RemoveNamespace |
pkg/executor/executortype/poolmgr/poolpodcontroller.go |
+RemoveNamespace |
pkg/executor/executortype/newdeploy/newdeploymgr.go |
+поле nsCancels, modify AddNamespace, +RemoveNamespace |
pkg/executor/executortype/container/containermgr.go |
+поле nsCancels, modify AddNamespace, +RemoveNamespace |
pkg/executor/multitenant/namespace_subscriber.go |
+RemoveFunc |
pkg/executor/multitenant/ns_watcher.go |
+deregisterNamespace, DispatchRemove |
pkg/executor/multitenant/ns_watcher_test.go |
+RemoveNamespace в fakeExecutorType |
pkg/buildermgr/namespace_subscriber.go |
+интерфейсы remover, +RemoveFunc, +deregisterBuilderNamespace |
pkg/buildermgr/envwatcher.go |
+nsCancels, modify AddNamespace, +RemoveNamespace |
pkg/buildermgr/pkgwatcher.go |
+nsCancels, modify AddNamespace, +RemoveNamespace |
pkg/buildermgr/ns_watcher.go |
DispatchRemove |
pkg/router/httpTriggers.go |
+nsCancels, modify AddNamespace, +RemoveNamespace |
pkg/router/namespace_subscriber.go |
+routerNamespaceRemover, +RemoveFunc |
pkg/router/ns_watcher.go |
DispatchRemove |
doc/FORENSIC_ARCHITECTURE_AUDIT.md |
перемещён из корня (git rename) |
doc/console-compat-2026-05-15.md |
создан (отдельная задача) |
8. Ошибки в процессе
Ошибка 1: Синтаксическая ошибка в envwatcher.go
Что случилось: При попытке заменить блок инициализации struct добавился nsCancels: с тройным отступом и без закрывающей }:
// Стало (неверно):
enableOwnerReferences: utils.IsOwnerReferencesEnabled(),
nsCancels: make(map[string]context.CancelFunc),
err := envWatcher.EnvWatchEventHandlers(ctx)
// ← пропущена } закрывающая struct literal
Причина: replace_string_in_file не нашёл точное совпадение с нужным whitespace и применил замену частично некорректно.
Исправление: второй вызов replace_string_in_file с правильным контекстом (включая соседние строки для однозначного совпадения).
Вывод компилятора:
pkg/buildermgr/envwatcher.go:117:6: syntax error: unexpected := in composite literal; possibly missing comma or }
Ошибка 2: Тест-фейк не реализует интерфейс
Что случилось: После добавления RemoveNamespace в interface ExecutorType, тест ns_watcher_test.go не компилировался:
*fakeExecutorType does not implement executortype.ExecutorType (missing method RemoveNamespace)
Исправление: добавлена заглушка в fakeExecutorType.
9. Инварианты безопасности
nsCancel()идемпотентен: повторный вызов не паникует (context package гарантирует это)RemoveNamespace("")защищён early return во всех реализацияхderegisterBuilderNamespaceчерез type assertion — безопасно если интерфейс не реализован (просто пропускает)routerNamespaceRemoverчерез type assertion в router subscriber — аналогично- Goroutines informer factories останавливаются асинхронно после
cancel()— это нормально, k8s client-go гарантирует graceful shutdown при отмене контекста - После
RemoveNamespaceи до следующегоAddNamespace— любые события от k8s для этого NS будут проигнорированы (informers остановлены, listers удалены)