Лаборатория кодера

Справочник · Терминал и система

Bash: скрипты без сюрпризов

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

Обновлено · 7 мин чтения

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

Заготовка скрипта

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

main() {
  local target="${1:?укажите папку}"
  echo "Работаю с $target"
}

main "$@"
НастройкаЧто даёт
set -eскрипт остановится на первой команде с ненулевым кодом выхода
set -uобращение к неопределённой переменной считается ошибкой
set -o pipefailконвейер падает, если упала любая его часть, а не только последняя
set -xпечатать каждую команду перед выполнением, удобно для отладки

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

Переменные и кавычки

bash
name="world"            # без пробелов вокруг =
echo "Hello, $name"     # двойные кавычки: переменные раскрываются
echo 'Hello, $name'     # одинарные: текст как есть
files=$(ls)             # результат команды
today=$(date +%F)       # 2026-10-14
readonly VERSION=1.2    # константа
export PATH="$HOME/bin:$PATH"   # переменная окружения для дочерних процессов

Главное правило: всегда берите переменные в двойные кавычки. Без кавычек bash разобьёт значение по пробелам и раскроет звёздочки:

bash
file="My Report.txt"
rm $file      # пытается удалить "My" и "Report.txt"
rm "$file"    # удаляет один нужный файл

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

ЗаписьРезультат
${var:-def}значение var или def, если пусто
${var:=def}то же, и заодно присвоить def
${var:?текст}ошибка с текстом, если пусто
${#var}длина строки
${var#prefix}убрать самый короткий префикс по шаблону
${var##*/}убрать всё до последнего /, то есть имя файла
${var%.txt}убрать суффикс .txt
${var%/*}убрать последний компонент пути, то есть каталог
${var/a/b}заменить первое a на b; если удвоить первую косую черту, заменятся все
${var^^} / ${var,,}в верхний / нижний регистр
${var:0:5}подстрока с 0-го символа длиной 5
bash
path="/var/log/nginx/access.log"
echo "${path##*/}"    # access.log
echo "${path%/*}"     # /var/log/nginx
f="photo.jpeg"
echo "${f%.*}.jpg"    # photo.jpg

Условия

bash
if [[ -f "$config" ]]; then
  echo "файл есть"
elif [[ -d "$config" ]]; then
  echo "это каталог"
else
  echo "нет такого"
fi
ПроверкаИстина, если
-f pathобычный файл существует
-d pathкаталог существует
-e pathсуществует что угодно
-x pathфайл исполняемый
-s pathфайл не пустой
-z "$s"строка пустая
-n "$s"строка не пустая
"$a" == "$b"строки равны
"$s" == *.logстрока подходит под шаблон (внутри [[ ]])
"$s" =~ ^[0-9]+$совпадение с регулярным выражением
$a -eq $bчисла равны; также -ne, -lt, -le, -gt, -ge

Для арифметики удобнее двойные круглые скобки: if (( count > 10 )); then ..., а (( i++ )) увеличивает счётчик.

Ветвление по значению делается через case:

bash
case "$1" in
  start|run) start_app ;;
  stop)      stop_app ;;
  *.tar.gz)  unpack "$1" ;;
  *)         echo "Использование: $0 start|stop" >&2; exit 2 ;;
esac

Циклы

bash
for f in *.log; do
  [[ -e "$f" ]] || continue     # если файлов нет, шаблон останется строкой
  gzip "$f"
done

for i in {1..5}; do echo "$i"; done
for ((i = 0; i < 10; i += 2)); do echo "$i"; done

# построчное чтение файла, безопасное для пробелов
while IFS= read -r line; do
  echo "> $line"
done < input.txt

# найденные файлы с любыми именами, включая переводы строк
find . -name '*.md' -print0 | while IFS= read -r -d '' f; do
  wc -w "$f"
done

Массивы

bash
servers=(web1 web2 "db main")
echo "${servers[0]}"          # web1
echo "${#servers[@]}"         # 3 элемента
for s in "${servers[@]}"; do echo "$s"; done
servers+=(cache)              # добавить элемент

declare -A port=([web]=80 [db]=5432)   # ассоциативный массив
echo "${port[db]}"
for k in "${!port[@]}"; do echo "$k=${port[$k]}"; done

"${arr[@]}" в кавычках раскрывается в отдельные элементы и сохраняет пробелы внутри них. "$@" работает так же для аргументов скрипта.

Функции, коды возврата, ошибки

bash
log() { printf '%s %s\n' "$(date +%T)" "$*" >&2; }

backup() {
  local src="$1" dst="$2"
  [[ -d "$src" ]] || { log "нет каталога $src"; return 1; }
  tar -czf "$dst" -C "$src" .
}

if ! backup /srv/data "/backup/data-$(date +%F).tar.gz"; then
  log "резервная копия не создана"
  exit 1
fi
  • local делает переменную видимой только внутри функции. Без него она глобальная.
  • return задаёт код возврата функции, exit завершает весь скрипт.
  • $? хранит код последней команды; 0 значит успех.
  • >&2 отправляет сообщение в поток ошибок, чтобы оно не смешивалось с полезным выводом.

Уборка за собой

bash
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT    # выполнится при любом завершении скрипта

Перенаправления

ЗаписьСмысл
cmd > fileвывод в файл, файл перезаписывается
cmd >> fileдописать в конец
cmd 2> err.logошибки в файл
cmd > all.log 2>&1и вывод, и ошибки в один файл
cmd &> all.logто же короче (только bash)
cmd > /dev/nullвыбросить вывод
cmd < fileвзять ввод из файла
cmd <<< "$s"передать строку на вход
diff <(sort a) <(sort b)подставить вывод команды как файл

Многострочный текст удобно передавать через heredoc. С кавычками вокруг метки переменные не раскрываются:

bash
cat > config.ini <<EOF
user=$USER
home=$HOME
EOF

cat <<'EOF'
Здесь $HOME останется как есть
EOF

Аргументы скрипта

ПеременнаяЗначение
$0имя скрипта
$1, $2 …позиционные аргументы
$#число аргументов
"$@"все аргументы по отдельности
$$PID текущей оболочки
$!PID последнего фонового процесса

Разбор коротких ключей встроенной командой getopts:

bash
verbose=0 out="result.txt"
while getopts "vo:h" opt; do
  case "$opt" in
    v) verbose=1 ;;
    o) out="$OPTARG" ;;
    h) echo "Использование: $0 [-v] [-o файл] вход"; exit 0 ;;
    *) exit 2 ;;
  esac
done
shift $((OPTIND - 1))
input="${1:?нужен входной файл}"

Если скрипт запускается по расписанию, помните, что у cron почти пустое окружение. Об этом в статье про cron.