Погодин Евгений — ИТ Заметки
🐧Linux Обновлено 04.11.2026

Linux для админа Windows 12. Bash-скрипты: от первой строки до надёжного скрипта

Note

Материал сверен с официальной документацией (Ubuntu Server, man-страницы и документация проектов; ссылки — в конце статьи). Примеры вывода — иллюстрации: на вашем сервере значения и формат могут отличаться. Перед работой на боевом сервере проверьте команды на тестовой машине.

Повторяющуюся работу администратор Windows пишет на PowerShell. В Linux для этого есть скрипт: текстовый файл с командами, который оболочка bash выполняет по порядку. Любую команду из статьи про командную строку можно вставить в скрипт. Сложность в другом: скрипт запускают без вас, по расписанию и от имени root, поэтому его ошибки дорого стоят. Пустая переменная в команде rm, недописанная копия или два запуска одновременно легко ломают сервер.

В статье вы научитесь:

  • создавать скрипт и запускать его;
  • работать с переменными, аргументами, условиями, циклами и функциями;
  • включать защитные режимы set -euo pipefail и знать, где они не срабатывают;
  • убирать за собой (trap, mktemp), не запускать скрипт дважды (flock) и писать в журнал (logger);
  • проверять скрипты (bash -n, bash -x, shellcheck).

Закрепим двумя скриптами: проверка свободного места и копия каталога с ротацией.

Задача PowerShell bash
Переменная $name = 'x' name=x (без пробелов вокруг =)
Значение переменной $name "$name" (в кавычках)
Результат команды в переменную $d = Get-Date d=$(date)
Аргументы скрипта $args, param() $1, "$@", $#
Код возврата $LASTEXITCODE $?
Условие if ($a -eq 5) { } if [[ $a -eq 5 ]]; then … fi
Сравнение строк -eq без учёта регистра == с учётом регистра
Остановка при ошибке $ErrorActionPreference = 'Stop' set -e (по смыслу похоже, но не тождественно)
Запрет неизвестных переменных Set-StrictMode set -u
Очистка при выходе try … finally trap … EXIT
Между командами объекты текст

Warning

Скрипты ниже создают и удаляют файлы. Отрабатывайте их на тестовой машине в своём домашнем каталоге (~/lab), а на боевом сервере запускайте только после проверки на копии данных.

Что нужно знать заранее

Примеры рассчитаны на Ubuntu 24.04 LTS (bash 5.2.21); в 26.04 LTS пакет новее: проверьте apt policy bash и документацию выпуска.

Первый скрипт

Создайте файл hello.sh:

#!/bin/bash
name=${1:-мир}
echo "Привет, $name! Сегодня $(date +%F)."

Первая строка, шебанг (shebang), сообщает системе, чем выполнять файл. Допустимы два варианта. #!/bin/bash указывает путь прямо. #!/usr/bin/env bash ищет bash в PATH: так принято писать переносимые скрипты. В системных скриптах на Ubuntu обычно ставят #!/bin/bash: при запуске из cron и systemd окружение беднее, чем в вашем терминале, и результат не должен зависеть от PATH. Мы используем этот вариант.

Скрипт запускают двумя способами:

bash hello.sh
chmod +x hello.sh
./hello.sh Анна

Для ./hello.sh нужен бит выполнения, а имя пишут с путём, потому что текущего каталога в PATH нет. Готовые скрипты администратора принято класть в /usr/local/bin (по FHS, стандарту размещения файлов, это каталог для локально установленных программ), а нужные только root — в /usr/local/sbin. Скрипт, который запускает root, должен принадлежать root и не быть доступным для записи другим: иначе любой, кто его изменит, получит права root при следующем запуске.

sudo cp hello.sh /usr/local/bin/hello
sudo chown root:root /usr/local/bin/hello
sudo chmod 755 /usr/local/bin/hello

Переменные, кавычки, подстановки

Переменную задают как name=значение (без пробелов вокруг =; имя состоит из латинских букв, цифр и _), читают как $name. Главное правило: всегда пишите "$name" в кавычках. Без кавычек bash разбивает результат подстановки на слова по символам из переменной IFS (Internal Field Separator; по умолчанию это пробел, табуляция и перевод строки) и раскрывает в нём *, ? и [. Имя файла «my file.txt» превратится в два аргумента:

f="my file.txt"
ls -l $f
ls -l "$f"

Первая команда ищет файлы my и file.txt, вторая работает с одним файлом. В одинарных кавычках всё остаётся буквальным, в двойных подставляются $name, $(команда) и обратные кавычки.

Полезные формы подстановки:

Запись Значение
$(команда) вывод команды без завершающих переводов строки
${name:-запас} значение или «запас», если переменная пуста или не задана
${name:?сообщение} если переменная пуста или не задана, вывести сообщение и завершить скрипт
${name%.txt} значение без суффикса .txt
$((a + b)) арифметика

Форма ${name:?} выручит там, где пустая переменная опасна (ниже она защищает rm).

Аргументы, ввод и коды возврата

Аргументы скрипта лежат в $1, $2 и так далее, их число в $#, имя скрипта в $0, все аргументы в "$@". Именно "$@" в кавычках передаёт каждый аргумент отдельным словом, даже с пробелами. Ввод пользователя читает read; опция -r отключает обработку обратного слэша:

read -r -p 'Продолжить? [y/N] ' answer
[[ $answer == [yY] ]] || exit 1

Если включён set -u (о нём ниже), необязательный аргумент нужно читать как ${1:-} или с запасным значением, как ${1:-мир} в hello.sh: иначе его отсутствие станет ошибкой.

Каждая команда завершается с кодом возврата: 0 значит успех, остальное ошибку; последний код лежит в $?. Код 127 значит «команда не найдена», 126 «не исполняется». Коды удобно комбинировать: a && b запускает b только после успеха a, a || b после неудачи. Свой код скрипт задаёт командой exit N; соглашение такое: 0 — всё хорошо, 1 — ошибка выполнения, 2 — неверные аргументы.

Условия

Для проверок есть два синтаксиса: старая команда [ … ] (она же test) и конструкция bash [[ … ]]. Используйте [[ ]]: внутри неё нет разбиения на слова и раскрытия шаблонов, поэтому пустая переменная и пробелы не ломают проверку, а ещё доступны &&, || и сравнение с шаблоном.

if [[ -d $dir && -w $dir ]]; then
  echo "каталог доступен для записи"
elif [[ -e $dir ]]; then
  echo "это не каталог или нет прав"
fi
Проверка Смысл
-e путь, -f, -d существует; обычный файл; каталог
-r, -w, -x можно читать, писать, выполнять
-s путь файл не пуст
-z строка, -n строка строка пуста; не пуста
a == b, a != b строки равны; не равны (с учётом регистра)
a -eq b, -ne, -lt, -le, -gt, -ge сравнение чисел
строка =~ regex совпадение с регулярным выражением

Справа от == внутри [[ ]] стоит шаблон: [[ $f == *.log ]] проверяет окончание. Чтобы сравнить буквально, берите правую часть в кавычки. В PowerShell -eq для строк не различает регистр, в bash == различает: [[ $s == abc ]] для ABC вернёт ложь.

Циклы, case, функции, массивы

for f in "$dir"/*.log; do                 # по файлам
  [[ -e $f ]] || continue                 # нет файлов: шаблон не раскрылся
  echo "$f"
done

while IFS= read -r line; do               # по строкам файла
  echo "строка: $line"
done < /etc/hostname

case $answer in
  [yY]|[yY]es) echo "да" ;;
  *)           echo "нет" ;;
esac

log() {                                   # функция
  local msg=$1                            # local: видна в функции и вызванных
  printf '%s %s\n' "$(date '+%F %T')" "$msg"
}

files=(a.txt "b c.txt")                   # массив
echo "элементов: ${#files[@]}"
for f in "${files[@]}"; do echo "$f"; done

IFS= перед read сохраняет пробелы по краям строки. "${files[@]}" в кавычках раскрывается в отдельные слова без разбиения, это способ хранить список имён с пробелами. Функция возвращает код (return N), а не значение: результат передают через вывод и $(…).

Текст вместо объектов

Чем текст bash отличается от объектов PowerShell, разобрано в статье про командную строку. Для скриптов отсюда три привычки. Не разбирайте вывод ls: берите шаблоны ("$dir"/*.log), find или команды с выбором полей (в check-disk.sh ниже это df --output=pcent,target). Помните, что ошибка здесь не объект, а число, код возврата. Проверяйте это число сами или поручайте режиму set -e.

Надёжный скрипт

set -euo pipefail

Сразу после шебанга пишут три защитных режима. Каждый делает одну вещь:

  • -e (errexit): завершить скрипт, если команда вернула ненулевой код;
  • -u (nounset): неизвестная переменная считается ошибкой, а не пустой строкой;
  • -o pipefail: код конвейера равен коду самой правой из упавших команд, а не просто последней команды (по умолчанию для false | true получится 0).

Режимы полезны, но не волшебны. Особенно -e: у него много исключений, и о них нужно знать.

  • Условия. -e не срабатывает, если команда стоит в проверке if или elif, в условии while или until, в списке && и || (кроме последней команды) или инвертирована !.
  • Функции в условии. Если функция вызвана там, где -e игнорируется (например, if check; then), он не действует во всём её теле: после упавшей команды функция продолжит работу.
  • Подстановки. По умолчанию $(команда) не наследует -e: команда внутри продолжит после ошибки. Это меняет shopt -s inherit_errexit.
  • local и export. В local out=$(cmd) код возврата cmd теряется: результат берётся у local. Объявляйте и присваивайте раздельно.
  • Счётчик. ((count++)) при count=0 вычисляет 0, и команда ((…)) возвращает код 1, скрипт с -e завершится. Пишите count=$((count + 1)).
  • Подоболочки. Подоболочка (subshell) — дочерний экземпляр bash: в ней выполняются $(…) и команды конвейера. Режим -e применяется к скрипту и к каждой подоболочке отдельно, и подоболочка может завершиться раньше, чем дойдёт до последней команды.
  • Конвейеры и grep. Без pipefail ошибка не последней команды конвейера остаётся незамеченной. grep без совпадений возвращает 1: под -e одиночный grep завершит скрипт, а под pipefail и конвейер вроде grep … | wc -l. Нужно: grep -q шаблон файл || true или проверка в if.
f() { local out; out=$(date); echo "$out"; }   # а не: local out=$(date)
count=$((count + 1))                           # а не: ((count++))
[[ -r $log ]] || exit 1                        # нет файла: grep вернул бы 2
grep -q ошибка "$log" || echo "ошибок нет"     # код 1 - это не сбой

trap, mktemp, flock, logger

trap 'команды' EXIT выполняет команды при выходе из скрипта, в том числе после ошибки при -e (но не при kill -9). Это место для очистки. Временные файлы создают mktemp: он выбирает уникальное имя и выдаёт права только владельцу; предсказуемые имена вроде /tmp/work небезопасны. Опция -d создаёт каталог, -p КАТАЛОГ выбирает место.

tmp=$(mktemp)
trap 'rm -f -- "$tmp"' EXIT

flock не даёт запустить скрипт дважды. Блокировку держит открытый файл, пока скрипт работает. Номер, под которым процесс держит открытый файл, называется файловым дескриптором:

exec 9>/var/lock/myjob.lock      # открыли файл под номером 9 (от root)
flock -n 9 || { echo "уже запущен" >&2; exit 1; }

Опция -n не ждёт, а сразу завершается неудачей, если блокировка занята. Держите файл блокировки на локальном диске: на NFS и CIFS flock может работать ограниченно. logger пишет в системный журнал; -t задаёт метку, -p приоритет (user.warning), по умолчанию user.notice. Прочитать запись можно по метке: sudo journalctl -t метка.

Для разбора опций (-t 90) есть встроенная команда getopts: строка ':t:' объявляет опцию -t с аргументом, значение попадает в OPTARG, а двоеточие в начале включает тихий режим, где ошибки разбора вы обрабатываете сами.

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

Скрипт check-disk.sh печатает файловые системы, заполненные не меньше порога, пишет об этом в журнал и завершается кодом 1, если порог превышен (код 2 — неверные аргументы, 3 — сбой df). df --output=pcent,target печатает только процент и точку монтирования, а -x исключает типы файловых систем (образы squashfs только для чтения всегда заполнены на 100 %).

#!/bin/bash
# check-disk.sh - сообщает о файловых системах выше порога заполнения.
set -euo pipefail

usage() {
  echo "Использование: ${0##*/} [-t ПОРОГ_В_ПРОЦЕНТАХ]" >&2
  exit 2
}

threshold=85
while getopts ':t:' opt; do
  case $opt in
    t) threshold=$OPTARG ;;
    *) usage ;;
  esac
done
shift $((OPTIND - 1))
(( $# == 0 )) || usage                     # лишние аргументы - ошибка

if ! [[ $threshold =~ ^[1-9][0-9]*$ ]] || (( threshold > 100 )); then
  echo "Порог должен быть целым числом от 1 до 100" >&2
  exit 2
fi

# Сбой df - отдельный код 3: мониторинг отличит его от превышения порога.
out=$(df --output=pcent,target -x tmpfs -x devtmpfs -x squashfs) || {
  echo "df завершился с ошибкой" >&2
  exit 3
}

status=0
while read -r pcent target; do
  used=${pcent%\%}                         # убрали знак % в конце
  [[ $used =~ ^[0-9]+$ ]] || continue      # пропуск заголовка и строк без числа
  if (( used >= threshold )); then
    msg="Заполнено ${used}%: ${target} (порог ${threshold}%)"
    echo "$msg"
    logger -t check-disk -p user.warning -- "$msg"
    status=1
  fi
done <<< "$out"

exit "$status"

Вывод df сохраняется в переменную, а сбой df обрабатывается явно и получает собственный код, который мониторинг отличит от превышения порога. При чтении через < <(df …) код df терялся бы, и скрипт молча сообщал бы «всё в порядке». Цикл получает текст через <<< "$out" (here-string, строка в роли ввода): так while работает в текущей оболочке, а не в подоболочке, и значение status сохраняется. Строка заголовка Use% Mounted on отсеивается проверкой на число. После цикла getopts команда shift убирает разобранные опции, а лишние аргументы (например, ./check-disk.sh 90 без -t) считаются ошибкой, а не игнорируются молча. Запустите с низким порогом, чтобы увидеть срабатывание, затем проверьте код возврата и журнал:

chmod +x check-disk.sh
./check-disk.sh -t 10; echo "код: $?"
sudo journalctl -t check-disk --since today

Пример вывода:

Заполнено 16%: / (порог 10%)
код: 1

Письмо с сервера отправляется только при настроенной почтовой службе (отдельная тема); без неё читайте журнал или подключите внешнюю систему мониторинга (Zabbix с нуля). Для запуска от root скрипт кладут в /usr/local/sbin (владелец root, права 755), а расписание (раз в час) задают cron или таймером systemd, как в статье 06.

Практика 2: копия каталога с ротацией

Скрипт backup-dir.sh собирает архив каталога под именем ИМЯ-дата.tar.gz и хранит последние N копий. Три правила делают его безопасным: архив сначала пишется во временный файл и переименовывается только после успеха; старые копии удаляются после создания новой; удаляются только файлы, подходящие под точный шаблон имени.

#!/bin/bash
# backup-dir.sh - архив каталога с ротацией старых копий.
# Использование: backup-dir.sh ИМЯ ИСТОЧНИК КАТАЛОГ_КОПИЙ [СКОЛЬКО_ХРАНИТЬ]
set -euo pipefail
umask 077                                  # архивы читает только владелец

name=${1:?Не указано имя копии}
src=${2:?Не указан каталог-источник}
dest=${3:?Не указан каталог для копий}
keep=${4:-7}

[[ $name =~ ^[A-Za-z0-9._-]+$ ]] || { echo "Недопустимое имя" >&2; exit 2; }
[[ $keep =~ ^[1-9][0-9]*$ ]]     || { echo "Нужно число копий" >&2; exit 2; }
[[ -d $src ]]                    || { echo "Нет каталога: $src" >&2; exit 1; }
mkdir -p -- "$dest"

# Одна копия за раз: блокировка живёт, пока открыт дескриптор 9.
exec 9>"$dest/.$name.lock"
flock -n 9 || { echo "Копия $name уже выполняется" >&2; exit 1; }

archive="$dest/$name-$(date +%F_%H%M%S).tar.gz"
tmp=$(mktemp -p "$dest" ".$name.XXXXXX")
trap 'rm -f -- "$tmp"' EXIT                # при выходе убираем временный файл

tar -czf "$tmp" -C "$src" .
mv -- "$tmp" "$archive"
logger -t backup-dir -- "создан $archive"
echo "Создан $archive"

# Ротация: только после успешной копии, по шаблону имени.
shopt -s nullglob
files=( "$dest/$name"-????-??-??_??????.tar.gz )
extra=$(( ${#files[@]} - keep ))
if (( extra > 0 )); then
  for old in "${files[@]:0:extra}"; do
    rm -- "$old"
    logger -t backup-dir -- "удалена старая копия $old"
  done
fi

Что здесь происходит:

  • ${1:?…} останавливает скрипт, если аргумент не передан; имя копии проверяется регулярным выражением, потому что оно попадает в шаблон для rm.
  • exec 9>файл и flock -n 9 не дают двум запускам работать одновременно.
  • Дата в имени записана как ГГГГ-ММ-ДД_ЧЧММСС, поэтому алфавитный порядок совпадает с хронологическим. shopt -s nullglob заставляет шаблон без совпадений раскрываться в пустой список (а не оставаться строкой), а ${files[@]:0:extra} берёт первые extra элементов, то есть самые старые.
  • rm стоит без -r и -f и получает конкретные файлы после --. Каталог копий не должен лежать внутри источника, иначе архив попадёт сам в себя.

Подготовьте тестовые каталоги и запустите копирование:

chmod +x backup-dir.sh
mkdir -p ~/lab/src ~/lab/backups ~/lab/restore
echo test > ~/lab/src/a.txt
./backup-dir.sh docs ~/lab/src ~/lab/backups 3
ls -l ~/lab/backups

Повторите команду ./backup-dir.sh … несколько раз с паузой в секунду (имя архива содержит секунды) и снова посмотрите ls: останется не больше трёх копий.

Главная проверка копии — восстановление. Найдите архив в ls, замените имя в команде и распакуйте его в пустой каталог:

tar -tzf ~/lab/backups/docs-2026-11-04_131500.tar.gz
tar -xzf ~/lab/backups/docs-2026-11-04_131500.tar.gz -C ~/lab/restore
ls -l ~/lab/restore

Убрать учебные файлы: cd ~, затем ls -R lab и rm -r lab.

Для сравнения вот опасная запись, которую встречают в чужих скриптах:

rm -rf $dir/*          # антипример: при пустой $dir станет rm -rf /*
rm -rf -- "${dir:?}"/* # защита: пустая $dir остановит скрипт

Защита ${dir:?} описана в Bash Manual, а shellcheck предупреждает о подобных записях (в том числе кодом SC2115). Но надёжнее вообще не удалять «всё под путём»: в примере выше удаляются только файлы по точному шаблону.

Отладка и проверка

  • bash -n скрипт читает файл и проверяет синтаксис, ничего не выполняя.
  • bash -x скрипт печатает каждую команду после подстановок: видно, что реально было запущено. Чтобы трассировать только подозрительный участок, окружите его командами set -x и set +x. В трассировку попадут и значения переменных, включая пароли: не показывайте и не сохраняйте такой вывод.
  • shellcheck скрипт ищет типичные ошибки (кавычки, cd без проверки, потерянные коды возврата) и выдаёт коды SCxxxx, каждый описан в вики проекта. Ставится командой sudo apt install shellcheck. Прогоните через него оба скрипта: замечания полезно читать, а не просто подавлять.

Безопасность

  • Не используйте eval с чужими данными. eval выполняет строку как команду, и значение от пользователя или из файла превращается в произвольную команду. Составные команды собирайте в массив и запускайте как "${cmd[@]}", варианты выбирайте через case.
  • Не храните пароли в скрипте и в аргументах. Аргументы видны в ps и истории. Секрет кладут в отдельный файл с правами 600, принадлежащий пользователю скрипта, а скрипт читает его при запуске.
  • Ограничивайте права на создаваемое. umask 077 в начале скрипта делает новые файлы доступными только владельцу.
  • Не подключайте через source файлы, которые может править кто-то ещё: source выполняет их содержимое как код.
  • Имена файлов — это данные. Используйте кавычки, -- перед именами и ./ перед относительными путями: файл -rf не должен стать опцией.

Идемпотентность

Скрипт идемпотентен, если повторный запуск даёт тот же результат и ничего не портит. Это свойство надёжнее любых напоминаний «не запускать дважды». Примеры: mkdir -p вместо mkdir; добавление строки в файл только при отсутствии: grep -qxF 'строка' файл || echo 'строка' >> файл; а в backup-dir.sh ротация оставляет заданное число копий, сколько бы раз вы ни запускали скрипт.

Типичные ошибки и недоразумения

  • Переменная без кавычек. rm $file ломается на пробелах и *. Всегда "$file".
  • Пробелы вокруг =. name = x запускает команду name. Пишите name=x.
  • [ $x == y ] с пустой $x. Старая [ ] получает неверное число аргументов. Используйте [[ ]].
  • set -e как страховка от всего. Он молчит в условиях, функциях в if и при local x=$(…), а ((i++)) при нуле, наоборот, неожиданно завершает скрипт. Проверяйте важные команды явно.
  • cd без проверки. Если каталога нет, дальнейшие команды работают в чужом месте: пишите cd "$dir" || exit 1.
  • *`rm -rf $var/.** Пустая переменная превращает команду в удаление корня. Используйте${var:?}` или точные шаблоны.
  • Конвейер с while read. Изменения переменных в нём теряются (цикл идёт в подоболочке): читайте через <<< "$out", как в check-disk.sh, или через < <(команда) (но код этой команды -e не увидит).
  • Запуск по расписанию, а в терминале работало. Нет вашего PATH и профиля: пишите полные пути и перенаправляйте вывод (статья 06).
  • Копия не проверена восстановлением. Пока вы не распаковали архив в пустой каталог, это не резервная копия.

Проверьте себя

  1. Почему переменную нужно писать как "$file", а не $file? Без кавычек bash разбивает значение по пробелам и раскрывает `,?,[`: имя с пробелом превращается в несколько аргументов, а шаблон в список файлов. В кавычках значение остаётся одним словом.*
  2. Что делает каждая часть set -euo pipefail? -e завершает скрипт при ошибке команды, -u считает неопределённую переменную ошибкой, pipefail делает код конвейера равным коду последней упавшей команды. -e не срабатывает в условиях if, while, списках &&/|| и функциях, вызванных в таких местах.
  3. Скрипт с set -e завершился на строке ((count++)). Почему и как исправить? При count=0 выражение равно нулю, и ((…)) возвращает код 1. Пишите count=$((count + 1)).
  4. Как защитить rm -rf "$dir"/* от пустой переменной? Записать "${dir:?}": если переменная пуста или не задана, bash остановит скрипт с сообщением. Ещё лучше удалять только файлы по точному шаблону.
  5. Зачем в backup-dir.sh архив пишется во временный файл, а ротация идёт после копирования? Если копирование прервётся, неполный файл не станет «копией» (trap его удалит), а старые копии не пострадают: сначала создаётся новая, потом удаляются лишние.

Что дальше

Источники

Ещё по теме «Linux»