Док: разбор инцидента 2026-07-08 (PG OOM + EPERM)
This commit is contained in:
@@ -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 этого не было
|
||||||
Reference in New Issue
Block a user