Создание демона Python с использованием Systemd

Создание демона Python с использованием Systemd

Недавно у меня возникла задача создать демон (фоновое приложение) реализованный на Python в системе Linux использующей Systemd. В поисках современного решения и родилась данная статья. Ранее для реализации демона выполнялась "демонизация" приложения Python, зачастую с помощью библиотеки python-daemon (opens new window). Даже была создана спецификация pep-3143 (opens new window) для реализации демонов. Но на текущий момент времени с использованием Systemd нет необходимости демонизировать наше Python приложение, достаточно корректно описать его запуск в юните. Для начала рассмотрим как работает Systemd.

Все операции указанные в данной статье выполнялись в окружении Linux Ubuntu 20, с установленным Python 3.8.

# Systemd Unit

Systemd позволяет запустить сервисы находящиеся в его конфигурации. Конфигукация состоит из юнитов, отдельных файлов расположенных в следующих каталогах:

  • /usr/lib/systemd/system/ - юниты из установленных пакетов, такие как nginx, postgreee и др.
  • /run/systemd/system - юниты созданные в runtime
  • /etc/systemd/system - юиниты, созданные администратором, в основном пользовательские юниты должны храниться здесь.

Для создания юнита нам необходимо описать 3 секции: [Unit], [Service], [Install] Основные переменные блока [Unit]:

[Unit]
Descripiton=Unit Descripion
After=syslog.target  
After=network.target  
After=nginx.service  
After=mysql.service
Requires=mysql.service
Wants=redis.service

где:

  • Description - описание юнита
  • After - указывает, что юнит должен быть запущен после группы указанных сервисов
  • Requires - узказывает, что для запуска юнита требуется запущенный сервис mysql, запуск нашего сервиса выполняется паралллельно с требуемым (mysql), если требуемый не указан в After
  • Wants - описательная переменная, показывающая, что для запуска сервиса желателен запущенный сервис redis
[Service]
Type=simple
PIDFile=/var/lib/service.pid
WorkingDirectory=/var/www/myapp
User=user
Group=user
Environment=STAGE_ENV=production
OOMScoreAdjust=-100
ExecStart=/my_venv/bin/python my_app.py --start
ExecStop=/my_venv/bin/python my_app.py --stop
ExecReload=/my_venv/bin/python my_app.py --restart
TimeoutSec=300
Restart=always

где:

  • Type - тип запуска: simple (по умолчанию) запускает службу незамедлительно при этом процесс не должен разветвляться, не подходит если другие службы зависят от очередности при запуске данной службы; forking - служба запускается однократно и процесс разветвляется с завершением родительского процесса; другие типы можно рассмотреть по ссылке ниже.
  • PIDFile - позволяет задать место нахождения pid файла
  • WorkingDirectory - указывает рабочий каталог приложения, если указан то ExecStart|Stop|Reload запускаются из этого каталога, т.е. my_app.py станет /var/www/myapp/my_app.py
  • User, Group - соответственно пользователь и группа под которыми будет запущен сервис.
  • Environment - переменные окружения
  • OOOMSCoreAdjust - запрет на kill сервиса вследствие нехватки памяти и срабатывания механизма ООМ: -1000 полный запрет, -100 понижает вероятность.
  • ExecStart|Stop|Reload - команды запуска, останова, перезагрузки сервиса, команда должна использовать абсолютный путь к исполняемому файлу.
  • Timeout - время ожидания systemd отработки команд Start|Stop в сек.
  • Restart - перезапуск сервиса если он упадет.
[Install]
WantedBy=multi-user.target

где:

  • WantedBy - уровень запуска нашего сервиса, mulit-user.target или runlevel3.target соответствует runlevel=3 "Многопользовательский режим без графики"

Размещаем данный файл с указанными секциями в директории /etc/systemd/sysmtem/<имя_сервиса>.service

Проверяем его статус:

systemctl -l status test_unit

Разрешаем его запуск:

systemd enable test_unit

Запускаем наш сервис

systemctl start test_unit

При внесении изменений в наш сервис перегружаем его:

systemctl daemon-reload

Более подробная документация Systemd на Русском (opens new window)

# Python приложение

Разобрав как работает Systemd можем приступить к созданию нашего сервиса. Для ознакомления создадим приложение Python которое будет выводить сообщения в log файл test_daemon.py:

import time
import argparse
import logging

logger = logging.getLogger('test_daemon')
logger.setLevel(logging.INFO)
formatstr = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
formatter = logging.Formatter(formatstr)

def do_something():
	"""
	Здесь мы только лишь пишем сообщение в Log, но можем реализовать
	абсолютно любые задачи выполняемые в фоне.
	"""
    while True:
        logger.info("this is an INFO message")
        time.sleep(5)


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Example daemon in Python")
	# Мы можем заменить default или запускать приложение с указанием нахождения 
	# log файла, через параметр -l /путь_к_файлу/файл.log
    parser.add_argument('-l', '--log-file', default='/home/user/test_daemon.log')
    args = parser.parse_args()

    fh = logging.FileHandler(args.log_file)
    fh.setLevel(logging.INFO)
    fh.setFormatter(formatter)
    logger.addHandler(fh)
    
    do_something()

Проверим работоспособность нашего скрипта:

python test_daemon.py 

Проверим что log-файл создан и в него пишутся сообщения:

tail -f /home/user/test_daemon.log

Теперь создадим юнит Systemd для запуска демона. В директории /etc/systemd/system файл test_daemon.service с содержанием:

[Unit]
Description=Test daemon
After=syslog.target

[Service]
Type=simple
User=user
Group=user
WorkingDirectory=/home/user/
ExecStart=/usr/bin/python3 test_daemon.py

[Install]
WantedBy=multi-user.target

В данном юните следует изменить пользователя и группу User, Group на вашего пользователя. Также указать в качестве рабочего каталога WorkingDirectory абсолютный путь к директории где находится файл test_daemon.py.

В случае использования venv в параметре ExecStart следует указать путь к python внутри venv, т.е. заменть /usr/bin/python3 на <путь_к_venv>/venv/bin/python

Проверяем статус нашего сервиса:

systemctl -l status test_daemon

Разрешаем его запуск:

systemd enable test_daemon

Запускаем наш сервис

systemctl start test_daemon

Проверим отображение данных в логе:

tail -f /home/user/test_daemon.log

Наш демон заработал, но осталась не решенной еще одна задача. Зачастую при остановке нашего демона требуется выполнить каие либо задачи (сохранить состояние приложения, отправить уведомление, выполнить очистку данных и т.п.), но на текущий момент при останове наш демон просто закрывается.

# Обработка сигналов завершения

В стандартной библиотеке Pyhton реализован модуль sygnal позволяющий обрабатывать сигналы UNIX-based операционной системы. Полный перечень сигналов можно посмотреть по команде:

kill - l

Демон Systemd при выполнении операции останова демона отправляет изначально сигнал 15 (SIGTERM) - нормальный останов процесса. По умолчанию если в течение 30 сек. не будет получен сигнал выхода приложения будет отправлен сигнал жесткого завершения процесса 9 (SIGKILL).

Таким образом в нашем приложении необходимо предусмотреть обработчик сигнала 15 (SIGTERM), как результат наш скрипт test_daemon.py примет следующий вид:

import time
import argparse
import logging
import sys
import signal

logger = logging.getLogger('test_daemon')
logger.setLevel(logging.INFO)
formatstr = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
formatter = logging.Formatter(formatstr)

def terminate(signalNumber, frame):
	"""
	Здесь мы можем обработать завершение нашего приложения
	Главное не забыть в конце выполнить выход sys.exit()
	"""
    logger.info(f'Recieved {signalNumber}')
    sys.exit()

def do_something():
	"""
	Здесь мы только лишь пишем сообщение в Log, но можем реализовать
	абсолютно любые задачи выполняемые в фоне.
	"""
    while True:
        logger.info("this is an INFO message")
        time.sleep(5)


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Example daemon in Python")
	# Мы можем заменить default или запускать приложение с указанием нахождения 
	# log файла, через параметр -l /путь_к_файлу/файл.log
    parser.add_argument('-l', '--log-file', default='/home/user/test_daemon.log')
    args = parser.parse_args()
	
	signal.signal(signal.SIGTERM, terminate)

    fh = logging.FileHandler(args.log_file)
    fh.setLevel(logging.INFO)
    fh.setFormatter(formatter)
    logger.addHandler(fh)
    
    do_something()

Хорошая статья о работе signals на английском (opens new window)

Теперь при останове нашего демона:

systemctl start test_daemon

Мы увидим в лог файле сообщение Recieved 15

2021-06-27 15:06:48,024 - test_daemon - INFO - this is an INFO message
2021-06-27 15:06:53,029 - test_daemon - INFO - this is an INFO message
2021-06-27 15:06:56,971 - test_daemon - INFO - Recieved 15

Таким образом мы создали шаблон демона который в дальнейшем может быть использован для решения реальных фоновых задач.