Разработка системы с элегантным завершением работы может оказаться той ещё пляской с бубном. В идеальном мире каждый сервис управлялся бы юнитом systemd. ExecStart запускала бы процесс, обрабатывающий SIGTERM, а ExecStop оповещало бы процесс и осуществляло блокировку, которая бы корректно завершала процесс и его ресурсы. Однако многие программы завершаются некорректно, а то и вовсе сбивают все настройки при закрытии. В этой статье мы рассмотрим поведение systemd при завершении работы и методы написания юнитов systemd для выборочной очистки (custom cleanup) перед закрытием. Подробности — к старту нашего курса по DevOps. Как и система init, systemd наряду со многими другими задачами управляет сервисами — от запуска до завершения работы. При загрузке она запускается первой, а при закрытии останавливается последней. В отличие от более ранних последовательных скриптов (sequential scripts) сервисы systemd ориентированы на юниты systemd. Их отношения построены на зависимости и упорядочивании. Это позволяет запускать (или закрывать) многие сервисы параллельно. Всё это важно для статьи в дальнейшем: Что происходит при завершении работы? Несколько подчинённых команд systemctl (subcommands, указаны ниже) завершают работу системы, при этом они активируют специальные юниты systemd: reboot.target, poweroff.target и halt.target. Команда Requires этих целевых юнитов запрашивает извлечение таких зависимостей, как systemd-reboot.service, systemd-poweroff.service, и systemd-halt.service (соответственно), а те в обратном порядке запрашивают shutdown.target. Для всех юнитов и областей действия (scopes) по умолчанию установлено DefaultDependencies=yes, таким образом, для них в явном виде добавлены выражения Before=shutdown.target и Conflicts=shutdown.target. Conflicts запускает операцию shutdown.target и останавливает конфликтующие юниты. Запуск shutdown.target параллельно останавливает все конфликтующие юниты (если не предусмотрено иное). При прекращении работы юниты systemd должны корректно завершить запущенные процессы, освободить ресурсы и дождаться завершения работы. Система распределения нагрузки (load balancer) может перестать принимать новые соединения и отключить свою конечную точку готовности (readiness endpoint). База данных может сбрасывать данные на диск, а агент может сообщить кластеру о своём выходе из группы. Любые процессы, которые продолжают работать после выполнения ExecStop завершаются некорректно (то есть принудительно) командой SIGKILL в systemd (при отсутствии иных настроек). Многие программы завершаются некорректно, а то и вовсе сбивают все свои настройки, отклоняются от модели systemd. Некоторые задачи по завершению работы требуют согласования с кластерной системой. Инструменты типа systemd-analyze при отключении не помогают. А многие сервисы могут даже не являться вашим программным обеспечением. В этих случаях могут помочь действия юнитов по очистке на ранних стадиях выключения (early shutdown units). Рассмотрим некоторые стратегии написания юнитов systemd, которые выполняют заданные пользователем действия по очистке перед завершением работы. Начнём с простого скрипта cleanup, который симулирует задачу по очистке. Команда echo записывает сообщения, цикл показывает прогресс, а sleep гарантирует, что задача выполняется достаточно долго, чтобы наверняка дождаться последующих действий. Заметим, что этот скрипт не зависит от сети, контейнеров и других системных компонентов: Поместим этот скрипт в каталог /usr/local/bin/cleanup и сделаем его исполняемым. Если позднее вы увидите ошибку systemd 203/EXEC, вернитесь к этому разделу. Не помещайте этот скрипт в домашний каталог. Политика SELinux по умолчанию не даёт устройствам systemd выполнять «домашние» скрипты. И это корректно. Теперь рассмотрим блок systemd oneshot, который запускает cleanup как скрипт ExecStart. Это будет наша первая и, как мы скоро увидим, ошибочная попытка. Этот юнит создаёт зависимость shutdown.target от clean.service, использует Before=shutdown.target при отдаче команды clean.service перед shutdown.target, использует Type=oneshot, чтобы сервис считался запущенным при выходе из скрипта (чтобы отложить выключение компьютера до завершения очистки). RemainAfterExit оставляет сервис активным даже после завершения скрипта. По умолчанию юниты systemd (Type=simple) считаются запущенными одновременно с процессом. Это, к примеру, позволяет последующим юнитам начать работу немедленно. Активируем clean.service, systemctl reboot, потом посмотрим журнал истории операций… Это неверный подход. Мы не видим даже истории. Сервис очистки может запуститься, но это не отсрочит завершение работы. У нас shutdown.target вступает в конфликт со всеми юнитами, поэтому clean.service прерывается до завершения. Вы могли бы добавить DefaultDependencies=no, но, как мы может прочесть в systemd.unit man pages, это тоже не отсрочит завершение работы: «Given two units with any ordering dependency between them, if one unit is shut down and the other is started up, the shutdown is ordered before the start-up» («При любой зависимости между двумя юнитами, один из которых начинает свою работу, а другой завершает её, команда завершения работы будет выполняться раньше»). Теперь рассмотрим юнит oneshot в systemd, который запускает очистку скриптом ExecStop. Этот юнит извлекается multi-user.target, но в процессе запуска получает команду на выполнение After=multi-user.target довольно поздно. Поскольку командно управляемые (ordered) юниты закрываются в обратном запуску порядке, юнит clean должен начать закрываться раньше других юнитов. Активируем и запускаем clean.service. Подтверждаем, что он active (хотя мы и вышли из него). Обратите внимание на Boot ID в журнале операций, затем запустите systemctl reboot: Проверим журнал после перезагрузки. Когда была запущена systemctl reboot, я указал пометкой 🔁. Юнит прекращает работу, но скрипт cleanup в это время работает до победного, что задерживает завершение работы. После этого происходит перезагрузка. При следующей загрузке юнит запускает (/bin/true), после чего его работа завершается. Всё работает, как и ожидалось, но с одной оговоркой. Работа командно управляемых юнитов завершается в обратном запуску порядке. И вот тут стоит вспомнить, сколько сервисов прекращает работу единовременно. Наш скрипт cleanup не даёт гарантий, что те или иные сервисы будут работать при выполнении в ExecStop. Сам же cleanup работает только потому, что не зависит от других компонентов системы. Если бы он, например, зависел от сети, нам бы пришлось добавить After=network.target, чтобы при завершении работы его работа закончилась раньше, чем перестанут работать сетевые сервисы. Теперь перейдём к задаче очистки в контейнерах, которая в наши дни становится обычным делом. Рассмотрим блок systemd, который запускает контейнерный процесс в ExecStop перед выключением. В отличие от минималистичного скрипта, которым мы пользовались ранее, запуск этого контейнера требует работы множества системных компонентов. ExecStop запускается одновременно с другими юнитами, прекращающими работу. Успешного выполнения это не сулит. Активируем и запустим clean.service. При ручной деактивации clean.service ExecStop создаст контейнер, монтирует путь /usr/local/bin и нормально запустит тот же cleanup. Но в ходе реального завершения работы ExecStop не сможет создать контейнер. Я совершал эту ошибку сам и видел, как пользователи борются с ней, не осознавая её. В современных бездемонных контейнерных движках трудно определить упорядочивание зависимостей, чтобы гарантировать, что контейнер можно надёжно запустить в ходе завершения работы. Это открытая проблемная область, которая зависит от движка контейнеров. Теперь мы стали умнее. Если нельзя просто так взять и стартануть контейнер при завершении работы, пусть контейнер уже исполняется и ожидает сигнала. Создадим новый скрипт, отражающий этот подход, и назовём его await. Запустим этот скрипт напрямую и убедимся, что он ждёт, а затем по нажатию Ctrl-C печатает сообщения. Позаботимся о том, чтобы bash-скрипт await запускался в контейнере как процесс №1 и обрабатывал сигналы. Невстроенные команды (например, sleep) могут откладывать обработку сигналов в неинтерактивных контекстах. Поэтому важно сделать sleep фоновым и использовать встроенную (прерываемую) команду wait для ожидания предыдущей команды. Создаём, активируем и запускаем новый юнит clean.service в systemd. Это типичный юнит с длительным временем выполнения и Type=simple. Podman запускает контейнер и проксирует сигналы (такие как SIGTERM при завершении работы) первому процессу контейнера. При прекращении работы юнита podman перестаёт отправлять сигнал SIGTERM первому процессу контейнера (и SIGKILL после --stop-timeout, что по умолчанию требует 10 секунд). Как и ранее, юнит извлекается multi-user.target, но получает команду на запуск After (после) multi-user.target, на довольно поздней стадии запуска. Командно управляемые (ordered) юниты прекращают работу в обратном запуску порядке, поэтому прекращение работы для clean начинается раньше, чем для других юнитов. Сервис TimeoutStopSec определяет, как долго systemd должна ждать выполнения ExecStop до выполнения SIGKILL. Подтверждаем активность юнита, дожидаемся сигнала. Обратите внимание на идентификатор загрузки и запустите systemctl reboot (с пометкой 🔁). После перезагрузки проверьте журнал операций. Задача очистки выполняется и задерживает завершение работы, однако эта задача выполняется дважды. Запущенный контейнерный процесс получает SIGTERM, и podman stop также отправляет SIGTERM. Воспроизведём это при запуске скрипта await напрямую. Нажмём Ctrl-C много раз — и увидим многократный вызов cleanup. Исправить это можно несколькими способами. Первый — удаление вызова podman stop из ExecStop. В данном случае помехой является то, что при нормальной работе systemctl такие операции, как systemctl stop clean.service, работать не будут, и нам потребуется отправить контейнеру SIGTERM самостоятельно при отладке. Давайте лучше зачистим или сбросим (clear/reset) эту ловушку. Обрабатывать сигналы и параллелизм в скриптах оболочки не очень удобно, в Go было бы куда лучше. Но пока оставим это решение. Перезагрузим и перезапустим clean.service. Обратим внимание на Boot ID в журнале операций, затем попытаемся снова запустить systemctl reboot (с пометкой 🔁). Убедимся, что cleanup запущен не более одного раза (3 сообщения waiting...), а также что завершение его работы отсрочено. Есть ещё одно возможное исправление. Код завершения 143 указывает на выход из контейнера из-за запроса на корректное завершение работы SIGTERM; systemd отмечает это как ошибку, мы же скорее назовём это успехом. Установим статус сервиса SuccessExitStatus. Как мы убедились, завершение работы при помощи юнита systemd — дело тонкое. А ведь мы уделили внимание только фазе раннего выключения, а не тем сервисам (или операциям), которые должны прекращать работу как можно позднее (например, журналы событий). Тем не менее мы рассмотрели несколько возможных стратегий. В следующем посте мы применим эти методы к Kubernetes Kubelet. Сервис Kubelet регистрируется в кластере Kubernetes и запускает контейнеры через среду для запуска контейнеров (container runtime) с такими функциями, как хуки preStop, terminationGracePeriod и бюджеты прерывания (disruption budgets). Остановка сервиса Kubelet не оповещает кластер и не останавливает контейнеры, при этом (до недавнего времени) ни один из них не отключался. Хотите больше такого контента? Рассмотренные в этой статье примеры доступны на blog-bits по лицензии MPL 2.0. Они тестировались на Fedora CoreOS 36.20221001.3.0 (systemd 250.8). Если вы нашли баг, пожалуйста, пришлите свой вариант исправления, и я постараюсь обновить код. Научим вас аккуратно работать с данными, чтобы вы прокачали карьеру и стали востребованным IT-специалистом. Новогодняя акция — скидки до 50% по промокоду HABR:  Data Science и Machine Learning Python, веб-разработка Мобильная разработка Java и C# От основ — в глубину А также