Täglich auf Zack (Linux-Magazin, Juni 2001)

Wer hat schon Zeit, täglich seine Logdateien zu kontrollieren? Das heute vorgestellte Skript fasst alle aktuellen Veränderungen in einem Report zusammen und verschickt sie einmal am Tag per Email.

Logdateien protokollieren mit, was so abläuft. Sie halten wichtige Ereignisse oder Fehler fest und helfen, eventuell aufgetretene Fehler einzukreisen und zu beheben. Beim Überwachen (``Monitoring'') aktiver Prozesse helfen die angesammelten Daten allerdings nur, falls sich jemand aktiv um sie kümmert und täglich kontrolliert, ob noch alles ordnungsgemäß läuft. Auch tendieren Logdateien dazu, sehr schnell zu wachsen, so dass wir einen Mechanismus brauchen, der nur seit dem letzten Prüftermin hinzugekommene Daten analysiert.

Auf perlmeister.com gibt es beispielsweise die Datei rbsub.txt, die festhält, welche Leute sich auf die Liste für die Amerika-Rundbriefe setzen ließen -- immer wenn jemand das entsprechende Webformular ausgefüllt hat, wächst rbsub.txt um eine Zeile, die die eingetragene Email-Adresse enthält. Außerdem protokolliert die Datei errors.txt, ob irgendwo auf dem Webserver Fehler aufgetreten sind.

Tägliche Kontrolle per Skript

Das heute vorgestellte Skript logmail.pl läuft einmal am Tag per Cronjob, merkt sich die aktuelle Größe der Logdateien, stellt hinzugekommene Zeilen fest, fasst sie in einem Report nach Abbildung 1 zusammen und verschickt sie per Email an mich. So sehe ich beim täglichen Email-Lesen, ob noch alles in Ordnung ist.

Abbildung 1: Die Email mit den Logdaten ist da!

Listing logmail.pl zeigt die Implementierung. Die Konfigurationssektion am Anfang legt eine Reihe von Parametern fest: In $DATABASE steht, wie die Datei heißt, in der logmail.pl sich merkt, wie weit es in den einzelnen Dateien schon gelesen hat. Die Werte für $FROM, @TO und $SUBJECT geben für die später zu verschickende Email an, woher sie stammt, wohin sie geht und was in der Betreffs-Zeile steht. $MAXLINES legt fest, wieviele Zeilen pro Logdatei maximal vollständig im Email-Report stehen. Der letzte Parameter $B gibt nur ein Verzeichnis an, in dem die Dateien stehen, damit wir es später nicht hundertmal tippen müssen.

In Zeile 10 steht ein Array, der Logdateipfaden aussagekräftige Namen zuordnet. Die geraden Elemente des Arrays enthalten die Pfadnamen, die ungeraden die Alias-Namen. Zeile 17 deklariert den Hash %offs, der später unter dem Pfadnamen der Logdatei jeweils abspeichert, wieviele Bytes logmail.pl beim letzten Aufruf schon aus der Datei ausgelesen hat und dementsprechend im aktuellen Durchlauf überspringen kann. Die Zeilen 14 und 15 ziehen die Module Mail::Mailer und Storable herein, ersteres zum Verschicken von Email und letzteres zum Speichern von Daten, die die Laufzeit von logmail.pl in einer Datei überleben und beim nächsten Aufruf wieder bereit stehen. Beide kommen frisch vom CPAN und werden wie üblich mit

    perl -MCPAN -eshell
    cpan> install Storable
    cpan> install Mail::Mailer

installiert.

Speichern und Laden mit Storable

Während man Hashes sonst mit tie und DBM-Dateien persistent macht, wollen wir heute mal Storable verwenden, das sich durch ein bestechend klares Interface auszeichnet: store() friert Daten in einer angegebenen Datei ein und restore() holt sie daraus wieder zurück -- einfacher geht's nicht.

Beide Funktionen arbeiten mit einer Referenz auf die beliebig komplexe zu sichernde Datenstruktur. Einen einfachen Hash legt folgender Code in der in der Datei datei.dat ab:

    use Storable;
    $offs = { "rbsub.txt"  => 257,
              "errors.txt" => 12 };
        # Abspeichern
    store($offs, "datei.dat");

$offs ist eine Referenz auf einen anonymen Hash, der mittels des {}-Konstrukts entstand. retrieve() holt die Datenstruktur wieder aus der Versenkung:

    use Storable;
        # Zurückholen
    $offs = retrieve("datei.dat");
        # => 257
    print "$offs->{rbsub.txt}\n";
        # => 12
    print "$offs->{errors.txt}\n";

Sowohl store() als auch retrieve() liefern undef zurück, falls etwas schiefgeht, weswegen die Zeilen 21 und 76 in logmail.pl ordnungsgemäß den Rückgabewert prüfen und notfalls das Programm abbrechen.

Die while()-Schleife ab Zeile 27 holt paarweise Werte aus dem Array @FILES. Der erste Wert ist, wie in Zeile 10 festgelegt, immer der beschreibende Text und der zweite Wert der Pfad zur jeweiligen Logdatei.

In der Variablen $mailtext wird nach Abschluss der Loganalyse der fertige Report stehen. Die Zeilen 30 bis 32 schreiben eine Überschrift für die aktuell analysierte Datei, eingerahmt von zwei Zeilen, in denen jeweils 70 Mal das Zeichen # steht, wie in Abbildung 1 gezeigt.

Zeile 35 findet heraus, wieviele Zeichen der Datei bereits während früherer Aufrufe von logmail.pl analysiert wurden -- die werden anschließend übersprungen, denn schließlich soll logmail.pl nicht jedes Mal bei Adam und Eva anfangen. Existiert ein Eintrag im persistenten Hash zur gerade analysierten Logdatei, steht $offset nach Zeile 35 auf dem gespeicherten Wert. Andernfalls setzt es die ||-Verknüpfung auf 0 .

Zeile 37 öffnet schließlich die Logdatei zum Lesen. Die nachfolgende if-Bedingung prüft, ob der gespeicherte Offset zum Überspringen von Daten überhaupt sinnvoll ist -- ist er größer als die Datei selbst (was sich leicht mit -s herausfinden lässt), wurde offensichtlich die Datei manuell verkürzt und statt eines Reports schreibt logmail.pl eine Warnungsmeldung nach $mailtext.

Ist der Offset sinnvoll, springt Zeile 45 die angegebene Anzahl von Bytes mittels der seek()-Funktion nach vorne und Zeile 48 liest die dann folgenden Zeilen bis zum Zeilenende auf einen Rutsch in den Array @data. $noflines ist die Anzahl der Elemente in @data, also die Anzahl der neuen Zeilen seit dem letzten Aufruf von logmail.pl.

Zeile 52 stellt den persistenten Offset auf das Dateiende ein, auf das das Filehandle FILE zu diesem Zeitpunkt zeigt und dessen Offset vom Dateianfang deswegen die tell()-Funktion zurückgibt.

Die auszusendende Email soll freilich nicht mit tausenden von Logzeilen vollgekleistert sein, deswegen wird die Anzahl der auszugebenden Zeilen auf $MAXLINES begrenzt. Hierzu schneidet die splice()-Funktion in Zeile 56 den Mittelteil aus @data heraus, so dass am linken und am rechten Ende jeweils genau $MAXLINES/2 Elemente übrigbleiben und ersetzt sie durch ein Element, das mit drei Punkten die herausgeschnittenen Elemente symbolisieren soll.

Zeile 59 fasst die Einzelzeilen zu einem einzigen langen Textstring zusammen. Zeile 62 sorgt dafür, dass logmail.pl die Anzahl der neuen Zeilen in der Logdatei korrekt in Einzahl oder Mehrzahl anzeigt: 0 lines, 1 line, 2 lines. Die gesammelten Logdaten hängt Zeile 66 an den zu verschickenden Mailtext an und Zeile 67 schließt die Logdatei.

Nachdem alle Dateien aus @FILES abgearbeitet wurden, schickt Zeile 74 mit der weiter unten definierten mailto()-Funktion den Report in $mailtext an die in der Parametersektion definierte Adresse. Die store()-Funktion in Zeile 76 schreibt die unter der Referenz $offs stehende Datenstruktur mit den Offsets der einzelnen Logdateien zurück auf die Festplatte.

Emailen aus Perl

Die ab Zeile 80 definierte Funktion mailto schickt eine Email mit dem angegebenen Text an alle Adressaten, die der Aufrufer in der Parameterliste übergeben hat. Das Zusatzmodul Mail::Mailer nimmt uns dabei die ganze schwierige Arbeit ab, wir müssen nur ein Objekt vom Typ Mail::Mailer erzeugen und dessen open()-Methode aufrufen. Diese nimmt unter den Einträgen "To", "From", "Subject" und "Reply-To" den/die Adressaten, den Absender, die Betreffszeile und den Wert für den Reply-Header der Email entgegen. Zeile 95 verwendet das Mailer-Objekt daraufhin wie ein Filehandle und nutzt die print-Funktion, um den übergebenen Nachrichtentext einzuspeisen. Die in Zeile 96 aufgerufene close()-Methode schickt die Mail ab.

Das in Zeile 86 erzeugte Mail::Mailer-Objekt wurde auf den sendmail-Dämon zugeschnitten -- dies funktioniert, falls auf dem System, auf dem das Skript läuft, ein korrekt konfigurierter Sendmail-Dämon aktiv ist. Als weitere Möglichkeit kann man einfach 'mail' sagen, worauf der Mailer das Unix-eigene mail-Kommando für seine Zwecke nutzt. Auch 'smtp' geht, dann kontaktiert der Mailer einen extra angegebenen Mailserver direkt per SMTP-Protokoll. Einzelheiten stehen in der Manualseite, die mit Mail::Mailer kommt.

Bleibt nur noch, die Parametersektion des Skripts an die lokalen Gegebenheiten anzupassen: Die Mailadressen, die Betreffszeile und die Anzahl der maximal gedruckten Einträge und die Pfade zur Merkerdatei sowie der überwachten Logdateien samt ihrer Alias-Namen sollte jeder nach Bedarf einstellen, bevor das Skript zum ersten Mal abläuft. Klappt alles, hilft ein Cronjob der Art

    00 0 * * * /data/bin/logmail.pl

das Skript jeden Tag um Mitternacht zu starten und den Report auf den Weg zu schicken. Bis zum nächsten Mal, kontrolliert fleißig, was so abgeht!

Listing 1: logmail.pl

    01 #!/usr/bin/perl -w
    02 
    03 my $FROM     = 'absender@irgendwo.com';
    04 my @TO       = qw(logs@perlmeister.com);
    05 my $SUBJECT  = 'Today\'s Logs';
    06 my $MAXLINES = 10;
    07 my $DATABASE = '/home/mschilli/tmp/logmail.dat';
    08 my $B        = '/home/mschilli/tmp';
    09 
    10 my @FILES = ("Neukunden"  => "$B/rbsub.txt",
    11              "Fehler"     => "$B/errors.txt",
    12             );
    13 use strict;
    14 use Mail::Mailer;
    15 use Storable;
    16 
    17 my $offs = {};
    18 
    19 if(-r $DATABASE) {
    20         # Gespeicherte Daten auslesen
    21     $offs = retrieve($DATABASE) or 
    22         die "Cannot open $DATABASE";   
    23 }
    24 
    25 my $mailtext = "";
    26 
    27 while(my ($text, $file) = splice(@FILES, 0, 2)) {
    28 
    29         # Teilüberschrift
    30     $mailtext .= "\n" . "#" x 70;
    31     $mailtext .= "\n$text:\n";
    32     $mailtext .= "#" x 70 . "\n";
    33 
    34         # Gespeicherter Offset vom letzten Mal
    35     my $offset = ($offs->{$file} || 0);
    36 
    37     if(open(FILE, "<$file")) {
    38         if(-s $file < $offset) {
    39                 # Datei wurde gekürzt
    40             $offs->{$file} = -s $file;
    41             $mailtext .= "(Offset adjusted)\n";
    42         } else {
    43                 # Bis zum gespeicherten Offset
    44                 # vorfahren
    45             seek(FILE, $offset, 0);
    46 
    47                 # Rest zeilenweise auslesen
    48             my @data = <FILE>;
    49             my $noflines = @data;
    50 
    51                 # Neuen Offset einstellen
    52             $offs->{$file} = tell(FILE);
    53 
    54                 # Kürzen falls > $MAXLINES
    55             if(@data > $MAXLINES) {
    56                 splice(@data, $MAXLINES/2, 
    57                        @data-$MAXLINES, "...\n");
    58             }
    59             my $data = join '', @data;
    60 
    61                 # Zeilenzahl ausgeben
    62             $mailtext .= sprintf "(%d line%s)\n", 
    63                        $noflines,
    64                        $noflines == 1 ? "" : "s";
    65                 # Logdaten ausgeben
    66             $mailtext .= $data;
    67             close(FILE);
    68         }
    69     } else {
    70         $mailtext .= "Cannot open $file\n";
    71     }
    72 }
    73 
    74 mailto($SUBJECT, $mailtext, $FROM, @TO);
    75 
    76 store($offs, $DATABASE) or 
    77     die "Cannot store data in $DATABASE";
    78 
    79 ##################################################
    80 sub mailto {
    81 ##################################################
    82     my ($subj, $text, $from, @to) = @_;
    83 
    84     return if @to == 0;
    85 
    86     my $mailer = Mail::Mailer->new( 'sendmail' );
    87     my $to = join ", ", @to;
    88 
    89     $mailer->open( { "To"       => $to,
    90                      "From"     => $from,
    91                      "Subject"  => $subj,
    92                      "Reply-To" => $from,
    93                    } ) or die "open failed";
    94 
    95     print $mailer $text;
    96     $mailer->close();
    97 }

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.