суббота, 14 февраля 2015 г.

Запуск программы по расписанию, или история с сюрпризами

Запуск программы по расписанию в ОС семейста Unix делается cron'ом. Достаточно с помощью утилиты crontab добавить в расписание строку, чтобы указанная команда с указанной периодичностью запускалась демоном cron.

Вот пример файла расписания с комментариями к полям и единственной командой /home/ay/todo.sh, запускаемой каждые 10 минут:

# +----------------------------- minute (0 - 59)
# |               +------------- hour (0 - 23)
# |               |  +---------- day of month (1 - 31)
# |               |  |  +------- month (1 - 12)
# |               |  |  |  +---- day of week (0 - 6) (Sunday=0 or 7)
# |               |  |  |  |
# v               v  v  v  v    command to be executed

0,10,20,30,40,50  *  *  *  *    /home/ay/todo.sh

Но что, если периодически должна запускаться программа, требующая исключительного (единоличного) доступа к некоторому ресурсу?

Если /home/ay/todo.sh именно такая программа и если может случиться так, что ее выполнение займет более 10 минут, то одновременно окажутся запущенными два процесса, конкурирующие за один ресурс. Чего нельзя допустить!

Программа будет запускаться с помощью shell-скрипта, который и обеспечит выполнение не более одного ее экземпляра одновременно:

ay@tsuki:~$ cat runner0.sh
#!/bin/sh

MY_FLAG=/tmp/$1.flag
MY_COMMAND=$2

[ -f $MY_FLAG ] && exit 1

touch $MY_FLAG

$MY_COMMAND

rm $MY_FLAG

Перед запуском команды, переданной скрипту через второй параметр, скрипт проверяет наличие флага - пустого файла в каталоге /tmp с именем, определяемым первым параметром. Если файл присутствует, то скрипт завершает работу без запуска программы. Если файла нет, то скрипт создает его, запускает программу на выполнение, а после ее завершения удаляет файл. Таким образом, секция кода от создания флага до его удаления может выполняться не более, чем одним процессом одновременно.

Протестирую работу runner0.sh, запустив с его помощью на выполнение команду sleep 10 (команда sleep без побочных эффектов, и, что немаловажно, позволяет задать время ее выполнения):

ay@tsuki:~$ ./runner0.sh runner0 "sleep 10" || echo "failed to run"
ay@tsuki:~$ 

Успешно.

Теперь протестирую одновременный запуск более одного процесса:

ay@tsuki:~$ ./runner0.sh runner0 "sleep 10" || echo "failed to run" &
[1] 2336
ay@tsuki:~$ ./runner0.sh runner0 "sleep 10" || echo "failed to run"
failed to run
ay@tsuki:~$ ./runner0.sh runner0 "sleep 10" || echo "failed to run"
failed to run
ay@tsuki:~$ 
[1] +  Done                    ./runner0.sh runner0 "sleep 10" || echo "failed to run" &
ay@tsuki:~$ 

Пока процесс 2336, запущенный в фоновом режиме, выполнялся, запустить еще один процесс с флагом runner0, не удалось. Тест прошел успешно.

Скрипт, подобный runner0.sh, отработал несколько недель, запускамый cron'ом по расписанию, когда одним прекрасным утром выяснилось, что флаг в каталоге /tmp, созданный пару дней назад, почему-то не был удален! Очевидно, произошел сбой при работе скрипта и он не отработал до конца и не удалил флаг. В результате, флаг не позволяет запускать программу, хотя программа в данный момент не выполняется.

Эту ситуацию легко воcпроизвести:

ay@tsuki:~$ touch /tmp/runner0.flag
ay@tsuki:~$ ./runner0.sh runner0 "sleep 10" || echo "failed to run"
failed to run
ay@tsuki:~$ ./runner0.sh runner0 "sleep 10" || echo "failed to run"
failed to run
ay@tsuki:~$ 

Прекратить безобразие просто (но для этого нужно его заметить):

ay@tsuki:~$ rm /tmp/runner0.flag
ay@tsuki:~$ ./runner0.sh runner0 "sleep 10" || echo "failed to run"
ay@tsuki:~$ 

Как можно исключить подобную проблему в скрипте, запускаемом cron'ом? Кроме проверки наличия флага, запускающий скрипт должен убедиться, что охраняемый флагом процесс все еще выполняется. А если не выполняется, то запускать программу на выполнение.

Новая версия скритпа:

ay@tsuki:~$ cat runner1.sh
#!/bin/sh

MY_FLAG=/tmp/$1.flag
MY_COMMAND=$2

check_flag()
{
    # check that only one process is running
    if [ -s ${MY_FLAG} ]
    then
        MY_PID=`cat ${MY_FLAG}`
        if ( ps -e -o pid | grep -q $MY_PID )
        then
            # process PID is still running
            exit 1
        else
            # re-create PID file
            echo $$ > ${MY_FLAG}
        fi
    else
        # create PID file
        echo $$ > ${MY_FLAG}
    fi
}


check_flag

$MY_COMMAND

rm $MY_FLAG

Теперь в файл-флаг помещается идентификатор текущего процесса, а проверка состоит в выяснении, есть ли среди текущих выполняющихся процессов процесс с сохраненным идентификатором.

Протестирую общую работоспособность runner1.sh:

ay@tsuki:~$ ./runner1.sh runner1 "sleep 10" || echo "failed to run"
ay@tsuki:~$ ./runner1.sh runner1 "sleep 10" || echo "failed to run" &
[1] 2354
ay@tsuki:~$ ./runner1.sh runner1 "sleep 10" || echo "failed to run"
failed to run
ay@tsuki:~$ ./runner1.sh runner1 "sleep 10" || echo "failed to run"
failed to run
ay@tsuki:~$ 
[1] +  Done                    ./runner1.sh runner1 "sleep 10" || echo "failed to run" &
ay@tsuki:~$ 

А теперь протестирую работу скрипта в ситуации, с которой не справляется runner0.sh. В файл /tmp/runner1.flag помещу идентификатор процесса, который отработал и завершился (это процесс интерпретатора bash, запущенного из командной строки):

ay@tsuki:~$ bash -c 'echo $$ > /tmp/runner1.flag'
ay@tsuki:~$ cat /tmp/runner1.flag
2208
ay@tsuki:~$ ./runner1.sh runner1 "sleep 10" || echo "failed to run"
ay@tsuki:~$ 

Скрипт runner1.sh запустил на выполнение команду, несмотря на наличие флага в каталоге /tmp. И после выполнения скрипта флага больше нет:

ay@tsuki:~$ ls -l /tmp/runner1.flag
/tmp/runner1.flag not found

Скрипт, подобный runner1.sh, отработал несколько недель, запускамый cron'ом по расписанию, когда однажды вечером выяснилось, что запущенный скриптом процесс фатально завис! Процесс, идентификатор которого хранит флаг, был запущен больше суток назад, и до сих пор не завершился.

Это аномальная ситуация, требующая завершить процесс принудительно (после чего runner1.sh снова запустит программу при следующеми вызове cron'ом):

ay@tsuki:~$  cat /tmp/runner1.flag
2556
ay@tsuki:~$  kill -9 2556
ay@tsuki:~$ 

И вновь безобразие устраняется просто, после того, как замечено. Можно ли усовершенствовать запускающий скрипт, чтобы он учитывал возможность зависания запущенной программы?

Отведем на выполнение программы некоторое конечное время, по истечении которого будем принудительно завершать процесс. Для этого добавим в скрипт runner1.sh функции timeout_handler и run_with_timeout и сохраним скрипт под именем runner2.sh:

ay@tsuki:~$ cat runner2.sh
#!/bin/sh

MY_FLAG=/tmp/$1.flag
MY_COMMAND=$2
MY_TIMEOUT=$3

# USR1 signal handler
timeout_handler()
{
    if [ "$MY_COMMAND_PID" != "" ]
    then
        kill -9 $MY_COMMAND_PID 2> /dev/null
    fi
}

run_with_timeout()
{
    if [ "$MY_TIMEOUT" != "0" ]
    then
        trap timeout_handler USR1
        # send USR1 to this process after sleeping in background for MY_TIMEOUT seconds
        sleep $MY_TIMEOUT && kill -s USR1 $$  &
        MY_KILLER_PID=$!
    fi

    # run the process in background
    if [ "$MY_COMMAND" != "" ]
    then
        $MY_COMMAND &
        # and remember PID of the last background process
        MY_COMMAND_PID=$!
    fi

    # wait for the background process
    if [ "$MY_COMMAND_PID" != "" ]
    then
        wait $MY_COMMAND_PID
    fi

    # reset signal handler and kill the sleeping killer
    if [ "$MY_TIMEOUT" != "0" ]
    then
        unset MY_COMMAND_PID
        trap "" USR1
        kill $MY_KILLER_PID 2> /dev/null
    fi
}

check_flag()
{
    # check that only one process is running
    if [ -s ${MY_FLAG} ]
    then
        # check if PID is running
        MY_PID=`cat ${MY_FLAG}`
        if ( ps -e -o pid | grep -q $MY_PID )
        then
            exit 1
        else
            # re-create PID file
            echo $$ > ${MY_FLAG}
        fi
    else
        # create PID file
        echo $$ > ${MY_FLAG}
    fi
}

check_flag

run_with_timeout

rm $MY_FLAG

Функция run_with_timeout, в отличие от прежних версий скрипта, запускает программу в фоновом режиме и далее ожидает ее завершения с помощью команды wait. Такой подход потенциально позволяет запустить на выполнение более одной команды параллельно, добавляя, через пробел, идентификаторы запущенных процессов в переменную MY_COMMAND_PID, и далее ожидать их завершения. Но пока мне достаточно запуска одной команды.

Протестирую общую работоспособность скрипта runner2.sh, передавая для третьего параметра значение 10 (секунд), превышающее время выполнения команды sleep 5:

ay@tsuki:~$ ./runner2.sh runner2 "sleep 5" 10 || echo "failed to run"
ay@tsuki:~$ ./runner2.sh runner2 "sleep 5" 10 || echo "failed to run" &
[1] 2932
ay@tsuki:~$ ./runner2.sh runner2 "sleep 5" 10 || echo "failed to run"
failed to run
ay@tsuki:~$ ./runner2.sh runner2 "sleep 5" 10 || echo "failed to run"
[1] +  Done                    ./runner2.sh runner2 "sleep 5" 10 || echo "failed to run" &
ay@tsuki:~$ 

ay@tsuki:~$ bash -c 'echo $$ > /tmp/runner2.flag'
ay@tsuki:~$ cat /tmp/runner2.flag
2712
ay@tsuki:~$ ./runner2.sh runner2 "sleep 5" 10 || echo "failed to run"
ay@tsuki:~$ ls /tmp/runner2.flag
/tmp/runner2.flag not found

А теперь протестирую принудительное завершение "зависшего" процесса по истечении времени, отпущенного на его выполнение:

ay@tsuki:~$ date; (./runner2.sh runner1 "sleep 5" 3 || echo "failed to run"); date
Fri Jan 17 14:14:41 VLAT 2015
Fri Jan 17 14:14:44 VLAT 2015

Успешно! Запущенный процесс был завершен через 3 секунды после начала выполнения, что видно из вывода команд date, обрамляющих вызов runner2.sh.

Уже в течение трех недель скрипт, подобный runner2.sh, в расписании cron'а запускает программы. Пока полет нормальный. Интересно, будут ли еще сюрпризы?

Комментариев нет:

Отправить комментарий