Когда вы автоматизируете какую-либо задачу, например, упаковываете свое приложение для 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
? Он выявляет некоторые проблемы... но не все:
Если вы запустите
shellcheck
, он укажет на наличие неполадок в export.Если вы запустите
shellcheck -o all
, чтобы запустить все проверки, он также укажет на проблему сecho "$(nonexistentprogram ...)"
. Это при условии, что вы используете версию v0.8, которая была выпущена в ноябре 2021 года. Более ранние версии не имели такой проверки, поэтому любой дистрибутив Linux, предшествующий этой версии, выдаст вамshellcheck
, который не обнаружит эту проблему.В нем не предлагается
set -euo pipefail
.
Если вы полагаетесь на shellcheck
, я настоятельно рекомендую обновиться и убедиться, что вы запускаете его с параметром -o all
.
Прекратите писать shell-скрипты
В определенных ситуациях shell-скрипты вполне уместны:
Для разовых скриптов, которые вы администрируете вручную; здесь можно обойтись методами попроще.
Иногда у вас действительно нет гарантий, что доступен другой язык программирования, и вам нужно использовать
shell
, чтобы все заработало.В достаточно простых случаях, когда требуется выполнить несколько команд последовательно, без подоболочек, условной логики или циклов, достаточно использовать
set -euo pipefail
(и обязательно используйтеshellcheck -o all
).
Как только вы обнаружите, что дополнительно делаете что-то сверх этого, начните использовать менее подверженный ошибкам язык программирования. А учитывая, что большая часть программного обеспечения имеет тенденцию со временем расти, лучше всего начинать с чего-то менее ломкого.
Материал подготовлен для будущих учащихся на курсах "Administrator Linux. Professional" и "Administrator Linux. Advanced". Всех желающих приглашаем на бесплатные demo-заняти:
«Puppet — система контроля конфигураций». На занятии будет дан обзор архитектуры puppet, его основных инструментов и методов их использования, на практике будет разобран вопрос установки, первоначальной настройки сервера и клиента, а также пример использования: настройка служб, конфигурационных файлов, установка пакетов. Регистрация
«Введение в Docker». На занятии мы рассмотрим основы контейнеризации и ее отличие от виртуализации, плавно перейдем к рассмотрению самого популярного на данный момент инструмента контейнеризации Docker — узнаем, из каких основных компонентов и сущностей он состоит, и как они взаимодействуют между собой. Регистрация