14 примеров нетривиального использования bash из рабочей практики

Введение
Bash — самая популярная командная оболочка в GNU/Linux системах, доступна также на BSD системах, macOS и Windows и широко используется как для выполнения отдельных команд, так и в решениях автоматизации разной сложности. Я работаю в CloudLinux в роли SDET Automation Engineer над средством обеспечения безопасности веб-серверов Imunify360 и в нашей кодовой базе достаточно много кода на bash. Для этой статьи я выбрал 14 интересных примеров использования bash у нас.
Важное примечание
Здесь не будет описания базового синтаксиса bash, включая переменные, условный оператор, циклы, функции, массивы и т. д. Статья не претендует на роль введения в использование bash и подразумевает наличие у читателя определённых базовых знаний этой командной оболочки.
Также здесь не будет разбора базовых команд UNIX систем (ls, grep, find и прочих) и более специализированных, возможны только небольшие комментарии об их опциях, где это необходимо для описания конкретной возможности bash.
Статья публикуется без претензий на полноту описания всех доступных интересных возможностей bash или на новизну для 100% читателей. Среди приведённых примеров есть и относительно простые и более сложные, достаточно распространённые и весьма редкие. В общем, главная цель статьи — показать нетривиальные примеры использования bash в реальной рабочей практике и смотивировать по полной использовать его широкие возможности.
Примеры команд и кода намеренно синтетические и/или упрощённые для большей наглядности, но сами конструкции взяты из реального кода.
Короткий синтаксис перенаправления каналов вывода и ошибок
Начнём с очень простого. У каждой программы есть стандартный канал ввода, вывода и ошибок. В командной оболочке доступна возможность их перенаправления в указанный файл, и в bash в дополнение к, пожалуй, знакомым всем опциям > для вывода, 2> для ошибок и < для ввода есть более продвинутые.
Например, &> используется для перенаправления и стандартного канала вывода, и стандартного канала ошибок в один и тот же файл и позволяет использовать some-command &> some-file вместо дублирующих конструкций вида some-command > some-file 2> some-file или some-command > some-file 2>&1.
Близкая ей по смыслу опция для использования в конвейерах — |&. Она позволяет вместо some-command 2>&1 | some-other-command просто написать some-command |& some-other-command. Реализуемый этой опцией функционал необходим для передачи по конвейеру содержимого канала вывода вместе с каналом ошибок, так как по умолчанию по конвейеру передаётся только стандартный вывод.
Here-строка, here-документ и here-процесс
Кроме упомянутой ранее возможности перенаправления стандартного канала ввода из файла доступны более продвинутые возможности настройки входных данных для команды.
Here-строка используется для указания входных данных для команды непосредственно внутри командной строки. Простейший пример позволяет избежать необходимости создания файла и написать просто:
$ cat <<< "hello world" hello world
Да, конкретно приведённый пример ничем не отличается от `echo “hello world”`. Но если надо передать конкретную небольшую строку в команду, работающую только со стандартным вводом, то это очень удобный способ. Сама конструкция очевидно идентична `echo SOME_STRING | other-command`, просто немного короче.
Чуть более сложный вариант — here-документ, он позволяет ввести содержимое многострочного файла непосредственно в скрипте без необходимости использования интерактивных редакторов. Вот пример использования этой конструкции создания тела для запроса к API.
$ cat <<EOF > data.json { “name”: “Data Soong”, “type”: "android” } EOF $ curl --header "Content-Type: application/json" --request POST --data @data.json https://some-host.com/api/v1/user
И последний пример варианта указания входных данных для команды — here-процесс (термин не является общеупотребительным и использован просто по аналогии с предыдущими вариантами). Этот вариант позволяет избежать необходимости в промежуточном шаге для сохранения вывода команды для использования в последующей и представляет скорее альтернативный вариант конвейеру из команд:
$ cat < <(echo "hello world") hello world
Обратите внимание на пробел между двумя < — это не опечатка, он необходим.
Отлов ошибок в конвейерах
Как вы наверняка знаете, по умолчанию bash-скрипт продолжает выполняться даже при возврате ошибки одной из команд в скрипте. Это можно решить опцией `set -e`, прекращающей выполнение скрипта в случае ошибки. Но если в скрипте есть конвейеры — их кодом завершения является код последней команды, и поэтому критичные для выполнения скрипта ошибки могут быть проигнорированы таким образом и привести к некорректному поведению скрипта. Рассмотрим четыре варианта скрипта: без опций обработки ошибок, два с set -e и с set -o pipefail.
Первый выполнится до конца:
succeeded-command-1 failed-command succeeded-command-2
Второй прервётся после failed-command
set -e succeeded-command-1 failed-command succeeded-command-2
но продолжится, если к failed-command через | добавлена команда с успешным выполнением как в третьем:
set -e succeeded-command-1 failed-command | succeeded-command-3 succeeded-command-2
Указание опции pipefail позволит избежать этого и прервать выполнение скрипта после неуспешного выполнения хотя бы одной команды из конвейера, то есть после failed-command | succeeded-command-3 здесь:
set -e set -o pipefail succeeded-command-1 failed-command | succeeded-command-3 succeeded-command-2
И ещё пара полезных опций. set -x выводит каждую команду из скрипта перед выполнением, а set -u прерывает выполнение скрипта в случае использования неинициализированной переменной.
Объединить упомянутые опции можно таким «эпиграфом» в начале файла:
set -euo pipefail
Раскрытие параметра со значением по умолчанию
Если в скрипте нужно использовать какой-то параметр из переменных окружения, но нет уверенности в его существовании или в непустом его значении, можно задать значение по умолчанию, например так:
TIMEOUT=${TIMEOUT:-60}
Если переменная окружения `TIMEOUT` существует в окружении и имеет непустое значение, будет использовано её значение, иначе будет использовано значение по умолчанию `60`.
И ещё одна конструкция полезна для одноимённых переменных, как в примерах выше, она при использовании и установит TIMEOUT в 60 и вернёт это значение, если TIMEOUT не была объявлена ранее или пуста, иначе вернёт значение переменной:
echo ${TIMEOUT:=60}
Но эта конструкция синтаксически верна только там, где ожидается возвращаемое значение, просто для присваивания она не работает. То есть только ${TIMEOUT:=60} в строке не сработает.
Группировка команд
Если необходимо сгруппировать вывод нескольких команд и передать результат для дальнейшей обработки — можно использовать фигурные скобки:
$ { echo value-1; echo value-2; echo foo; } | grep value value-1 value-2
Как видно из примера, `grep` применён к объединённому выводу всех трёх команд группы.
Подобная конструкция — с обычными скобками, её отличие в создании subshell, отдельного процесса оболочки для выполнения команд внутри скобок, это например позволяет отделить переменные, текущую директорию и прочую специфичную для сеанса оболочки информацию, например (обратите внимание, что ; после последней команды в скобках не нужна):
$ pwd; (cd /tmp; pwd); pwd /etc /tmp /etc
Как видим, текущая директория, изменённая внутри subshell, не повлияла на родительскую.
Срез
Возможно, Python срезы списков были вдохновлены таковыми в bash (кто-то может ещё вспомнить Fortran 🙂). Вот так можно например получить один символ строки из переменной FOO начиная с нулевой позиции:
$ FOO=bar $ echo "${FOO:0:1}" b
Короткая запись преобразования строки
Продолжаем тему обработки строк. С помощью конструкции ${VARIABLE#PATTERN} например можно отфильтровать из переменной VARIABLE часть строки PATTERN:
$ ASSIGNMENT=variable=value $ echo ${ASSIGNMENT#*=} value
Важное уточнение — удаляется кратчайшее соответствие шаблону от начала строки. Для удаления наибольшего соответствия # нужно заменить на ##. Также доступна обратная операция — удаление шаблона с конца строки:
$ echo ${ASSIGNMENT%=*} variable
И она тоже работает для кратчайшего совпадения, а для наибольшего — аналогично нужны два %.
Такой разный if
Хотя я упоминал, что в этой статье не будет описания базовых синтаксических конструкций bash, мимо одной решил всё-таки не проходить из-за её поистине широких возможностей.
Вот такие возможности есть в bash у оператора if:
- Без скобок — проверка кода возврата команды
 testили[]— для простых условий, конструкция является частью стандарта POSIX и доступна не только в bash, но и во многих других оболочках[[]]— более продвинутый оператор сравнения(())— для математических операций
Вот пример скрипта, где все условия вычисляются в истину:
NUM_VAR=4 STR_VAR="a b" if ls | grep sh > /dev/null then echo "Shell scripts found" fi if test $NUM_VAR = 4 then echo "Num variable matches" fi if [ "$STR_VAR" = "a b" ] then echo "String variable also matches" fi if [[ $STR_VAR == "a b" ]] then echo "String variable again matches" fi if (( NUM_VAR % 2 == 0 )) then echo "Num variable again good" fi
Соответствие регулярному выражению
В прошлом пункте было указано про расширенные возможности двойных квадратных скобок в if операторе. Остановимся на этом чуть подробнее.
Проверка соответствия регулярной строке может быть осуществлена конструкцией
[[ $VARIABLE =~ PATTERN ]]
Например:
$ FOO=bar $ [[ $FOO =~ ba. ]] && echo true || echo false true
Но нужно учитывать, что в bash реализован стандарт Extended Regular Expressions (ERE), более ограниченный чем например Perl Compatible Regular Expressions (PCRE), и вот такая конструкция с использованием класса символа уже не будет работать:
$ [[ $FOO =~ ba\w ]] && echo true || echo false false
В таких случаях нас спасёт grep:
$ ( echo $FOO | grep -Pq "ba\w" ) && echo true || echo false true
-P здесь нужна для включения режима PCRE, а -q для подавления вывода.
Корректный вывод многострочной переменной
Если в переменную сохраняется вывод команды, где есть много строк, то простой вывод переменной подавляет символы новой строки:
$ SHELL_SCRIPTS=$(ls -1 | grep sh) $ echo $SHELL_SCRIPTS script-1.sh script-2.sh
Это легко решается добавлением кавычек:
$ echo "$SHELL_SCRIPTS" script-1.sh script-2.sh
Контроль времени
Три простых команды, связанных со временем выполнения команд
sleep— приостановить выполнение скрипта на указанное время, доступны секунды, минуты, часы и даже дниtime— вывод времени выполнения команды, причём как реальное календарное, так и непосредственно “активное” время затраченное процессором для выполнения в пользовательском режиме и режиме ядраtimeout— ограничение времени выполнение команды заданных временем, также доступны разные единицы как и в `sleep`, очень полезно для сетевых команд
Сокращённое указание диапазона для перебора
В UNIX есть команда для вывода последовательности чисел с разными параметрами и это удобно для циклов:
$ for i in `seq 1 3`; do echo $i; done 1 2 3
Но в bash доступно сокращение для подобного функционала:
$ for i in {1..3}; do echo $i; done 1 2 3
Специальные переменные
В bash много всего полезного кодируется короткими языковыми конструкциями, вот примеры:
$?— код возврата последней команды$#— число аргументов, переданных функции или самому скрипту$*и$@— аргументы, переданные функции или самому скрипту, одним словом или отдельными соответственно$!— PID последней команды, запущенной в фоновом режиме$$— PID самой оболочки командной строки
Вот простейший пример файла с ними (вместо function foo доступен синтаксис foo()):
function foo { echo $# echo $* echo $@ return 1 } foo a b c echo $? ls > /dev/null & echo $! echo $$
И вот его вывод:
3 a b c a b c 1 454082 454081
Lock-файл
И последнее, пожалуй, наиболее интересное. Бывает, что нужно ограничить число разрешённых экземпляров какой-либо программы до одного, чтобы избежать конфликтов работы с разделяемыми ресурсами, например. Вот упрощённый пример такой программы, где полезной нагрузки нет, только ожидание:
echo trying to acquire lock for process $$ ( flock -w 20 9 echo begin operation in process $$ sleep 10 echo end operation in process $$ ) 9>/tmp/foo.lock
9 здесь — не какое-то магическое число, а просто код файлового дескриптора, так же как 1 — код стандартного вывода. 20 — время ожидания доступности файла блокировки, а 10 — просто длительность имитации работы, меньшая чем время ожидания, чтобы второй экземпляр скрипта из примера ниже тоже успел поработать.
И вот что происходит при одновременном запуске двух экземпляров этого скрипта:
$ bash flock.sh & [1] 454845 trying to acquire lock for process 454845 begin operation in process 454845 $ bash flock.sh & [2] 454851 trying to acquire lock for process 454851 end operation in process 454845 begin operation in process 454851 end operation in process 454851
Как видим, сначала первый процесс 454845 успешно захватывает lock-файл и начинает работу (то есть спит 😀), второй 454851 пытается занять блокировку, но получается это только после завершения работы первым скриптом.
Заключение
Возможности bash очень широки за счёт множества сильных синтаксических конструкций и доступа к огромному базовому набору команд, расширяемых установкой новых пакетов до покрытия многих и многих потребностей. Однако всё-таки это не язык программирования, и бывают такие сценарии использования или такое развитие первых версий функционала, когда лучше переключиться на язык программирования типа Python или на решение автоматизации как Ansible, иначе поддержка и развитие становятся очень и очень трудозатратными.
Полезные ссылки для дополнительного изучения
- Очень обширное руководство «Искусство программирования на языке сценариев командной оболочки» в переводе Андрея Киселева (в оригинале «Advanced Bash-Scripting Guide» от Mendel Cooper) https://www.opennet.ru/docs/RUS/bash_scripting_guide/
 - Шпаргалка по bash, больше про синтаксические конструкции самого bash https://devhints.io/bash (на английском)
 - Ещё одна шпаргалка по bash, здесь синтаксические конструкции bash демонстрируются на примерах с командами оболочки https://github.com/RehanSaeed/Bash-Cheat-Sheet (на английском)
 
