Кращі практики bash-скриптів: короткий посібник з надійних та продуктивних скриптів bash

Кращі практики bash-скриптів: короткий посібник з надійних та продуктивних скриптів bash
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 <HANDLER_FXN> <LIST OF SIGNALS TO 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. Наш телеграм-канал про цифрову трансформацію.

Джерело: habr.com

Додати коментар або відгук