PHP. Как создать программу-демон на PHP (daemon)

Демон - это программа в системах класса UNIX, запускаемая самой системой и работающая в фоновом режиме без прямого взаимодействия с пользователем. Если это понятие применить к нашей задаче, т.е. к языку программирования PHP, то можно сделать следующие выводы: Демон - это обычный PHP скрипт выполняемый интерпретатором. PHP демон работает на сервере в фоновом режиме и не взаимодействует на прямую с пользователем.

Самый простой вариант реализации

Применимо к PHP, это скрипт, который может работать самостоятельно, без остановок и без участия пользователя. Как получить такой скрипт? На самом деле, очень просто, нужно лишь нарушить одно из первых правил программирования, которому учат в школе, и создать бесконечный цикл:

// Чтобы программа работала постоянно, она просто должна постоянно работать ;)
while(1) {
    // Тут будет располагаться код Демона
    // ...
    // Время сна Демона между итерациями (зависит от потребностей системы)
    sleep(1); 
}

Простой до невозможности код вызывает, всё же, несколько вопросов. Как его запустить? Как отслеживать его выполнение? Как его остановить?

Как запустить php-демона

А как вообще запускают php-скрипты? Если это веб-приложение, то при помощи браузера и веб-сервера. Но этот вариант не подходит, ведь мы имеем дело с бесконечным скриптом, а время выполнения скриптов ограничены директивой max_execution_time в php.ini. Следовательно, бесконечный скрипт необходимо запускать через консоль, ведь тогда максимальное время его выполнения не учитывается. Примерно так выглядит команда запуска демона:

php -f /path/to/your/daemon.php &

Для ручного запуска её нужно ввести в ssh терминале (putty, WinSCP и т.д.), а для запуска системой при загрузке - в соответствующий файл автозагрузки (положение и название файла зависит от операционной системы). Обратите внимание, что консольный скрипт демона запускается в фоновом режиме, не вовлекая пользователя в ожидание его завершения (ведь скрипт бесконечен). Именно в наличии возможности запустить процесс в фоновом режиме и лежит причина того, что описываемый мной способ не подходит для Windows-серверов. После запуска в консоли должен отобразиться идентификатор процесса нашего демона, так называемый PID.

Отслеживание и остановка демонов

Проверить, запущен ли процесс демона можно просто открыв список процессов в системе:

ps -aux

Найти демона в списке процессов несложно, как по команде запуска, так и по PID:

USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND 
... 
root 22193 0.1 0.2 393180 72132 ? S Apr24 5:05 php -f /path/to/your/daemon.php &

Остановить процесс демона можно так же, как и любой другой процесс:

kill xxxx

В приведённом примере xxxx - это и есть PID, идентификатор процесса.

Улучшаем реализацию. Вариант решения через расширение PCNTL

Для реализации демона на php нам понадобится расширение PCNTL. Данное расширение дает возможность управлять запущенными процессами. Убедились, что данное расширение присутствует на Вашем сервере! Тогда рассмотрим каркас простейшего демона.

$stop = false;
/**
 * pcntl_fork() - данная функция разветвляет текущий процесс
 */
$pid = pcntl_fork();
if ($pid == -1) {
    /**
     * Не получилось сделать форк процесса, о чем сообщим в консоль
     */
    die('Error fork process' . PHP_EOL);
} elseif ($pid) {
    /**
     * В эту ветку зайдет только родительский процесс, который мы убиваем и сообщаем об этом в консоль
     */
    die('Die parent process' . PHP_EOL);
} else {
    /**
     * Бесконечный цикл
     */
    while(!$stop) {
        /*
         * Тело демона
         */
    }
}
/**
 * Установим дочерний процесс основным, это необходимо для создания процессов
 */
posix_setsid();

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

Бесконечный цикл реализован при помощи конструкции while и переменной $stop. Данная переменная нам необходима для корректного завершения процесса, например, так:

...
    while(!$stop) {
        /*
         * Тело демона
         */
        for ($i = 0; $i < 10; $i++) {
            file_put_contents('/var/data/tmp/' . $i . '.txt', time());
            sleep(2);
        }
        //Завершим корректно выполнение демона
        $stop = true;
    }
...

В данном примере демон за 20 секунд создает 10 файлов и присваивает переменной $stop значение true, на следующей итерации бесконечный цикл while прекратится и программа завершит работу. В данном примере мы рассмотрели однопроцессорный демон на языке программирования PHP

Запуск демона

Для запуска подключаемся по SSH и запускаем скрипт:

/usr/bin/php /var/data/deamon.php & >/dev/null

Добавим обработку сигналов в скрипте

...
//Без этой директивы PHP не будет перехватывать сигналы
declare(ticks=1);

//Обработчик
function sig_handler($signo) {
    switch ($signo) {
         case SIGTERM:
             // Обработка задач остановки
             exit;
             break;
         case SIGINT:
             // обработка CTRL+C
             break;
         case SIGHUP:
             // обработка задач перезапуска
             break;
         case SIGUSR1:
             echo "Получен сигнал SIGUSR1...\n";
             break;
         default:
             // Обработка других сигналов
     }
}

// Установка обработчиков сигналов. Подробнее читаем тут https://www.php.net/manual/ru/function.pcntl-signal.php
pcntl_signal(SIGTERM, "sig_handler");
pcntl_signal(SIGHUP,  "sig_handler");
pcntl_signal(SIGINT,  "sig_handler");
pcntl_signal(SIGUSR1, "sig_handler");

Поддержание уникальности демона

Обычно для этих целей используются т.н. .pid-файлы — файл, в котором записан pid данного конкретного демона, если он запущен.

function isDaemonActive($pid_file) {
    if( is_file($pid_file) ) {
        $pid = file_get_contents($pid_file);
        //проверяем на наличие процесса
        if(posix_kill($pid,0)) {
            //демон уже запущен
            return true;
        } else {
            //pid-файл есть, но процесса нет 
            if(!unlink($pid_file)) {
                //не могу уничтожить pid-файл. ошибка
                exit(-1);
            }
        }
    }
    return false;
}

if (isDaemonActive('/tmp/my_pid_file.pid')) {
    echo 'Daemon already active';
    exit;
}

А после демонизации — нужно записать в pid-файл текущий PID демона.

file_put_contents('/tmp/my_pid_file.pid', getmypid());

Контролирование процессов

Контролировать процессы можно через  Supervisor

$ apt install supervisor

Настройка Supervisor состоит из двух частей: конфигурационного файла supervisord и самой программы, предназначенной для постоянного выполнения в фоне.

Конфиг сервиса

Конфигурационные файлы сервисов Supervisor находятся в /etc/supervisor/conf.d. Логично будет назвать сервис как поддомен для сайта, к которому этот сервис относится, то есть для example.com это будет  service.example.com.conf. Если же сервис один, то можно не усложнять и назвать его как самого пользователя.

Если задачи выполняются от пользователя  example.com  из корня сайта который живет в домашнем каталоге этого пользователя, то конфигурационный файл получается следующий:

[program:service.example.com]
user = example.com
command = php bin/run-service.php
directory = /home/example.com/www
numprocs = 1
autorestart = true
autostart = true
stdout_logfile = /home/example.com/www/logs/supervisor_service.log
stderr_logfile = /home/example.com/www/logs/supervisor_service_errors.log
stopwaitsecs = 60

С этим конфигом для запуска сервиса Supervisor сначала перейдет в /home/example.com/www, затем запустит команду php bin/run-service.php и будет следить за её работой, перезапуская при падении. Описание всех директив можно найти в официальной документации.

Чтобы Supervisor увидел новый сервис нужно перезапустить его самого.

$ sudo service supervisor restart

Для проверки перезапустим сервис и остановим его, подождав немного:

$ supervisorctl restart service.example.com
$ supervisorctl stop service.example.com

Что ещё нужно иметь ввиду?

После обновления исходных кодов нужно обязательно перезапускать все подобные сервисы чтобы они использовали новую кодовую базу. Это можно делать прямо из хука post-receive или из Makefile, если такой используется для сборки ресурсов после выгрузки новый версии.

$ sudo /usr/bin/supervisorctl restart service.example.com

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

$ %users ALL = NOPASSWD: /usr/bin/supervisorctl restart *