PHP выполнить shell команду синхронно с timeout

Задача которую решаем — возможность из-под PHP-кода выполнить произвольную shell-команду (как через shell_exec) с возможностью задать timeout на команду.

PHP Shell exec if no timeout applied

Для чего задавать timeout для shell команды?

Timeout нужен в случае, если shell_exec на выполнении команды просто подвисает. Мы не можем никаким образом обернуть функцию shell_exec чтобы прервать через заданное время и выбросить исключение.

Если shell_exec() запущен, то PHP интерпретатор будет ждать завершения. Timeout нужен в случае, когда команда, которую мы запускаем, может или повиснуть вовсе, или отрабатывать дольше, чем мы хотим ждать результат. Если для CLI долгое время ожидания не так критично, то для веба отвечать 504 Gateway Timeout будет не самым лучшим вариантом.

Вариант 1: запуск команды через exec либо shell_exec

shell_exec не позволяет ограничить время своего выполнения, то есть не дает ограничить время ожидания ответа от команды.

Представленный ниже PHP код будет работать отлично, если запускаемая внутри функции exec команда работает отлично. В зависимости от тела команды в exec, данный код может создавать проблемы из-за отсутствия timeout. sleep 40 приведен для примера. Внутри может быть запуск произвольной команды.

<?php

declare(strict_types=1);

$command = 'sleep 40';

//this call can cost a fortune depending on $command and what happens inside the command' execution
//Long execution can cause problems in case of php-fpm (for example 504 Bad Gateway response)
exec($command, $output, $code);

echo 'Exit Code: ' . $code . PHP_EOL;
echo 'Output: ' . implode(PHP_EOL, $output);

Плюсы подхода:

  • exec и shell_exec — самый простой в PHP вариант запуска shell команды.
  • Отлично подходит для случаев когда "команда" работает предсказуемо по времени.

Минус подхода:

  • Единственный минус — невозможность прервать "зависшую" или очень долгую команду, выполнение кода висит в ожидании.

Вариант 2: запуск команды используя symfony/process с timeout

Можно использовать возможности пакета symfony/process для явного указания timeout на команду. Пример:

<?php

declare(strict_types=1);

use Symfony\Component\Process\Process;

require_once 'vendor/autoload.php';

$command = 'sleep 40';

$process = Process::fromShellCommandline($command, timeout: 5);

// mustRun is the same as run but will throw exception if exit status != 0
$process->mustRun();

echo 'Output: ' . $process->getOutput();

В вызове fromShellCommandline явно передаю в аргументе желаемый timeout ожидания команды (в данном примере 5 сек).

Если время работы команды превысит отведенный timeout, то будет выброшен Exception.

Вариант 3: пишем shell_exec используя proc_open, читая оутпут из пайпов и проверяя время выполнения периодически в цикле

Пример скрипта можно найти в данном PR.

Это упрощенная версия того, что происходит внутри symfony/process. Подойдет тому, кто не хочет тянуть доп. зависимость в виде целого пакета.

Для понимания того что происходит, смотри комментарии в коде ниже.

<?php

/**
 * PHP shell_exec with timeout handling
 * You can use code below if you don't want symfony/process package as dependency to project
 */

declare(strict_types=1);

$command = sprintf('bash %s/extremely-long-script.sh 2>&1', __DIR__);

$result = execWithTimeout($command, 4);

echo 'Exit code: '.$result[0].PHP_EOL;
echo 'Output from script: '.$result[1].PHP_EOL;

/**
 *
 * Let's write something like symfony/process but using minimal amount of code.
 * Will work on *nix, not verified on Windows.
 *
 * Execute a command and return it's exit code and output.
 * Either wait until the command exits or the timeout has expired.
 *
 * @throws Exception
 */
function execWithTimeout(string $cmd, int $timeout): array
{
    // File descriptors passed to the process.
    $descriptors = [
        ['pipe', 'r'],  // stdin
        ['pipe', 'w'],  // stdout
        ['pipe', 'w'],  // stderr
    ];

    $startTime = microtime(true);

    // Start the process.
    $process = proc_open($cmd, $descriptors, $pipes);

    if (!is_resource($process)) {
        throw new Exception('Could not execute process');
    }

    // Set the pipes to non-blocking
    foreach ($pipes as $pipe) {
        stream_set_blocking($pipe, false);
    }

    // Turn the timeout into milliseconds.
    $timeoutMillis = $timeout * 1000;

    // Output buffer.
    $buffer = '';

    do {
        // collect stdout and stderr both, but you can do it separately if you want
        $buffer .= stream_get_contents($pipes[1]).stream_get_contents($pipes[2]);

        //sleep for some time (1ms) and verify the time limit and the process status
        usleep(1000);

        $status = proc_get_status($process);
        $exitCode = $status['exitcode']; // exitcode = -1 when still running
    } while (!exceededTimeout($startTime, $timeoutMillis) && $status['running']);

    // read the remaining data from pipes
    $buffer .= stream_get_contents($pipes[1]).stream_get_contents($pipes[2]);

    // Close all streams
    foreach ($pipes as $pipe) {
        fclose($pipe);
    }

    // Close the process
    proc_close($process);

    // Return array for test purposes. You can handle exit code right here and throw exception if exitcode !=0
    return [$exitCode, $buffer];
}

function exceededTimeout(float $startTime, int $timeoutMillis): bool
{
    $exceededTimeLimit = $startTime * 1000 + $timeoutMillis < microtime(true) * 1000;

    if ($exceededTimeLimit) {
        throw new Exception(sprintf(
            'The process exceeded the timeout of %d seconds.',
            $timeoutMillis / 1000,
        ));
    }

    return false;
}

Когда может быть нужно ограничивать время выполнения shell команды

  • Если команда может отрабатывать непозволительно долго. Сталкивался с бинарями (вероятно, багованными), при подаче на вход которых не ожидаемых данных наблюдался бесконечный его процессинг, то есть бесконечное ожидание. Не всегда есть возможность исправить и пересобрать бинарь. Приходится фиксить уровнем выше, то есть ставить timeout на shell_exec в PHP и перехватывать ошибки.
  • При выполнении бинаря из-под php-fpm всё становится гораздо критичней. По причине того, что при "подвисании" сторонней команды запрос будет возвращен веб-сервером со статусом 504 Bad Gateway, а PHP процесс надолго будет занят. В случае, если запуск такого бинаря происходит внутри транзакции, то "при подвисании" дело не дойдет ни до commit, ни до rollback. При использовании MySQL, ряды останутся залоченными на время wait_timeout секунд, после которых rollback произойдет автоматически и лок снимется. При этом (пока wait_timeout не закончился), другие запросы к уже залоченным рядам будут ждать innodb_lock_wait_timeout секунд, прежде чем словить что-то вроде этого: "ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction". Старайтесь избегать какие-либо exec внутри запущенной SQL-транзакции.
     
 
 
 
 

icon Комментарии 0

Ваш комментарий к статье.. (для авторизованных)

ctrl+enter

icon Вход в систему

зарегистрироваться
НОВЫЕ ПОЛЬЗОВАТЕЛИ