Справочник · Терминал и система
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: она находит забытые кавычки, ошибки в условиях и непереносимые конструкции.
Переменные и кавычки
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 разобьёт значение по пробелам и раскроет звёздочки:
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 |
path="/var/log/nginx/access.log"
echo "${path##*/}" # access.log
echo "${path%/*}" # /var/log/nginx
f="photo.jpeg"
echo "${f%.*}.jpg" # photo.jpgУсловия
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:
case "$1" in
start|run) start_app ;;
stop) stop_app ;;
*.tar.gz) unpack "$1" ;;
*) echo "Использование: $0 start|stop" >&2; exit 2 ;;
esacЦиклы
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Массивы
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[@]}" в кавычках раскрывается в отдельные элементы и сохраняет пробелы внутри них. "$@" работает так же для аргументов скрипта.
Функции, коды возврата, ошибки
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
filocalделает переменную видимой только внутри функции. Без него она глобальная.returnзадаёт код возврата функции,exitзавершает весь скрипт.$?хранит код последней команды; 0 значит успех.>&2отправляет сообщение в поток ошибок, чтобы оно не смешивалось с полезным выводом.
Уборка за собой
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. С кавычками вокруг метки переменные не раскрываются:
cat > config.ini <<EOF
user=$USER
home=$HOME
EOF
cat <<'EOF'
Здесь $HOME останется как есть
EOFАргументы скрипта
| Переменная | Значение |
|---|---|
$0 | имя скрипта |
$1, $2 … | позиционные аргументы |
$# | число аргументов |
"$@" | все аргументы по отдельности |
$$ | PID текущей оболочки |
$! | PID последнего фонового процесса |
Разбор коротких ключей встроенной командой getopts:
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.