Модуль 7. Отказ зависимости: bulkhead, circuit breaker, деградация

Цель модуля: воспроизвести отказ партнёра, увидеть, как таймауты без ограничения параллелизма топят маршрут, и защитить сервис bulkhead'ом, circuit breaker'ом и управляемой деградацией.

Перед началом: таймауты и retry — go-concurrency m9; circuit breaker и bulkhead — go-concurrency m12; паттерны устойчивости — arch v2 m5. Инструменты: Docker Compose v2, Grafana, Prometheus, curl, make. Трудоёмкость: ~2 ч (разбор 45 мин · практика 60 мин).

🔥 Инцидент: «партнёр повис — и наш API перестал отвечать»

11:20. Клиенты жалуются, что проверка риска «висит»: страница заказа крутится, затем показывает ошибку. В Grafana p99 /risk вырос до ~5 с, панель «Партнёр: исходы вызовов» состоит из error, а /healthz отвечает за единицы миллисекунд.

Партнёр не вернул 500 и не оборвал соединение — он завис на 30 секунд. API ждёт до трёх секунд, получает таймаут контекста и возвращает 502. Но каждый новый запрос начинает новое ожидание, а старые ещё не завершились: двадцать воркеров создают очередь из «мёртвых» вызовов, и p99 складывается из таймаута и времени в очереди. «API перестал отвечать» — взгляд пользователя: он ходит только в /risk и не различает процесс и маршрут. Процесс жив, /healthz быстрый, но бизнес-операция недоступна: health-check проверяет процесс, а не зависимость.

📖 7.1. Почему таймаут — не защита

Таймаут ограничивает одно ожидание, а не число одновременных ожиданий. В хендлере стоит context.WithTimeout(r.Context(), 3*time.Second) — верхняя граница: вызов прерывается, клиент получает 502. Пока ожидание идёт, горутина, сокет и соединение заняты: при 20 запросах занято 20 горутин, при 200 — 200. Входящий поток не останавливается, и новые вызовы заведомо уходят в таймаут.

Таймаут отвечает «сколько ждать», но не «сколько ждать одновременно». Без второго ограничения он лишь превращает зависание в ошибку — и то не сразу: p99 /risk всё равно ~5 с из-за очереди. /healthz быстрый только потому, что не трогает партнёра. Retry усиливает эффект: повтор после таймаута порождает несколько вызовов на один запрос. Теория — go-concurrency m9 и go-concurrency m12.

📖 7.2. Bulkhead: ограничиваем число одновременных вызовов

Bulkhead — перегородка: отсек, в котором тонет партнёр, не затапливает остальной корабль. В стенде это семафор на 8 одновременных вызовов (PARTNER_BULKHEAD_LIMIT=8): вызов занимает слот, а девятый не ждёт, а сразу получает ErrBulkhead → 503.

Ключевое решение — отсутствие очереди: она лишь переносит ожидание, а память и горутины растут. Fail-fast отдаёт отказ за микросекунды и оставляет в полёте ровно восемь вызовов — это радиус поражения. При зависании партнёра восемь горутин провисят до таймаута, остальные запросы получат быстрый 503 degraded.

Bulkhead считается на процесс: у двух реплик api будет по восемь слотов, итого шестнадцать. Лимит подбирают от ёмкости партнёра; ориентир по закону Литтла — параллелизм ≈ RPS × latency. В норме восемь слотов держат сотни RPS, в отказе — ограничивают ущерб.

📖 7.3. Circuit breaker: перестаём бить в мёртвую зависимость

Bulkhead ограничивает параллелизм, но продолжает отправлять запросы в заведомо мёртвый сервис. Circuit breaker добавляет память о последних исходах:

СостояниеЧто делаетПереход
closedпропускает вызовы, считает ошибки5 ошибок подряд → open
openне вызывает партнёра, сразу ErrOpenчерез 2 с → halfOpen
halfOpenпропускает пробный вызовуспех → closed; ошибка → open

Порог — 5 ошибок подряд, cooldown — 2 секунды (PARTNER_COOLDOWN_MS=2000). Таймаут тоже считается ошибкой: после пяти неудач breaker открывается, и следующие запросы получают 503 degraded без сетевого вызова. Через две секунды пробный запрос проверяет, ожил ли партнёр: успех сбрасывает счётчик и закрывает breaker, ошибка снова открывает его на две секунды.

Состояние видно в /debug/partner как cb_state. Breaker живёт в памяти процесса: каждая реплика открывается независимо — защиты локальны, а не распределены. Breaker защищает обе стороны: мы не ждём мёртвую зависимость, а она не получает шторм повторных попыток.

📖 7.4. Деградация: 503 degraded вместо 500 и вместо зависания

Маршрут /risk/{id} различает два класса отказа:

  • ErrOpen и ErrBulkhead — локальное решение защит: 503 с телом {"error":"...","degraded":true};
  • ошибка или таймаут самого вызова партнёра — 502 с degraded:true.

500 здесь не используется: это код для нашей собственной ошибки, а не для чужого отказа. 503 говорит «сервис временно не может выполнить операцию», 502 — «шлюз получил плохой ответ от зависимости». Оба ответа быстрые и помечены degraded, чтобы UI показал «проверка риска недоступна» и предложил ручной сценарий.

Деградация — явный контракт: мы меняем полноту функции на предсказуемую задержку. Альтернативы — устаревший результат из кэша, очередь на проверку или ручной разбор; в стенде выбран самый честный вариант — быстрый отказ. Цена в том, что доля 5xx растёт, а loadgen считает 503 неуспехом, хотя для нас это штатный отказ. Смотрите на p99, код ответа и флаг degraded: управляемый 503 приходит за миллисекунды и не тянет очередь.

📖 7.5. Диагностика: исходы вызовов и состояние breaker'а

curl -s localhost:8088/debug/partner показывает состояние breaker'а: {"cb_state":"closed"}. Метрика partner_calls_total{result="ok|error|open|bulkhead"} показывает, чем закончились вызовы. Срез за минуту:

bash
curl -s localhost:9092/api/v1/query \
  --data-urlencode 'query=sum by (result) (rate(partner_calls_total[1m]))'
  • ok — партнёр ответил; в норме это почти весь трафик.
  • error — вызов дошёл до партнёра и упал: таймаут, 500, обрыв. В fail-7 растёт именно он.
  • open — breaker отклонил запрос до вызова партнёра; растёт после пяти ошибок и в течение cooldown.
  • bulkhead — все восемь слотов заняты, запрос отклонён семафором; растёт при воркерах больше лимита.

В make fail-7 защиты выключены: cb_state остаётся closed, open и bulkhead не растут, видны только error и p99 ~5 с. С защитами при зависшем партнёре — короткий всплеск error (пять вызовов), затем устойчивый open, а при 20 воркерах ещё и bulkhead; ok падает почти до нуля. После set=ok breaker ещё до двух секунд держит open, затем пробный вызов возвращает closed.

В Grafana смотрите панель «Партнёр: исходы вызовов» вместе с http_request_duration_seconds{route="/risk"} и http_requests_total{route="/risk",code=...}. Картина «p99 низкий, 5xx высокий, open/bulkhead растут» означает, что защита работает и мы платим деградацией.

📖 7.6. Компромиссы

  • Таймаут короткий vs длинный. Короткий освобождает горутину, но рубит медленные вызовы; длинный удерживает ресурсы и растит очередь.
  • Bulkhead малый vs большой. Малый лимит защищает, но чаще отказывает; большой пропускает больше параллелизма и быстрее насыщает партнёра.
  • Breaker чувствительный vs терпимый. Порог 5 и cooldown 2 с открываются быстро, но ловят случайные всплески; редкие ошибки дольше терпят реальный отказ.
  • Fail fast vs fallback. 503 честен и дёшев, но ухудшает UX; устаревший ответ или ручной сценарий лучше для бизнеса, но требует данных.
  • Локальные защиты vs общие. Состояние в процессе просто, но реплики открываются независимо; общий breaker в Redis сложнее и сам становится зависимостью.

💻 Практика

Все команды — из каталога polygon/; стенд поднят (make up && make seed). Время — ориентир для одного человека. Базовая норма на свежем стенде: curl -s -o /dev/null -w '%{http_code} %{time_total}s\n' localhost:8088/risk/1 даёт 200 за ~30 мс.

  1. Сломать (5 мин). Убедитесь, что партнёр завис, и запустите сценарий:
bash
curl "http://localhost:8091/admin/mode?set=hang"
make fail-7
curl -s -o /dev/null -w 'healthz: %{http_code} %{time_total}s\n' localhost:8088/healthz
curl -s localhost:8088/debug/partner

fail-7 пересоздаёт api с PARTNER_CB=false PARTNER_BULKHEAD=false и 20 секунд гоняет risk-профиль. Ожидаемо: p99=~5000ms, /healthz — единицы миллисекунд, cb_state — closed: защиты выключены.

  1. Красный чек (3 мин). make check-7 сам гоняет 15 секунд risk-нагрузки и требует p99 /risk ≤ 500 мс и /healthz ≤ 100 мс; p99 берётся из вывода loadgen, а не из Prometheus. Ожидаемо: CHECK FAIL: /risk медленнее 500ms — защиты не работают.

  2. Починить (2 мин).

bash
make fix-7
make check-7

fix-7 возвращает партнёра в ok, включает PARTNER_CB=true PARTNER_BULKHEAD=true и повторяет нагрузку; check-7 — зелёный.

  1. Упражнение: защиты под отказом (15 мин). Снова зависьте партнёра и нагрузите api уже с защитами:
bash
curl "http://localhost:8091/admin/mode?set=hang"
make load-risk
curl -s localhost:8088/debug/partner
curl -s localhost:9092/api/v1/query \
  --data-urlencode 'query=sum by (result) (rate(partner_calls_total[1m]))'
for i in $(seq 1 10); do curl -s -o /dev/null -w '%{http_code} %{time_total}s\n' localhost:8088/risk/1; done

Объясните, почему bulkhead вырос сразу, open — после пяти ошибок, а error идёт редкими всплесками. Почему большинство ответов — быстрые 503 degraded, хотя партнёр всё ещё висит?

  1. Вернуть норму и защиту (5 мин).
bash
curl "http://localhost:8091/admin/mode?set=ok"
sleep 3
curl -s localhost:8088/risk/1
curl -s localhost:8088/debug/partner
make check-7

Ожидаемо: 200, cb_state — closed, CHECK OK. Сформулируйте лимит bulkhead, порог и cooldown breaker'а и правило деградации: что вернуть клиенту, когда партнёр недоступен.

🧩 Quiz "Проверь себя"

1. Что означает рост partner_calls_total{result="open"}?
2. Почему таймаут 3 с сам по себе не спасает /risk?
3. Что происходит, когда все 8 слотов bulkhead заняты?
4. Как circuit breaker переходит из open в closed?
5. Какой ответ отдаёт api при ErrOpen или ErrBulkhead?

✅ Итоги модуля

  • Объяснить, почему таймаут ограничивает ожидание, но не параллелизм.
  • Воспроизвести fail-7 и увидеть p99 /risk ~5 с при быстром /healthz.
  • Описать переходы breaker'а: 5 ошибок → open → 2 с → halfOpen → closed.
  • Показать, как bulkhead на 8 слотов превращает зависание в быстрый 503.
  • Прочитать partner_calls_total по result и /debug/partner под отказом.
  • Пройти цикл fail-7 → check-7 (red) → fix-7 → check-7 (green).

🔗 Мостик к Модулю 8

Партнёр — только одна из зависимостей. В «чёрную пятницу» отказы приходят вместе: удалённый индекс снова делает /orders медленным, replay на реплике стоит, а партнёр зависает — и всё это на фоне пикового трафика. Появится соблазн чинить всё сразу; в модуле 8 будем приоритизировать по симптомам и SLO, вести чек-лист дежурного и собирать комбинированный инцидент по частям. Защиты из этого модуля станут одним из пунктов чек-листа, а не единственным ответом.