Перейти к содержимому

Владение shell: bash как инженерный инструмент

Bash — главный язык эксплуатации. Ansible, Kubernetes-операторы и GitHub Actions скрывают его за слоями абстракции, но когда абстракция трещит — ты всё равно оказываешься в терминале. В краткой версии ты видел безопасную основу: шебанг, set -euo pipefail, обязательные аргументы через ${1:?}. Здесь мы идём глубже: разбираем каждый флаг до механики, учимся писать скрипты, которые живут месяцами в cron и systemd-таймерах и честно кричат, когда что-то пошло не так.

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

-e (errexit) — немедленный выход, если команда вернула ненулевой код. Без него скрипт продолжает исполнение после ошибки, и падение происходит через десять строк, в совершенно другом месте. Важный нюанс: errexit проверяет код последней команды в списке, разделённом ; или &&. Команда в условии if cmd; then не триггерит выход — там код проверяется явно, и это правильно: if grep -q ... обязан уметь «не найти» без смерти скрипта.

-u (nounset) — обращение к несуществующей переменной — ошибка. Классика: переменная называется DB_PASS, а в скрипте DB_PASSWORD — пустая строка улетает в команду, и ты три часа гадаешь, почему подключение отклонено. С -u скрипт упадёт на строке с опечаткой.

-o pipefail — код выхода пайпа равен коду самой левой команды, завершившейся с ошибкой. Без него grep pattern app.log | head -1 возвращает код head, а он почти всегда 0. Итог: grep ничего не нашёл (код 1), пайп вернул 0, set -e промолчал, а скрипт пошёл дальше с пустым результатом. В продакшене это самая частая причина «молчащих» бэкапов: pg_dump | gzip > dump.gzpg_dump упал, gzip успешно сожрал пустой поток и написал валидный пустой архив.

Окно терминала
# Демонстрация pipefail
$ grep root /etc/passwd | head -1 ; echo $?
0
$ grep НЕТ_ТАКОГО /etc/passwd | head -1 ; echo $? # без pipefail
0 # ложное "успех"
$ set -o pipefail
$ grep НЕТ_ТАКОГО /etc/passwd | head -1 ; echo $?
1 # честный код

Это то, что отличает bash от «набора команд в файле». Все формы:

Окно терминала
NAME="${1:?Укажи имя хоста}" # обязательный аргумент; без него — выход с сообщением
ENV="${2:-production}" # значение по умолчанию
TAG="${3:-latest}" # можно комбинировать
CONFIG="${CONFIG_FILE:-/etc/app/config.yml}"
FILE="/var/backups/db-$(date +%F).sql.gz"
BASE="${FILE##*/}" # db-2026-09-06.sql.gz (удалили самый длинный префикс до /)
DIR="${FILE%/*}" # /var/backups (удалили самый короткий суффикс от /)
NAME="${BASE%.sql.gz}" # db-2026-09-06 (один суффикс)
NAME="${BASE%%.*}" # db (все суффиксы от первой точки)
echo "${#FILE}" # длина строки
echo "${ARR[@]:1:2}" # срез массива: элементы с 1, два штуки
# Замена в строке
MSG="postgres://user:secret@db:5432/app"
SAFE="${MSG/secret/****}" # первая замена: postgres://user:****@db:5432/app
SAFE="${MSG//[0-9]/X}" # все цифры → X

Разница # и ## (а также % и %%): один символ — «коротшее совпадение», два — «длиннейшее». ##*/ режет до последнего слэша (basename), #*/ — до первого. Именно поэтому для путей берут ## и %, а для одного суффикса — %.

Всегда кавычки: "$var", а не $var. Раскрытие без кавычек проходит word splitting и globbing — имя файла My Report.pdf превратится в три «слова», а * в списке файлов — в их перечисление. ShellCheck ловит это мгновенно.

Обычные массивы:

Окно терминала
SERVICES=(app worker scheduler) # объявление
SERVICES+=(mailer) # добавить элемент
echo "${SERVICES[0]}" # app
echo "${SERVICES[@]}" # все элементы — ВСЕГДА в кавычках
echo "${!SERVICES[@]}" # индексы: 0 1 2 3
echo "${#SERVICES[@]}" # количество: 4
for svc in "${SERVICES[@]}"; do # кавычки критичны: иначе элементы с пробелами развалятся
systemctl restart "$svc"
done

Ассоциативные массивы (bash 4+, есть везде, кроме macOS по умолчанию):

Окно терминала
declare -A ENV_PORT=(
[dev]=3001
[staging]=3002
[prod]=3000
)
ENV="${1:-dev}"
PORT="${ENV_PORT[$ENV]:-3000}" # значение по ключу или дефолт
for env in "${!ENV_PORT[@]}"; do
printf '%-10s → %s\n' "$env" "${ENV_PORT[$env]}"
done

Массивы — причина, по которой «просто строка с пробелами» через for i in $list — антипаттерн: два сервиса с пробелом в имени, и цикл идёт по словам, а не по сервисам.

Окно терминала
HOST_COUNT=0
[ "$HOST_COUNT" = 0 ] && echo "пусто" # POSIX, всё в кавычках, = для строк
[[ $HOST_COUNT -eq 0 ]] && echo "пусто" # bash: кавычки не нужны, нет word splitting
FILE="report final.pdf"
[[ -f $FILE ]] # OK: внутри [[ ]] не происходит splitting
[ -f "$FILE" ] # OK в кавычках; [ -f $FILE ] — синтаксическая ошибка!
[[ $ENV == prod && $DRY_RUN != true ]] # && и || внутри одного теста
[[ $VERSION =~ ^[0-9]+\.[0-9]+$ ]] # регулярки без внешнего grep
(( RETRIES++ )) # арифметика: (( )) возвращает 0 при результате 0 — осторожно с set -e

Правило: внутри скриптов — только [[ ]], (( )) и [[ -v VAR ]] для проверки существования переменной. Классический [ оставь для /bin/sh-совместимых однострочников.

Четыре способа прочитать файл построчно — и только один правильный:

Окно терминала
# ПОХО: for подменяет IFS, режет пробелы и бэкслеши, съедает пустые строки
for line in $(cat hosts.txt); do echo "$line"; done
# НЕОЧЕНЬ: старается с обратными кавычками, сначала читает весь файл в память
cat hosts.txt | while read -r line; do echo "$line"; done
# ХОРОШО: while read с перенаправлением ввода
while IFS= read -r line; do
[[ -z $line || $line == \#* ]] && continue # пропуск пустых и комментариев
echo "хост: $line"
done < hosts.txt
# read по полям: /etc/passwd как таблица
while IFS=: read -r user _ uid gid _ home shell; do
[[ $uid -ge 1000 ]] && echo "$user$home ($shell)"
done < /etc/passwd

Ключевые флаги: -r запрещает интерпретацию бэкслешей (без него \ в файле съедается), IFS= сохраняет ведущие и хвостовые пробелы строки. Конвейер | while создаёт подоболочку — переменные, изменённые внутри цикла, не попадут наружу. Поэтому вход перенаправляем, а не пайпим.

Цикл select удобен для интерактивных меню, а getopts — для флагов -v -f config.yml твоего скрипта; для чего-то сложнее шести флагов бери Python.

Окно терминала
log() {
printf '[%s] %s\n' "$(date '+%F %T')" "$*" >&2
}
# Локальные переменные — иначе функция засоряет глобальное пространство
backup_one() {
local src="$1" dest="$2"
local started rc
started=$(date +%s)
if rsync -a --delete "$src/" "$dest/"; then
rc=0
else
rc=$?
log "ОШИБКА rsync $src (код $rc)"
fi
log "готово за $(( $(date +%s) - started ))с"
return "$rc" # return — только код выхода 0-255, не данные!
}
# Данные из функции — через stdout:
config_value() {
local key="$1" file="${2:-/etc/app/config.ini}"
awk -F= -v k="$key" '$1==k {print $2; exit}' "$file"
}
PORT="$(config_value port)" || exit 1

Классика, которую должен знать каждый: local var=$(cmd) маскирует код выхода команды — local всегда возвращает 0, и set -e не сработает. Разделяй объявление и присваивание.

Скрипт могут прервать в любой момент: SIGTERM от systemd при остановке, SIGINT от Ctrl+C, EXIT — нормальное завершение. trap — твой finally:

#!/usr/bin/env bash
set -euo pipefail
WORKDIR="$(mktemp -d)"
cleanup() {
rm -rf "$WORKDIR" # снимаем lock, чистим temp — всегда
}
trap cleanup EXIT
trap 'echo "Прервано пользователем" >&2; exit 130' INT TERM
# lock от параллельных запусков
exec 9>"${LOCK_FILE:-/var/run/mybackup.lock}"
flock -n 9 || { echo "Уже запущен"; exit 1; }
heavy_work "$WORKDIR"

trap ... EXIT срабатывает при любом завершении — и по ошибке, и по сигналу (после соответствующего trap’а сигнала), и по нормальному выходу. Код 130 — соглашение для «убито по Ctrl+C». Для долгих скриптов добавь flock на файл-лок — два параллельных запуска бэкапа базы — прямой путь к битому дампу.

Процесс-подстановка и quoting: два скрытых приёма

Заголовок раздела «Процесс-подстановка и quoting: два скрытых приёма»

Осталось два механизма, которые отделяют уверенный bash от «писал на SO-ответах». Первый — process substitution < <(...): вместо пайпа (с его подоболочкой) подставляешь вывод команды как файл:

Окно терминала
# Сравнить список сервисов в конфиге с реально запущенными, БЕЗ временных файлов
comm -23 <(cut -d= -f1 /etc/app/services.conf | sort) \
<(systemctl list-units --type=service --plain --no-legend | awk '{print $1}' | sort)
# Цикл по строкам вывода команды — переменные живут в текущем шелле
while IFS= read -r img; do
docker pull "$img"
done < <(grep -v '^#' images.txt)

Второй — понимание quoting по уровням. Одинарные кавычки '...' — вообще без подстановок, самый безопасный вариант. Двойные "..." — подставляются $var и $(cmd), но нет word splitting и globbing. Без кавычек — всё подставляется и раскалывается по пробелам. Отсюда правило написания любой команды: сначала напиши всё в одинарных кавычках, потом сними их ровно там, где нужна подстановка, и сразу проверь shellcheck’ом. Классическая цитата из BashFAQ: «quote everything unless you know exactly what you’re doing» — и ты почти никогда не знаешь.

awk — колоночная обработка. Подсчитаем по логу nginx количество запросов по статусам:

Окно терминала
awk '{print $9}' access.log | sort | uniq -c | sort -rn
# 4212 200
# 317 304
# 88 404
# Среднее время ответа по эндпоинтам (лог-формат: ... "GET /api/users HTTP/1.1" 200 1234 "0.052")
awk '{gsub(/"/, "", $7); sum[$7]+=$NF; cnt[$7]++}
END {for (u in sum) printf "%-30s avg=%.3fs n=%d\n", u, sum[u]/cnt[u], cnt[u]}' access.log
# Память по процессам, топ-5: ps aux вывод
ps aux | awk 'NR>1 {mem[$11]+=$4} END {for (p in mem) printf "%6.2f%% %s\n", mem[p], p}' \
| sort -rn | head -5

awk-программа состоит из паттернов условие {действие}. NR>1 — пропуск заголовка, $n — поле, $NF — последнее, END — блок после потока. gsub — глобальная замена.

sed — потоковая правка. Типовые рецепты:

Окно терминала
sed -i.bak 's/old_domain/new_domain/g' nginx.conf # замена с бэкапом .bak
sed -n '100,120p' app.log # вывести диапазон строк
sed '/^#/d; /^$/d' config.ini # выкинуть комментарии и пустые
docker ps --format '{{.Names}}' | sed 's/^/ - /' # префикс каждой строки

Флаг -i правит файл на месте — всегда с бэкапом-суффиксом (-i.bak) в скриптах: одна опечатка в регулярке, и конфиг безвозвратно испорчен. Под macOS/BSD sed синтаксис -i '' отличается — ещё аргумент за GNU sed (sed -i.bak везде одинаково).

jq — JSON как первоклассный гражданин. Docker, GitHub API, Kubernetes — всё говорит JSON:

Окно терминала
# Статус контейнеров одной строкой
docker inspect $(docker ps -q) | jq -r '.[] | "\(.Name[1:]) \(.State.Status) \(.State.OOMKilled)"'
# Версии образов в Compose-проекте
jq -r '.services | to_entries[] | "\(.key): \(.value.image // "build")"' docker-compose.yml
# Только упавшие таймстемпы из массива событий
curl -s api.example.com/events | jq -r '.[] | select(.level=="error") | .timestamp'
# Собрать новый JSON из кусков
jq -n --arg host "$(hostname)" --arg ts "$(date -Is)" '{host: $host, ts: $ts, ok: true}'

Ключевые приёмы: -r — сырая строка без кавычек, select(...) — фильтр, --arg name value — безопасная подстановка переменных (никогда не вставляй $var в jq-строку напрямую — инъекция), jq -n — собрать JSON с нуля.

curl — HTTP из скрипта:

Окно терминала
# Отправить алерт в Telegram о результате бэкапа
curl -sf -X POST "https://api.telegram.org/bot${TG_TOKEN}/sendMessage" \
-H 'Content-Type: application/json' \
-d "{\"chat_id\": ${TG_CHAT}, \"text\": \"Бэкап ${STATUS}\"}"
# Скачать с ретраями и таймаутом — никогда без них в скриптах
curl -sfL --retry 3 --retry-delay 5 --max-time 60 \
-o /tmp/release.tar.gz "https://example.com/release.tar.gz"

-f — не писать тело HTTP-ошибки в файл (код ≥400 → код выхода ≥22), -s — тихо, -L — следовать редиректам, --retry — ретраи только для «сетевых» ошибок, для 5xx добавь --retry-all-errors.

Собираем всё вместе — работающий скрипт, который архивирует каталог, проверяет целостность и шлёт уведомление:

#!/usr/bin/env bash
# backup.sh — бэкап каталога с ротацией и уведомлением
# Использование: backup.sh /var/lib/app
set -euo pipefail
IFS=$'\n\t' # предсказуемое раскрытие: слова только по \n и табу
SRC="${1:?Укажи каталог для бэкапа}"
DEST_DIR="${DEST_DIR:-/var/backups}"
KEEP_DAYS="${KEEP_DAYS:-14}"
TG_TOKEN="${TG_TOKEN:-}"
TG_CHAT="${TG_CHAT:-}"
log() { printf '[%s] %s\n' "$(date '+%F %T')" "$*" >&2; }
notify() { # notify "текст" — no-op, если токены не заданы
[[ -n $TG_TOKEN && -n $TG_CHAT ]] || return 0
curl -sf --max-time 10 -X POST \
"https://api.telegram.org/bot${TG_TOKEN}/sendMessage" \
-d "chat_id=${TG_CHAT}" --data-urlencode "text=$1" >/dev/null \
|| log "WARN: уведомление не отправлено"
}
main() {
local ts archive
ts="$(date +%Y%m%d-%H%M%S)"
archive="${DEST_DIR}/$(basename "$SRC")-${ts}.tar.gz"
mkdir -p "$DEST_DIR"
exec 9>"${DEST_DIR}/.backup.lock"
flock -n 9 || { log "другой бэкап уже выполняется"; exit 1; }
log "Архивирую $SRC$archive"
# pipefail гарантирует: если tar упал — пайп вернёт ошибку
tar -C "$(dirname "$SRC")" -czf - "$(basename "$SRC")" | gpg --symmetric --batch --passphrase "$GPG_PASS" -o "$archive.gpg"
# Проверка целостности: распакуем список файлов из свежего архива
if gpg --batch --passphrase "$GPG_PASS" -d "$archive.gpg" 2>/dev/null \
| tar -tzf - >/dev/null 2>&1; then
log "Проверка архива OK ($(du -h "$archive.gpg" | cut -f1))"
else
notify "БЭКАП БИТЫЙ: $archive.gpg"
exit 1
fi
log "Ротация: удаляю архивы старше $KEEP_DAYS дней"
find "$DEST_DIR" -name '*.tar.gz.gpg' -mtime "+$KEEP_DAYS" -delete
notify "Бэкап $SRC готов: $(basename "$archive.gpg")"
log "Готово"
}
trap 'notify "БЭКАП УПАЛ с ошибкой (код $?)"' ERR
main "$@"

Пройдись по механизмам: ${1:?} и ${DEST_DIR:-}, pipefail за честность tar | gpg, lock через flock против параллельных запусков, trap ERR — уведомление при любом ненулевом выходе (bash 4.1+), verify-шаг, который доказывает, что архив читается, а не просто существует. Такой скрипт можно ставить в systemd-таймер из соседней главы и спать спокойно.

  1. for i in $(ls) — перебор файлов через ls ломается на пробелах и переносах в именах. Правильно: for f in /path/*; do ... done или find ... -print0 | while IFS= read -d '' f.
  2. rm -rf "$DIR/" при пустом DIR** — получается rm -rf /. Ставь guard: [ -n "$DIR" ] && [ "$DIR" != / ] || exit 1. Это подсвечивает ShellCheck.
  3. local x=$(cmd) — съедает код выхода команды (local всегда возвращает 0). Разделяй: local x; x=$(cmd).
  4. Цикл | while read — переменные не возвращаются наружу (подоболочка). Перенаправляй вход: while read ... done < file, либо используй process substitution < <(cmd).
  5. [ $a = $b ] без кавычек — при пустых значениях превращается в [ = ] — синтаксическая ошибка. Внутри [[ ]] кавычки не нужны, внутри [ — обязательны.
  6. set -e внутри командного подстановления в if: if ! x=$(false); then — сработает, но x=$(false) внутри условия «глушит» errexit осознанно. Главное — не путаться, где код проверяется явно.

Что делает set -o pipefail и зачем он нужен вместе с set -e? Код выхода пайпа становится равен коду самой левой упавшей команды. Без него код пайпа — код последней команды (head, gzip почти всегда возвращают 0), и set -e не замечает падения grep или pg_dump в начале пайпа. Классика: pg_dump | gzip > dump.gz без pipefail пишет валидный пустой архив при упавшем дампе.

Чем [[ ]] отличается от [ ]? [[ ]] — конструкция bash, не требует кавычек (нет word splitting и pathname expansion), поддерживает &&/|| внутри, =~ для регулярок, арифметические выражения (( ))-стиля. [ — внешняя команда test, POSIX, требует кавычек и экранирования. В скриптах — всегда [[ ]].

Что выведет echo "${VAR:-default}" и чем это отличается от ${VAR:=default}? - подставит default, если VAR не задана или пуста, но не изменит VAR. = в дополнение запишет default в VAR. Есть также ${VAR:?сообщение} — ошибка с сообщением, и ${VAR:+альтернатива} — альтернатива, если VAR задана (для «флаг включён»).

Как корректно читать файл построчно и почему не for line in $(cat file)? while IFS= read -r line; do ...; done < file. for in $(...) разбивает по любым пробелам (IFS), съедает пустые строки и молча теряет пробелы по краям. -r не даёт съесть бэкслеши, IFS= сохраняет ведущие/хвостовые пробелы.

Зачем local в функциях и в чём ловушка local var=$(cmd)? local ограничивает область видимости переменной функцией — иначе функции засоряют глобальное пространство и перезаписывают переменные вызывающего кода. Ловушка: local всегда возвращает код 0, поэтому присваивание вместе с командной подстановкой маскирует код выхода команды, и set -e её не поймает. Объявляй и присваивай раздельно.

Как обработать SIGTERM в долгоживущем скрипте? Через trap 'команда' TERM (обычно — корректный выход с cleanup через trap cleanup EXIT). Важно: trap в bash прерывается только между командами, поэтому внутри длинной внешней команды сигнал подействует после её завершения — долгие команды запускай с awareness или используй timeout.

Чем awk предпочтительнее grep/sed для логов? awk работает по полям ($1, $2, $NF), умеет агрегации (словари сумм/счётчиков) в одном проходе файла, математику и форматированный вывод. Для «посчитать среднее время по эндпоинтам» grep+sed потребуют трёх пайпов, awk — одного прохода.

  1. Свой бэкап с проверкой. Возьми скрипт из главы и перепиши его с нуля, по памяти: pipefail, lock, архивация, verify, ротация, уведомление. Критерий: убей tar (сделай каталог недоступным) — скрипт должен прислать «упал» и не оставить битый архив.
  2. Отчёт по логу. По access.log nginx напиши awk-отчёт: топ-5 IP по количеству запросов, распределение статусов, суммарный трафик. Сравни свой вывод с goaccess — должен совпасть.
  3. jq-парсер событий. Напиши events.sh, который берёт JSON-массив событий из stdin и выводит таблицу время | уровень | сообщение только для error/warn. Сообщения с кавычками и юникодом не должны ломать вывод.
  4. Обработка сигналов. Напиши sleeper.sh, который раз в секунду пишет tick в файл, по SIGTERM пишет “получен SIGTERM, завершаюсь” и выходит с кодом 0. Проверь: systemd-run --user ./sleeper.sh и systemctl --user stop — в логе journal должен быть твой текст.
  5. Shellcheck-чистота. Прогони все свои старые скрипты через shellcheck -x, разбери каждое предупреждение (не отключай директивами без понимания) и исправь. Критерий: ноль предупреждений SC2086, SC2046, SC2086.