24-cloudflare — отказоустойчивый app-ingress через Cloudflare (mitdev cf)

☐ Опциональный, командный. В отличие от обычных модулей его не «ставят» одной галочкой в установщике — это набор подкоманд mitdev cf …, которыми узел вводится в отказоустойчивый ingress через Cloudflare: туннель (без входящих портов), DNS и Load Balancing — всё только через API Cloudflare, плюс два локальных демона (health-эндпоинт и tunnel-watchdog) для честного failover на уровне приложения.

Зачем нужен

Отказоустойчивость баз данных (HA.md) отвечает на вопрос «переживёт ли смерть узла состояние». Этот модуль отвечает на другой: как пользователи попадут на живой узел приложения, когда один из узлов лёг или начал обслуживать ошибки — и как убрать больной узел из-под трафика, не открывая при этом ни одного входящего порта наружу.

Идею проще всего понять через три проблемы, которые она закрывает:

  1. Не нужно выставлять узлы в интернет. cloudflared держит исходящее соединение к сети Cloudflare (edge) и принимает трафик оттуда. На узле можно закрыть входящие 80/443 полностью — снаружи не видно ни одного открытого порта, а сайт при этом работает.
  2. «Серый отказ» становится виден. Узел, у которого жив веб-сервер, но сломан путь к БД или Redis, отдаёт пользователю ошибки, оставаясь «живым» для простого пинга. Локальный health-эндпоинт отвечает 200 только если живы app И БД И Redis — иначе 503. По этому сигналу узел выводится из ротации.
  3. Failover без ручного вмешательства. Локальный tunnel-watchdog при устойчивом 503 останавливает cloudflared → узел исчезает из ротации Cloudflare за секунды. Cloudflare Load Balancer дублирует это со стороны edge (health-монитор). Восстановился health — узел возвращается.

Термины, которые встретятся ниже:

Картина целиком (режим по умолчанию lb-over-tunnel)

                     пользователи
                          │
                    app.example.com
                          │
              ┌───────────▼────────────┐
              │   Cloudflare (edge)     │
              │  Load Balancer          │
              │   └ pool                │
              │      ├ origin: туннель-1 │  health-монитор →
              │      ├ origin: туннель-2 │   Host: health.app.example.com
              │      └ origin: туннель-3 │   ждёт 200
              └───────┬──────┬──────┬────┘
        QUIC/7844     │      │      │   (исходящее от узлов; входящих портов нет)
        ┌─────────────┘      │      └─────────────┐
        ▼                    ▼                     ▼
   ┌──────────┐        ┌──────────┐         ┌──────────┐
   │  узел-1  │        │  узел-2  │         │  узел-3  │
   │cloudflared│       │cloudflared│        │cloudflared│
   │  nginx→app│       │  nginx→app│        │  nginx→app│
   │ health :8009 200/503 (loopback/WG, не наружу)      │
   │ tunnel-watchdog: 503×N → stop cloudflared          │
   └──────────┘        └──────────┘         └──────────┘

Каждый app-узел держит свой туннель; каждый туннель — origin в одном LB-пуле. Здоровье узла оценивается дважды: локально (tunnel-watchdog, снимает узел за ~15 с) и со стороны edge (health-монитор LB, второй эшелон, ~3 мин). Данные не фрагментируются — все узлы работают с общей БД (см. HA.md).

Три режима ingress (CF_INGRESS_MODE)

Режим Транспорт origin в пуле Когда
lb-over-tunnel (дефолт) туннель на каждый узел <uuid>.cfargotunnel.com Полный HA: нет входящих портов + health/гео/affinity от LB
tunnel-only туннель на каждый узел — (LB не используется) Один узел или round-robin по DNS; LB не нужен
lb-only публичные IP узлов публичный IP узла LB поверх узлов без туннелей

Команды согласованы с режимом: cf lb … в tunnel-only честно откажет (LB там не нужен), а origin-адреса в пуле различаются по режиму.

⚠️ lb-only и health-failover. В lb-only туннеля нет, а health-эндпоинт слушает loopback/WG и из сети Cloudflare недостижим — health-монитору некому увести пробу в него. Поэтому cf lb monitor/pool/create в режиме lb-only намеренно отказывают fail-loud (health-failover в этом режиме не обеспечить). Для полного HA используйте lb-over-tunnel.

Четыре варианта применения: tunnel и LB в разных сочетаниях

Три режима CF_INGRESS_MODE выше — это технический enum. На практике из них складываются четыре варианта применения: tunnel-only распадается на «один узел» и «несколько узлов через round-robin DNS», а «чистый LB без туннеля» существует лишь как отклоняемый режим lb-only. Матрица выбора:

Вариант Туннель LB Узлов Health-failover Входящие порты Поддержка в mitdev
1. lb-over-tunnel (рекоменд.) ✅ на каждом узле ✅ поверх туннелей 1..N ✅ два эшелона (watchdog + LB-монитор) закрыты полная
2. tunnel-only, один узел ✅ один 1 — (резерва нет) закрыты полная
3. tunnel-only + round-robin DNS ✅ на каждом узле ❌ (DNS-раскидка) 2..N ❌ мёртвый узел держит свою долю трафика закрыты частичная (LB нет; watchdog грубо смягчает)
4. LB по публичным IP без туннеля (lb-only) ✅ по публичным IP 1..N ❌ health-эндпоинт из edge недостижим открыты 80/443 cf lb … падает fail-loud

Вариант 1 — lb-over-tunnel (несколько узлов, полный HA) — по умолчанию

Туннель на каждом узле + Cloudflare Load Balancer поверх туннелей. Здоровье узла проверяется дважды: локальный tunnel-watchdog (15 с) и health-монитор LB со стороны edge (3 мин). Входящих портов на узлах нет. Это единственный вариант с настоящим health-failover между узлами. Полное развёртывание — в разделе «Развёртывание» ниже.

Работает и на одном узле (пул из одного origin) — но если узел один и рост не планируется, проще Вариант 2.

Вариант 2 — tunnel-only, один узел

Один туннель, одна DNS-запись на него, LB не нужен. Балансировать нечего, поэтому cf lb … в этом режиме честно откажет.

export CF_INGRESS_MODE=tunnel-only
sudo -E mitdev cf login
sudo mitdev cf health install        # опционально
sudo mitdev cf watchdog install      # опционально
sudo mitdev cf tunnel create app
sudo mitdev cf tunnel config app app.example.com
sudo mitdev cf tunnel run            # cloudflared + закрыть вход 80/443
sudo mitdev cf dns set app.example.com CNAME <tunnel-id>.cfargotunnel.com

cf dns set здесь обязателенlb-over-tunnel DNS-запись создаёт cf lb create). health/watchdog опциональны: при локальном отказе watchdog остановит cloudflared, и узел станет недоступен — резерва, куда увести трафик, в этом варианте нет.

Вариант 3 — tunnel-only + round-robin DNS (несколько узлов, без health-failover)

По туннелю на узел (как в Варианте 2), плюс несколько CNAME-записей на один хост — Cloudflare отдаёт их по очереди:

# на каждом узле — свой туннель в режиме tunnel-only
sudo mitdev cf dns set app.example.com CNAME <uuid-узел-1>.cfargotunnel.com
sudo mitdev cf dns set app.example.com CNAME <uuid-узел-2>.cfargotunnel.com
sudo mitdev cf dns set app.example.com CNAME <uuid-узел-3>.cfargotunnel.com

⚠️ Нет health-проверки узлов. Round-robin DNS не знает о здоровье origin'ов: при падении одного из трёх узлов ~1/3 запросов продолжит лететь на мёртвый узел, пока запись не убрать вручную (cf dns rm). Нет второго эшелона — того самого скрытого health-хоста, ради которого существует LB. cf watchdog install частично смягчает (остановит cloudflared → Cloudflare вернёт ошибку коннектора для этого туннеля), но это грубее health-failover и не ловит «серый отказ», когда туннель жив, а app отдаёт 503. Нужен настоящий межузловой failover — берите Вариант 1.

Вариант 4 — LB по публичным IP без туннеля (lb-only) — в mitdev не поддержан

«Чистый LB без туннеля» соответствует режиму lb-only, но команды cf lb monitor/pool/create в нём намеренно завершаются с ошибкой (_cf_lb_reject_lb_only). Причина структурная: origin пула — публичный IP узла, cloudflared на узле нет, а health-эндпоинт слушает loopback/WG и из сети Cloudflare недостижим. Увести пробу монитора (Host: health.<домен>) в скрытый health-хост без туннеля некому — она упрётся в публичный веб-сервер узла. Итог был бы один из двух плохих:

Чтобы не создавать ложное чувство HA, mitdev такую конструкцию не собирает. Режим lb-only остаётся в enum, но команды cf lb в нём падают fail-loud с подсказкой переключиться на lb-over-tunnel. Если туннель принципиально не нужен (узлы обязаны принимать трафик напрямую по публичным IP), Cloudflare Load Balancing настраивается вручную в аккаунте с публичным health-эндпоинтом — health-эндпоинт mitdev для этого не подходит (он не рассчитан торчать наружу).

Как выбрать вкратце: несколько узлов и нужен failover → Вариант 1; один узел → Вариант 2; несколько узлов, но простой части трафика на минуты допустим → Вариант 3; туннель недопустим по требованиям → LB руками вне mitdev (Вариант 4).

Скрытый health-хост (ключевое решение)

Health-эндпоинт по требованию безопасности слушает loopback или WG-адрес, никогда 0.0.0.0 — иначе карта здоровья узлов (коды 200/503 и причина отказа в теле) светила бы наружу и стала бы дешёвой точкой разведки и DoS на пути failover. Но тогда health-монитор Cloudflare, живущий в сети edge, не может достучаться до эндпоинта напрямую.

Решение: скрытый health-хост. В ingress-правила туннеля добавляется правило health.<домен> → http://localhost:CF_HEALTH_PORT перед правилом приложения. Health-монитор бьёт в origin-туннель, подставляя заголовок Host: health.<домен> — cloudflared по этому Host уводит пробу в health-эндпоинт, минуя приложение на 80-м порту. Публичной DNS-записи на health.<домен> не создаётся и быть не должно: снаружи он не резолвится, карта здоровья наружу не видна.

Имя health-хоста собирается ровно в одном месте кода (одна функция) — и для правила туннеля, и для заголовка монитора: разъехались бы они, проба ушла бы в приложение, и серый отказ снова стал бы невидимым.

Безопасность API-токена

Взаимодействие с Cloudflare идёт через curl к api.cloudflare.com/client/v4. Токен — самый чувствительный секрет модуля, и с ним обращаются строго:

Fail-loud и идемпотентность

Локальные демоны

health-эндпоинт узла

Персистентный socat-листенер (не socket-activation — чтобы частый опрос не упирался в start-limit systemd) на CF_HEALTH_PORT (8009), bind на CF_HEALTH_BIND_IP (loopback или WG-адрес). На каждый коннект проверяет локально:

Отвечает 200, только если все три живы; иначе 503 с указанием отказавшего компонента. Логика «любой отказ → 503» намеренна: узел с живым app, но мёртвым путём к БД обслуживает ошибки — для ротации он обязан выглядеть больным. Юнит бежит под User=nobody с NoNewPrivileges.

Проверки fail-closed: если pg_isready/redis-cli на узле не установлены, health отвечает 503. Поэтому cf health install ставит postgresql-client и redis-tools (на RHEL — postgresql/redis) наряду с socat.

tunnel-watchdog

Демон, который каждые CF_TUNNEL_CHECK_INTERVAL (5 с) бьёт curl в health-эндпоинт с таймаутом CF_TUNNEL_CURL_TIMEOUT (3 с) и по устойчивому сигналу управляет cloudflared:

Дебаунс (пороги, а не первый же промах) — против флаппинга: моргание mesh или единичный 503 не должны дёргать stop/start. Порог возврата ниже порога снятия (2 < 3): доступность важна, но не с первого же 200.

Различает 503 и «эндпоинт недоступен». 503 — это диагноз app-уровня (снимать после порога). А если сам эндпоинт не отвечает (не путать!) — это отдельный сигнал, он логируется, но по нему узел не снимается по ложной тревоге. И главное — watchdog снимает узел только по node-local отказу (мёртв локальный app). По общей зависимости (лежит общая БД/Redis) узел не снимается: иначе падение общего компонента вывело бы из ротации все узлы разом (анти-паттерн каскадного отказа).

Firewall: закрыть вход, не отрезав туннель

cf tunnel run закрывает входящие 80/443, но делает это в безопасном порядке:

  1. Сначала разрешает исходящие 443/tcp, 7844/tcp и 7844/udp (QUIC к edge + http2-фолбэк) — до закрытия входа. При дефолтной политике allow outgoing это no-op, но если оператор ужесточил исходящие, без этих правил туннель молча не поднимется.
  2. Ставит cloudflared, поднимает юнит и убеждается, что коннектор активен (systemctl is-active).
  3. Только теперь закрывает вход 80/443 — и подтверждает фактическое состояние по ufw status (не «удалили правило», а «правила больше нет»). Обратный порядок при неудачном старте туннеля оставил бы узел без веб-входа И без туннеля — полный отказ ingress.
  4. Откат каждого правила регистрируется до его удаления; SSH не трогается ни при каких условиях.

После закрытия 80/tcp перестаёт работать ACME HTTP-01 (модуль 08-certbot) — при ingress через туннель TLS терминирует Cloudflare, обычный сертификат узлу не нужен; об этом печатается предупреждение.

Подкоманды

Полные примеры — в ../COMMANDS.md. Кратко:

Подкоманда Что делает
cf login Проверить CF_API_TOKEN (/user/tokens/verify), сохранить token/account/zone в креды (600)
cf health install / remove Поднять/снять health-эндпоинт узла
cf watchdog install / remove Поднять/снять tunnel-watchdog
cf tunnel create <имя> Создать (или переиспользовать) remotely-managed туннель; id и connector-token → в креды
cf tunnel config <имя> <домен> [url] Записать ingress-правила туннеля в Cloudflare (health-хост + домен→app + catch-all 404)
cf tunnel run Поставить cloudflared, поднять юнит, закрыть вход 80/443
cf tunnel remove Снять коннектор и вернуть вход 80/443
cf dns set <имя> <тип> <значение> [proxied] Идемпотентно создать/обновить запись зоны (A/AAAA/CNAME/TXT)
cf dns rm <имя> <тип> Удалить запись (идемпотентно)
cf lb monitor <домен> Создать/обновить health-монитор (проба в скрытый health-хост)
cf lb pool <имя> <origin…> Создать/обновить пул origin'ов с монитором
cf lb create <домен> Создать/обновить балансировщик на зоне поверх пула
cf status Сводка: токен, туннели и их коннекторы, пулы и здоровье origin'ов, локально cloudflared/health/watchdog

cf status не врёт

status — единственная команда модуля, которой нельзя умирать на первом отказе API: оператор смотрит её именно во время инцидента, когда часть API/узлов уже лежит. Поэтому каждая секция ловит свой отказ сама, продолжает опрашивать остальные, и никогда не печатает «здорово» по секции, которую не смогла опросить («не разобрано» ≠ «здорово»). Код возврата 0 — только если всё опрошено и всё здорово; любой отказ опроса или нездоровый компонент → rc ≠ 0 (годится как гейт в мониторинге). Причины различены честно: «сеть», «Cloudflare деградировал (HTTP 5xx/429)», «токен невалиден», «креды недоступны без root» — это разные диагнозы, требующие разных действий.

Настройка

Переменная По умолчанию Влияние Когда менять
CF_API_TOKEN пусто scoped API-токен Cloudflare задаётся перед cf login
CF_ACCOUNT_ID пусто id аккаунта (нужен для tunnel/lb) перед tunnel/lb
CF_ZONE_ID пусто id зоны (нужен для dns/lb) перед dns/lb
CF_INGRESS_MODE lb-over-tunnel режим ingress (см. таблицу режимов) tunnel-only/lb-only под свою топологию
CF_HEALTH_PORT 8009 порт health-эндпоинта при конфликте порта
CF_HEALTH_BIND_IP 127.0.0.1 адрес bind health-эндпоинта (loopback/WG) WG-адрес, если монитор/сосед бьёт по mesh; никогда 0.0.0.0
CF_APP_PORT 80 порт локального фронта, который проверяет health если фронт не на 80
CF_HEALTH_HOST_PREFIX health префикс скрытого health-хоста <prefix>.<домен> при конфликте с реальным поддоменом
CF_TUNNEL_UNIT mitdev-cf-tunnel имя systemd-юнита cloudflared если cloudflared поставлен под именем cloudflared
CF_TUNNEL_FAIL_THRESHOLD 3 сколько 503 подряд до снятия узла реже/чаще снимать
CF_TUNNEL_RESTORE_THRESHOLD 2 сколько 200 подряд до возврата симметрия возврата
CF_TUNNEL_CHECK_INTERVAL 5 интервал опроса health, с плотность опроса
CF_TUNNEL_CURL_TIMEOUT 3 таймаут одной пробы, с < интервала
CF_LB_MONITOR_INTERVAL 60 период пробы LB-монитора, с второй эшелон, учащать незачем
CF_LB_MONITOR_TIMEOUT 5 таймаут пробы монитора, с < интервала
CF_LB_MONITOR_RETRIES 2 провалов подряд до «origin мёртв» анти-флаппинг

Развёртывание (режим по умолчанию, три узла на общей БД)

Предполагается: приватная сеть (MESH.md), общая БД доступна с каждого узла (кластер + pg proxy, см. HA.md), домен app.example.com в зоне Cloudflare. Токен в чат/на чужой сервер не передавайте — экспортируйте локально перед командами.

На каждом app-узле:

export CF_API_TOKEN=…  CF_ACCOUNT_ID=…  CF_ZONE_ID=…
sudo -E mitdev cf login                 # проверить токен, сохранить в креды

sudo mitdev cf health install           # health-эндпоинт (200 если app+БД+Redis живы)
sudo mitdev cf watchdog install         # tunnel-watchdog (снимает узел при 503)

sudo mitdev cf tunnel create узел-1                       # свой туннель на узел
sudo mitdev cf tunnel config узел-1 app.example.com       # ingress-правила в CF
sudo mitdev cf tunnel run                                 # cloudflared + закрыть вход 80/443

Один раз (с любого узла), собрать LB поверх туннелей:

# UUID туннелей узлов взять из вывода `cf tunnel create` или `cf status`
sudo mitdev cf lb monitor app.example.com
sudo mitdev cf lb pool app-pool <uuid-узел-1> <uuid-узел-2> <uuid-узел-3>
sudo mitdev cf lb create app.example.com

Проверка:

sudo mitdev cf status                   # токен, туннели/коннекторы, пул/origin'ы, локальные демоны

Проверка, что всё работает

sudo mitdev cf status                                    # сводная картина
systemctl is-active mitdev-cf-tunnel                     # cloudflared активен
systemctl is-active mitdev-cf-health mitdev-cf-tunnel-watchdog
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8009/   # 200 или 503

Снаружи (со стороны, не с узла): nmap показывает 80/443 закрытыми, а сайт при этом открывается — трафик идёт только через туннель.

⚠️ Перед продакшеном: чекпойнт живого Cloudflare

Весь модуль покрыт офлайн-тестами (API-вызовы — через подменяемую обёртку cf_api, локальные проверки — через стабы). Это проверяет логику, но не проверяет поведение настоящего Cloudflare. Несколько вещей закрываются только на живом аккаунте — прогоните их на стенде до боевого включения:

Частые ошибки

Симптом: cf login говорит «токен невалиден», хотя токен рабочий. Причина: Cloudflare ответил 5xx/429 (деградация/лимит), либо сетевой сбой. Решение: актуальный cf login/cf status различают эти случаи и в них токен не обвиняют. Если всё же «невалиден» — проверьте scope токена (список выше) и что он не истёк.

Симптом: после cf lb create сайт не открывается, все origin'ы «unhealthy». Причина: health-монитор не доходит до health-эндпоинта (проба уходит в приложение) — вероятно, вопрос живого CF про Host-override (см. чекпойнт), либо режим lb-only. Решение: проверьте cf status; для lb-only вспомните ограничение (health-failover там не обеспечить — используйте lb-over-tunnel).

Симптом: узел не выходит из ротации при сломанной БД. Причина: tunnel-watchdog не установлен, либо health отдаёт 200 (не проверяет реальный путь к БД). Решение: systemctl is-active mitdev-cf-tunnel-watchdog; curl 127.0.0.1:8009 — должен быть 503 при сломанной БД; проверьте CF_APP_PORT/порты pg-proxy/redis-proxy.

Симптом: health всегда 503, хотя app/БД/Redis живы. Причина: на узле нет pg_isready/redis-cli (fail-closed), либо health бьёт не в тот Redis (не proxy-порт), либо IPv6-bind без скобок. Решение: переустановите cf health install (ставит клиентов); проверьте REDIS_PROXY_PORT; для IPv6-bind — вопрос socat из чекпойнта.

Симптом: после cf tunnel run пропал ACME-сертификат / не продлевается. Причина: закрыт вход 80/tcp — ACME HTTP-01 больше не пройдёт. Решение: это ожидаемо: при ingress через туннель TLS терминирует Cloudflare. Если сертификат на узле всё же нужен — переведите продление на DNS-01.

Безопасность и продакшен

Откат и удаление

Модуль командный — «удаляется» снятием того, что ставилось:

sudo mitdev cf tunnel remove       # снять коннектор, ВЕРНУТЬ вход 80/443
sudo mitdev cf watchdog remove     # снять tunnel-watchdog
sudo mitdev cf health remove       # снять health-эндпоинт

Ресурсы в самом Cloudflare (туннель, DNS-записи, пул, монитор, балансировщик) удаляются через API/дашборд отдельно — mitdev их не сносит автоматически, чтобы случайный remove на одном узле не обрушил ingress остальных. Установка каждого локального демона регистрирует откат: при сбое установки юнит и файлы снимаются автоматически.

См. также