Если завершение вашей программы или, если хотите, сервиса вы отдаёте полностью на откуп операционной системе, скорее всего это не самый верный путь. И в этом материале мы разберёмся, почему.
Для чего нужен Graceful Shutdown
Вы наверняка слышали такое словосочетание как stateless service. Это сервис, который не хранит никакого состояния внутри себя, а делегирует эту задачу внешнему хранилищу. Под состоянием чаще всего имеют ввиду данные. Стремление к тому, чтобы сервис был “stateless”, т.е. свободным от состояния, является хорошей архитектурной практикой, поскольку облегчает жизнь, когда дело доходит до масштабирования. Иногда это «правило» игнорируется для повышения производительности. Например, большинство современных СУБД хранит часть актуальных данных не на диске, а прямо в памяти процесса. Периодически и, самое главное, перед завершением процесса СУБД актуализирует эти данные на диске.
Если всё, что делает сервис это принимает a и b, и возвращает a+b, то никаких проблем нет, поскольку у сервиса в данном случае нет состояния. Скорее всего большая часть сервисов, которые вы разрабатываете или будете разрабатывать устроены несколько сложнее. Даже если они свободны от состояния в классическом понимании, но обращаются к СУБД, диску или другим сервисам, они всё ещё обладают состоянием, просто оно выражено не данными, а ресурсами. Почему ресурсы, которые мы запрашиваем у операционной системы необходимо освобождать — тема отдельного разговора. Сейчас давайте примем как аксиому, что за очисткой ресурсов важно следить.
Системные ресурсы можно разделить на два вида:
- С коротким жизненным циклом — например, чтение конфигурационного файла или соединение для REST-запроса. Обычно освобождаются сразу после использования.
- С неопределённым жизненным циклом — вебсокеты, стриминг, консьюминг из очереди, кэш на диске и т.д. Информация, которую они предоставляют, требуют быстрого доступа, поэтому такие ресурсы занимаются редко, но надолго.
Второй тип ресурсов я назвал так именно потому, что скорее всего мы не знаем, когда нам придётся их очистить. Интуиция подсказывает, что это имеет смысл делать перед тем как программа завершится. Но когда она завершится тоже неизвестно, строго говоря. Поэтому нам требуется механизм, который позволил бы понять, что операционная система по какой-то причине намерена завершить процесс, чтобы программа могла очистить ресурсы. Таким механизмом как раз и является Graceful Shutdown или изящное завершение программы по-русски.
Как устроен механизм Graceful Shutdown
Операционная система общается с запущенными в ней процессами с помощью сигналов. Часть из этих сигналов отвечает за завершение программы. Круто то, что эти сигналы можно «перехватить» в коде. После перехвата можно их проигнорировать (так делать не нужно) или очистить ресурсы, занятые программой, а затем явно завершить программу.
Давайте перейдем от слов к делу и взглянем на код:
|
|
Обратите внимание, что на завершение программы накладывается ограничение по времени. Это необходимо на случай, если какая-то из операций по очистке ресурсов повисла. Сэмулировать такую ситуацию можно, увеличив время сна в горутине, которая пишет в канал longShutdown
.
«Паттерн» Closer
Если очищать все ресурсы в одном месте программы, очень быстро станет неудобно сопровождать эту часть, к тому же это не всегда удобно или вообще возможно сделать, не ухудшив архитектуру приложения. Для решения этой проблемы существует трюк или паттерн, если угодно.
Можно делегировать машинерию по доставке сигнала на завершение в разные части приложения с помощью специального компонента. Назовём его Closer
. Пусть каждый компонент, заинтересованный в том, чтобы ему сообщили, когда необходимо очистить ресурсы и прекратить работу, сообщит об этом Closer
, зарегистрировав у него обработчик:
|
|
В свою очередь Closer
будет ловить сигналы от операционной системы и следить за тем, чтобы завершение программы не вышло за таймаут.
Давайте переработаем программу, реализовав трюк с Closer
. Для начала создадим папку closer
и положим туда файл closer.go
:
|
|
Во-первых, нам нужен метод, регистрирующий обработчики на завершение:
|
|
Блокировка здесь необходима для того, чтобы не позволить зарегистрировать новый обработчик после того, как началась процедура завершения программы.
Внутри метода Close
по порядку вызываются обработчики. Для того чтобы завершить остановку программы по таймауту, цикл вызовов работает в отдельной горутине, а в «текущей» мы ждём одно из двух событий:
- Все обработчики уложатся в таймаут, и тогда в канал
complete
произойдёт запись; - Наступит дедлайн, и придётся выйти из
Close
принудительно с ошибкой.
Для того чтобы ошибка от одного обработчика не мешала другим, сообщения сначала накапливаются в слайс, а затем возвращается ошибка-агрегат, если есть хотя бы одно сообщение в слайсе:
|
|
Файл main.go
теперь будет выглядеть вот так:
|
|
Если немного поиграться с кодом в main.go
, а именно снова увеличить время сна на строке 43 или раскомментировать код на строках 48-54, можно увидеть, как Closer
реагирует на различные ситуации при завершении программы.
Если обернуть Closer
в синглтон (да, иногда так делать можно), то он не будет привязан к какому-то конкретному компоненту приложения, и тогда каждый компонент при своей инициализации сможет самостоятельно зарегистрировать обработчик на завершение программы.
Для простоты я привёл именно такую реализацию «доводчика» программы, и она обладает недостатком. Заключается он в том, что если один из обработчиков зависнет на время, достаточное для выхода за таймаут, Closer
не вызовет все последующие, т.к. при выходе за таймаут, процедура завершения программы мгновенно останавливается.
Предлагаю вам подумать, как устранить данный недостаток, используя возможности Go для работы с конкурентным кодом.
Полезные ссылки
- «Graceful Shutdown в Go-сервисах и как подружить его с Kubernetes» — Артемий Рябинков
- “Implementing Graceful Shutdown in Go” — Leonidas Vrachnis
- Тред на Quora о том, почему важно закрывать открытые файлы