Troubleshooting Linux und IP-Netzwerke

4. Grundlagen Linux

Ein paar Grundlagen benötige ich immer wieder, wenn ich die Vorgänge in einem Linux-System verstehen will.

Von diesen halte ich im Zusammenhang mit Fehlersuche für besonders wichtig:

Darauf gehe ich in den folgenden Abschnitten ein.

Dateien und Verzeichnisse

Ein grundlegendes Konzept, dass man verinnerlicht haben muss, um weitere Eigenheiten von Linux und UNIX zu verstehen, ist das Verhältnis von Dateien, Inodes und Verzeichnissen.

Abstrakt betrachtet ist eine Datei ein Datenspeicher: ich kann hinein schreiben und daraus lesen. Diese Aussage erscheint zunächst wie ein Allgemeinplatz, aber dieser Makel haftet fast allen grundsätzlichen Ideen an. Der wesentliche Punkt ist, dass fast alles in einem Linux-System als Datei betrachtet wird, in die man Daten schreiben beziehungsweise aus der man Daten lesen kann.

Dementsprechend gibt es verschiedene Arten von Dateien, die für einfache Lese- und Schreiboperationen oft austauschbar sind. So kann ich zum Beispiel mit

# dd if=/dev/hda of=/dev/hdb

eine bit-genaue Kopie der ersten Festplatte auf der zweiten anlegen, wenn letztere groß genug ist. Dagegen lege ich mit der Variante

# dd if=/dev/hda of=/mnt/backup/hda.img

eine Sicherungskopie der ersten Festplatte als Datei im Verzeichnis /mnt/backup/ an. Mit dem Befehl

# dd if=/dev/hda \
  | ssh rechner2 dd of=rechner1-hda.img

schicke ich das Abbild der ersten Platte zu rechner2 und lege es dort in der Datei rechner1-hda.img ab. In allen drei Fällen hat das Programm dd dieselbe Operation mit zwei Dateien gemacht, das Ergebnis ist aber völlig verschieden.

Wir haben mit dem Dateimodell eine Schnittstelle, die es uns erlaubt uns bei der Betrachtung eines Vorgangs auf einen Aspekt zu konzentrieren und andere temporär auszublenden. Wann immer ich es bei einem Problem mit einem komplexen System zu tun habe, erlauben mir Schnittstellen, das Problem in einfachere Teilprobleme zu zerlegen und diese zuerst zu bearbeiten.

Kommen wir zurück zu den verschieden Arten von Dateien. Da gibt es zunächst die regulären Dateien, die noch am ehesten dem nahe kommen, was ich mir unter einer Datei vorstelle.

Diese könnte ich auf einer Festplatte als Muster in der Magnetisierung, auf einer CD-ROM als optisches Muster und in Flash-Speicher als Ladung in Halbleitern lokalisieren. Die Daten, die hinein geschrieben wurden, sind in codierter Form in dem Bereich vorhanden, der die Datei ausmacht und können unverändert wieder ausgelesen werden, solange nichts kaputt geht.

Um eine reguläre Datei anzulegen, kann ich den Befehl touch verwenden, in der Shell die Standardausgabe umleiten oder mit einem der unzähligen Programme, welche mit Dateien arbeiten, eine neue Datei anlegen.

Neben den regulären Dateien gibt es Gerätedateien. Diese kann ich genau wie reguläre Dateien verwenden, die Daten gehen jedoch an das mit der Datei verknüpfte Gerät.

Ich finde Gerätedateien üblicherweise unterhalb von /dev/, sie belegen außer dem Inode keinen weiteren Speicherplatz auf dem Datenträger. Auf modernen Systemen gibt es spezielle Dateisysteme, wie devfs, in denen der Kernel die Gerätedateien dynamisch zur Verfügung stellt, wenn die entsprechende Hardware verfügbar ist.

Gerätedateien unterteilt man in blockorientierte Geräte, wie zum Beispiel /dev/hda für die rohen Daten der ersten Festplatte im Rechner, und zeichenorientierte Geräte, wie zum Beispiel /dev/ttyS0 für die erste serielle Leitung. Der wesentliche Unterschied zwischen beiden ist, dass ich bei blockorientierten Geräten wahlfrei an beliebigen Stellen lesen und schreiben kann, während ich bei zeichenorientierten Geräten immer nur ein Zeichen nach dem anderen lesen oder schreiben kann.

Im Kernel werden die Gerätedateien mit Hauptnummern (major number) unterteilt, die den Typ des Gerätes bestimmen, und Nebennummern (minor number), die gleichartige Geräte untereinander differenzieren. Gerätedateien kann ich mit dem Befehl mknod anlegen.

Es gibt eine Reihe virtueller Gerätedateien, die bestimmten Zwecken dienen:

/dev/null
verwirft alle Eingaben und gibt nichts aus.
/dev/zero
erzeugt einen Zeichenstrom aus Nullzeichen (\0)
/dev/random
gibt echte Zufallszahlen oder kryptographisch starke Pseudozufallszahlen aus. Sollte nicht genügend Entropie angesammelt sein, blockiert der Kernel den Lesezugriff.
/dev/urandom
gibt Pseudozufallszahlen aus, ohne zu blockieren.

Die nächste Art von Dateien sind FIFO oder Named Pipes. Diese sind Endpunkte für die Interprozesskommunikation (IPC). Wenn ein Prozess etwas in eine FIFO schreibt, kann ein anderer das lesen. Der Kernel speichert die geschriebenen Daten zwischen und blockiert den schreibenden Prozess bis ein anderer Prozess die Daten gelesen hat. Genauso blockiert er einen lesenden Prozess, bis ein anderer Prozess Daten in die FIFO geschrieben hat.

Dieser Umstand wird unter anderem vom Init-Programm systemd verwendet, um verschiedene parallel gestartete Dienste zu synchronisieren. UNIX-Sockets sind in gewisser Weise den FIFOs ähnlich, der Hauptunterschied ist, dass ein Prozess bei einem Socket sowohl lesen als auch schreiben kann. Damit ist bidirektionale Kommunikation möglich, für die man sonst zwei FIFOs benötigen würde.

Alle genannten Dateitypen befinden sich im Dateibaum, einer hierarchischen Datenbank aller in einem System verfügbaren Dateien. Der Dateibaum setzt sich aus mindestens einem, in der Regel aber mehreren, oft unterschiedlichen Dateisystemen zusammen. Neben den Dateisystemen, die auf Blockgeräten, wie Festplatten, angelegt werden, gibt es verschiedene Spezial-Dateisysteme, wie proc, sysfs, tmpfs, die der Kernel intern zur Verfügung stellt und Netzwerkdateisysteme, bei denen die Daten auf anderen Rechnern liegen.

In den Dateisystemen finde ich eine weitere spezielle Art von Dateien, die Verzeichnisse. Diese werden zwar genauso wie reguläre Dateien im Dateisystem gespeichert, jedoch kann ich nicht beliebige Daten hineinschreiben. Verzeichnisse enthalten strukturierte Einträge, die jeweils den Namen einer Datei und einen Verweis auf die Verwaltungseinheit, den Inode, umfassen. In jedem Verzeichnis gibt es zwei Standardeinträge: “.” verweist auf den Inode des Verzeichnisses selbst und “..” verweist auf den Inode des übergeordneten Verzeichnisses, in dem der Name dieses Verzeichnisses steht. Lediglich im Wurzelverzeichnis eines Dateisystems verweist “..” auf den Inode des Verzeichnisses selbst. Da der Eintrag “..” immer auf das übergeordnete Verzeichnis verweisen muss, ist es nicht möglich, mehrere namentliche Verweise auf ein Verzeichnis in verschiedenen anderen Verzeichnissen anzulegen. Dadurch kann ich anhand der Linkzahl im Inode eines Verzeichnisses sagen, wie viele Unterverzeichnisse dieses enthält, denn jedes Unterverzeichnis fügt mit seinem Eintrag “..” einen weiteren Link hinzu. Verzeichnisse ohne Unterverzeichnis haben eine Linkzahl von 2. Der Befehl ls -l zeigt in der zweiten Spalte die Linkzahl an.

Der Inode einer Datei enthält deren Verwaltungsinformationen, er beschreibt den Typ und die Eigenschaften der Datei und notiert den Platz, den die Datei auf dem Medium einnimmt. Inodes sind fest mit einer Datei verknüpft, Verzeichniseinträge hingegen nicht.

Ich kann mehrere Namen für dieselbe Datei, das heißt denselben Inode vergeben. Diese Namen werden Links genannt. Durch das Anlegen eines Links, also eines neuen Namens für eine Datei ändern sich die Eigenschaften der Datei nicht.

Der letzte Satz stimmt nicht ganz für den Inode, da in diesem die Anzahl der Links, die auf die Datei verweisen, mitgezählt wird. Sobald diese Anzahl 0 wird, gibt der Kernel den Inode und den von der Datei belegten Speicher frei.

Außer den Einträgen in Verzeichnissen des Dateisystems geht allerdings noch die Anzahl der Prozesse, die eine Datei geöffnet halten in diese Linkzahl mit ein. Dadurch ist es möglich, alle Verzeichniseinträge einer Datei zu entfernen, ohne dass der Inode und der Speicherplatz freigegeben werden. In diesem Fall spricht man von versteckten Dateien. Erst wenn der letzte Prozess, der die Datei geöffnet hat, seinen Dateideskriptor dafür schließt, wird auch der Inode und Speicherplatz freigegeben. Bis dahin kann ich die Datei zum Beispiel mit lsof wieder finden.

Um eine so gefundene Datei wiederherzustellen, muss ich auf dateisystemspezifische Werkzeuge zurückgreifen. Für ext2, ext3 und ext4 Dateisysteme verwende ich das Programm debugfs.

Schließlich gibt es neben den schon erwähnten Links, die immer nur innerhalb eines Dateisystems gelten und auch Hardlinks genannt werden, noch symbolische Links. Das sind Verzeichniseinträge, die lediglich auf einen anderen Pfad verweisen und sonst nichts mit der Datei, auf die der Pfad verweist, zu tun haben. Da sie nicht auf Inodes verweisen, zählen sie auch nicht bei der Linkzahl letzterer. Es ist möglich, dass ein symbolischer Link auf einen nicht vorhandenen Verzeichniseintrag verweist. Dafür können symbolische Links über Dateisystemgrenzen und auch auf Verzeichnisse verlinken, was wiederum nicht mit Hardlinks funktioniert.

Prozessmodell

Das nächste wichtige Konzept ist das UNIX-Prozessmodell, das ich wenigstens in groben Zügen verstehen muss, damit sich mir andere Konzepte, wie zum Beispiel Zugriffsrechte, erschließen.

Bevor ich auf dieses eingehe, will ich kurz die Begriffe Programm und Prozess erläutern, da diese im allgemeinen Sprachgebrauch oft vermischt werden.

Ein Programm ist nicht mehr als eine Reihe von Anweisungen für den Prozessor eines Rechners, die so angeordnet sind, dass deren Abarbeitung mehr oder weniger sinnvolles Verhalten des Rechners ermöglicht. Das Programm im Sinne des hier besprochenen Prozessmodels ist immer auf den ausführenden Prozessor zugeschnitten. Zwar spricht man auch bei Shell-, Perl- oder sonstigen Skripten von Programmen, im Sinne des UNIX-Prozessmodells sind jedoch die entsprechenden Interpreter die Programme, welche von eben jenen Skripten nur gesteuert werden.

Ein Prozess demgegenüber ist ein komplexeres Konstrukt, das über spezielle Datenstrukturen im Kernel identifiziert wird, Zugang zu bestimmten Dateien im Dateisystem hat, Rechenzeit und Hauptspeicher zugeteilt bekommt und ein Programm abarbeitet. Ein Prozess wird immer durch eine PID identifiziert, über die Benutzeridentität (UID) und zugeordnete Gruppen (GID) werden seine Zugriffsrechte auf Ressourcen bestimmt.

Mitunter sind Prozesse unterteilt in Threads (Fäden), die nur Teile des Programms abarbeiten, weniger Kontextinformationen enthalten und daher schnelleres Umschalten erlauben und zum Teil auch parallel ausgeführt werden können. Für die Betrachtung des UNIX-Prozessmodells sind diese hier nicht relevant.

Wenn jemand sagt, Programm xyz startet, ist damit gemeint, ein Prozess führt Programm xyz aus. Bildlich kann man einen Prozess mit einem Lebewesen vergleichen und das Programm mit seiner DNA. Auch wenn das Programm im wesentlichen vorgibt, wozu ein Prozess in der Lage ist, so bestimmt es doch nicht in allen Einzelheiten, was er tut. Das Programm bestimmt, ob ein ein Prozess als Mailserver, Shell oder was weiß ich arbeitet, so wie die DNA bestimmt, ob ein Lebewesen Fisch, Katze oder Baum wird. Wie gut oder schlecht ein Programm seine Aufgabe erfüllt, hängt zu einem Teil vom Programm und darin enthaltenen Fehlern ab, genau wie bei der DNA eines konkreten Lebewesens. Zu einem weiteren Teil hängt der Prozess von seiner Umgebung ab, so wie ein Lebewesen von seinen Lebensumständen. Der dritte Teil, der das Verhalten eines Prozesses bestimmt, sind die Daten die er verarbeitet, die ich mit den Interaktionen eines Lebewesens vergleiche. Ein Prozess kann so lange “leben”, bis er seine Aufgabe erfüllt hat oder vorzeitig durch ein Signal wie SIGTERM oder SIGKILL beendet werden, so wie ein Lebewesen durch Krankheit oder Unfall vorzeitig sterben kann. Ich breche an dieser Stelle den Vergleich ab, da er nur das Verhältnis von Prozess zu Programm illustrieren sollte.

Der Lebenszyklus eines Prozesses mit Ausnahme des allerersten, vom Kernel gestarteten Prozesses beginnt immer mit dem fork() Systemaufruf. Dieser Aufruf macht nichts anderes, als eine exakte Kopie des aufrufenden Prozesses anzulegen. Damit haben wir zwei vollkommen identische Prozesse, die sich erst nach Rückkehr des fork() Systemaufruf unterscheiden, und zwar im Rückgabewert desselben. Beim aufrufenden Prozess, Parent oder Elternprozess genannt, gibt fork() die PID des Klonprozesses zurück. Beim geklonten Prozess liefert es 0. Das ist die einzige Möglichkeit für das abgearbeitete Programm, zwischen diesen beiden Prozessen zu unterscheiden.

Je nachdem, welches Programm gerade an welcher Stelle ausgeführt wurde, werden anschließend beide Prozesse das gleiche Programm ausführen, wie zum Beispiel die Worker-Prozesse beim Apache HTTP-Dämon, oder ein Prozess beginnt ein anderes Programm abzuarbeiten.

Das führt uns zum nächsten wichtigen Bestandteil, dem Laden eines Programmes für die Abarbeitung. Nachdem der Kernel einen neuen Prozess erzeugt hat, arbeitet dieser zunächst das gleiche Programm wie sein Parent ab. Um ein anderes Programm auszuführen, verwendet er den exec() Systemaufruf. Dabei überlagert der Kernel den Teil des Hauptspeichers, welcher das ausführbare Programm enthält, mit dem Abbild des neuen Programms. Das neue Programm wird anschließend ab einem definierten Eintrittspunkt abgearbeitet. Programmargumente und Umgebungsvariablen werden im Hauptspeicher übergeben. Dateideskriptoren, also offene Dateien, bleiben über einen exec() Systemaufruf unverändert.

Bleibt als letzter Teil im Lebenszyklus eines UNIX-Prozesses das Ende zu besprechen. Ein Prozess kann sich durch den exit() Systemaufruf selbst beenden oder vom Kernel beendet werden, zum Beispiel mit SIGKILL oder bei Schutzverletzungen. In jedem Fall bleibt die Prozessstruktur so lange im Kernel erhalten, bis der Parent den Prozessstatus mit wait() abfragt. Versäumt der Parent-Prozess die Abfrage mit wait(), ist das im Prozesslisting mit ps als Zombieprozess sichtbar. Wird ein Parent vor dem Child beendet, wie zum Beispiel bei Dämonprozessen, die die Kontrolle sofort an die Shell zurückgeben, übernimmt der erste vom Kernel gestartete Prozess die Funktion des Parent und fragt den Child-Status mit wait() ab.

Suche im Internet zum Thema unix process model und schaue in den Ergebnissen nach Beispiel-Code. Kompiliere diesen und beobachte, was beim Start der Programme passiert.

Schnittstellen von Programmen

Bei der Fehlersuche auf Linux-Servern bewege ich mich fast die gesamte Zeit auf der Kommandozeile und rufe die verschiedensten Programme auf. Für das Aufrufen von Programmen selbst, brauche ich keine weiteren Kenntnisse. Bei der Fehlersuche, ist es jedoch von Vorteil, wenn ich genau weiß, wie ich mit einem Programm kommunizieren kann. Reinhard Fößmeier erläutert in [Foessmeier1991] die verschiedenen Schnittstellen von Programmen unter UNIX sehr ausführlich.

Kommandozeile

Die Kommandozeile bezeichnet Fößmeier als K-Schnittstelle. Diese besteht aus einer Reihe von Parametern, die als C-Strings übergeben werden und die jedes Programm beim Aufruf erhält. Das Programm strace zeigt diese als Parameter beim execve() Systemaufruf an.

Mit dieser Schnittstelle kann ich nur in einer Richtung kommunizieren: von der Aufrufumgebung zum Programm.

Wenn ich Shell-Skripts schreibe, kann ich auf diese Parameter mit den Variablen $0, $1, … zugreifen. In $# habe ich die Anzahl der beim Aufruf übergebenen Parameter.

In C-Programmen bekommt die Funktion main() als ersten Parameter die Anzahl und als zweiten Parameter einen Zeiger auf das Parameterfeld.

Prinzipiell gibt es keine Regeln für die Gestaltung dieser Parameter. Es hat sich aber eine Konvention herausgebildet, die von vielen Programmen eingehalten wird und für die es Unterstützung bei der Programmierung durch einige Bibliotheken gibt. Nach dieser Konvention werden Parameter in folgende Gruppen eingeteilt:

Optionen
bestehen oft aus einem Bindestrich, dem ein einzelnes Zeichen folgt. Diese werden unterschieden in Wertoptionen, denen ein Wert nachfolgt und Schaltoptionen, deren bloßes Vorhandensein ausreicht.

Neben den alten Optionen, die aus einem Zeichen bestehen, gibt es seit geraumer Zeit lange Optionen, die oft mit zwei Bindestrichen, manchmal mit einem + oder, seltener, mit einem Bindestrich eingeleitet werden.

Werteparameter
folgen einer Wertoption und beinhalten den zur Option gehörigen Wert. Diese Werteparameter können bei den kurzen Optionen direkt anschließen oder als nächster Parameter übergeben werden.

Bei den langen Optionen werden Werteparameter entweder mit einem = direkt an die Option angefügt oder als nächster Parameter angegeben.

Namensparameter
stellen oft Namen von Dateien dar, die vom Programm bearbeitet werden sollen.

Um Verwechslungen von Namensparametern mit Optionen auszuschließen, gibt es die Konvention, dass alle Parameter nach -- als Namensparameter verarbeitet werden, um so auch Dateien bearbeiten zu können, deren Name mit einem Bindestrich beginnt.

Umgebungsvariablen

Umgebungsvariablen bezeichnet Fößmeier als U-Schnittstelle. Im Gegensatz zur K-Schnittstelle, deren Parameter positionsabhängig sind, kann ich diese Variablen nur über ihren Namen ansprechen.

Auch mit dieser Schnittstelle kann ich nur von der Aufrufumgebung in Richtung aufgerufenes Programm kommunizieren.

In Shell-Skripts kann ich auf diese Variablen, genau wie auf lokale Variablen über den Namen mit vorangestelltem $ zugreifen.

Wie ich Umgebungsvariablen für ein aufgerufenes Programm setze, hängt von der verwendeten Shell ab. Bei der POSIX-Shell und den damit kompatiblen geschieht das durch einfache Zuweisung variable="wert". Die Anführungszeichen um den Wert sind notwendig, wenn dieser Leerzeichen enthält. Für die Übergabe an aufgerufene Programme habe ich zwei Möglichkeiten:

        TERM=vt100
        export TERM
        ssh server1
     TERM=vt220 ssh server2

Da die Kommunikation nur in einer Richtung geht, gibt es keine Möglichkeit, für ein aufgerufenes Programm, die Umgebungsvariablen des aufrufenden Prozesses zu ändern. Bei Shellprogrammen kann ich dafür auf einen Trick zurückgreifen, bei dem die Shell die Ausgabe des aufgerufenen Programms interpretiert und dieses die Zuweisung an Variablen in die Standardausgabe schreibt.

$ echo $abc
$ eval $(/bin/echo abc=def)
$ echo $abc
def

Rückgabewert

Wenn ein Prozess durch Aufruf von exit() endet, kann er einen ganzzahligen Wert als Statuscode angeben. Dieser Statuscode und der Zeitpunkt zu dem der Prozess endet bilden die R-Schnittstelle.

Die Shell und das Programm make zum Beispiel werten diesen Statuscode aus. Ein Wert von 0 wird dabei als erfolgreiche Beendigung des Programms gewertet, alle anderen Codes deuten auf ein Problem und sind abhängig vom aufgerufenen Programm.

In der Shell kann ich den Statuscode des letzten aufgerufenen Programms in der Variable $? abfragen. In C-Programmen durch Aufruf der Funktion wait().

Starte ich ein Shell-Skript mit der Option -e, bricht die Shell die Abarbeitung ab, sobald ein aufgerufenes Programm einen anderen Rückgabewert als 0 hat. Das ist auch das Standardverhalten von make.

Datenströme

Die S-Schnittstelle fasst alle Schnittstellen zusammen, die aus einem Strom von Zeichen bestehen. Das können die Standard-Datenströme sein (STDIN, STDOUT, STDERR) oder weitere geöffnete Dateien, Sockets oder Pipes.

Auf der Kommandozeile sind insbesondere die Standard-Datenströme relevant, da ich einzelne Programme damit verketten kann, so dass jedes folgende Programm die Standardausgabe (STDOUT) des vorigen Programms als Standardeingabe (STDIN) bekommt.

Im folgenden Beispiel liest das Programm tail fortlaufend aus einer Logdatei und übergibt seine Ausgabe dem Programm grep als Eingabe, welches alle Zeilen ignoriert bis auf diejenigen, die das Wort CRON enthalten:

$ tail -f /var/log/syslog | grep CRON

Prinzipiell kann über eine S-Schnittstelle ein unbegrenzter Strom von Daten übertragen werden, wie in obigem Beispiel.

Mit Dateiende (EOF) wird zu einem Datenstrom übermittelt, dass keine weiteren Daten mehr folgen. In diesem Fall kann ein Programm angemessen reagieren und sich beispielsweise beenden:

$ zcat /var/log/syslog* | grep CRON

In diesem Beispiel liest das Programm zcat alle Dateien aus dem Verzeichnis /var/log, deren Name mit syslog beginnt und sendet ihren Inhalt wie im Beispiel davor an grep. Nachdem es den Inhalt aller Dateien zur Standardausgabe geschickt hat, beendet sich zcat, wobei seine Standardausgabe geschlossen wird. Der Kernel übermittelt das als Dateiende bei STDIN an grep, welches sich daraufhin ebenfalls beendet.

Schreibe ich Daten von Hand in die Standardeingabe eines Prozesses, kann ich mit CTRL-D die Dateiendeinformation für den lesenden Prozess erzeugen.

Dateien

Die N-Schnittstelle entspricht dem Inode, der eine Datei in einem Dateisystem beschreibt. Diese Schnittstelle enthält Metainformationen über die betreffende Datei, aber nicht die Daten selbst. Ich verwende diese Schnittstelle um die Verwaltungsinformationen der Dateien abzufragen oder zu ändern.

Bei Gerätedateien kann ich darüber auch Einstellungen an den betreffenden Geräten vornehmen.

Text-Terminal

Die T-Schnittstelle bezeichnet das Treiberprogramm für das Text-Terminal über das ich meine Eingaben mache und die Ausgaben angezeigt bekomme. Dabei kann das Terminal eine normale Computerkonsole sein, eine serielle Konsole oder ein Programm wie Xterm.

Diese Schnittstelle sorgt dafür, dass beim traditionellen Zeilenendezeichen (LF) automatisch ein Wagenrücklauf (CR) ausgeführt wird, damit die nächste Zeile wieder am linken Rand beginnt. Im Cooked Mode sammelt diese Schnittstelle meine Eingabe bis zum RETURN um sie dann als ganze Zeile an den Prozess zu schicken.

Auf der Kommandozeile kann ich über das Programm stty auf diese Schnittstelle zugreifen und sie bearbeiten. In C Programmen verwende ich die Funktion ioctl().