Написание скриптов 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 bash

env ищет bash в $PATH. Прямой вариант:

#!/bin/bash

Shebang используется при запуске ./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.txt

IFS= сохраняет пробелы по краям, 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

Практические рекомендации


Краткая памятка

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 проверяет фактическое поведение сценария.