Fb. In. Li. Vk.

Crontab: как не наступать на грабли

10.04.2021, Manuals
9 минут на чтение
Photo by Taylor Vick on Unsplash

Для начала уточню: cron - это демон (программа, работающая в фоновом режиме), который используется для периодического выполнения заданий (запуска других программ) в определенное время; crontab - это формат файлов, которые описывают задания для cron.

Каждый пользователь имеет свой crontab, и все команды в нем будут запускаться от имени этого пользователя.

Работа с crontab

Сама команда предельно проста и поддерживает два формата вызова:

  • crontab [-u user] file - устанавливает crontab из файла
  • crontab [-u user] { -l | -r | -e } - выводит текущий crontab (-l), удаляет (-r) или открывает для редактирования (-e)

Параметр -u позволяет указать пользователя с чьим crontab мы работаем. По умолчанию все манипуляции происходят с файлом текущего пользователя.

Команда crontab -e открывает файл в редакторе по умолчанию, который установлен в настройках. Это может быть неудобно, особенно если вы привыкли к nano, а crontab откроется в vi.

Выходом из ситуации может быть такая последовательность команд:

$ crontab -l > crontab.txt
$ nano crontab.txt
$ crontab crontab.txt
$ crontab -l

Сохранили текущий crontab в файл, отредактировали его, сохранили, установили в крон, проверили, что все OK.

Плюс такого подхода еще и в том, что у вас будет копия последнего варианта crontab, на случай если кто-то его испортит или случайно сотрет.

Формат crontab

  • Каждая строка - отдельная команда или присвоение значения переменной окружения.
  • Пустые строки, пробелы и табы в начале строки игнорируются.
  • Если строка начинается с # - это комментарий, при этом недопустимо использовать символ # не в начале строки; исключение - этот символ является частью выполняемой команды.

Грабли №1: новая строка

Формат требует, чтобы каждая запись в файле crontab заканчивалась символом новой строки. Если последняя строка в файле не имеет символа новой строки в конце, cron будет считать, что файл crontab некорректен и не установит его.

Строки могут быть двух типов: присвоение значения переменной окружения и команда.

Переменные окружения

Формат:

NAME = value
NAME = "value"
NAME = 'value'

Пробелы вокруг знака = игнорируются, однако все пробелы после значения будут относится к этому значению. Значения переменных можно заключать в кавычки (одинарные или двойные), чтобы избежать такой ситуации или, наоборот, добавить пробелы в начало, середину или конец переменной. Имена переменных также можно заключать в одинарные или двойные кавычки.

Некоторые переменные окружения cron устанавливает при запуске: SHELL (обычно устанавливается в /bin/sh), LOGNAME и HOME (значения берутся из /etc/passwd). Переменную LOGNAME переопределить нельзя.

Также при запуске крон смотрит на значения переменной MAILTO, которая не задается по умолчанию.

Если MAILTO задана и не пустая, cron отправит весь вывод исполняемых команд по указанному адресу или адресам (несколько адресов можно указать через запятую). Если переменной задать пустое значение (MAILTO = ""), то письмо отправлено не будет.

Во всех остальных случаях письмо отправляется пользователю, от имени которого производился запуск.

Некоторые современные версии cron поддерживают переменную MAILFROM. Если она задана и не пустая - ее значение будет использовано в качестве отправителя письма. В иных случаях письма будут отправлены от имени root.

Команды

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

* * * * * выполняемая команда
- - - - -
| | | | |
| | | | ----- день недели (0—6) (воскресенье - суббота, в некоторых системах 7 может быть воскресеньем)
| | | ------- месяц (1—12)
| | --------- день (1—31)
| ----------- час (0—23)
------------- минута (0—59)

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

Можно указать диапазон чисел (два числа, разделенные дефисом). Тут действует правило "включительно", например, 8-10 будет означает 8, 9 и 10.

Можно перечислить нужные значения через запятую, например 1,2,5,9; в большинстве систем можно использовать диапазоны в перечислении: 0-4,8-12.

Диапазоны можно дополнять значением шага в виде /число. Например, в поле час можно указать 0-23/2, что будет означать "каждый четный час" или в виде перечисления - 0,2,4,6,8,10,12,14,16,18,20,22.

Шаг допустимо использовать со звездочкой: */3 означает каждый третий час.

Для полей месяц и день недели можно использовать символьные идентификаторы - первые три символа английского названия месяца (JAN–DEC) или дня недели (SUN–SAT). Регистр значения не имеет.

Вместо первых пяти полей в некоторых вариантах реализации cron возможно использование специальных строк:

строка          значение
------          -------
@reboot         при запуске cron
@yearly         раз в год, "0 0 1 1 *"
@annually       (то же самое, что @yearly)
@monthly        раз в месяц, "0 0 1 * *"
@weekly         раз в неделю, "0 0 * * 0"
@daily          раз в день, "0 0 * * *"
@midnight       (то же самое, что @daily)
@hourly         раз в час, "0 * * * *"

Иногда к перечисленным добавляются эти две строки:

строка          значение
------          -------
@every_minute   раз в минуту, "*/1 * * * *"
@every_second   раз в секунду

Команда (остаток строки) будет выполнена с помощью /bin/sh (или другого интерпретатора, указанного в переменной SHELL) вплоть до символа %. Символ % в команде, если он не экранирован (\%) будет заменен символом новой строки, а все данные после первого % будут переданы в команду как стандартный ввод.

Например:

0 22 * * 1-5    mail -s "It's 10pm" joe%Joe,%%Where are your kids?%

Будет в 22:00 каждый будний день отправлять письмо пользователю joe с темой It's 10pm и текстом

Joe,

Where are your kids?

Грабли №2: неочевидный момент с символом *

Для запуска задачи минута, час и месяц должны совпасть с текущим временем, плюс должны совпасть день месяца или день недели. Например

0 12 1 * 1-5 /some/very/useful/program

Будет выполняться в 12:00 в первый день каждого месяца, а так же каждый рабочий день. То есть фактически это объединение (∪) 0 12 1 * * и 0 12 * * 1-5.

Что говорит документация

«День выполнения команды может быть задан в двух полях: день месяца и день недели. Если оба эти поля жестко заданы (т.е. не содержат символа “*”), команда будет выполнена при совпадении хотя бы одного поля.»

Например, 30 4 1,15 * 5 будет выполняться в 04:30 каждый 1-й и 15-й день месяца, а также по пятницам.

Другими словами, если ни день недели ни день месяца не содержат *, crontab делает объединение (∪) этих условий ("хотя бы одно условие совпало"), в остальных случаях - пересечение (∩) ("оба условия должны совпасть").

Если день месяца - 15, а день недели - *, crontab делает пересечение 15-го дня месяца со всеми днями недели. В результате задача будет выполнятся каждое 15-е число месяца независимо от дня недели.

Если же день месяца - 15, а день недели - 1 (понедельник), crontab сделает объединение этих условий. В результате задача будет выполняться каждое 15-е число месяца, а так же по понедельникам.

Баг

Проблема в том, что crontab проверяет только первый символ дня месяца или дня недели на равенство *, чтобы решить делать пересечение или объединение условий. В результате

0 12 *,10 * 20 12 10,* * 2

В первом случае день месяца начинается со * и cron будет использовать пересечение. Условие будет срабатывать только по вторникам, потому что "все дни месяца" ∩ "вторник" = "вторник". В итоге условие сводится к 0 12 * * 2.

Во втором случае в дне месяца тоже есть *, на она стоит не на первом месте и cron будет использовать объединение. Условие будет срабатывать каждый день, потому что "все дни месяца" ∪ "вторник" = "все дни месяца". Условие сводится к 0 12 * * *.

И это довольно неочевидный момент, потому что большинство считает, что * - символ, обозначающий любое значение. Проблема особенно часто всплывает при использовании диапазонов в условиях. Например, все знают, что 0-59 * * * * то же самое, что и * * * * *. Но:

0 12 1-31 * 20 12 * * 2

В первом случае условие будет срабатывать каждый день (оно сводится к 0 12 1-31 * * или 0 12 * * *), во втором - только по вторникам.

На эти грабли также можно наступить при использовании шагов. Напомню, что 0-10/2 означает каждое второе значение в диапазоне от нуля до десяти (то же самое, что и 0,2,4,6,8,10), а */3 - каждое третье значение. При указании шага с использованием * в дне месяца или дне недели вы переводите cron в режим пересечения условий, что может привести к непредсказуемым результатам.

В большинстве случаев использование * в правилах крона делает их более читаемыми. Но при этом стоить помнить про эту его "особенность".

Надеюсь, теперь вы понимаете, почему 0 12 */2 * 0,6 не будет запускаться каждый нечетный день месяца, а также в субботу и воскресенье. Вместо этого из-за этой "особенности" условие сработает только в нечетный день, который одновременно является и выходным. Чтобы добиться желаемого поведения, нужно переписать условие в виде 0 12 1-31/2 * 0,6.

Рекомендации при работе с cron

Наводите порядок

Располагайте записи в определенном порядке. Например, можно:

  • расположить самые важные записи сверху;
  • расположить сверху самые часто выполняемые задания;
  • упорядочить записи по времени запуска;
  • упорядочить по группам (отправка писем, бекапы и т.д.).

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

Проверяйте работу крона

Команды в cron имеют дурацкую привычку работать из командной строки, но не работать при вызове из cron. Обычно такое бывает из-за отсутствия нужных переменных окружения или проблем с путями.

Проверить довольно просто. Нужно добавить тестовое задание, которое выполнится через 1-2 минуты. Если все OK - переделаете его в регулярное.

Используйте скрипты

Вместо того, чтобы усложнять сам файл crontab сложными командами и большим количеством переменных, перенесите это все в скрипты. Плюсы такого решения:

  • скрипты можно запускать автономно вне крон;
  • несколько crontab могут использовать один скрипт;
  • легче параллелить команды и обрабатывать ошибки;
  • скрипт может обрабатывать результат работы команд и писать более понятный лог.

Настройте почту

Убедитесь, что результат работы крон отправляет на реальный почтовый ящик и этот ящик периодически читается.

Письма от крон - самый быстрый и эффективный способ узнать, что что-то пошло не так.

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

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

Документируйте

Правило хорошего тона - написать комментарий к каждому заданию cron. Особенно актуально для самописных скриптов в кроне и стандартных, но редкоиспользуемых программ.

root - зло

Золотое правило системного администриарования "как можно меньше делать из-под суперюзера" относится и к cron. Не только из-за соображений безопасности, потому что:

  • пользователь root обычно получает много почты и сообщение с ошибкой от cron может затерятся;
  • задачи должны выполнятся от имени пользователя, к которому они относятся; если нужны доп. права - настройте sudo;
  • поскольку root всемогущ - простая опечатка в файле crontab может натворить много бед.

Не перенаправляйте вывод в /dev/null

Особенно старайтесь избегать /dev/null 2>&1, которая порежет весь вывод программы. В итоге вы не получите письмо с ошибками.

Если хотите уменьшить или отключить вывод программы, используйте параметры команды (-q, --quite, --silent и т.д.). Если такой вариант не подходит - напишите свой скрипт, который будет обрабатывать и фильтровать результат работы программы.

Не указывайте пароли

Это не только вопрос безопасности, но и централизованного хранения данных. Используйте более подходящие инструменты: для FTP и пр - .netrc файл, для mysql - login path и т.д.

Используйте полные пути вызова

Вместо php напишите /usr/local/bin/php. Чтобы узнать полный путь программы - используйте команду which.

В сложных случаях используйте два задания

Например, вам нужно запускать какую-то программу каждые 1,5 часа. Одним заданием такую задачу не решить, но она легко решается двумя заданиями:

  1. 0 */3 * * * - будет выполняться каждые три часа в 00 минут
  2. 30 1/3 * * * - будет выполняться каждые три часа, начиная с 01:30

✌️