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

Інцидент 2026-06-12 — abitly.org 503: видалені SSM-параметри заблокували запуск ECS-таски

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_URLNEXT_PUBLIC_* інлайнюються в клієнтський бандл під час next build; runtime-значення ні на що не впливає. Прод-колектора аналітики взагалі не існує (задеплоєний лише dev) — параметр виглядав «мертвим», що, ймовірно, і спровокувало клінап.
  • SENTRY_AUTH_TOKEN — рудимент: Sentry видалили з коду ще 2026-03-31 (abitly-frontend-v2@1ab331f2, «perf: remove Sentry»), тож на момент інциденту параметр не використовував ані білд, ані рантайм — але механізм buildspec (див. нижче) все одно зробив його launch-залежністю контейнера.

Але для ECS це неважливо: будь-який параметр зі списку secrets, якого немає в SSM, робить запуск таски неможливим.

ЧасПодіяСтан
11.06 22:01Деплой task-def :26 (образ 589c9d1) — штатно, таска працює🟢 baseline
12.06 02:28Зареєстровано :27 (образ 3414e42); сервіс лишився на :26🟢
14:13shura: DeleteParameter (dev-параметр) → AccessDenied — explicit deny групи abitly-deny-destructive🟢 guardrail спрацював
14:25shura: GetGroupPolicy/ListGroupsForUserRemoveUserFromGroup — вилучив сам себе з abitly-deny-destructive🟡 guardrail знято
14:55DeleteParameter /abitly/dev/frontend/NEXT_PUBLIC_ANALYTICS_COLLECTOR_URL — успіх🟡
15:02DeleteParameter ×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-parameterParameterNotFound → CloudTrail🟡 root cause знайдено
19:27Band-aid: зареєстровано :28 (= :27 мінус два секрети), update-service🟡
19:29Таска running + target healthy, abitly.org → 200🟢 resolved

Вікно впливу: ~84 хв повної недоступності фронтенду (усі сторінки, вечірній прайм-тайм). ~46 000 запитів отримали 5xx від ALB. MTTD ≈ 70 хв (виявлення людиною), від підтвердження до відновлення — 12 хв (сам фікс ~2 хв).

Як ECS injection секретів перетворив видалення параметра на відкладену бомбу

Section titled “Як ECS injection секретів перетворив видалення параметра на відкладену бомбу”

ECS-агент при запуску таски робить batch-виклик до SSM за всіма secrets із task-def і інжектить значення в env контейнера. Після старту таска до SSM не звертається. Тому:

  1. О 15:02 параметри зникли — працююча таска цього «не побачила» і жила ще ~3 години.
  2. О ~18:04 таска зупинилась — і кожна спроба ECS поставити заміну падала ще до запуску контейнера:
ResourceInitializationError: unable to pull secrets or registry auth:
… invalid ssm parameters: /abitly/prod/frontend/NEXT_PUBLIC_ANALYTICS_COLLECTOR_URL
  1. desired=1 → нуль резервування: смерть єдиної таски = повний даунтайм.

Пастка: ECS називає лише ПЕРШИЙ відсутній параметр

Section titled “Пастка: ECS називає лише ПЕРШИЙ відсутній параметр”

У помилці фігурує тільки NEXT_PUBLIC_ANALYTICS_COLLECTOR_URL. Насправді відсутніх було дваSENTRY_AUTH_TOKEN виявили лише звіркою повного списку secrets проти SSM. Якби відновили один параметр «за текстом помилки», запуск упав би на другому. Обов’язковий крок тріажу:

Terminal window
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, рантайму не потрібні → прибрати посилання»:

Terminal window
# 1. Взяти найсвіжішу ревізію (:27, образ 3414e42) і прибрати два «мертві» секрети
aws ecs describe-task-definition --task-definition abitly-prod-frontend:27 \
--query 'taskDefinition' --output json > td27.json
jq '
.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.json
aws 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/uk200
curl https://abitly.org/ru307 (очікуваний redirect)
ECS running/desired1/1, target group healthy
  • Зроблено 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 (TG abitly-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/* — deny ssm:DeleteParameter для всіх, крім адміна.
  • Прибрати build-time секрети з runtime secrets task-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 не постраждає).
  1. Що зупинило таску о ~18:04? CloudTrail StopTask — порожньо; логи /ecs/abitly-prod-frontend за 17:45–18:15 — порожні; Fargate on-demand (не Spot). Запис зупиненої таски в ECS уже недоступний (TTL ~1 год). Кандидати: OOM-kill, падіння процесу, Fargate maintenance. Якщо повториться — дивитись stoppedReason одразу.
  2. ALB-wide сплеск 5xx 14:30–15:00 (~2.6 тис.) — передує інциденту і ним не пояснюється (фронтенд-таска тоді ще працювала). Можливо, стосується іншого сервісу за тим самим ALB.

Ключові ідентифікатори

Section titled “Ключові ідентифікатори”
ПолеЗначення
Кластер/сервісabitly-prod-frontend / abitly-prod-frontend (Fargate, desired=1)
ALB / TGapp/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
CloudTrailDeleteParameter 15:02:06 +03 (shura) · RemoveUserFromGroup 14:25:41 +03 (shura → сам себе з abitly-deny-destructive) · AccessDenied-спроба 14:13:31
IAM-група guardrailabitly-deny-destructive
  1. Видалення SSM-параметра — відкладена бомба. Працюючі таски тримають секрети в пам’яті; вибух стається при першому ж рестарті — тут через 3 години, могло бути через тиждень. «Сайт працює після видалення» нічого не доводить.
  2. ECS повідомляє лише перший невалідний параметр. Завжди звіряти повний список secrets проти SSM (команда вище), інакше фікс ітеративний: полагодив один — упав на наступному.
  3. Build-time значення в runtime secrets — анти-патерн. Кожне зайве посилання — це ще одна залежність, здатна заблокувати запуск контейнера, не даючи нічого в рантаймі.
  4. Guardrail без захисту від self-removal — не guardrail. Explicit deny відпрацював ідеально і був знятий за 12 хвилин самим користувачем. Реальний контроль — permissions boundary/SCP, яких користувач сам не знімає.
  5. desired=1 без алертів = виявлення людиною. 70 хв MTTD у вечірній прайм-тайм. Один CloudWatch-алярм на HealthyHostCount коштує копійки і зводить це до хвилин.

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

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