Aus Linux-Magazin 06/2022

Hängende Programme erkennen und neu starten

© lightfieldstudios / 123RF.com

Hängende Programme mit eingefrorener Standardausgabe zu erkennen erfordert unter Umständen Klimmzüge bei der Terminalemulation. Go-Klempner Mike Schilli konstruiert eine Saugglocke, die die Rohre wieder freimacht.

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 es oft problemlos weiter, wenn der User mit [Strg]+[C] abbricht und das Kommando neu startet. Solche Szenarien, bei denen der Mensch in laufende Prozesse eingreifen muss, weil der Computer nicht bemerkt, 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. Das Dienstprogramm schnuppert in der Standardausgabe (oder auch in Stderr) von ihm 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 das und startet das Programm nach einem einstellbaren Timeout neu, in der Hoffnung, das Problem auf diese Weise zu beheben.

Gefühltes Terminal

Einfach, oder? Allerdings verhalten sich Programme unterschiedlich, je nachdem, 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 hilft dem Aufrufer besonders beim Einchecken größerer Dateien abzuschätzen, wie lange es wohl noch dauern wird (Abbildung 1, oberer Teil).

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

Abbildung 1: Nur wenn es sich 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 (Abbildung 1, unterer Teil), 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-Quellcodes 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 dem aus Listing 1 also unmöglich, den Fortschritt auf »git push« zu verfolgen: Sobald er Stdout und Stderr des zu überwachenden Programms anzapft, liegt daran 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

package main
import (
  "log"
  "os/exec"
)
func main() {
  cmd := exec.Command(
  "/usr/bin/git", "push",
  "origin", "master")
  stderr, _ := cmd.StderrPipe()
  cmd.Start()
  buf := make([]byte, 1024)
  for {
    _, err := stderr.Read(buf)
    if err != nil {
      panic(err)
    }
    log.Println(string(buf))
  }
}

Terminal als Kulisse

Als Ausweg muss der Überwacher dem betreuten Programm eine Umgebung bieten, die es denken lässt, es befinde 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 [1]. Diese Kernel-Strukturen gaukeln ausgeführten Programmen ein Terminal vor und erlauben es gleichzeitig Überwachern, die dort erfolgenden Eingaben (Stdin) zu steuern und Ausgaben (Stdout, Stderr) abzufangen. Standard-Linux-Utilities wie Ssh, Screen, Tmux und Script (zum Aufzeichnen von Shell-Sessions) machen regen Gebrauch von Pseudo-Terminals.

Zur Simulation eines überwachten Programms mit Terminal-Fühler dient das Testskript aus Listing 2. Es prüft in Zeile 2 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 5 mit einer Fehlermeldung ab. Die For-Schleife ab Zeile 7 zählt anderenfalls im Sekundentakt bis fünf, und danach schläft Zeile 12 (optional) 31 Sekunden, damit der Überwacher bei einem Timeout 30 Sekunden später den Wiederanlauf anberaumt.

Listing 2

test.sh

#!/bin/bash
if [ ! -t 1 ]
then
  echo "not a terminal!"
  exit 1
fi
for i in `seq 1 5`
do
  echo -n "$i "
  sleep 1
done
# sleep 31
echo

Das weiter unten vorgestellte Go-Programm Yoyo meistert die Aufgabe hervorragend, wie Abbildung 2 und Abbildung 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 Anweisung »sleep 31«, ergibt sich die Situation aus Abbildung 3: Der Überwacher Yoyo wartet geduldig 30 Sekunden, bevor er die Reißleine zieht und das Skript neu startet.

Abbildung 2: Das Testskript beendet sich nach 5 Sekunden, was Yoyo mitbekommt.

Abbildung 2: Das Testskript beendet sich nach 5 Sekunden, was Yoyo mitbekommt.

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

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

Gut geklaut

Wie nun sieht die Implementierung von Yoyo mit seiner Terminal-Trickserei aus? Ein Pseudo-Terminal-Paar aufzusetzen erfordert einigen Boilerplate-Code, aber zum Glück liegt auf Github schon ein Projekt namens Expectre [2], das das Linux-Tool Expect in Go implementiert. Yoyo klaut den Expectre-Pty-Code einfach: In Zeile 5 von Listing 3 zieht es ihn von Github.

In Zeile 24 erzeugt 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äß der Name des aufgerufenen Programms (»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 Kommandozeile herausgefieselt. Zeile 25 gibt sie ausgeflacht an »Spawn()« weiter.

Die Funktion »Spawn()« aus dem Paket expectre startet den ihr übergebenen Prozess mit einem Pseudo-Terminal-Paar, sodass sich der Prozess in einem regulären Terminal wähnt und sich dementsprechend verhält. Die For-Schleife ab Zeile 30 springt in eine Select-Anweisung, die auf eines von vier verschiedenen Ereignissen wartet:

  • Auf den Channels »exp.Stdout« oder »exp.Stderr« kommt eine Ausgabezeile des gestarteten Prozesses an.
  • Der Timer in Zeile 36 läuft ab.
  • 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.

Listing 3

yoyo.go

package main
import (
  "flag"
  "log"
  "github.com/mittwingate/expectre"
  "os"
  "time"
  "fmt"
)
func main() {
  timeout := flag.Int("timeout", 30,
    "seconds of inactivity before restart")
  maxtries := flag.Int("maxtries", 10,
    "max number of retries")
  flag.Parse()
  if flag.NArg() == 0 {
    fmt.Printf("usage: %s command ...\n", os.Args[0])
    os.Exit(1)
  }
restart:
  for try := 0; try <= *maxtries; try++ {
    log.Printf("Starting %s ...\n", flag.Arg(0))
    exp := expectre.New()
    err := exp.Spawn(flag.Args()...)
    if err != nil {
      panic(err)
    }
    var triggered bool
    for {
      select {
      case val := <-exp.Stdout:
        log.Println(val)
      case val := <-exp.Stderr:
        log.Println(val)
      case <-time.After(time.Duration(*timeout) * time.Second):
        log.Printf("Timed out. Shutting down ...\n")
        triggered = true
        exp.Cancel()
      case <-exp.Released:
        log.Printf("%s ended.\n", os.Args[0])
        if triggered {
          continue restart
        }
        break restart
      }
    }
  }
}

Stockender Fluss

Letztlich unterscheidet Yoyo zwischen zwei Fällen: 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 36 ab, muss Yoyo den überwachten Prozess stoppen, was in Zeile 39 praktischerweise die Funktion »Cancel()« aus dem Paket expectre erledigt.

In diesem Fall setzt Zeile 38 allerdings die Variable »triggered« auf einen wahren Wert. Sobald der überwachte Prozess nach einer »exp.Released()«-Nachricht in die Grube gefahren ist, schubst Zeile 43 mit »continue restart« die äußere For-Schleife mit dem Label »restart« ab Zeile 22 wieder neu an. Der Reigen beginnt von vorn, nachdem »Spawn()« in Zeile 25 den Prozess erneut gestartet hat.

Yoyo kompilieren

Zum Kompilieren des Yoyo-Binarys aus den Quellen benötigt der Go-Compiler die in Listing 3 genutzte Expectre-Library von Github. Der Dreisprung aus Listing 4 erledigt wie immer die Auflösung der Abhängigkeiten und das Übersetzen des lauffähigen Programms.

Listing 4

Kompilieren

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

Kurz angebunden

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 die entsprechenden Werte, und Gos flag-Paket erledigt das Entgegennehmen von der Kommandozeile und die Syntaxprüfung der Argumente.

Abbildung 4 zeigt einen Yoyo-Lauf mit 2 Sekunden Timeout und maximal zwei Restarts des Testskripts »test.sh« aus Listing 2. Nachdem auch der zweite Restart in einen Timeout läuft, bricht Yoyo vorschriftsmäßig ab. Beim Übertragen 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. Yoyo erkennt das nach 30 Sekunden, terminiert den hängenden Prozess und schubst ihn dann erneut an (Abbildung 5). Prompt geht es weiter.

Abbildung 4: Ein Yoyo-Durchlauf mit 2&nbsp;Sekunden Timeout und maximal zwei Versuchen.

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

Abbildung 5: Nachdem das &raquo;git push&laquo; an Fahrt verliert, schubst Yoyo es wieder an.

Abbildung 5: Nachdem das »git push« an Fahrt verliert, schubst Yoyo es 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, statt wieder von vorn anzufangen. Rsync zum Beispiel prüft im Modus »–append«, ob auf dem Zielrechner schon eine gleichnamige Datei aus einem vorhergegangenen, abgebrochenen Übertragungsversuch liegt, und spult die Übertragung gegebenenfalls entsprechend vor.

Im Aufruf aus Listing 5 schiebt die Option »-a« (archive) dabei eine angegebene Datei auf den Server, »-v« schaltet den Verbose-Modus an, und »-P« steht als 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.

Listing 5

Wiederanlauf

$ rsync -avP --append file hoster.com

Mit »yoyo /usr/local/bin/rsync -avP …« aufgerufen beobachtet Yoyo, ob der Rsync-Prozess immer schön weiter Ausgaben produziert. Falls nichts mehr vorwärts geht, etwa weil der Server gerade einen Powernap macht, bricht Yoyo Rsync rücksichtslos ab und schubst es wieder neu an.

Beim Aufruf gilt es zu beachten, dass Yoyo den vollständigen Pfad zum überwachten Programm erwartet und nicht in »$PATH« danach sucht wie die Shell. Schon wieder einen manuellen Eingriff gespart, dank Automatisierung – so muss es sein! (uba/jlu)

Der Autor

Michael Schilli arbeitet als Software Engineer in der San Francisco Bay Area in Kalifornien. In seiner Kolumne forscht er jeden Monat nach praktischen Anwendungen verschiedener Programmiersprachen. Unter mailto:mschilli@perlmeister.com beantwortet er gern Ihre Fragen.

Infos

  1. Snapshot: Michael Schilli, “Dressur mit Tiefgang”: LM 06/2021, S.80, https://www.linux-magazin.de/ausgaben/2021/06/snapshot/
  2. Expectre: https://github.com/mittwingate/expectre
DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 4 HeftseitenPreis €0,99
(inkl. 19% MwSt.)
LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Inline Feedbacks
Alle Kommentare anzeigen
Nach oben