Schnelllöser

Zugriffstatistiken von Webservern haben mehr Aussagekraft, wenn in den Protokolldateien die Hostnamen der Clients festgehalten sind. Deren Ermittlung durch den Nameserver kann den Rechner belasten und zu langen Wartezeiten für den Anwender führen. Mit Perl lässt sich eine schnelle Lösung implementieren.

In Pocket speichern vorlesen Druckansicht 16 Kommentare lesen
Lesezeit: 9 Min.
Von
  • Winfried Trümper
Inhaltsverzeichnis

Tippt der Anwender eine URL in den Browser ein, löst dieser den enthaltenen Hostnamen via Nameserver-Anfrage in eine IP-Adresse auf. Umgekehrt kennt der Server den Hostnamen des Browsers erst, wenn auch er eine DNS-Anfrage startet, und zwar einen ‘Reverse Lookup’ auf die IP-Adresse des Clients. Solche DNS-Abfragen kosten Zeit, während der Anwender vor dem Browser wartet, und damit steigt die Belastung des Servers, weil die Verbindung zum Client länger offen ist.

Solche Überlegungen betreffen in erster Linie Rechner mit vielen Zugriffen und internationalem Publikum. Beispiel: Auf dem Hauptserver der Deutschen Welle treffen durchschnittlich circa sechs HTTP-Anfragen pro Sekunde ein. In dieser Zeit können in der Regel aber nur fünf Hostnamen aufgelöst werden, trotz des lokalen DNS-Caching. Die Ursache hierfür ist ein kleiner Teil der DNS-Server, die wegen Fehlkonfiguration, langsamer Internet-Anbindung oder besonderer Originalität des Systemverwalters erst nach vielen Sekunden oder Minuten reagieren.

Man kann sich leicht vorstellen, was mit dem Server bei eingeschalteter Namensauflösung geschieht: Er kommt mit der Beantwortung der Anfragen nicht mehr nach und verbraucht trotzdem erhebliche Ressourcen. Abhilfe brächte bereits die Option eines Timeout für DNS-Abfragen im Server, ein praktikabler Wert wäre etwa drei Sekunden. Gepaart mit einer Option zur Wiederholung nach Zeitüberschreitung ließe sich vermutlich eine große Anzahl von IP-Adressen auflösen.

Entscheidendes Argument gegen die Namensuche per DNS sind Denial-of-Service-Angriffe (DOS): Jemand stellt seinen DNS-Server so ein, dass Anfragen ewig dauern und bombardiert den Webserver dann mit Anfragen von Clients aus seiner Domain. Nun waren solche Teer-Fallen schon den Säbelzahntigern bekannt, und eine zähe Abfrage kann man genauso mit einem bösartigen bandbreitenbegrenzten Browser erreichen. Aber man muss sich ja nicht noch weitere Stolperstellen ins Haus holen. Also keine Auflösung von IP-Nummern, und deshalb HostNameLookups off als Direktive in der Konfigurationsdatei des Apache.

Am Ende des Tages oder Auswertungszeitraums hat man zum Beispiel in der Protokolldatei des Servers eine halbe Million Zeilen, in denen nur die IP-Adresse steht. In einem ersten Schritt lässt sich deren Anzahl durch Weglassen von Dubletten reduzieren. Dass überhaupt IP-Adressen mehrfach in der Protokolldatei erscheinen, folgt unter anderem aus dem Einsatz von Grafiken, deren Versand als eigener Zugriff in der Protokolldatei erscheint. Trotzdem verbleiben noch viele tausend verschiedene IP-Nummern zum Auflösen, was ohne Parallelisierung einige Stunden in Anspruch nimmt.

Apache liegen zwei Werkzeuge für diese Aufgabe bei: logresolve und logresolve.pl. Ersteres merkt sich immerhin die bereits bekannten Adressen, sodass es sich doppelte Arbeit spart. Weil kein Timeout vorgesehen ist, geht die Performance von logresolve bei langsamen DNS-Servern in den Keller. logresolve.pl andererseits kennt Timeouts und verkürzt mit einer Herde von Prozessen die benötigte Zeit. Allerdings steigt die Geschwindigkeit nicht proportional mit deren Zahl. Der Ansatz einer Herde von Prozessen ist aber derselbe wie beim Apache selbst, sodass mit dieser Strategie nicht viel mehr als fünf Auflösungen pro Sekunde erreichbar waren - kein Unterschied zum HTTP-Daemon unter den gleichen Umständen. Für 10 000 IP-Adressen braucht dieses Verfahren immerhin circa 30 Minuten bei deutlichem Ressourcenverbrauch. Die Deutsche Welle trat an mich mit der Frage heran, ob man diese Zeit verkürzen könne. Man kann.

Threads als leichtgewichtige Prozesse schienen eine Alternative zu sein. Leider ließen sich in Perl für sie keine Timeouts einstellen, wie das bei nslookup mit der Option -timeout möglich ist. Das kann an meiner mangelnden Kenntnis liegen oder an der Realisierung von Threads in Perl. Jedenfalls brauchte eine darauf basierende Implementierung noch mehr Zeit als logresolve.pl und schied deshalb aus.

Eine andere Möglichkeit zur asynchronen Parallelisierung sind UDP-Sockets. Der Kernel puffert deren Empfangsdaten automatisch, und aufgrund der geringen Größe der DNS-Pakete kann er auch die vollständige Antwort puffern. Mit anderen Worten: Alle Unix-Kernel verfügen über eine Art natürliche Parallelisierung über den Umweg der UDP-Sockets. Je nach Kernelressourcen kann man zwischen 50 (Solaris) und 250 (Linux) von ihnen gleichzeitig zur Auflösung verwenden. Mehr Sockets bringen keine nennenswerte Steigerung der Geschwindigkeit, weil die Ausführung der Schleife ab einer gewissen Länge mehr Zeit in Anspruch nimmt als die durchschnittliche DNS-Anfrage. Mit dieser Art der Parallelisierung sind über 44 Auflösungen pro Sekunde zu erreichen.

Ohne spezielle Tricks kommt die zugehörige Perl-Implementierung der Idee aus, weil die Handhabung der UDP-Sockets komfortabel durch das Perl-Modul Net::DNS::Packet mit seinen Hintergrundfunktionen bgsend, bgisready und bgread realisiert ist (‘bg’ steht für background). Die Subroutine read_hosts() extrahiert die IP-Nummern aus der Protokolldatei und gibt eine Tabelle der IP-Nummern als Hashreferenz zurück, die in der Variablen $hosts gespeichert wird. Wegen der Verwendung eines Hashes erledigt sich die Entfernung von Doubletten von selbst, das heißt, jede IP-Nummer kommt im Hash genau einmal vor. Die Subroutine lookup() extrahiert die IP-Nummern mit keys() aus $hosts und speichert sie im Hilfs-Array @addresses. Innerhalb einer foreach-Schleife entnimmt ein shift() diesem Array IP-Adressen, mit denen DNS-Abfragen gestartet werden.

Die Schleife besteht funktional aus drei Teilen: dem fortlaufenden Start von DNS-Abfragen, der Entgegennahme von Ergebnissen und der Einhaltung einer Zeitschranke. Zum Starten der DNS-Abfrage dient die Resolver-Methode bgsend($address), die ein Objekt der Klasse Net::DNS::Packet zurückliefert. Das Skript speichert es zusammen mit der IP-Adresse, der aktuellen Zeit in Sekunden und der Anzahl der Versuche in der Hashreferenz $query. Damit viele parallele Anfragen laufen können, ist $query über die foreach-Schleife automatisch ein Alias in ein Array von Hashreferenzen namens @queries.

Liegt das Ergebnis einer Anfrage vor, verzweigt bgisready() in die if-Abfrage, und bgread() liest die DNS-Daten vom UDP-Socket. Tests mit defined() stellen sicher, dass das Ergebnis tatsächlich einen Inhalt hat, der mit Methoden abzufragen ist. Sonst könnte der Perl-Interpreter zur Laufzeit mit einem fatalen Programmfehler aussteigen. Der Socket wird ordnungsgemäß geschlossen und $query durch Löschung als frei markiert. In der Tat endet das Einsammeln der Ergebnisse mit einem goto, vor dem ich der Übersichtlichkeit wegen nicht zurückgeschreckt bin.

Liegt kein Ergebnis auf dem Socket vor, prüft das Skript die Zeitschranke. Startzeit und Anzahl der Versuche sind zu diesem Zweck in $query enthalten. Pro IP-Adresse unternimmt das Programm bis zu drei Versuche mit einer Schranke von jeweils fünf Sekunden, bevor es sie als nicht auflösbar markiert. Durch den wiederholten Versuch erhält man für circa 20 % der anfänglich nicht aufgelösten Adressen doch noch eine Antwort aus dem DNS.

Bis zu einem Viertel aller IP-Adressen lassen sich keine Rechnernamen zuordnen. Leider ist der asiatische Raum für die Vernachlässigung umgekehrter DNS-Abfragen berüchtigt, was wohl jedem bekannt ist, der öfter mit traceroute E-Mail-Spam aus dieser Region verfolgen will. Daher kann es zu einer Unterbewertung der Zugriffe von dort kommen. Schön wäre, wenn man für die nicht aufgelösten IP-Adressen per Whois-Protokoll die RIPE-, ANIC- und ARIN-Datenbanken bemühen würde. Da deren Ausgabeformat maschinell nur mit Aufwand weiterzuverarbeiten ist, habe ich diesen Ansatz noch nicht weiterverfolgt.

Ist die foreach-Schleife schließlich durchlaufen, werden alle leeren (weil nicht länger benötigten) Einträge in @queries entfernt. Sobald @queries keine Einträge mehr enthält, beendet die außen liegende while-Schleife ihre Arbeit. Die Subroutine lookup() gibt keinen Wert zurück, da sie die Hashreferenz $hosts direkt manipuliert.

Eine Besonderheit sind Hostnamen von Einwahlzugängen, etwa bei T-Online-Kunden (cw01.DU1.srv.t-online.de et cetera). Um diese ein wenig zusammenzufassen, ersetzt das Skript alle aufeinander folgenden Ziffern durch Sternchen. Will man eine noch stärkere Zusammenfassung erreichen, kann man alles bis auf die letzten beiden Domain-Komponenten wegschneiden. Etwa mit einem

$hosts{$address} = join("\.", (@name)[-2,-1])

an Stelle von if ($#name >= 2). Schließlich liest die Subroutine sar_logfile() die Protokolldatei erneut und schreibt eine zweite Datei, in der die Hostnamen an Stelle der IP-Nummer enthalten sind. Bei Erfolg überschreibt sar_logfile() die Ausgangsdatei. Es wäre ebenso möglich, die Protokolldatei nur einmal zu lesen. Die Schwierigkeit dabei besteht in der Erhaltung der Reihenfolge der Zeilen, weil man eine gerade gelesene Zeile im ungünstigsten Fall erst nach dem Timeout von 15 Sekunden schreiben kann. Bei 44 Auflösungen pro Sekunde und 40 Zeilen mit demselben Hostnamen ergibt das 44 x 40 x 15 = 26 400 Zeilen, die sich im Puffer ansammeln könnten. Außerdem wäre eine zusätzliche Kommunikation zwischen dem lesenden Programmteil und dem auflösenden notwendig, was die Sache noch weiter verkompliziert hätte.

Winfried Trümper
arbeitet an lustigen Sachen und bekommt diese am Ende des Monats auch noch bezahlt.

Mehr Infos

Listing 1

Auszüge aus dem Skript zur Wandlung von IP-Adressen in Hostnamen.

#! /usr/bin/perl

use strict;
use Net::DNS::Packet;

my $config = {
'max_sockets' => 240,
'tries' => 2,
'timeout' => 5,
};
my $ip_re = '\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}';

my $filename = $ARGV[0];
my $hosts = read_hosts($filename);

my $max_sockets = 240;
lookup($hosts, $config);

foreach my $address (keys(%$hosts)) {
if ($hosts->{$address}) {
my @name = split(/\./, $hosts->{$address});
if (($#name >= 2) && ($name[0] =~ s/(\d+)/\*/g)) {
$hosts->{$address} = join("\.", @name);
}
# oder: $hosts->{$address} = join("\.", (@name)[-2,-1])
} else {
$hosts->{$address} = $address;
}
}

sar_logfile($filename, $hosts);
exit;

sub lookup ($$) {
my $hosts = shift();
my $config = shift();

my $resolver = Net::DNS::Resolver->new();
my @addresses = keys(%$hosts);

my @queries = ();
foreach my $i (1..$config->{'max_sockets'}) {
push(@queries, {});
}

while ($#queries > -1) {
foreach my $query (@queries) {
RESTART: unless (exists($query->{'address'})) {
my $address = shift(@addresses) || next;
$query = {
'address' => $address,
'start' => time(),
'resolver' => $resolver->bgsend($address),
'tries' => 1,
};
next;
}
if ($resolver->bgisready($lookup)) {
my $response = $resolver->bgread($query->{'resolver'});
if (defined($response)) {
my @answers = $response->answer();
my $main = $answers[0];
if (defined($main)) {
$hosts->{$query->{'address'}} = $main->rdatastr();
$hosts->{$query->{'address'}} =~ s/\.$//;
}
}
$query->{'resolver'}->close();
$query = {};
goto RESTART;
} else {
my $now = time();
if (($now -- $query->{'start'}) > $config->{'timeout'})
{
$query->{'resolver'}->close();
if ($query->{'tries'} > $config->{'tries'}) {
$query = {};
goto RESTART;
}
$query->{'tries'} += 1;
$query->{'start'} = $now;
$query->{'resolver'} =
$resolver->bgsend($query->{'address'});
}
}
}
@queries = grep {exists($_->{'address'})} @queries;
}
}

(ck)