Простой и удобный шаблон для bash-скриптов, выполняемых по расписанию

BASH 07.12.23 07.12.23 68
Бесплатные курсына главную сниппетов

Хочу поделиться с сообществом простым и полезным шаблоном скрипта-обёртки на 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 '<Subject>' root@. Потом я нашёл Cronic - обёртку на bash, которая скрывает вывод запускаемой задачи, если она завершена успешно. Стало полегче, но обнаружилось, что от некоторых заданий всё же лучше получать сообщение "Task OK", чтобы не столкнуться в самый неподходящий момент с тем, что выполнение задания тихо сломано месяц назад. Постепенно копились и другие хотелки:

  1. иногда требуется, чтобы задание было автоматически принудительно остановлено, если выполняется больше определённого времени;

  2. иногда нужны гарантии, что в каждый момент времени запущена только одна копия задания;

  3. бывает так, что запускать задачу нужно с рандомной задержкой по времени (такая дисперсия иногда нужна, чтобы не положить какой-нибудь сервис одновременными запросами с большого количества машин).

В какой-то момент я отложил в сторону cronic и написал свой шаблон для запуска периодических заданий, в котором реализовано всё, что перечислено выше. Вот что он умеет:

  1. сохраняет в лог-файлы STDERR и STDOUT выполняемых команд;

  2. если задание завершилось ошибкой, то отправляет на заданный электронный адрес последние 10000 (можно настроить любое) строк STDOUT и STDERR;

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

  4. гарантирует, что одновременно будет запущена только одна копия задания;

  5. опционально может запускать задачу с рандомной (в заданном диапазоне) задержкой по времени;

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

Вот как выглядит пример сообщения, которое присылает скрипт
Вот как выглядит пример сообщения, которое присылает скрипт
Давайте посмотрим на сам шаблон
#!/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() {
    # Включаем вывод в STDERR выполняемых команд с аргументами
    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() в шаблоне пуста. В неё можно добавить команды, которые будут выполнены перед завершением скрипта. Например, команды очистки временных файлов.

Что можно улучшить

  1. Если вы используете Sentry для трекинга ошибок, то при помощи sentry-cli можно заменить отправку трейсов по электронной почте на отправку их в Sentry.

  2. Можно отправлять метрики успешного завершения задачи в Prometheus/VictoriaMetrics, при помощи curl (нужен pushgateway) или, что проще, использовать prometheus node_exporter textfile collector.

 

на главную сниппетов
Курсы