Інцидент 2026-05-28 — Strapi 502 під час переключення на Cloudflare Access
Контекст
Section titled “Контекст”Переключення cms.abitly.org на жорсткіший security-режим:
- Cloudflare → orange-cloud (проксі) замість DNS-only
- Cloudflare Access на
/admin(Zero Trust) - mTLS Authenticated Origin Pulls — origin приймає тільки сертифікат CF
- Власний Cloudflare Origin CA-сертифікат, який обслуговує Caddy перед Strapi
- Перенесення збірки/деплою з GitHub Actions → AWS CodePipeline
Деталі сервісу → Strapi · інфра → domains-dns · pipeline → deploy-pipeline.
Хронологія (UTC+3)
Section titled “Хронологія (UTC+3)”| Час | Подія | Стан |
|---|---|---|
| ~15:40 | Pipeline e141874d (push) — успіх | 🟢 baseline |
| ~15:45 | Переключення CF: orange-cloud + Access + AOP + Origin CA. Швидка перевірка через CF: /api=200, /admin=302 | 🟢 OK |
| ~15:50 | Публічний API → 521 (CF не може дістатись origin). Прямий запит до EIP 3.126.87.23:443 → connection refused | 🔴 incident start |
| ~15:58 | Manual rerun pipeline 6fb44b77 → впав на Build через immutable ECR (тег вже існує) | 🔴 |
| — | Швидке виправлення: ідемпотентність buildspec.yml — пропустити build/push, якщо тег є | 🟡 |
| ~16:05 | Ручний прямий деплой через SSM RunCommand (2cf5d63f) наявного образу → Caddy піднявся, але Strapi в рестарт-лупі → 502 | 🔴 |
| ~16:10 | Логи: entrypoint.sh: export: line 10: MIIEvQIB…: bad variable name; Caddy: dial tcp: lookup strapi … no such host. Знайдено першопричину. | 🟡 |
| — | Фікс entrypoint.sh: виключити cert-параметри Caddy з завантаження SSM | 🟡 |
| ~16:20 | Push (eb4fd33) → pipeline 666f225b впав на Source: No Commit found (транзієнт CodeStar↔GitHub) | 🔴 |
| ~16:33 | Rerun → 3ab7e19a Succeeded. /api=200, /_health=204, /admin=302, пряме до EIP → TLS handshake reject (mTLS працює) | 🟢 resolved |
Вікно впливу: ~43 хв. Адмін-панель — навмисно недоступна (це й була мета переключення); постраждав публічний API для фронтенду Abitly.org.
Першопричина (deep-dive)
Section titled “Першопричина (deep-dive)”Що зробив entrypoint.sh
Section titled “Що зробив entrypoint.sh”Спрощено логіка завантаження env з SSM в контейнері виглядала так:
set -euaws ssm get-parameters-by-path \ --path /abitly/prod/strapi --recursive --with-decryption \ --query 'Parameters[].[Name,Value]' --output text \ | while read -r name value; do key="${name##*/}" export "$key=$value" doneexec node ./node_modules/.bin/strapi startЦе працює тільки якщо всі значення однорядкові. PEM — багаторядковий за специфікацією: BEGIN CERTIFICATE / base64-блоки по 64 символи / END CERTIFICATE, розділені \n. SSM зберігає String-параметри as-is, включно з переносами рядків.
Чому export падає на PEM
Section titled “Чому export падає на PEM”get-parameters-by-path --output text повертає одну рядок-запис на параметр: Name<TAB>Value. Але Value PEM-у містить буквальні \n, тому AWS CLI виводить це так:
/abitly/prod/strapi/ORIGIN_CERT -----BEGIN CERTIFICATE-----MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC......-----END CERTIFICATE-----/abitly/prod/strapi/ORIGIN_KEY -----BEGIN PRIVATE KEY-----...while read -r name value парсить порядково. Перший рядок: name=/abitly/prod/strapi/ORIGIN_CERT, value=-----BEGIN CERTIFICATE-----. Другий рядок (MIIEvQIB…) тлумачиться як новий запис: name=MIIEvQIB…, value="". Далі:
export "MIIEvQIB...=−"Base64-токени містять + та /, які — невалідні символи в імені shell-змінної (POSIX 3.231: [a-zA-Z_][a-zA-Z0-9_]*). Bash повертає bash: export: 'MIIEvQIB...': not a valid identifier (exit code 1).
Чому це повалило весь контейнер
Section titled “Чому це повалило весь контейнер”set -eu — кожен ненульовий exit код вбиває скрипт. export з невалідним ім’ям = ненульовий. Скрипт виходить до exec node strapi start. Docker restart: unless-stopped (або ECS task) перезапускає контейнер — і він знову падає на тому ж рядку. Це класичний crash-loop.
Чому Caddy віддавав 502, а не 503/timeout
Section titled “Чому Caddy віддавав 502, а не 503/timeout”Caddy і Strapi жили в одній Docker-мережі, Caddy робить reverse_proxy strapi:1337. Поки Strapi-контейнер у рестарт-лупі:
- Між спробами Docker сервісний DNS не резолвить
strapi→ Caddy лог:dial tcp: lookup strapi: no such host - Caddy це конвертує в HTTP 502 Bad Gateway до клієнта (CF)
- CF, своєю чергою, проксює 502 далі (бо origin таки відповів, просто погано) — або апгрейдить до 521, якщо TCP-конекшн до origin зривався під час handshake
Зовнішній спостерігач бачив суміш 502/521 залежно від моменту запиту.
Маскувальні фактори (чому це тривало 40 хв)
Section titled “Маскувальні фактори (чому це тривало 40 хв)”Кожен наступний шар маскував попередній — типовий «onion of failures»:
1. Immutable ECR блокував retry
Section titled “1. Immutable ECR блокував retry”ECR-репозиторій abitly/strapi створено з IMAGE_TAG_MUTABILITY: IMMUTABLE. Тег у CI — це commit SHA. Перший manual rerun pipeline на тому ж SHA → docker push → ImageTagAlreadyExistsException (HTTP 400) → CodeBuild step exit ≠ 0 → pipeline зупиняється до Deploy. Наслідок: не можна було «просто перезапустити» — треба було або новий коміт, або інший шлях.
Фікс:
buildspec.ymlтеперaws ecr describe-images --image-ids imageTag=$SHA→ якщо є, пропустити build/push, перейти одразу до Deploy зі наявним артефактом.
2. CodeStar↔GitHub transient
Section titled “2. CodeStar↔GitHub transient”CodeStarSourceConnection після start-pipeline-execution робить запит до GitHub API за заданим SHA. Є вікно ~секунди-хвилини після git push, коли SHA вже в refs/heads/main, але не реплікувався у внутрішні fan-out replicas GitHub, які бачить connection. → [GitHub] No Commit [eb4fd33…] found. Це не баг — це eventual consistency.
Mitigation: проста повторна команда
start-pipeline-executionчерез ~1 хв спрацювала.
3. Швидка перевірка після CF-перемикання дала false-positive
Section titled “3. Швидка перевірка після CF-перемикання дала false-positive”Між кроком «orange-cloud + Access» і моментом, коли Strapi-контейнер реально упав, був короткий вікно «все працює» (контейнер ще не перезавантажувався після зміни SSM — або SSM ще не оновлено в момент перевірки). Перевірка /api=200 за CF підтвердила одне (CF↔origin TLS), але не перевірила цикл рестарту контейнера.
Чому це не зловили раніше
Section titled “Чому це не зловили раніше”| Можлива гарантія | Чи була | Чому не спрацювала |
|---|---|---|
| Staging-перевірка переключення CF Access | ❌ ні | Немає окремого cms-staging.abitly.org — переключення тестувалось одразу на prod |
| Health-check на TCP до strapi:1337 у Caddy | ⚠️ частково | Health-check був, але не блокував rollout (немає readiness gate в Docker) |
| Алерт на crash-loop EC2 контейнера | ❌ ні | CloudWatch для контейнерних exit codes на EC2 не налаштовано (це не ECS) |
Pre-prod валідація entrypoint.sh на багаторядкових SSM | ❌ ні | Ніхто не очікував багаторядкових значень в SSM-просторі сервісу |
Виправлення (що реально спрацювало)
Section titled “Виправлення (що реально спрацювало)”-
entrypoint.sh— виключити cert-параметри з SSM-завантаження (вирішальне)Terminal window # ДО:aws ssm get-parameters-by-path --path /abitly/prod/strapi --recursive ...# ПІСЛЯ: відфільтрувати ORIGIN_CERT / ORIGIN_KEY / ORIGIN_PULL_CA| grep -vE '/(ORIGIN_CERT|ORIGIN_KEY|ORIGIN_PULL_CA)\b'Це мінімальний тимчасовий фікс. Дивись «Превентивні заходи» нижче для системного рішення.
-
buildspec.yml— ідемпотентний build/pushTerminal window if aws ecr describe-images --repository-name abitly/strapi \--image-ids imageTag=$CODEBUILD_RESOLVED_SOURCE_VERSION >/dev/null 2>&1; thenecho "Image already exists, skipping build/push"elsedocker buildx build --platform linux/arm64 --push -t $ECR/abitly/strapi:$SHA .fi -
Retry CodePipeline після CodeStar transient (без коду).
Фінальна перевірка:
| Перевірка | Результат |
|---|---|
curl https://cms.abitly.org/api через CF | 200 |
curl https://cms.abitly.org/_health через CF | 204 |
curl https://cms.abitly.org/admin через CF | 302 → Access |
Прямо до EIP 3.126.87.23:443 без CF cert | TLS handshake refused (mTLS правильно блокує) |
Превентивні заходи
Section titled “Превентивні заходи”- Винести cert-параметри Caddy у власний SSM-простір (
/abitly/prod/caddy/{ORIGIN_CERT,ORIGIN_KEY,ORIGIN_PULL_CA}), додати EC2 IAM read-only на нього. Прибрати з/abitly/prod/strapi/. Усуває весь клас колізій namespace-level. - Захистити
entrypoint.shвід багаторядкових значень загалом — або:- перейти на
aws ssm get-parameters-by-path --output json | jq(json-safe для багаторядкових); - або skip значення, що містять
\n([[ "$value" == *$'\n'* ]] && continue).
- перейти на
- SG-блокування :443 на origin до діапазонів IP Cloudflare (
terraform applyчекає). Ешелон поверх mTLS. - Алерт на crash-loop EC2-контейнера — CloudWatch metric filter на
docker eventsexit-code != 0 з threshold > 3 за 5 хв. - Staging-перевірка для CF Access/AOP — або тимчасовий
cms-staging.abitly.org, або dry-run уdevSSM-просторі. - Документувати: запуск CodePipeline на вже зібраному коміті тепер покладається на ідемпотентний шлях у
buildspec.yml. - Перевірити повний адмін-флоу в браузері (CF Access one-time PIN → Strapi admin login → редагування контенту).
Ключові ідентифікатори
Section titled “Ключові ідентифікатори”| Поле | Значення |
|---|---|
| Prod EC2 | i-06c688bb89a71415f (EIP 3.126.87.23) |
| ECR repo | 952854879948.dkr.ecr.eu-central-1.amazonaws.com/abitly/strapi (immutable) |
| Pipeline | abitly-prod-strapi (CodeBuild abitly-prod-strapi-build) |
| Резолюція pipeline-execution | 3ab7e19a-8566-441e-a430-1e06207850fd |
| SSM cert-простір (до фіксу) | /abitly/prod/strapi/{ORIGIN_CERT,ORIGIN_KEY,ORIGIN_PULL_CA} |
| CF Access team domain | mute-frog-0d0b.cloudflareaccess.com |
- SSM
get-parameters-by-path+exportу shell — небезпечна комбінація, якщо в namespace можуть з’являтись багаторядкові значення. Це не специфіка Strapi — будь-який сервіс із таким patternом entrypoint-у вразливий. Перевіритиabitly-apiіstudsearch-backendentrypoint.shтеж. - Immutable ECR + retry без ідемпотентності в buildspec — гарантована пастка. Якщо вмикаєте immutability, одночасно вмикайте
describe-imagesshort-circuit. - Eventual consistency CodeStar↔GitHub — нормально для першої спроби після push. Не панікувати, retry через хвилину.
- Layered security рятує під час інциденту: Cloudflare Access на admin зберіг адмін-сесії від компрометації, поки публічний API лежав. AOP/mTLS усе ще працювали — прямі запити до origin блокувались. Падіння було «нагорі» (контейнер), не в безпековому шарі.
Пов’язана документація
Section titled “Пов’язана документація”- Strapi service card — env, рантайм, типові проблеми
- Deploy pipeline — CodePipeline для Strapi
- Domains & DNS — CF AOP, origin cert ротація
- Service down — first-look triage
- Deploy rollback