Files
fission-console/doc/POOLMGR_REUSE_BUG_POSTMORTEM_2026-04-27.md
T

325 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Postmortem: Fission poolmgr reuse bug — полное исследование и fix
**Дата**: 2026-04-27
**Статус**: РЕШЕНО
**Image**: `naeel/fission-bundle:v1.22.0-multi-ns-10`
**Commit**: `0cae275 executor: update gpm.podLister in AddNamespace to fix IsValid nil panic`
---
## Симптом
При последовательных invoke одной и той же poolmgr-функции executor каждый раз делал `choosePod + specializePod` вместо reuse уже существующего специализированного pod.
**Наблюдаемые признаки:**
- `phppp` (PHP): 10/10 HTTP 200, но `PHP_PODS_BEFORE=1 → PHP_PODS_AFTER=11`
- `88` (Python): 3/10 HTTP 200, затем HTTP 000 (timeout), `PY_PODS_BEFORE=1 → PY_PODS_AFTER=4`
- В executor logs на каждый invoke: `choosing pod from pool`, `relabel pod`, `specializing pod`, `added function service`
- Через ~2 минуты idle reaper убирал накопившиеся pods: `release idle function resources`
- **Не было ни одной строчки**: `served from cache` или аналогичного
---
## Архитектура reuse path (как должно работать)
```
[router RoundTrip]
├─ getServiceEntry(ctx)
│ └─ getServiceEntryFromExecutor() ─── POST /v2/getServiceForFunction
│ │
│ │ [executor api.go]
│ ├─ GetFuncSvcFromCache(fn) ─── poolcache.GetSvcValue(CacheKeyURG, rpp, concurrency)
│ │ ├─ CACHE HIT → activeRequests++ → return fsvc
│ │ └─ CACHE MISS → getServiceForFunction → choosePod → specializePod → AddFunc → activeRequests=1
│ │
│ └─ IsValid(ctx, fsvc) ─── podLister[ns].Get(pod.Name)
│ ├─ pod ready + IP match → return true → "served from cache"
│ └─ nil panic / not found → return false → DeleteFuncSvcFromCache → choosePod
├─ defer unTapService(ctx, fn, serviceURL)
│ └─ POST /v2/unTapService
│ │ [executor api.go → gpm.UnTapService]
│ └─ fsCache.MarkAvailable(CacheKeyURGFromMeta(fnMeta), svcHost)
│ └─ poolcache.markAvailable → activeRequests--
└─ [NEXT INVOKE]: GetFuncSvcFromCache видит activeRequests=0 → CACHE HIT → reuse
```
**Условие reuse**: `activeRequests < requestsPerPod` (default: `requestsPerPod=1`, т.е. нужно `activeRequests=0`).
---
## Bug #1 (исправлен ранее, commit `775845b`)
### Что было не так
`UnTapService` в `pkg/executor/client/client.go` передавал `FnMetadata` без поля `Generation`:
```go
// ДО fix (client.go UnTapService):
FnMetadata: metav1.ObjectMeta{
Name: fnMeta.Name,
Namespace: fnMeta.Namespace,
ResourceVersion: fnMeta.ResourceVersion,
UID: fnMeta.UID,
// Generation отсутствовал!
},
```
### Где ломалось
`CacheKeyURGFromMeta` в `pkg/crd/key.go`:
```go
func CacheKeyURGFromMeta(metadata *metav1.ObjectMeta) CacheKeyURG {
return CacheKeyURG{
UID: metadata.UID,
ResourceVersion: metadata.ResourceVersion,
Generation: metadata.Generation, // был 0 вместо реального
}
}
```
Кэш записывался с ключом `{UID, RV, Generation=1}`.
`MarkAvailable` искал по ключу `{UID, RV, Generation=0}`.
`cache[{UID, RV, Generation=0}]` не существовал → `activeRequests` никогда не уменьшался.
### Fix
Добавлен `Generation: fnMeta.Generation` в `TapService` и `UnTapService` в `client.go`.
### Почему fix был необходим, но недостаточен
После fix `MarkAvailable` стал находить правильный ключ и декрементировал `activeRequests` до 0.
Следующий `GetFuncSvcFromCache` успешно находил запись (0 < 1) → инкрементировал до 1 → возвращал `fsvc`.
Дальше вызывался `IsValid(ctx, fsvc)` — и здесь находился второй баг.
---
## Bug #2 (исправлен в v1.22.0-multi-ns-10, commit `0cae275`) — ROOT CAUSE
### Точная цепочка отказа
**1. `IsValid` в `gpm.go` строка 289:**
```go
func (gpm *GenericPoolManager) IsValid(ctx context.Context, fsvc *fscache.FuncSvc) bool {
for _, obj := range fsvc.KubernetesObjects {
if strings.ToLower(obj.Kind) == "pod" {
pod, err := gpm.podLister[obj.Namespace].Pods(obj.Namespace).Get(obj.Name)
// ^^^^^^^^^^^^^^^^
// NIL для user namespace!
```
**2. `gpm.podLister` инициализируется только один раз при старте** в `MakeGenericPoolManager`:
```go
for ns, informerFactory := range gpmInformerFactory {
gpm.podLister[ns] = informerFactory.Core().V1().Pods().Lister()
}
```
**3. `gpmInformerFactory` строится из `DefaultNSResolver()`**, который читает env vars executor:
```json
"FISSION_FUNCTION_NAMESPACE": "",
"FISSION_RESOURCE_NAMESPACES": "default"
```
Итог: `gpm.podLister = {"default": <lister>}`**только namespace `default`**.
**4. При создании нового user namespace** `fission-ffd1f598c169b0ae` вызывается `AddNamespace`:
```go
func (gpm *GenericPoolManager) AddNamespace(ctx context.Context, ns string, ...) error {
// ... создаёт gpmInformer для нового NS ...
gpm.poolPodC.AddNamespaceInformers(ctx, ns, finformer, gpmInformer)
// ↑ регистрирует poolPodC.podLister[ns] — ОК
// НО gpm.podLister[ns] НЕ обновлялся ← БАГ
gpmInformer.Start(ctx.Done())
}
```
`AddNamespaceInformers` регистрировал `podLister` только в **`poolPodC`** (для `PoolPodController`), но **не в `gpm`** (для `IsValid`).
**5. Результат**: `gpm.podLister["fission-ffd1f598c169b0ae"]` = **nil**.
**6. `nil.Pods(ns).Get(name)`****nil pointer dereference panic**.
**7. `net/http` перехватывает panic** в handler goroutine → разрывает соединение.
**8. Router's `retryablehttp`** видит connection error → retry.
**9. На retry**: `GetFuncSvcFromCache` видит `activeRequests=1` (был инкрементирован на шаге cache hit, до panic, и не был декрементирован), `1 >= requestsPerPod=1`**cache miss**`choosePod`**новый pod**.
### Почему каждый invoke создавал ровно 1 новый pod
| Invoke N | Что происходит | Состояние после |
|----------|----------------|-----------------|
| 1 | Cache miss (первый invoke) → pod₁ specialized, `activeRequests=1`. UnTap → `activeRequests=0` | pod₁ ready, AR=0 |
| 2 | Cache HIT (AR=0→1), IsValid → nil panic, retry: AR=1≥1 → miss → pod₂ specialized | pod₁ stuck AR=1, pod₂ AR=1→0 |
| 3 | Cache hit pod₂ (AR=0→1), IsValid → nil panic; retry: AR=1≥1 → miss → pod₃ specialized | pod₁ stuck AR=1, pod₂ stuck AR=1, pod₃ AR=1→0 |
| … | То же | +1 pod каждый invoke |
| 10 | | 10 stuck pods + 1 "live" pod |
**Итого после 10 invokes**: 11 pods (`PHP_PODS_BEFORE=1 → PHP_PODS_AFTER=11`). Совпадает с наблюдением.
Каждые ~2 минуты idle reaper убирал stuck pods (у которых `activeRequests=1` но запросов нет — баг idle reaper в отдельной логике списания).
---
## Fix
**Файл**: `pkg/executor/executortype/poolmgr/gpm.go`
**Функция**: `AddNamespace`
**Изменение**: добавлена одна строка — обновление `gpm.podLister[ns]` после `AddNamespaceInformers`
```go
// Register the new informers with PoolPodController.
if err := gpm.poolPodC.AddNamespaceInformers(ctx, ns, finformer, gpmInformer); err != nil {
return fmt.Errorf("AddNamespace %s: register informers: %w", ns, err)
}
// Also update gpm.podLister so IsValid can resolve pods in this namespace.
// Without this line gpm.podLister[ns] is nil for dynamically-added user namespaces,
// causing a nil-pointer panic in IsValid, which leaves activeRequests permanently stuck at 1.
gpm.podLister[ns] = gpmInformer.Core().V1().Pods().Lister() // ← PATCH
// Start the factories — they will begin syncing immediately.
finformer.Start(ctx.Done())
gpmInformer.Start(ctx.Done())
```
### Почему именно здесь, а не в `AddNamespaceInformers`
`AddNamespaceInformers` — метод `PoolPodController`, который знает только о своём `podLister`.
`gpm.podLister` — поле `GenericPoolManager`, логически отдельное от `poolPodC`.
Обновлять оба необходимо, чтобы `IsValid` (метод `GenericPoolManager`) работал корректно.
`gpmInformer` уже создан и передан в `AddNamespaceInformers` — повторное использование того же объекта безопасно.
---
## Live validation
### Методология
1. Удалить все накопленные specialized pods в user namespace (сброс состояния).
2. Запустить 10 последовательных invokes с паузой 2s.
3. Считать число php pods до и после.
4. Ожидаемый результат: `NEW_PODS_CREATED=1` (первый cold start) и `REUSE_WORKING=YES`.
### Результат после patch (v1.22.0-multi-ns-10)
```
=== CLEAN BASELINE: PHP_PODS_BEFORE=1 ===
--- 10 sequential phppp invokes (2s apart) ---
invoke 1: HTTP=200
invoke 2: HTTP=200
invoke 3: HTTP=200
invoke 4: HTTP=200
invoke 5: HTTP=200
invoke 6: HTTP=200
invoke 7: HTTP=200
invoke 8: HTTP=200
invoke 9: HTTP=200
invoke 10: HTTP=200
PHP_PODS_BEFORE=1
PHP_PODS_AFTER=2
NEW_PODS_CREATED=1
REUSE_WORKING=YES_REUSE_FIXED
```
**Интерпретация**:
- `NEW_PODS_CREATED=1` — ровно 1 pod создан для cold start первого invoke.
- invokes 210 полностью reuse тот же pod (`activeRequests` корректно 0→1→0 на каждый цикл).
- `REUSE_WORKING=YES_REUSE_FIXED` — условие `delta ≤ 1` выполнено.
**До patch (v1.22.0-multi-ns-9)**:
```
PHP_PODS_BEFORE=1 → PHP_PODS_AFTER=11
NEW_PODS_CREATED=10
REUSE_WORKING=NO
```
---
## Build и deploy summary
| Шаг | Команда / артефакт |
|-----|-------------------|
| Patch | `gpm.go`: добавлена строка `gpm.podLister[ns] = gpmInformer.Core().V1().Pods().Lister()` |
| Compile check | `go build ./pkg/executor/...` — ошибок нет |
| Commit | `0cae275 executor: update gpm.podLister in AddNamespace to fix IsValid nil panic` |
| Binary build | `CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o _build/linux_amd64/fission-bundle ./cmd/fission-bundle/` |
| Docker build | `naeel/fission-bundle:v1.22.0-multi-ns-10` |
| Docker push | `sha256:f3a054408e7ffe6e89ae2059614ece6250c0658154902b3b772236ccee89dafa` |
| Rollout executor | `kubectl set image deployment/executor executor=naeel/fission-bundle:v1.22.0-multi-ns-10 -n fission` |
| Rollout router | `kubectl set image deployment/router router=naeel/fission-bundle:v1.22.0-multi-ns-10 -n fission` |
| Rollout status | `deployment "executor" successfully rolled out` / `deployment "router" successfully rolled out` |
---
## История двух bugов в timeline
```
[Сессия 1]
Симптом: quota saturation + 504
Причина: quota limits слишком низкие + reuse не работает (оба бага)
Действие: поднята quota
[Сессия 2]
Симптом: phppp 10/10 200, но pods 1→11
Найден Bug #1: Generation отсутствует в TapService/UnTapService
Fix: добавлен Generation в client.go
Commit: 775845b
Image: v1.22.0-multi-ns-9
Результат: MarkAvailable теперь находит ключ, НО IsValid паникует → reuse всё ещё сломан
[Сессия 3 (текущая)]
Симптом: phppp 10/10 200, pods 1→11 (то же)
Найден Bug #2: gpm.podLister не обновляется в AddNamespace
Fix: добавлена одна строка в gpm.AddNamespace
Commit: 0cae275
Image: v1.22.0-multi-ns-10
Результат: phppp 10/10 200, pods 1→2 (1 cold start + 9 reuse) ✓
```
---
## Файлы затронутые в этом исследовании
| Файл | Роль | Изменён |
|------|------|---------|
| `pkg/crd/key.go` | Определение `CacheKeyURG`, `CacheKeyURGFromMeta` | Нет |
| `pkg/executor/client/client.go` | Router→executor HTTP client, `TapService`/`UnTapService` | Да (Bug #1, `775845b`) |
| `pkg/executor/api.go` | HTTP endpoints `/v2/getServiceForFunction`, `/v2/unTapService`, `/v2/tapServices` | Нет |
| `pkg/executor/executor.go` | `serveCreateFuncServices` goroutine, `createServiceForFunction` | Нет |
| `pkg/executor/fscache/functionServiceCache.go` | `GetFuncSvc`, `AddFunc`, `MarkAvailable` (delegating to PoolCache) | Нет |
| `pkg/executor/fscache/poolcache.go` | `activeRequests` accounting, `getValue`, `markAvailable` | Нет |
| `pkg/executor/executortype/poolmgr/gp.go` | `getFuncSvc`, `choosePod`, `specializePod`, `AddFunc` | Нет |
| `pkg/executor/executortype/poolmgr/gpm.go` | `IsValid`, `UnTapService`, `AddNamespace` | Да (Bug #2, `0cae275`) |
| `pkg/executor/executortype/poolmgr/poolpodcontroller.go` | `AddNamespaceInformers`, `podLister` для PoolPodController | Нет |
| `pkg/router/functionHandler.go` | `RoundTrip`, `unTapService`, `getServiceEntry` | Нет |
| `pkg/utils/namespace.go` | `DefaultNSResolver`, `NamespaceResolver` | Нет |
| `pkg/utils/informer.go` | `GetInformerFactoryByExecutor` | Нет |
---
## Ключевые инварианты для будущей поддержки
1. **`gpm.podLister` и `poolPodC.podLister` — два разных объекта.** При добавлении нового namespace нужно обновлять оба. Сейчас это обеспечено патчем.
2. **`CacheKeyURG` = `{UID + ResourceVersion + Generation}`.** Любой код, вызывающий `MarkAvailable` или `GetFuncSvc`, обязан передавать все три поля в `FnMetadata`.
3. **`activeRequests` — единственный критерий reuse.** Если он не возвращается к 0 (из-за паники, незавершённого UnTap и т.д.), pod навсегда "застревает" как занятый, а `listAvailableValue` (idle reaper) не уберёт его пока `activeRequests > 0`.
4. **poolmgr всегда ходит в executor** (`getServiceEntryFromExecutor`) для каждого invoke — кэш в router (`fmap`) для poolmgr не используется (см. `getServiceEntry`). Кэш живёт только на стороне executor в `connFunctionCache`.
5. **`requestsPerPod` default = 1.** Значит: один pod может обслуживать только один одновременный запрос. Для reuse нужно завершить предыдущий UnTap до следующего GetFuncSvc.