Надёжные bash-скрипты: strict mode, ошибки, ShellCheck и NFS с автомонтированием
Bash-скрипты имеют дурную славу: они «просто работают», пока не наступает день, когда переменная оказывается пустой, а команда rm -rf $DIR/* превращается в rm -rf /*. Хорошая новость в том, что большинство таких катастроф предотвращается несколькими строками в начале файла и привычкой прогонять код через линтер. В этой статье соберём шаблон надёжного скрипта, научимся обрабатывать ошибки, подключим ShellCheck, а в конце применим всё это на практике: настроим NFS с автомонтированием и напишем скрипт, который его проверяет.
Примеры идут на srv-linux01 (192.168.10.11); NFS-сервер у нас srv-ldap01 (192.168.10.20) в роли файлового хранилища, сеть 192.168.10.0/24, домен itpages.lab.local.
Шаг 1. Strict mode: три опции, которые спасают
Минимальная защита выглядит так, и её стоит ставить в каждый серьёзный скрипт:
#!/usr/bin/env bash
set -Eeuo pipefail
IFS=$'\n\t'
Разберём. Опция -e завершает скрипт при первой команде с ненулевым кодом возврата. Опция -u считает обращение к неопределённой переменной ошибкой, и тот самый rm -rf $DIR/* с пустым DIR остановится до удаления. Опция pipefail заставляет конвейер возвращать ошибку, если упала любая его часть, а не только последняя. Опция -E передаёт ловушки ERR в функции и подоболочки. Строка с IFS убирает пробел из разделителей слов, что защищает от развала путей с пробелами.
У strict mode есть нюансы. Команда, которая законно может вернуть ненулевой код (например, grep без совпадений), требует явного разрешения:
count=$(grep -c 'error' /var/log/app.log || true)
if ! systemctl is-active --quiet nginx; then
echo 'nginx не запущен' >&2
fi
name=${1:-default}
Конструкция ${1:-default} подставляет значение по умолчанию, а ${1:?нужен аргумент} прерывает скрипт с понятным сообщением. Это лучший способ проверять обязательные параметры.
Шаг 2. Обработка ошибок: trap и cleanup
Скрипт, который создаёт временные файлы или блокировки, обязан убирать за собой даже при падении. Для этого служит trap:
#!/usr/bin/env bash
set -Eeuo pipefail
TMPDIR_WORK=$(mktemp -d)
LOCKFILE=/var/lock/backup.lock
cleanup() {
local rc=$?
rm -rf -- "$TMPDIR_WORK"
[[ -e $LOCKFILE ]] && rm -f -- "$LOCKFILE"
exit $rc
}
on_error() {
echo "Ошибка в строке $1: команда '$2'" >&2
}
trap cleanup EXIT
trap 'on_error $LINENO "$BASH_COMMAND"' ERR
Ловушка EXIT срабатывает при любом завершении, в том числе после ошибки, а ERR печатает номер строки и команду, на которой всё сломалось, и экономит часы отладки. Вместо echo в рабочих скриптах лучше писать в syslog: logger -t backup "сообщение", тогда записи окажутся в journald рядом с остальными службами.
Чтобы запуск не пересекался с самим собой (например, cron стартует новый экземпляр, пока старый не закончил), используй flock:
exec 9>/var/lock/backup.lock
flock -n 9 || { echo 'уже запущен' >&2; exit 1; }
Шаг 3. Шаблон скрипта целиком
Объединим всё в каркас, который можно копировать для новых задач:
#!/usr/bin/env bash
# backup-etc.sh: копия /etc на сервер хранения
set -Eeuo pipefail
IFS=$'\n\t'
readonly SCRIPT_NAME=${0##*/}
readonly SRC=/etc
readonly DEST=${1:?Использование: $SCRIPT_NAME каталог_назначения}
readonly STAMP=$(date +%F_%H%M)
log() { logger -t "$SCRIPT_NAME" -- "$*"; echo "$(date '+%F %T') $*"; }
die() { log "ОШИБКА: $*"; exit 1; }
[[ -d $DEST ]] || die "нет каталога $DEST"
[[ $EUID -eq 0 ]] || die 'нужен root'
main() {
local archive="$DEST/etc-$STAMP.tar.gz"
log "создаю $archive"
tar -czf "$archive" -C / etc
log "размер: $(du -h "$archive" | cut -f1)"
find "$DEST" -name 'etc-*.tar.gz' -mtime +14 -delete
}
main "$@"
Обрати внимание на мелочи: readonly для констант, local для переменных внутри функций, двойные кавычки вокруг каждого раскрытия, двойные квадратные скобки для условий и отдельная функция main. Всё это не украшательство, а защита от целого класса неприятностей.
Шаг 4. ShellCheck: линтер, который читает за тебя
ShellCheck находит ошибки до запуска: неэкранированные переменные, устаревшие конструкции, опасные подстановки. Ставится и запускается просто:
sudo apt install -y shellcheck
shellcheck backup-etc.sh
shellcheck -x -S warning scripts/*.sh
shellcheck -f gcc backup-etc.sh
Типичное предупреждение SC2086 говорит о переменной без кавычек: cp $file /backup сломается на имени с пробелом. SC2155 предупреждает о маскировке кода возврата в записи local x=$(cmd). Если есть осознанное исключение, отключай его точечно комментарием выше нужной строки, а не весь линтер:
# shellcheck disable=SC2034
UNUSED_FOR_NOW=1
Хорошая привычка: подключить ShellCheck в проверку перед коммитом. Простейший хук .git/hooks/pre-commit выглядит так: git diff --cached --name-only --diff-filter=ACM | grep '\.sh$' | xargs -r shellcheck. А для оформления используй shfmt: shfmt -w -i 2 script.sh.
Шаг 5. NFS: настраиваем сервер
Теперь практическое применение. Нам нужен общий каталог /srv/share на srv-ldap01, доступный для клиентов сети. Ставим сервер и описываем экспорт:
sudo apt install -y nfs-kernel-server
sudo mkdir -p /srv/share
sudo chown nobody:nogroup /srv/share
echo '/srv/share 192.168.10.0/24(rw,sync,no_subtree_check,root_squash)' | sudo tee -a /etc/exports
sudo exportfs -ra
sudo exportfs -v
sudo systemctl enable --now nfs-kernel-server
Параметр root_squash не даёт клиентскому root действовать как root на сервере, а sync гарантирует, что запись подтверждается только после сохранения на диск. Открой файрвол для сети: sudo ufw allow from 192.168.10.0/24 to any port nfs. С клиента проверь доступность: showmount -e 192.168.10.20.
Шаг 6. Автомонтирование на клиенте
Постоянное монтирование в fstab неудобно: если сервер недоступен при загрузке, клиент может зависнуть. Решение, это автомонтирование по требованию. Есть два пути: systemd-automount и autofs. Начнём с первого, он не требует дополнительных пакетов:
sudo apt install -y nfs-common
sudo mkdir -p /mnt/share
echo '192.168.10.20:/srv/share /mnt/share nfs4 noauto,x-systemd.automount,x-systemd.idle-timeout=300,x-systemd.mount-timeout=15,_netdev,soft,timeo=50,retrans=3 0 0' | sudo tee -a /etc/fstab
sudo systemctl daemon-reload
sudo systemctl restart remote-fs.target
ls /mnt/share
findmnt /mnt/share
Каталог смонтируется при первом обращении и отмонтируется после пяти минут простоя. Опция soft делает так, что при недоступности сервера операции в итоге завершаются ошибкой, а не висят вечно. Для критичных данных на запись soft рискован (возможна потеря записи), и там лучше hard с таймаутами и мониторингом. Альтернатива, autofs, удобна, когда нужно монтировать много каталогов по шаблону:
sudo apt install -y autofs
echo '/net/itpages /etc/auto.itpages --timeout=300' | sudo tee -a /etc/auto.master
echo 'share -fstype=nfs4,rw,soft,timeo=50 192.168.10.20:/srv/share' | sudo tee /etc/auto.itpages
sudo systemctl restart autofs
ls /net/itpages/share
Шаг 7. Скрипт проверки NFS, написанный по всем правилам
Закрепим материал небольшой проверкой, которая не зависнет на мёртвом хранилище. Секрет в том, что любое обращение к зависшему NFS блокируется, поэтому оборачиваем его в timeout:
#!/usr/bin/env bash
set -Eeuo pipefail
IFS=$'\n\t'
readonly MOUNT=${1:-/mnt/share}
readonly SERVER=192.168.10.20
log() { logger -t nfs-check -- "$*"; echo "$*"; }
if ! timeout 5 ping -c1 -W2 "$SERVER" >/dev/null 2>&1; then
log "ОШИБКА: сервер $SERVER не отвечает"
exit 2
fi
if ! timeout 10 stat -t "$MOUNT" >/dev/null 2>&1; then
log "ОШИБКА: $MOUNT не отвечает за 10 секунд"
exit 3
fi
probe="$MOUNT/.probe-$(hostname -s)"
if timeout 10 sh -c "date > '$probe' && rm -f '$probe'"; then
log 'OK: запись и чтение работают'
else
log "ОШИБКА: запись в $MOUNT не проходит"
exit 4
fi
Проверь код через shellcheck nfs-check.sh, сделай исполняемым и поставь в systemd timer или cron: */5 * * * * root /usr/local/bin/nfs-check.sh. Ненулевой код возврата легко подхватит система мониторинга.
Шаг 8. Параметры, функции и разбор аргументов
Как только скрипту нужно больше одного аргумента, переходи на getopts вместо самодельного разбора. Он стандартен, понятен и выдаёт нормальные сообщения об ошибках:
usage() { echo "Использование: ${0##*/} [-n] [-d каталог] [-v]" >&2; exit 64; }
dry_run=0; dest=/backup; verbose=0
while getopts ':nd:vh' opt; do
case $opt in
n) dry_run=1 ;;
d) dest=$OPTARG ;;
v) verbose=1 ;;
h|*) usage ;;
esac
done
shift $((OPTIND - 1))
(( verbose )) && set -x
Режим «сухого прогона» (-n) особенно ценен для скриптов, которые удаляют или переносят данные: сначала печатаем, что будем делать, и лишь потом делаем по-настоящему. Коды выхода тоже стоит продумать: 0 успех, 1 общая ошибка, 2 неверное использование, а свои коды 3 и выше закрепи за конкретными проблемами, и мониторинг сможет отличать их друг от друга.
Шаг 9. Работа с файлами и данными без сюрпризов
Несколько привычек, которые отличают надёжный скрипт от хрупкого. Для обхода файлов используй цикл с разделителем NUL, а не вывод ls, иначе пробелы и переводы строк в именах всё сломают:
find /var/log/app -type f -name '*.log' -mtime +7 -print0 |
while IFS= read -r -d '' f; do
gzip -9 -- "$f"
done
mapfile -t hosts < <(grep -v '^#' hosts.txt)
for h in "${hosts[@]}"; do
ssh -o ConnectTimeout=5 -o BatchMode=yes "$h" uptime || echo "$h недоступен" >&2
done
tmp=$(mktemp) && echo data > "$tmp" && mv -- "$tmp" /etc/app/config.conf
Последняя строка показывает приём атомарной записи: сначала пишем во временный файл, потом переименовываем, и читатель никогда не увидит наполовину записанный конфиг. Двойной дефис перед именами защищает от файлов, начинающихся с минуса. Не парси вывод ls и не используй backticks, отдавая предпочтение $(...) и массивам. Если логика разрастается больше пары сотен строк или требует сложных структур данных, это сигнал переписать задачу на Python, а bash оставить для склейки команд.
Шаг 10. Тестирование и сопровождение скриптов
Скрипты тоже можно тестировать. Библиотека bats позволяет писать проверки в духе юнит-тестов и запускать их в CI вместе с ShellCheck:
sudo apt install -y bats
cat > test_backup.bats <<'EOF'
@test "без аргумента скрипт завершается ошибкой" {
run ./backup-etc.sh
[ "$status" -ne 0 ]
}
@test "несуществующий каталог даёт понятное сообщение" {
run ./backup-etc.sh /no/such/dir
[[ "$output" == *"нет каталога"* ]]
}
EOF
bats test_backup.bats
Храни скрипты в git, добавляй в начало короткий комментарий с назначением и примером запуска и версионируй изменения. А запуск по расписанию переводи с cron на systemd timer: он пишет журнал в journald, умеет перезапуск после сбоя и зависимости от монтирования. Сделай два файла, /etc/systemd/system/nfs-check.service с ExecStart=/usr/local/bin/nfs-check.sh и nfs-check.timer с OnCalendar=*:0/5 и Persistent=true, затем sudo systemctl enable --now nfs-check.timer. Проверить расписание поможет systemctl list-timers.
Частые ошибки и как их избежать
- Переменные без кавычек. Пробелы в именах файлов, пустые значения и символы подстановки ломают скрипт самым неожиданным образом. Всегда пиши
"$var", массивы раскрывай как"${arr[@]}", и пусть ShellCheck ловит пропуски до запуска. - Слепая вера в set -e. Опция не срабатывает внутри условий, в левой части
&&и||, в подстановках внутри local. Поэтому критичные места проверяй явно:cmd || die 'причина', а присваивание и объявление local разделяй на две строки. - Жёсткое монтирование NFS без таймаутов. Запись в fstab без noauto и автомонтирования может подвесить загрузку, а hard-монтирование без timeout повесит каждый скрипт, который тронет каталог. Используй x-systemd.automount, разумные timeo и retrans, а в скриптах оборачивай обращения в timeout.
Что дальше
Администрирование Linux — это набор связанных навыков, вот соседние темы серии: