Когда вы автоматизируете какую-либо задачу, например, упаковываете свое приложение для Docker, то часто сталкиваетесь с написанием shell-скриптов. У вас может быть bash-скрипт для управления процессом упаковки и другой скрипт в качестве точки входа в контейнер. По мере возрастающей сложности при упаковке меняется и ваш shell-скрипт.

Все работает хорошо.

И вот однажды shell-скрипт совершает что-то совсем неправильное.

Тогда вы осознаете свою ошибку: bash, и вообще shell-скрипты, в основном, по умолчанию не работают. Если с самого начала не проявить особую осторожность, любой shell-скрипт достигнув определенного уровня сложности почти гарантированно будет глючным... а доработка функций корректности будет довольно затруднительна.

Проблема с shell-скриптами

Давайте сосредоточимся на bash в качестве конкретного примера.

Проблема №1: Ошибки не останавливают выполнение

Рассмотрим следующий shell-скрипт:

#!/bin/bash
touch newfile
cp newfil newfile2  # Deliberate typo
echo "Success"

Как вы думаете, что произойдет, когда мы его запустим?

$ bash bad1.sh 
cp: cannot stat 'newfil': No such file or directory
Success

Скрипт продолжал выполняться, даже если команда завершилась неудачно! Сравните это с Python, где исключение не позволяет выполнить последующий код.

Вы можете решить эту проблему, добавив set -e в начало shell-скрипта:

#!/bin/bash
set -e
touch newfile
cp newfil newfile2  # Deliberate typo, don't omit!
echo "Success"

А теперь:

$ bash bad1.sh 
cp: cannot stat 'newfil': No such file or directory

Проблема №2: Неизвестные переменные не вызывают ошибок

Далее рассмотрим следующий скрипт, который пытается добавить каталог в переменную окружения PATH. PATH — это способ определения местоположения исполняемых файлов.

#!/bin/bash
set -e
export PATH="venv/bin:$PTH"  # Typo is deliberate
ls

Когда мы его запускаем:

$ bash bad2.sh 
bad2.sh: line 4: ls: command not found

Он не может найти ls, потому что мы допустили опечатку, написав $PTH вместо $PATH, при этом bash не жалуется на неизвестную переменную окружения. В Python вы получили бы исключение NameError; на скомпилированном языке код даже не компилировался бы. В bash скрипт просто продолжает выполняться; что может пойти не так?

Решением является параметр -u:

#!/bin/bash
set -eu
export PATH="venv/bin:$PTH"  # Typo is deliberate
ls

А теперь bash нашел опечатку:

$ bash bad2.sh
bad2.sh: line 3: PTH: unbound variable

Проблема №3: Пайпы не отлавливают ошибки

Мы думали, что разобрались с неработающими командами с помощью set -e, но это не решило всех проблем:

#!/bin/bash
set -eu
nonexistentprogram | echo
echo "Success!"

и когда мы запускаем его:

$ bash bad3.sh 
bad3.sh: line 3: nonexistentprogram: command not found

Success! 

Решение set -o pipefail:

#!/bin/bash
set -euo pipefail
nonexistentprogram | echo
echo "Success!"

Теперь:

$ bash bad3.sh 
bad3.sh: line 3: nonexistentprogram: command not found

На данный момент мы имплементировали (большую часть) неофициального "строгого" режима bash. Но и этого все еще недостаточно.

Проблема №4: Subshells работают странно

Используя синтаксис $(), вы можете запустить subshell (подоболочку):

#!/bin/bash
set -euo pipefail
export VAR=$(echo hello | nonexistentprogram)
echo "Success!"

Когда мы ее запустим:

$ bash bad4.sh 
bad4.sh: line 3: nonexistentprogram: command not found
Success!

Что происходит? Ошибки в подоболочках не воспринимаются, если они являются частью аргументов команды. Это означает, что ошибка в подоболочке просто отбрасывается.

Единственное исключение — это непосредственная установка переменной, поэтому нам нужно написать код следующим образом:

#!/bin/bash
set -euo pipefail
VAR=$(echo hello | nonexistentprogram)
export VAR
echo "Success!"

Теперь наша программа работает правильно:

$ bash good4.sh 
good4.sh: line 3: nonexistentprogram: command not found

Возможно, это достаточная демонстрация плохого поведения bash, но далеко не полная.

О некоторых нежелательных причинах для использования shell-скриптов

Каковы могут быть причины, по которым вы все равно захотите использовать shell-скрипты?

Плохая причина №1: Это всегда там есть!

Практически каждая вычислительная среда Unix имеет базовую оболочку (shell). Поэтому, если вы пишете какие-то скрипты для упаковки или запуска, возникает соблазн использовать инструмент, который уже там присутствует.

Дело в том, если вы упаковываете Python-приложение, то практически наверняка в среде разработки, CI и среде выполнения будет установлен Python. Так почему бы не использовать язык программирования, который по умолчанию обрабатывает ошибки?

По большому счету, практически каждый язык программирования с достаточно большой пользовательской базой содержит какую-то скрипт-ориентированную библиотеку или идиомы. В Rust, например, есть xshell, а также другие библиотеки. Так что в большинстве случаев вы можете использовать свой язык программирования вместо shell-скрипта.

Плохая причина №2: Просто пишите правильный код!

В теории, если вы знаете, что делаете, сохраняете концентрацию и не забываете о бойлерплейте, то можете писать правильные shell-скрипты, даже довольно сложные. А также написать юнит-тесты.

На практике:

  • Вы, вероятно, работаете не один; вряд ли каждый в вашей команде обладает соответствующим опытом.

  • Любой человек устает, отвлекается и допускает ошибки.

  • Почти в каждом сложном shell-скрипте, который я видел, отсутствовал вызов set -euo pipefail, и добавить его постфактум довольно сложно (обычно невозможно).

  • Не помню, чтобы я когда-либо видел автоматизированный тест для shell-скрипта. Наверняка они существуют, но встречаются довольно редко.

Плохая причина №3: Shellcheck обнаружит все эти ошибки!

Если вы пишете shell-программы, shellcheck — очень полезный способ поиска ошибок. К сожалению, его одного недостаточно.

Рассмотрим следующую программу:

#!/bin/bash
echo "$(nonexistentprogram | grep foo)"
export VAR="$(nonexistentprogram | grep bar)"
cp x /nosuchdirectory/
echo "$VAR $UNKNOWN_VAR"
echo "success!"

Если мы запустим эту программу, она выдаст "success!", несмотря на то, что у нее 4 отдельные проблемы (как минимум):

$ bash bad6.sh 
bad6.sh: line 2: nonexistentprogram: command not found

bad6.sh: line 3: nonexistentprogram: command not found
cp: cannot stat 'x': No such file or directory
 
success!

Как работает shellcheck? Он выявляет некоторые проблемы... но не все:

  1. Если вы запустите shellcheck, он укажет на наличие неполадок в export.

  2. Если вы запустите shellcheck -o all, чтобы запустить все проверки, он также укажет на проблему с echo "$(nonexistentprogram ...)". Это при условии, что вы используете версию v0.8, которая была выпущена в ноябре 2021 года. Более ранние версии не имели такой проверки, поэтому любой дистрибутив Linux, предшествующий этой версии, выдаст вам shellcheck, который не обнаружит эту проблему.

  3. В нем не предлагается set -euo pipefail.

Если вы полагаетесь на shellcheck, я настоятельно рекомендую обновиться и убедиться, что вы запускаете его с параметром -o all.

Прекратите писать shell-скрипты

В определенных ситуациях shell-скрипты вполне уместны:

  • Для разовых скриптов, которые вы администрируете вручную; здесь можно обойтись методами попроще.

  • Иногда у вас действительно нет гарантий, что доступен другой язык программирования, и вам нужно использовать shell, чтобы все заработало.

  • В достаточно простых случаях, когда требуется выполнить несколько команд последовательно, без подоболочек, условной логики или циклов, достаточно использовать set -euo pipefail (и обязательно используйте shellcheck -o all).

Как только вы обнаружите, что дополнительно делаете что-то сверх этого, начните использовать менее подверженный ошибкам язык программирования. А учитывая, что большая часть программного обеспечения имеет тенденцию со временем расти, лучше всего начинать с чего-то менее ломкого.


Материал подготовлен для будущих учащихся на курсах "Administrator Linux. Professional" и "Administrator Linux. Advanced". Всех желающих приглашаем на бесплатные demo-заняти:

  1. «Puppet — система контроля конфигураций». На занятии будет дан обзор архитектуры puppet, его основных инструментов и методов их использования, на практике будет разобран вопрос установки, первоначальной настройки сервера и клиента, а также пример использования: настройка служб, конфигурационных файлов, установка пакетов. Регистрация

  2. «Введение в Docker». На занятии мы рассмотрим основы контейнеризации и ее отличие от виртуализации, плавно перейдем к рассмотрению самого популярного на данный момент инструмента контейнеризации Docker — узнаем, из каких основных компонентов и сущностей он состоит, и как они взаимодействуют между собой. Регистрация