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.
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 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.
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 }
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.
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. |
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.
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.
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.
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. |
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!
Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2022/04/snapshot/
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>
Go-Expect-Klon "Expectre" auf Github, https://github.com/mittwingate/expectre
Hey! The above document had some coding errors, which are explained below:
Unknown directive: =desc