Обеспечение переносимости Bash-скриптов в различных системах

Пишите переносимые Bash-скрипты, учитывающие различия GNU, BSD и BusyBox в средах Linux, macOS и CI.

Обеспечение переносимости Bash-скриптов между разными системами

Писать Bash-скрипты, которые работают на вашем ноутбуке, Linux-сервере и CI-раннере, сложнее, чем кажется. Переносимость Bash-скриптов обычно нарушается из-за мелких различий: флаг sed -i, работающий в Linux, но не работающий в macOS; опция date, существующая только в GNU coreutils; или скрипт, предполагающий, что /bin/bash — это та версия, которую вы тестировали.

Основная сложность в том, что Bash — лишь часть окружения. Linux обычно поставляется с утилитами GNU. macOS — с утилитами BSD. Контейнеры на основе BusyBox могут предоставлять более урезанные реализации с меньшим количеством опций. Ваш скрипт должен чётко указывать, что ему требуется.

Это руководство фокусируется на Bash-скриптах, а не на строго POSIX-совместимых sh-скриптах. Если вам нужна настоящая переносимость для /bin/sh, полностью избегайте синтаксиса, специфичного для Bash, и тестируйте с такими оболочками, как dash.

Начните с чёткого контракта оболочки

Используйте shebang, соответствующий вашим намерениям. Если скрипту требуется Bash, укажите это:

#!/usr/bin/env bash

/usr/bin/env находит Bash через $PATH, что полезно, когда пользователи устанавливают более новую версию Bash вне /bin. Если на ваших production-хостах требуется фиксированный путь к интерпретатору, задокументируйте и применяйте этот путь.

Строгий режим помогает выявить многие ошибки на раннем этапе, но это не панацея:

#!/usr/bin/env bash

set -euo pipefail
IFS=$'\n\t'

Эти опции помогают, но с оговорками:

  • -e: Завершает работу, когда многие простые команды возвращают ненулевой статус.
  • -u: Обрабатывает неопределённые переменные как ошибки.
  • pipefail: Заставляет конвейер завершаться ошибкой, если любая команда в конвейере завершилась неудачно.

Обрабатывайте ожидаемые ошибки явно:

if ! grep -q "ready" "$log_file"; then
    echo "Сервис ещё не готов"
fi

Знайте свою версию Bash

Не полагайтесь случайно на функцию Bash, которой нет в ваших целевых системах. В macOS исторически поставляется более старая версия Bash в /bin/bash, в то время как многие дистрибутивы Linux поставляют более новые версии.

К функциям, которые следует использовать с осторожностью, относятся:

  • Ассоциативные массивы.
  • Расширенные шаблоны, такие как **.
  • Подстановка процессов, например <(command).
  • Новое поведение раскрытия параметров.

Если вам требуется минимальная версия Bash, проверьте её в начале:

if (( BASH_VERSINFO[0] < 4 )); then
    echo "Этот скрипт требует Bash версии 4 или новее." >&2
    exit 1
fi

Учитывайте различия GNU, BSD и BusyBox

Самые большие проблемы с переносимостью часто возникают из-за внешних команд, а не из-за самого Bash.

sed -i

GNU sed принимает -i без расширения для резервной копии. BSD sed в macOS требует аргумент расширения после -i, даже если это расширение — пустая строка.

file="data.txt"
pattern="s/error/success/g"

case "$(uname -s)" in
    Darwin)
        sed -i '' "$pattern" "$file"
        ;;
    *)
        sed -i "$pattern" "$file"
        ;;
esac

Для критически важных скриптов более безопасный шаблон — записать во временный файл, а затем переместить его на место. Это позволяет вообще не полагаться на редактирование на месте.

date

Работа с датами отличается в разных системах:

Цель GNU date BSD date в macOS
30 дней назад date -d "30 days ago" +%Y%m%d date -v-30d +%Y%m%d

Если вашему скрипту нужны сложные вычисления с датами, используйте согласованную зависимость, например Python, или требуйте GNU coreutils в macOS и вызывайте gdate явно. Не предполагайте молча, что date -d существует.

grep, find и xargs

По возможности придерживайтесь широко поддерживаемых опций:

  • Используйте grep -E вместо egrep.
  • Избегайте grep -P, если вы не проверяете наличие GNU grep с поддержкой PCRE.
  • Будьте осторожны с предикатами find, которые различаются в реализациях GNU и BSD.
  • Для имён файлов предпочитайте конвейеры с нулевым разделителем, когда это поддерживается:
find "$root" -type f -name '*.log' -print0 | xargs -0 rm -f

Управляйте зависимостями и путями

Используйте $PATH для обычного поиска команд, но проверяйте необходимые инструменты перед началом работы:

check_dependency() {
    if ! command -v "$1" >/dev/null 2>&1; then
        echo "Ошибка: требуемая команда '$1' не найдена." >&2
        exit 1
    fi
}

check_dependency jq
check_dependency curl

Предпочитайте command -v вместо which, так как это встроенная команда Bash и ведёт себя более предсказуемо в скриптах.

Заключайте переменные в кавычки, если вы не намеренно не хотите разделения слов:

cp "$source_file" "$target_dir/"

Это важно для таких путей, как Project Files/report.txt, а также защищает от раскрытия подстановочных знаков в неожиданных входных данных.

Безопасно используйте временные файлы

Используйте mktemp для временной работы. Простой переносимый шаблон — создать один временный каталог и поместить файлы внутрь него:

tmp_dir=$(mktemp -d)
trap 'rm -rf "$tmp_dir"' EXIT

tmp_file="$tmp_dir/output.txt"
some_command > "$tmp_file"

Одинарные кавычки в trap предотвращают раскрытие $tmp_dir до момента выполнения trap. Поскольку переменная всё ещё в области видимости, очистка удаляет правильный каталог.

Следите за окончаниями строк и регистром файловой системы

Скрипты, отредактированные в Windows, могут использовать окончания строк CRLF. Типичный симптом:

/usr/bin/env: bash\r: No such file or directory

Настройте свой редактор для сохранения shell-скриптов с окончаниями LF или запускайте dos2unix в процессе сборки.

Также помните, что большинство файловых систем Linux по умолчанию чувствительны к регистру, в то время как стандартные настройки APFS в macOS часто нечувствительны к регистру. Если ваш скрипт записывает Config.yml, а позже читает config.yml, он может работать на вашем Mac, но выдать ошибку на Linux.

Тестируйте на поддерживаемых системах

Лучшая проверка переносимости — небольшая тестовая матрица:

  • Linux с утилитами GNU.
  • macOS с утилитами BSD.
  • Минимальные контейнеры, если ваш скрипт работает в средах Alpine или BusyBox.

Также запускайте ShellCheck. Он не выявит все проблемы платформы, но поймает многие проблемы с кавычками, неопределёнными переменными и хрупкими командами до того, как это сделают ваши пользователи.

Вывод

Переносимость Bash-скриптов достигается за счёт явного указания ваших предположений. Выберите оболочку, проверьте зависимости, заключайте переменные в кавычки, избегайте флагов, специфичных для GNU, если они не требуются, и тестируйте на тех же операционных системах, которые используют ваши пользователи. Небольшая CI-матрица с Linux и macOS выявляет большинство ошибок переносимости до того, как ваша автоматизация попадёт в production.