Projekt 3: kopie zapasowe, które naprawdę da się odtworzyć
Po tej lekcji: kursant ma skrypt, który codziennie robi archiwum plików i bazy, trzyma kopie dzienne, tygodniowe i miesięczne, sam sprawdza, czy z kopii da się odzyskać dane, i zostawia raport w pliku, w dzienniku i w mailu.
Wstęp
Piątek, za kwadrans piąta. Dysk w serwerze umiera. Spokojnie, mamy kopie! Codziennie o drugiej w nocy, od trzech lat, cron grzecznie je robi. Otwierasz folder z kopiami. Ostatni plik ma zero bajtów. Przedostatni też. Okazuje się, że od pół roku, od kiedy ktoś zmienił hasło do bazy, skrypt pada w połowie. Cron robił kopie z takim samym zaangażowaniem, z jakim kot pilnuje lodówki. W branży krąży takie powiedzenie: kopia, której nikt nie odtworzył, to nie kopia, to nadzieja. Dziś budujemy system, który nie żyje nadzieją. Robi archiwum, trzyma kopie według mądrego klucza, a potem sam próbuje je odtworzyć i mówi Ci wynik. Codziennie. Bez Twojego udziału.
Plan: spiżarnia z datami na słoikach
Kopie zapasowe to spiżarnia. Pytania są te same co u babci: co wkładamy, gdzie stoi, jak długo trzymamy i czy ktoś czasem otwiera słoik, żeby sprawdzić, czy nie spłeśniał.
- Co. Pliki:
/etc, czyli cała konfiguracja,/var/wwwze stronami z projektu 2,/home. Plus baza danych, ale nie jako pliki z/var/lib/mysql, tylko zrzut przezmysqldump. Kopiowanie plików działającej bazy to jak fotografowanie kuchni w trakcie gotowania: na zdjęciu pół jajka w powietrzu. - Gdzie.
/srv/kopie. Najlepiej na osobnym dysku, a w zadaniu na koniec dorzucimy wysyłkę na drugą maszynę. Pamiętasz zasadę 3-2-1 z lekcji 98: trzy kopie, dwa różne nośniki, jedna poza budynkiem. - Jak długo. Rotacja „dziadek, ojciec, syn”: 7 kopii dziennych, 4 niedzielne, 6 z pierwszego dnia miesiąca. Dzięki temu masz wczoraj i przedwczoraj, ale też stan sprzed miesiąca, gdy okazuje się, że ktoś skasował plik trzy tygodnie temu i dopiero teraz zauważył.
- Czy da się odtworzyć. Po każdej kopii: suma kontrolna, pełne rozpakowanie do folderu tymczasowego, porównanie liczby plików i sprawdzenie kanarka. O kanarku za chwilę.
- Raport. Zawsze, także gdy się nie uda. Zwłaszcza gdy się nie uda.
Konfiguracja osobno od kodu
Ustawienia trzymamy w osobnym pliku /etc/kopia.conf. Skrypt ma rozsądne wartości domyślne, a plik je nadpisuje. Ten sam skrypt na pięciu serwerach, pięć różnych kartek z ustawieniami. Przykład dla serwera z projektu 2:
# /etc/kopia.conf
CEL=/srv/kopie
ZRODLA=(/etc /var/www /home /root)
DNI=7
TYGODNIE=4
MIESIACE=6
BAZY_MYSQL=1
MAIL_DO=admin@mojafirma.plUwaga, ważne: ten plik jest wczytywany kropką, czyli wykonywany jak kod, z prawami roota. Kto może go edytować, ten może zrobić na serwerze wszystko. Dlatego: właściciel root, prawa 600.
sudo chown root:root /etc/kopia.conf && sudo chmod 600 /etc/kopia.confA hasło do bazy? Na świeżej MariaDB root z systemu wchodzi do bazy bez hasła, przez gniazdo, bo baza ufa temu, że system już go sprawdził. Jeśli u Ciebie jest inaczej, hasło idzie do osobnego pliku z prawami 600 i podajesz go przez MYSQL_OPCJE=(--defaults-extra-file=/root/.kopia.cnf). Nigdy w skrypcie, pamiętasz lekcja 105.
Skrypt: pełny kod
Plik /usr/local/sbin/kopia.sh. sbin, bo to narzędzie dla roota.
#!/usr/bin/env bash
# kopia.sh — kopie zapasowe z rotacją, raportem i testem odtwarzania
# Użycie: sudo kopia.sh (kopia + rotacja + test + raport)
# sudo kopia.sh -t PLIK (tylko test odtwarzania wskazanego archiwum)
set -euo pipefail
umask 077
# ---------- Ustawienia domyślne (nadpisuje je /etc/kopia.conf) ----------
CEL=/srv/kopie # dokąd zapisywać archiwa
ZRODLA=(/etc /var/www /home) # co kopiować
WYKLUCZ=('*.tmp' '*/.cache/*') # czego nie kopiować
DNI=7 # ile kopii dziennych trzymać
TYGODNIE=4 # ile niedzielnych
MIESIACE=6 # ile z pierwszego dnia miesiąca
BAZY_MYSQL=0 # 1 = zrzut MariaDB/MySQL przed kopią
MYSQL_OPCJE=() # np. (--defaults-extra-file=/root/.kopia.cnf)
MAIL_DO="" # adres na raport; pusty = bez maila
STAN=/var/lib/kopia # kanarek i zrzuty baz
KONF=${KOPIA_KONF:-/etc/kopia.conf}
# shellcheck source=/dev/null
[[ -r $KONF ]] && . "$KONF"
DZIS=${KOPIA_DATA:-$(date +%F)} # KOPIA_DATA=2026-11-01 tylko do testów rotacji
HOST=$(hostname -s)
START=$(date +%s)
RAPORT=$(mktemp)
SPRZATAJ=("$RAPORT")
STATUS=OK
log() { printf '%s\n' "$*" | tee -a "$RAPORT"; }
blad() { STATUS=BŁĄD; log "BŁĄD: $*"; exit 1; }
sprzatanie() {
local kod=$?
(( kod == 0 )) || STATUS=BŁĄD
if [[ -n ${PLIK_RAPORTU:-} ]]; then
log "Czas: $(( $(date +%s) - START )) s, status: $STATUS"
cp "$RAPORT" "$PLIK_RAPORTU"
logger -t kopia "status=$STATUS raport=$PLIK_RAPORTU" || true
if [[ -n $MAIL_DO ]] && command -v mail >/dev/null; then
mail -s "[kopia] $HOST $DZIS: $STATUS" "$MAIL_DO" < "$RAPORT" || true
fi
fi
rm -rf -- "${SPRZATAJ[@]}"
exit "$kod"
}
trap sprzatanie EXIT
# test_odtwarzania ARCHIWUM — czy z kopii da się naprawdę coś odzyskać
test_odtwarzania() {
local plik=$1 tmp w_archiwum odtworzone
log "Test odtwarzania: $(basename "$plik")"
[[ -f $plik && -f $plik.sha256 ]] || blad "brak archiwum albo sumy kontrolnej: $plik"
( cd "$(dirname "$plik")" && sha256sum --quiet -c "$(basename "$plik").sha256" ) \
|| blad "suma kontrolna się NIE zgadza, archiwum uszkodzone"
log " [ OK ] suma SHA-256 zgodna"
tmp=$(mktemp -d "$CEL/.test.XXXXXX"); SPRZATAJ+=("$tmp")
tar -xzf "$plik" -C "$tmp" || blad "tar nie umie rozpakować archiwum"
w_archiwum=$(tar -tzf "$plik" | grep -vc '/$')
odtworzone=$(find "$tmp" -mindepth 1 ! -type d | wc -l)
(( w_archiwum == odtworzone )) || blad "w archiwum $w_archiwum plików, odtworzono $odtworzone"
log " [ OK ] odtworzono wszystkie $odtworzone plików"
[[ -f $tmp$STAN/kanarek ]] || blad "w kopii nie ma kanarka"
log " [ OK ] kanarek żyje: $(cat "$tmp$STAN/kanarek")"
grep -qxF "$(cat "$STAN/kanarek")" "$tmp$STAN/kanarek" \
|| log " [INFO] to starsza kopia, kanarek z innego dnia niż bieżący"
if [[ -f $tmp$STAN/bazy.sql.gz ]]; then
gzip -t "$tmp$STAN/bazy.sql.gz" || blad "zrzut bazy uszkodzony"
zcat "$tmp$STAN/bazy.sql.gz" | tail -n 1 | grep -q 'Dump completed' \
|| blad "zrzut bazy urwany w połowie"
log " [ OK ] zrzut bazy kompletny"
fi
rm -rf -- "$tmp"
}
# rotuj KATALOG ILE — zostaw ILE najnowszych archiwów, resztę usuń
rotuj() {
local kat=$1 ile=$2 p
local -a pliki
(( ile >= 1 )) || blad "rotacja $kat: liczba kopii musi być co najmniej 1"
mapfile -t pliki < <(find "$kat" -maxdepth 1 -name "kopia-$HOST-*.tar.gz" -printf '%f\n' | sort -r)
for p in "${pliki[@]:ile}"; do
rm -f -- "${kat:?}/$p" "${kat:?}/$p.sha256"
log " usunięto stare: $(basename "$kat")/$p"
done
log " $(basename "$kat"): zostało $(( ${#pliki[@]} < ile ? ${#pliki[@]} : ile )) z $ile"
}
# ---------- Start ----------
[[ $EUID -eq 0 ]] || { echo "Uruchom przez sudo" >&2; exit 1; }
install -d -m 700 "$CEL" "$CEL/dzienne" "$CEL/tygodniowe" "$CEL/miesieczne" "$STAN"
exec 9>"$CEL/.lock"
flock -n 9 || { echo "Kopia już trwa (blokada $CEL/.lock)" >&2; exit 1; }
if [[ ${1:-} == -t ]]; then
[[ -n ${2:-} ]] || { echo "Użycie: $0 -t ARCHIWUM" >&2; exit 1; }
test_odtwarzania "$2"
exit 0
fi
PLIK_RAPORTU=$CEL/raport-ostatni.txt
log "=== Kopia $HOST, $DZIS ==="
# ---------- 1. Kanarek i zrzut baz ----------
printf '%s %s\n' "$DZIS" "$(head -c 6 /dev/urandom | od -An -tx1 | tr -d ' \n')" > "$STAN/kanarek"
if (( BAZY_MYSQL )); then
SPRZATAJ+=("$STAN/bazy.sql.gz.czesc")
mysqldump "${MYSQL_OPCJE[@]}" --all-databases --single-transaction --routines --events \
| gzip > "$STAN/bazy.sql.gz.czesc" || blad "mysqldump się nie udał"
mv "$STAN/bazy.sql.gz.czesc" "$STAN/bazy.sql.gz"
log "Zrzut baz: $(du -h "$STAN/bazy.sql.gz" | cut -f1)"
fi
# ---------- 2. Archiwum ----------
PLIK=$CEL/dzienne/kopia-$HOST-$DZIS.tar.gz
SCIEZKI=()
for z in "${ZRODLA[@]}" "$STAN"; do
if [[ -e $z ]]; then SCIEZKI+=("${z#/}"); else log "UWAGA: nie ma $z, pomijam"; fi
done
WYK=()
for w in "${WYKLUCZ[@]}"; do WYK+=(--exclude="$w"); done
SPRZATAJ+=("$PLIK.czesc")
rc=0
tar -czf "$PLIK.czesc" -C / "${WYK[@]}" --exclude="${CEL#/}" "${SCIEZKI[@]}" 2>"$RAPORT.tar" || rc=$?
SPRZATAJ+=("$RAPORT.tar")
if (( rc == 1 )); then
log "UWAGA: część plików zmieniała się w trakcie kopiowania:"
sed 's/^/ /' "$RAPORT.tar" | tee -a "$RAPORT"
elif (( rc > 1 )); then
cat "$RAPORT.tar" >> "$RAPORT"
blad "tar zakończył się kodem $rc"
fi
mv "$PLIK.czesc" "$PLIK"
( cd "$CEL/dzienne" && sha256sum "$(basename "$PLIK")" > "$(basename "$PLIK").sha256" )
log "Archiwum: $(basename "$PLIK"), $(du -h "$PLIK" | cut -f1), źródła: ${ZRODLA[*]}"
# ---------- 3. Kopie tygodniowe i miesięczne (twarde dowiązania, zero miejsca) ----------
if [[ $(date -d "$DZIS" +%u) == 7 ]]; then
ln -f "$PLIK" "$PLIK.sha256" "$CEL/tygodniowe/"
log "Niedziela: dowiązanie w tygodniowe/"
fi
if [[ $(date -d "$DZIS" +%d) == 01 ]]; then
ln -f "$PLIK" "$PLIK.sha256" "$CEL/miesieczne/"
log "Pierwszy dzień miesiąca: dowiązanie w miesieczne/"
fi
# ---------- 4. Rotacja ----------
log "Rotacja:"
rotuj "$CEL/dzienne" "$DNI"
rotuj "$CEL/tygodniowe" "$TYGODNIE"
rotuj "$CEL/miesieczne" "$MIESIACE"
# ---------- 5. Test odtwarzania świeżej kopii ----------
test_odtwarzania "$PLIK"
# ---------- 6. Podsumowanie ----------
log "Miejsce w $CEL: $(df -h --output=avail "$CEL" | tail -n 1 | tr -d ' ') wolne, kopie zajmują $(du -sh "$CEL" | cut -f1)"Co tu jest ciekawego
Raport zawsze, nawet przy katastrofie. Funkcja sprzatanie jest podpięta pod trap ... EXIT, czyli to nasza sprzątaczka na koniec imprezy. Wchodzi zawsze: gdy skrypt skończył się dobrze, gdy set -e zatrzymał go na pierwszej kontrolce i gdy sami wołamy blad. Najpierw sprawdza kod wyjścia. Jeśli nie zero, status to BŁĄD. Potem zapisuje raport do pliku, do dziennika systemowego przez logger i wysyła maila. Na końcu sprząta pliki tymczasowe z tablicy SPRZATAJ. Czyli scenariusz z haka, cichy upadek przez pół roku, odpada: każdy upadek kończy się mailem ze słowem BŁĄD w temacie.
umask 077. W kopii jest /etc/shadow z haszami haseł i klucze certyfikatów z projektu 2. Archiwum czytelne dla wszystkich to jak trzymanie zapasowych kluczy do całego biurowca w szufladzie na korytarzu. umask 077 sprawia, że wszystko, co skrypt utworzy, ma prawa tylko dla właściciela.
flock. Gdy kopia trwa dłużej niż zwykle, a cron odpali kolejną, dwie kopie piszące do tego samego folderu to proszenie się o kłopoty. exec 9>plik otwiera plik blokady jako deskryptor 9, a flock -n 9 próbuje zająć blokadę bez czekania. Kartka „zajęte” na drzwiach łazienki. Blokadę zwalnia jądro, gdy proces się kończy, nawet po kill -9, więc nie ma problemu z kartką, którą ktoś zapomniał zdjąć.
Najpierw .czesc, potem mv. Archiwum powstaje pod nazwą z końcówką .czesc. Dopiero gdy tar skończy, zmieniamy nazwę na docelową. Zmiana nazwy w obrębie jednego systemu plików jest niepodzielna: albo plik jest cały pod nową nazwą, albo go nie ma. Nigdy nie zobaczysz pół archiwum udającego całe. Jak z ciastem: nie wystawiasz go na ladę, dopóki nie wyjdzie z pieca.
tar -C / i ścieżki bez ukośnika. Usuwamy początkowy / ze źródeł, ${z#/}, i każemy tar zacząć od katalogu głównego. W archiwum są wtedy ścieżki etc/hosts, a nie /etc/hosts. Dzięki temu rozpakowujesz kopię tam, gdzie chcesz, a nie przypadkiem na żywy system.
Kod wyjścia tara: 1 to nie 2. O tym w typowym błędzie, bo to klasyk.
Suma SHA-256 obok archiwum. Plik .sha256 to odcisk palca kopii. Jeśli dysk albo transfer przestawią choćby jeden bit, odcisk się nie zgodzi.
Rotacja przez sortowanie nazw. W nazwie jest data w formacie RRRR-MM-DD, więc alfabetycznie znaczy chronologicznie. sort -r stawia najnowsze na początku, a ${pliki[@]:ile} bierze wszystko od pozycji ile do końca, czyli te za stare. Szuflada z przegródkami, z której wyrzucasz wszystko poza pierwszymi siedmioma. ${kat:?} przy rm to bezpiecznik z lekcji 105: gdyby zmienna była pusta, skrypt stanie, zamiast kasować od korzenia.
Tygodniowe i miesięczne za darmo. Kopia niedzielna nie jest kopiowana, tylko dowiązana twardym linkiem ln -f. Pamiętasz z lekcji 106: twardy link to druga etykieta na tym samym i-węźle. Jeden słoik, trzy karteczki: „połówka tygodnia”, „niedziela”, „początek miesiąca”. Rotacja dziennych zdejmuje tylko swoją karteczkę, a słoik zostaje, dopóki ma choć jedną.
Kanarek. Górnicy brali do kopalni kanarka, bo padał od gazu wcześniej niż człowiek. U nas kanarek to mały plik z dzisiejszą datą i losowym napisem, zapisywany tuż przed kopią. Po rozpakowaniu sprawdzamy, czy jest w środku. Jeśli kanarek z dzisiejszą datą jest w kopii, to wiemy, że kopia jest dzisiejsza, a nie na przykład stary plik, który ktoś podmienił.
Sprawdzenie zrzutu bazy. mysqldump kończy plik linijką -- Dump completed on .... Jeśli zrzut się urwie, tej linijki nie ma. Prosty test, a łapie najgorszy przypadek: plik, który wygląda na pełny, ale kończy się w połowie tabeli z zamówieniami.
Uruchomienie
sudo install -m 700 kopia.sh /usr/local/sbin/kopia.sh
sudo kopia.shWynik z mojego laboratorium, z bazą MariaDB i jednym celowo błędnym źródłem w konfiguracji, żebyś zobaczył, jak wygląda ostrzeżenie:
=== Kopia vm, 2026-11-04 ===
Zrzut baz: 512K
UWAGA: nie ma /nie/ma/takiego, pomijam
Archiwum: kopia-vm-2026-11-04.tar.gz, 2.2M, źródła: /etc /var/www /nie/ma/takiego
Rotacja:
usunięto stare: dzienne/kopia-vm-2026-10-28.tar.gz
dzienne: zostało 7 z 7
tygodniowe: zostało 2 z 4
miesieczne: zostało 1 z 3
Test odtwarzania: kopia-vm-2026-11-04.tar.gz
[ OK ] suma SHA-256 zgodna
[ OK ] odtworzono wszystkie 3087 plików
[ OK ] kanarek żyje: 2026-11-04 48613f07c9c1
[ OK ] zrzut bazy kompletny
Miejsce w /srv/kopie: 24G wolne, kopie zajmują 14M
Czas: 3 s, status: OKSkąd „usunięto stare” przy pierwszym uruchomieniu? Bo nie czekałem dwóch miesięcy na nagranie. Zmienna KOPIA_DATA pozwala udawać inny dzień, więc przepuściłem skrypt w pętli przez kolejne daty:
d=2026-09-01
while [[ $d < 2026-11-05 ]]; do
sudo KOPIA_DATA=$d kopia.sh >/dev/null || echo "PORAŻKA $d"
d=$(date -d "$d +1 day" +%F)
done
ls /srv/kopie/*/ | grep -v sha256/srv/kopie/dzienne/:
kopia-vm-2026-10-29.tar.gz
kopia-vm-2026-10-30.tar.gz
kopia-vm-2026-10-31.tar.gz
kopia-vm-2026-11-01.tar.gz
kopia-vm-2026-11-02.tar.gz
kopia-vm-2026-11-03.tar.gz
kopia-vm-2026-11-04.tar.gz
/srv/kopie/miesieczne/:
kopia-vm-2026-09-01.tar.gz
kopia-vm-2026-10-01.tar.gz
kopia-vm-2026-11-01.tar.gz
/srv/kopie/tygodniowe/:
kopia-vm-2026-10-11.tar.gz
kopia-vm-2026-10-18.tar.gz
kopia-vm-2026-10-25.tar.gz
kopia-vm-2026-11-01.tar.gzSześćdziesiąt pięć dni w niecałe trzy minuty, prawie jak wakacje. Siedem dziennych, cztery niedzielne, a miesięczne trzy, bo w testowej konfiguracji miałem MIESIACE=3. 1 listopada 2026 to niedziela i pierwszy dzień miesiąca, więc ten jeden plik siedzi w trzech folderach naraz. Dowód, że to ten sam słoik:
ls -li /srv/kopie/*/kopia-vm-2026-11-01.tar.gz1505004 -rw------- 3 root root 1713850 Nov 4 02:50 /srv/kopie/dzienne/kopia-vm-2026-11-01.tar.gz
1505004 -rw------- 3 root root 1713850 Nov 4 02:50 /srv/kopie/miesieczne/kopia-vm-2026-11-01.tar.gz
1505004 -rw------- 3 root root 1713850 Nov 4 02:50 /srv/kopie/tygodniowe/kopia-vm-2026-11-01.tar.gzTen sam numer i-węzła, licznik linków 3. Miejsce na dysku zajęte raz.
Test odtwarzania na żądanie i uszkodzona kopia
Opcja -t sprawdza dowolne archiwum, na przykład stare tygodniowe:
sudo kopia.sh -t /srv/kopie/tygodniowe/kopia-vm-2026-10-11.tar.gzTest odtwarzania: kopia-vm-2026-10-11.tar.gz
[ OK ] suma SHA-256 zgodna
[ OK ] odtworzono wszystkie 3085 plików
[ OK ] kanarek żyje: 2026-10-11 d1d751b2be5a
[INFO] to starsza kopia, kanarek z innego dnia niż bieżącyA teraz psujemy. Jeden bajt w środku archiwum, symulacja umierającego dysku. Uwaga, dd z conv=notrunc pisze po istniejącym pliku, więc rób to na kopii, której nie potrzebujesz, w laboratorium:
printf '\xff' | sudo dd of=/srv/kopie/dzienne/kopia-vm-2026-11-03.tar.gz bs=1 seek=500000 conv=notrunc status=none
sudo kopia.sh -t /srv/kopie/dzienne/kopia-vm-2026-11-03.tar.gz; echo "kod=$?"Test odtwarzania: kopia-vm-2026-11-03.tar.gz
kopia-vm-2026-11-03.tar.gz: FAILED
sha256sum: WARNING: 1 computed checksum did NOT match
BŁĄD: suma kontrolna się NIE zgadza, archiwum uszkodzone
kod=1Jeden bajt na 1,7 megabajta i odcisk palca od razu się nie zgadza. Druga próba uruchomienia, gdy pierwsza jeszcze trwa:
Kopia już trwa (blokada /srv/kopie/.lock)I najczęstszy prawdziwy scenariusz: ktoś skasował jeden plik i chce go z powrotem. Nigdy nie rozpakowujesz kopii prosto na /. Rozpakowujesz do folderu obok, patrzysz i dopiero kopiujesz:
mkdir -p /tmp/odzysk
sudo tar -xzf /srv/kopie/dzienne/kopia-vm-2026-11-02.tar.gz -C /tmp/odzysk etc/hosts
ls -l /tmp/odzysk/etc
diff /tmp/odzysk/etc/hosts /etc/hoststotal 4
-rw-r--r-- 1 root root 81 Nov 4 02:36 hostsNazwę w archiwum podajesz bez początkowego ukośnika, dokładnie tak, jak wypisuje ją tar -tzf. Uprawnienia i data pliku wróciły razem z nim. [dla zaawansowanych: nasz test odtwarzania bazy sprawdza, czy zrzut jest cały, ale nie ładuje go do bazy. Pełny test to uruchomienie tymczasowej instancji MariaDB, na przykład w kontenerze z lekcji 94, załadowanie zrzutu i jedno zapytanie typu SELECT COUNT(*) na najważniejszej tabeli. Raz w miesiącu warto. A jeśli kopie mają wyjść poza firmę, szyfruj je, na przykład gpg z lekcji 85, albo od razu przesiądź się na restic lub borg z lekcji 98, które szyfrują i deduplikują same.]
Harmonogram: cron albo timer
Wersja klasyczna, budzik, plik /etc/cron.d/kopia:
# /etc/cron.d/kopia — codziennie o 2:30
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
30 2 * * * root /usr/local/sbin/kopia.sh >/dev/null 2>&1Wynik wyrzucamy do /dev/null, bo raport i tak ląduje w pliku, w dzienniku i w mailu. W plikach w /etc/cron.d jest kolumna z użytkownikiem, której nie ma w crontab -e. Pamiętasz lekcja 87. Wersja nowoczesna, dwa pliki systemd. /etc/systemd/system/kopia.service:
[Unit]
Description=Kopia zapasowa (kopia.sh)
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/kopia.sh
Nice=10
IOSchedulingClass=idleI /etc/systemd/system/kopia.timer:
[Unit]
Description=Codzienna kopia zapasowa o 2:30
[Timer]
OnCalendar=*-*-* 02:30:00
RandomizedDelaySec=15min
Persistent=true
[Install]
WantedBy=timers.targetsudo systemd-analyze verify /etc/systemd/system/kopia.timer /etc/systemd/system/kopia.service
systemd-analyze calendar '*-*-* 02:30:00'
sudo systemctl daemon-reload
sudo systemctl enable --now kopia.timer
systemctl list-timers kopia.timerNormalized form: *-*-* 02:30:00
Next elapse: Thu 2026-11-05 02:30:00 CET
(in UTC): Thu 2026-11-05 01:30:00 UTC
From now: 23h leftCo daje timer ponad crona? Persistent=true: jeśli serwer był wyłączony o 2:30, kopia zrobi się zaraz po starcie, a cron po prostu ją przegapi. Nice=10 i IOSchedulingClass=idle mówią jądru, żeby kopia ustępowała miejsca klientom sklepu, jak ktoś, kto zamiata sklep, ale odsuwa się, gdy wchodzi klient. Do tego cały wynik masz w journalctl -u kopia.service. [dla zaawansowanych: pliki jednostek sprawdzisz przed włączeniem przez systemd-analyze verify, a wyrażenie czasu przez systemd-analyze calendar. Przy logger w skrypcie stoi || true, żeby brak dziennika, na przykład w chroocie z płyty ratunkowej, nie zabijał raportu.]
Jak to działa pod spodem
Co się dzieje przy tar -czf? tar chodzi po drzewie katalogów i każdy plik zapisuje jako nagłówek z nazwą, właścicielem, prawami i datą, a za nim treść. To wszystko leci jednym strumieniem przez gzip, który kompresuje całość naraz. Wada: żeby wyciągnąć jeden plik z końca archiwum, tar musi rozpakować wszystko przed nim. Przy kilku gigabajtach to kwestia minut. Narzędzia jak restic i borg tną pliki na kawałki i trzymają indeks, więc pojedynczy plik wyciągają od razu, a powtarzające się kawałki zapisują raz. Nasz skrypt to dobry pierwszy system i świetny materiał na rozmowę o pracy. Przy dużych danych przesiadka na restic albo borg to naturalny następny krok. Czemu folder testowy powstaje w $CEL, a nie w /tmp? Bo w wielu systemach /tmp jest w pamięci RAM, w tmpfs. Rozpakowanie kilku gigabajtów do RAM-u to sposób na zabicie serwera testem, który miał mu pomóc. Na dysku kopii miejsce i tak musi być. flock to nie plik z napisem „zajęte”, tylko blokada trzymana przez jądro na otwartym pliku. Sam plik .lock może leżeć na dysku latami. Liczy się tylko to, czy jakiś żywy proces trzyma go otwartego z blokadą. Dlatego nie ma problemu ze „zdechniętą” blokadą po awarii, typowego dla skryptów, które robią touch /tmp/kopia.lock.
Ściąga
| Komenda | Flaga | Co robi |
|---|---|---|
tar | -czf PLIK -C / | tworzy archiwum gzip, ścieżki liczone od / |
tar | --exclude=WZORZEC | pomija pasujące pliki |
tar | -tzf | wypisuje zawartość bez rozpakowania |
tar | -xzf PLIK -C KATALOG ŚCIEŻKA | wyciąga jeden plik do wskazanego folderu |
sha256sum | -c --quiet | sprawdza sumy, odzywa się tylko przy błędzie |
flock | -n | nie czeka, gdy blokada zajęta, tylko kończy się błędem |
ln | -f | twardy link, nadpisuje istniejący |
mysqldump | --single-transaction | spójny zrzut InnoDB bez blokowania tabel |
mysqldump | --all-databases --routines --events | wszystkie bazy razem z procedurami i zdarzeniami |
gzip | -t | test integralności archiwum gzip |
logger | -t ZNACZNIK | wpis do dziennika systemowego |
systemd-analyze | verify, calendar | sprawdza pliki jednostek i wyrażenie czasu |
⚠ Typowy błąd
Skrypt z set -e robi kopię przez tar, w nocy działa, a raz na kilka dni pada bez żadnej kopii. W logach:
tar: www/blog/b.log: file changed as we read itI nic więcej. Zrobiłem to celowo, z drugim procesem dopisującym do pliku w trakcie kopiowania:
#!/usr/bin/env bash
set -euo pipefail
( for i in $(seq 200); do echo x >> www/blog/b.log; sleep 0.01; done ) &
tar -czf /tmp/x.tgz www
echo "Kopia gotowa"tar: www/blog/b.log: file changed as we read it
kod=1Napis „Kopia gotowa” się nie pojawił. Co się stało? GNU tar kończy się kodem 1, gdy któryś plik zmienił się w trakcie czytania. To ostrzeżenie: archiwum jest, tylko ten jeden plik może być w nim w stanie „pomiędzy”. Kod 2 to dopiero prawdziwa awaria. A set -e nie zna niuansów, dla niego wszystko poza zerem to kciuk w dół, więc staje przy pierwszej kontrolce. Na serwerze z logami, które rosną cały czas, to się dzieje regularnie. Naprawa jest w naszym skrypcie: || rc=$? przechwytuje kod, zamiast pozwolić set -e zabić skrypt. Kod 1 to ostrzeżenie w raporcie, kod 2 i wyżej to błąd. I tak wygląda to w raporcie:
UWAGA: część plików zmieniała się w trakcie kopiowania:
tar: var/www/blog/duzy.log: file changed as we read itIle w tym prawdy? Plik, który się zmieniał, może być w kopii niespójny. Dla logów to bez znaczenia. Dla plików bazy danych to katastrofa, i to jest drugi powód, żeby bazy kopiować przez mysqldump, a nie przez tar na /var/lib/mysql.
✎ Zadanie
Dopisz do kopia.sh krok 7: wysyłanie całego katalogu kopii na drugą maszynę wirtualną przez rsync po SSH z logowaniem kluczem. Adres celu trzymaj w /etc/kopia.conf w zmiennej ZDALNIE, na przykład ZDALNIE=kopie@backup:/srv/kopie/web1. Gdy ZDALNIE jest puste, krok ma się pomijać, a gdy rsync się nie uda, raport ma mieć status BŁĄD. Potem na drugiej maszynie sprawdź sha256sum -c, że archiwa doszły całe.
Pokaż rozwiązanie
Kopia poza serwerem. Do ustawień w kopia.sh dochodzą dwie zmienne:
ZDALNIE="" # np. kopie@backup:/srv/kopie/web1; pusty = bez wysyłki
RSYNC_SSH="ssh -o BatchMode=yes" # jak rsync ma się łączyć (port, klucz)A na końcu skryptu krok 7:
# ---------- 7. Kopia poza serwerem (3-2-1) ----------
if [[ -n $ZDALNIE ]]; then
rsync -aH --delete -e "$RSYNC_SSH" --exclude=.lock --exclude='.test.*' \
"$CEL/" "$ZDALNIE/" 2>>"$RAPORT" || blad "rsync do $ZDALNIE się nie udał"
log "Wysłano kopie na $ZDALNIE"
else
log "UWAGA: ZDALNIE puste, kopie są tylko na tym serwerze"
fiBatchMode=yes to ważny drobiazg. Bez niego SSH, któremu nie zadziała klucz, grzecznie zapyta o hasło i będzie czekał do rana, bo w cronie nikt nie odpowie. Z nim od razu kończy się błędem, a błąd trafia do raportu. W /etc/kopia.conf:
ZDALNIE=kopie@backup:/srv/kopie/web1
RSYNC_SSH="ssh -i /root/.ssh/kopia_ed25519 -o BatchMode=yes"Klucz bez hasła, osobny, tylko do kopii. Pamiętasz z lekcji 76: ssh-keygen -t ed25519 -f /root/.ssh/kopia_ed25519 -N '', a publiczny do authorized_keys użytkownika kopie na maszynie z kopiami. Wynik i sprawdzenie po drugiej stronie:
[ OK ] zrzut bazy kompletny
Wysłano kopie na kopie@backup:/srv/kopie/web1
Czas: 1 s, status: OKcd /srv/kopie/web1/dzienne && sha256sum -c *.sha256kopia-vm-2026-10-29.tar.gz: OK
kopia-vm-2026-10-30.tar.gz: OK
kopia-vm-2026-10-31.tar.gz: OK
kopia-vm-2026-11-01.tar.gz: OK
kopia-vm-2026-11-02.tar.gz: OK
kopia-vm-2026-11-03.tar.gz: OK
kopia-vm-2026-11-04.tar.gz: OKA gdy maszyna z kopiami nie odpowiada, raport mówi to wprost:
ssh: connect to host 127.0.0.1 port 2298: Connection refused
rsync: connection unexpectedly closed (0 bytes received so far) [sender]
BŁĄD: rsync do kopie@backup:/srv/kopie/web1 się nie udałTeraz smaczek, na którym bardzo łatwo się wyłożyć: litera H. Pierwszą wersję zrobiłem ze zwykłym rsync -a i porównałem obie strony:
1499189 -rw------- 1 root root 1713886 Nov 4 02:52 web1/dzienne/kopia-vm-2026-11-01.tar.gz
1499197 -rw------- 1 root root 1713886 Nov 4 02:52 web1/miesieczne/kopia-vm-2026-11-01.tar.gz
1499201 -rw------- 1 root root 1713886 Nov 4 02:52 web1/tygodniowe/kopia-vm-2026-11-01.tar.gzTrzy różne i-węzły, licznik linków 1. -a nie zachowuje twardych linków, więc nasz jeden słoik z trzema karteczkami po drugiej stronie zamienił się w trzy słoiki. Z -aH jest jeden i-węzeł z licznikiem 3, a całość zajmuje 14 MB zamiast 17. Przy prawdziwych kopiach to różnica między „mieści się” a „kup nowy dysk”. I rzecz najważniejsza, czyli --delete. Dzięki niemu rotacja działa też po drugiej stronie. Ale pomyśl: ktoś przejmuje serwer i kasuje /srv/kopie. O 2:30 nasz skrypt grzecznie skasuje też kopie zdalne. Albo gorzej: włamywacz ma na serwerze klucz roota do maszyny z kopiami i sam je wyczyści. Dwie obrony. Pierwsza: zablokuj, co ten klucz może zrobić. Na maszynie z kopiami w authorized_keys:
command="/usr/bin/rrsync -wo /srv/kopie",restrict ssh-ed25519 AAAA... kopia@web1rrsync to strażnik dostarczany z rsync: wpuszcza tylko rsync, tylko do tego folderu, -wo znaczy „tylko zapis”. Ścieżkę podajesz wtedy względną: ZDALNIE=kopie@backup:web1. Sprawdziłem, co się stanie, gdy ktoś spróbuje czegoś więcej:
/usr/bin/rrsync error: reading from write-only server is not allowed
/usr/bin/rrsync error: SSH_ORIGINAL_COMMAND does not run rsyncDruga obrona, lepsza: odwrócić kierunek. To maszyna z kopiami łączy się z serwerem i sama pobiera archiwa. Serwer nie ma żadnego klucza do niej, więc nawet przejęty serwer nie sięgnie do kopii. Listonosz, który sam odbiera paczki, zamiast dać każdemu nadawcy klucz do magazynu.
Co dalej
Kopie raportują się mailem, ale my jeszcze nie umiemy porządnie wysyłać maili z serwera. W następnym projekcie to naprawiamy i budujemy monitor: dysk, pamięć i usługi, z alarmem na skrzynkę, który krzyczy raz, a nie co pięć minut przez całą noc.