[Перевод] Лучшие практики bash-скриптов: краткое руководство по надежным и производительным скриптам bash

khn34uc6flnhocn0ppwes0gxazo.jpeg
Shell wallpaper by manapi

Отладка сценариев bash — это как поиск иголки в стоге сена, тем более, когда новые дополнения появляются в существующей кодовой базе без своевременного рассмотрения вопросов структуры, логирования и надежности. В таких ситуациях можно оказаться как из-за собственных ошибок, так и при управлении сложными нагромождениями скриптов.

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

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

Обработчики ловушек


Большинство скриптов bash, с которыми я сталкивался, никогда не использовали эффективный механизм очистки, когда во время выполнения скрипта происходит что-то неожиданное.

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

function handle_exit() {
  // Add cleanup code here
  // for eg. rm -f "/tmp/${lock_file}.lock"
  // exit with an appropriate status code
}
  
// trap  
trap handle_exit 0 SIGHUP SIGINT SIGQUIT SIGABRT SIGTERM


trap — это встроенная команда оболочки, помогающая вам зарегистрировать функцию очистки, которая вызывается в случае каких-либо сигналов. Однако следует соблюдать особую осторожность с такими обработчиками, как SIGINT, который вызывает прерывание сценария.

Кроме того, в большинстве случаев следует ловить только EXIT, но идея в том, что вы действительно можете настроить поведение скрипта для каждого отдельного сигнала.

Встроенные функции set — быстрое завершение при ошибке


Очень важно реагировать на ошибки, как только они возникают, и быстро прекращать выполнение. Ничего не может быть хуже, чем продолжать выполнение команды вроде такой:

rm -rf ${directory_name}/*


Обратите внимание, что переменная directory_name не определена.

Для обработки таких сценариев важно использовать встроенные функции set, такие как set -o errexit, set -o pipefail или set -o nounset в начале скрипта. Эти функции гарантируют, что ваш скрипт завершит работу, как только он встретит любой ненулевой код завершения, использование неопределенных переменных, неправильные команды, переданные по каналу и так далее:

#!/usr/bin/env bash

set -o errexit
set -o nounset
set -o pipefail

function print_var() {
  echo "${var_value}"
}

print_var

$ ./sample.sh
./sample.sh: line 8: var_value: unbound variable


Примечание: встроенные функции, такие как set -o errexit, выйдут из скрипта, как только появится «необработанный» код возврата (кроме нуля). Поэтому лучше ввести пользовательскую обработку ошибок, например:

#!/bin/bash
error_exit() {
  line=$1
  shift 1
  echo "ERROR: non zero return code from line: $line -- $@"
  exit 1
}
a=0
let a++ || error_exit "$LINENO" "let operation returned non 0 code"
echo "you will never see me"
# run it, now we have useful debugging output
$ bash foo.sh
ERROR: non zero return code from line: 9 -- let operation returned non 0 code


Подобное написание скриптов заставляет вас внимательнее относиться к поведению всех команд в скрипте и предусматривать возможность возникновения ошибки прежде, чем она застанет врасплох.

ShellCheck для выявления ошибок во время разработки


Стоит интегрировать что-то вроде ShellCheck в ваши конвейеры разработки и тестирования, чтобы проверять ваш код bash на применение лучших практик.

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

Использование своих exit-кодов


Коды возврата в POSIX — это не просто ноль или единица, а ноль или ненулевое значение. Используйте эти возможности для возврата пользовательских кодов ошибок (между 201–254) для различных случаев ошибок.

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

#!/usr/bin/env bash

SUCCESS=0
FILE_NOT_FOUND=240
DOWNLOAD_FAILED=241

function read_file() {
  if ${file_not_found}; then
    return ${FILE_NOT_FOUND}
  fi
}


Примечание: пожалуйста, будьте особенно осторожны с именами переменных, которые вы определяете, чтобы не допустить случайного переопределения переменных среды.

Функции-логгеры


Красивое и структурированное ведение логов важно, чтобы легко понять результаты выполнения вашего скрипта. Как и в других языках программирования высокого уровня, я всегда использую в моих скриптах bash собственные функции логирования, такие как __msg_info, __msg_error и так далее.

Это помогает обеспечить стандартизированную структуру ведения логов, внося изменения только в одном месте:

#!/usr/bin/env bash

function __msg_error() {
    [[ "${ERROR}" == "1" ]] && echo -e "[ERROR]: $*"
}

function __msg_debug() {
    [[ "${DEBUG}" == "1" ]] && echo -e "[DEBUG]: $*"
}

function __msg_info() {
    [[ "${INFO}" == "1" ]] && echo -e "[INFO]: $*"
}

__msg_error "File could not be found. Cannot proceed"

__msg_debug "Starting script execution with 276MB of available RAM"


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

Например, что-то вроде:

$ ./run-script.sh --debug


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

Я обычно основываю выбор, что инициализировать, а что нет, на компромиссе между пользовательским интерфейсом и деталями конфигураций, в которые пользователь может/должен вникнуть.

Архитектура для повторного использования и чистого состояния системы


Модульный / многоразовый код

├── framework
│   ├── common
│   │   ├── loggers.sh
│   │   ├── mail_reports.sh
│   │   └── slack_reports.sh
│   └── daily_database_operation.sh


Я держу отдельный репозиторий, который можно использовать для инициализации нового проекта/скрипта bash, который хочу разработать. Всё, что можно использовать повторно, может быть сохранено в репозитории и получено в других проектах, которые хотят использовать такие функциональные возможности. Такая организация проектов значительно уменьшает размер других скриптов, а также гарантирует, что кодовая база мала и легко тестируема.

Как и в приведенном выше примере, все функции ведения логов, такие как __msg_info, __msg_error и другие, например отчеты по Slack, содержатся отдельно в common/* и динамически подключаются в других сценариях, вроде daily_database_operation.sh.

Оставьте после себя чистую систему


Если вы загружаете какие-то ресурсы во время выполнения сценария, рекомендуется хранить все такие данные в общем каталоге со случайным именем, например /tmp/AlRhYbD97/*. Вы можете использовать генераторы случайного текста для выбора имени директории:

rand_dir_name="$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 16 | head -n 1)"


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

Использование lock-файлов


Часто нужно обеспечить выполнение только одного экземпляра сценария на хосте в любой момент времени. Это можно сделать с помощью lock-файлов.

Я обычно создаю lock-файлы в /tmp/project_name/*.lock и проверяю их наличие в начале скрипта. Это помогает корректно завершить работу скрипта и избежать неожиданных изменений состояния системы другим сценарием, работающим параллельно. Lock-файлы не нужны, если вам необходимо, чтобы один и тот же скрипт выполнялся параллельно на данном хосте.

Измерить и улучшить


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

В таких случаях я всегда стараюсь разбивать сценарий на отдельные маленькие скрипты и сообщать об их состоянии и времени выполнения с помощью:

time source "${filepath}" "${args}">> "${LOG_DIR}/RUN_LOG" 2>&1


Позже я могу посмотреть время выполнения с помощью:

tac "${LOG_DIR}/RUN_LOG.txt" | grep -m1 "real"


Это помогает мне определить проблемные/медленные области в скриптах, которые нуждаются в оптимизации.

Удачи!

Что еще почитать:

  1. Go и кеши GPU.
  2. Пример event-driven приложения на основе вебхуков в объектном S3-хранилище Mail.ru Cloud Solutions.
  3. Наш телеграм-канал о цифровой трансформации.

© Habrahabr.ru