Інцидент 2026-06-12 — abitly.org 503: видалені SSM-параметри заблокували запуск ECS-таски
Контекст
Section titled “Контекст”Abitly Web — Next.js SSR на ECS Fargate: кластер abitly-prod-frontend, одна таска (desired=1) за ALB abitly-prod-shared, попереду Cloudflare (orange-cloud). Env-змінні контейнер отримує як ECS secrets зі SSM /abitly/prod/frontend/* — 14 параметрів у task-def. ECS-агент читає їх лише в момент запуску таски.
Серед цих 14 два параметри насправді потрібні тільки на етапі збирання образу, а не в рантаймі:
NEXT_PUBLIC_ANALYTICS_COLLECTOR_URL—NEXT_PUBLIC_*інлайнюються в клієнтський бандл під часnext build; runtime-значення ні на що не впливає. Прод-колектора аналітики взагалі не існує (задеплоєний лише dev) — параметр виглядав «мертвим», що, ймовірно, і спровокувало клінап.SENTRY_AUTH_TOKEN— рудимент: Sentry видалили з коду ще 2026-03-31 (abitly-frontend-v2@1ab331f2, «perf: remove Sentry»), тож на момент інциденту параметр не використовував ані білд, ані рантайм — але механізм buildspec (див. нижче) все одно зробив його launch-залежністю контейнера.
Але для ECS це неважливо: будь-який параметр зі списку secrets, якого немає в SSM, робить запуск таски неможливим.
Хронологія (UTC+3)
Section titled “Хронологія (UTC+3)”| Час | Подія | Стан |
|---|---|---|
| 11.06 22:01 | Деплой task-def :26 (образ 589c9d1) — штатно, таска працює | 🟢 baseline |
| 12.06 02:28 | Зареєстровано :27 (образ 3414e42); сервіс лишився на :26 | 🟢 |
| 14:13 | shura: DeleteParameter (dev-параметр) → AccessDenied — explicit deny групи abitly-deny-destructive | 🟢 guardrail спрацював |
| 14:25 | shura: GetGroupPolicy/ListGroupsForUser → RemoveUserFromGroup — вилучив сам себе з abitly-deny-destructive | 🟡 guardrail знято |
| 14:55 | DeleteParameter /abitly/dev/frontend/NEXT_PUBLIC_ANALYTICS_COLLECTOR_URL — успіх | 🟡 |
| 15:02 | DeleteParameter ×2 prod: NEXT_PUBLIC_ANALYTICS_COLLECTOR_URL, SENTRY_AUTH_TOKEN. Працююча таска не постраждала — секрети вже в пам’яті | 🟡 бомба закладена |
| ~18:04 | Єдина таска :26 зупинилась (причина не встановлена — див. «Відкриті питання») | 🔴 incident start |
| 18:05 | Заміни падають: ResourceInitializationError: … invalid ssm parameters → 0 healthy targets → ALB 503 (пік ~1300 5xx/хв) | 🔴 |
| ~19:15 | Виявлено людиною (скриншот 503 у браузері). Жоден алерт не спрацював | 🔴 |
| 19:17 | Підтверджено (curl → 503 на всіх шляхах); діагностика: ECS events → get-parameter → ParameterNotFound → CloudTrail | 🟡 root cause знайдено |
| 19:27 | Band-aid: зареєстровано :28 (= :27 мінус два секрети), update-service | 🟡 |
| 19:29 | Таска running + target healthy, abitly.org → 200 | 🟢 resolved |
Вікно впливу: ~84 хв повної недоступності фронтенду (усі сторінки, вечірній прайм-тайм). ~46 000 запитів отримали 5xx від ALB. MTTD ≈ 70 хв (виявлення людиною), від підтвердження до відновлення — 12 хв (сам фікс ~2 хв).
Першопричина (deep-dive)
Section titled “Першопричина (deep-dive)”Як ECS injection секретів перетворив видалення параметра на відкладену бомбу
Section titled “Як ECS injection секретів перетворив видалення параметра на відкладену бомбу”ECS-агент при запуску таски робить batch-виклик до SSM за всіма secrets із task-def і інжектить значення в env контейнера. Після старту таска до SSM не звертається. Тому:
- О 15:02 параметри зникли — працююча таска цього «не побачила» і жила ще ~3 години.
- О ~18:04 таска зупинилась — і кожна спроба ECS поставити заміну падала ще до запуску контейнера:
ResourceInitializationError: unable to pull secrets or registry auth:… invalid ssm parameters: /abitly/prod/frontend/NEXT_PUBLIC_ANALYTICS_COLLECTOR_URL- desired=1 → нуль резервування: смерть єдиної таски = повний даунтайм.
Пастка: ECS називає лише ПЕРШИЙ відсутній параметр
Section titled “Пастка: ECS називає лише ПЕРШИЙ відсутній параметр”У помилці фігурує тільки NEXT_PUBLIC_ANALYTICS_COLLECTOR_URL. Насправді відсутніх було два — SENTRY_AUTH_TOKEN виявили лише звіркою повного списку secrets проти SSM. Якби відновили один параметр «за текстом помилки», запуск упав би на другому. Обов’язковий крок тріажу:
comm -23 \ <(aws ecs describe-task-definition --task-definition abitly-prod-frontend \ --query 'taskDefinition.containerDefinitions[0].secrets[].valueFrom' \ --output text | tr '\t' '\n' | sort) \ <(aws ssm get-parameters-by-path --path /abitly/prod/frontend/ --recursive \ --query 'Parameters[].Name' --output text | tr '\t' '\n' | sort)# → повний список параметрів, яких вимагає task-def, але немає в SSMЧому ALB віддавав саме 503, і чому Cloudflare його не «перекрасив»
Section titled “Чому ALB віддавав саме 503, і чому Cloudflare його не «перекрасив»”Target group лишилась без жодної цілі → ALB згенерував власну сторінку 503 Service Temporarily Unavailable (той самий serif-шрифт на скриншоті). Origin (ALB) при цьому живий і відповідає — тому Cloudflare чесно проксіює відповідь origin as-is (server: cloudflare, cf-cache-status: DYNAMIC), а не показує 521/523. Це підтверджує правило з service-down: 5xx «від Cloudflare» ≠ проблема Cloudflare.
Чому rolloutState: COMPLETED при running: 0 — не парадокс
Section titled “Чому rolloutState: COMPLETED при running: 0 — не парадокс”Деплой :26 завершився ще 11.06 — rollout «виконано». Падіння таски після успішного деплою не змінює стан деплою: сервіс-шедулер просто нескінченно намагається підтримати desiredCount, і кожна спроба падає на секретах. Тому «COMPLETED + running 0 + події про failed placement» — типовий підпис саме цього класу відмов.
IAM: guardrail, який можна зняти самому
Section titled “IAM: guardrail, який можна зняти самому”Перша спроба видалення (14:13) була заблокована explicit deny з identity-based політики групи abitly-deny-destructive — механізм спрацював як задумано. Але користувач мав право iam:RemoveUserFromGroup і о 14:25 вилучив сам себе з deny-групи, після чого видалення пройшли. Guardrail, який захищуваний може зняти самостійно, — це підказка, а не контроль: потрібен permissions boundary / SCP або заборона self-removal.
Чому це не зловили раніше
Section titled “Чому це не зловили раніше”| Можлива гарантія | Чи була | Чому не спрацювала |
|---|---|---|
| IAM explicit deny на руйнівні дії | ✅ була (abitly-deny-destructive) | Користувач сам вилучив себе з групи; self-removal не заборонений |
Алерт на HealthyHostCount = 0 / сплеск ELB 5xx | ❌ ні | Grafana/Telegram-алерти не покривають ALB-метрики фронтенду — 70 хв до виявлення людиною |
| ECS deployment circuit breaker | ❌ вимкнений | Тут не відкотив би (це не деплой), але дав би ранній сигнал у events |
| Перевірка споживачів перед видаленням параметра | ❌ процесу немає | Ніхто не звірив, чи посилаються task-def-и на параметр (а посилались: :26 і :27) |
| Резервування таски (desired ≥ 2) | ❌ desired=1 | Смерть єдиної таски одразу = повний даунтайм |
Виправлення (що реально спрацювало)
Section titled “Виправлення (що реально спрацювало)”Band-aid за принципом «обидва параметри build-time-only, рантайму не потрібні → прибрати посилання»:
# 1. Взяти найсвіжішу ревізію (:27, образ 3414e42) і прибрати два «мертві» секретиaws ecs describe-task-definition --task-definition abitly-prod-frontend:27 \ --query 'taskDefinition' --output json > td27.jsonjq ' .containerDefinitions[0].secrets |= map(select(.name != "NEXT_PUBLIC_ANALYTICS_COLLECTOR_URL" and .name != "SENTRY_AUTH_TOKEN")) | del(.taskDefinitionArn, .revision, .status, .requiresAttributes, .compatibilities, .registeredAt, .registeredBy, .deregisteredAt)' td27.json > td28.json
# 2. Зареєструвати :28 і перевести сервісaws ecs register-task-definition --cli-input-json file://td28.jsonaws ecs update-service --cluster abitly-prod-frontend --service abitly-prod-frontend \ --task-definition abitly-prod-frontend:28Результат: таска running за ~30 с, target healthy, / і /uk → 200 (TTFB ~0.25 с) о 19:29.
Фінальна перевірка:
| Перевірка | Результат |
|---|---|
curl https://abitly.org/ | 200 |
curl https://abitly.org/uk | 200 |
curl https://abitly.org/ru | 307 (очікуваний redirect) |
ECS running/desired | 1/1, target group healthy |
Превентивні заходи
Section titled “Превентивні заходи”- Зроблено 12.06 ~23:10: обидва prod-параметри відтворені (
SENTRY_AUTH_TOKEN— новий organization auth token, валідований через Sentry API;NEXT_PUBLIC_ANALYTICS_COLLECTOR_URL— dev-колекторhttps://5l29l59we6.execute-api.eu-central-1.amazonaws.com), сервіс повернуто зі:28на:27— запуск пройшов без помилок (live-репетиція наступного деплою через CodePipeline). Старе значення Sentry-токена не знайдено ніде (CodeBuild/Amplify/локальні.env) — старий токен, якщо він ще існує в Sentry, варто відкликати. - Алерт
HealthyHostCount < 1(TGabitly-prod-frontend, 1 хв) + аномаліяHTTPCode_ELB_5XX_Count→ Telegram-канал алертів. Закриває 70-хвилинну дірку у виявленні. - Увімкнути ECS deployment circuit breaker (+ rollback) для prod-сервісів.
- IAM: повернути
shuraуabitly-deny-destructive; заборонити self-removal з deny-груп (permissions boundary або deny наiam:RemoveUserFromGroup/iam:Detach*для не-адмінів); прод-простір/abitly/prod/*— denyssm:DeleteParameterдля всіх, крім адміна. - Прибрати build-time секрети з runtime
secretstask-def-ів назавжди. Корінь знайдено:buildspec.yml(рядки 73–81) автогенерує список secrets з усіх параметрів під/abitly/prod/frontend/— будь-який параметр шляху стає launch-залежністю контейнера, навіть якщо його ніхто не читає. Фікс: відфільтруватиNEXT_PUBLIC_*іSENTRY_AUTH_TOKENізSECRETS_JSON(вони споживаються лише через.env.productionна стадіїyarn build). Фікс у PR abitly-frontend-v2#425 (разом із поверненням Sentry). - Процес видалення параметрів: перед
DeleteParameter— звірка споживачів (describe-task-definition … secrets), мінімум для/abitly/prod/*. - Розглянути
desiredCount: 2для prod-фронтенду (або свідомо прийняти ризик single-task). - Зроблено 12.06: dev-параметр
/abitly/dev/frontend/NEXT_PUBLIC_ANALYTICS_COLLECTOR_URLвідтворено (наступний Amplify dev-build не постраждає).
Відкриті питання
Section titled “Відкриті питання”- Що зупинило таску о ~18:04? CloudTrail
StopTask— порожньо; логи/ecs/abitly-prod-frontendза 17:45–18:15 — порожні; Fargate on-demand (не Spot). Запис зупиненої таски в ECS уже недоступний (TTL ~1 год). Кандидати: OOM-kill, падіння процесу, Fargate maintenance. Якщо повториться — дивитисьstoppedReasonодразу. - ALB-wide сплеск 5xx 14:30–15:00 (~2.6 тис.) — передує інциденту і ним не пояснюється (фронтенд-таска тоді ще працювала). Можливо, стосується іншого сервісу за тим самим ALB.
Ключові ідентифікатори
Section titled “Ключові ідентифікатори”| Поле | Значення |
|---|---|
| Кластер/сервіс | abitly-prod-frontend / abitly-prod-frontend (Fargate, desired=1) |
| ALB / TG | app/abitly-prod-shared/f6f542cf982c77a9 / targetgroup/abitly-prod-frontend/86f94ddb7f1349c2 |
| Task-def | :26 (інцидент, образ 589c9d1) · :27 (стандартна, живий — повернуто 12.06 ~23:10) · :28 band-aid (служив 19:27–23:10) |
| Видалені параметри | /abitly/prod/frontend/{NEXT_PUBLIC_ANALYTICS_COLLECTOR_URL, SENTRY_AUTH_TOKEN} + /abitly/dev/frontend/NEXT_PUBLIC_ANALYTICS_COLLECTOR_URL |
| CloudTrail | DeleteParameter 15:02:06 +03 (shura) · RemoveUserFromGroup 14:25:41 +03 (shura → сам себе з abitly-deny-destructive) · AccessDenied-спроба 14:13:31 |
| IAM-група guardrail | abitly-deny-destructive |
- Видалення SSM-параметра — відкладена бомба. Працюючі таски тримають секрети в пам’яті; вибух стається при першому ж рестарті — тут через 3 години, могло бути через тиждень. «Сайт працює після видалення» нічого не доводить.
- ECS повідомляє лише перший невалідний параметр. Завжди звіряти повний список
secretsпроти SSM (команда вище), інакше фікс ітеративний: полагодив один — упав на наступному. - Build-time значення в runtime
secrets— анти-патерн. Кожне зайве посилання — це ще одна залежність, здатна заблокувати запуск контейнера, не даючи нічого в рантаймі. - Guardrail без захисту від self-removal — не guardrail. Explicit deny відпрацював ідеально і був знятий за 12 хвилин самим користувачем. Реальний контроль — permissions boundary/SCP, яких користувач сам не знімає.
- desired=1 без алертів = виявлення людиною. 70 хв MTTD у вечірній прайм-тайм. Один CloudWatch-алярм на
HealthyHostCountкоштує копійки і зводить це до хвилин.
Пов’язана документація
Section titled “Пов’язана документація”- Abitly Web service card — рантайм, env, деплой
- Service down — first-look triage
- Deploy rollback
- Environments / SSM — простори параметрів
- Deploy pipeline — CodePipeline фронтенду
- Abitly Analytics — чому prod-колектора не існує
- Інцидент 2026-05-28 — попередній SSM-related інцидент (інший клас: багаторядкові значення)