Bash прощает многое: можно писать кривые скрипты годами, и они будут работать. Кривизна вылезает, как правило, в самый неподходящий момент. В статье рассмотрим пять ошибок, которые встречаются даже в проектах с опытными админами, потому что поведение Bash в этих местах неинтуитивно и плохо документировано в одном месте.
Pipe в while‑цикл теряет переменные
Классический скрипт‑обходчик файлов:
#!/bin/bash counter=0 find /var/log -name '*.log' | while read -r f; do counter=$((counter + 1)) done echo "Found $counter files"
Что ожидается: counter содержит число найденных файлов. Что выводится: Found 0 files.
Причина — pipe | в Bash порождает подоболочку (subshell) для правой части. Цикл while крутится в этой подоболочке, и counter в ней увеличивается, но как только цикл закончился, подоболочка умирает, а изменения в переменной с ней. Родительская оболочка видит counter=0.
Лечится через process substitution, который позволяет обойтись без pipe:
#!/bin/bash counter=0 while read -r f; do counter=$((counter + 1)) done < <(find /var/log -name '*.log') echo "Found $counter files"
Конструкция < <(...) подаёт вывод команды на stdin цикла, но сам цикл выполняется в основной оболочке, и все изменения переменных сохраняются. Альтернатива — shopt -s lastpipe в Bash 4.2 и выше, который заставляет последнюю команду pipeline выполниться в основной оболочке, но эта опция влияет на весь скрипт, и применять её имеет смысл с пониманием побочных эффектов.
set ‑e не срабатывает там, где вы рассчитываете
set -e (он же errexit) выглядит как «упасть на любой ошибке». На практике у него много исключений.
set -e не падает, если команда стоит в условии if, while, until, либо слева от && или ||, либо запущена в pipeline без set -o pipefail, либо является аргументом !.
Пример, который выглядит безопасно:
#!/bin/bash set -e risky_command || handle_error do_other_work
Если risky_command упадёт, выполнится handle_error, и set -e НЕ остановит скрипт. Дальше пойдёт do_other_work, как будто всё хорошо, а это часто не то, что хотел автор.
Использовать set -e имеет смысл вместе с set -o pipefail, помнить список исключений и для критичных мест ставить явные проверки if ! cmd; then echo "..." >&2; exit 1; fi. Полагаться на set -e как на полноценную обработку ошибок — неверная стратегия.
Полезная пара для верхней части любого нетривиального скрипта:
set -euo pipefail IFS=$'\n\t'
Что добавляет: set -u — падать при использовании необъявленной переменной. pipefail заставляет pipeline возвращать первую ненулевую ошибку, а не код последней команды. IFS=$'\n\t' убирает пробел из разделителей, что снимает целый класс багов с word splitting.
((x++)) валит скрипт с set ‑e
В арифметическом расширении есть тонкая засада: ((expr)) возвращает ненулевой код, если результат выражения — ноль, и это сделано намеренно, чтобы ((x)) можно было использовать как тест на ноль.
Но это значит, что ((counter++)) при counter=0 вернёт код 1 и под set -e уронит скрипт:
#!/bin/bash set -e counter=0 ((counter++)) # скрипт падает здесь, потому что counter был 0 echo "Survived"
Survived в выводе вы не увидите, потому что counter++ означает «вернуть текущее значение (0), потом увеличить», текущее значение 0 становится результатом выражения, код возврата 1, и set -e срабатывает.
Лечится использованием ((counter+=1)) (результат всегда ненулевой) или присваиванием через $((...)):
((counter+=1)) # или counter=$((counter + 1)) # или, если очень хочется ++, с явным подавлением: ((counter++)) || true
Третий вариант — самый адекватный: вы явно говорите, что знаете про этот return code и сознательно его игнорируете.
Trap не наследуется в subshell
Trap, установленный в основной оболочке, не виден в подоболочках:
#!/bin/bash cleanup() { rm -f /tmp/work.lock; } trap cleanup EXIT ( # subshell do_something_that_might_fail )
Если do_something_that_might_fail упадёт внутри подоболочки, cleanup не вызовется по выходе из подоболочки — он вызовется только по выходу из родительской оболочки. Это, как правило, не то, что хочет автор. Аналогично работает с bash -c '...' и ssh user@host 'script' — это новые процессы, родительские trap'ы туда не приходят.
Trap внутри подоболочки объявляется явно, если нужно, чтобы он сработал именно на её границе:
( trap 'rm -f /tmp/sub.lock' EXIT do_something_that_might_fail )
Альтернатива — использовать set -E в комбинации с trap '...' ERR, чтобы ERR‑trap наследовался функциями и подоболочками. С EXIT‑trap наследования нет, его нужно ставить вручную внутри каждой подоболочки, где он нужен.
Глобы без совпадений возвращают сами себя
В дефолтном Bash, если глоб не находит ни одного файла, он подставляется в команду как литерал:
for f in /var/log/*.gz; do process "$f" done
Если в /var/log нет ни одного .gz‑файла, цикл выполнится ровно один раз, и $f будет равно строке /var/log/*.gz — чему‑то такому, чего на диске не существует. Команда process получит «файл», который не открывается, и поведение скрипта непредсказуемо.
Лечится через shopt -s nullglob (несовпадающий глоб становится пустым списком) либо failglob (несовпадающий глоб считается ошибкой):
shopt -s nullglob for f in /var/log/*.gz; do process "$f" done # если файлов нет, цикл просто не выполнится
nullglob — самое практичное решение для большинства скриптов, и включать его имеет смысл в шапке наряду с set -euo pipefail.
Итого
Bash ведёт себя не так, как ожидает человек, привыкший к языкам с предсказуемой семантикой. Поведение формально документировано в man bash, но прочитать всю эту документацию целиком мало кто способен.
Минимальная защита — шапка скрипта в виде:
#!/bin/bash set -euo pipefail shopt -s nullglob IFS=$'\n\t'
Это закрывает три из пяти описанных проблем. Оставшиеся две — ((x++)) под set -e и pipe в while нужно знать в лицо и отлавливать на ревью, потому что синтаксически они выглядят совершенно нормально.
И отдельно, если скрипт длиннее ста строк или супер важен для деплоя, имеет смысл задуматься, не пора ли переписать его на Python или Go. Bash хорош для тонкой автоматизации (короткие склейки команд, обёртки над утилитами), а для серьёзной логики его пределы видно довольно быстро.
Bash — хороший индикатор уровня: вроде бы простой синтаксис, но за ним быстро всплывают процессы, окружение, права, память, обработка ошибок и особенности самой оболочки.
Если хотите проверить, насколько уверенно вы ориентируетесь в таких вещах, можно пройти вступительное тестирование по Linux продвинутого уровня. Это быстрый способ оценить текущий уровень и понять, какие темы уже закрыты, а где ещё есть пробелы.
Ещё можно присмотреться к открытым урокам — они бесплатные, проходят вживую, и на них можно познакомиться с преподавателями‑практиками, задать вопросы и посмотреть, как устроен формат обучения:
4 июня, 20:00 — «Продвинутый Bash». Записаться
Обсудим расширенные возможности Bash как языка программирования: массивы, отладку скриптов, функции и другие практические вещи.22 июня, 20:00 — «Память в Linux. Cache, swap, dirty pages». Записаться
Объясним, как работает память в Linux и как читать показатели использования RAM, swap и кэша.25 июня, 19:00 — День открытых дверей курса «Администратор Linux. Продвинутый уровень». Записаться
Разберем, зачем Senior‑инженеру глубокое понимание механизмов изоляции, cgroups, namespaces и других системных тем.
А чтобы не пропускать новые открытые уроки, разборы и материалы для IT‑специалистов, можно подписаться на канал OTUS в MAX.
Комментарии (2)

Granulex
02.06.2026 10:35Шапка + закрывает три из пяти проблем – и это уже огромный прогресс. Но есть шестая, которую статья не упомянула: . Ключевое слово всегда возвращает 0 и маскирует ошибку внутри подстановки. Под скрипт пройдёт мимо падения молча. Лечится разделением: .
RumataEstora
Из 5 описанных ситуаций действительной проблемой является только третья с
(( x++ )).Остальные - особенности любого шелла или особенность баша, которая задокументирована. Но когда что-то работает не так как задумано, надо хоть чуть-чуть знать, что и где искать. Можно было бы сказать, что это проблема шелла, но это касается любого инструмента, даже питон, го и т.д..
1) конвейеры и циклы. Вообще не проблема - то специфика любого шелла
Если переменная нужна по выходу из цикла, то лучше делать универсально (поддерживается большинством оболочек, включая posix shell):
Почему так? Потому что конструкуция
< <( ... )является башизмом и работает надежно только в bash. Но если очень хочется в баш, то можно порекомендовать herestring:... <<<"$( some_cmd )".2) И снова не проблема.
set -eработает так потому, что так определено (см. https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#set)А комбинация вида
set -e ; risky_cmd || another_cmdработает именно так, как и задумал автор: если первая команда облажалась, то запустить вторую без падения в ошибку.3)
(( x++ ))А это действительно глючная команда и ею лучше не пользоваться.
4) Trap не наследуется в subshell
5) Глобы без совпадений возвращают сами себя
Об это по началу спотыкаешься, пока внимательно не почитаешь мануал конкретно по trap, shopt, set.
Если мне не изменяет память
shopt,set -Eиtrap ERR- это чистые башизмы, часть которых перетекли в другие оболочки.