Картина знакома многим. Скрипт годами исправно трудился на CentOS, его перенесли на Ubuntu - и он рассыпался с непонятными ошибками вроде жалобы на унарный оператор или ненайденную команду. Код не менялся ни на строку. Поменялась оболочка, которая его исполняет, хотя в заголовке скрипта стоит всё та же привычная строчка. Виновник - расхождение между расширениями bash и стандартом POSIX, и точкой, где это расхождение больно бьёт, чаще всего оказывается оболочка dash в Ubuntu.
Корень в том, что bash умеет гораздо больше, чем требует стандарт. Эти приятные сверх-возможности зовут bashism - конструкции, которых нет в POSIX. Пока скрипт исполняется самим bash, они работают. Но стоит запустить его под более скромной оболочкой, придерживающейся стандарта, как незнакомый ей синтаксис вызывает сбой. Разберём, почему так вышло в Ubuntu, какие именно конструкции ломаются, и как писать так, чтобы скрипт пережил переезд.
Ubuntu подменяет sh на быстрый dash, и заголовок скрипта врёт
Чтобы понять проблему, нужно увидеть одну тонкость заголовка скрипта. Первая строка вида с указанием оболочки определяет, кто будет исполнять файл. И здесь Ubuntu расставляет ловушку. Заголовок, ссылающийся на стандартную оболочку sh, во многих дистрибутивах указывает на bash. Но в Ubuntu и Debian та же ссылка ведёт на dash - компактную и быструю оболочку, которая придерживается только POSIX и не знает расширений bash.
#!/bin/sh
# В CentOS это запустит bash, в Ubuntu - dash. Один заголовок, разные оболочки.
Решение перевести стандартную оболочку на dash приняли ради скорости. Dash меньше, легче и стартует быстрее bash, что ускоряет загрузку системы, где выполняется множество служебных скриптов. Расплата за это - именно та поломка переносимых на вид скриптов, которые втихую полагались на bashism, не объявляя об этом честно.
Ключевой вывод сразу: заголовок с sh обещает POSIX-совместимость, а заголовок с явным указанием bash обещает расширения bash. Если скрипт использует bashism, его заголовок обязан явно называть bash. Иначе обещание расходится с содержимым, и на машине с dash наступает расплата.
#!/bin/bash
# Честный заголовок: скрипт заявляет, что ему нужен именно bash
Двойные квадратные скобки против одинарных
Самый частый bashism - расширенный условный оператор в двойных квадратных скобках. Bash добавил его как более удобную и безопасную форму проверок. В POSIX его нет вовсе, там есть только одинарные скобки, которые на деле являются командой проверки.
# Bashism: dash такого не знает
if [[ $a -lt $b ]]; then ...
# POSIX: работает везде
if [ "$a" -lt "$b" ]; then ...
Разница не только в числе скобок. Двойные скобки прощают незакавыченные переменные и не разваливаются на пустых значениях. Одинарные требуют дисциплины: переменные нужно брать в кавычки, иначе пустое значение превращает проверку в синтаксически неполную и роняет её с той самой ошибкой про унарный оператор.
# Опасно в POSIX: при пустой переменной проверка ломается
if [ $переменная = "значение" ]; then ...
# Классический приём защиты от пустоты
if [ "x$переменная" = "xзначение" ]; then ...
Приём с приставкой - дописать одинаковый символ к обеим сторонам сравнения - старый способ застраховаться от пустой переменной в POSIX-скриптах. Он гарантирует, что по обе стороны от знака равенства всегда стоит непустая строка, и проверка не разваливается. Тот же эффект даёт аккуратное закавычивание переменных, что сегодня предпочтительнее.
Оператор сравнения, ключевое слово source и эхо с флагами
Целая россыпь мелких bashism подстерегает в повседневных конструкциях. Двойной знак равенства для сравнения строк - расширение bash. POSIX признаёт только одинарный знак равенства внутри команды проверки.
# Bashism
[ "$a" == "$b" ]
# POSIX
[ "$a" = "$b" ]
Команда подключения другого файла через ключевое слово source - тоже bashism. POSIX для той же цели использует точку, лаконичную и переносимую.
# Bashism
source ./настройки.conf
# POSIX
. ./настройки.conf
Эта замена встречается в реальных исправлениях системного кода особенно часто, потому что подключение конфигурации - типичное действие, и именно на нём скрипты спотыкались при переезде на dash.
Отдельная головная боль - команда вывода с флагами. Привычка писать вывод с флагом интерпретации управляющих последовательностей ненадёжна, потому что поведение этой команды разнится между оболочками. POSIX советует для форматированного вывода с переносами и табуляциями использовать printf, чья работа предсказуема везде.
# Ненадёжно: поведение флага разнится между оболочками
echo -e "строка один\nстрока два"
# Переносимо: printf ведёт себя одинаково
printf 'строка один\nстрока два\n'
Массивы, локальные переменные и случайные числа просто отсутствуют
Некоторые bashism не имеют POSIX-замены вовсе, потому что соответствующей возможности в стандарте нет в принципе. Массивы - яркий пример. Индексные и ассоциативные массивы целиком относятся к расширениям bash. Скрипт, опирающийся на массивы, переносимым на чистый POSIX-shell быть не может, и тут выбора нет: либо честный заголовок с bash, либо переработка логики без массивов.
То же касается ассоциативных массивов, объявления переменных как локальных внутри функций через ключевое слово local, встроенной переменной случайного числа и арифметического разворачивания в двойных скобках. Все они - удобства bash, отсутствующие в скромном POSIX-наборе.
# Всё это bashism без прямого POSIX-аналога
массив=(один два три) # массивы
local перем="значение" # локальные переменные в функции
число=$RANDOM # встроенный генератор случайных
((счётчик++)) # арифметика в двойных скобках
Здесь и проходит главная развилка. Если скрипт всерьёз нуждается в массивах, локальных переменных или другой роскоши bash, попытка втиснуть его в POSIX обернётся уродливыми обходными путями. Разумнее честно объявить зависимость от bash в заголовке. Переносимость нужна не любой ценой - она оправдана для системных и установочных скриптов, которым предстоит бегать по самым разным машинам, но избыточна для скрипта, живущего в одном известном окружении.
Инструмент checkbashisms находит расхождения заранее
Ловить bashism вручную, перечитывая код глазами, утомительно и ненадёжно. Для этого есть специальный инструмент - checkbashisms. Это проверяющая программа, которая просматривает скрипт с заголовком sh и предупреждает обо всех найденных конструкциях, выходящих за рамки POSIX.
# Установка в Debian и Ubuntu
sudo apt install devscripts
# Проверка скрипта на bashism
checkbashisms мой_скрипт.sh
Инструмент укажет конкретные строки и конструкции, которые сломаются под dash, - бесценно при подготовке скрипта к переносу. Под капотом он опирается на определение bashism как возможности оболочки, не обязательной к поддержке стандартом POSIX. Рядом с ним часто применяют и более общий статический анализатор shell-скриптов, который ловит не только bashism, но и широкий круг других типичных ошибок.
Существует и простой способ проверить скрипт на практике: запустить его явно через dash и посмотреть, не посыплются ли ошибки. Если скрипт отработал под dash без сбоев, он, скорее всего, чист от bashism.
# Прямая проверка: запуск под dash вместо sh
dash мой_скрипт.sh
Как выбрать сторону осознанно
Картина складывается в ясную стратегию, и сводится она к честности заголовка. Первый вопрос - нужна ли скрипту переносимость на самом деле. Системные скрипты, установщики, код, который поедет на неизвестные машины и встроенные системы, выигрывают от строгой POSIX-совместимости, потому что dash, busybox и прочие минимальные оболочки встречаются повсюду.
Для таких скриптов держат заголовок с sh, избегают двойных скобок в пользу одинарных с обязательным закавычиванием переменных, заменяют двойной знак равенства на одинарный, source на точку, вывод с флагами на printf, и отказываются от массивов с локальными переменными. Проверку доверяют инструменту checkbashisms, а не своей памяти.
Для скриптов, живущих в известном окружении, где bash гарантированно есть, правильнее не насиловать код ради переносимости, а честно объявить зависимость заголовком с явным указанием bash. Тогда все его удобства - массивы, расширенные проверки, арифметика - доступны без оговорок, и никакой dash их не сломает, потому что исполнять скрипт будет именно bash.
Главное, что стоит унести: проблема не в самих bashism и не в dash, а в расхождении между обещанием заголовка и содержимым скрипта. Заголовок с sh, набитый расширениями bash, - это бомба замедленного действия, ждущая переезда на Ubuntu. Стоит привести заголовок в соответствие с реальными потребностями кода, и загадочные поломки при переносе исчезают. Скрипт честен ровно настолько, насколько его первая строка соответствует тому, что написано ниже.