Wischmopp (Linux-Magazin, September 2001)

Ein Skript räumt alte Backup-Dateien auf und etabliert dabei tägliche, wöchentliche und monatliche Sicherungszyklen.

Statt mit magnetischen Bändern zu jonglieren, kann man Backups auch einfach auf eine zusätzliche Festplatte schreiben. Das geht nicht nur superschnell sondern auch billig -- 30-Gigabyte-Platten bekommt man in den USA heutzutage schon für unter $100. Ein Skript wie backup.pl sichert dann einfach per tar-Befehl eine Reihe von Verzeichnissen (in @DIRS) und legt deren Inhalt in einer komprimierten tar-Datei unter einem neuen Namen ab: Am 21.9.2001 steht dann da beispielsweise backup.20010921.tgz und wartet auf den aufgeregten Systemadministrator, falls am 22. September die andere Festplatte kracht.

Diese täglichen tar-Bälle sammeln sich kontinuierlich an und irgendwann steht der Administrator vor dem Problem, alte Backups wegzuwerfen -- nur welche?

Listing 1: backup.pl

    01 #!/usr/bin/perl
    02 ##################################################
    03 # backup -- Mike Schilli, 2001 (m@perlmeister.com)
    04 ##################################################
    05 use warnings;
    06 use strict;
    07 
    08 my $TAR     = "/bin/tar";
    09 my @DIRS    = qw(/u/mschilli/dev /data/scripts);
    10 my $BAK_DIR = "/data/backups";
    11 
    12 my ($y, $m, $md) = (localtime(time))[5,4,3];
    13 
    14 my $date = sprintf "%d%02d%02d", 
    15                    1900+$y, $m+1, $md;
    16 
    17 system($TAR, "zcfv", "$BAK_DIR/backup.$date.tgz", 
    18        @DIRS) and die "$TAR failed ($!)";

Abbildung 1 zeigt einen typischen Backup-Kalender. Der rote Ring markiert das aktuelle Datum, die grünen Häkchen bezeichnen die Tage, von denen wir gerne ein Backup hätten: Von jedem Tag der vergangenen Woche, dann jeweils wöchentlich am Montag für die letzten vier Wochen und schließlich monatlich jeweils am Ersten für die letzten drei Monate.

Abbildung 1: Typische Backup-Zeiten im Kalender

Das heute vorgestellte Skript mop.pl sucht in einem vorgegebenen Verzeichnis nach Backup-Dateien, ermittelt, welche davon nach dem Backup-Kalender tatsächlich gebraucht werden und putzt alles Veraltete weg.

Die Zeilen 6, 7 und 8 in mop.pl fordern mindestens perl Version 5.6.0 an, schalten den Warnungsmodus an und bestehen auf strengen Richtlinien für nicht deklariere Variablen und anderen Schnickschnack, den Perl in seiner unendlichen Güte sonst durchlässt, der aber unsere lieben Anfänger rechts und links herumschleudert.

Anschließend zieht es File::Basename zur Pfadbehandlung, Getopt::Std zur Bearbeitung von Kommandozeilenoptionen und Date::Calc für Kalenderberechnungen herein -- letzteres holen wir einfach mittels folgendem Kommando schnell vom CPAN:

    perl -MCPAN -shell
    cpan> install Date::Calc

In Zeile 15 steht mit @BAK_DIR als konfigurierbarem Parameter das Verzeichnis, in dem backup.pl die Backup-Dateien ablegte.

Optionen beim Aufruf

mop.pl nimmt zwei optionale Kommandozeilenparameter entgegen: -v veranlasst das sonst sehr schweigsame Skript, dem Benutzer auf der Standardausgabe genau zu melden, was genau vor sich geht.

Die Option -n hingegen veranlasst mop.pl, nur so zu tun als ob. Es löscht die überflüssigen Backup-Dateien also nicht wirklich, sondern meldet statt dessen nur xxx can go away. Praktisch zum Testen.

Die in Zeile 20 aufgerufene Funktion getopts() aus dem Modul Getopt::Std nimmt sich der Kommandozeilenparameter an und setzt im Hash %OPTS die Einträge n oder v, wenn mop.pl mit -v oder -n aufgerufen wurde.

getopts() hat nur den Haken, dass es das Programm abbricht, falls eine nicht unterstützte Option (z.B. -x) vorliegt. Deshalb bilden die Zeilen 19 bis 21 einen eval-Block, der den getopts()-Befehl in einem Sandkasten ablaufen lässt. Falls das Programm auf einen mit die() ausgelösten tödlichen Fehler läuft, schnappt sich eval diesen, unterbricht die eval-Verarbeitung, und es liefert einen falschen Wert zurück. Gemeinerweise gibt getopts() auch noch eine mit warn() ausgelöste Warnung aus, die allerdings der mit $SIG{__WARN__} definierte leere Signal-Handler unterdrückt. mop.pl nimmt alles selbst in die Hand, gibt im Fehlerfall mit usage() eine Meldung samt Bedienungsanleitung aus und beendet in Zeile 124 das Programm mit einem Fehlercode.

Zeile 27 ruft das weiter unten definierte calc_dates() auf, das eine Referenz auf den Hash %dates_needed erwartet. Dessen Schlüssel setzt calc_dates() auf die Datumsangaben der Tage, von denen wir gerne -- wie im Kalender in Abbildung 1 grün markiert -- ein Backup hätten. Existiert zum Beispiel $dates_needed{"2001 4 17"}, bleibt die Backupdatei vom 17.4.2001 später unangetastet.

Zeile 30 iteriert anschließend mit einem glob-Konstrukt über alle Dateien im Backup-Verzeichnis und füllt den Hash %files jeweils mit den Dateinamen als Schlüssel und deren letztem Modifikationsdatum als Wert. Diese Unix-üblichen Sekunden seit 1970, die die Funktion stat() auf Indexposition 9 der von ihr zurückgegebenen Liste liefert, geben einen Hinweis darauf, wann die Backup-Datei erstellt wurde.

Die for-Schleife ab Zeile 37 braucht dann nur noch durch die nach Datum sortierten Einträge dieses Hashes zu laufen und zu untersuchen, ob die jeweilige Datei gemäß dem Backup-Fahrplan noch gebraucht wird oder nicht. Die sort-Bedingung in Zeile 37 sortiert deswegen numerisch aufsteigend nach dem Modifikationsdatum der Datei in Sekunden seit 1970 -- so sind die Hashwerte die Sortierkriterien für die Schlüssel! Die Zeilen 39 bis 42 nutzen die Perl-Funktion localtime() und den Formatierer sprintf, um die Unix-Sekunden-Zeit in das Kalenderformat (Jahr Monat Tag) umzuwandeln. Zu beachten ist, dass localtime() den Monat um 1 und das Jahr um 1900 verkürzt zurückgibt -- das wird kurzerhand korrigiert.

Zeile 44 prüft, ob die Datei im Backup-Fahrplan steht (Eintrag in %dates_needed vorhanden) oder aber von heute ist -- in diesen Fällen muß der Wischmop die Finger von ihr lassen. In diesem Fall löscht Zeile 49 den Eintrag in %dates_needed, denn: Produziert ein handgestricktes Sicherungsskript mehrere Backup-Dateien am gleichen Tag, wählt der Algorithmus so nur die erste an diesem Tag erstellte zum Überleben aus. Bleibt die Datei bestehen, gibt Zeile 46 eine informative Meldung aus, falls die Kommandozeilenoption -v (für verbose) gesetzt war.

Vorsicht, übrigens: mop.pl ermittelt das Erstellungsdatum einer Backup-Datei aus derem letzten Modifikationsdatum. Wer also zu einem späteren Zeitpunkt die Backup-Dateien manipuliert, bringt den Algorithmus ins Schleudern. Wer dergleichen nicht ausschließen kann, muss das Datum woanders her beziehen -- zum Beispiel aus dem Dateinamen, den das Backup-Skript backup.pl auf das aktuelle Sicherungsdatum setzt.

Wird's ernst, löscht Zeile 55 die Datei mit unlink -- aber nur, falls nicht die Kommandozeilenoption -n zum Simulieren vorliegt. Für die Meldung in Zeile 58 im -v-Modus extrahiert die Funktion basename() des File::Basename-Pakets aus der langen Pfadangabe nur den Dateinamen.

Zeitrechnung

Um zu ermitteln, welche Sicherungsdateien der Backup-Fahrplan einfordert, setzt calc_dates() das Modul Date::Calc von Steffen Beyer ein, der, wie aus dem Autorenhinweis von Date::Calc hervorgeht, noch immer in München in der Ainmillerstraße 5, Appartment 513, wohnt! Sein Date::Calc ist jedenfalls, wie schon einmal an dieser Stelle gesagt ([2]), die Wucht in Tüten. Zeile 12 von mop.pl forderte Date::Calc schon auf, die Funktionen Today(), Add_Delta_Days(), Add_Delta_YMD() und Day_of_Week() in den Namensraum von mop.pl zu exportieren.

Today() in Zeile 70 liefert dann das heutige Datum als Liste mit den Elementen Jahr, Monat, Tag zurück. Ab Zeile 73 berechnet mop.pl das Datum der sieben vorhergehenden Tage einfach dadurch, dass es mittels Add_Delta_Days aus Date::Calc vom heutigen Datum 1, 2, ..., 7 Tage abzieht.

Alle errechneten Daten speichert calc_dates() im Format "Jahr Monat Tag" als Schlüssel im Hash %dates_needed des Hauptprogramms, auf den innerhalb der Mauern von calc_dates() die Referenz $needed zeigt.

Die Datumsangaben der verstrichenen 4 letzten Montage erhält man, indem man mit der Funktion Day_of_Week den heutigen Wochentag als Zahl von 1 (Montag) bis 7 (Sonntag) ausrechnet, und dann entweder eine Woche zurückgeht (falls gerade Montag ist) oder den Differenzbetrag zwischen 1 und dem heutigen Wochentag mit Add_Delta_Days (negative Tageszahl!) zurückschreitet. Dann geht mop.pl ab Zeile 89 vier mal 7 Tage zurück und sammelt so die Montagsdaten ein.

Das Datum der letzten 3 Monatsersten hängt davon ab, ob heute der Erste ist oder nicht. Falls ja, starten wir einen Monat in der Vergangenheit, was Add_Delta_YMD() einfach ausrechnet, indem wir ihm das heutige Datum und die Liste (0, -1, 0) (0 Jahre, -1 Monate, 0 Tage) zum Zurückschreiten geben. Falls heute nicht der Erste ist, setzt Zeile 103 einfach den Tagesanteil von @today auf 1 und schreitet von dort an ab Zeile 105 dreimal jeweils einen Monat zurück.

Ab Zeile 113 steht die usage()-Funktion, die aus dem Hauptprogramm dann aufgerufen wird, falls letzteres mit einer unbekannten Option gestartet wurde. usage() gibt nicht nur die ihr übergebene Fehlermeldung aus sondern teilt dem Benutzer auch gleich noch mit, welche Parameter mop.pl versteht. Anschließend bricht es mit dem exit-Code 1 ab.

Sauber aufgeputzt

Stellt also ein täglicher Cronjob sicher, dass ein Skript wie backup.pl alle zu sichernden Verzeichnisse zu einer Backup-Datei im Backup-Verzeichnis zusammenzurrt, brauchen wir nur den Wischlappen mop.pl hinterherzujagen, um die veralteten Backups vergangener Tage wegzuputzen. So stellen wir sicher, dass jederzeit eine hilfreiche Backup-Sammlung für den Notfall bereit steht, ohne dass Datenmüll die Platte unnötig vollpflastert.

Fröhliches Moppen!

Listing 2: mop.pl

    001 #!/usr/bin/perl
    002 ##################################################
    003 # mop -- Überflüssige Backup-Dateien löschen
    004 # Mike Schilli, 2001 (m@perlmeister.com)
    005 ##################################################
    006 use 5.6.0;
    007 use warnings;
    008 use strict;
    009 
    010 use File::Basename;
    011 use Getopt::Std;
    012 use Date::Calc qw(Today Add_Delta_Days 
    013                   Add_Delta_YMD Day_of_Week);
    014 
    015 my $BAK_DIR = "/data/backups";
    016 
    017     # Kommandozeilenoptionen einsammeln
    018 my %OPTS;
    019 eval { local $SIG{__WARN__} = sub {};
    020        getopts('nv', \%OPTS); 
    021      } or usage("Bad option");
    022 
    023 my %files        = ();
    024 my %dates_needed = ();
    025 
    026     # Gewünschte Backup-Tage errechnen
    027 calc_dates(\%dates_needed);
    028 
    029     # Backup-Dateien analysieren
    030 for my $file (<$BAK_DIR/*>) {
    031         # mtime als Hash-Value speichern
    032     $files{$file} = (stat $file)[9];
    033 }
    034 
    035     # Nach letztem Modifikationszeitpunkt 
    036     # sortiert durchlaufen
    037 for my $file (sort { $files{$a} <=> $files{$b} } 
    038               keys %files) {
    039     my $mtime = $files{$file};
    040     my ($md, $m, $y) = (localtime($mtime))[3..5];
    041     my $date = sprintf "%d %d %d",
    042                        $y + 1900, $m+1, $md;
    043 
    044     if(exists $dates_needed{$date} or
    045        $date eq join ' ', Today()) {
    046         print "Keeping $file\n" if $OPTS{v};
    047             # Datum entfernen -- nur erste Datei
    048             # eines Tages wird aufgehoben
    049         delete $dates_needed{$date};
    050     } else {
    051         if($OPTS{n}) {
    052             print basename($file), 
    053                   " can go away.\n";
    054         } else {
    055             unlink $file or 
    056                 die "Cannot unlink $file ($!)";
    057     
    058             print basename($file), 
    059                 " deleted.\n" if $OPTS{v};
    060         }
    061     }
    062 }
    063 
    064 ##################################################
    065 sub calc_dates {
    066 ##################################################
    067     my ($needed) = @_;
    068 
    069         # Erwünschte Backup-Tage errechnen
    070     my @today        = Today();
    071 
    072         # Tage letzter Woche
    073     for my $delta (1..7) {
    074        my @date = Add_Delta_Days(@today, -$delta); 
    075        $needed->{"@date"} = 1; 
    076     }
    077 
    078         # Die letzten 4 Montage
    079     my $current_dow = Day_of_Week(@today);
    080     my @last_monday;
    081     if($current_dow == 1) {
    082         # Heute ist Montag
    083         @last_monday = Add_Delta_Days(@today, -7);
    084     } else {
    085         # Heute ist nicht Montag
    086         @last_monday = Add_Delta_Days(@today, 
    087                                   1-$current_dow);
    088     }
    089     for my $weeks_back (0..3) {
    090         my @date = Add_Delta_Days(@last_monday,
    091                                 $weeks_back * -7);
    092         $needed->{"@date"} = 1; 
    093     }
    094 
    095         # Die letzten 3 Monatsersten
    096     my @last_first;
    097     if($today[2] == 1) {
    098             # Heute ist der Monatserste
    099         @last_first = Add_Delta_YMD(@today, 
    100                                     0, -1, 0);
    101     } else {
    102             # Heute ist nicht der Monatserste
    103         @last_first = ($today[0], $today[1], 1);
    104     }
    105     for my $months_back (0..2) {
    106         my @date = Add_Delta_YMD(@last_first, 0, 
    107                             -1 * $months_back, 0);
    108         $needed->{"@date"} = 1; 
    109     }
    110 }
    111 
    112 ##################################################
    113 sub usage {
    114 ##################################################
    115     my ($message) = @_;
    116     my $program = basename($0);
    117 
    118     print <<EOT;
    119 $program: $message
    120 usage: $program [-nv]
    121     -n: Don't actually delete
    122     -v: Print informative messages
    123 EOT
    124     exit 1;
    125 }

Infos

[1]
Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2001/09/Perl

[2]
``Kalenderrechnung'', Linux-Magazin, September 1998, http://www.linux-magazin.de/ausgabe/1998/09/Kalender/kalender.html

Michael Schilli

arbeitet als Software-Engineer bei Yahoo! in Sunnyvale, Kalifornien. Er hat "Goto Perl 5" (deutsch) und "Perl Power" (englisch) für Addison-Wesley geschrieben und ist unter mschilli@perlmeister.com zu erreichen. Seine Homepage: http://perlmeister.com.