Модуль 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"} показывает, чем закончились вызовы. Срез за минуту:
bashcurl -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 мс.
- Сломать (5 мин). Убедитесь, что партнёр завис, и запустите сценарий:
bashcurl "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: защиты выключены.
-
Красный чек (3 мин).
make check-7сам гоняет 15 секунд risk-нагрузки и требует p99/risk≤ 500 мс и/healthz≤ 100 мс; p99 берётся из вывода loadgen, а не из Prometheus. Ожидаемо:CHECK FAIL: /risk медленнее 500ms — защиты не работают. -
Починить (2 мин).
bashmake fix-7 make check-7
fix-7 возвращает партнёра в ok, включает PARTNER_CB=true PARTNER_BULKHEAD=true и повторяет нагрузку;
check-7 — зелёный.
- Упражнение: защиты под отказом (15 мин). Снова зависьте партнёра и нагрузите api уже с защитами:
bashcurl "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, хотя партнёр всё ещё висит?
- Вернуть норму и защиту (5 мин).
bashcurl "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 "Проверь себя"
✅ Итоги модуля
- Объяснить, почему таймаут ограничивает ожидание, но не параллелизм.
- Воспроизвести
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, вести чек-лист
дежурного и собирать комбинированный инцидент по частям. Защиты из этого модуля станут одним из пунктов
чек-листа, а не единственным ответом.