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

Інцидент 2026-05-28 — Strapi 502 під час переключення на Cloudflare Access

Переключення 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.

ЧасПодіяСтан
~15:40Pipeline 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:443connection refused🔴 incident start
~15:58Manual 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:20Push (eb4fd33) → pipeline 666f225b впав на Source: No Commit found (транзієнт CodeStar↔GitHub)🔴
~16:33Rerun → 3ab7e19a Succeeded. /api=200, /_health=204, /admin=302, пряме до EIP → TLS handshake reject (mTLS працює)🟢 resolved

Вікно впливу: ~43 хв. Адмін-панель — навмисно недоступна (це й була мета переключення); постраждав публічний API для фронтенду Abitly.org.

Спрощено логіка завантаження env з SSM в контейнері виглядала так:

Terminal window
set -eu
aws 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"
done
exec node ./node_modules/.bin/strapi start

Це працює тільки якщо всі значення однорядкові. PEM — багаторядковий за специфікацією: BEGIN CERTIFICATE / base64-блоки по 64 символи / END CERTIFICATE, розділені \n. SSM зберігає String-параметри as-is, включно з переносами рядків.

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="". Далі:

Terminal window
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»:

ECR-репозиторій abitly/strapi створено з IMAGE_TAG_MUTABILITY: IMMUTABLE. Тег у CI — це commit SHA. Перший manual rerun pipeline на тому ж SHA → docker pushImageTagAlreadyExistsException (HTTP 400) → CodeBuild step exit ≠ 0 → pipeline зупиняється до Deploy. Наслідок: не можна було «просто перезапустити» — треба було або новий коміт, або інший шлях.

Фікс: buildspec.yml тепер aws ecr describe-images --image-ids imageTag=$SHA → якщо є, пропустити build/push, перейти одразу до Deploy зі наявним артефактом.

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 “Виправлення (що реально спрацювало)”
  1. 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'

    Це мінімальний тимчасовий фікс. Дивись «Превентивні заходи» нижче для системного рішення.

  2. buildspec.yml — ідемпотентний build/push

    Terminal window
    if aws ecr describe-images --repository-name abitly/strapi \
    --image-ids imageTag=$CODEBUILD_RESOLVED_SOURCE_VERSION >/dev/null 2>&1; then
    echo "Image already exists, skipping build/push"
    else
    docker buildx build --platform linux/arm64 --push -t $ECR/abitly/strapi:$SHA .
    fi
  3. Retry CodePipeline після CodeStar transient (без коду).

Фінальна перевірка:

ПеревіркаРезультат
curl https://cms.abitly.org/api через CF200
curl https://cms.abitly.org/_health через CF204
curl https://cms.abitly.org/admin через CF302 → Access
Прямо до EIP 3.126.87.23:443 без CF certTLS handshake refused (mTLS правильно блокує)
  • Винести 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 events exit-code != 0 з threshold > 3 за 5 хв.
  • Staging-перевірка для CF Access/AOP — або тимчасовий cms-staging.abitly.org, або dry-run у dev SSM-просторі.
  • Документувати: запуск CodePipeline на вже зібраному коміті тепер покладається на ідемпотентний шлях у buildspec.yml.
  • Перевірити повний адмін-флоу в браузері (CF Access one-time PIN → Strapi admin login → редагування контенту).

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

Section titled “Ключові ідентифікатори”
ПолеЗначення
Prod EC2i-06c688bb89a71415f (EIP 3.126.87.23)
ECR repo952854879948.dkr.ecr.eu-central-1.amazonaws.com/abitly/strapi (immutable)
Pipelineabitly-prod-strapi (CodeBuild abitly-prod-strapi-build)
Резолюція pipeline-execution3ab7e19a-8566-441e-a430-1e06207850fd
SSM cert-простір (до фіксу)/abitly/prod/strapi/{ORIGIN_CERT,ORIGIN_KEY,ORIGIN_PULL_CA}
CF Access team domainmute-frog-0d0b.cloudflareaccess.com
  1. SSM get-parameters-by-path + export у shell — небезпечна комбінація, якщо в namespace можуть з’являтись багаторядкові значення. Це не специфіка Strapi — будь-який сервіс із таким patternом entrypoint-у вразливий. Перевірити abitly-api і studsearch-backend entrypoint.sh теж.
  2. Immutable ECR + retry без ідемпотентності в buildspec — гарантована пастка. Якщо вмикаєте immutability, одночасно вмикайте describe-images short-circuit.
  3. Eventual consistency CodeStar↔GitHub — нормально для першої спроби після push. Не панікувати, retry через хвилину.
  4. Layered security рятує під час інциденту: Cloudflare Access на admin зберіг адмін-сесії від компрометації, поки публічний API лежав. AOP/mTLS усе ще працювали — прямі запити до origin блокувались. Падіння було «нагорі» (контейнер), не в безпековому шарі.

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

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