Files
ipwhitelist-app/docs/incident-2026-07-08-pg-oom.md
T

89 lines
4.5 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.
# Инцидент 2026-07-08: PostgreSQL OOM + EPERM
## Итоговая цепочка
```
0.1.13 деплой
→ fs.readdirSync throws ENOENT (sql/migrations/ не в образе)
→ process.exit(1)
→ k8s CrashLoopBackoff, rapid restarts
→ каждый рестарт: новый pool + тяжёлый DO$$ блок на PG
→ 20-30 concurrent connections + ALTER locks
→ PG 512 MB → 134% OOM → PG крашится
→ NodeJS теряет PG → дальнейшие рестарты уже без PG
→ после recovery PG: stale iptables на ноде NodeJS
→ EPERM до полного обновления kube-proxy
→ редеплой NodeJS → чистый pod → работает
```
---
## Детальный разбор
### 1. Почему PG ушёл в OOM?
**512 MB = катастрофически мало для PG 17 с этой нагрузкой.** Zalando operator выставляет `shared_buffers = 25% RAM = 128 MB`. Каждое подключение стоит ~5–10 MB.
Триггеры при деплое 0.1.13:
**a) Rolling update → двойное число подключений.** Пока k8s поднимал новый под, старый ещё жил. Новый под открывает pool (10 соединений) плюс старый не успел корректно закрыть свой (`process.exit(1)` без `pool.end()`). PG видел 20–30 соединений → +100-200 MB.
**b) DO $$ блок в `schema.js`.** При каждом старте делал `ALTER TABLE ... DROP CONSTRAINT` + `ADD CONSTRAINT` (AccessExclusiveLock + rewrite). Несколько подов подряд → очередь блокировок → спайк памяти.
**c) connect-pg-simple + `saveUninitialized: true`.** Health-check'и создавали пустые сессии (вторичный фактор).
### 2. EPERM после recovery
`EPERM` на TCP-коннекте — ошибка **ядра** на уровне iptables/netfilter.
После recovery PG-под получил новый Pod IP. k8s Service IP (`10.102.125.70`) не менялся, но `kube-proxy` должен был обновить iptables-правила — процесс асинхронный.
NodeJS делал `connect(10.102.125.70:5432)`, iptables пытался перенаправить на старый Pod IP PG (уже не существующий) → ядро возвращало `EPERM`.
DNS здесь ни при чём — `svc.cluster.local` резолвился правильно.
### 3. Как правильно делать readiness probe?
**`/ready` всегда 200 — правильное решение.** Если `/ready` возвращает 503:
1. Все поды → NotReady
2. nginx → 503
3. k8s перезапускает поды
4. Новые поды создают соединения к нагруженному PG
5. **Каскадный сбой**
Модель:
```
/healthz → всегда 200 (liveness)
/ready → всегда 200 (readiness)
+ страницы показывают user-friendly ошибку
```
### 4. 6202 записей session — нормально ли?
Да. 6202 / 8 часов = ~775/час = ~13/минута. Для многокомпанийного сервиса разумно.
`saveUninitialized: true` создаёт сессии от ботов/сканеров. Для PG не критично: 6202 × ~5 KB ≈ 31 MB.
### 5. `fs.readdirSync` в managed k8s
`fs` доступен, но SQL-файлы должны быть в Docker-образе. Если `.dockerignore` их исключил → `ENOENT` → crash → k8s рестарты → OOM PG.
---
## Что починили
1. `process.exit(1)` убран везде — приложение не падает
2. `/ready` всегда 200 — nginx не блокирует трафик
3. Ошибки показываются на странице
4. PG: 512 MB → 2048 MB
---
## Рекомендации
1. **DO $$ DROP+ADD CONSTRAINT** — запускать один раз, проверять через `pg_constraint`
2. **PG memory** — минимум 1 GB для production
3. **`saveUninitialized: false`** для публичных роутов
4. **SQL-миграции** — включить в образ явно или встроить в js-строки
5. **`pool.end()`** — уже реализован в SIGTERM, но при `process.exit(1)` без SIGTERM этого не было