Перейти до вмісту

Інцидент 2026-07-17 — прод-БД насичення EBS-throughput: незакешовані запити стояли 30-40с

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:07EBSByteBalance% ще 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:50ModifyDBInstance db.t4g.smalldb.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 хв (полегшення — ще швидше).

Чому 677-рядковий MV-запит раптом «висів» 38с

Section titled “Чому 677-рядковий MV-запит раптом «висів» 38с”

Сам запит не зламався — він чекав на I/O. pg_stat_activity під час інциденту:

pid | state | wait_event_type | wait_event | age | query
15599 | 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%
ReadLatency1.7 мс~25 мс11 мс
DiskQueueDepth2.8~10.74.6
ReadIOPS2266391 (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 ГБ) на прод, що обслуговує оплати обох продуктів у пік вступу — недорозмір ніхто не переглядав

Крок 1 — негайне полегшення (без даунтайму): скасувати завислі OfferRequest, щоб звільнити throughput:

SELECT pg_cancel_backend(pid) FROM pg_stat_activity
WHERE 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 (ресайз інстансу):

Terminal window
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 незакешовано ×6200, ~0.13с (було 38с)
EBSByteBalance%0% → 99%
FreeableMemory450 МБ → 3.4 ГБ
ReadLatency / DiskQueueDepth25 мс / 10.7 → 11 мс / 4.6
ECS сервіс:49, running 1/1, steady state
Інстансdb.m6g.large, available (LIVE)
  • Зроблено 17.07 16:11: ресайз db.t4g.smalldb.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/алярмах, а не лише «здавалось, що працює».
  1. Що спровокувало сплеск ~15:21? 15 паралельних OfferRequest — реальний трафік на списки вступників/конкурс, батч, чи бот-краулер? Треба джерело (per-endpoint аналітика), бо ресайз дав запас, але першопричину сплеску не усунено.
  2. Чи достатньо 8 ГБ у пік? База ~20 ГБ; якщо гарячий набір більший — розглянути r6g.large (16 ГБ, memory-optimized), що кешує майже всю БД.
  3. 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
  1. «Бекенд не працює» ≠ бекенд лежить. Сервіс був 200 на /health увесь час — насправді деградувала БД (I/O). Завжди розрізняй «застосунок впав» (RunningTaskCount/heartbeat) від «БД повільна» (латентність/черга/throughput-баланс) — це різні алярми й різні фікси.
  2. Відкат коду не лікує вичерпання ресурсів БД. MV/БД спільні на рівні бази; жоден :48/:49 цього не змінює. Коли корінь — I/O/RAM, фікс — ресурси, а не деплой.
  3. EBSByteBalance% — найраніший сигнал на burstable-БД. Він падає ще до того, як черга/латентність перетнуть пороги. Алярм на throughput-баланс ловить проблему на 20-30 хв раніше за алярм на симптом.
  4. RAM — головний важіль проти DataFileRead. Насичення throughput часто вторинне до нестачі кешу: мало RAM → читання з диска → вичерпаний throughput. Більше RAM прибирає обидва.
  5. Алярм, що спрацював, але нікого не розбудив, — не алярм. DiskQueueDepth-high перейшов в ALARM, а виявили все одно людиною — перевіряй, що дія алярму доходить до каналу, який моніторять.
  6. Прод на burstable-nano — відкладена бомба в пік. db.t4g.small (2 ГБ) на дві продакшн-бази з оплатами у сезон вступу; right-sizing має бути процесом, а не реакцією на інцидент.

Пов’язана документація

Section titled “Пов’язана документація”