Проверяемая цепочка команд shell-скрипта рядом с терминалом и сервером
Linux

Как писать надёжные shell-скрипты на Bash

Разбираем структуру Bash-скрипта, аргументы, кавычки, коды возврата, временные файлы, повторный запуск и проверку через bash -n и ShellCheck.

Содержание

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

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

Сначала зафиксируйте оболочку и назначение

Первая строка сообщает системе, какой интерпретатор должен читать файл. Если используется синтаксис Bash — массивы, [[ ... ]] или специальные подстановки, — не объявляйте скрипт как обычный /bin/sh:

#!/usr/bin/env bash

set -Eeuo pipefail

-u считает обращение к неопределённой переменной ошибкой. pipefail возвращает ошибку, если упала любая команда конвейера, а не только последняя. -e завершает скрипт при многих необработанных ошибках, но у него есть исключения в условиях и списках команд. Поэтому ожидаемые сбои всё равно нужно проверять через if или ||.

Параметр -E наследует обработчик ERR в функциях. Если обработчик не используется, этот параметр можно убрать. Набор set -Eeuo pipefail полезен, когда автор понимает его поведение; он не исправляет неверную логику автоматически.

Опишите аргументы до работы с файлами

Пусть первый аргумент задаёт исходный каталог, а второй — каталог для архивов. ${1:-} возвращает пустую строку, если аргумент не передан, и не конфликтует с режимом -u:

readonly source_dir="${1:-}"
readonly destination_dir="${2:-}"

if [[ -z "$source_dir" || -z "$destination_dir" ]]; then
  printf 'Использование: %s ИСХОДНЫЙ_КАТАЛОГ КАТАЛОГ_АРХИВОВ\n' "$0" >&2
  exit 64
fi

if [[ ! -d "$source_dir" ]]; then
  printf 'Каталог не найден: %s\n' "$source_dir" >&2
  exit 66
fi

readonly запрещает случайно изменить значения ниже по коду. Вывод в >&2 попадает в поток ошибок, а коды 64 и 66 позволяют вызывающей программе отличить неверные аргументы от отсутствующего каталога. Числа взяты из распространённых кодов sysexits, но важнее документировать выбранные значения и применять их последовательно.

Кавычки защищают границы аргумента

После подстановки переменных Bash может разделить строку по пробелам и раскрыть * как список файлов. Поэтому путь почти всегда передают в двойных кавычках:

mkdir -p -- "$destination_dir"
printf 'Источник: %s\n' "$source_dir"

Если destination_dir содержит пробел, программа mkdir всё равно получит один аргумент. Двойной дефис завершает разбор параметров: путь с начальным - не будет принят за ключ. Значение не нужно помещать внутрь команды через eval; такая конструкция повторно разбирает текст как код и особенно опасна для внешних данных.

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

Создавайте результат через временный файл

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

umask 077
timestamp="$(date -u +'%Y%m%dT%H%M%SZ')"
final_archive="$destination_dir/files-$timestamp.tar.gz"
temporary_archive="$(mktemp --tmpdir="$destination_dir" '.files.XXXXXX.tar.gz')"

cleanup() {
  rm -f -- "$temporary_archive"
}
trap cleanup EXIT

umask 077 не даёт группе и другим пользователям права на новые файлы. mktemp создаёт уникальное имя атомарно, поэтому два процесса не выберут один путь. Шесть X заменяются случайной частью имени. Обработчик EXIT сработает и после успеха, и после ошибки.

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

Проверяйте создание архива до переименования

Команда tar меняет рабочий каталог через -C и архивирует его содержимое. Затем tar -tzf читает таблицу архива; только после успешной проверки файл получает окончательное имя:

tar -C "$source_dir" -czf "$temporary_archive" .
tar -tzf "$temporary_archive" >/dev/null
mv -- "$temporary_archive" "$final_archive"
trap - EXIT

printf 'Создан архив: %s\n' "$final_archive"

Ключи -c, -z и -f означают создание, сжатие gzip и следующий аргумент как имя файла. Точка после -C обозначает всё содержимое исходного каталога без встраивания полного пути. Если создание или чтение архива завершится ошибкой, set -e остановит скрипт, а trap удалит временный файл.

После успешного mv ловушка отключается: временного пути больше нет. printf сообщает вызывающей системе точное имя готового архива.

Соберите пример целиком

Создайте файл в пользовательском каталоге, например ~/bin/archive-directory, и вставьте полный вариант. Он не требует root, если у пользователя есть права чтения источника и записи в каталог назначения:

#!/usr/bin/env bash

set -Eeuo pipefail

readonly source_dir="${1:-}"
readonly destination_dir="${2:-}"

if [[ -z "$source_dir" || -z "$destination_dir" ]]; then
  printf 'Использование: %s ИСХОДНЫЙ_КАТАЛОГ КАТАЛОГ_АРХИВОВ\n' "$0" >&2
  exit 64
fi

if [[ ! -d "$source_dir" ]]; then
  printf 'Каталог не найден: %s\n' "$source_dir" >&2
  exit 66
fi

umask 077
mkdir -p -- "$destination_dir"

timestamp="$(date -u +'%Y%m%dT%H%M%SZ')"
final_archive="$destination_dir/files-$timestamp.tar.gz"
temporary_archive="$(mktemp --tmpdir="$destination_dir" '.files.XXXXXX.tar.gz')"

cleanup() {
  rm -f -- "$temporary_archive"
}
trap cleanup EXIT

tar -C "$source_dir" -czf "$temporary_archive" .
tar -tzf "$temporary_archive" >/dev/null
mv -- "$temporary_archive" "$final_archive"
trap - EXIT

printf 'Создан архив: %s\n' "$final_archive"

Этот пример ещё не является полной системой резервного копирования: он не проверяет свободное место, не удаляет старые архивы, не шифрует данные и хранит результат на указанном диске. Его задача — показать управление аргументами, ошибками и временным состоянием.

Проверьте синтаксис без выполнения

Сначала сделайте файл исполняемым и попросите Bash разобрать синтаксис. Ключ -n читает команды, но не выполняет их:

chmod u+x "$HOME/bin/archive-directory"
bash -n "$HOME/bin/archive-directory"

При успехе bash -n ничего не печатает и возвращает код 0. Это находит незакрытые кавычки и неверную грамматику, но не обнаруживает ошибку в имени каталога или неправильный алгоритм.

Если установлен ShellCheck, запустите статический анализ. Не отключайте предупреждение до того, как поняли описанный риск:

shellcheck "$HOME/bin/archive-directory"

ShellCheck особенно полезен для пропущенных кавычек, хрупкого разбора вывода и скрытых кодов возврата. Его чистый отчёт не заменяет тест с настоящими файлами.

Испытайте неудобные входные данные

Создайте временные каталоги с пробелом в имени, положите туда файл и выполните скрипт без административных прав:

test_root="$(mktemp -d)"
mkdir -p -- "$test_root/source files" "$test_root/archives"
printf 'test\n' > "$test_root/source files/example.txt"

"$HOME/bin/archive-directory" "$test_root/source files" "$test_root/archives"
find "$test_root/archives" -maxdepth 1 -type f -print

В конце должен появиться один архив files-...tar.gz. Проверьте его содержимое через tar -tzf, а после теста удалите только значение test_root, предварительно выведя его через printf. Не подставляйте в рекурсивное удаление пустую или непроверенную переменную.

Повторный запуск и параллельная работа

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

Если две копии не должны работать одновременно, добавьте внешнюю блокировку через flock в планировщике. Это разобрано в статье о cron и systemd timers. Для важных операций записывайте начало, конец и понятную причину ошибки в стандартные потоки: systemd сохранит их в журнале.

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

Первоисточники

  • Bash Reference Manual — подстановки, кавычки, коды возврата и режимы set.
  • ShellCheck — статический анализ shell-скриптов и каталог разобранных предупреждений.
  • mktemp(1) — безопасное создание временных файлов и каталогов.
  • tar(1) — создание и проверка архивов.
Рекламное местоВаша компания здесьРазместить рекламу

Самопроверка

Проверьте, что материал усвоен

Ответьте на все вопросы. Результат сохранится только в этом браузере и будет учтён в статистике прочитанных материалов.

01Как безопасно передать переменную path программе rm?
02Для чего нужен trap при работе с mktemp?
03Что означает идемпотентный повторный запуск?

Разбираем коротко

Частые вопросы

Делает ли set -e Bash-скрипт полностью безопасным?

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

Зачем заключать переменные в двойные кавычки?

Без кавычек Bash может разделить значение по пробелам и раскрыть символы шаблона как имена файлов. Конструкция с двойными кавычками передаёт значение одним аргументом.

Чем проверить shell-скрипт перед запуском?

Команда bash -n проверяет синтаксис без выполнения, а ShellCheck находит многие ошибки кавычек, конвейеров и кодов возврата. После них нужен тест в отдельном каталоге с обычными и неудобными именами файлов.