Przejdź do treści
Lekcja 50 z 117

Kurs › Moduł 5 › Lekcja 50

Tryb „zatrzymaj się przy błędzie”

⏱ ok. 14 min czytania· moduł 5: Skrypty, które się nie sypią

Po tej lekcji: kursant włącza w skryptach set -e, set -u i set -o pipefail, rozumie, co każde z nich robi, i zna pułapki, w których set -e po cichu nie działa albo zabija skrypt bez słowa.

Wstęp

Styczeń 2015 roku. Użytkownicy Steama na Linuksie zgłaszają, że po uruchomieniu programu zniknęły im wszystkie ich pliki. Dokumenty. Zdjęcia. Nawet zawartość dysków podpiętych przez USB.

Winowajcą okazała się jedna linijka w skrypcie startowym Steama, steam.sh:

rm -rf "$STEAMROOT/"*

Wygląda niewinnie. Usuń wszystko w folderze Steama. Ale jeśli zmienna STEAMROOT jest pusta, a była pusta u ludzi, którzy przenieśli folder Steama w inne miejsce, to Bash czyta to jako:

rm -rf "/"*

Usuń wszystko od korzenia systemu. Systemu jako takiego nie ruszyło, bo zwykły użytkownik nie ma kluczy do systemowych pokojów. Ale do swoich plików miał klucze. I rm z tych kluczy skorzystał.

Ten skrypt robił dokładnie to, co mu kazano. Skrypty w Bashu domyślnie jadą dalej, cokolwiek się dzieje: komenda się wysypała, zmienna jest pusta, folder nie istnieje. Dziś nauczymy Basha mówić „stop”. I zobaczymy, gdzie to „stop” potrafi cię okłamać.

Kierowca, który jedzie z zapaloną kontrolką

Zacznijmy od prostego skryptu sprzątającego:

#!/bin/bash
cd /nie/ma/takiego/folderu
echo "Jestem w: $PWD, sprzątam..."
bez_e.sh: line 2: cd: /nie/ma/takiego/folderu: No such file or directory
Jestem w: /home/eryk/kurs, sprzątam...
kod: 0

cd się nie udało, ale skrypt poszedł dalej i zaczął „sprzątać” w zupełnie innym folderze, tam, gdzie akurat stał. Na koniec kciuk w górę, kod 0, wszystko świetnie. Jak kierowca, któremu zapaliła się czerwona kontrolka oleju, a on dalej jedzie na wakacje, bo przecież samochód jeszcze jedzie.

ShellCheck od razu to widzi:

SC2164 (warning): Use 'cd ... || exit' or 'cd ... || return' in case cd fails.

set -e: stań przy pierwszej kontrolce

set -e to kierowca, który zatrzymuje się przy pierwszej zapalonej kontrolce. Jeśli jakakolwiek komenda zwróci kciuk w dół, czyli kod inny niż zero, skrypt kończy się od razu z tym kodem.

#!/bin/bash
set -e
cd /nie/ma/takiego/folderu
echo "Jestem w: $PWD, sprzątam..."
z_e.sh: line 3: cd: /nie/ma/takiego/folderu: No such file or directory
kod: 1

Żadnego sprzątania. Skrypt stanął, zanim narobił szkód. Pełna nazwa to set -o errexit, od „exit on error”, wyjdź przy błędzie.

set -u: puste pudełko to błąd

Wróćmy do Steama. Tam problemem nie była nieudana komenda, tylko pusta zmienna. Zobacz, jak łatwo o taką sytuację. Zamiast rm daję echo, żeby tylko zobaczyć, co by się wykonało:

#!/bin/bash
# UWAGA: zamiast rm jest echo, nie zmieniaj tego!
FOLDER_GRY=$(cd "$1" 2>/dev/null && pwd)
echo rm -rf "$FOLDER_GRY/"*
rm -rf /bin /bin.usr-is-merged /boot /cdrom /dev /etc /home /lib ...

Podałem folder, który nie istnieje. cd się nie udało, zmienna jest pusta i "$FOLDER_GRY/"* zamieniło się w /*. Cały system na liście do skasowania.

wielkie ostrzeżenie: „NIE uruchamiaj tego z rm zamiast echo. Nawet na maszynie wirtualnej, chyba że masz migawkę.”

set -u mówi: użycie zmiennej, której nikt nie ustawił, to błąd. Pudełko bez napisu nie jest pustym pudełkiem, tylko alarmem. Pełna nazwa: set -o nounset.

#!/bin/bash
set -u
echo "Sprzątam w: $FOLDR_GRY"
echo "tu już nie dojdę"
u2.sh: line 3: FOLDR_GRY: unbound variable
kod: 1

Zauważ literówkę: FOLDR zamiast FOLDER. Bez set -u Bash po cichu wstawiłby pusty napis. Z set -u od razu wiesz, gdzie się pomyliłeś.

Ważne: set -u łapie zmienne nieustawione, nie puste. Zmienna ustawiona na pusty napis przejdzie. W przykładzie ze Steamem zmienna była ustawiona, tylko na pusto, więc tam ratuje set -e po nieudanym cd albo jawne sprawdzenie. Najkrótsza forma takiego sprawdzenia to ${zmienna:?komunikat}:

#!/bin/bash
: "${BACKUP_DIR:?ustaw BACKUP_DIR, np. BACKUP_DIR=/backup $0}"
echo "Backup do $BACKUP_DIR"
q.sh: line 2: BACKUP_DIR: ustaw BACKUP_DIR, np. BACKUP_DIR=/backup q.sh
kod: 1

:? znaczy „jeśli puste albo nieustawione, wypisz komunikat i zakończ”. A dwukropek na początku linii to komenda, która nic nie robi. Jest tylko po to, żeby Bash rozwinął zmienną i przy okazji ją sprawdził.

Z set -u jest jeden kłopot: argumenty. $1, którego nie podano, też jest nieustawione:

bash: line 1: $1: unbound variable

Dlatego w skryptach z set -u piszesz ${1:-}, czyli „pierwszy argument albo pusty napis”, a potem sam sprawdzasz:

folder=${1:-}
if [[ -z $folder ]]; then
    echo "Użycie: $0 FOLDER" >&2
    exit 1
fi
Użycie: u3.sh FOLDER
kod: 1

macOS: systemowy Bash 3.2 z set -u uznaje pustą tablicę "${a[@]}" za błąd „unbound variable”. Bash 5.2 na serwerze już nie. Kolejny powód, żeby na Macu zainstalować nowego Basha.

pipefail: cała taśma, nie tylko ostatni pracownik

Rura to taśma w fabryce. Ale kod wyjścia rury to domyślnie kod ostatniego pracownika na taśmie. Jeśli pierwszy upuścił paczkę, a ostatni zapakował pusty karton, kontrola jakości widzi ładny karton i kciuk w górę.

Klasyczny przykład: kopia zapasowa bazy danych. W prawdziwym życiu byłby tu mysqldump, u nas w jego roli występuje cat:

#!/bin/bash
set -e
cat /var/lib/baza/dane.sql | gzip > kopia.sql.gz
echo "Kopia gotowa, kod: $?"
cat: /var/lib/baza/dane.sql: No such file or directory
Kopia gotowa, kod: 0
-rw-r--r-- 1 root root 20 Nov  3 22:12 kopia.sql.gz

Pliku z bazą nie ma, a mimo to „Kopia gotowa”. I jest plik kopii. 20 bajtów. Spakowane nic. gzip dostał pustą taśmę, uczciwie ją spakował i zgłosił sukces. A set -e patrzył tylko na gzip. Taki backup odkrywasz w dniu, w którym jest ci potrzebny. Czyli w najgorszym możliwym dniu.

set -o pipefail zmienia zasadę: rura dostaje kod ostatniego pracownika, któremu coś nie wyszło. Jeśli wszyscy zrobili swoje, dopiero wtedy zero.

#!/bin/bash
set -eo pipefail
cat /var/lib/baza/dane.sql | gzip > kopia.sql.gz
echo "Kopia gotowa"
cat: /var/lib/baza/dane.sql: No such file or directory
kod: 1

Zostaje jeszcze zepsuty plik kopia.sql.gz, ale przynajmniej skrypt nie kłamie. A sprzątaniem takich resztek zajmiemy się w następnej lekcji.

Wszystko razem to słynna linijka, którą zobaczysz na górze połowy porządnych skryptów w internecie:

set -euo pipefail

Sprawdzisz, co jest włączone, przez set -o:

errexit        	on
nounset        	on
pipefail       	on

A teraz najważniejsza część lekcji. Bo ta linijka to nie jest pas bezpieczeństwa. To pas bezpieczeństwa z kilkoma dziurami, o których nikt ci nie mówi.

Pułapka 1: if, && i || wyłączają hamulec

set -e nie działa w miejscach, gdzie Bash spodziewa się, że komenda może się nie udać. Czyli w warunku if, while, until, po lewej stronie && i || i po !. To ma sens: if [[ -f plik ]] ma prawo dać „nie”. Ale zobacz, co z tego wynika dla funkcji:

#!/bin/bash
set -e
przygotuj() {
    cp /nie/ma/pliku /tmp/
    echo "przygotuj: kopiuję dalej, jakby nigdy nic"
}
if przygotuj; then
    echo "if: przygotowanie się udało"
fi
przygotuj
echo "tu już nie dojdę"
cp: cannot stat '/nie/ma/pliku': No such file or directory
przygotuj: kopiuję dalej, jakby nigdy nic
if: przygotowanie się udało
cp: cannot stat '/nie/ma/pliku': No such file or directory
kod: 1

Ta sama funkcja zachowuje się zupełnie inaczej w zależności od tego, skąd ją wywołasz. Wywołana normalnie: staje na cp. Wywołana w if: set -e wyłącza się dla całego jej wnętrza, cp się wysypuje, funkcja jedzie dalej, kończy się udanym echo, więc zwraca zero i if ogłasza sukces. Kierowca stanął przy kontrolce na parkingu, ale na autostradzie już nie.

Wniosek: funkcji, które mogą się nie udać, nie wkładaj w if, licząc na set -e. Sprawdzaj błędy w środku jawnie: cp … || return 1.

Pułapka 2: && na końcu funkcji

Teraz odwrotnie: skrypt umiera, choć nic złego się nie stało.

#!/bin/bash
set -e
DEBUG=""
loguj() {
    [[ -n $DEBUG ]] && echo "debug: $1"
}
false && echo "nie wypisze się"
echo "po false &&: żyję"
loguj "start"
echo "po loguj: tu już nie dojdę"
po false &&: żyję
kod: 1

Bez żadnego komunikatu. Co tu się stało? false && echo w głównym skrypcie przeszło, bo lewa strona && jest zwolniona z set -e. Ale funkcja loguj kończy się linijką [[ -n $DEBUG ]] && …. DEBUG jest puste, więc test daje kciuk w dół, a to jest ostatnia komenda funkcji, więc funkcja zwraca 1. A wywołanie loguj "start" nie jest już w żadnym if ani &&. Kontrolka. Stop. Skrypt nie żyje, bo wyłączyłeś debugowanie.

Lekarstwo: w funkcjach pisz if [[ -n $DEBUG ]]; then echo …; fi zamiast skrótu z &&. Albo dopisz na końcu funkcji return 0.

Pułapka 3: local i $( )

#!/bin/bash
set -e
policz() {
    # celowo zły przykład:
    # shellcheck disable=SC2155
    local wynik=$(grep -c "CRITICAL" app.log)
    echo "local: wynik=$wynik, jadę dalej"
    local wynik2
    wynik2=$(grep -c "CRITICAL" app.log)
    echo "osobno: wynik2=$wynik2, tu już nie dojdę"
}
policz
local: wynik=0, jadę dalej
kod: 1

grep -c nic nie znalazł, więc zwraca 1. W pierwszej wersji ten kod ginie, bo local też jest komendą, i to ona zwraca wynik całej linijki. A local zawsze się udaje. Kciuk w górę. W drugiej wersji, z deklaracją i przypisaniem osobno, liczy się kod grep. I tu z kolei skrypt umiera, bo „nie znalazłem” to dla grep kod 1, a dla set -e kod 1 to kod 1.

ShellCheck ostrzega przed pierwszą wersją:

SC2155 (warning): Declare and assign separately to avoid masking return values.

To samo dotyczy export zmienna=$(…), declare i readonly.

Pułapka 4: $( ) nie dziedziczy hamulca

#!/bin/bash
set -e
wynik=$(cp /nie/ma/pliku /tmp/; echo "w środku \$( ) jadę dalej")
echo "wynik: $wynik"
shopt -s inherit_errexit
wynik=$(cp /nie/ma/pliku /tmp/; echo "tego już nie będzie")
echo "tu już nie dojdę"
cp: cannot stat '/nie/ma/pliku': No such file or directory
wynik: w środku $( ) jadę dalej
cp: cannot stat '/nie/ma/pliku': No such file or directory
kod: 1

Wnętrze $( ) to podpowłoka i Bash domyślnie nie przekazuje jej set -e. Pierwsze cp się wysypało, ale echo w środku poszło, i ono dało kod całego $( ). Opcja inherit_errexit (od Basha 4.4) przekazuje hamulec do środka. Dlatego warto ją dopisać pod set -euo pipefail.

Pułapka 5: ((licznik++)) zabija przy zerze

#!/bin/bash
set -e
licznik=0
for plik in a b c; do
    ((licznik++))
    echo "przetworzono $plik, licznik=$licznik"
done
echo "razem: $licznik"
kod: 1

Nic. Ani jednej linijki. Skrypt umarł na pierwszym obrocie pętli. Dlaczego? (( )) zwraca kciuk w dół, gdy wyrażenie wynosi zero. A licznik++ to „podaj starą wartość, potem dodaj”. Stara wartość to zero. Zero to fałsz. Fałsz to kod 1. Kod 1 przy set -e to koniec. Najbardziej absurdalny sposób na śmierć w całym Bashu: skrypt zginął, bo zaczął liczyć od zera, jak każdy porządny informatyk.

Lekarstwo: licznik=$((licznik + 1)) albo ((licznik += 1)), bo tu wynikiem jest już nowa wartość, czyli co najmniej 1.

Pułapka 6: pipefail i cichy kod 141

#!/bin/bash
set -euo pipefail
seq 1 1000000 | head -n 1
echo "kod rury: $?"
1
kod: 141

head wziął pierwszą linijkę i wyszedł, a seq dalej próbował kłaść liczby na taśmę, której już nikt nie odbiera. Jądro wysłało mu sygnał SIGPIPE, „nikt nie słucha”, i seq zakończył się kodem 141, czyli 128 plus numer sygnału 13. Z pipefail to już porażka całej rury, a z set -e koniec skryptu. Bez żadnego komunikatu. Przy dużej ilości danych dzieje się to za każdym razem, przy małej czasem tak, czasem nie, bo zależy, czy wszystko zmieściło się w rurze, zanim head wyszedł. Najgorszy rodzaj błędu: taki, który pojawia się w piątek o 17:00.

Lekarstwo, gdy świadomie ucinasz wynik: … | head -n 1 || true, albo pobierz dane inaczej, na przykład sort plik | head -n 1 zamień na sort plik | sed -n 1p, które czyta wszystko do końca.

Jak to wygląda porządnie

Zbierzmy to w jeden skrypt, który liczy błędy w logu i omija wszystkie dzisiejsze pułapki:

#!/bin/bash
set -euo pipefail
shopt -s inherit_errexit

log=${1:?Użycie: $0 PLIK_LOGU}

policz() {
    local wzorzec=$1
    local wynik
    # grep: 0 = znalazł, 1 = nie znalazł (to OK), 2 = prawdziwy błąd
    wynik=$(grep -c -- "$wzorzec" "$log") || [[ $? -eq 1 ]]
    echo "$wynik"
}

licznik=0
for poziom in ERROR CRITICAL; do
    ile=$(policz "$poziom")
    echo "$poziom: $ile"
    licznik=$((licznik + ile))
done
echo "Razem problemów: $licznik"
$ ./licz_bledy.sh app.log
ERROR: 1
CRITICAL: 0
Razem problemów: 1
$ ./licz_bledy.sh /nie/ma
grep: /nie/ma: No such file or directory
kod: 1

Najciekawsza linijka to || [[ $? -eq 1 ]]. Zamiast tępego || true, które połknęłoby też brak pliku, mówimy dokładnie: „kod 1 od grep jest w porządku, każdy inny nie”. I widać to w drugim uruchomieniu: brak pliku to kod 2, więc skrypt staje.

Jak to działa pod spodem

Każda komenda po zakończeniu oddaje Bashowi liczbę od 0 do 255. Z set -e Bash po każdej komendzie sprawdza tę liczbę i jeśli jest różna od zera, a komenda nie stoi w „miejscu chronionym”, czyli w warunku if, while, until, po lewej stronie && lub ||, po ! albo w środku rury bez pipefail, wychodzi z tym samym kodem.

To „miejsce chronione” działa jak zaraźliwa etykieta: wszystko, co zostanie wywołane z takiego miejsca, łącznie z całym wnętrzem funkcji, też jest chronione. Stąd pułapka numer 1.

$( ) i rury tworzą podpowłoki, czyli kopie procesu Basha. Kopia dostaje zmienne, ale set -e dostaje dopiero z inherit_errexit. Z kolei set -u działa podczas rozwijania zmiennych: zanim komenda w ogóle się uruchomi, Bash zauważa, że pudełko nie istnieje, i przerywa.

A kod 141 przy pipefail? Kiedy program pisze do rury, z której nikt już nie czyta, jądro wysyła mu SIGPIPE. Domyślna reakcja to natychmiastowe zakończenie. Bash raportuje śmierć od sygnału jako 128 plus numer sygnału. Ten wzór przyda się w następnej lekcji, kiedy będziemy łapać sygnały.

Ściąga flag

KomendaFlagaCo robi
set-e / -o errexitkończy skrypt przy pierwszej komendzie z kodem różnym od 0
set-u / -o nounsetnieustawiona zmienna to błąd
set-o pipefailrura zwraca kod ostatniej komendy, która zawiodła
set+ewyłącza -e (plus wyłącza, minus włącza)
set-opokazuje, co jest włączone
shopt-s inherit_errexitset -e działa też w $( ) (Bash 4.4+)
${x:-}—pusty napis, gdy x nieustawione (ratunek przy -u)
${x:?tekst}—błąd z komunikatem, gdy x puste lub nieustawione
cmd|| trueświadomie ignoruje błąd jednej komendy
cmd|| [[ $? -eq 1 ]]akceptuje tylko konkretny kod
((x += 1))—bezpieczne zwiększanie licznika pod set -e

⚠ Typowy błąd

Skrypt z set -e kończy się po cichu, w połowie, bez żadnego komunikatu. Kod 1 albo 141, a w logu pusto.

$ ./raport.sh
$ echo $?
1

Dlaczego: któraś komenda zwróciła kod różny od zera, nie wypisując niczego. Najczęściej grep, który nic nie znalazł, ((licznik++)) przy zerze, test [[ … ]] && … na końcu funkcji albo head w rurze z pipefail.

Jak znaleźć: uruchom skrypt przez bash -x ./raport.sh. Ostatnia linijka z plusem na początku to komenda, która zabiła skrypt. Narzędziom do szukania błędów poświęcimy cała lekcja 53, ale to jedno warto znać już teraz.

✎ Zadanie

Weź skrypt policz_jpg.sh z dzisiejszego rozwiązania i dopisz na górze set -euo pipefail oraz shopt -s inherit_errexit. Uruchom go trzy razy: na folderze zdjecia, na pustym folderze i na folderze, który nie istnieje. Sprawdź echo $? po każdym. Jeśli coś się wysypuje tam, gdzie nie powinno, albo nie wysypuje tam, gdzie powinno, napraw to. Na koniec dorzuć w pętli ((licznik++)) zamiast obecnego dodawania i zobacz, kiedy skrypt umiera, a kiedy nie.

Pokaż rozwiązanie

Dopisujemy pod nagłówkiem set -euo pipefail i shopt -s inherit_errexit, i trzy próby:

$ ./policz_jpg.sh zdjecia
...
Plików: 4, razem: 1500 B
kod 0
$ ./policz_jpg.sh pusty
Plików: 0, razem: 0 B
kod 0
$ ./policz_jpg.sh /nie/ma
find: '/nie/ma': No such file or directory
Plików: 0, razem: 0 B
kod 0

Pierwsze dwa są w porządku. Pusty folder to zero zdjęć, a nie błąd. Ale trzecie? find krzyczy, że folderu nie ma, a skrypt i tak melduje „zero plików” i kciuk w górę. Mimo set -e i pipefail.

To kolejna dziura w siatce: kod wyjścia programu w < <( ) po prostu ginie. Podstawianie procesu to osobny pracownik w osobnym pokoju i nikt nie sprawdza, jak skończył. Najprostsza naprawa: zanim zaczniesz, sprawdź sam.

folder=${1:-.}
if [[ ! -d $folder ]]; then
    echo "Nie ma takiego folderu: $folder" >&2
    exit 1
fi

A ((licznik++))?

$ ./policz_jpg.sh zdjecia
[zdjecia/zwykle.jpg] 1200 B
kod 1

Jeden plik i koniec. Pierwszy obrót: licznik równy zero, ((0)) to fałsz, set -e zabija. Z pustym folderem by przeżył, bo pętla nie zrobiłaby ani jednego obrotu. Skrypt, który działa tylko wtedy, kiedy nie ma nic do roboty. Jak niektóre urzędy.

Co dalej

Skrypt już umie się zatrzymać. Ale kiedy staje w połowie, zostawia po sobie bałagan: pliki tymczasowe, niedokończone kopie, zablokowane rzeczy. W następnej lekcji zatrudnimy sprzątaczkę: trap, który posprząta po skrypcie zawsze, nawet po Ctrl+C, i mktemp, który da nam bezpieczne pliki tymczasowe.