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' "$*"
}