Почему важен graceful shutdown в облачной среде (на примере Kubernetes + Spring Boot)

Kate

Administrator
Команда форума
В облаке многие думают над стартом приложения, но не все задумываются о том, как оно завершается. В свое время мы наловили довольно много ошибок, связанных именно с остановкой подов. Например, увидели, что Kubernetes изредка убивает наше приложение до того, как оно успевает освободить ресурсы, хотя вроде бы так происходить не должно. Воспроизвести проблему с первого подхода не получилось, и мы задались вопросом, а что же там происходит под капотом?

14ba2013c2e5aefea99d22a0860088ba.png

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

Мы разрабатываем на Kotlin/Spring Boot. Проект крутится в облачной среде и его жизненным циклом управляет Kubernetes. Фактически мы задаем конфигурацию, как должны жить наши приложения, а об остальном заботится Kubernetes, не спрашивая у нас детали.

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

Но начну издалека - с того, как это происходит в обычной среде.

Жизненный цикл приложения в ОС​

Чтобы сообщить нечто приложению, ОС посылает ему сигнал с определенным кодом. Идея появилась еще в POSIX-совместимых ОС и активно используется по сей день. Сейчас есть порядка 30 различных сигналов, но я здесь вспомню лишь те, что относятся к завершению приложения:

  • SIGINT - сигнал, который должен завершить работающее приложение “в штатном режиме” (без спешки). Примерно то же самое происходит с Java-процессом, когда мы нажимаем Stop в IDEA. По умолчанию считается, что SIGINT завершает работу процесса в интерактивном режиме. Получив его, процесс может запросить подтверждение пользователя или даже использовать сигнал как-то иначе (в том числе, вообще проигнорировать).
  • SIGTERM - дефолтный сигнал завершения процесса. По умолчанию именно этот сигнал отправляется из консоли по команде kill в Linux. Но у процесса все еще остается шанс завершить потоки и освободить ресурсы или даже проигнорировать сигнал.
  • SIGKILL - однозначное мгновенное завершение процесса без освобождения ресурсов и завершения потоков.
Разница между SIGTERM и SIGKILL
Разница между SIGTERM и SIGKILL
Если в ходе разработки вам все время приходится жать на черепок в IDEA (аналог SIGKILL), чтобы наконец-то убить процесс или тест, это должно настораживать. Когда сервис нельзя остановить SIGTERM или SIGINT и приходится использовать SIGKILL, вполне можно потерять запрос или не записать что-то ценное в файл, поймав на этом неприятные баги.

Неразбериха с сингалами - Kubernetes и Spring Boot​

Управляя подами в облачной среде, Kubernetes также использует сигналы, посылая их в соответствии со своей внутренней логикой. Он может перезапустить под, просто потому что решил перенести его на другую ноду без какой-либо команды с нашей стороны. Он поднимает новый под, а затем убивает старый. Казалось бы, приложение продолжает работать, но на самом деле именно здесь мы столкнулись с тем, чего не ожидали.

Kubernetes не требует мгновенного завершения процессов. Отправляя в контейнер SIGTERM, он ждет некоторое время (таймаут настраивается, по умолчанию равен 30 секундам) и только если по истечении таймаута процесс не завершился, отправляет SIGKILL.

Казалось бы, что тут может пойти не так?

Сигнал SIGTERM, а реакция как на SIGKILL​

Проблема:

При попытке остановить Apache Tomcat Kubernetes слал ему SIGTERM, но вместо ожидаемого “штатного” завершения процесса, Spring Boot мгновенно останавливал веб-сервер, прерывая потоки. Обработка всех уже пришедших запросов прекращалась - сервер возвращал ошибку 503.

Замечание о сложности поиска решения:
Поиск решения логично начинать с перехвата сигналов - кто, кому, в какой момент и что посылает. Но в мире Java, Kotlin, Scala и схожих языков всем правит JVM, которая считает, что все эти сигналы - исключительно для нее, а не для разработчика. Мы можем подписаться на хук JVM, чтобы узнавать, когда в приложение приходят сигналы, чтобы например освободить ресурсы при завершении процесса, но без использования нерекомендованных инструментов нам не узнать, какой изначально пришел сигнал.
Я пробовал реализовать свой слушатель сигналов на JVM, найдя нужные возможности в JDK-пакетах. Выяснилось, что этим подходом я все сломал, т.к. SIGKILL нельзя оверрайдить, а я по факту заменил дефолтные слушатели и процессы вообще перестали останавливаться. После этого я пришел к использованию стандартных инструментов Spring Boot.
Решение:

В новом Spring Boot есть специальная настройка в конфиге:

server.shutdown=graceful
Настройка позволяет реализовать все более логично. Получив SIGTERM, сервер прекращает прием новых запросов, пытается ответить на существующие запросы в разумное время, более осмысленно завершить тяжелые запросы и успеть все это до прихода SIGKILL.

SIGKILL и зависшие джобы из батча​

Среди прочего на проекте мы используем фреймворк Spring Batch для всех повторяющихся работ (для запуска пользуемся аннотацией @Scheduled в Spring Boot). Под капотом у него есть собственная БД, где хранится информация о том, что и когда было запущено, как обработано и какой был результат.

Проблема:

Если убить Spring Batch-приложение во время работы сигналом SIGKILL, то в истории запусков останется "зависшая" джоба. Она навечно останется в статусе "запущена".

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

Решение:

Мы реализовали graceful shutdown для батчей, придерживаясь той же логики, что и в предыдущем примере с веб-сервером:

  • при получении SIGTERM запрещаем запуск новых задач;
  • пытаемся завершить все запущенные задачи;
  • ждем какое-то время (не более ожидания самого Kubernetes);
  • принудительно завершаем все задачи, которые не успели завершиться (помечаем их соответствующим образом в БД).
Профит - когда от Kubernetes приходит SIGKILL все ресурсы уже освобождены.

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

Автор: Дмитрий Литвин, Максилект.

 
Сверху