Szukanie błędów
Po tej lekcji: kursant szuka błędu w skrypcie metodą, a nie zgadywaniem: sprawdza składnię przez bash -n, śledzi wykonanie przez bash -x i set -x z własnym PS4, przepuszcza skrypt przez ShellCheck, a kiedy to nie wystarcza, podsłuchuje rozmowę programu z jądrem przez strace.
Wstęp
Skrypt działał. U Ciebie na laptopie działał. Kod wyjścia zero, kciuk w górę. A raport pusty.
Co robi początkujący? Wpatruje się w kod. Czyta go piąty raz. Dopisuje echo "tu jestem" w dwunastu miejscach. Restartuje serwer, na wszelki wypadek. Modli się.
Co robi admin? Bierze cztery narzędzia i w dwie minuty wie, gdzie jest problem. Korektor, kamera, nadgorliwy sąsiad i podsłuch. Dziś poznasz wszystkie cztery.
Skrypt do zepsucia
plik raport.sh
#!/bin/bash
# raport.sh — liczy pliki .log w folderze i ich linijki
folder=$1
licznik=0
if [ ! -d $folder ]; then
echo "Nie ma folderu $folder"
exit 1
fi
for plik in "$folder"/*.log; do
licznik=$((licznik + 1))
linie=$(wc -l < "$plik")
echo "$plik: $linie linii"
done
echo "Plików: $licznk"Wygląda niewinnie. Uruchamiamy na folderze Moje logi, ze spacją w nazwie, bo przecież nikt nigdy nie daje spacji w nazwach folderów. Poza wszystkimi.
$ ./raport.sh "Moje logi"
./raport.sh: line 6: [: Moje: binary operator expected
Moje logi/app.log: 2 linii
Moje logi/db.log: 1 linii
Plików:
$ echo $?
0Dwa błędy, jeden komunikat, kod zero. Idealna zbrodnia. Bierzemy się do śledztwa.
bash -n: korektor, który czyta, ale nie wykonuje
Wyobraź sobie korektora w urzędzie. Bierze Twój wniosek i sprawdza tylko, czy jest po polsku: czy zdania się kończą, czy nawiasy się zamykają. Nie sprawdza, czy masz prawo do zasiłku. Niczego nie podbija, niczego nie wysyła.
To jest bash -n. Bash czyta skrypt i sprawdza składnię, ale nie wykonuje ani jednej komendy. Można go puścić na skrypcie, który kasuje pół serwera, i nic się nie stanie.
bash -n literowka.sh
bash -n cudzyslow.shliterowka.sh: line 4: syntax error: unexpected end of file
cudzyslow.sh: line 3: unexpected EOF while looking for matching `"'Pierwszy plik ma if bez fi. Drugi ma cudzysłów otwarty i nigdy niezamknięty, czyli kopertę, do której wsadzono pół skryptu i nikt jej nie zakleił. Zwróć uwagę: Bash zgłasza błąd na końcu pliku, a nie tam, gdzie zapomniałeś. On dopiero na końcu orientuje się, że czegoś brakuje. Jak kelner, który dopiero przy rachunku zauważa, że nikt nie zamówił deseru, a w karcie było, że deser obowiązkowy.
A teraz nasz raport.sh:
bash -n raport.sh; echo "kod: $?"kod: 0Czysto. Bo oba błędy są gramatycznie poprawne. Literówka w nazwie zmiennej to dla Basha po prostu inna zmienna. bash -n łapie tylko błędy składni. Dlatego to pierwsze narzędzie, a nie jedyne.
bash -x: kamera nad ramieniem urzędnika
Teraz montujemy kamerę nad biurkiem urzędnika. Widzimy każdy wniosek, który bierze do ręki, i to już po wypełnieniu: wszystkie zmienne podstawione, gwiazdki rozwinięte, cudzysłowy zdjęte. Dokładnie to, co naprawdę zostanie wykonane.
bash -x ./raport.sh "Moje logi"+ folder='Moje logi'
+ licznik=0
+ '[' '!' -d Moje logi ']'
./raport.sh: line 6: [: Moje: binary operator expected
+ for plik in "$folder"/*.log
+ licznik=1
++ wc -l
+ linie=2
+ echo 'Moje logi/app.log: 2 linii'
Moje logi/app.log: 2 linii
+ for plik in "$folder"/*.log
+ licznik=2
++ wc -l
+ linie=1
+ echo 'Moje logi/db.log: 1 linii'
Moje logi/db.log: 1 linii
+ echo 'Plików: '
Plików: podświetlona linijka '[' '!' -d Moje logi ']'
Popatrz na trzecią linijkę. Komenda [ dostała osobno Moje i osobno logi. To dwa argumenty zamiast jednego, czyli list rozerwany na pół, bo nie był w kopercie. Bash pokazuje to w cudzysłowach: każdy kawałek w apostrofach to jeden argument. A Moje logi jest bez apostrofów, czyli rozsypało się na dwa.
I zobacz, co się stało dalej: test się wyłożył, ale skrypt nie wyszedł, tylko pojechał dalej. Gdyby folderu w ogóle nie było, dostałbyś to samo, plus kilka błędów z pętli. Bramkarz zemdlał, a impreza trwa.
Ostatnia linijka: licznik to dwa, a echo wypisało pusto. Kamera pokazuje, że w echo nie było żadnej liczby. Czyli zmienna, którą wypisujemy, jest pusta. Patrzymy w kod: $licznk. Zjedzona literka.
Jeszcze dwa szczegóły w zapisie z kamery. Plus oznacza „to wykonuję”. Dwa plusy, ++, oznaczają „to wykonuję w środku czegoś innego”, tutaj w $( ). Im więcej plusów, tym głębiej w zagnieżdżeniu. A wszystkie linijki z plusami idą na drugi głośnik, czyli stderr. Wynik skryptu leci osobno, więc możesz go nadal przekierować do pliku.
set -x i set +x: kamera tylko na kawałek
Skrypt na trzysta linijek z bash -x to powódź. Często chcesz nagrać tylko podejrzany fragment.
#!/bin/bash
echo "To leci normalnie"
set -x
wynik=$(( 6 * 7 ))
echo "Wynik: $wynik"
set +x
echo "Znowu cisza"To leci normalnie
+ wynik=42
+ echo 'Wynik: 42'
Wynik: 42
+ set +x
Znowu ciszaset -x włącza kamerę, set +x wyłącza. Tak, w Bashu minus włącza, a plus wyłącza. Nie pytaj. Ktoś kiedyś tak zdecydował i od tamtej pory wszyscy udajemy, że to logiczne.
Kamerę można włączyć też w zwykłym terminalu. Świetny sposób, żeby zobaczyć, co Bash robi z Twoją komendą, zanim ją wykona:
set -x
plik="Moje logi"
ls $plik
set +x+ plik='Moje logi'
+ ls Moje logi
ls: cannot access 'Moje': No such file or directory
ls: cannot access 'logi': No such file or directoryLekcja o spacjach w nazwach w jednym obrazku.
PS4: kamera z numerem linijki
Kamera ma jedną wadę: widzisz, co się dzieje, ale nie wiesz, w której linijce pliku. Ten plusik na początku to zawartość zmiennej PS4. Pamiętasz PS1 z lekcji o ustawianiu Basha? To jej kuzynka, tylko od śledzenia.
PS4='+ ${BASH_SOURCE}:${LINENO}: '
set -xWstawiamy te dwie linijki na górę raport.sh i odpalamy:
+ raport.sh:5: folder='Moje logi'
+ raport.sh:6: licznik=0
+ raport.sh:8: '[' '!' -d Moje logi ']'
raport.sh: line 8: [: Moje: binary operator expected
+ raport.sh:13: for plik in "$folder"/*.logTeraz każda linijka mówi: z którego pliku i z której linijki. Przy skrypcie, który wczytuje inne pliki, to jak numer pokoju na nagraniu z monitoringu.
Uwaga na apostrofy. W pojedynczych cudzysłowach ${LINENO} zostaje napisem i Bash rozwija go za każdym razem od nowa, przy każdej komendzie. W podwójnych rozwinąłby się raz, przy przypisaniu, i każda linijka miałaby ten sam numer. Koperta zaklejona kontra koperta otwarta.
ShellCheck: nadgorliwy sąsiad, który ma rację
Każdy ma takiego sąsiada. Przechodzi obok, rzuca okiem na Twój płot i mówi: „Ten słupek się przewróci przy pierwszym wietrze”. Irytujące. I zwykle ma rację.
ShellCheck to program, który czyta skrypt i wypisuje wszystko, co wygląda podejrzanie: brak cudzysłowów, literówki w zmiennych, bashizmy w #!/bin/sh, setki typowych pułapek. Niczego nie uruchamia, tylko czyta, jak bash -n, ale zna nie tylko gramatykę, lecz także złe nawyki.
Instalacja:
sudo apt install shellcheck # [Ubuntu]
sudo dnf install ShellCheck # [RHEL] z repozytorium EPEL[RHEL] Na Red Hacie i pochodnych ShellCheck jest w dodatkowym repozytorium EPEL i nazywa się z wielkich liter. Jak włączyć EPEL, pokażę w lekcji o menedżerach pakietów. Do tego czasu możesz go wypróbować w przeglądarce na shellcheck.net, tylko nie wklejaj tam skryptów z hasłami.
shellcheck raport.shIn raport.sh line 6:
if [ ! -d $folder ]; then
^-----^ SC2086 (info): Double quote to prevent globbing and word splitting.
Did you mean:
if [ ! -d "$folder" ]; then
In raport.sh line 17:
echo "Plików: $licznk"
^-----^ SC2154 (warning): licznk is referenced but not assigned (did you mean 'licznik'?).Dwie sekundy. Oba błędy. Z numerem linijki, ze strzałką, z gotową poprawką. A przy drugim jeszcze pyta: „Czy chodziło ci o licznik?”. Lepiej niż autokorekta w telefonie, bo ta by poprawiła na „licznik gazu”.
Każde ostrzeżenie ma kod, na przykład SC2086. Pod kodem jest strona z wyjaśnieniem: https://www.shellcheck.net/wiki/SC2086. Czytaj je. To najlepszy darmowy kurs pisania skryptów, jaki istnieje, napisany na podstawie błędów milionów ludzi przed Tobą.
Po poprawkach:
$ shellcheck raport.sh && echo "czysto"
czysto
$ ./raport.sh "Moje logi"
Moje logi/app.log: 2 linii
Moje logi/db.log: 1 linii
Plików: 2Kiedy ShellCheck się myli albo robisz coś celowo, możesz mu powiedzieć „wiem, co robię” komentarzem nad linijką:
# shellcheck disable=SC2086Tylko nad tą jedną linijką i tylko z konkretnym kodem. Wyłączanie wszystkiego na górze pliku to jak wyjęcie baterii z czujnika dymu, bo piszczy przy smażeniu.
Przydatne opcje: -S warning pokazuje tylko ostrzeżenia i błędy, bez drobnych uwag. -x pozwala mu zajrzeć do plików wczytywanych przez source, wrócimy do tego za dwa lekcje. Większość edytorów ma wtyczkę, która podkreśla błędy ShellChecka na bieżąco, w trakcie pisania. Zainstaluj ją dziś.
strace: podsłuch rozmowy z jądrem
Czasem skrypt jest czysty, kamera nic nie pokazuje, a program i tak zachowuje się dziwnie. Nie widzi pliku, choć plik tam jest. Wisi i nie wiadomo na czym.
Wtedy podsłuchujemy. Każdy program, żeby cokolwiek zrobić, otworzyć plik, coś zapisać, połączyć się z siecią, musi poprosić jądro. Te prośby nazywają się wywołaniami systemowymi, po angielsku system calls, w skrócie syscalle. To jak okienko w urzędzie, tylko jeszcze niżej: okienko między programem a jądrem. strace stoi przy tym okienku i notuje każdą prośbę i każdą odpowiedź.
sudo apt install strace # [Ubuntu]
sudo dnf install strace # [RHEL]Przykład z życia. Skrypt kopii ma czytać ustawienia z pliku, a jeśli pliku nie ma, po cichu bierze domyślne:
#!/bin/bash
cel=/tmp/kopie
if [[ -r /etc/kopia.conf ]]; then
# shellcheck source=/dev/null
source /etc/kopia.conf
fi
echo "Kopiuję do: $cel"Kolega przysięga, że wpisał w ustawieniach cel=/srv/kopie. A skrypt uparcie kopiuje do /tmp/kopie. Podsłuchujemy:
strace -f -e trace=%file ./kopia_conf.sh 2>&1 | grep kopiaexecve("./kopia_conf.sh", ["./kopia_conf.sh"], 0x7ffd34acd788 /* 241 vars */) = 0
openat(AT_FDCWD, "./kopia_conf.sh", O_RDONLY) = 3
faccessat2(AT_FDCWD, "/etc/kopia.conf", R_OK, AT_EACCESS) = -1 ENOENT (No such file or directory)Ostatnia linijka: skrypt pyta jądro „czy mogę czytać /etc/kopia.conf?”, a jądro odpowiada ENOENT, czyli „nie ma takiego pliku”. A kolega? Wrzucił plik do /etc/kopia/kopia.conf, do folderu. Zagadka rozwiązana bez czytania ani jednej linijki kodu.
Jak to czytać: po lewej nazwa wywołania, w nawiasie argumenty, po znaku równości odpowiedź jądra. Liczba zero albo większa zwykle znaczy sukces. Minus jeden z nazwą błędu znaczy porażkę, a nazwy są czytelne: ENOENT brak pliku, EACCES brak uprawnień.
Kilka przydatnych wariantów:
strace -f -Z -e trace=%file ./kopia_conf.sh # tylko nieudane prośby o pliki
strace -o slad.txt -f ./kopia_conf.sh # cały zapis do pliku
strace -c ls /etc # statystyka: ile czego wywołał
sudo strace -p 1234 # podepnij się do działającego procesu-f śledzi też dzieci, czyli każdą komendę, którą skrypt odpali. Przy skryptach to obowiązkowe, bo skrypt to głównie odpalanie innych programów. -p podpina się do procesu, który już działa, na przykład wiszącego od godziny. Zobaczysz, na czym czeka. Do cudzych procesów potrzebujesz sudo.
Uwaga: strace mocno spowalnia śledzony program, bo zatrzymuje go przy każdej prośbie do jądra. Na produkcji podpinaj się krótko i z głową. I pamiętaj, że w zapisie widać wszystko, co program czyta i pisze, łącznie z hasłami. Plik slad.txt traktuj jak poufny.
Jak to działa pod spodem
Cztery narzędzia, cztery różne poziomy.
bash -n uruchamia tylko pierwszą połowę pracy urzędnika: czytanie i rozbiór na części. Bash i tak zawsze najpierw rozbiera całą komendę złożoną, na przykład cały if aż do fi, a dopiero potem wykonuje. Z opcją -n zatrzymuje się po rozbiorze.
set -x działa w drugiej połowie. Bash po rozwinięciu zmiennych, gwiazdek i cudzysłowów, tuż przed uruchomieniem komendy, wypisuje ją na stderr z prefiksem z PS4. Dlatego widzisz wartości, a nie nazwy zmiennych.
ShellCheck w ogóle nie jest Bashem. To osobny program, który buduje sobie drzewo skryptu i przykłada do niego setki reguł. Skryptu nie uruchamia nigdy. Dlatego jest bezpieczny, ale też dlatego nie wie, co jest naprawdę w Twoim /etc.
strace korzysta z mechanizmu jądra o nazwie ptrace, tego samego, którego używają debugery. Jądro zatrzymuje śledzony proces przy wejściu do każdego wywołania systemowego i przy wyjściu z niego, a strace w tym czasie odczytuje argumenty i wynik. Dwa przystanki na każdą prośbę, stąd spowolnienie.
Ściąga flag
| Komenda | Flaga | Co robi |
|---|---|---|
bash | -n | sprawdza składnię, nic nie wykonuje |
bash | -x | wypisuje każdą komendę po rozwinięciu |
bash | -v | wypisuje linijki skryptu tak, jak są w pliku |
set | -x / +x | włącza / wyłącza śledzenie w środku skryptu |
PS4 | '+ ${BASH_SOURCE}:${LINENO}: ' | dodaje plik i numer linijki do śledzenia |
BASH_XTRACEFD | numer deskryptora | wysyła śledzenie do osobnego pliku |
shellcheck | -S warning | pomija drobne uwagi, zostawia ostrzeżenia i błędy |
shellcheck | -x | zagląda do plików wczytywanych przez source |
shellcheck | -e SC2086 | pomija wskazany kod |
shellcheck | -f gcc / -f json | wynik dla innych narzędzi |
strace | -f | śledzi też procesy potomne |
strace | -e trace=%file | tylko wywołania związane z plikami |
strace | -Z | tylko nieudane wywołania |
strace | -p PID | podpina się do działającego procesu |
strace | -o PLIK | zapis do pliku zamiast na ekran |
strace | -c | podsumowanie: ile jakich wywołań |
strace | -tt / -T | znacznik czasu / czas trwania wywołania |
⚠ Typowy błąd
Debugujesz skrypt, który wysyła raport z hasłem. Wstawiasz na górze set -x, znajdujesz problem, naprawiasz. I zapominasz usunąć set -x.
#!/bin/bash
set -x
haslo='Tajne123!'
curl -s -u "admin:$haslo" -o /dev/null http://127.0.0.1:9/raport+ haslo='Tajne123!'
+ curl -s -u 'admin:Tajne123!' -o /dev/null http://127.0.0.1:9/raportHasło jest teraz w logu crona, w dzienniku systemowym, w mailu z wynikiem zadania i w zgłoszeniach, do których ktoś wkleił „dziwny wynik skryptu”. Kamera nagrała PIN przy bankomacie i wrzuciła nagranie na tablicę ogłoszeń.
ShellCheck tego nie złapie, bo set -x jest w pełni poprawne. Co robić:
- przed commitem przeszukaj skrypty:
grep -n 'set -x' *.sh; - wokół linijek z sekretami wyłączaj kamerę:
{ set +x; } 2>/dev/nullprzed nimi iset -xpo nich (nawiasy z przekierowaniem sprawiają, że nawet samo wyłączenie się nie nagra); - haseł nie trzymaj w skrypcie w ogóle, tylko w osobnym pliku z uprawnieniami
600albo w menedżerze sekretów. O tym w module o bezpieczeństwie.
✎ Zadanie
Masz skrypt policz.sh, który ma policzyć pliki w folderze:
#!/bin/bash
katalog=$1
ile=0
for f in $katalog/*; do
if [ -f $f ]; then
ile=$((ile + 1))
fi
done
echo "W $katalog jest $ile plików"
if [ $ile -gt 3 ] then
echo "Sporo!"
fi- Sprawdź go
bash -ni zanotuj, w której linijce Bash zgłasza błąd, a w której błąd naprawdę jest. - Popraw składnię, utwórz folder
Moje dokumentyz kilkoma plikami i uruchom skrypt na nim przezbash -x. Znajdź w zapisie kamery miejsce, gdzie nazwa folderu się rozsypuje. - Przepuść skrypt przez ShellCheck i popraw wszystko, co pokaże, aż wynik będzie czysty.
- Bonus: sprawdź
strace -e trace=%file whoami, z jakiego plikuwhoamibierze Twoją nazwę użytkownika.
Pokaż rozwiązanie
$ bash -n policz.sh
policz.sh: line 12: syntax error near unexpected token `fi'
policz.sh: line 12: `fi'Bash krzyczy o linijce 12, czyli o fi. A prawdziwy błąd jest w linijce 10: brakuje średnika przed then. Bez średnika Bash uznaje then za kolejny argument komendy [, czyta dalej i dopiero na fi orientuje się, że nigdzie nie było prawdziwego then. Zasada na całe życie: błąd składni szukaj w zgłoszonej linijce albo wyżej. Nigdy niżej.
Po dopisaniu średnika kamera:
$ bash -x ./policz.sh "Moje dokumenty"
+ katalog='Moje dokumenty'
+ ile=0
+ for f in $katalog/*
+ '[' -f Moje ']'
+ for f in $katalog/*
+ '[' -f 'dokumenty/*' ']'
+ echo 'W Moje dokumenty jest 0 plików'
W Moje dokumenty jest 0 plikówFolder rozsypał się na Moje i dokumenty/*, a gwiazdka nie miała czego złapać. Sześć plików, wynik zero. ShellCheck pokazuje trzy rzeczy: SC2231 (cudzysłów w pętli), SC2086 (cudzysłów w teście) i SC1010 (średnik przed then). Po poprawkach:
for f in "$katalog"/*; do
if [ -f "$f" ]; then
ile=$((ile + 1))
fi
done
echo "W $katalog jest $ile plików"
if [ "$ile" -gt 3 ]; then
echo "Sporo!"
fiW Moje dokumenty jest 6 plików
Sporo!A bonus? strace -e trace=%file whoami pokazuje, że whoami najpierw czyta /etc/nsswitch.conf, czyli spis, gdzie szukać użytkowników, a potem /etc/passwd. Program, który zna Twoje imię, musi je najpierw sprawdzić w spisie lokatorów. Jak recepcjonista, który widzi Cię codziennie od pięciu lat.
Co dalej
Skoro już wiesz, że stderr to osobny głośnik, w następnej lekcji robimy z tego porządek: co skrypt mówi na stdout, a co na stderr, jak pisać do dziennika systemowego przez logger, jak dodać kolory, które nie zaśmiecą logów, i co znaczą kody wyjścia 1, 2, 126, 127 i 130.