Przejdź do treści
Lekcja 51 z 117

Kurs › Moduł 5 › Lekcja 51

Sprzątanie po sobie

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

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: 1

cp 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 kill i 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 1000
Liczę... (naciśnij Ctrl+C)
^CSprzątaczka: usuwam /tmp/tmp.RbJECjvFpN

Dział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ąta

Nacisnął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' TERM

Sprzą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 5
Dostałem TERM
Sprzątaczka sprząta
kod: 143 po 5s

Pięć 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 0s

Zero 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 1000

Zabijamy go z innego terminala przez kill -9:

kod: 137
-rw------- 1 root root 0 Nov  3 22:19 /tmp/tmp.M1DWG1OOrF

Plik 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"
kopiuj
start
cp: cannot stat '/nie/ma/pliku': No such file or directory
BŁĄD: linia 5, komenda: cp /nie/ma/pliku /tmp/, kod: 1

To -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 zachowanie
trap -- 'echo bye' EXIT
trap -- '' SIGINT

trap '' 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 /tmp o nazwie tmp. i dziesięć losowych znaków.
  • -d: folder zamiast pliku. Zwykle wygodniejszy: jeden folder na wszystkie robocze pliki, jedno rm -rf przy 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 folder drwx------. 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.txt

A teraz Ctrl+C w trakcie:

^CPrzerwano.
kod: 130
$ ls wyniki/
$ ls /tmp | grep -c '^tmp\.'
0

Nic. 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

KomendaFlagaCo 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-Epułapka ERR działa też w funkcjach
trap-ppokazuje ustawione pułapki
trap '' INT—ignoruje sygnał
trap - INT—przywraca domyślne zachowanie
kill-llista sygnałów z numerami
wait$!czeka na ostatni proces w tle, daje się przerwać
mktemp(brak)bezpieczny plik w /tmp, prawa 600
mktemp-dbezpieczny folder, prawa 700
mktempwzór.XXXXXXwłasna nazwa, min. 3 iksy
mktemp-p FOLDERtworzy 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.txt

Dlaczego: 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:

  1. tworzy folder tymczasowy przez mktemp -d,
  2. kopiuje do niego /var/log (cp -r, błędy o braku uprawnień możesz wyciszyć),
  3. czeka 10 sekund (sleep 10, udajemy ciężką pracę),
  4. wypisuje, ile plików skopiował i ile zajmują (find … | wc -l, du -sh),
  5. zawsze sprząta swój folder tymczasowy,
  6. po Ctrl+C wypisuje „Przerwano” na stderr i kończy się kodem 130, a po kill kodem 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
0

Za 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.