Написание скриптов Bash
Bash (Bourne Again Shell) — командная оболочка и язык сценариев для автоматизации задач в Linux. Bash-скрипты объединяют команды, условия, циклы и функции, работают с файлами, аргументами и кодами завершения.
#!/usr/bin/env bash
name="Linux"
printf 'Hello, %s!\n' "$name"Запуск:
chmod +x script.sh
./script.sh
# или
bash script.shСинтаксис Bash-скриптов и shebang
Первая строка исполняемого файла указывает интерпретатор:
#!/usr/bin/env bashenv ищет bash в $PATH. Прямой вариант:
#!/bin/bashShebang используется при запуске ./script.sh. При вызове bash script.sh интерпретатор задан явно. Не запускайте Bash-скрипт через sh, если применяются массивы, [[ ... ]], (( ... )) и другие расширения Bash.
Комментарии начинаются с #:
# Создать каталог и скопировать файл
mkdir -p backup && cp -- config.ini backup/&& запускает следующую команду после успеха предыдущей, || — после ошибки:
command || printf 'Ошибка\n' >&2Кавычки и подстановки
name="Alice"
printf '%s\n' '$name' # буквально $name
printf '%s\n' "$name" # Alice
printf '%s\n' "$(pwd)" # результат командыПодстановки переменных почти всегда заключают в двойные кавычки:
cp -- "$source" "$destination"Без кавычек Bash выполняет разбиение на слова и раскрытие файловых шаблонов. -- у многих утилит завершает список параметров и защищает имена файлов, начинающиеся с -.
Арифметика:
count=$((count + 1))
if ((count > 10)); then
echo "Больше десяти"
fiПеременные
В присваивании вокруг = нет пробелов:
name="Alice"
count=10
printf '%s\n' "$name"
printf '%s\n' "${name}_admin"Запись name = "Alice" ошибочна: Bash попытается запустить команду name.
В функциях применяйте локальные переменные:
greet() {
local name=${1:-Guest}
printf 'Hello, %s!\n' "$name"
}
readonly CONFIG_FILE="/etc/myapp.conf"Значения по умолчанию
| Форма | Действие |
|---|---|
${var:-default} |
Значение по умолчанию, если переменная не задана или пуста |
${var-default} |
Значение по умолчанию, если переменная не задана |
${var:=default} |
Присвоить значение по умолчанию |
${var:?message} |
Завершиться с сообщением при отсутствии значения |
${var:+replacement} |
Использовать замену, если значение есть |
name=${1:-Guest}
: "${BACKUP_DIR:?Переменная BACKUP_DIR не задана}"Переменная окружения передаётся дочерним процессам:
export APP_ENV="production"
APP_ENV="test" ./run-tests.sh # только для одной командыМассивы
Индексированные массивы
packages=(curl git "bats core")
packages+=(shellcheck)
printf '%s\n' "${packages[0]}"
printf 'Количество: %s\n' "${#packages[@]}"
for package in "${packages[@]}"; do
printf 'Package: %s\n' "$package"
done"${array[@]}" передаёт каждый элемент отдельным аргументом и сохраняет пробелы внутри элементов.
for index in "${!packages[@]}"; do
printf '%s: %s\n' "$index" "${packages[$index]}"
done
unset 'packages[1]'Ассоциативные массивы
declare -A ports=([http]=80 [https]=443 [ssh]=22)
printf '%s\n' "${ports[https]}"
for service in "${!ports[@]}"; do
printf '%s: %s\n' "$service" "${ports[$service]}"
doneПорядок обхода ассоциативного массива не гарантируется.
Чтение строк в массив:
mapfile -t lines < input.txt
mapfile -d '' -t files < <(find . -type f -print0)Условные конструкции if и case
if проверяет код завершения команды: 0 означает успех, ненулевой код — ложь или ошибку.
if command; then
echo "Успех"
elif another_command; then
echo "Второй вариант"
else
echo "Ошибка" >&2
fiПроверки через [[ ... ]]
[[ -z $value ]] # пустая строка
[[ -n $value ]] # непустая строка
[[ $name == "admin" ]] # равенство строк
[[ $file == *.log ]] # glob-шаблон
[[ $value =~ ^[0-9]+$ ]] # регулярное выражение
[[ -f $file ]] # обычный файл
[[ -d $path ]] # каталог
[[ -r $file ]] # можно читать
[[ -w $file ]] # можно записывать
[[ -x $file ]] # можно выполнятьДля чисел удобна арифметическая конструкция:
if ((a >= b)); then
echo "a больше или равно b"
fiВ переносимом POSIX-коде используют [ ... ]. В Bash обычно удобнее [[ ... ]], поскольку она безопаснее для строк и поддерживает шаблоны и регулярные выражения.
case
case ${1:-} in
start) echo "Запуск" ;;
stop) echo "Остановка" ;;
restart|reload) echo "Перезапуск" ;;
*)
printf 'Неизвестная команда: %s\n' "${1:-}" >&2
exit 2
;;
esacШаблоны case — это glob, а не регулярные выражения:
case $file in
*.tar.gz|*.tgz) echo "Архив tar.gz" ;;
*.zip) echo "ZIP-архив" ;;
*) echo "Другой файл" ;;
esacЦиклы for и while
for environment in dev test prod; do
printf '%s\n' "$environment"
done
for argument in "$@"; do
printf 'Argument: %s\n' "$argument"
done
for ((i = 0; i < 5; i++)); do
printf '%d\n' "$i"
doneФайлы текущего каталога:
for file in ./*.log; do
[[ -e $file ]] || continue
process_file "$file"
doneНе используйте for file in $(ls): пробелы и специальные символы в именах будут обработаны неправильно.
while
count=1
while ((count <= 5)); do
printf '%d\n' "$count"
((++count))
doneБезопасное чтение файла:
while IFS= read -r line || [[ -n $line ]]; do
printf 'Line: %s\n' "$line"
done < input.txtIFS= сохраняет пробелы по краям, read -r не интерпретирует обратные слеши, дополнительное условие обрабатывает последнюю строку без перевода строки.
Рекурсивная обработка любых имён файлов:
while IFS= read -r -d '' file; do
process_file "$file"
done < <(find . -type f -print0)continue переходит к следующей итерации, break завершает цикл.
Функции
require_file() {
local file=$1
if [[ ! -f $file ]]; then
printf 'Файл не найден: %s\n' "$file" >&2
return 1
fi
}Аргументы функции доступны через $1, $2, $# и "$@". return возвращает код от 0 до 255, а не строку.
Текстовый результат выводят в stdout:
get_filename() {
basename -- "$1"
}
name=$(get_filename "/var/log/app.log")Если return отсутствует, возвращается код последней команды. Для удобного тестирования отделяйте основную функцию:
main() {
printf 'Arguments: %s\n' "$#"
}
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
main "$@"
fiПри обычном запуске выполняется main, а при source script.sh загружаются только определения.
Аргументы скрипта
| Переменная | Значение |
|---|---|
$0 |
Имя скрипта |
$1…$9 |
Позиционные аргументы |
${10} |
Десятый и последующие аргументы |
$# |
Количество аргументов |
"$@" |
Все аргументы по отдельности |
"$*" |
Все аргументы одной строкой |
$? |
Код предыдущей команды |
$$ |
PID текущей оболочки |
$! |
PID последнего фонового процесса |
if (($# < 1)); then
printf 'Использование: %s FILE\n' "$0" >&2
exit 2
fi
file=$1Для передачи аргументов используйте "$@", сохраняющий их границы:
run_command() {
command "$@"
}shift удаляет первый позиционный параметр и сдвигает остальные.
getopts
verbose=false
output=""
usage() {
printf 'Использование: %s [-v] [-o FILE] INPUT\n' "$0"
}
while getopts ':vo:h' option; do
case $option in
v) verbose=true ;;
o) output=$OPTARG ;;
h) usage; exit 0 ;;
:) printf 'Параметр -%s требует значение\n' "$OPTARG" >&2; exit 2 ;;
\?) printf 'Неизвестный параметр: -%s\n' "$OPTARG" >&2; exit 2 ;;
esac
done
shift "$((OPTIND - 1))"
(($# == 1)) || { usage >&2; exit 2; }
input=$1Первый : в :vo:h включает самостоятельную обработку ошибок. Двоеточие после o означает обязательное значение. getopts предназначен для коротких параметров; длинные параметры обычно разбирают через while и case.
Работа со строками и файлами
text="Bash scripting"
printf '%s\n' "${#text}" # длина
printf '%s\n' "${text:0:4}" # подстрока
printf '%s\n' "${text^^}" # верхний регистр
printf '%s\n' "${text,,}" # нижний регистрУдаление частей и замена:
path="/var/log/app.log"
printf '%s\n' "${path##*/}" # app.log
printf '%s\n' "${path%.*}" # /var/log/app
value="one two two"
printf '%s\n' "${value/two/2}" # первое совпадение
printf '%s\n' "${value//two/2}" # все совпаденияПроверка, чтение и запись:
[[ -r $file ]] || exit 1
content=$(<"$file")
printf '%s\n' "$content" > "$output"
printf '%s\n' "$content" >> "$output"Для вывода предпочтителен printf. Не используйте внешние данные как формат: вместо printf "$input" пишите printf '%s\n' "$input".
Here document:
cat > config.ini <<EOF
[application]
name=$app_name
port=$port
EOFКавычки вокруг маркера отключают подстановки:
cat > example.sh <<'EOF'
echo "$HOME"
EOFВременные ресурсы создавайте через mktemp:
tmp_dir=$(mktemp -d)
trap 'rm -rf -- "$tmp_dir"' EXITОбработка ошибок, exit codes и trap
Код 0 означает успех, ненулевой код — ошибку или отрицательный результат проверки. Распространённые соглашения: 2 — ошибка аргументов, 126 — файл нельзя выполнить, 127 — команда не найдена, 128 + N — завершение сигналом N.
if cp -- "$source" "$destination"; then
echo "Копирование завершено"
else
echo "Ошибка копирования" >&2
exit 1
fiЧасто применяется строгий режим:
set -Eeuo pipefail| Параметр | Назначение |
|---|---|
-e |
Выход при необработанной ошибке |
-E |
Наследование ловушки ERR функциями |
-u |
Ошибка для неустановленной переменной |
pipefail |
Ошибка пайплайна, если ошиблась любая команда |
set -e имеет контекстные исключения и не заменяет явную проверку ожидаемых ошибок:
if ! result=$(important_command); then
echo "Команда завершилась ошибкой" >&2
exit 1
fiПри set -u необязательные параметры раскрывают как ${1:-}.
trap
cleanup() {
local status=$?
rm -rf -- "$tmp_dir"
exit "$status"
}
trap cleanup EXITОбработка Ctrl+C:
trap 'echo "Прервано" >&2; exit 130' INTЧастые события: EXIT, ERR, INT, TERM, HUP. SIGKILL и SIGSTOP перехватить нельзя.
Диагностика:
bash -n script.sh # проверить синтаксис
bash -x script.sh # показать выполняемые командыbash -x может вывести секреты, поэтому трассировку применяют осторожно.
ShellCheck — статический анализ
ShellCheck проверяет shell-скрипты без выполнения и находит пропущенные кавычки, ненадёжное разбиение слов, ошибки массивов и несовместимость с оболочкой.
sudo apt install shellcheck # Debian/Ubuntu
sudo dnf install ShellCheck # Fedora
sudo pacman -S shellcheck # Arch Linux
shellcheck script.sh
shellcheck scripts/*.sh
shellcheck --shell=bash script
shellcheck --format=gcc scripts/*.shПроблема:
file=$1
cat $fileИсправление:
file=${1:?Не указан файл}
cat -- "$file"Частые коды:
| Код | Проблема |
|---|---|
SC2046 |
Подстановка команды без кавычек |
SC2086 |
Переменная без двойных кавычек |
SC2155 |
local скрывает код ошибки подстановки |
SC2164 |
Результат cd не проверяется |
SC2181 |
Лучше проверить команду напрямую, а не $? |
SC2207 |
Ненадёжное заполнение массива через $(...) |
Для SC2155 разделите операции:
local result
result=$(command)Предупреждение можно отключить точечно, только если причина понятна:
# shellcheck disable=SC2086
command $intentionally_split_optionsДля списков аргументов обычно нужен массив:
options=(--output "$output" --verbose)
command "${options[@]}"Подсказка для динамического source:
# shellcheck source=lib/common.sh
source "$SCRIPT_DIR/lib/common.sh"Bats — тестирование Bash-скриптов
Bats (Bash Automated Testing System) — фреймворк тестирования shell-скриптов. Пакет обычно называется bats или bats-core.
sudo apt install bats
sudo dnf install bats
sudo pacman -S bats
bats --version
bats test/example.bats
bats test/Первый тест:
#!/usr/bin/env bats
@test "скрипт выводит приветствие" {
run ./greet.sh Alice
[ "$status" -eq 0 ]
[ "$output" = "Hello, Alice!" ]
[ "${lines[0]}" = "Hello, Alice!" ]
}После run доступны $status, $output и массив $lines.
Проверка ошибки:
@test "без аргументов возвращается ошибка" {
run ./backup.sh
[ "$status" -eq 2 ]
[[ "$output" == *"Использование:"* ]]
}setup и teardown
setup() {
TEST_DIR=$(mktemp -d)
SCRIPT="$BATS_TEST_DIRNAME/../bin/copy.sh"
}
teardown() {
rm -rf -- "$TEST_DIR"
}
@test "создаётся выходной файл" {
printf 'data\n' > "$TEST_DIR/input.txt"
run "$SCRIPT" "$TEST_DIR/input.txt" "$TEST_DIR/output.txt"
[ "$status" -eq 0 ]
[ -f "$TEST_DIR/output.txt" ]
}setup выполняется перед каждым тестом, teardown — после него. $BATS_TEST_DIRNAME содержит каталог тестового файла.
Тестирование функции из библиотеки:
setup() {
source "$BATS_TEST_DIRNAME/../lib/math.sh"
}
@test "функция суммирует числа" {
run add 2 3
[ "$status" -eq 0 ]
[ "$output" = "5" ]
}Внешние программы можно подменять исполняемыми заглушками во временном каталоге, добавленном в начало $PATH. Это исключает реальные сетевые запросы и другие побочные эффекты.
Общий пример
Скрипт bin/line-count.sh:
#!/usr/bin/env bash
set -Eeuo pipefail
usage() {
printf 'Использование: %s [-n] FILE\n' "${0##*/}"
}
main() {
local number_only=false option file count
while getopts ':nh' option; do
case $option in
n) number_only=true ;;
h) usage; return 0 ;;
\?) usage >&2; return 2 ;;
esac
done
shift "$((OPTIND - 1))"
(($# == 1)) || { usage >&2; return 2; }
file=$1
[[ -f $file ]] || {
printf 'Файл не найден: %s\n' "$file" >&2
return 1
}
count=$(wc -l < "$file")
if [[ $number_only == true ]]; then
printf '%s\n' "$count"
else
printf '%s\t%s\n' "$count" "$file"
fi
}
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
main "$@"
fiТест test/line-count.bats:
#!/usr/bin/env bats
setup() {
SCRIPT="$BATS_TEST_DIRNAME/../bin/line-count.sh"
TEST_DIR=$(mktemp -d)
printf 'one\ntwo\nthree\n' > "$TEST_DIR/three lines.txt"
}
teardown() {
rm -rf -- "$TEST_DIR"
}
@test "считает строки в файле с пробелом в имени" {
run "$SCRIPT" -n "$TEST_DIR/three lines.txt"
[ "$status" -eq 0 ]
[ "$output" = "3" ]
}
@test "возвращает ошибку для отсутствующего файла" {
run "$SCRIPT" "$TEST_DIR/missing.txt"
[ "$status" -eq 1 ]
[[ "$output" == *"Файл не найден"* ]]
}Автоматизация через Makefile:
.PHONY: check syntax lint test
check: syntax lint test
syntax:
bash -n bin/*.sh
lint:
shellcheck bin/*.sh test/*.bats
test:
bats test/make checkПрактические рекомендации
- Заключайте подстановки в кавычки:
"$value","${items[@]}". - Храните аргументы команды в массиве, а не в строке.
- Не разбирайте вывод
ls; используйте glob илиfind -print0. - Избегайте
eval: внешние данные могут стать исполняемым кодом. - Используйте
mktempи удаляйте временные ресурсы черезtrap. - Проверяйте ожидаемые ошибки явно даже при
set -e. - В библиотечных функциях применяйте
return, а неexit. - Отделяйте функции от
main, чтобы их было проще тестировать. - Не включайте
set -xрядом с паролями, токенами и ключами. - Проверяйте код командами
bash -n,shellcheckиbats.
Краткая памятка
chmod +x script.sh
./script.sh
bash -n script.sh
bash -x script.sh
set -Eeuo pipefail
name="Alice"
items=(one "two words")
printf '%s\n' "$name" "${items[@]}"
[[ -f $file ]]
[[ -n $value ]]
((number > 10))
printf '<%s>\n' "$@"
shellcheck script.sh
bats test/Итог
Надёжный Bash-скрипт использует корректный shebang, кавычки для подстановок, массивы для списков аргументов, функции с локальными переменными и явную обработку ошибок. trap освобождает временные ресурсы и обрабатывает сигналы, ShellCheck обнаруживает распространённые дефекты до запуска, а Bats проверяет фактическое поведение сценария.