Док: разбор инцидента 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