Автоматизация рутинных задач сисадмина: 10 готовых bash-скриптов | AdminWiki

Автоматизация рутинных задач сисадмина: 10 готовых bash-скриптов

24 сентября 2026 21 мин. чтения
Содержание статьи

Зачем автоматизировать рутину сисадмина и как читать эту подборку

Десять скриптов ниже закрывают пять ежедневных задач: контроль свободного места на дисках, ротацию бэкапов и логов, очистку временных файлов, проверку доступности сервисов и синхронизацию конфигов между серверами. Каждый раздел построен одинаково: задача, логика решения, готовый код, подводные камни, переменные для адаптации и команда проверки результата. Скопировали, поменяли переменные в начале файла, запустили.

Скрипты написаны на bash с set -euo pipefail и опираются на утилиты, которые уже стоят на любом сервере: df, find, rsync, curl, openssl, systemctl, journalctl, logrotate. Планировщик - cron или systemd timer. Ставить дополнительный софт не нужно.

Порядок работы один: сначала прогон на тестовом стенде, потом прод. Все примеры идемпотентны, то есть повторный запуск не ломает состояние, и поддерживают dry-run там, где есть удаление или перезапись. Общую структуру скрипта, обработку аргументов и откат при ошибке разбирали в материале про скрипт развёртывания для Linux.

Как пользоваться подборкой: от копирования до адаптации

Формат каждого скрипта одинаков, поэтому навигация занимает полминуты. Сначала идёт постановка задачи: что именно проверяем или чистим и почему это нужно делать автоматически. Потом логика: какие утилиты вызываем, как парсим вывод, куда отправляем уведомление. Затем готовый текст, список переменных для адаптации и команда, которой вы убедитесь, что скрипт отработал правильно.

  • Переменные в начале файла. Пути, пороги, списки хостов и адрес webhook выносим в константы. Тело логики править не приходится.
  • DRY_RUN по умолчанию. Переменная DRY_RUN=1 печатает список планируемых действий. Меняете на 0 - скрипт начинает реально удалять и перезаписывать.
  • Идемпотентность. Повторный запуск не должен создавать дубли, копить блокировки или удалять лишнее. Если состояние уже нужное, скрипт выходит с кодом 0 и не делает ничего.
  • Явные коды возврата. 0 - успех и изменений не требуется, 1 - ошибка конфигурации или окружения, 2 - проблема с данными (битый бэкап, недоступный источник). По коду возврата планировщик и система алертов понимают, эскалировать событие или нет.
  • Логирование. Скрипт пишет в stdout, поэтому его вывод легко перенаправить в файл, а при запуске через systemd timer он автоматически уходит в journald.

Пример перенаправления для cron:

15 3 * * * /usr/local/bin/disk-check.sh >> /var/log/disk-check.log 2>&1
Для systemd timer ничего добавлять не нужно: смотрите вывод командой journalctl -u disk-check.service --since today.

Базовые правила безопасного запуска скриптов в проде

Скрипт, который удаляет файлы или перезапускает сервисы, требует дисциплины. Пять практик закрывают большинство инцидентов: строгий режим bash, проверка переменных, блокировка повторного запуска, минимальные права и понятное логирование.

  • set -euo pipefail. Прерывает работу при ошибке команды, при обращении к необъявленной переменной и при сбое внутри конвейера. Без этого опечатка в пути превращается в удаление не того каталога.
  • Проверка переменных на пустоту. Конструкция ${VAR:?сообщение} останавливает скрипт, если переменная не задана. Особенно важно для путей и масок файлов.
  • flock. Блокировка не даёт двум копиям скрипта работать одновременно, если предыдущий запуск затянулся.
  • Минимальные права. Ротация бэкапов и очистка /tmp не требуют root. Запускайте от отдельного пользователя, а через sudo повышайте права только для конкретных команд.
  • Логирование с временными метками. Каждая запись в формате ISO 8601, чтобы при разборе инцидента выстроить хронологию.

Скелет, к которому приводим все десять скриптов:

#!/usr/bin/env bash
set -euo pipefail

SCRIPT_NAME="$(basename "$0")"
LOG_FILE="/var/log/sysadmin/${SCRIPT_NAME%.sh}.log"
LOCK_FILE="/var/lock/${SCRIPT_NAME}.lock"
mkdir -p "$(dirname "$LOG_FILE")"

log() {
  printf '%s %s\n' "$(date -u +%FT%TZ)" "$*" | tee -a "$LOG_FILE"
}

exec 9> "$LOCK_FILE"
flock -n 9 || { log "уже выполняется, выходим"; exit 0; }

: "${TARGET_DIR:?переменная TARGET_DIR не задана}"

log "старт"
# тело скрипта
log "готово"

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

Скрипт 1: проверка свободного места на дисках с алертом

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

Логика. Команда df -P выводит использование разделов в машиночитаемом формате, ключи -x исключают tmpfs, devtmpfs, squashfs и overlay, чтобы не получать алерты по служебным файловым системам. Дальше awk убирает знак процента, и скрипт сравнивает значение с порогом. Уведомление уходит POST-запросом в webhook (Slack, Mattermost, внутренний бот).

#!/usr/bin/env bash
set -euo pipefail

THRESHOLD=85
INODE_THRESHOLD=80
WEBHOOK="${ALERT_WEBHOOK:?переменная ALERT_WEBHOOK не задана}"
HOST="$(hostname -f)"

alert() {
  printf '{"host":"%s","text":"%s"}' "$HOST" "$1" |
    curl -fsS -m 10 -X POST "$WEBHOOK" -H 'Content-Type: application/json' --data-binary @- > /dev/null
}

check_space() {
  df -P -x tmpfs -x devtmpfs -x squashfs -x overlay |
  awk 'NR>1 {gsub(/%/,"",$5); print $5, $6}' |
  while read -r usage mount; do
    if [ "$usage" -ge "$THRESHOLD" ]; then
      alert "DISK ${HOST}: раздел ${mount} занят на ${usage}%"
    fi
  done
}

check_inodes() {
  df -Pi -x tmpfs -x devtmpfs -x squashfs -x overlay |
  awk 'NR>1 {gsub(/%/,"",$5); print $5, $6}' |
  while read -r usage mount; do
    if [ "$usage" -ge "$INODE_THRESHOLD" ]; then
      alert "INODE ${HOST}: раздел ${mount} занят на ${usage}%"
    fi
  done
}

check_space
check_inodes

Подводные камни. Первый: df показывает процент от общего объёма, а не от доступного; на разделах с зарезервированными блоками под root реальный запас меньше. Второй: кратковременные пики во время распаковки архивов дают ложный алерт, поэтому проверку лучше ставить на расписание раз в 15-30 минут, а не каждую минуту. Третий: значения Capacity из df округляются до целых, поэтому порог 84 и 85 могут сработать при одинаковом состоянии диска.

Как не пропустить переполнение inode

Ситуация, когда df -h показывает 60 процентов занятости, а сервис не может создать файл с ошибкой No space left on device, означает переполнение inode. Индексные дескрипторы расходуются по одному на файл, поэтому разделы с миллионами мелких объектов (сессии PHP, кэш, почтовые очереди, каталоги с логами в отдельных файлах) упираются в лимит раньше, чем в гигабайты.

Смотрите отдельно:

df -i -x tmpfs -x devtmpfs
find /var/spool -xdev -type f -printf '%h\n' 2>/dev/null | sort | uniq -c | sort -rn | head -20

Первая команда показывает процент IUsed по разделам, вторая находит каталоги, которые съедают больше всего inode. Порог по inode обычно ставят ниже, чем по месту: 80 процентов. Лечится не удалением файлов, а изменением архитектуры хранения: реже создавать мелкие файлы, чистить сессии чаще, переводить логи в единый файл с ротацией.

Адаптация и проверка. Переменные THRESHOLD, INODE_THRESHOLD и ALERT_WEBHOOK. Для проверки занизьте порог до 1 и запустите скрипт вручную: уведомление должно прийти по всем подходящим разделам. Затем верните рабочие значения и запустите снова - тишина означает, что скрипт работает корректно. df -h и df -i до и после помогают сверить картину.

Скрипт 2: ротация бэкапов с удалением старых копий

Задача. Хранить N последних копий и удалять всё, что вышло за пределы политики хранения. Без ротации бэкап-каталог заполняет диск и роняет саму систему, которую он должен защищать. Смежные сценарии с проверкой восстановления и снапшотами разбирали в материале про автоматизацию резервного копирования и восстановления.

Логика. Скрипт находит самый свежий архив по маске, проверяет его целостность, и только после этого берёт список кандидатов на удаление по возрасту. При DRY_RUN=1 удаление не выполняется, печатается только список файлов.

#!/usr/bin/env bash
set -euo pipefail

BACKUP_DIR="/srv/backups/app"
PATTERN="*.tar.gz"
RETENTION_DAYS=14
DRY_RUN="${DRY_RUN:-1}"

if [ ! -d "$BACKUP_DIR" ]; then
  echo "каталог $BACKUP_DIR не найден"; exit 2
fi

newest="$(find "$BACKUP_DIR" -maxdepth 1 -type f -name "$PATTERN" -printf '%T@ %p\n' | sort -nr | head -n 1 | cut -d' ' -f2-)"
if [ -z "$newest" ]; then
  echo "копии по маске $PATTERN не найдены"; exit 2
fi

if ! tar -tzf "$newest" > /dev/null 2>&1; then
  echo "свежая копия повреждена: $newest"; exit 2
fi

mapfile -t victims < <(find "$BACKUP_DIR" -maxdepth 1 -type f -name "$PATTERN" -mtime "+${RETENTION_DAYS}" -print)

for f in "${victims[@]}"; do
  if [ "$DRY_RUN" = "1" ]; then
    echo "будет удалён: $f"
  else
    rm -f -- "$f" && echo "удалён: $f"
  fi
done

Подводные камни. Широкая маска вида * в BACKUP_DIR удалит всё, включая конфиги и скрипты, поэтому маска должна быть конкретной. Ключ -mtime считает сутки по времени изменения файла, и копия, скопированная с сохранением временных меток, может считаться старой раньше срока. Отсутствие проверки целостности приводит к худшему сценарию: битая последняя копия есть, а предыдущие уже удалены.

Проверка целостности бэкапа перед удалением старой копии

Удалять историю можно только тогда, когда свежая копия доказанно пригодна. Для tar-архивов минимум - чтение оглавления командой tar -tzf, оно ловит обрыв записи и повреждение заголовков. Для больших архивов добавляйте контрольную сумму, но не в одном каталоге с архивом: считайте сразу при создании и складывайте рядом файл .sha256.

sha256sum app-2026-09-24.tar.gz > app-2026-09-24.tar.gz.sha256
sha256sum -c app-2026-09-24.tar.gz.sha256

Для дампов баз данных проверка другая: pg_restore --list для формата custom или распаковка в отдельную схему. Общий принцип одинаков: пока новая копия не проверена, старые не трогаем. Если проверка не прошла, скрипт выходит с кодом 2, удаление не выполняется, а система алертов должна получить уведомление.

Адаптация и проверка. Переменные BACKUP_DIR, PATTERN, RETENTION_DAYS, DRY_RUN. Перед первым боевым запуском выполните ls -lt в каталоге копий, затем запустите с DRY_RUN=1 и сравните список кандидатов с ожиданиями. Убедитесь, что в списке нет файлов, которые вы не собирались удалять.

Скрипт 3: очистка временных файлов и старых логов

Задача. Освобождать место в /tmp, /var/tmp и нестандартных каталогах с временными данными, не задевая файлы, которые прямо сейчас держат открытыми работающие процессы.

Логика. find по списку каталогов с -xdev (не переходить на другие файловые системы), фильтр по возрасту файла и явные исключения по маске. Сначала всегда dry-run со списком кандидатов.

#!/usr/bin/env bash
set -euo pipefail

CLEAN_DIRS=("/tmp" "/var/tmp" "/srv/app/cache")
AGE_DAYS=7
DRY_RUN="${DRY_RUN:-1}"

for dir in "${CLEAN_DIRS[@]}"; do
  if [ ! -d "$dir" ]; then
    echo "пропуск: $dir не найден"; continue
  fi
  find "$dir" -xdev -type f -mtime "+${AGE_DAYS}" \
    ! -name '*.sock' ! -name '*.lock' ! -name 'systemd-*' -print |
  while read -r f; do
    if [ "$DRY_RUN" = "1" ]; then
      echo "будет удалён: $f"
    else
      rm -f -- "$f" && echo "удалён: $f"
    fi
  done
done

Подводные камни. Удаление файла не освобождает место, если его держит процесс: inode живёт до закрытия дескриптора. Проверяйте такие случаи через lsof +L1, который показывает удалённые, но открытые файлы. Второй риск: /tmp и /var/tmp часто чистятся штатным systemd-tmpfiles, и самодельный скрипт с другим сроком создаёт конфликт ожиданий. Третий: age по -mtime считает сутки целиком, поэтому файл возрастом 6 суток и 23 часа не попадёт под порог 7 дней, хотя фактически близок к нему.

Ротация логов через logrotate вместо самодельного скрипта

Для файлов в /var/log правильный инструмент - logrotate. Он умеет сжатие, отложенное удаление, сигналы приложению после ротации и учёт размера, а не только возраста. Самодельный скрипт нужен для каталогов вне /var/log и для временных данных.

/var/log/nginx/*.log {
    daily
    rotate 14
    compress
    delaycompress
    missingok
    notifempty
    create 0640 www-data adm
    sharedscripts
    postrotate
        systemctl reload nginx > /dev/null 2>&1 || true
    endscript
}

Файл кладём в /etc/logrotate.d/, проверяем конфигурацию прогоном logrotate -d /etc/logrotate.d/nginx (режим отладки, ничего не делает) и один раз запускаем принудительно: logrotate -f /etc/logrotate.d/nginx. Параметр delaycompress оставляет последний сжатый лог распакованным на один цикл, чтобы приложения успели дописать в старый файл после переоткрытия дескрипторов.

Адаптация и проверка. Переменные CLEAN_DIRS, AGE_DAYS, DRY_RUN. Результат оцените командой du -sh по каждому каталогу до и после, а подозрительные файлы проверьте через lsof для конкретного процесса.

Скрипт 4: мониторинг доступности сервисов через HTTP и порты

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

Логика. Список целей лежит в отдельном файле в формате тип|адрес|ожидаемое значение. Для HTTP проверяем код ответа через curl с таймаутом, для TCP - доступность порта через nc. Скрипт ведёт счётчик последовательных неудач в отдельном файле и отправляет алерт только после третьего провала подряд. Успешная проверка сбрасывает счётчик.

# /etc/healthcheck/targets.conf
# http|https://app.example.com/healthz|200
# tcp|db-01.internal|5432
#!/usr/bin/env bash
set -euo pipefail

TARGETS="/etc/healthcheck/targets.conf"
STATE_DIR="/var/lib/healthcheck"
FAIL_THRESHOLD=3
TIMEOUT=5
WEBHOOK="${ALERT_WEBHOOK:?переменная ALERT_WEBHOOK не задана}"
mkdir -p "$STATE_DIR"

alert() {
  printf '{"text":"%s"}' "$1" |
    curl -fsS -m 10 -X POST "$WEBHOOK" -H 'Content-Type: application/json' --data-binary @- > /dev/null
}

check() {
  case "$1" in
    http) [ "$(curl -sS -o /dev/null -m "$TIMEOUT" -w '%{http_code}' "$2" || true)" = "$3" ] ;;
    tcp)  nc -z -w "$TIMEOUT" "$2" "$3" ;;
    *)    return 1 ;;
  esac
}

while IFS='|' read -r kind target expect; do
  case "$kind" in ''|'#'*) continue ;; esac
  state_file="$STATE_DIR/$(printf '%s' "$kind$target" | tr -c 'A-Za-z0-9' '_').fails"
  fails=0
  if [ -f "$state_file" ]; then fails="$(cat "$state_file")"; fi
  if check "$kind" "$target" "$expect"; then
    rm -f "$state_file"
  else
    fails=$((fails + 1))
    printf '%s' "$fails" > "$state_file"
    if [ "$fails" -ge "$FAIL_THRESHOLD" ]; then
      alert "DOWN: ${kind} ${target} не отвечает ${fails} проверок подряд"
    fi
  fi
done < "$TARGETS"

Подводные камни. Отсутствие -m у curl превращает проверку в зависание, если сервис принимает соединение, но не отвечает. Проверка только TCP-порта не говорит о состоянии приложения: процесс слушает, а база за ним недоступна, поэтому для критичных сервисов добавляйте HTTP-эндпоинт, который заодно дергает зависимости. Состояние в файле не переживёт пересоздание контейнера, для контейнеров счётчик выносите на общий том или во внешнюю систему алертов.

Как отличить реальную недоступность от кратковременного сбоя

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

Полезные команды ручной диагностики, чтобы сверить показания скрипта: curl -I -m 5 с выводом заголовков видно код и время ответа, а nc -vz host port показывает именно установку соединения. Имитация падения простая: остановите сервис на тестовом стенде (systemctl stop) и посмотрите, через сколько проверок придёт алерт.

Адаптация и проверка. Переменные TARGETS, STATE_DIR, FAIL_THRESHOLD, TIMEOUT. Список целей редактируется без правки скрипта. Убедитесь, что в файле целей нет комментариев, начинающихся с пробела, иначе строка обработается как цель.

Скрипт 5: синхронизация конфигов между серверами

Задача. Держать одинаковые конфиги на нескольких серверах без ручного копирования и без риска потерять локальные правки.

Логика. rsync в режиме архива с удалением лишнего на приёмнике, списком исключений и предварительным dry-run. Код возврата 24 (файлы исчезли во время передачи) для конфигов считается нормальным и обрабатывается отдельно, остальные ошибки завершают работу.

#!/usr/bin/env bash
set -euo pipefail

SRC="/srv/config/"
HOSTS=("app-02" "app-03")
DEST="/srv/config/"
EXCLUDES=(--exclude 'local.conf' --exclude '*.swp' --exclude '.git/')
DRY_RUN="${DRY_RUN:-1}"

for h in "${HOSTS[@]}"; do
  args=(-az --delete --numeric-ids --itemize-changes "${EXCLUDES[@]}" -e ssh)
  if [ "$DRY_RUN" = "1" ]; then args+=(-n); fi
  set +e
  rsync "${args[@]}" "$SRC" "root@${h}:${DEST}"
  rc=$?
  set -e
  case "$rc" in
    0) echo "OK: $h" ;;
    24) echo "ОК с замечаниями (файлы изменились в процессе): $h" ;;
    *) echo "ОШИБКА rsync для $h, код $rc"; exit "$rc" ;;
  esac
done

Подводные камни. Ключ --delete без предварительного dry-run удаляет на приёмнике всё, чего нет в источнике, включая файлы, созданные администратором вручную. Одновременная правка источника и приёмника приводит к тому, что изменения с приёмника исчезают без предупреждения. Отсутствие версионирования означает, что откатить некорректный конфиг нечем, кроме свежего бэкапа.

Почему конфиги лучше хранить в git, а не синхронизировать напрямую

rsync отвечает на вопрос «как доставить файлы», но не отвечает на вопросы «кто и когда поменял» и «как вернуть предыдущую версию». Репозиторий закрывает и то, и другое: история изменений, ревью через pull request, откат одного коммита, единый источник правды для всех серверов.

Рабочая схема выглядит так: конфиги лежат в git, на сервере выполняется git pull по расписанию или в деплой-пайплайне, применённые изменения логируются. rsync оставляем там, где он уместен: бинарные артефакты, большие каталоги с данными, первичная доставка файлов на новый узел. Комбинировать оба подхода удобно: git хранит состояние, rsync ускоряет массовую доставку.

Адаптация и проверка. Переменные SRC, DEST, HOSTS, EXCLUDES, DRY_RUN. Перед первой боевой синхронизацией запустите вариант с -n и --itemize-changes: вывод покажет каждое отличие с пометкой, что именно изменится. Затем сверьте каталоги командой diff -r по ключевым файлам.

Скрипты 6-10: короткие заготовки для остальных рутинных задач

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

Скрипт 6: перезапуск упавших сервисов

Логика. Проверяем состояние юнита через systemctl is-active, при статусе failed делаем systemctl restart, но не бесконечно: счётчик попыток в файле состояния, после лимита отправляем алерт и оставляем сервис как есть.

#!/usr/bin/env bash
set -euo pipefail

UNITS=("nginx.service" "postgresql.service" "app-worker.service")
MAX_ATTEMPTS=3
STATE_DIR="/var/lib/service-watch"
mkdir -p "$STATE_DIR"

for unit in "${UNITS[@]}"; do
  state="$STATE_DIR/${unit}.attempts"
  attempts=0
  if [ -f "$state" ]; then attempts="$(cat "$state")"; fi
  case "$(systemctl is-active "$unit" || true)" in
    active)
      rm -f "$state"
      ;;
    failed|inactive)
      if [ "$attempts" -ge "$MAX_ATTEMPTS" ]; then
        echo "ALERT: $unit не поднимается после $attempts попыток"
      else
        systemctl restart "$unit"
        printf '%s' "$((attempts + 1))" > "$state"
        echo "перезапущен $unit, попытка $((attempts + 1))"
      fi
      ;;
  esac
done

Подводные камни. Бесконечный перезапуск маскирует реальную проблему с конфигом или зависимостями и забивает логи. Если юнит стартует автоматически через Restart=on-failure в unit-файле, внешний скрипт ему не нужен, лишний цикл только мешает. Проверка: systemctl status имя-юнита и journalctl -u имя-юнита -n 50.

Скрипт 7: сбор метрик и запись в файл

Логика. Раз в минуту снимаем загрузку, память и место на корневом разделе, дописываем строку в CSV с меткой времени в формате ISO 8601. Данные пригодятся для разбора инцидентов, когда полноценная система мониторинга ещё не развёрнута.

#!/usr/bin/env bash
set -euo pipefail

OUT="/var/log/sysadmin/metrics.csv"
mkdir -p "$(dirname "$OUT")"
[ -f "$OUT" ] || echo 'ts,load1,mem_used_mb,disk_root_pct' > "$OUT"

ts="$(date -u +%FT%TZ)"
load="$(cut -d' ' -f1 /proc/loadavg)"
mem="$(awk '/MemTotal/{t=$2} /MemAvailable/{a=$2} END{printf "%d", (t-a)/1024}' /proc/meminfo)"
disk="$(df -P / | awk 'NR==2 {gsub(/%/,"",$5); print $5}')"

printf '%s,%s,%s,%s\n' "$ts" "$load" "$mem" "$disk" >> "$OUT"

Подводные камни. Файл растёт бесконечно, поэтому добавьте его в logrotate или ограничьте частоту запуска. Запись раз в минуту при 1440 строках в сутки даёт примерно полмегабайта в год, но метрики с большим числом полей меняют порядок цифр. Адаптация: OUT и расписание запуска. Проверка: tail -n 5 файла метрик и жёсткая проверка, что метка времени не повторяется. Более полный набор примеров сбора и отправки метрик есть в практическом гайде по автоматизации инфраструктуры.

Скрипт 8: проверка срока действия TLS-сертификатов

Логика. Скрипт забирает сертификат с порта 443 через openssl s_client, переводит дату окончания в секунды и сравнивает с порогом в днях. Алерт уходит заранее, чтобы осталось время на продление и перевыпуск в цепочке.

#!/usr/bin/env bash
set -euo pipefail

DOMAINS=("app.example.com" "api.example.com")
DAYS_THRESHOLD=30
NOW="$(date +%s)"

for d in "${DOMAINS[@]}"; do
  end="$(echo | openssl s_client -servername "$d" -connect "${d}:443" 2>/dev/null |
        openssl x509 -noout -enddate | cut -d= -f2)"
  if [ -z "$end" ]; then echo "ALERT: не удалось получить сертификат $d"; continue; fi
  end_epoch="$(date -d "$end" +%s)"
  days=$(( (end_epoch - NOW) / 86400 ))
  echo "$d: осталось $days дней"
  if [ "$days" -le "$DAYS_THRESHOLD" ]; then
    echo "ALERT: сертификат $d истекает через $days дней"
  fi
done

Подводные камни. У домена может быть несколько сертификатов в цепочке, и проверять нужно листовой, а не промежуточный. Самоподписанные сертификаты дадут ошибку проверки, поэтому для внутренних сервисов добавляйте ключ -verify 0 или отдельную логику. Формат даты зависит от локали, ключ -d у date корректно разбирает вывод openssl в большинстве сборок, но в минималистичных образах его может не быть. Проверка: echo | openssl s_client -connect домен:443 2>/dev/null | openssl x509 -noout -dates.

Скрипт 9: бэкап баз данных с проверкой дампа

Логика. Снимаем дамп, проверяем код возврата и размер файла, затем валидируем структуру: pg_restore --list для формата custom или mysqlcheck для подготовленного дампа. Пароль передаём через переменные окружения и файлы прав, а не через командную строку.

#!/usr/bin/env bash
set -euo pipefail

BACKUP_DIR="/srv/backups/pg"
DB_NAME="appdb"
MIN_SIZE_MB=10
STAMP="$(date -u +%F_%H%M)"
out="$BACKUP_DIR/${DB_NAME}_${STAMP}.dump"
mkdir -p "$BACKUP_DIR"

pg_dump -Fc -f "$out" "$DB_NAME"
size_mb=$(( $(stat -c '%s' "$out") / 1048576 ))
if [ "$size_mb" -lt "$MIN_SIZE_MB" ]; then
  echo "ALERT: дамп подозрительно мал: ${size_mb} МБ"; exit 2
fi

pg_restore --list "$out" > /dev/null
sha256sum "$out" > "$out.sha256"
echo "готово: $out (${size_mb} МБ)"

Подводные камни. pg_dump в формате custom не блокирует таблицы целиком и подходит для горячего бэкапа, но при высокой нагрузке всё равно удлиняет транзакции и увеличивает WAL. Пароль в командной строке виден в выводе ps, поэтому используйте ~/.pgpass или переменную окружения. Нехватка места в момент записи дампа даёт файл с корректным началом и обрывом в конце, поэтому проверка через pg_restore --list обязательна. Проверка: восстановите дамп в тестовую базу и сравните количество строк в двух-трёх ключевых таблицах.

Скрипт 10: инвентаризация пакетов и версий

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

#!/usr/bin/env bash
set -euo pipefail

OUT_DIR="/var/lib/inventory"
mkdir -p "$OUT_DIR"
current="$OUT_DIR/packages-$(date -u +%F).txt"
baseline="$OUT_DIR/baseline.txt"

if command -v dpkg-query > /dev/null; then
  dpkg-query -W -f='${Package} ${Version}\n' | sort > "$current"
else
  rpm -qa --qf '%{NAME} %{VERSION}-%{RELEASE}\n' | sort > "$current"
fi

if [ -f "$baseline" ]; then
  diff -u "$baseline" "$current" || true
else
  cp "$current" "$baseline"
  echo "эталон создан: $baseline"
fi

Подводные камни. Формат вывода различается между дистрибутивами, и сравнивать списки из dpkg и rpm между собой бессмысленно. Файл с полным списком на серверах с тысячами пакетов занимает сотни килобайт, поэтому храните только эталон и последнюю выгрузку. Проверка: diff -u baseline.txt текущий-файл и визуальная сверка нескольких строк, установленных вручную.

Как запускать скрипты по расписанию: cron или systemd timer

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

Критерийcronsystemd timer
ЛогиПишем сами, перенаправляя вывод в файлАвтоматически в journald, смотреть через journalctl -u
Пропущенный запускТеряется, если сервер был выключенPersistent=true догоняет после включения
ОкружениеМинимальный PATH, переменные задаём вручнуюЗадаётся в unit через Environment=
ЗависимостиНетAfter=, Requires=, Wants=
Лимиты ресурсовНетCPUQuota=, MemoryMax=, IOSchedulingClass=
Разброс запусковНетRandomizedDelaySec=

Простой вариант через cron, с блокировкой и логированием:

30 3 * * * flock -n /var/lock/backup-rotate.lock /usr/local/bin/backup-rotate.sh >> /var/log/backup-rotate.log 2>&1

Вариант через systemd timer. Сначала unit-файл /etc/systemd/system/backup-rotate.service:

[Unit]
Description=Ротация бэкапов приложения

[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup-rotate.sh
Environment=DRY_RUN=0
IOSchedulingClass=idle
Nice=10

Затем таймер /etc/systemd/system/backup-rotate.timer:

[Unit]
Description=Ежедневный запуск ротации бэкапов

[Timer]
OnCalendar=*-*-* 03:30:00
Persistent=true
RandomizedDelaySec=300

[Install]
WantedBy=timers.target

После создания файлов выполните systemctl daemon-reload, включите таймер командой systemctl enable --now backup-rotate.timer и проверьте расписание через systemctl list-timers. Принудительный запуск для теста: systemctl start backup-rotate.service, затем journalctl -u backup-rotate.service -n 50.

Подводные камни cron: PATH по умолчанию короткий, поэтому все утилиты вызывайте с полным путём или задайте PATH в начале crontab. Почтовый вывод cron без настроенного MTA теряется, а вместе с ним и сообщения об ошибках. Пересечение запусков решается через flock, но лок нужно ставить на сам скрипт, а не только в строке cron, иначе при запуске вручную защита не сработает. Структуру юнитов и переменные окружения подробнее разбирали в материале про структуру автоматизированных сценариев установки.

Как не допустить одновременного запуска двух копий скрипта

Долгий бэкап или синхронизация легко превышают интервал расписания, и второй экземпляр начинает работать с теми же файлами. Блокировка через flock решает проблему на уровне ядра: второй запуск сразу видит занятый лок и завершается.

exec 9> "/var/lock/${SCRIPT_NAME}.lock"
if ! flock -n 9; then
  echo "$(date -u +%FT%TZ) лок занят, выходим"
  exit 0
fi

Возвращать код 0 при занятом локе или 1, зависит от цели. Если задача идемпотентна и следующий запуск всё повторит, достаточно 0: планировщик не станет сигнализировать об ошибке. Если пропуск запуска критичен, возвращайте 1 и настраивайте алерт по коду возврата сервиса.

В systemd-юните для того же эффекта хватает условия: чтобы запускать задачу только при живом сервисе, добавьте Requires= нужного юнита, а для защиты от параллельных копий используйте один и тот же файл лока в ExecStart.

Чек-лист перед внедрением скриптов в прод

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

  • Прогон на стенде. Скрипт отработал на копии каталогов или на отдельной виртуалке. Проверка: успешный запуск с реальными путями, но на тестовых данных.
  • Режим dry-run. Все разрушительные операции (rm, rsync --delete, ротация) сначала показывают список действий. Проверка: запуск с DRY_RUN=1 и чтение вывода построчно.
  • Логирование включено. Вывод попадает в файл или journald с метками времени в ISO 8601. Проверка: tail -n 5 по логу или journalctl -u имя-юнита -n 20.
  • Блокировка повторного запуска. flock на файл лока внутри скрипта, а не только в строке планировщика. Проверка: запустите две копии подряд и убедитесь, что вторая завершилась сразу.
  • Права минимальны. Задача выполняется не от root, если ей не нужны системные пути. Проверка: ps -o user= -p по PID процесса во время работы.
  • Алерт при падении. Ненулевой код возврата приводит к уведомлению, а не к молчаливому пропуску. Проверка: искусственно вызовите ошибку и дождитесь сигнала.
  • Откат продуман. Известно, как вернуть предыдущее состояние: снимок диска, бэкап каталога, git revert. Проверка: восстановление прошло на стенде.
  • Переменные задокументированы. Список параметров и допустимые значения описаны в комментарии в начале файла. Проверка: коллега может поменять порог без чтения тела скрипта.
  • Ротация логов скрипта. Файл вывода не растёт бесконечно. Проверка: запись добавлена в logrotate или настроено ограничение по размеру.
  • Учёт пропущенных запусков. Для критичных задач понимаете, что произойдёт при выключенном сервере: догонит ли задача после включения. Проверка: Persistent=true в таймере или ручная догоняющая команда.

Автоматизация освобождает часы в неделю, но только при дисциплине на старте: стенд, dry-run, логи, блокировка, ограниченные права. Начните с проверки места на дисках и мониторинга сервисов, эти два скрипта дают максимум пользы при минимальном риске, а ротацию бэкапов и очистку каталогов подключайте после того, как убедитесь в корректной работе базовых.

Поделиться:
Сохранить гайд? В закладки браузера