Это статья об основах программирования на Go. На канале я рассказываю об опыте перехода в IT с нуля, структурирую информацию и делюсь мнением.
Хой, джедаи и амазонки!
В предыдущих публикациях рассказывал об участии в Мастерской Яндекс Практикума - проекте, где можно было поработать над реальным проектом в команде под руководством опытного разработчика. Одной из задач, которые я выполнил, была реализация в приложении безопасного завершения работы.
Тема эта объёмная, в этой публикации я покажу минималистичный способ безопасно завершить работу приложения, который принял в ревью ментор. Схему приложения я упрощу: оставлю только обработчик на эндпоинт /ping, а о функции безопасного завершения и используемых механизмах расскажу подробнее.
1. Зачем нужен Graceful Shutdown?
1.1. Введение
Graceful Shutdown - это механизм внутри ПО, который позволяет безопасно завершить работу этого ПО при его остановке.
Graceful Shutdown (далее - GS) можно перевести с английского языка как изящное завершение или плавное завершение.
Для чего завершать работу и как это может происходить?
При разработке учебных проектов мы завершаем работу приложения, прежде всего сетевого, нажатием комбинаций клавиш в терминале: Ctrl+C. По крайней мере я так делал. Можно терминал закрыть или вообще компьютер перезагрузить. Завершаем, как правило, после того как всё протестировали и порадовались его корректной работе или для устранения багов и перезапуска. Другие приложения сами отрабатывали и завершались, не требуя особых действий: например, читали из файла и выводили его содержимое в терминал или выполняли другой подобный алгоритм.
В реальности приложения могут останавливать, например, для обновления, переконфигурирования или для техобслуживания аппаратного сервера, или перезагружаться из-за критичных ошибок или ещё чего-то.
Хорошо, приложение можно остановить. Какая здесь может быть проблема? Что вообще значит безопасно завершить работу?
Под безопасностью подразумевается, что при закрытии приложения, ему даётся время для завершения обработки данных. Обычно это заключается в следующем:
- Запрет на запуск новых задач. Например, прекращается прослушивание порта и следовательно, обработки веб-запросов идущих на этот порт.
- Попытка корректно завершить все запущенные задачи за некоторое время или иное условие, которое можно настроить. Например, закончить обработку ранее полученных веб-запросов и вернуть ответ клиенту.
- Принудительно завершаем все незаконченные задачи по истечению таймаута или какого-то иного условия.
Под безопасностью подразумевается корректная обработка данных без их безвозвратной потери.
1.2. Сценарий использования
Рассмотрим ситуацию. Мы - команда разработчиков и развиваем страртап, который может складывать числа. Пользователь в браузере отправляет два числа, наш сервер обрабатывает запрос и возвращает пользователю сумму. Такой стартап.
Допустим мы решили обновить наше приложение: теперь оно может не только складывать числа, но ещё и умножать. Для этого решили остановить наш программный сервер и нажали Ctrl+C в терминале, чтобы обновить ПО.
Проблемы с безопасностью едва ли возникнут, разве что мы успеем завершить приложение между тем, как приложение получило запрос и вернуло ответ.
Более реалистичные проблемы с безопасностью обработки данных возникнут, когда появится база данных, появится внутренний клиент, который обращается к стороннему серверу, или просто данные будут накапливаться в буфере (в ОЗУ) до записи их в ПЗУ. В общем, если есть какая-то продолжительная обработка данных.
Вот в таких случаях если резко остановить приложение, это будет небезопасным завершением - данные потеряются. Например, человек писал длинное письмо, старался - отправил его своему другу через почтовый сервер. Сервер получил письмо но в этот момент резко остановился: письмо нигде не сохранилось и не доставлено. Придётся писать и отправлять по-новой.
Чтобы этого не произошло, в приложение добавляется код, который обрабатывает сигналы от операционной системы о необходимости завершения работы.
1.3. Сигналы ОС
ОС общается с приложением через разнообразные сигналы. Для остановки приложения у ОС есть три сигнала: SIGINT, SIGTERM, SIGKILL.
Эти сигналы в порядке приоритетов остановки. Можно сказать, что сигнал SIGINT говорит приложению "готовься потихоньку к остановке", SIGKILL - "давай мгновенно отключайся". SIGTERM - нечто среднее.
Но это описательные правила, в действительности все эти сигналы можно обработать в приложении как угодно, вплоть до их игнорирования. Можно накодить такого, что остановить приложение можно будет только перезагрузкой компьютера.
Если в коде никак эти сигналы не обрабатываются, то при получении любого из сигналов, приложение сразу останавливается.
Я в работе мастерской обрабатывал два сигнала - SIGINT и SIGTERM как один. SIGKILL не обрабатывал, т.е. при получении приложением такого сигнала от ОС, мой GH не активировался бы, и всё приложение мгновенно остановилось.
SIGINT - это аналог комбинации клавиш Ctrl+C в терминале.
В Go для работы с сигналами есть пакет syscall. Вот фрагмент из документации по этому пакету, среди них есть три интересующих нас:
То же самое в большем масштабе:
Также используется пакет os и os/signal.
В частности, в пакете os есть константы сигналов:
Переходим к коду.
2. Реализуем Graceful Shutdown
2.1. Псевдокод
Расскажу, как работает приложение:
- Определяем константы для приложения: эндпоинт, текст ответа клиенту, адрес хоста и таймаут грейсфул-шутдауна.
- Создаём мультиплексор маршрутизации, который отвечает за распределение входящих http-запросов между обработчиками. В учебных проектах мы используем мультиплексор по-умолчанию, не создавая их (просто пишем http.HandleFunc(...). Здесь же полезнее создать мультиплексор.
- Пишем обработчик для эндпоинта /ping, который выводит логи о получении запроса и завершении запроса, моделирует процесс длительной работы через time.Sleep (например, обращение к удалённой БД) и возвращает клиенту слово pong.
- Создаём экземпляр программного сервера с привязкой к мультиплексору и адресу сервера (IP-адрес компьютера на котором работает сервер + порт этого компьютера к которому привязываем наше сетевое приложение).
- Запускаем сервер в новой горутине (первая/главная горутина - функция main). Обрабатываем ошибки работы сервера особым образом. Запуск в новой горутине нужен, чтобы асинхронно обрабатывать два типа данных: веб-запросы и сигналы от ОС.
- В главной горутине создаём канал сигналов. Указываем, какие сигналы могут попасть в этот канал.
- Пробуем читать из этого канала. Пока что-то не попадёт в этот канал, код в функции main далее не будет исполняться. Т.к. горутина блокируется попыткой чтения из пустого канала.
- Если что-то попало в канал, происходит продолжение исполнения кода в функции main и запускаем функцию GS.
- В функции GS используем специальную функцию по плавной остановке имеющегося сервера, помещаем в него информацию о максимально допустимом времени продолжения работы работающих в нём задачах до его принудительной остановки. Если все задачи сервера завершены до таймаута, приложение завершается безопасно.
- Приложение остановлено.
2.2. Код
Код в GitGub: *клик*
Рассмотрим его скриншотами:
2.3. Тестируем приложение
2.3.1. Случай №1
Запустим приложение и отправим запрос из браузера:
Получен запрос - через три секунды отправлен ответ. Напомню, я задержку сделал для моделирования процесса длительной обработки данных. Сейчас она составляет 3 секунды, а таймаут GS составляет 10 секунд.
Завершим работу сервиса комбинацией клавиш в терминале Ctrl+C:
Судя по логам, приложение остановилось мгновенно: т.к. не было активных запросов.
2.3.2. Случай №2
Перезапустим приложение, отправим запрос и быстро вернёмся в терминал, чтобы завершить приложение. Результат:
Сервер получил сигнал о завершении приложения до того, как ответ был отправлен. Однако приложению было дано время завершить обработку данных и вернуть ответ.
2.3.3. Случай №3
А теперь сократим таймаут до 1 секунды, перезапустим приложение, вновь отправим запрос и быстро вернёмся в терминал чтобы остановить приложение:
Маленький таймаут для GS или длительная операция обработки данных приводят к тому, что данные всё же могут теряться.
В более сложных приложениях это может лечиться например сохранением каких-то исходников в БД, а при перезагрузке чтением их этой БД для восстановления процесса.
В любом случае, мы протестировали возможный сценарий, когда при наличии GS часть данных может теряться.
2.3.4. Случай №4
Отдельно хочу сказать об обработке ошибок при запуске сервера.
В наших учебных проектах мы часто писали так:
http.ListenAndServe(...)
или в более усовершенствованной форме:
err := http.ListenAndServe(...)
if err!=nil{
log.Fatal(err)
}
Здесь же я игнорирую одну ошибку:
http.ErrServerClosed - это не совсем ошибка, это информация, сигнализирующая, что сервер прекратил приём запросов. Здесь такая форма используется, чтобы не путать такую ситуацию с настоящими ошибками при работе или запуске сервера.
Если удалить обработку этой ошибки:
Восстановить константу таймаута с 1 до 10 секунд:
И протестировать приложение по сценарию №2, то вот что получим:
Выведен лог об ошибке сервера, что может ввести в заблуждение. А в целом, приложение завершилось безопасно.
На этом с основами Graceful Shutdown всё.
3. Выводы
В этой публикации познакомились с основами Graceful Shutdown: для чего он нужен, как реализовать простейшим образом, как работает. Напомню, чтобы реализовать Graceful Shutdown укрупнённо, нужен канал и контекст. Также GS обычно применяется в отношении веб-серверов, поэтому ещё нужна структура веб-сервера.
На этом всё, благодарю, что дочитали публикацию до конца. Успехов и будем на связи.
Бро, ты уже здесь? 👉 Подпишись на канал для начинающих IT-специалистов «Войти в IT» в Telegram, будем изучать IT вместе 👨💻👩💻👨💻