Bash

Poradnik Bash Bushidō cz. VII – debugowanie i bezpieczne wyjścia skryptu

Można być fanem skryptów powłoki lub ich po prostu nie lubić. Nie zmienia to faktu, że czy nam się to podoba, czy nie, wciąż występują „często i gęsto”. Co – o zgrozo – jeszcze straszniejsze, czasem sami je piszemy! Tworząc nietrywialne skrypty, będziemy zmuszeni prawdopodobnie na pewnym etapie odpluskwiać (usuwać błędy), czyli debugować kod skryptu.

„The UNIX-HATERS Handbook”, tłumacząc swobodnie na język polski – „Podręcznik HEJTERÓW UNIX-a” – jest już niemal legendarną, a na pewno wysokowartościową książką, której recenzja z komentarzem być może pojawi się na naszym blogu ;) Słowo wstępu do niej zaczyna się następującą opinią:

I liken starting one’s computing career with Unix, say as an under-graduate, to being born in East Africa. It is intolerably hot, your body is covered with lice and flies, you are malnourished and you suffer from numerous curable diseases. But, as far as young East Africans can tell, this is simply the natural condition and they live within it. By the time they find out differently, it is too late. They already think that the writing of shell scripts is a natural act.”
— Ken Pier, Xerox PARC

Co w bardzo luźnym tłumaczeniu oznacza:

Porównałbym czyjeś rozpoczęcie kariery komputerowej z Unixem, powiedzmy jako student, do urodzenia się we wschodniej Afryce. Jest nieznośnie gorąco, twoje ciało jest pokryte przez wszy i muchy, jesteś niedożywiony i cierpisz z powodu licznych uleczalnych chorób. Jednak, jako młodzi mieszkańcy wschodniej Afryki mówicie, że to po prostu naturalne warunki i w nich żyjecie. Zanim jednak się dowiecie, że jest inaczej, jest już za późno. Myślicie, że pisanie skryptów powłoki jest naturalnym biegiem rzeczy.
— Ken Pier, Xerox PARC

Na moje nieszczęście pisanie skryptów jest dla mnie naturalne. Co więcej, po latach używania komputera wiem, że narzędzia graficzne z reguły są o wiele uboższe od ich konsolowych odpowiedników. Pozwolę sobie więc wrzucić popularnego wśród młodzieży mema i choć nie ma on szans, by zostać nowym ulubionym memem Elona Muska, to zgodnie z efektem IKEA mnie jako autorowi on się podoba.

Bash Bushido

Można być fanem skryptów powłoki lub ich po prostu nie lubić. Nie zmienia to faktu, że czy nam się to podoba, czy nie, wciąż występują „często i gęsto”. Co – o zgrozo – jeszcze straszniejsze, czasem sami je piszemy (może naprawdę jest dla nas za późno)! Tworząc nietrywialne skrypty, będziemy zmuszeni prawdopodobnie na pewnym etapie odpluskwiać (usuwać błędy), czyli debugować kod skryptu. Pierwsza część artykułu będzie traktować o tym, jak to zrobić.

Drugim tematem poruszanym w tym artykule, jest tworzenie „pułapek” w skryptach, czyli fragmentów kodu, które są warunkowo wykonywane, jeśli zaistnieje specyficzna sytuacja (z reguły wejście w niepoprawny stan). Są one przydatne między innymi w czyszczeniu środowiska nieuruchomionego.

Zapis wyjścia programu – przekierowania i tee

Najprostszą drogą do debugowania jest czytanie tego, co program wypisuje nam na wyjściu. Zdarza się jednak, że wypisywanych informacji jest tak dużo, że nie jesteśmy w stanie ich czytać w czasie rzeczywistym. Możemy wtedy użyć znanego nam już narzędzia tee lub zwykłego przekierowania.

Przykładowe użycie.

bash ./failing_script.sh > script.log
# LUB
bash ./failing_script.sh | tee script.log
# LUB
bash ./failing_script.sh | tee -a script.log

Przerwanie skryptu powłoki po pierwszym niepoprawnym wyjściu

Większość programów standardowo zwraca niezerowy status wyjścia w przypadku wystąpienia błędu. W związku z tym możemy ustawić opcję powłoki Bash -e, która opowiada za natychmiastowe wyjście z programu, jeżeli zostanie zwrócony niezerowy status wyjścia programu.

set -e

Przykładowe zastosowanie w skrypcie:

#!/usr/bin/env bash
set -e
false
echo "Try me"

Istnieje możliwość „obejścia” wyjścia, jeśli dodamy warunkowe wykonanie dodatkowej komendy, która wyjdzie z zerowym statusem (np. true).

#!/usr/bin/env bash
set -e
false  || true
echo "Try me"

Zauważmy też, że opcja jest na tyle inteligentna, by nie zamykać nam programu w przypadku warunków.

#!/usr/bin/env bash
set -e
if false; then
    echo "Ala ma kota"
else
    echo "Kot ma Ale"
fi

Nawet jeśli warunek nie zawiera instrukcji warunkowej else.

#!/usr/bin/env bash
set -e
if false; then
    echo "Ala ma kota"
fi
echo "Kot ma Ale"

Status wyjścia potoku (pipe)

W przypadku łączenia wielu programów w potok, status wyjścia zależy wyłącznie od ostatniego programu w potoku. Prezentuje to poniższy przykład.

[Alex@SpaceShip ~]$ false | true; echo $?
0
[Alex@SpaceShip ~]$ true | false; echo $?
1

Nasz poprzedni trik z wyjściem po pierwszym, niezerowym wyjściu nie zadziała w tym przypadku. Na nasze szczęście możemy użyć następnej opcji set -o pipefail. W ten sposób, jeżeli któraś z komend w potoku wyjdzie z niezerowym statusem wyjścia programu, to sam potok też będzie taki miał. Naturalne pytanie, które się pojawia – z jakim statusem wyjdzie potok, gdy kilka komend wyjdzie z niezerowym statusem wyjścia programu i na dodatek z różnymi od siebie?

Odpowiedź możemy znaleźć w dokumentacji lub w poniższym przykładzie.

[Alex@SpaceShip bash_bushido_VII]$ exit 123 | true; echo $?
0
[Alex@SpaceShip bash_bushido_VII]$ set -o pipefail
[Alex@SpaceShip bash_bushido_VII]$ exit 123 | true; echo $?
123
[Alex@SpaceShip bash_bushido_VII]$ exit 1 | exit 12 | exit 123 | true; echo $?
123

Opcja (set -e) powinna być ustawiana wraz z opcją (set -o pipefail). Jest to najlepsza praktyka.

Kontrola niezainicjowanych zmiennych

Następnym „bezpiecznikiem” oraz czymś, co jest zupełnie naturalne w większości języków skryptowych (nie licząc oczywiście Basha), jest traktowanie niezainicjowanej zmiennej jako błędu. W celu włączenia tej funkcjonalności ustawiamy opcję -u.

Przykładowe zastosowanie:

[Alex@SpaceShip bash_bushido_VII]$ echo $asdff

[Alex@SpaceShip bash_bushido_VII]$ set -u
[Alex@SpaceShip bash_bushido_VII]$ echo $asdff
bash: asdff: unbound variable
[Alex@SpaceShip bash_bushido_VII]$ echo $?
1
[Alex@SpaceShip bash_bushido_VII]$

Jak widzimy, po użyciu zmiennej asdff dostaliśmy ładny komunikat bash: asdff: unbound variable informujący o niezainicjowanej zmiennej. Na dodatek taki komunikat kończy się niezerowym wyjściem (1), więc zostanie obsłużony przez set -e.

W celu obsłużenia niezainicjowanej zmiennej możemy użyć wartości domyślnej. Więcej o wykorzystaniu tego tricku napiszę w następnej części Bash Bushido. Poniżej przykładowe zastosowanie.

[Alex@SpaceShip bash_bushido_VII]$ set -u
[Alex@SpaceShip bash_bushido_VII]$ echo ${adsff:-"wartosc domyslna"}
wartosc domyslna

BASH tryb debugowania

Innym popularnym sposobem na debugowanie skryptu jest wypisywanie każdej komendy przed jej wykonaniem. W tym celu należy włączyć opcję -x. Jest to bardzo „gadatliwy” (ang. verbose) tryb pracy Basha.

Przykład użycia ze skryptem.

#!/usr/bin/env bash
for i in {0..4}; do
    echo $( echo "2^$i" | bc );
done
echo $( echo "subshell of $(echo "subsubshell")" );

if true; then
    echo "if"
    if true; then
        echo "if if "
    fi
    echo "if"
fi 

for i in 1; do
    for j in 1; do
        echo "for for"
    done
done

Co z uruchomieniem w trybie -x da nam:

[vagrant@localhost ~]$ bash -x x.sh 
+ for i in '{0..4}'
++ echo '2^0'
++ bc
+ echo 1
1
+ for i in '{0..4}'
++ echo '2^1'
++ bc
+ echo 2
2
+ for i in '{0..4}'
++ echo '2^2'
++ bc
+ echo 4
4
+ for i in '{0..4}'
++ echo '2^3'
++ bc
+ echo 8
8
+ for i in '{0..4}'
++ echo '2^4'
++ bc
+ echo 16
16
+++ echo subsubshell
++ echo 'subshell of subsubshell'
+ echo subshell of subsubshell
subshell of subsubshell
+ true
+ echo if
if
+ true
+ echo 'if if '
if if 
+ echo if
if
+ for i in 1
+ for j in 1
+ echo 'for for'
for for

Należy tutaj zwrócić uwagę na ilość plusów, które wskazują nam, jak głęboko „wychodzimy” poza nasz skrypt/powłokę do podpowłoki lub innego skryptu.

Zgodnie z dokumentacją, przed wypisaniem linii używana jest zmienna PS4, którą możemy ustawić tak, by oprócz samych plusów mówiących o głębokości podpowłok, mieć także informację na temat pliku, linii oraz nazwy funkcji.

export PS4='+(${BASH_SOURCE}:${LINENO}): ${FUNCNAME[0]:+${FUNCNAME[0]}(): }'

W celu lepszej demonstracji użyjemy innego skryptu:

#!/usr/bin/env bash
cleanup(){
    echo "Clean up!"
}
trap cleanup EXIT
set -eo pipefail
bar(){
    echo "bar"
    exit 1
}
foo(){
   echo "foo"
   bar
}
foo

Jego uruchomienie w trybie debugowania wraz z ustawioną zmienną PS4 da nam następujące wyjście:

[Alex@SpaceShip bash_bushido_VII]$ export PS4='+(${BASH_SOURCE}:${LINENO}): ${FUNCNAME[0]:+${FUNCNAME[0]}(): }'
[Alex@SpaceShip bash_bushido_VII]$ bash -x tmp.sh 
+(tmp.sh:5): trap cleanup EXIT
+(tmp.sh:6): set -eo pipefail
+(tmp.sh:18): foo
+(tmp.sh:14): foo(): echo foo
foo
+(tmp.sh:15): foo(): bar
+(tmp.sh:9): bar(): echo bar
bar
+(tmp.sh:10): bar(): exit 1
+(tmp.sh:1): bar(): cleanup
+(tmp.sh:3): cleanup(): echo 'Clean up!'
Clean up!

Pułapki, czyste wyjścia ze skryptu

Często zdarza się, iż pisząc bardziej skomplikowany skrypt, musimy stworzyć coś, co będzie tymczasowe. Może to być maszyna wirtualna, plik blokujący (lock file), plik z PID czy dodatkowe pliki pomocnicze. W szczęśliwej ścieżce (ang. happy path) zawsze po sobie posprzątamy. Jednak w przypadku gdy szczęśliwa ścieżka nie wystąpi, wypadałoby móc wykonać odpowiednie polecenia w celu sprzątnięcia swoich działań.

W takich wypadkach przydatnym mechanizmem są pułapki (ang. trap). Są to kawałki kodu, które są wykonywane, gdy skrypt bashowy dostanie zdefiniowany sygnał. Dzięki sygnałom procesy mogą się w prosty sposób ze sobą komunikować. Istnieje wiele sygnałów o różnych przeznaczeniach oraz wiele sposobów ich obsługi. Oprócz tych standardowych sygnałów BASH obsługuje także specjalne (wewnętrzne) sygnały takie jak EXIT, DEBUG, ERR czy RETURN. Ich użycie jest wytłumaczone w manualu, który możemy wywołać, wpisując help trap. W skryptach bashowych przyjęło się używać EXIT, które jest wywoływane przy każdym wyjściu – sygnalizowanym lub nie.

Kilka przykładowych zastosowań:

#!/usr/bin/env bash

cleanup(){
    echo "Clean up!"
}
trap cleanup EXIT
# Nawet przy braku komend funkcja `cleanup` zostanie wywołana
#!/usr/bin/env bash

cleanup(){
    echo "Clean up!"
}
trap cleanup EXIT
exit 23 # Wyjście
#!/usr/bin/env bash

cleanup(){
    echo "Clean up!"
}
trap cleanup EXIT
kill -15 $$ # Wysłanie SIGTERM do samego siebie
#!/usr/bin/env bash

cleanup(){
    echo "Clean up!"
}
trap cleanup EXIT
set -eo pipefail
true | true | false | true# Wysłanie SIGTERM do samego siebie
#!/usr/bin/env bash
cleanup(){
    echo "Clean up!"
}
trap cleanup EXIT
set -eo pipefail


foo(){
   echo "foo"
   exit 1
}
bar(){
   echo "bar"
   exit 1
}
foo

Podziękowania

W tym miejscu chciałbym podziękować czytelnikom najdłuższej serii na naszym blogu. Jeśli spodobał się Państwu tekst, to zapraszam do zapisania się na nasz newsletter (za pomocą formularza znajdującego się poniżej) i/lub do podzielenia się linkiem z kolegą/koleżanką (za pomocą przycisków znajdujących się pod formularzem rejestracyjnym) :)

W następnej części Bash Bushido zajmiemy się błędami często popełnianymi przez użytkowników Basha.

Autorzy

Artykuły na blogu są pisane przez osoby z zespołu EuroLinux. 80% treści zawdzięczamy naszym developerom, pozostałą część przygotowuje dział sprzedaży lub marketingu. Dokładamy starań, żeby treści były jak najlepsze merytorycznie i językowo, ale nie jesteśmy nieomylni. Jeśli zauważysz coś wartego poprawienia lub wyjaśnienia, będziemy wdzięczni za wiadomość.