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

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

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

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

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

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

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

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

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

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

© Habrahabr.ru