Коротко:
- Graceful degradation - это способность сервиса продолжать работу в ограниченном режиме, когда часть зависимостей недоступна.
- Graceful shutdown - это корректное завершение процесса: без потери запросов, без обрыва соединений, без незакрытых транзакций.
- Без обоих подходов один упавший сервис может положить всю систему через каскадный сбой.
- Реализация требует явных таймаутов, circuit breaker-ов, обработки сигналов завершения и drain-периода.
- Чаще всего проблемы возникают не при падении, а при деплое: новая версия поднимается, старая убивается без ожидания завершения запросов.
Сервис упал. Это неприятно, но не катастрофа. Катастрофа начинается тогда, когда один упавший сервис тянет за собой соседей: очереди переполняются, таймауты накапливаются, пользователи видят белый экран вместо хотя бы частично работающего интерфейса.
Большинство команд думают о том, как сервис стартует и как он работает в штатном режиме. Гораздо меньше думают о том, как он должен завершаться и как вести себя, когда что-то рядом сломалось. Именно здесь живут самые дорогие инциденты.
В этой статье разберем два связанных понятия: graceful degradation и graceful shutdown. Что это такое, как реализовать, где чаще всего ошибаются и как проверить, что всё работает правильно.
Что такое graceful degradation
Graceful degradation - это проектное решение: сервис продолжает выполнять свою основную функцию, даже если часть зависимостей недоступна. Вместо того чтобы вернуть ошибку 500 или зависнуть, он возвращает частичный результат, кешированные данные или упрощенный ответ.
Простой пример: интернет-магазин показывает карточку товара, даже если сервис рекомендаций временно недоступен. Пользователь не видит блок «похожие товары», но может купить то, за чем пришел. Без деградации запрос к сервису рекомендаций завис бы на таймауте, и страница не открылась бы вовсе.
Противоположность graceful degradation - это жесткая зависимость: если один компонент упал, весь запрос падает вместе с ним. В монолите это иногда приемлемо. В распределенной системе это путь к каскадным сбоям.
Что такое graceful shutdown
Graceful shutdown - это процесс корректного завершения работы сервиса. Когда процесс получает сигнал завершения (обычно SIGTERM), он не убивается немедленно, а проходит несколько шагов:
- Перестает принимать новые запросы (убирает себя из балансировщика или отказывает новым соединениям).
- Дожидается завершения текущих запросов.
- Закрывает соединения с базами данных, брокерами сообщений, внешними сервисами.
- Завершает процесс.
Без этого при деплое или перезапуске часть запросов обрывается на полуслове: транзакция не завершена, ответ не отправлен, сообщение из очереди не обработано и не подтверждено.
Это особенно критично в 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 нужно:
- Прекратить читать новые сообщения из очереди.
- Дождаться завершения обработки текущих сообщений.
- Подтвердить (ack) обработанные сообщения.
- Закрыть 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-периода | Вызывающий сервис получает ошибки, если не умеет retry | Drain-период плюс 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 на уровне оркестрации.