Простой и удобный шаблон для bash-скриптов выполняемых по расписанию
Хочу поделиться с сообществом простым и полезным шаблоном скрипта-обёртки на bash для запуска заданий по cron (а сейчас и systemd timers), который моя команда повсеместно использует много лет.
Сначала пара слов о том зачем это нужно, какие проблемы решает. С самого начала моей работы системным администратором linux, я обнаружил, что cron не очень удобный планировщик задач. При этом практически безальтернативный. Чем больше становился мой парк серверов и виртуальных машин, тем больше я получал абсолютно бесполезных почтовых сообщений «From: Cron Daemon». Задание завершилось с ошибкой — cron напишет об этом. Задание выполнено успешно, но напечатало что-нибудь в STDOUT/STDERR — cron всё равно напишет об этом. При этом даже нельзя отформатировать тему почтового сообщения для удобной автосортировки. Сначала были годы борьбы с использованием разных вариаций из > /dev/null
, 2> /dev/null
, > /dev/null 2>&1
, | mail -E -s '
. Потом я нашёл Cronic — обёртку на bash, которая скрывает вывод запускаемой задачи, если она завершена успешно. Стало полегче, но обнаружилось, что от некоторых заданий всё же лучше получать сообщение «Task OK», чтобы не столкнуться в самый неподходящий момент с тем, что выполнение задания тихо сломано месяц назад. Постепенно копились и другие хотелки:
иногда требуется, чтобы задание было автоматически принудительно остановлено, если выполняется больше определённого времени;
иногда нужны гарантии, что в каждый момент времени запущена только одна копия задания;
бывает так, что запускать задачу нужно с рандомной задержкой по времени (такая дисперсия иногда нужна, чтобы не положить какой-нибудь сервис одновременными запросами с большого количества машин).
В какой-то момент я отложил в сторону cronic и написал свой шаблон для запуска периодических заданий, в котором реализовано всё, что перечислено выше. Вот что он умеет:
сохраняет в лог-файлы SDERR и SDTOUT выполняемых команд;
если задание завершилось ошибкой, то отправляет на заданный электронный адрес последние 10000 (можно настроить любое) строк SDOUT и STDERR;
опционально может отправить метрику в Zabbix, если задача выполнена успешно (удобно для сброса времени срабатывания триггера);
гарантирует, что одновременно будет запущена только одна копия задания;
опционально может запускать задачу с рандомной (в заданном диапазоне) задержкой по времени;
опционально можно установить максимальное время работы, по истечению которого задача будет принудительно завершена с ошибкой.
Вот как выглядит пример сообщения, которое присылает скрипт
Давайте посмотрим на сам шаблон
#!/usr/bin/env bash
##
# bash options
##
set -eu -o pipefail
export LC_ALL="C"
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
##
# Variables
##
SET_EXECUTION_LOCK="${SET_EXECUTION_LOCK:-}"
MAX_EXECUTION_TIME="${MAX_EXECUTION_TIME:-10}"
START_DELAY_RANGE="${START_DELAY_RANGE:-0-0}"
LOGS="/var/log/$(basename "$0")"
HOSTNAME="${HOSTNAME:-$(hostname -f)}"
REPORT_MAIL="monitoring@example.com"
REPORT_SUBJ="$0 fail on $HOSTNAME"
#ZABBIX_ITEM="example.task.ok"
##
# Execution lock and timeout
##
if [[ -n "$MAX_EXECUTION_TIME" ]]; then
command="timeout -v -k 60 $MAX_EXECUTION_TIME"
fi
if [[ -n "$SET_EXECUTION_LOCK" ]]; then
command="flock -E 0 -n $0 ${command:-}"
fi
if [[ -z "${_run_:-}" ]]; then
sleep "$(shuf -i "$START_DELAY_RANGE" -n 1)"
export _run_=1
exec ${command:-} "$0" "$@"
fi
##
# Functions
##
print_logs() {
cd "$LOGS"
echo "Trace of $HOSTNAME:$0"
for log in stderr stdout; do
if [[ -s "$log" ]]; then
echo "----- $(basename "$log")"
tail -n 10000 "$log"
fi
done
}
send_to_zabbix() {
local item="${1:-}"
local value="${2:-1}"
zabbix_sender -c /etc/zabbix/zabbix_agentd.conf -s "$HOSTNAME" -k "$item" -o "$value"
}
on_exit() {
return
}
on_error() {
print_logs 2>&1 | mail -E -s "$REPORT_SUBJ" "$REPORT_MAIL"
on_exit
# Нам не нужно лишнее письмо от crond
exit 0
}
main() {
set -x
"$@"
if [[ -n "${ZABBIX_ITEM:-}" ]]; then
send_to_zabbix "${ZABBIX_ITEM:-}" 1
fi
}
##
# Main
##
trap on_error ERR
trap on_exit EXIT
[[ -d "$LOGS" ]] || mkdir -p "$LOGS"
(main "$@" > "$LOGS/stdout" 2> "$LOGS/stderr")
В целом скрипт тривиален, но некоторые пояснения, думаю, требуются.
Переменные которые определяю параметры запуска. Их значения могут быть установлены напрямую или через одноимённые переменные окружения.
# Максимальное время после которого задача будет принудительно завершена.
MAX_EXECUTION_TIME="${MAX_EXECUTION_TIME:-8h}"
# Не пустое значение запрещает запуск нескольких копий задачи.
SET_EXECUTION_LOCK="${SET_EXECUTION_LOCK:-}"
# Случайное число из этого диапазона опеределяет количество секунд задержки
# перед стартом.
START_DELAY_RANGE="${START_DELAY_RANGE:-0-0}"
Точка входа, запускаем главную функцию в subshell. Это нужно для того, чтобы в случае ошибки не была завершена сама обёртка.
(main "$@" > "$LOGS/stdout" 2> "$LOGS/stderr")
Основная функция.
main() {
# Включаем вывод в SDTERR выполняемых команд с аргументами
set -x
# Здесь помещаем вызов или логику задачи на bash. Если использовать
# "$@", то здесь будет выполнена команда переданная обёртке в качестве
# аргумента командной строки
"$@"
# Если определена переменная ZABBIX_ITEM, то отправляем метрику в сервер Zabbix
if [[ -n "${ZABBIX_ITEM:-}" ]]; then
send_to_zabbix "${ZABBIX_ITEM:-}" 1
fi
}
on_error()
— функция, которая будет вызвана в случае ошибки.
on_error() {
# Отправляем трейсы
print_logs 2>&1 | mail -E -s "$REPORT_SUBJ" "$REPORT_MAIL"
on_exit
# Завершаем работу с rc=0, нам не нужно лишнее письмо от crond
exit 0
}
Функция on_exit()
в шаблоне пуста. В неё можно добавить команды, которые будут выполнены перед завершением скрипта. Например команды очистки временных файлов.
Что можно улучшить
Если вы используете Senty для трекинга ошибок, то при помощи sentry-cli можно заменить отправку трейсов по электронной почте на отправку их в Sentry.
Можно отправлять метрики успешного завершения задачи в Prometheus/VictoriaMetrics, при помощи curl (нужен pushgateway) или, что проще, использовать prometheus node_exporter textfile collector.