Graceful degradation и graceful shutdown: как сервис должен падать, чтобы не тянуть за собой всё остальное

Graceful degradation и graceful shutdown: как сервис должен падать, чтобы не тянуть за собой всё остальное

Коротко:

  • Graceful degradation - это способность сервиса продолжать работу в ограниченном режиме, когда часть зависимостей недоступна.
  • Graceful shutdown - это корректное завершение процесса: без потери запросов, без обрыва соединений, без незакрытых транзакций.
  • Без обоих подходов один упавший сервис может положить всю систему через каскадный сбой.
  • Реализация требует явных таймаутов, circuit breaker-ов, обработки сигналов завершения и drain-периода.
  • Чаще всего проблемы возникают не при падении, а при деплое: новая версия поднимается, старая убивается без ожидания завершения запросов.

Сервис упал. Это неприятно, но не катастрофа. Катастрофа начинается тогда, когда один упавший сервис тянет за собой соседей: очереди переполняются, таймауты накапливаются, пользователи видят белый экран вместо хотя бы частично работающего интерфейса.

Большинство команд думают о том, как сервис стартует и как он работает в штатном режиме. Гораздо меньше думают о том, как он должен завершаться и как вести себя, когда что-то рядом сломалось. Именно здесь живут самые дорогие инциденты.

В этой статье разберем два связанных понятия: graceful degradation и graceful shutdown. Что это такое, как реализовать, где чаще всего ошибаются и как проверить, что всё работает правильно.

Что такое graceful degradation

Graceful degradation - это проектное решение: сервис продолжает выполнять свою основную функцию, даже если часть зависимостей недоступна. Вместо того чтобы вернуть ошибку 500 или зависнуть, он возвращает частичный результат, кешированные данные или упрощенный ответ.

Простой пример: интернет-магазин показывает карточку товара, даже если сервис рекомендаций временно недоступен. Пользователь не видит блок «похожие товары», но может купить то, за чем пришел. Без деградации запрос к сервису рекомендаций завис бы на таймауте, и страница не открылась бы вовсе.

Противоположность graceful degradation - это жесткая зависимость: если один компонент упал, весь запрос падает вместе с ним. В монолите это иногда приемлемо. В распределенной системе это путь к каскадным сбоям.

Что такое graceful shutdown

Graceful shutdown - это процесс корректного завершения работы сервиса. Когда процесс получает сигнал завершения (обычно SIGTERM), он не убивается немедленно, а проходит несколько шагов:

  1. Перестает принимать новые запросы (убирает себя из балансировщика или отказывает новым соединениям).
  2. Дожидается завершения текущих запросов.
  3. Закрывает соединения с базами данных, брокерами сообщений, внешними сервисами.
  4. Завершает процесс.

Без этого при деплое или перезапуске часть запросов обрывается на полуслове: транзакция не завершена, ответ не отправлен, сообщение из очереди не обработано и не подтверждено.

Это особенно критично в Kubernetes, где поды пересоздаются при каждом деплое. Если не настроен graceful shutdown, каждый деплой - это маленький инцидент с потерянными запросами.

Почему это важно именно сейчас

В монолитной архитектуре падение одного компонента - это падение всего приложения. Неприятно, но предсказуемо. В микросервисной архитектуре сервисов десятки, они вызывают друг друга синхронно и асинхронно, и отказ одного может распространяться непредсказуемо.

Представим платежный сервис, который при обработке каждой транзакции обращается к сервису антифрода. Сервис антифрода начал отвечать медленно - за 5 секунд вместо 200 миллисекунд. Потоки платежного сервиса заняты ожиданием. Новые запросы встают в очередь. Очередь переполняется. Платежный сервис перестает отвечать. Пользователи не могут оплатить заказы. Служба поддержки получает шквал обращений.

Сервис антифрода при этом просто был медленным, не упавшим. Без таймаутов и circuit breaker-ов этого достаточно для полного отказа.

Как реализовать graceful degradation

Таймауты на все внешние вызовы

Каждый вызов к внешнему сервису, базе данных или API должен иметь явный таймаут. Без таймаута запрос может висеть бесконечно, удерживая поток или соединение.

Таймаут нужно выставлять осознанно: слишком короткий - и вы будете получать ошибки при нормальной нагрузке, слишком длинный - и медленный сервис всё равно заблокирует ресурсы. Хорошая отправная точка - посмотреть на p99 времени ответа зависимости в нормальном режиме и поставить таймаут в 2-3 раза выше.

Circuit breaker

Circuit breaker - это паттерн, который отслеживает количество ошибок при вызове зависимости. Если ошибок становится слишком много, он «размыкает цепь»: последующие вызовы сразу возвращают ошибку, не дожидаясь таймаута. Через некоторое время он пробует снова - если зависимость восстановилась, цепь «замыкается».

Это защищает от ситуации, когда медленная или упавшая зависимость продолжает потреблять ресурсы через таймауты. Популярные реализации: Resilience4j для Java, polly для .NET, hystrix (устаревший, но часто встречается в legacy), tenacity для Python.

Fallback-логика

Когда вызов к зависимости не удался, нужно решить: что вернуть вместо ошибки. Варианты зависят от контекста:

СценарийFallbackКогда подходит
Сервис рекомендаций недоступенПустой список или популярные товары из кешаРекомендации не критичны для основного сценария
Сервис профиля недоступенБазовые данные из JWT-токенаТокен содержит достаточно данных для отображения
Сервис антифрода недоступенПропустить транзакцию с флагом для ручной проверкиБизнес готов к небольшому риску ради доступности
База данных недоступнаОшибка пользователюБез данных операция невозможна

Важно: fallback должен быть явным решением, а не случайным поведением. Если вы не решили, что делать при недоступности зависимости, система решит за вас - обычно неудачно.

Изоляция некритичных зависимостей

Разделите зависимости на критичные и некритичные. Критичные - без них основная функция невозможна (база данных для интернет-магазина). Некритичные - без них функция деградирует, но работает (аналитика, рекомендации, логирование активности).

Для некритичных зависимостей используйте отдельные пулы потоков или асинхронные вызовы с таймаутом. Тогда их падение не заблокирует обработку основных запросов.

Как реализовать graceful shutdown

Обработка сигналов

Первый шаг - явно перехватить сигнал SIGTERM (в Kubernetes это сигнал, который отправляется поду перед удалением). По умолчанию многие фреймворки обрабатывают его корректно, но стоит убедиться в этом явно.

В Node.js это выглядит так:

process.on('SIGTERM', async () => {
  server.close(() => {
    db.end();
    process.exit(0);
  });
});

В Python с FastAPI или uvicorn нужно настроить lifespan-обработчик. В Java Spring Boot graceful shutdown включается через server.shutdown=graceful в конфигурации.

Drain-период

После получения SIGTERM сервис должен перестать принимать новые запросы, но дать завершиться текущим. Это называется drain-период. Его длительность зависит от типичного времени обработки запроса: для HTTP API обычно достаточно 15-30 секунд, для обработчиков очередей - дольше.

В Kubernetes есть параметр terminationGracePeriodSeconds (по умолчанию 30 секунд). Если ваш drain-период длиннее, нужно увеличить это значение, иначе Kubernetes отправит SIGKILL до того, как сервис успеет завершиться корректно.

Проблема с балансировщиком

Есть тонкий момент: когда Kubernetes отправляет SIGTERM, он одновременно начинает убирать под из endpoints (список адресов, на которые балансировщик отправляет трафик). Но это не мгновенно - между SIGTERM и фактическим обновлением endpoints может пройти несколько секунд. В это время балансировщик продолжает слать запросы на умирающий под.

Решение: добавить небольшую паузу перед тем, как начать отклонять новые соединения. Обычно 5-10 секунд достаточно, чтобы endpoints обновились.

Закрытие соединений с базой данных

После завершения всех запросов нужно явно закрыть соединения с базой данных и другими ресурсами. Если этого не сделать, соединения останутся открытыми до таймаута на стороне базы. При частых деплоях это может привести к исчерпанию пула соединений.

Обработчики очередей

Для сервисов, которые читают из очереди (Kafka, RabbitMQ), graceful shutdown сложнее. При получении SIGTERM нужно:

  1. Прекратить читать новые сообщения из очереди.
  2. Дождаться завершения обработки текущих сообщений.
  3. Подтвердить (ack) обработанные сообщения.
  4. Закрыть consumer.

Если убить процесс до подтверждения, брокер вернет сообщения в очередь и они будут обработаны повторно. Это может быть приемлемо, если обработка идемпотентна, но создает проблемы, если нет.

Типичные ошибки

Разберем конкретные ошибки, которые встречаются чаще всего - не абстрактные, а те, которые приводят к реальным инцидентам.

ОшибкаПоследствиеКак исправить
Нет таймаута на вызов зависимостиМедленная зависимость блокирует все потокиЯвный таймаут на каждый внешний вызов
SIGTERM игнорируется, процесс убивается через SIGKILLПотеря запросов при каждом деплоеПерехватить SIGTERM, добавить drain-период
terminationGracePeriodSeconds меньше drain-периодаKubernetes убивает под до завершения запросовУвеличить terminationGracePeriodSeconds
Fallback не реализован, ошибка зависимости = ошибка запросаНедоступность некритичного сервиса ломает основной сценарийЯвная fallback-логика для некритичных зависимостей
Соединения с БД не закрываются при shutdownИсчерпание пула соединений при частых деплояхЯвное закрытие в обработчике завершения
Сообщения из очереди не подтверждаются перед shutdownПовторная обработка сообщений, дублиAck перед завершением consumer

Как проверить, что всё работает

Теория хороша, но без проверки непонятно, работает ли реализация на практике. Вот конкретные способы убедиться.

Проверка graceful shutdown

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

В Kubernetes: сделать деплой во время нагрузочного теста и проверить, что в метриках нет ошибок 502/503 и нет резкого роста ошибок в логах клиента.

Проверка деградации

Отключите зависимость (или используйте chaos engineering инструменты вроде Chaos Monkey или Toxiproxy для эмуляции задержек и ошибок) и проверьте поведение системы. Вопросы для проверки:

  • Возвращает ли сервис частичный результат или полную ошибку?
  • Срабатывает ли circuit breaker после нескольких ошибок?
  • Восстанавливается ли circuit breaker, когда зависимость снова доступна?
  • Не растет ли время ответа до таймаута при каждом запросе?

Метрики для мониторинга

Настройте алерты на:

  • Количество ошибок при вызове каждой зависимости (отдельно по зависимостям)
  • Состояние circuit breaker-ов (open/closed/half-open)
  • Количество запросов, прерванных при shutdown (если есть такая метрика)
  • Время завершения пода при деплое

Важно: Не проверяйте graceful shutdown только в dev-окружении. Поведение в Kubernetes с реальным балансировщиком и реальными таймаутами может отличаться. Тестируйте в staging с нагрузкой, близкой к production.

Когда это не нужно

Graceful degradation и graceful shutdown - не универсальные требования для любого сервиса.

Если у вас простой монолит с одной базой данных, без внешних зависимостей, с небольшой нагрузкой и редкими деплоями - сложная реализация circuit breaker-ов и fallback-логики может быть избыточной. Достаточно базовых таймаутов и корректной обработки SIGTERM.

Для batch-задач, которые запускаются по расписанию и не обслуживают живые запросы, graceful shutdown проще: достаточно дождаться завершения текущей итерации и выйти.

Сложность реализации должна соответствовать сложности системы и требованиям к доступности. Если SLA не предполагает высокой доступности, инвестиции в сложный graceful degradation могут не окупиться.

Чеклист

Проверьте перед следующим деплоем:

  • На каждый внешний вызов выставлен явный таймаут
  • Для некритичных зависимостей реализован fallback
  • Circuit breaker настроен для зависимостей с нестабильной доступностью
  • Сервис перехватывает SIGTERM и не завершается немедленно
  • Drain-период достаточен для завершения текущих запросов
  • terminationGracePeriodSeconds в Kubernetes больше drain-периода
  • Соединения с базой данных явно закрываются при shutdown
  • Consumer очереди подтверждает сообщения перед завершением
  • Graceful shutdown проверен под нагрузкой, а не только в dev
  • Есть метрики и алерты на состояние circuit breaker-ов

Graceful degradation и graceful shutdown в разных архитектурах

Подходы к реализации заметно различаются в зависимости от того, как устроена система. То, что работает для классического HTTP-сервиса, не всегда подходит для event-driven архитектуры или сервиса с долгими фоновыми задачами.

Для синхронных HTTP-сервисов приоритет отдается таймаутам, circuit breaker-ам и drain-периоду при shutdown. Здесь всё относительно предсказуемо: запрос пришел, ответ ушел, соединение закрыто.

Для асинхронных систем на основе очередей сложнее. Сообщение может обрабатываться несколько минут. При shutdown нужно решить: ждать завершения текущего сообщения (и сколько ждать), или вернуть его в очередь и рискнуть повторной обработкой. Ответ зависит от того, идемпотентна ли обработка.

Для gRPC-сервисов есть специфика: gRPC поддерживает graceful shutdown на уровне протокола через механизм GOAWAY. Сервер отправляет клиенту сигнал о завершении, клиент заканчивает текущие стримы и переключается на другой инстанс. Это удобнее, чем просто закрывать соединение, но требует явной настройки.

Для сервисов с WebSocket-соединениями graceful shutdown еще сложнее: нужно уведомить клиентов о завершении и дать им время переподключиться к другому инстансу. Без этого пользователи просто потеряют соединение без объяснений.

Связь с observability: как понять, что деградация происходит

Graceful degradation без наблюдаемости - это черный ящик. Сервис тихо возвращает fallback-ответы, а команда не знает, что половина запросов уже идет в обход основной логики.

Хорошая observability для деградирующего сервиса включает несколько уровней.

Метрики: отдельный счетчик для каждого fallback-пути. Если счетчик растет - что-то не так с зависимостью. Без этого счетчика вы узнаете о проблеме только от пользователей или из мониторинга зависимости.

Логи: каждый раз, когда срабатывает fallback или circuit breaker переходит в открытое состояние, должна быть запись в логах с контекстом: какая зависимость, сколько ошибок подряд, какой fallback был применен.

Трейсинг: в распределенной трассировке (Jaeger, Zipkin, Tempo) должно быть видно, какие спаны завершились через fallback, а не через реальный вызов. Это помогает при разборе инцидентов понять, что именно деградировало и как долго.

Практика: Заведите отдельный дашборд для состояния зависимостей. На нем должны быть: процент успешных вызовов к каждой зависимости, состояние circuit breaker-ов, количество fallback-ответов за последние 5 минут. Если что-то начинает краснеть, команда видит это до того, как пользователи начнут жаловаться.

Как graceful shutdown и деградация связаны между собой

На первый взгляд это два независимых паттерна. На практике они пересекаются в нескольких сценариях.

Во время shutdown сервис сам становится недоступной зависимостью для тех, кто его вызывает. Если вызывающий сервис не реализовал graceful degradation или retry с переключением на другой инстанс, он начнет получать ошибки в момент вашего деплоя. Это значит, что graceful shutdown одного сервиса работает корректно только тогда, когда вызывающие сервисы умеют обрабатывать временную недоступность.

Второй сценарий: во время деградации (когда зависимость недоступна и circuit breaker открыт) может прийти сигнал завершения. Нужно убедиться, что обработчик shutdown корректно завершает работу даже в этом состоянии: не зависает в ожидании ответа от уже недоступной зависимости, не пытается закрыть соединение, которого нет.

СценарийЧто может пойти не такРешение
Деплой без drain-периодаВызывающий сервис получает ошибки, если не умеет retryDrain-период плюс retry на стороне клиента
Shutdown во время открытого circuit breakerОбработчик зависает, ожидая закрытия соединенияТаймаут на закрытие каждого соединения в обработчике shutdown
Частые деплои без graceful shutdownНакопление ошибок в circuit breaker вызывающего сервисаКорректный shutdown плюс мониторинг ошибок при деплоях
Зависимость упала в момент shutdownСервис не может завершиться, ждет ответаТаймаут на все исходящие вызовы, включая вызовы при shutdown

Что читать и изучать дальше

Если хотите углубиться в тему, есть несколько направлений.

Паттерны отказоустойчивости подробно описаны в книге Майкла Найгарда «Release It!». Это практическое руководство по проектированию систем, которые выживают в production. Там же подробно разобраны circuit breaker, bulkhead и другие паттерны.

Для Kubernetes-специфики стоит изучить официальную документацию по жизненному циклу пода, особенно раздел про terminationGracePeriodSeconds и preStop hooks. Многие проблемы с деплоями решаются правильной настройкой именно этих параметров.

Для практики с chaos engineering начните с Toxiproxy: это простой прокси, который позволяет эмулировать задержки, обрывы соединений и ошибки для конкретных зависимостей. Запустить его локально и проверить поведение сервиса при деградации можно за час.

Если система работает в Kubernetes и нагрузка значительная, изучите, как настроить PodDisruptionBudget. Этот ресурс гарантирует, что при обновлении кластера или деплое одновременно не будет убито больше определенного числа подов. Это дополняет graceful shutdown на уровне оркестрации.

FAQ

Чем graceful degradation отличается от fault tolerance?

Fault tolerance - более широкое понятие: способность системы продолжать работу при отказах компонентов, включая репликацию, резервирование и восстановление. Graceful degradation - один из инструментов fault tolerance: система работает в ограниченном режиме вместо полного отказа.

Что такое circuit breaker простыми словами?

Это автоматический выключатель для вызовов к зависимостям. Если зависимость начала часто возвращать ошибки или отвечать медленно, circuit breaker перестает к ней обращаться на некоторое время и сразу возвращает ошибку или fallback. Это защищает от накопления зависших запросов и дает зависимости время восстановиться.

Как настроить graceful shutdown в Kubernetes?

Нужно три вещи: перехватить SIGTERM в коде приложения, добавить drain-период перед завершением, и выставить terminationGracePeriodSeconds в манифесте пода больше, чем drain-период. Также полезно добавить preStop hook с небольшой паузой, чтобы дать время на обновление endpoints в балансировщике.

Что делать, если зависимость упала, а fallback не реализован?

В краткосрочной перспективе - убедитесь, что таймаут на вызов достаточно короткий, чтобы не блокировать потоки надолго. В среднесрочной - добавьте circuit breaker, чтобы не тратить ресурсы на таймауты при каждом запросе. В долгосрочной - реализуйте явный fallback или примите решение, что эта зависимость критична и её падение должно приводить к ошибке.

Как проверить, что graceful shutdown работает в Kubernetes?

Запустите нагрузочный тест (например, через k6 или wrk) и сделайте деплой во время теста. Проверьте метрики ошибок на стороне клиента: если есть 502/503 или резкий рост ошибок во время деплоя - shutdown не настроен корректно. Также смотрите логи пода во время завершения: должны быть записи о начале shutdown и корректном завершении.

Нужен ли graceful shutdown для serverless-функций?

Для большинства serverless-платформ (AWS Lambda, Yandex Cloud Functions) управление жизненным циклом берет на себя платформа. Но если функция работает долго или использует соединения с базой данных через connection pooling, стоит убедиться, что соединения корректно закрываются. Некоторые платформы поддерживают обработку сигнала завершения.

Итог

Правильное поведение сервиса при падении и завершении - это не детали реализации, а архитектурные решения, которые нужно принимать осознанно. Graceful degradation защищает от каскадных сбоев: один медленный сервис не должен останавливать всю систему. Graceful shutdown защищает от потери запросов при деплоях и перезапусках.

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

Начните с малого: выставьте таймауты на все внешние вызовы и настройте обработку SIGTERM. Это даст наибольший эффект при минимальных усилиях. Остальное - circuit breaker-ы, fallback-логика, мониторинг состояния - добавляйте по мере роста системы и требований к доступности.