Написание Bash-скриптов

TL;DR: set -euo pipefail в начале. Кавычки вокруг переменных. shellcheck для проверки. Функции + local переменные + trap для cleanup. getopts для аргументов.

Шаблон скрипта

#!/bin/bash
set -euo pipefail
 
# --- Описание ---
# script.sh — что делает скрипт
# Usage: ./script.sh [-v] [-o output] <input>
 
# --- Константы ---
readonly SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
readonly VERSION="1.0.0"
 
# --- Функции ---
log()   { echo "[$(date '+%H:%M:%S')] $*"; }
error() { echo "[ERROR] $*" >&2; exit 1; }
 
usage() {
    cat << EOF
Usage: $(basename "$0") [OPTIONS] <input>
 
Options:
  -o FILE   Output file (default: stdout)
  -v        Verbose mode
  -h        Show help
EOF
    exit 0
}
 
cleanup() {
    rm -f "${TMPFILE:-}"
    log "Cleanup done"
}
trap cleanup EXIT
 
# --- Аргументы ---
VERBOSE=false
OUTPUT=""
 
while getopts "o:vh" opt; do
    case $opt in
        o) OUTPUT="$OPTARG" ;;
        v) VERBOSE=true ;;
        h) usage ;;
        *) usage ;;
    esac
done
shift $((OPTIND - 1))
 
[[ $#--lt-1-| -lt 1 ]] && error "Missing input argument. Use -h for help."
INPUT="$1"
 
# --- Основной код ---
TMPFILE=$(mktemp)
log "Processing $INPUT..."
 
if $VERBOSE; then
    log "Verbose mode enabled"
fi
 
# ... ваш код ...
 
log "Done!"

Обработка ошибок

# set -euo pipefail — разобрать по частям:
set -e            # exit при ошибке (ненулевой код)
set -u            # exit при обращении к undefined переменной
set -o pipefail   # ошибка в пайпе → ошибка всего пайпа
 
# Игнорировать ошибку конкретной команды
command_that_may_fail || true
 
# Проверить код возврата
if ! command; then
    error "Command failed"
fi
 
# trap для cleanup
trap 'rm -f /tmp/tempfile.$$' EXIT    # при любом завершении
trap 'error "Interrupted"' INT TERM   # при Ctrl+C

Работа с аргументами

# Позиционные
echo "Script: $0, First: $1, All: $@, Count: $#"
 
# Значения по умолчанию
name="${1:-World}"                 # $1 или "World"
dir="${OUTPUT_DIR:=/tmp}"          # переменная или "/tmp" (и присвоить)
 
# getopts (короткие опции)
while getopts "f:n:vh" opt; do
    case $opt in
        f) FILE="$OPTARG" ;;
        n) COUNT="$OPTARG" ;;
        v) VERBOSE=true ;;
        h) usage ;;
    esac
done
shift $((OPTIND - 1))             # оставшиеся — позиционные

shift — сдвиг аргументов

shift удаляет $1 и сдвигает все аргументы: $2$1, $3$2 и т.д. $# уменьшается на 1. Полезно для последовательной обработки:

# Обработать все аргументы в цикле
while [ $# -gt 0 ]; do
    echo "Processing: $1"
    # ... делать что-то с $1 ...
    shift
done

$@ — передать все аргументы другой команде

$@ разворачивается во все аргументы скрипта. Главный use case — скрипт-обёртка, который добавляет опции по умолчанию и пробрасывает остальное:

#!/bin/bash
# gs-render.sh — ярлык для Ghostscript с дефолтными опциями
gs -q -dBATCH -dNOPAUSE -dSAFER \
   -sOutputFile=- -sDEVICE=pnmraw "$@"
# Всё, что передано скрипту, уйдёт в gs после дефолтных флагов

"$@" с кавычками — сохраняет каждый аргумент как отдельный элемент (даже с пробелами внутри). Без кавычек $@ разобьёт "my file.txt" на два аргумента.

Диагностические сообщения: $0 + stderr

Ошибки должны идти в stderr (>&2), а не в stdout — чтобы не мешать нормальному выводу скрипта:

error() {
    echo "$0: $*" >&2       # $0 = имя скрипта, >&2 = stderr
    exit 1
}
 
# Использование
[ -f "$CONFIG" ] || error "config file not found: $CONFIG"
# → ./deploy.sh: config file not found: /etc/app.conf

Строки

str="Hello World"
echo "${#str}"                    # длина: 11
echo "${str,,}"                   # lowercase: hello world
echo "${str^^}"                   # UPPERCASE: HELLO WORLD
echo "${str/World/Bash}"          # замена: Hello Bash
echo "${str:0:5}"                 # подстрока: Hello
 
# Проверки
[[ -z "$str" ]]                   # пустая?
[[ -n "$str" ]]                   # не пустая?
[[ "$str" == *World* ]]           # содержит?
[[ "$str" =~ ^Hello ]]           # regex?

Массивы

# Объявление
arr=("one" "two" "three")
 
# Доступ
echo "${arr[0]}"                  # первый
echo "${arr[@]}"                  # все элементы
echo "${#arr[@]}"                 # длина
 
# Итерация
for item in "${arr[@]}"; do
    echo "$item"
done
 
# Добавить
arr+=("four")
 
# Ассоциативные (bash 4+)
declare -A config
config[host]="localhost"
config[port]="8080"
echo "${config[host]}:${config[port]}"

Отладка

# Режим отладки
bash -x script.sh                 # показывать каждую команду
set -x                            # включить внутри скрипта
set +x                            # выключить
 
# shellcheck — статический анализ (РЕКОМЕНДУЕТСЯ)
# Установить: apt install shellcheck / pacman -S shellcheck
shellcheck script.sh

Временные файлы (mktemp)

Если скрипту нужен временный файл — не придумывайте имена вручную, используйте mktemp. Он гарантирует уникальность и создаёт файл атомарно.

# mktemp заменяет XXXXXX на случайные символы
TMPFILE=$(mktemp /tmp/myapp.XXXXXX)
echo "data" > "$TMPFILE"
 
# Без аргумента — шаблон /tmp/tmp.XXXXXX
TMPFILE=$(mktemp)
 
# Временная директория
TMPDIR=$(mktemp -d /tmp/build.XXXXXX)

Всегда комбинируйте с trap для очистки — иначе при Ctrl+C или ошибке файлы останутся:

#!/bin/bash
set -euo pipefail
 
TMPFILE1=$(mktemp /tmp/im1.XXXXXX)
TMPFILE2=$(mktemp /tmp/im2.XXXXXX)
trap "rm -f $TMPFILE1 $TMPFILE2; exit 1" INT TERM
trap "rm -f $TMPFILE1 $TMPFILE2" EXIT
 
# Пример: сравнить прерывания за 2 секунды
cat /proc/interrupts > "$TMPFILE1"
sleep 2
cat /proc/interrupts > "$TMPFILE2"
diff "$TMPFILE1" "$TMPFILE2"

Важно: в обработчике INT/TERM нужен явный exit, иначе скрипт продолжит выполнение после обработки сигнала.

basename — имя файла из пути

basename извлекает имя файла, отбрасывая путь. С двумя аргументами — дополнительно удаляет суффикс:

basename /usr/local/bin/myapp        # → myapp
basename report.tar.gz .tar.gz       # → report
basename /home/user/photo.jpg .jpg   # → photo
 
# Практика: конвертация форматов
for file in *.gif; do
    [ -f "$file" ] || continue
    base=$(basename "$file" .gif)
    convert "$base.gif" "$base.png"
    echo "Converted: $base.gif → $base.png"
done

Here-documents

Когда нужно передать многострочный текст команде — вместо цепочки echo используйте here-document:

# <<MARKER — всё до строки MARKER идёт на stdin предыдущей команды
cat <<EOF
Отчёт от $(date)
Сервер: $(hostname)
Uptime: $(uptime -p)
EOF

Переменные и подстановки $() внутри here-document раскрываются. Если нужен текст буквально (без подстановок) — заключите маркер в кавычки:

# Переменные НЕ раскрываются
cat <<'EOF'
Path is $PATH
User is $(whoami)
EOF
# Выведет буквально: Path is $PATH

Практическое применение — генерация конфигов:

DB_HOST="10.0.1.5"
DB_NAME="myapp"
 
cat <<EOF > /etc/myapp/db.conf
[database]
host = $DB_HOST
name = $DB_NAME
pool_size = 10
EOF

Пользовательский ввод (read)

read считывает строку из stdin в переменную:

# Простое подтверждение
read -p "Удалить все логи? (y/N): " answer
if [ "$answer" = "y" ]; then
    rm -f /var/log/myapp/*.log
    echo "Удалено"
fi
 
# Таймаут (не зависать вечно)
read -t 10 -p "Продолжить? (y/N): " answer || answer="N"
 
# Скрытый ввод (пароли)
read -s -p "Password: " password
echo  # перевод строки после скрытого ввода

Принцип: read хорош для простых подтверждений вроде «Вы уверены?». Если обнаруживаете, что строите сложные формы ввода с валидацией — пора переходить на Python. Подробнее: shell-internals.

Типичные ошибки

ОшибкаПравильно
[ $var = "val" ][ "$var" = "val" ] (кавычки!)
if [ $? == 0 ]if command; then
cd dir && ... без проверки`cd dir
Пробелы в name = "val"name="val" (без пробелов)
echo $arrayecho "${array[@]}"
$(ls *.txt) в циклеfor f in *.txt (glob безопаснее)
Не используют shellcheckshellcheck script.sh ловит 90% багов
mktemp без trapВсегда trap cleanup EXIT