Przez lata pracy jako admin, potem developer, a aktualnie devops zawsze zachodzę w głowę dlaczego skrypty shell bardzo często wyglądają jakby były pisane na kolanie. Oczywiście nie zawsze, ale traktowanie skryptu bash/sh z reguły wygląda na zasadzie zrób po kolei to i to, nie ma porządnej parametryzacji, a o handlowaniu błędów to już w ogóle można zapomieć. Sam tak kiedyś robiłem, bo skrypt traktowałem jako taki nazwijmy to “pierdolnik” z komendami do wykonania i wsio. Ale to nie jest dobra droga, a już na pewno nie jest dobra w bardziej skomplikowanych procesach, które mają dużo zależności, konfiguracji i są rozczłonowane na wiele systemów i elementów. Traktując takie skrypty jak wspomniany już pierdolnik skończymy z wyrwanym owłosieniem, podkrążonymi i zalanymi szałem oczami, a przecież nie o to chodzi w fajnym i seksownym procesie.

Po raz kolejny pisząc jakiś dokument w firmie dotyczący jak prawilnie napisać skrypt używany gdzieś w procesie CI/CD, postanowiłem zrobić do na blogu, który przecież jest online :)

Skrypt którego przepływ działania musi ładnie ogarniać error code, musi być odporny na złe wykonanie komend, lub błędną konfigurację środowiska istnieje i to nawet powiem więcej - istnieje na wyciągnięcie ręki, wystarczy poznać kilka prostych rzeczy i wziąć się do pracy, ale najpierw usiąść i przez kilkanaście minut pomyśleć czego tak naprawdę potrzebujemy.

Shebang, czyli #!

Ja wiem, że #! na początku skryptu to coś co po prostu jest, ale czy zastanawialiście się czym i dlatego jest shebang? Tak się nazywa ów zlepek magicznych znaków, mówi on kernelowi jakiego interpretera ma użyć dla wykonania komend zawartych w pliku. Dla ciekawskich, Email od Denisa Ritchie z 1980 opisujący shebang, przy okazji - ja należę do tych, którzy płakali po Denisie, a po Jobsie mniej (między ich śmiercią było kila miesięcy). Miałem go nawet w terminalu jako asciiart, a i na ścianie się znajdzie :}

No dobra tyle wstępu, ale dlaczego o tym piszę? Otóż każdy kto chce stworzyć skrypt shell, którego środowisko uruchomieniowe nie do końca jest znane i kontrolowane chyba już wie do czego zmierzam, a mianowicie do #!/bin/bash na systemach redhatowych nie istnieje 😎, a jak używamy jakichś tam jak im było… kontenerów i to jeszcze o zgrozo zrobionych przez kogoś innego, kogo albo nie znamy, albo nie lubimy? No to używanie #!/bin/bash odpada. Nie lubimy hardcoded ścieżek, no ok ale to w takim razie jak to zrobić prawilnie? #!/usr/bin/env bash oo tak! /usr/bin/env ma więcej pozytywów, bo potrafi ogarniać środowisko uruchomieniowe, a to już w przypadku rozwiązań choćby nodejs, czy python jest bardzo wygodne.

Konfiguracja interpretera set

Jak już nasz shebang ustawia odpowiedni interpreter, (skupmy się teraz na bash) to pora na odpowiednie skonfigurowanie owego, bo przecież chcemy mieć kontrolę nad zachowaniem, nie tylko kanalizacji w domu, ale też naszych skryptów, które nie mają już być “pierdolnikiem”.

Dobra to na początek pytanie, ile razy zdarzyło się Wam tak, że skrypt zakończył działanie poprawnie, ale pewne rzeczy się nie wykonały? Albo skrypt długo się wykonywał i nagle wyskoczył z errorem, że nie ma jakiejś zmiennej i bang… a działał na SageMaker na AWS, który właśnie uczył model, a błąd wyskoczył po 4 dniach pracy? Straciłeś kilka stówek i nie masz nic poza niesmakiem i naskórkiem pod paznokciami, który zdarłeś(aś) z lewego policzka.

A można było tego uniknąć jedną linijką w skrypcie, najlepiej zaraz po shebang, ha! A te kilka stówek to koszt tej nauki, a można było za to kupić taki fajny areograf… :D W naszym narzeczu nazywamy to “frycowe”

set -eaou pipefail

W przeliczeniu na ten areograf to drogie te ascii znaczki. Skoro takie drogie, to trzeba je dokładnie opisać, w końcu z jakiegoś powodu kosztują ile kosztują.

set -e przerwij działanie skryptu przy błędzie, w przypadku komend które mogą zwrócić błąd w tym przypadku należy użyć konstrukcji command_error_allowed || true

set -a włącza exportowanie wszystkich utworzonych zmiennych do procesów podrzędnych, pewnie nie raz mieliście problem z brakiem zmiennej, która nie pojawiała się w kontekście skryptu uruchamianego ze skryptu.

set -u wymuszenie błędu w przypadku braku definicji zmiennej wymaganej w działaniu skryptu, bardzo przydatna rzecz, ale wymaga zmiany podejścia do definiowania i używania zmiennych, bo przy jej użyciu każda zmienna użyta gdzieś w ciele skryptu musi być zdefiniowana i mieć jakąś domyślną wartość.

set -o pipefail bardzo przytadna opcja, a mianowicie jeżeli używamy potoków w skryptach, to dzięki tej opcji interpreter zwraca status z pierwszego polecenia, które zakończyło się błędem. Domyślnie działa to tak, że zwraca błąd tylko z ostatniego (a to bardzo często powoduje dużo problemów)

Oczywiście opcji konfiguacji jest dużo więcej, więc odsyłam do dokumentacji bash na GNU

Jak lubicie mieć bardziej opisowo to wszystko ustawione, to można każdą rzecz ustawić za pomocą parametru -o np.:

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

Możliwości jest wiele, wszystko zależy od wygody i potrzeb.

Funkcje

Jak już automatyzujemy to często fajnie coś zamknać w reużywalną funkcję, ja mam kilka, których używam często i w wielu skryptach.

Czy faktycznie potrzebujemy funkcji w skryptach shell? Pewnie!

Przy każdym skrypcie, który wykonuje coś wiele razy, funkcja daje nam “jeden punkt zmiany”. Chodzi o to, że chcąc zmienić jakąś często wykonywaną komendę, albo sekwencję komend, zamiast robić to kilka lub kilkanaście razy w kodzie, zmieniamy coś w jednym miejscu.

Np. chcemy sobie coś zainstalować, ale nie wiemy do końca jakiego będziemy używali systemu, bo w firmie używane są RedHaty i Debiany, możemy sobie stworzyć funkcję install, która ogarnie taki temat w sposób mądrzejszy niż wklepywanie apt-get install lub yum install

Deklaracja

W bash funkcja jest deklarowana za pomocą nazwy np.:

install() {
  echo "Instalowanie"
}

Bash nie umożliwia zdefiniowania wejścia w sposób jawny, robi się to inaczej, ale to tym za chwilę.

Wracając do przykładu z instalacją pakietów na RedHat lub Debian, musimy jakoś wykryć na jakim systemie pracujemy, żeby w skrypcie zobaczyć typ systemu najwygodniej skorzystać z /etc/os-release (polecam rzucić okiem na freedesktop)


install() {
  . /etc/os-release

  case "$ID" in
    debian|ubuntu)
      apt-get install $1
      ;;
    rhel|centos|fedora)
      yum install $1
      ;;
    *)
      echo "System is not supported"
      exit 1
  esac
}

W funkcji wykorzystaliśmy też parametr, w bash funkcje działają jak komendy w powłoce, czyli nie definiujemy parametrów przy deklaracji funkcji, po prostu wykonujemy funkcję z parametrami, które w ciele funkcji pobieramy za pomocą $1, $2 etc…

Teraz w skrypcie wykonując komendę install vim instalujemy po postu vim odpowiednim managerem pakietów w zależności od dystrybucji.

Przyśpieszenie wykonywania

Podobnie jak w różnych językach programowania musimy zastanowić się, czy nasza funkcja jest napisana optymalnie, np. czy za każdym razem nie wykonuje zbędnych operacji, w tej powyżej widać, że przy każdym wykonaniu zaczytuje plik /etc/os-release - ale czy to ma sens? Zawsze to kilka dodatkowych taktów procesora.

Error handling

Temat, który naprawdę często jest pomijany w skryptach powłoki, co niestety bardzo negatywnie wpływa na ich jakość i stabilność. Wspomniany już pipefail jest powodem tracenia masy czasu na sprawdzaniu co poszło źle w procesie, skoro wszystkie skrypty zakończyły się prawidłowo.

Każdy programista wie, że odpowiednio zaprojektowane wyjątki i error handlery to podstawa dobrej aplikacji. Tak samo musimy podchodzić do skryptów shell, bez tej świadomości Wasze procesy mogą mieć naprawdę dużą bezwładność.

Wyżej przy set opisałem set -eo pipefail czyli exit przy błędzie oraz zwracanie pierwszego błędu z potoku, takie ustawienie w znacznym stopniu poprawia kontrolę wykonywania skryptu i reakcje na błędy, ale to nie jest wszystko co musimy przemyśleć.

W przypadku set -e skrypt zakończy działanie przy ERR, często zdarza się, że będziemy potrzebowali wykonać komendy, które mogą zakończyć się błędem, ale nie będzie to powodowało przerwania działania skryptu. W takim przypadku musimy zastosować prostą sztuczkę:

#!/usr/bin/env bash
set -e

cat /tmp/jakis_se_pliczek || true

Powyższa konstrukcja powoduje, że jeżeli cat /tmp/jakis_se_pliczek zwróci błąd, czyli jeżeli pliku nie ma to interpreter wykona instrukcję true, dzieje się tak dlatego, że użyto w konstrukcji operatora “lub” (||), który maskuje ewentualne błędy z wykonania cat.

Często może się zdarzyć, że nasz skrypt podczas wykonywania wpływa na środowisko, np. zapisuje sobie jakieś dane w pliku, który na końcu wykonania skryptu musi być usunięty, ale podczas startu skryptu nie może istnieć.

Powyższy schemat może być problematyczny w przypadku, kiedy podczas wykonywania skryptu coś pójdzie nie tak i w połowie działania proces zakończy działanie. Plik zostanie i kolejne wykonanie skryptu albo nie dojdzie do skutku, albo przepływ będzie zupełnie inny niż spodziewany.

Co zrobić w takiej sytuacji?

Założyć pułapkę, czyli trap.

Pozwoli to na zdefiniowanie kodu, który wykona się po wystąpieniu odpowiedniego sygnału podczas działania skryptu.

Przykład użycia: trap cleanup EXIT ERR SIGINT SIGTERM, czyli trap <komenda> <lista sygnałów> same sygnały reprezentowane są za pomocą liczby lub nazwy.

Dostępne sygnały możemy sobie wylistować w powłoce za pomocą komendy trap -l

sh-5.2$ trap -l
 1) SIGHUP	 2) SIGINT	 3) SIGQUIT	 4) SIGILL	 5) SIGTRAP
 6) SIGABRT	 7) SIGBUS	 8) SIGFPE	 9) SIGKILL	10) SIGUSR1
11) SIGSEGV	12) SIGUSR2	13) SIGPIPE	14) SIGALRM	15) SIGTERM
16) SIGSTKFLT	17) SIGCHLD	18) SIGCONT	19) SIGSTOP	20) SIGTSTP
21) SIGTTIN	22) SIGTTOU	23) SIGURG	24) SIGXCPU	25) SIGXFSZ
26) SIGVTALRM	27) SIGPROF	28) SIGWINCH	29) SIGIO	30) SIGPWR
31) SIGSYS	34) SIGRTMIN	35) SIGRTMIN+1	36) SIGRTMIN+2	37) SIGRTMIN+3
38) SIGRTMIN+4	39) SIGRTMIN+5	40) SIGRTMIN+6	41) SIGRTMIN+7	42) SIGRTMIN+8
43) SIGRTMIN+9	44) SIGRTMIN+10	45) SIGRTMIN+11	46) SIGRTMIN+12	47) SIGRTMIN+13
48) SIGRTMIN+14	49) SIGRTMIN+15	50) SIGRTMAX-14	51) SIGRTMAX-13	52) SIGRTMAX-12
53) SIGRTMAX-11	54) SIGRTMAX-10	55) SIGRTMAX-9	56) SIGRTMAX-8	57) SIGRTMAX-7
58) SIGRTMAX-6	59) SIGRTMAX-5	60) SIGRTMAX-4	61) SIGRTMAX-3	62) SIGRTMAX-2
63) SIGRTMAX-1	64) SIGRTMAX	

Pełny przykład:

#!/usr/bin/env bash
set -eaou pipefail

cleanup() {
    echo "Cleanup"
    rm /tmp/jakis_tam_pliczek || true
}

# Set trap
trap cleanup EXIT ERR SIGINT SIGTERM

Pełny przykład

Poniższy plik może być szablonem dla Waszych entrypointów w kontenerach, lub skryptów wykonywanych w jakimś procesie CI/CD.

#!/usr/bin/env bash
set -eaou pipefail

declare -i term_width=120

VERBOSE=${VERBOSE:-false}
LOG_FILE="/tmp/script.log"
DEPENDENCY="jq podman kubectl"

if [ "$VERBOSE" = true ]; then
    set -o xtrace
fi

h1() {
    declare border padding text
    border='\e[1;34m'"$(printf '=%.0s' $(seq 1 "$term_width"))"'\e[0m'
    padding="$(printf ' %.0s' $(seq 1 $(((term_width - $(wc -m <<<"$*")) / 2))))"
    text="\\e[1m$*\\e[0m"
    echo -e "$border"
    echo -e "${padding}${text}${padding}"
    echo -e "$border"
}

h2() {
    printf '\e[1;33m==>\e[37;1m %s\e[0m\n' "$*"
}

error_exit() {
    log_error "$1"
    exit "${2:-1}"
}

logfile() {
    if [ ! -f $LOG_FILE ]; then
        local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
        echo "$timestamp $1: $2" >> "$LOG_FILE"
    fi
}

log_error() {
    logfile ERROR "$1"
    echo "ERROR: $1" >&2
}

log_info() {
    logfile INFO "$1"
    echo "INFO: $1" >&2
}

debug() {
    if [ "$VERBOSE" = true ]; then
        echo "DEBUG: $1" >&2
    fi
}

check_deps() {
    for command in ${DEPENDENCY}
    do
        if ! command -v "${command}" &> /dev/null; then
            error_exit "Komenda: ${command} nie znaleziona, zainstaluj i uruchom ponownie" 2
        fi
    done
}

cleanup() {
  h1 "Czyszczenie"
}


trap cleanup EXIT ERR SIGINT SIGTERM

main() {
    h1 "Skrypt uruchomiono..."
    
    h2 "Sprawdzam zależności"
    check_deps

    if ! process_data; then
        error_exit "Błąd wykonania" 3
    fi

    cleanup
    h2 "Zakończono działanie i czyszczenie"
}

# Run main function
main "$@"

Moje funkcje, które lubię i często używam

h1

Ładnie drukuje tekst w konsoli, robi górną i dolną kreskę, a wpisany tekst środkuje do wartości term_width - fajne i czytelne w outpucie procesu CI na stronach (gitlab, gitea, etc…).

declare -i term_width=120

h1() {
    declare border padding text
    border='\e[1;34m'"$(printf '=%.0s' $(seq 1 "$term_width"))"'\e[0m'
    padding="$(printf ' %.0s' $(seq 1 $(((term_width - $(wc -m <<<"$*")) / 2))))"
    text="\\e[1m$*\\e[0m"
    echo -e "$border"
    echo -e "${padding}${text}${padding}"
    echo -e "$border"
}

h2

Koloruje tekst i daje przedrostek, podobnie jak wyżej używam w procesach dla zwiększenia czytelności.

h2() {
    printf '\e[1;33m==>\e[37;1m %s\e[0m\n' "$*"
}

Materiały online