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

4.5 KiB
Raw Blame History

Инцидент 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 этого не было