MAATRIX / Блог / Отвалился один микросервис и утянул за собой пять

Отвалился один микросервис и утянул за собой пять

MAATRIX

Дежурный алерт в три часа ночи: упал сервис уведомлений — второстепенный, не критичный, без него можно жить сутки. Но через две минуты в панике загораются ещё пять сервисов, включая биллинг и авторизацию, которые к уведомлениям вообще не должны иметь отношения. Если вы это уже видели — вы знаете, что самое неприятное здесь не сам инцидент, а то, что граф зависимостей системы вы, скорее всего, впервые видите целиком именно в этот момент.

Первое недоумение: почему это вообще связано

Первая реакция дежурного инженера — недоверие к мониторингу. Сервис уведомлений не участвует в цепочке оформления заказа, у него нет прав на базу биллинга, он не в критическом пути ни по одной диаграмме, которая висит в Confluence. Логично предположить совпадение: может, это просто общий инцидент с сетью или облаком, а падение уведомлений — побочный симптом, а не причина.

Но графики говорят другое. Латентность биллинга начинает расти ровно с того момента, когда уведомления перестали отвечать. Через минуту-полторы у биллинга заканчиваются свободные потоки в пуле, и он начинает отвечать 504 даже на запросы, которые вообще не трогают уведомления — на банальный health-check и на чтение баланса, где о существовании сервиса уведомлений никто не подозревает. Формально биллинг жив: процесс работает, память в норме, CPU не перегружен. Фактически — он не может обслужить ни один запрос, потому что все обработчики зависли в ожидании.

Это и есть первый признак cascading failure: сбой распространяется не по логике бизнес-функций, а по скрытому графу технических вызовов, который никто не рисовал целиком, потому что каждая команда знает только свои прямые зависимости.

Настоящая причина: синхронный вызов без таймаута

Причина почти всегда одна и та же, меняются только имена сервисов. Где-то в коде биллинга на пути обработки запроса стоит синхронный HTTP-вызов к сервису уведомлений — например, чтобы отправить событие "платёж прошёл, отправь письмо". Разработчик написал этот вызов год назад, сервис уведомлений тогда падал раз в квартал и на секунды, поэтому таймаут никто явно не выставил — библиотека HTTP-клиента взяла дефолт, а дефолт у многих клиентов — это отсутствие таймаута вовсе, то есть "ждать бесконечно".

Пока уведомления живы, это работает: ответ приходит за 20-50 мс, поток обработчика биллинга занят долю секунды и освобождается. Проблема не в самом вызове, а в его крайнем случае — что происходит, когда получателя нет и ответа не будет вообще.

Когда сервис уведомлений падает, у вызывающей стороны нет ни ответа, ни ошибки соединения. TCP-соединение может даже установиться (если под уведомлениями остался балансировщик или под всё ещё принимает коннекты, но не отвечает на уровне приложения), а может зависнуть на этапе connect. В обоих случаях поток, который делает вызов, блокируется на неопределённое время — секунды, минуты, до перезапуска процесса.

Дальше — простая арифметика. Пул потоков (или пул соединений, если стек асинхронный, но с ограниченным числом одновременных запросов) конечен: 50, 100, 200 воркеров. Каждый новый входящий запрос к биллингу, в обработке которого есть этот синхронный вызов, занимает воркер и вешает его навсегда. Через несколько секунд или минут — в зависимости от интенсивности трафика — свободных воркеров не остаётся. Все входящие запросы к биллингу встают в очередь ожидания свободного потока, и эта очередь тоже конечна (backlog у сервера приложений или у самого сокета). Когда переполняется и она — новые подключения начинают получать отказ или таймаут уже на уровне TCP.

Итог: сервис уведомлений упал полностью изолированно, а биллинг лёг полностью — не потому что у них общая база или общая инфраструктура, а потому что один вызывал другого синхронно и без страховки на случай молчания.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Что такое cascading failure и почему микросервисы к этому более хрупкие

Cascading failure (каскадный отказ) — это ситуация, когда локальный сбой одного компонента через цепочку зависимостей вызывает отказ компонентов, которые сами по себе исправны. Ключевое отличие от простого "сервис A упал" — здесь падает B и C, у которых нет собственной неисправности: их код работает правильно, их инфраструктура здорова, единственная проблема — они ждут ответа от того, кто не отвечает.

Монолит от такого класса проблем защищён почти по умолчанию: если один модуль внутри процесса завис в цикле или в ожидании, это видно сразу и локализовано в рамках этого процесса — либо весь процесс отвечает медленно, либо падает целиком, и рестарт возвращает систему в консистентное состояние. Все зависимости — вызовы функций в памяти, без сети, без таймаутов, без частичной доступности.

В микросервисной архитектуре каждый вызов между сервисами — это сеть: со своими задержками, обрывами соединений, частичной доступностью (сервис отвечает на одни эндпоинты и виснет на других), и без единого владельца, который видит полную картину. Команда биллинга не обязана знать внутреннее устройство сервиса уведомлений, а команда уведомлений не обязана предупреждать всех, кто решил дёрнуть их API синхронно. Граф вызовов растёт органически быстрее, чем кто-либо успевает его документировать, и рано или поздно в нём оказывается синхронная зависимость там, где её логически быть не должно.

Микросервисы не хрупкие сами по себе — они хрупкие без защитных механизмов, рассчитанных именно на частичные, а не полные отказы: без таймаутов, без circuit breaker, без изоляции пулов ресурсов по зависимостям, без грамотной деградации функциональности. Именно эти механизмы и есть цена, которую нужно платить за гибкость независимого развёртывания и масштабирования — если её не платить, легко получить систему, которая менее надёжна, чем монолит, который она заменила.

Почему пул потоков — это самое узкое место

Стоит отдельно остановиться на механике исчерпания ресурсов — именно она превращает "сервис Х не ответил одному запросу" в "сервис Y лежит целиком". У большинства серверов приложений число одновременно обрабатываемых запросов ограничено явно:

# Пример для типичного Java-стека (Tomcat/Spring Boot)
server.tomcat.threads.max=200
server.tomcat.accept-count=100

Если синхронный вызов без таймаута висит внутри обработчика запроса, он держит не только поток веб-сервера, но зачастую и соединение из пула к базе данных, если транзакция была открыта раньше по коду. Один зависший вызов может исчерпать сразу два разных ограниченных ресурса — воркеры HTTP-сервера и коннекты к БД — и оба дефицита проявятся как разные, на первый взгляд не связанные друг с другом инциденты.

Ловушка в том, что при исчерпании пула сервис не деградирует плавно, а падает почти ступенькой: пока свободных воркеров хватает, латентность нормальная, а как только они закончились — отказывают вообще все запросы, включая самые лёгкие и не связанные с проблемной зависимостью. Здоровый health-check /healthz, который ничего не вызывает и должен отвечать за миллисекунды, тоже встаёт в очередь наравне с тяжёлыми запросами — и мониторинг шлёт алерт "сервис недоступен" на сервис, у которого физически нет ни одной неполадки в коде.

Фикс №1: таймауты на все межсервисные вызовы без исключений

Первое и самое дешёвое исправление — явный таймаут на каждый синхронный вызов между сервисами, без исключений "этот сервис у нас надёжный, ему таймаут не нужен". Надёжность сервиса не гарантирует, что он не окажется недоступен именно сегодня ночью — а разбор похожей симптоматики на уровне прокси есть в статье про nginx и 504 Gateway Timeout.

Таймаут должен быть на нескольких уровнях сразу, потому что зависание может произойти на любом из них:

# HTTP-клиент на Python (requests) — раздельные connect и read timeout
import requests

response = requests.post(
    "http://notifications-service.internal/send",
    json=payload,
    timeout=(1, 3),  # 1 секунда на коннект, 3 секунды на чтение ответа
)
# Nginx как обратный прокси перед сервисом — таймауты на уровне инфраструктуры
location /api/notifications/ {
    proxy_connect_timeout 2s;
    proxy_send_timeout    3s;
    proxy_read_timeout    3s;
    proxy_pass http://notifications_upstream;
}

Важный нюанс: таймаут сам по себе не решает проблему, а только ограничивает её масштаб. Если у биллинга 200 потоков и таймаут на вызов к уведомлениям — 3 секунды, а трафик — 100 запросов в секунду, то при полном отказе уведомлений весь пул всё равно исчерпается за пару секунд, просто каждый отдельный запрос будет виснуть на 3 секунды вместо бесконечности. Лучше — пользователь получит ошибку, а не вечное ожидание, — но это не спасает от перегрузки. Таймаут необходим, но недостаточен; вторая часть решения — не пытаться дозваниваться до явно недоступного сервиса вообще.

Фикс №2: circuit breaker и быстрый fallback вместо ожидания

Circuit breaker (предохранитель) — паттерн, который отслеживает долю неудачных вызовов (таймаутов, ошибок соединения, 5xx) к конкретной зависимости и после превышения порога на время "размыкает цепь": перестаёт вообще пытаться дозвониться до сервиса и сразу возвращает ошибку или fallback-ответ, не тратя ни секунды на реальный сетевой вызов. Через заданный интервал предохранитель переходит в "полуоткрытое" состояние, пропускает пробный запрос и, если тот успешен, снова закрывается — трафик возобновляется.

Три состояния коротко:

СостояниеПоведение
Closed (замкнут)Запросы идут как обычно, ошибки считаются
Open (разомкнут)Запросы не идут, сразу возвращается fallback/ошибка
Half-open (полуоткрыт)Пропускается пробный запрос, чтобы проверить восстановление

Пример конфигурации на Java с resilience4j (наследник Hystrix, который Netflix перестал развивать):

resilience4j.circuitbreaker:
  instances:
    notificationsService:
      failure-rate-threshold: 50        # открыть цепь при 50% ошибок
      wait-duration-in-open-state: 10s  # держать открытой 10 секунд
      sliding-window-size: 20           # окно из 20 последних вызовов
      permitted-number-of-calls-in-half-open-state: 3

Аналогичный паттерн есть в Node.js (библиотека opossum), в Python (pybreaker), а на уровне инфраструктуры без изменения кода приложений его можно получить через service mesh — например, Istio с настройкой outlier detection в DestinationRule, которая сама выводит из ротации подозрительные поды и возвращает быстрый отказ. Здесь стоит явно оговориться: конкретные пороги (сколько ошибок, какое окно, сколько ждать в открытом состоянии) — это не универсальные цифры, их нужно подбирать под реальный профиль трафика вашей системы, а не копировать бездумно из документации библиотеки.

Fallback-логика — не менее важная часть паттерна, чем сам предохранитель. "Быстро вернуть ошибку" уже лучше, чем зависнуть, но часто можно сделать ещё мягче: если сервис уведомлений недоступен, платёж всё равно можно провести, а письмо поставить в очередь и отправить позже, когда сервис восстановится — это как раз то место, где вместо синхронного HTTP-вызова уместнее асинхронная очередь сообщений: публикация события в неё не блокирует обработчик и не зависит от того, жив ли получатель прямо сейчас.

Как документировать граф зависимостей заранее, а не после инцидента

Главный практический вывод из подобных инцидентов не про код, а про знание системы: до падения почти никто в команде не знал, что биллинг синхронно зависит от уведомлений. Каждая команда владеет своим сервисом и своими прямыми зависимостями, но полного графа "кто кого дёргает синхронно и что случится, если получатель не ответит" не существовало нигде в явном виде.

Практические шаги, которые стоит внедрить до следующего инцидента, а не после:

  • Явная карта синхронных зависимостей. Для каждого сервиса — список исходящих синхронных вызовов (не через очередь) с пометкой критичности: обязателен ли ответ для успешного завершения запроса пользователя.
  • Аудит таймаутов. Пройтись по всем HTTP-клиентам, gRPC-стабам и клиентам к БД/кэшу и убедиться, что таймаут выставлен явно везде — дефолтные значения библиотек нельзя считать безопасными.
  • Circuit breaker на все внешние вызовы, не только на "ненадёжные" — заранее не угадать, какая зависимость откажет следующей.
  • Изоляция пулов ресурсов по зависимостям (bulkhead pattern): каждой исходящей зависимости — свой ограниченный пул потоков/соединений, чтобы зависание одной не съедало ресурсы, нужные для остальных.
  • Нагрузочное тестирование деградации: искусственно "убить" одну зависимость в тестовом контуре и посмотреть, что происходит с остальными — дешевле, чем узнавать это ночью в проде.
  • Мониторинг насыщения пулов, а не только 5xx: метрика "сколько потоков занято из максимума" сигнализирует о надвигающемся каскаде раньше, чем начнут падать запросы — подробнее в материале про мониторинг и алерты при падении сайта.

Отдельно стоит завести практику постмортема без поиска виноватых (blameless postmortem): хронология, реальная причина и главное — какой защиты не хватило именно в этом месте графа. Через несколько таких разборов карта зависимостей и список настроенных предохранителей станут полнее любой диаграммы, нарисованной заранее умозрительно.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Обязательно ли внедрять circuit breaker при всего 3-4 микросервисах?

Порог определяется не количеством сервисов, а наличием синхронных вызовов между ними. Даже при трёх сервисах, если один вызывает другого на критическом пути, каскадный отказ возможен, просто на меньшем масштабе. Таймауты стоит ставить всегда, circuit breaker — по мере роста числа зависимостей.

Что делать с legacy-кодом, если переписать все клиенты с таймаутами сразу нельзя?

Начните с точки входа — прокси-слоя (nginx, Envoy, service mesh), где таймауты выставляются централизованно, без правки кода каждого сервиса. Это не заменит таймауты в клиентских библиотеках полностью, но снижает риск бесконечного ожидания уже сейчас.

Чем circuit breaker отличается от простого ретрая (retry)?

Ретрай повторяет неудачный запрос, что при реальном отказе зависимости только увеличивает нагрузку на неё. Circuit breaker после серии неудач прекращает попытки вовсе на заданное время — это защита от усугубления, а не от единичного сбоя. Их часто комбинируют: ограниченный ретрай с задержкой внутри закрытого состояния предохранителя.

Можно ли обойтись без circuit breaker, если везде стоят таймауты?

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

Как быстро понять на реальном инциденте, что это именно каскадный отказ?

Характерный признак — несколько сервисов отдают ошибки почти одновременно, при этом по метрикам (CPU, память, диск) у части из них всё в норме, растёт только число занятых потоков/соединений и латентность. Если здоровые с виду сервисы не отвечают даже на health-check — это почти всегда исчерпание пула из-за зависшей зависимости, а не независимый сбой каждого по отдельности.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →