Rohrreiniger (Linux-Magazin, Juni 2022)

Lädt der Browser eine Webseite nur halb und hängt dann, wissen erfahrene User, dass oft ein Klick auf den Reload-Button hilft, und beim nächsten Versuch klappt alles wie am Schnürchen. Geht bei einer Übertragung mit rsync nichts mehr vorwärts, weil der Server eingeschlafen ist, geht's oft problemlos weiter, wenn der User mit Ctrl-C abbricht und das Kommando neu startet. Solche Szenarien, bei denen der Mensch in laufende Prozesse eingreifen muss, weil der Computer nicht realisiert, dass ein automatischer Neustart die Lösung brächte, sind eine letzte Domäne menschlichen Einschreitens in Vorgänge, die eigentlich automatisiert gehörten.

Das in dieser Ausgabe vorgestellte Go-Programm yoyo verfährt mit den ihm anvertrauten Programmen wie mit einem Jojo-Spielzeug, das auch immer wieder von Menschenhand hochgezogen wird, damit es in Bewegung bleibt. Die Utility schnuppert dabei in der Standardausgabe (oder auch in Stderr) von ihr gestarteter Programme herum, die mit Fortschrittsbalken oder ähnlichen Instrumenten anzeigen, ob noch etwas vorwärts geht. Friert deren Anzeige ein, etwa weil das Netzwerk hängt oder der Server die Lust verloren hat, bemerkt yoyo dies und startet das Programm nach einem einstellbaren Timeout neu, in der Hoffnung, dass sich das Problem dadurch behebt.

Gefühltes Terminal

Einfach, oder? Allerdings verhalten sich viele Programme unterschiedlich, je nach dem, ob sie sich in einem Terminal wähnen oder nicht. Ein "git push" zum Beispiel gibt in einem Terminal laufend den Fortschritt der Übertragung in Prozent an, das ist besonders nach dem Einchecken größerer Dateien von Nutzen für den Aufrufer, der damit abschätzen kann, wie lange es wohl noch dauern wird (oberer Teil von Abbildung 1).

Abbildung 1: Nur wenn sich git in einem Terminal wähnt, spuckt git Statistiken zum Datentransfer aus.

Findet "git push" allerdings kein Terminal vor, zum Beispiel weil sowohl seine Stdout- als auch Stderr-Kanäle in eine Datei out umgeleitet wurden, wie im unteren Teil von Abbildung 1, sendet es während der Übertragung der Dateien auf den Git-Server überhaupt keine Fortschrittsmeldungen, sondern nur eine Meldung am Ende, wenn alles erledigt ist. Wie man anhand des Git-Sourcecodes auf Github einfach herausfinden kann, prüft git anhand der C-Standardfunktion isatty(2), ob die Fehlerausgabe (File-Deskriptor 2) an einem Terminal hängt, und unterbindet die Ausgabe, falls isatty(2) den Wert 0 zurückgibt.

Ohne Terminal wortkarg

Ohne weitere Trickserei wäre es einem simplen Jojo-Controller wie aus Listing 1 also unmöglich, den Fortschritt auf "git push" zu verfolgen, denn sobald es Stdout und Stderr des zu überwachenden Programms anzapft, liegt an diesen kein Terminal mehr an, was git bemerkt, und auf wortkarg schaltet. Listing 1 gibt demnach nur eine kurze Statusmeldung aus, nachdem das git-Kommando sich beendet hat. Für eine Überwachung des Ablaufs taugt das nicht.

Listing 1: capture.go

    01 package main
    02 
    03 import (
    04   "log"
    05   "os/exec"
    06 )
    07 
    08 func main() {
    09   cmd := exec.Command(
    10   "/usr/bin/git", "push",
    11   "origin", "master")
    12 
    13   stderr, _ := cmd.StderrPipe()
    14   cmd.Start()
    15 
    16   buf := make([]byte, 1024)
    17 
    18   for {
    19     _, err := stderr.Read(buf)
    20     if err != nil {
    21       panic(err)
    22     }
    23     log.Println(string(buf))
    24   }
    25 }

Terminal als Kulisse

Als Ausweg muss der Überwacher dem betreuten Programm eine Umgebung bieten, die es denken lässt, es befände sich in einem Terminal. Zum Glück bietet Linux dafür Pseudo-Terminals, die in einer zurückliegenden Ausgabe des Snapshots schon einmal Verwendung fanden ([2]). Diese Kernel-Strukturen gaukeln ausgeführten Programmen ein Terminal vor, und erlauben es Überwachern gleichzeitig, die dort erfolgenden Eingaben (Stdin) zu steuern und Ausgaben (Stdout, Stderr) abzufangen. Standard-Linux-Utilities wie ssh, screen, tmux oder script (zum Aufzeichnen von Shell-Sessions) machen regen Gebrauch von Pseudo-Terminals.

Zur Simulation eines überwachten Programms mit Terminal-Fühler dient das Test-Skript in Listing 2. Es prüft in Zeile 3 zunächst mit -t, ob die Standardausgabe (File-Deskriptor Nummer 1) an einem Terminal hängt. Schlägt dieser Test fehl, bricht das Skript den Ablauf in Zeile 6 mit einer Fehlermeldung ab. Die for-Schleife ab Zeile 9 zählt dann im Sekundentakt bis fünf, und danach schläft Zeile 15 (optional) 31 Sekunden, damit der Überwacher bei einem Timeout nach 30 Sekunden später den Wiederanlauf anberaumt. Das weiter unten vorgestellte Go-Programm yoyo meistert die Aufgabe hervorragend, wie die Abbildungen 2 und 3 zeigen. Im ersten Fall bekommt yoyo die einzelnen Meldungen im Sekundentakt mit und gibt sie aus, bis es feststellt, dass das zu überwachende Skript den Betrieb eingestellt hat. Das ist normal und gut so. Aktiviert Listing 2 hingegen die sleep 31-Anweisung, zeigt Abbildung 3, dass der Überwacher yoyo geduldig 30 Sekunden wartet, bevor er die Reißleine zieht und das Skript neu startet.

Listing 2: test.sh

    01 #!/bin/bash
    02 
    03 if [ ! -t 1 ]
    04 then
    05     echo "not a terminal!"
    06     exit 1
    07 fi
    08 
    09 for i in `seq 1 5`
    10 do
    11     echo -n "$i "
    12     sleep 1
    13 done
    14 
    15 # sleep 31
    16 echo

Abbildung 2: Das Testskript beendet sich nach 5 Sekunden und yoyo bekommt es mit.

Abbildung 3: Schläft das Testskript 31 Sekunden, startet es yoyo endlos neu.

Gut geklaut

Wie nun sieht die Implementierung der yoyo-Utility mit ihrer Terminal-Trickserei aus? Ein Pseudo-Terminal-Paar aufzusetzen erfordert einigen Boilerplate-Code, aber zum Glück liegt auf Github schon ein Projekt namens "expectre" ([3]), das die Linux-Utility expect in Go implementiert und deren Pty-Code Listing 3 einfach klaut. In Zeile 6 zieht es den Code von Github, und in Zeile 27 kreiert es ein neues expectre-Objekt, das danach Spawn aufruft, mit den Prozessparametern, die yoyo als Argumente auf der Kommandozeile überreicht wurden. In os.Args[0] liegt traditionsgemäß das aufgerufene Programm (yoyo). Die vom Aufrufer mitgegebenen Argumente, im vorliegenden Fall also ./test.sh, denn yoyo soll das Testprogramm ausführen, hat das flag-Paket aus der Standardprogrammsammlung schon aus der Kommandzeile herausgefieselt und Zeile 28 gibt sie ausgeflacht an Spawn() weiter.

Listing 3: yoyo.go

    01 package main
    02 
    03 import (
    04     "flag"
    05   "log"
    06   "github.com/mittwingate/expectre"
    07   "os"
    08   "time"
    09   "fmt"
    10 )
    11 
    12 func main() {
    13   timeout := flag.Int("timeout", 30,
    14     "seconds of inactivity before restart")
    15   maxtries := flag.Int("maxtries", 10,
    16     "max number of retries")
    17   flag.Parse()
    18 
    19   if flag.NArg() == 0 {
    20     fmt.Printf("usage: %s command ...\n", os.Args[0])
    21     os.Exit(1)
    22   }
    23 
    24 restart:
    25   for try := 0; try <= *maxtries; try++ {
    26     log.Printf("Starting %s ...\n", flag.Arg(0))
    27     exp := expectre.New()
    28     err := exp.Spawn(flag.Args()...)
    29     if err != nil {
    30       panic(err)
    31     }
    32 
    33     var triggered bool
    34 
    35     for {
    36       select {
    37       case val := <-exp.Stdout:
    38         log.Println(val)
    39       case val := <-exp.Stderr:
    40         log.Println(val)
    41       case <-time.After(time.Duration(*timeout) * time.Second):
    42         log.Printf("Timed out. Shutting down ...\n")
    43         triggered = true
    44         exp.Cancel()
    45       case <-exp.Released:
    46         log.Printf("%s ended.\n", os.Args[0])
    47         if triggered {
    48           continue restart
    49         }
    50         break restart
    51       }
    52     }
    53   }
    54 }

Die Funktion Spawn() aus dem Paket expectre startet den ihr übergebenen Prozess mit einem Pseudo-Terminal-Paar, sodass sich der Prozess also einem regulären Terminal wähnt und sich dementsprechend verhält. Die for-Schleife ab Zeile 35 springt in eine select-Anweisung, die auf eines von vier verschiedenen Ereignissen wartet: Entweder kommt auf den Channels exp.Stdout oder exp.Stderr eine Ausgabezeile des gestarteten Prozesses an, oder der Timer in Zeile 41 läuft ab, oder der Channel exp.Released signalisiert, dass der zur Überwachung gestartete Prozess gerade das Zeitliche gesegnet hat und nun nicht mehr existiert. Derartige select-Anweisungen sind typisch für Go-Programme, die auf Events warten, jeder case-Fall wartet entweder auf Nachrichten beliebig vieler überwachter Channels, oder auf das Ablaufen eines Timers, und alles gleichzeitig, ohne dass der Rechner dazu aktiv Arbeit verrichten müsste.

Stockender Fluss

Letzteres kann auf zweierlei Weise passieren: Entweder der Prozess beendet sich selbst, weil er am Ende seiner Instruktionen angekommen ist. Das ist perfekt, denn dann muss yoyo nichts unternehmen und kann sich selbst ebenfalls beenden. Läuft allerdings der Timer in Zeile 41 ab, muss yoyo den überwachten Prozess stoppen, was in Zeile 44 praktischerweise die Funktion Cancel() aus dem expectre-Paket erledigt. In diesem Fall setzt Zeile 43 allerdings die Variable triggered auf einen wahren Wert, und sobald der überwachte Prozess nach einer exp.Released()-Nachricht in die Grube gefahren ist, schubst Zeile 48 mit continue restart die äußere for-Schleife mit dem Label restart ab Zeile 24 wieder neu an, und der Reigen beginnt von vorne, nachdem Spawn() in Zeile 28 den Prozess erneut gestartet hat.

Stockender Fluss

Jede Applikation ist anders und während eine nach 30 Sekunden angeschubst werden möchte, ist bei einer anderen vielleicht ein kürzerer Timeout sinnvoll. Außerdem soll yoyo die Anzahl der Startversuche begrenzen, denn geht etwas schief, sollte es nicht unbegrenzt Neustarts versuchen und damit womöglich den Admin des Servers erzürnen. Die Flags --timeout und --maxtries setzen diese Werte, und Gos flag-Paket erledigt das Entgegennehmen von der Kommandozeile und die Syntaxprüfung der Argumente.

Zum Kompilieren des Binaries aus den Sourcen benötigt der Go-Compiler die in Listing 3 genutzte Expectre-Library von Github, der Dreisprung

    $ go mod init yoyo
    $ go mod tidy
    $ go build yoyo.go

erledigt wie immer die Auflösung der Abhängigkeiten und das Kompilieren des lauffähigen Programms.

Abbildung 4: Yoyo-Durchlauf mit 2 Sekunden-Timeout und maximal 2 Versuchen.

Abbildung 4 zeit einen Yoyo-Lauf mit zwei Sekunden-Timeout und einer auf zwei begrenzten Zahl von Restarts des Testsskripts test.sh aus Listing 2. Nachdem auch der zweite Restart auf einen Timeout gelaufen ist, bricht yoyo vorschriftsmäßig ab. Bei der Übertragung eines länglichen Commits zeigt die yoyo-Überwachung des Kommandos "git push", dass die Übertragung nach einiger Zeit einschläft, weil der Server nur noch sehr zögerlich antwortet, und yoyo dies nach 30 Sekundne erkennt, um den Prozess daraufhin zu terminieren und erneut anzuschubsten. Prompt geht es weiter.

Abbildung 5: Als ein "git push" an Fahrt verliert, schubst es "yoyo" wieder an.

Nicht zurück auf Los

Damit der Wiederanlauf eines stockenden Programms besonders effektiv gelingt, sollte es in der Lage sein, dort weiterzumachen, wo es aufgehört hat, und nicht wieder von vorne anfangen. Die Funktion rsync zum Beispiel prüft im Modus --append, ob auf dem Zielrechner schon eine Datei gleichen Namens von einem vorhergegangenen und abgebrochenen Übertragungsversuch liegt, und spult die Übertragung soweit vor:

    $ rsync -avP --append file hoster.com:

Die Option -a ("archive") schiebt dabei eine angegebene Datei auf den Server, -v schaltet den Verbose-Modus an, und -P ist eine Abkürzung für --partial --progress. In diesem Modus behält rsync nur teilweise angekommene Dateien auf dem Zielserver, wenn deren Übertragung unterbrochen wurde und --progress zeigt im Sekundentakt den Fortschritt an. Mit

    $ yoyo /usr/local/bin/rsync -avP ...

aufgerufen wird yoyo dafür sorgen, dass der rsync-Prozess immer schön weiter Ausgaben produziert, und wird ihn rücksichtslos abbrechen und wieder anschubsen, falls nichts mehr vorwärts geht, etwa weil der Server gerade eine Power-Nap macht. Zu beachten ist, dass yoyo den vollständigen Pfad zum überwachten Programm erwartet und nicht in $PATH erst danach sucht wie die Shell. Schon wieder einen manuellen Eingriff gespart, dank Automatisierung, so muss es sein!

Infos

[1]

Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2022/04/snapshot/

[2]

https://www.linux-magazin.de/ausgaben/2021/06/snapshot/, Michael Schilli, "Dressur mit Tiefgang": Linux-Magazin 21/06, S.80, <U>https://www.linux-magazin.de/ausgaben/2021/06/snapshot/<U>

[3]

Go-Expect-Klon "Expectre" auf Github, https://github.com/mittwingate/expectre

Michael Schilli

arbeitet als Software-Engineer in der San Francisco Bay Area in Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen verschiedener Programmiersprachen. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.

POD ERRORS

Hey! The above document had some coding errors, which are explained below:

Around line 5:

Unknown directive: =desc