Владение shell: bash как инженерный инструмент
Bash — главный язык эксплуатации. Ansible, Kubernetes-операторы и GitHub Actions скрывают его за слоями абстракции, но когда абстракция трещит — ты всё равно оказываешься в терминале. В краткой версии ты видел безопасную основу: шебанг, set -euo pipefail, обязательные аргументы через ${1:?}. Здесь мы идём глубже: разбираем каждый флаг до механики, учимся писать скрипты, которые живут месяцами в cron и systemd-таймерах и честно кричат, когда что-то пошло не так.
set -euo pipefail: анатомия трёх флагов
Заголовок раздела «set -euo pipefail: анатомия трёх флагов»#!/usr/bin/env bashset -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.gz — pg_dump упал, gzip успешно сожрал пустой поток и написал валидный пустой архив.
# Демонстрация pipefail$ grep root /etc/passwd | head -1 ; echo $?0
$ grep НЕТ_ТАКОГО /etc/passwd | head -1 ; echo $? # без pipefail0 # ложное "успех"
$ 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/appSAFE="${MSG//[0-9]/X}" # все цифры → XРазница # и ## (а также % и %%): один символ — «коротшее совпадение», два — «длиннейшее». ##*/ режет до последнего слэша (basename), #*/ — до первого. Именно поэтому для путей берут ## и %, а для одного суффикса — %.
Всегда кавычки: "$var", а не $var. Раскрытие без кавычек проходит word splitting и globbing — имя файла My Report.pdf превратится в три «слова», а * в списке файлов — в их перечисление. ShellCheck ловит это мгновенно.
Массивы и ассоциативные массивы
Заголовок раздела «Массивы и ассоциативные массивы»Обычные массивы:
SERVICES=(app worker scheduler) # объявлениеSERVICES+=(mailer) # добавить элементecho "${SERVICES[0]}" # appecho "${SERVICES[@]}" # все элементы — ВСЕГДА в кавычкахecho "${!SERVICES[@]}" # индексы: 0 1 2 3echo "${#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 не сработает. Разделяй объявление и присваивание.
trap и обработка сигналов
Заголовок раздела «trap и обработка сигналов»Скрипт могут прервать в любой момент: SIGTERM от systemd при остановке, SIGINT от Ctrl+C, EXIT — нормальное завершение. trap — твой finally:
#!/usr/bin/env bashset -euo pipefail
WORKDIR="$(mktemp -d)"cleanup() { rm -rf "$WORKDIR" # снимаем lock, чистим temp — всегда}trap cleanup EXITtrap '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, sed, jq, curl
Заголовок раздела «Текстовая кухня: awk, sed, jq, curl»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 -5awk-программа состоит из паттернов условие {действие}. NR>1 — пропуск заголовка, $n — поле, $NF — последнее, END — блок после потока. gsub — глобальная замена.
sed — потоковая правка. Типовые рецепты:
sed -i.bak 's/old_domain/new_domain/g' nginx.conf # замена с бэкапом .baksed -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/appset -euo pipefailIFS=$'\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 "БЭКАП УПАЛ с ошибкой (код $?)"' ERRmain "$@"Пройдись по механизмам: ${1:?} и ${DEST_DIR:-}, pipefail за честность tar | gpg, lock через flock против параллельных запусков, trap ERR — уведомление при любом ненулевом выходе (bash 4.1+), verify-шаг, который доказывает, что архив читается, а не просто существует. Такой скрипт можно ставить в systemd-таймер из соседней главы и спать спокойно.
Типичные ошибки и грабли
Заголовок раздела «Типичные ошибки и грабли»for i in $(ls)— перебор файлов через ls ломается на пробелах и переносах в именах. Правильно:for f in /path/*; do ... doneилиfind ... -print0 | while IFS= read -d '' f.rm -rf "$DIR/"при пустомDIR** — получаетсяrm -rf /. Ставь guard:[ -n "$DIR" ] && [ "$DIR" != / ] || exit 1. Это подсвечивает ShellCheck.local x=$(cmd)— съедает код выхода команды (local всегда возвращает 0). Разделяй:local x; x=$(cmd).- Цикл
| while read— переменные не возвращаются наружу (подоболочка). Перенаправляй вход:while read ... done < file, либо используй process substitution< <(cmd). [ $a = $b ]без кавычек — при пустых значениях превращается в[ = ]— синтаксическая ошибка. Внутри[[ ]]кавычки не нужны, внутри[— обязательны.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 — одного прохода.
Практика
Заголовок раздела «Практика»- Свой бэкап с проверкой. Возьми скрипт из главы и перепиши его с нуля, по памяти: pipefail, lock, архивация, verify, ротация, уведомление. Критерий: убей
tar(сделай каталог недоступным) — скрипт должен прислать «упал» и не оставить битый архив. - Отчёт по логу. По access.log nginx напиши awk-отчёт: топ-5 IP по количеству запросов, распределение статусов, суммарный трафик. Сравни свой вывод с
goaccess— должен совпасть. - jq-парсер событий. Напиши
events.sh, который берёт JSON-массив событий из stdin и выводит таблицувремя | уровень | сообщениетолько для error/warn. Сообщения с кавычками и юникодом не должны ломать вывод. - Обработка сигналов. Напиши
sleeper.sh, который раз в секунду пишет tick в файл, по SIGTERM пишет “получен SIGTERM, завершаюсь” и выходит с кодом 0. Проверь:systemd-run --user ./sleeper.shиsystemctl --user stop— в логе journal должен быть твой текст. - Shellcheck-чистота. Прогони все свои старые скрипты через
shellcheck -x, разбери каждое предупреждение (не отключай директивами без понимания) и исправь. Критерий: ноль предупреждений SC2086, SC2046, SC2086.
Что почитать
Заголовок раздела «Что почитать»- GNU Bash Manual — Parameter Expansion — все формы ${} с примерами
- ShellCheck — wiki по каждому SC-коду — лучший учитель bash
- mywiki.wooledge.org — BashGuide и BashFAQ — особенно «Don’t read lines with for»
- GNU awk (gawk) manual — разделы про ассоциативные массивы и END
- jq Manual — операторы select, map, to_entries на реальных примерах