diff --git a/docs/incident-2026-07-08-pg-oom.md b/docs/incident-2026-07-08-pg-oom.md new file mode 100644 index 0000000..b896408 --- /dev/null +++ b/docs/incident-2026-07-08-pg-oom.md @@ -0,0 +1,88 @@ +# Инцидент 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 этого не было