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

Почему серверу нужен собственный голос

Типовая домашняя инфраструктура живёт по принципу молчания. Всё хорошо, пока кто-то сам не зайдёт и не посмотрит. Проверка раз в неделю даёт гарантию с точностью до недели, а неприятности случаются в промежутке.

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

Второй повод касается bash-скриптов. Полноценная система мониторинга с агентами и хранилищем метрик оправдана на десятках узлов. Для одного-двух серверов достаточно скрипта на двадцать строк и канала, куда он пишет.

Локальный мессенджер как точка доставки и чем он лучше внешнего

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

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

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

Минимальное развёртывание требует базу и приложение:

services:
  mm-db:
    image: postgres:18
    restart: unless-stopped
    environment:
      POSTGRES_DB: mattermost
      POSTGRES_USER: mmuser
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - ./mm-db:/var/lib/postgresql/data

  mattermost:
    image: mattermost/mattermost-team-edition
    restart: unless-stopped
    depends_on:
      - mm-db
    environment:
      MM_SQLSETTINGS_DRIVERNAME: postgres
      MM_SQLSETTINGS_DATASOURCE: "postgres://mmuser:${DB_PASSWORD}@mm-db:5432/mattermost?sslmode=disable"
      MM_SERVICESETTINGS_SITEURL: "http://127.0.0.1:8065"
      MM_SERVICESETTINGS_ENABLEINCOMINGWEBHOOKS: "true"
    ports:
      - "127.0.0.1:8065:8065"
    volumes:
      - ./mm-data:/mattermost/data

Последняя переменная обязательна: без неё платформа отклоняет запросы, а проблему обычно ищут в скрипте.

Первый вебхук и отправка пробного сообщения через curl

Вебхук создаётся в интерфейсе. Нужен раздел интеграций, пункт входящих вебхуков, создание нового с привязкой к каналу. На выходе получается адрес вида:

http://127.0.0.1:8065/hooks/xxxxxxxxxxxxxxxxxxxxxxxx

Порядок подготовки канала короткий:

  1. В интерфейсе создаётся канал с названием вроде server-alerts, отдельный от общих переписок, чтобы уведомления не терялись;
  2. В разделе интеграций добавляется входящий вебхук с привязкой к этому каналу;
  3. Полученный адрес записывается в файл с правами 600, поскольку он заменяет собой авторизацию;
  4. Пробное сообщение отправляется через curl и проверяется ответ ok;
  5. Только после этого скрипт добавляется в расписание, иначе отладка идёт вслепую.

Проверка занимает одну команду:

curl -s -X POST \
  -H 'Content-Type: application/json' \
  -d '{"text":"Проверка связи от сервера"}' \
  http://127.0.0.1:8065/hooks/xxxxxxxxxxxxxxxxxxxxxxxx

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

Адрес вебхука это секрет. Любой, кто его знает, пишет в канал от имени сервера, поэтому его хранят в отдельном файле с правами 600, а не в теле скрипта:

sudo install -m 600 /dev/null /etc/server-alert.conf
echo 'WEBHOOK_URL=http://127.0.0.1:8065/hooks/xxxxxxxxxxxxxxxxxxxxxxxx' \
  | sudo tee /etc/server-alert.conf

Скрипт контроля места на диске с порогом и защитой от потока сообщений

Наивная проверка места выглядит просто и работает плохо:

df -h / | awk 'NR==2 {print $5}'

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

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

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

source /etc/server-alert.conf
THRESHOLD=85
STATE_DIR=/var/lib/server-alert
mkdir -p "$STATE_DIR"
HOST=$(hostname -s)

send() {
  curl -s -o /dev/null -X POST \
    -H 'Content-Type: application/json' \
    -d "{\"text\":\"$1\"}" "$WEBHOOK_URL"
}

df -P -x tmpfs -x devtmpfs -x overlay | awk 'NR>1 {print $6, $5}' | \
while read -r mount usage; do
  pct=${usage%\%}
  [ "$pct" -lt "$THRESHOLD" ] && continue

  state="$STATE_DIR/disk-${mount//\//_}"
  # повторное сообщение не чаще раза в 6 часов
  if [ -f "$state" ] && [ $(( $(date +%s) - $(stat -c %Y "$state") )) -lt 21600 ]; then
    continue
  fi
  touch "$state"

  free=$(df -h "$mount" | awk 'NR==2 {print $4}')
  send "**$HOST**: на \`$mount\` занято ${pct}%, свободно ${free}"
done

Флаг -P у df выводит каждую точку монтирования одной строкой, без переносов длинных имён устройств, из-за которых разбор ломается. Исключения tmpfs и overlay убирают служебные файловые системы контейнеров.

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

rm -f /var/lib/server-alert/disk-_var

Проверка через pipe в цикл создаёт подоболочку, поэтому переменные из цикла наружу не видны. На работу скрипта это не влияет, но при доработке об эту особенность легко споткнуться.

Оформление сообщений, чтобы их можно было читать одним взглядом

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

send "**$HOST**: на \`$mount\` занято ${pct}%
свободно ${free}, inode занято ${inode_pct}%"

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

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

journalctl -u nginx -p err -n 5 --no-pager | tail -5

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

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

Память смотрят не по свободному объёму, а по доступному, поскольку кэш в Linux отдаёт память по первому требованию:

awk '/MemAvailable/ {printf "%d\n", $2/1024}' /proc/meminfo

Загрузка считается как среднее за минуту, делённое на число ядер. Значение выше единицы означает очередь из задач:

cores=$(nproc)
load=$(awk '{print $1}' /proc/loadavg)
awk -v l="$load" -v c="$cores" 'BEGIN {printf "%.2f\n", l/c}'

Доступность службы проверяется по коду ответа. Для веб-сервера достаточно HEAD-запроса с коротким таймаутом:

code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 http://127.0.0.1:8080/ || echo 000)
if [ "$code" != "200" ]; then
  send "**$HOST**: веб-сервер отвечает кодом $code"
fi

Обработка отказа соединения обязательна. Без подстановки 000 переменная останется пустой и сравнение пройдёт молча, то есть оповещения не будет именно при полном отказе службы.

Все проверки удобно собрать в один скрипт с общим подавлением повторов. Один файл проще сопровождать, и пороги видны в одном месте.

Расписание запуска через cron или таймер systemd

Запуск по расписанию задаётся двумя способами, и выбор между ними не принципиален.

Классический cron пишется одной строкой:

*/5 * * * * /usr/local/sbin/server-alert.sh >/dev/null 2>&1

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

Таймер systemd даёт больше контроля и виден в общем списке служб. Нужны два файла, сервис и таймер:

# /etc/systemd/system/server-alert.service
[Unit]
Description=Server alert checks

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/server-alert.sh
# /etc/systemd/system/server-alert.timer
[Unit]
Description=Run server alert every 5 minutes

[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
AccuracySec=30s

[Install]
WantedBy=timers.target

AccuracySec стоит задать явно, поскольку по умолчанию systemd откладывает срабатывание ради экономии ресурсов. Включается таймер стандартно:

sudo systemctl daemon-reload
sudo systemctl enable --now server-alert.timer
systemctl list-timers --all | head

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

sudo bash -x /usr/local/sbin/server-alert.sh 2>&1 | tail -40

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

Третий, проверка кода ответа доставки. Молчание curl в скрипте скрывает ошибки, поэтому на время отладки стоит выводить и код, и тело ответа:

curl -s -w '\nHTTP %{http_code}\n' -X POST \
  -H 'Content-Type: application/json' \
  -d '{"text":"тест"}' "$WEBHOOK_URL"

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

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

sudo env -i /bin/bash /usr/local/sbin/server-alert.sh

Если в таком режиме сообщений нет, а в обычном есть, причина найдена.

Что делать с ложными срабатываниями и как не пропустить реальную беду

Ложное срабатывание вреднее отсутствия оповещения. После третьего сообщения ни о чём канал перестают читать, и настоящее происшествие в нём теряется.

Основной источник шума, кратковременные пики. Загрузка подскочила на минуту во время резервного копирования, память ушла в кэш после чтения большого файла. Лечится требованием устойчивости: условие должно выполняться два-три прогона подряд. Реализуется счётчиком в каталоге состояния.

Второй, плавающие пороги. Значение 85 процентов для раздела на 20 ГБ и для раздела на 2 ТБ даёт разный запас в абсолютных величинах. Разумнее задавать порог в гигабайтах для больших разделов и в процентах для маленьких.

Третий, отсутствие проверки самого канала. Скрипт работает, но сообщения не доходят из-за переехавшего вебхука или остановленного мессенджера. Раз в сутки стоит отправлять тестовое сообщение и проверять ответ:

resp=$(curl -s -X POST -H 'Content-Type: application/json' \
  -d '{"text":"Ежедневная проверка канала"}' "$WEBHOOK_URL")
[ "$resp" = "ok" ] || logger -t server-alert "канал доставки не отвечает: $resp"

Запись в системный журнал не случайна. Если канал доставки умер, сообщать об этом через него же бессмысленно, и остаётся локальный журнал.

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