Інцидент 2026-07-17 — прод-БД насичення EBS-throughput: незакешовані запити стояли 30-40с
Контекст
Section titled “Контекст”Abitly API (abitly-api-v2, NestJS + TypeORM) працює на ECS Fargate і ходить у одну RDS-інстанцію studsearch-prod (PostgreSQL 17.9), де лежить база abitly_prod_db (схема abitly) — цю ж інстанцію ділять і studsearch, і abitly-прод. Параметри на момент інциденту:
| Поле | Значення |
|---|---|
| Клас інстансу | db.t4g.small (2 vCPU, 2 ГБ RAM), burstable |
| Сховище | gp3, 20 ГБ, 3000 IOPS, 125 МБ/с throughput |
| Multi-AZ | ні (single-AZ) |
Ключова деталь: на burstable-інстансах (t*) пропускна здатність до EBS має baseline + burst, а залишок burst-кредиту відстежує метрика EBSByteBalance% (байти) та EBSIOBalance% (операції). Коли EBSByteBalance% доходить до 0, throughput дроселиться до baseline інстансу — і жодна зміна тому gp3 (він і так дає 125 МБ/с) цього не підіймає, бо ліміт на рівні інстансу, а не тому.
Список університетів (GET /universities) читає матеріалізований view universities та джойнить великі таблиці; результат кешується на 10с (data-source.ts cache: { duration: 10000 }). Тому кожен незакешований виклик = повний важкий запит.
Хронологія (за Києвом, UTC+3)
Section titled “Хронологія (за Києвом, UTC+3)”| Час | Подія | Стан |
|---|---|---|
| до 15:07 | EBSByteBalance% ще 9%, ReadLatency ~1.7 мс, черга ~2.8 | 🟢 baseline |
| ~15:21 (12:21 UTC) | EBSByteBalance% → 0%. Диск задроселено. ReadLatency стрибає до ~25 мс (×10), DiskQueueDepth до ~10 (×3), ReadIOPS падає 2266→391 | 🔴 incident start |
| 15:21–16:08 | ~15 паралельних OfferRequest-запитів (по 20-33с) у DataFileRead/BufferIo; незакешовані ендпоінти (список /universities) стоять 25-40с | 🔴 деградація |
| 15:53 | Алярм abitly-prod-rds-DiskQueueDepth-high (поріг 8.0) спрацював — але вже ~32 хв після онсету | 🔴 (пізнє виявлення) |
| ~15:57 | Виявлено людиною («бекенд не працює»). SSO-токен агента протух — потрібен aws sso login | 🔴 |
| 16:07 | Діагноз: сервіс живий (/health 200), але список /universities — 38с; pg_stat_activity = 15 OfferRequest у I/O-waits; RDS EBSByteBalance%=0 | 🟡 root cause |
| ~16:08 | Полегшення: pg_cancel_backend OfferRequest >12с → /universities 38с→0.14с (тимчасово) | 🟡 |
| 16:11:50 | ModifyDBInstance db.t4g.small→db.m6g.large, --apply-immediately (v.bandurin) | 🟡 |
| ~16:15 | Клас переключився на db.m6g.large → ребут БД (~2 хв). /health не падав (shallow), app перепідключився | 🔴→🟡 (короткий блип DB-запитів) |
| 16:17:21 | Інстанс available на db.m6g.large. EBSByteBalance%→99%, RAM 450 МБ→3.4 ГБ, /universities незакешовано 0.13с | 🟢 resolved |
Вікно впливу: ~56 хв інтермітентної повільности незакешованих ендпоінтів (не повний даун — сервіс і закешовані шляхи працювали; повний блип лише ~2 хв ребуту). MTTD ≈ 32 хв (алярм на симптом спрацював пізно; людина побачила ~35 хв). MTTR від виявлення до durable-фіксу ≈ 20 хв (полегшення — ще швидше).
Першопричина (deep-dive)
Section titled “Першопричина (deep-dive)”Чому 677-рядковий MV-запит раптом «висів» 38с
Section titled “Чому 677-рядковий MV-запит раптом «висів» 38с”Сам запит не зламався — він чекав на I/O. pg_stat_activity під час інциденту:
pid | state | wait_event_type | wait_event | age | query15599 | active | IO | DataFileRead | 30s | SELECT "OfferRequest"...12335 | active | IPC | BufferIo | 30s | SELECT "OfferRequest"...... (15 таких, найстаріший 31с, усі клієнт 10.20.31.42 = бекенд-таска)DataFileRead = читання сторінок з диска (не з кешу); BufferIo = очікування буферного I/O. 15 важких OfferRequest-запитів одночасно вичерпали пропускну здатність диска, а всі інші (зокрема список /universities) ставали в чергу за ними. Вимір curl:
/universities?limit=1 try1: 38.4s try2: 0.21s (кеш) try3: 0.12s (кеш)Чому диск задроселило — вичерпаний EBSByteBalance%
Section titled “Чому диск задроселило — вичерпаний EBSByteBalance%”RDS-метрики studsearch-prod (5-хв середні):
| Метрика | ~15:06 | ~15:36 (пік) | після ресайзу |
|---|---|---|---|
EBSByteBalance% | 9% | 0% | 99% |
| ReadLatency | 1.7 мс | ~25 мс | 11 мс |
| DiskQueueDepth | 2.8 | ~10.7 | 4.6 |
| ReadIOPS | 2266 | 391 (throttled) | — |
| FreeableMemory | ~450 МБ | ~450 МБ | 3.4 ГБ |
Діагностична пастка: EBSIOBalance% (операції) лишався 88–93% — тобто по IOPS усе гаразд; вичерпалась саме пропускна здатність (байти/сек). ReadIOPS падає при зростанні черги й латентности — класичний підпис throttle: диск не встигає віддавати байти, операції стають у чергу.
Корінь кореня: 2 ГБ RAM на прод-БД
Section titled “Корінь кореня: 2 ГБ RAM на прод-БД”db.t4g.small має лише 2 ГБ RAM (FreeableMemory ~450 МБ). Робочий набір важких запитів не вміщується в buffer cache, тож кожен виконує масу DataFileRead з диска. Це і генерує величезний throughput-попит, який вичерпує EBSByteBalance%. Тому справжній важіль — більше RAM (кеш → менше читань з диска), а не тюнінг тому.
Чому це не зловили раніше
Section titled “Чому це не зловили раніше”| Гарантія | Чи була | Чому не спрацювала |
|---|---|---|
Алярм на першопричинну метрику EBSByteBalance% (throughput-кредит) | ❌ немає | Є алярми на симптоми (черга, латентність), але не на вичерпання throughput-кредиту — а це найраніший сигнал (падав із 9%→0%) |
Алярм abitly-prod-rds-DiskQueueDepth-high (поріг 8.0) | ✅ є, спрацював о 15:53 | Пізно: черга перетнула 8.0 лише через ~32 хв після онсету (12:21). Поріг ловить уже-насичення, не деградацію-що-починається. Дія алярму, схоже, не дійшла до каналу, який моніторять (виявили людиною) |
Алярм abitly-prod-rds-ReadLatency-high (поріг 20 мс) | ✅ є, не спрацював удень | Середня латентність висіла ~19 мс — щойно під порогом; тільки max доходив 25 мс. Поріг занадто близький до норми під навантаженням |
App-алярми RunningTaskCount-low / heartbeat | ✅ є, лишались OK | Правильно: сервіс був живий — це деградація БД, а не даун застосунку. Ці алярми не покривають повільність БД |
| Right-sizing прод-БД | ❌ процесу немає | db.t4g.small (2 ГБ) на прод, що обслуговує оплати обох продуктів у пік вступу — недорозмір ніхто не переглядав |
Виправлення
Section titled “Виправлення”Крок 1 — негайне полегшення (без даунтайму): скасувати завислі OfferRequest, щоб звільнити throughput:
SELECT pg_cancel_backend(pid) FROM pg_stat_activityWHERE datname='abitly_prod_db' AND state='active' AND query ILIKE '%OfferRequest%' AND query NOT ILIKE '%pg_stat%' AND now()-query_start > interval '12s';-- → /universities 38s → 0.14s одразу (тимчасово: під навантаженням накопичиться знову)Крок 2 — durable (ресайз інстансу):
aws rds modify-db-instance --db-instance-identifier studsearch-prod \ --db-instance-class db.m6g.large --apply-immediately# single-AZ → ~2-хв ребут БД. /health не падає (shallow), app перепідключається сам.Чому m6g.large: 8 ГБ RAM (×4 кеш → різко менше DataFileRead) + значно вища baseline-пропускна здатність EBS (немає throttle). Метрика-корінь — RAM, тож memory/general-purpose клас б’є прямо в ціль.
Доступ до прод-БД для діагностики — SSM port-forward через jump host abitly-tutor-tutor (i-0d9b5c257862c5601); його SG дозволений RDS-SG (abitly-dev-backend — ні). Рецепт — db-issues.
Фінальна перевірка:
| Перевірка | Результат |
|---|---|
/universities?limit=1 незакешовано ×6 | 200, ~0.13с (було 38с) |
EBSByteBalance% | 0% → 99% |
| FreeableMemory | 450 МБ → 3.4 ГБ |
| ReadLatency / DiskQueueDepth | 25 мс / 10.7 → 11 мс / 4.6 |
| ECS сервіс | :49, running 1/1, steady state |
| Інстанс | db.m6g.large, available (LIVE) |
Превентивні заходи
Section titled “Превентивні заходи”- Зроблено 17.07 16:11: ресайз
db.t4g.small→db.m6g.large(ModifyDBInstance, apply-immediately). - Алярм на
EBSByteBalance%< ~30% (іEBSIOBalance%) наstudsearch-prodу канал, який моніторять — найраніший сигнал throughput-throttle (ловить до того, як черга перетне поріг). - Перевірити маршрутизацію RDS-алярмів:
DiskQueueDepth-highспрацював, але виявили людиною — дія алярму має йти в Telegram-канал команди; знизити порігReadLatency-high(20 мс висить під нормою). - Оптимізувати
OfferRequest-запит (списки вступників/конкурс): 20-33с під навантаженням — цільові індекси / переписати; він же спричиняв лок-контенцію під час MV-фіксу 16.07. - Розглянути Multi-AZ для
studsearch-prod(зараз single-AZ) — щоб майбутні ресайзи були failover (~60-120с), а не ребут, і був захист від AZ-відмови для БД з оплатами. - Right-sizing як процес: періодичний огляд класу прод-БД проти навантаження (особливо перед піком вступу). За потреби gp3-throughput піднімається онлайн (125→250+ МБ/с) без ребуту.
-
/healthмає бути «deep» (легкийSELECT 1до БД) або додати окремий readiness-чек, що чіпає БД — щоб деградація БД відображалась у health/алярмах, а не лише «здавалось, що працює».
Відкриті питання
Section titled “Відкриті питання”- Що спровокувало сплеск ~15:21? 15 паралельних
OfferRequest— реальний трафік на списки вступників/конкурс, батч, чи бот-краулер? Треба джерело (per-endpoint аналітика), бо ресайз дав запас, але першопричину сплеску не усунено. - Чи достатньо 8 ГБ у пік? База ~20 ГБ; якщо гарячий набір більший — розглянути
r6g.large(16 ГБ, memory-optimized), що кешує майже всю БД. - gp3 125 МБ/с тепер може стати новим лімітом за високого навантаження (m6g.large віддає значно більше) — моніторити й підняти онлайн за потреби.
Ключові ідентифікатори
Section titled “Ключові ідентифікатори”| Поле | Значення |
|---|---|
| RDS-інстанс | studsearch-prod (PostgreSQL 17.9, eu-central-1, акаунт 952854879948); база abitly_prod_db, схема abitly |
| Клас | db.t4g.small (2 ГБ, інцидент) → db.m6g.large (8 ГБ, фікс) |
| Сховище | gp3 20 ГБ / 3000 IOPS / 125 МБ/с; single-AZ |
| Ключові метрики | EBSByteBalance% (0%→99%), EBSIOBalance% (88%, ОК), ReadLatency, DiskQueueDepth |
| Важкий запит | OfferRequest (списки вступників/конкурс), ~15 паралельних, DataFileRead/BufferIo |
| Алярми | abitly-prod-rds-DiskQueueDepth-high (спрацював пізно), abitly-prod-rds-ReadLatency-high (не спрацював); немає на EBSByteBalance% |
| Jump host до БД | abitly-tutor-tutor i-0d9b5c257862c5601 (SG дозволений RDS-SG sg-0d7584eebe904643b) |
| Дія-ресайз | ModifyDBInstance 2026-07-17 16:11:50 +03, v.bandurin, apply-immediately |
- «Бекенд не працює» ≠ бекенд лежить. Сервіс був 200 на
/healthувесь час — насправді деградувала БД (I/O). Завжди розрізняй «застосунок впав» (RunningTaskCount/heartbeat) від «БД повільна» (латентність/черга/throughput-баланс) — це різні алярми й різні фікси. - Відкат коду не лікує вичерпання ресурсів БД. MV/БД спільні на рівні бази; жоден
:48/:49цього не змінює. Коли корінь — I/O/RAM, фікс — ресурси, а не деплой. EBSByteBalance%— найраніший сигнал на burstable-БД. Він падає ще до того, як черга/латентність перетнуть пороги. Алярм на throughput-баланс ловить проблему на 20-30 хв раніше за алярм на симптом.- RAM — головний важіль проти
DataFileRead. Насичення throughput часто вторинне до нестачі кешу: мало RAM → читання з диска → вичерпаний throughput. Більше RAM прибирає обидва. - Алярм, що спрацював, але нікого не розбудив, — не алярм.
DiskQueueDepth-highперейшов в ALARM, а виявили все одно людиною — перевіряй, що дія алярму доходить до каналу, який моніторять. - Прод на burstable-nano — відкладена бомба в пік.
db.t4g.small(2 ГБ) на дві продакшн-бази з оплатами у сезон вступу; right-sizing має бути процесом, а не реакцією на інцидент.
Пов’язана документація
Section titled “Пов’язана документація”- Abitly API service card — рантайм, БД, деплой
- DB issues — доступ до прод-БД (SSM port-forward), діагностика
- Service down — first-look triage (нагадування: 200 на health ≠ все гаразд)
- Hosting / інфраструктура — RDS, ECS
- Інцидент 2026-07-16 — dev↔prod дрейф схеми — попередній день; той самий
OfferRequest-запит спричиняв лок-контенцію під час фіксу - Інцидент 2026-06-12 — frontend 503 — споріднений урок: алерт-дірка + «здавалось, що працює»