Sprzątanie po sobie
Po tej lekcji: kursant używa trap, żeby skrypt zawsze po sobie sprzątał, także po błędzie i po Ctrl+C, rozumie podstawowe sygnały i robi bezpieczne pliki tymczasowe przez mktemp.
Wstęp
ls /tmp na serwerze, kilkaset plików raport.1234, raport.1235, kopia_tmp, kopia_tmp2…
Wchodzisz na serwer, który ktoś utrzymywał przed tobą. ls /tmp. Czterysta plików. raport.48213, dump_tmp, dump_tmp_NOWY, test. Każdy skrypt, który kiedyś się wysypał albo ktoś go przerwał Ctrl+C, zostawił tu swoje śmieci. Część zajmuje gigabajty. Część ma w środku hasła do bazy.
To jak impreza, po której nikt nie posprzątał, a gospodarz od pół roku udaje, że kuchnia tak wygląda z założenia.
Dziś zatrudnimy sprzątaczkę, która przychodzi na koniec imprezy zawsze. Czy impreza skończyła się normalnie, czy wybuchła awantura, czy przyjechała policja. Prawie zawsze. Na jedną sytuację nie ma rady i też ją dziś zobaczymy.
trap, czyli sprzątaczka na koniec imprezy
trap to komenda, która mówi Bashowi: „kiedy wydarzy się X, zrób Y”. Najważniejsze „X” to EXIT, czyli moment, w którym skrypt się kończy. Jakkolwiek się kończy.
#!/bin/bash
set -euo pipefail
tmp=$(mktemp)
sprzataj() {
echo "Sprzątaczka: usuwam $tmp"
rm -f -- "$tmp"
}
trap sprzataj EXIT
echo "Pracuję na: $tmp"
ls -l "$tmp"
cp /nie/ma/pliku "$tmp"
echo "tu już nie dojdę"Pracuję na: /tmp/tmp.E9lakmysVp
-rw------- 1 root root 0 Nov 3 22:16 /tmp/tmp.E9lakmysVp
cp: cannot stat '/nie/ma/pliku': No such file or directory
Sprzątaczka: usuwam /tmp/tmp.E9lakmysVp
kod: 1cp się wysypał, set -e zatrzymał skrypt, a mimo to sprzątaczka weszła i posprzątała. I zauważ: kod wyjścia dalej jest 1, sprzątanie go nie zamazało. Plik w /tmp zniknął.
Składnia: trap 'co zrobić' KIEDY. Co zrobić to komenda albo nazwa funkcji. Polecam funkcję, bo sprzątanie lubi rosnąć. A trap ustawiasz zaraz po stworzeniu tego, co trzeba sprzątać. Nie na końcu skryptu, bo na koniec skrypt może nigdy nie dojść.
Sygnały, czyli kto puka do drzwi
Skąd skrypt wie, że ktoś nacisnął Ctrl+C? Dostaje sygnał. Znasz je z lekcji o procesach: SIGTERM to grzeczna prośba o zamknięcie lokalu, SIGKILL to wyłączenie prądu. Pełna lista:
kill -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
...W skryptach liczą się głównie cztery:
- SIGINT (2) — Ctrl+C w terminalu. „Przerwij, proszę.”
- SIGTERM (15) — domyślny sygnał komendy
killi to, co wysyła systemd przy zatrzymywaniu usługi. „Zamykamy lokal.” - SIGHUP (1) — rozłączył się terminal, na przykład padło połączenie SSH. „Klient wyszedł.”
- SIGKILL (9) — wyłączenie prądu. Nie da się go złapać, zablokować ani na niego zareagować.
Pamiętasz wzór z poprzedniej lekcji? Proces zabity sygnałem kończy się kodem 128 plus numer sygnału. Ctrl+C to 130, kill to 143, kill -9 to 137. Jak zobaczysz takie liczby w logu, od razu wiesz, kto zgasił światło.
Ctrl+C: sprzątaczka wchodzi
#!/bin/bash
set -euo pipefail
tmp=$(mktemp)
trap 'echo "Sprzątaczka: usuwam $tmp"; rm -f -- "$tmp"' EXIT
echo "Liczę... (naciśnij Ctrl+C)"
sleep 1000Liczę... (naciśnij Ctrl+C)
^CSprzątaczka: usuwam /tmp/tmp.RbJECjvFpNDziała. W Bashu pułapka na EXIT odpala się także wtedy, gdy skrypt ginie od Ctrl+C albo od zwykłego kill. [dla zaawansowanych: to zachowanie Basha. Inne powłoki, na przykład dash, przy śmierci od sygnału pułapki EXIT nie odpalą. Jeśli kiedyś piszesz pod #!/bin/sh, łap sygnały jawnie.]
Pułapka: sprzątaczka, która nie zamyka drzwi
A teraz coś, co często widać w skryptach z internetu. Ktoś chce mieć pewność i łapie wszystko naraz:
#!/bin/bash
sprzataj() {
echo "Sprzątaczka sprząta"
}
trap sprzataj EXIT INT TERM
echo "Krok 1"
sleep 1000
echo "Krok 2: a ja dalej działam!"Krok 1
^CSprzątaczka sprząta
Krok 2: a ja dalej działam!
Sprzątaczka sprzątaNacisnąłeś Ctrl+C. Sprzątaczka posprzątała. A potem impreza… trwa dalej. Skrypt poszedł do kroku 2, już bez plików tymczasowych, które przed chwilą usunięto. Na koniec sprzątaczka przyszła drugi raz. I kod wyjścia 0, jakby nic się nie stało.
Dlaczego? Bo kiedy złapiesz sygnał, przejmujesz za niego odpowiedzialność. Bash mówi: „Dobra, sam to obsłużysz”, i po obsłudze wraca do pracy. Jeśli chcesz, żeby skrypt się zakończył, musisz sam napisać exit.
Porządny wzór wygląda tak:
trap sprzataj EXIT
trap 'echo "Przerwano." >&2; exit 130' INT
trap 'echo "Zakończono z zewnątrz." >&2; exit 143' TERMSprzątanie siedzi tylko w EXIT. Pułapki na sygnały robią tylko jedno: kończą skrypt z właściwym kodem. A exit sam uruchomi EXIT, więc sprzątaczka przychodzi dokładnie raz.
Pułapka: sprzątaczka czeka, aż kucharz skończy
Druga zaskakująca rzecz. Skrypt odpala długi program, a my wysyłamy mu kill z innego terminala:
#!/bin/bash
set -euo pipefail
trap 'echo "Sprzątaczka sprząta"' EXIT
trap 'echo "Dostałem TERM"; exit 143' TERM
sleep 5Dostałem TERM
Sprzątaczka sprząta
kod: 143 po 5sPięć sekund. kill przyszedł po pół sekundy, a skrypt zareagował dopiero, kiedy sleep skończył. Bash, który czeka na program na pierwszym planie, odkłada obsługę złapanego sygnału na później. Jak kierownik, który nie przerwie kucharzowi w połowie smażenia, tylko poczeka, aż ten zdejmie patelnię. Przy sleep 5 to drobiazg. Przy rsync na godzinę już nie.
Rozwiązanie: odpal długi program w tle i czekaj na niego komendą wait. wait daje się przerwać od razu:
sleep 5 &
wait $!Dostałem TERM
Sprzątaczka sprząta
kod: 143 po 0sZero sekund. Tylko pamiętaj, że wtedy program w tle dalej sobie działa, więc w sprzątaniu warto go zatrzymać: zapisz pid=$! i w funkcji sprzątającej zrób kill "$pid" 2>/dev/null || true.
Na co sprzątaczka nie ma rady
#!/bin/bash
tmp=$(mktemp)
trap 'rm -f -- "$tmp"' EXIT
echo "$tmp"
sleep 1000Zabijamy go z innego terminala przez kill -9:
kod: 137
-rw------- 1 root root 0 Nov 3 22:19 /tmp/tmp.M1DWG1OOrFPlik został. SIGKILL to wyłączenie prądu w całym lokalu. Nikt nie ma szansy niczego posprzątać, bo jądro po prostu usuwa proces z listy. Dlatego kill -9 to ostateczność, a nie pierwsze, co wpisujesz. Najpierw grzeczna prośba, SIGTERM, i daj procesowi chwilę na sprzątanie.
Na takie przypadki zostaje ostatnia linia obrony: /tmp czyszczony przy restarcie i przez systemowe sprzątanie starych plików. I to, żeby śmieci miały rozpoznawalne nazwy. O tym za chwilę.
Inne pułapki do łapania
ERR odpala się przy każdej komendzie, która zawiodła. Świetne do diagnozy, bo powie, gdzie dokładnie umarł skrypt:
#!/bin/bash
set -Eeuo pipefail
trap 'echo "BŁĄD: linia $LINENO, komenda: $BASH_COMMAND, kod: $?" >&2' ERR
kopiuj() {
cp /nie/ma/pliku /tmp/
}
echo "start"
kopiujstart
cp: cannot stat '/nie/ma/pliku': No such file or directory
BŁĄD: linia 5, komenda: cp /nie/ma/pliku /tmp/, kod: 1To -E w set -Eeuo sprawia, że pułapka ERR działa też wewnątrz funkcji. Bez niego błąd w kopiuj przeszedłby bez komunikatu. To jest odpowiedź na „cichą śmierć” z poprzedniej lekcji.
Do tego trzy drobiazgi:
trap -p # pokaż ustawione pułapki
trap '' INT # ignoruj Ctrl+C (pusty napis)
trap - INT # przywróć domyślne zachowanietrap -- 'echo bye' EXIT
trap -- '' SIGINTtrap '' INT przydaje się w krytycznym momencie, na przykład kiedy podmieniasz plik konfiguracyjny i przerwanie w połowie byłoby gorsze niż dokończenie. Tylko nie zapomnij potem przywrócić.
mktemp, czyli bezpieczny pokój na chwilę
Teraz skąd brać pliki tymczasowe. Stara szkoła:
tmp=/tmp/raport.$$$$ to numer procesu, więc wychodzi coś jak /tmp/raport.23025. Problem: ten numer da się przewidzieć. /tmp to biurko na korytarzu, każdy może tam coś położyć. Jeśli złośliwy użytkownik zawczasu położy tam pod tą nazwą dowiązanie do ważnego pliku, twój skrypt uruchomiony jako root może ten ważny plik nadpisać. [dla zaawansowanych: nowoczesne jądra mają osobne zabezpieczenia przed takimi sztuczkami w folderach typu /tmp (fs.protected_symlinks), ale nie pisz skryptów, które liczą na to, że ktoś inny je uratuje.]
mktemp tworzy plik o losowej nazwie, z prawami tylko dla ciebie, i robi to atomowo: albo plik powstał nowy i jest twój, albo komenda zwraca błąd.
mktemp
mktemp -d
mktemp /tmp/kopia.XXXXXX.sql
ls -ld "$(mktemp -d)"/tmp/tmp.jTltpBPq3k
/tmp/tmp.aiKW7KIygc
/tmp/kopia.ixSTyu.sql
drwx------ 2 root root 4096 Nov 3 22:19 /tmp/tmp.biuGykoVIC- Bez argumentów: plik w
/tmpo nazwietmp.i dziesięć losowych znaków. -d: folder zamiast pliku. Zwykle wygodniejszy: jeden folder na wszystkie robocze pliki, jednorm -rfprzy sprzątaniu.- Własny wzór z iksami: iksy zamieniają się na losowe znaki. Muszą być co najmniej trzy. Dawaj nazwę skryptu na początek, żeby wiedzieć, czyje śmieci leżą w
/tmp, jeśli jednak zostaną. - Plik dostaje prawa
-rw-------, a folderdrwx------. Nikt poza tobą nie zajrzy.
mktemp: too few X's in template '/tmp/zle.XX'Jak ustawisz zmienną TMPDIR, mktemp utworzy plik tam zamiast w /tmp. Przydaje się, gdy /tmp jest mały, a ty pracujesz na dużych plikach.
macOS: mktemp też jest, ale z rodziny BSD, a zmienna TMPDIR wskazuje tam zwykle osobny folder użytkownika zamiast /tmp. Proste mktemp i mktemp -d działają na obu systemach.
Wszystko razem: raport, który nigdy nie zostawia połówek
Pamiętasz zepsuty plik kopia.sql.gz z poprzedniej lekcji? 20 bajtów nicości, które wyglądały jak backup. Dziś zrobimy to porządnie. Skrypt zbiera stan serwera do pliku, a plik wynikowy albo powstaje cały, albo wcale.
#!/bin/bash
# raport.sh PLIK — zbiera stan serwera do pliku, nigdy nie zostawia połówek
set -euo pipefail
cel=${1:?Użycie: $0 PLIK_WYNIKOWY}
robocze=$(mktemp -d)
gotowy=$(mktemp "${cel}.XXXXXX")
sprzataj() {
rm -rf -- "$robocze"
rm -f -- "$gotowy"
}
trap sprzataj EXIT
trap 'echo "Przerwano." >&2; exit 130' INT
trap 'echo "Zakończono z zewnątrz." >&2; exit 143' TERM
df -h / > "$robocze/dyski.txt"
free -h > "$robocze/pamiec.txt"
uptime > "$robocze/obciazenie.txt"
sleep 3 # udajemy długą, ciężką pracę
cat "$robocze"/*.txt > "$gotowy"
chmod 644 -- "$gotowy" # mktemp daje 600, raport ma być do czytania
mv -- "$gotowy" "$cel"
echo "Raport gotowy: $cel"Normalne uruchomienie:
Raport gotowy: wyniki/raport.txt
-rw-r--r-- 1 root root 357 Nov 3 22:20 raport.txtA teraz Ctrl+C w trakcie:
^CPrzerwano.
kod: 130
$ ls wyniki/
$ ls /tmp | grep -c '^tmp\.'
0Nic. Ani połówki raportu, ani śmieci w /tmp. Kluczowy jest trik z ostatnimi linijkami: piszemy do pliku tymczasowego obok celu, a na końcu robimy mv. Zmiana nazwy w obrębie jednego systemu plików to dla jądra jedna operacja. Ktoś, kto czyta raport.txt, widzi albo stary raport, albo nowy. Nigdy pół na pół. Jak podmiana kelnera przy stoliku: gość widzi jednego albo drugiego, a nie dwóch kelnerów zrośniętych w połowie.
Dlaczego „obok celu”, a nie w /tmp? Bo /tmp często jest na innym systemie plików, a mv między systemami plików to w rzeczywistości kopiowanie i kasowanie. Już nie jedna operacja.
Jak to działa pod spodem
Sygnał to krótka wiadomość od jądra do procesu: numer i nic więcej. Każdy proces ma tabelkę, w której jądro sprawdza, co z danym sygnałem zrobić: domyślna akcja (zwykle śmierć), ignorowanie albo wywołanie funkcji programu. trap wpisuje do tej tabelki „wywołaj Basha”. SIGKILL i SIGSTOP nie mają w tabelce miejsca do edycji. Jądro obsługuje je samo i proces nawet nie wie, co go trafiło.
Bash, kiedy dostanie złapany sygnał, tylko zapisuje sobie „przyszedł SIGTERM”. Właściwą pułapkę uruchamia w bezpiecznym momencie, między komendami. Dlatego czeka, aż sleep na pierwszym planie skończy. wait jest wyjątkiem i daje się przerwać od razu.
EXIT nie jest prawdziwym sygnałem. To umowna nazwa Basha na „zaraz kończę”. Bash odpala tę pułapkę przy exit, przy końcu skryptu, przy zatrzymaniu przez set -e i przy śmierci od sygnału, który da się złapać.
A jeszcze jedno: sygnały ignorowane w chwili startu powłoki nie dadzą się w niej złapać. Skrypt odpalony w tle z nieinteraktywnej powłoki dostaje Ctrl+C ustawione na „ignoruj” i żaden trap tego nie zmieni. Kiedy testujesz pułapki, testuj w prawdziwym terminalu.
Ściąga flag
| Komenda | Flaga | Co robi |
|---|---|---|
trap 'cmd' EXIT | — | uruchamia cmd przy każdym zakończeniu skryptu |
trap 'cmd' INT TERM | — | łapie Ctrl+C i kill; pamiętaj o exit w środku |
trap 'cmd' ERR | — | uruchamia cmd przy komendzie, która zawiodła |
set | -E | pułapka ERR działa też w funkcjach |
trap | -p | pokazuje ustawione pułapki |
trap '' INT | — | ignoruje sygnał |
trap - INT | — | przywraca domyślne zachowanie |
kill | -l | lista sygnałów z numerami |
wait | $! | czeka na ostatni proces w tle, daje się przerwać |
mktemp | (brak) | bezpieczny plik w /tmp, prawa 600 |
mktemp | -d | bezpieczny folder, prawa 700 |
mktemp | wzór.XXXXXX | własna nazwa, min. 3 iksy |
mktemp | -p FOLDER | tworzy w podanym folderze |
$LINENO, $BASH_COMMAND | — | numer linii i treść komendy (przydatne w ERR) |
⚠ Typowy błąd
Raport powstaje, ale nikt poza rootem nie może go przeczytać:
$ cat /srv/raporty/raport.txt
cat: /srv/raporty/raport.txt: Permission denied
$ ls -l /srv/raporty/raport.txt
-rw------- 1 root root 5 Nov 3 22:22 /srv/raporty/raport.txtDlaczego: plik powstał przez mktemp, a mktemp daje prawa tylko właścicielowi. mv zachowuje prawa, więc plik docelowy też jest 600. Klasyczny objaw w praktyce: serwer WWW zwraca błąd 403 dla pliku, który „przecież tam jest”.
Naprawa: przed mv ustaw prawa świadomie, chmod 644 -- "$gotowy". Tak jak w skrypcie wyżej.
✎ Zadanie
Napisz skrypt kopia_logow.sh, który:
- tworzy folder tymczasowy przez
mktemp -d, - kopiuje do niego
/var/log(cp -r, błędy o braku uprawnień możesz wyciszyć), - czeka 10 sekund (
sleep 10, udajemy ciężką pracę), - wypisuje, ile plików skopiował i ile zajmują (
find … | wc -l,du -sh), - zawsze sprząta swój folder tymczasowy,
- po Ctrl+C wypisuje „Przerwano” na stderr i kończy się kodem 130, a po
killkodem 143.
Sprawdź trzy rzeczy: normalne uruchomienie, Ctrl+C w trakcie sleep i kill z drugiego terminala. Po każdej próbie zajrzyj do /tmp, czy nic nie zostało. ShellCheck ma milczeć.
Pokaż rozwiązanie
#!/bin/bash
# kopia_logow.sh — kopiuje /var/log do folderu tymczasowego, liczy i sprząta
set -euo pipefail
robocze=$(mktemp -d /tmp/kopia_logow.XXXXXX)
pid=""
sprzataj() {
if [[ -n $pid ]]; then
kill "$pid" 2>/dev/null || true
fi
rm -rf -- "$robocze"
}
trap sprzataj EXIT
trap 'echo "Przerwano." >&2; exit 130' INT
trap 'echo "Zakończono z zewnątrz." >&2; exit 143' TERM
cp -r /var/log "$robocze/" 2>/dev/null || true
sleep 10 &
pid=$!
wait "$pid"
pid=""
pliki=$(find "$robocze" -type f | wc -l)
rozmiar=$(du -sh "$robocze" | cut -f1)
echo "Skopiowano plików: $pliki, razem: $rozmiar"Trzy próby:
$ ./kopia_logow.sh
Skopiowano plików: 60, razem: 6.0M
kod: 0
$ ./kopia_logow.sh
^CPrzerwano.
kod: 130
$ ./kopia_logow.sh # a z drugiego terminala: kill PID
Zakończono z zewnątrz.
kod: 143
$ ls /tmp | grep -c kopia_logow
0Za każdym razem /tmp czysty. Trzy szczegóły warte uwagi. mktemp z nazwą skryptu we wzorze, więc gdyby coś jednak zostało, wiadomo czyje. sleep w tle z wait, żeby kill z zewnątrz zadziałał od razu, a nie po dziesięciu sekundach. I || true przy cp, bo jako zwykły użytkownik nie przeczytasz części logów i cp zwróci błąd, a nam wystarczy to, co się dało skopiować. To świadoma decyzja, a nie zamiatanie pod dywan. No, trochę zamiatanie. Ale świadome.
Co dalej
Nasze skrypty są już odporne na spacje, błędy i przerwania. Ale ciągle przyjmują argumenty jak w latach siedemdziesiątych: po kolei, bez nazw, bez pomocy. W następnej lekcji nauczymy je opcji jak w prawdziwych programach: -v, -d FOLDER, -h i --help, przez getopts, ze sprawdzaniem, czy użytkownik podał coś sensownego.