Aus Linux-Magazin 12/2019

Terminal-UI zeigt laufende Docker-Container samt Historie

© alphaspirit, 123rf

Mit einem in Go implementierten, selbstgestrickten Monitoring-System lässt sich Mike Schilli die auf seinem System gestarteten und beendeten Container anzeigen.

Auch im Zeitalter von neumodischen Schlagwörtern wie “Kubernetes” schätzen altgediente Systemadministratoren noch Kommandozeilenwerkzeuge wie das gute alte Top, das laufende Prozesse in Echtzeit anzeigt. Und da auch ich in meinem mittlerweile schon als fortgeschritten zu bezeichnenden Alter mit den jungen Füchslein mithalten will, strickte ich mir kürzlich einen ebenfalls terminalbasierten kleinen Monitor, der anzeigt, welche Container auf einem System kommen und gehen.

Der Feld-, Wald- und Wiesen-Docker-Client Docker ist in Go geschrieben und kommuniziert über eine Web-Schnittstelle mit dem Docker-Daemon, um den Status laufender Container abzufragen, neue zu starten oder alte zu terminieren. Neben einer Python-Schnittstelle bietet Docker auch ein Go-SDK an, und da Go auch über hervorragende Libraries zur Darstellung im Terminal verfügt, fiel die Wahl für die Implementierung des heute vorgestellten Monitors »dockertop« darauf.

Die Idee ist simpel: Das Programm befragt in regelmäßigen Abständen den Docker-Daemon nach allen auf dem System laufenden Containern, zeigt deren Namen in einer Liste an und frischt diese im Sekundentakt auf – ganz wie Top das seit eh und je macht. Als zusätzliches Schmankerl zeigt der Monitor im rechten Teil des zweigeteilten Screens auch noch eine rollende Historie der Container an. Jedes Mal, wenn es einen neuen entdeckt, schreibt das Programm »New: Name« hinein, und wenn ein Container seit dem letzten Aufruf abhanden kam – etwa, weil er zwischenzeitlich den Geist aufgegeben hat – lautet der Log-Eintrag: »Gone: Name« (Abbildung 1).

Abbildung 1: Die linke Spalte der Terminal-UI zeigt aktive Docker-Container, die rechte den zeitlichen Verlauf gefundener und verlorener Container.

Abbildung 1: Die linke Spalte der Terminal-UI zeigt aktive Docker-Container, die rechte den zeitlichen Verlauf gefundener und verlorener Container.

So erhält der Sysadmin auch auf einem heftig rödelnden System mit vielen Containern einen Eindruck davon, wie die einzelnen Instanzen operieren. Je nachdem, wie schnell das Log durchrattert, lässt sich so abschätzen, ob eventuell ein Fehler vorliegt, der soeben gestartete Container vorzeitig wieder zum Abbruch zwingt.

Aus dem Hause Google

Der Programmier-Snapshot hat in zurückliegenden Ausgaben schon mehrfach Terminal-UIs [1] vorgestellt, darunter Termui [2] sowie Promptui [3]. Diesmal kommt ein an Termui angelehntes Framework aus dem Hause Google dran: Termdash, das sich besonders zum Darstellen von Dashboards eignet, den Armaturenbrettern der Datenwelt.

Listing 1 implementiert die grafischen Komponenten aus der Terminal-UI in Abbildung 1, und zieht dazu eine ganze Litanei von Go-Libraries auf Github hinzu. Da der Widget-»container« in Zeile 7 mit den später genutzten Docker-Containern kollidieren würde, zieht der Code die Komponente unter dem Namen »tco« herbei. Die in Go etwas wortreiche Behandlung von individuellen Fehlern kürzt die Funktion »panicOnError()« ab Zeile 16 ab. In einem Produktionssystem würde der Code Fehler wohl explizit und dediziert behandeln, statt gleich das Programm abzubrechen, falls etwas schiefgeht. Aber in unserem Beispiel sparen wir uns dadurch ein ellenlanges Listing.

Bei dem in Zeile 28 ins Leben gerufenen »context«-Konstrukt handelt es sich um eine Art Fernbedienung, die Unterfunktionen in Go einander mitgeben. Wenn das Hauptprogramm mit dem Aufruf der zurückgegebenen »cancel()«-Funktion das Ende einläutet, bekommen alle Unterprogramme das mit und können ihrerseits Aufräumaktionen einleiten.

Das Hauptfenster der Applikation sowie die beiden nebeneinander liegenden Textfenster aus Abbildung 1 für die Top-Darstellung (»top«) und das rollende Log-Fenster (»rolled«) arrangiert der Aufruf von »SplitVertical()« mit den Helfern »Left()« und »Right()« im Terminal. Wenn der User die Taste [Q] drückt, soll Go die UI abräumen und das Programm abbrechen. Daher definiert Zeile 56 mit »quit« einen Tastaturüberwacher, der anschlägt, falls der User die entsprechende Taste auslöst. Einmal in Aktion, ruft der Callback in Zeile 58 die Funktion »cancel()« des vorher erzeugten Kontexts auf.

Damit die UI auf den Kontext zu reagieren vermag, wird dieser der mit »Run()« gestarteten UI-Hauptschleife in Zeile 62 mitsamt allen zu betreibenden Widgets mitgegeben. Die Hauptschleife bekommt etwaige Aufforderungen zum Verlassen des Geschäfts bei Ladenschluss über die Fernbedienung mit und faltet die UI sauber zusammen. Unterbliebe dies, wäre das Terminal so verstellt, dass der User anschließend keine Befehle mehr in die Shell eingeben könnte und gut beraten wäre, ein neues Terminal zu öffnen.

Listing 1

dockertop.go

package main
import (
  "context"
  "fmt"
  "github.com/mum4k/termdash"
  tco "github.com/mum4k/termdash/container"
  "github.com/mum4k/termdash/linestyle"
  "github.com/mum4k/termdash/terminal/termbox"
  "github.com/mum4k/termdash/terminal/terminalapi"
  "github.com/mum4k/termdash/widgets/text"
  "strings"
  "time"
)
func panicOnError(err error) {
  if err != nil {
    panic(err)
  }
}
func main() {
  t, err := termbox.New()
  panicOnError(err)
  defer t.Close()
  ctx, cancel :=
   context.WithCancel(context.Background())
  top, err := text.New()
  panicOnError(err)
  rolled, err := text.New(
    text.RollContent(), text.WrapAtWords())
  panicOnError(err)
  go updater(top, rolled)
  c, err := tco.New(
    t,
    tco.Border(linestyle.Light),
    tco.BorderTitle(" PRESS Q TO QUIT "),
    tco.SplitVertical(
      tco.Left(
        tco.PlaceWidget(top),
      ),
      tco.Right(
        tco.Border(linestyle.Light),
        tco.BorderTitle(" History "),
        tco.PlaceWidget(rolled),
      ),
    ),
  )
  panicOnError(err)
  quit := func(k *terminalapi.Keyboard) {
    if k.Key == 'q' || k.Key == 'Q' {
      cancel()
    }
  }
  err = termdash.Run(ctx, t, c,
    termdash.KeyboardSubscriber(quit))
  panicOnError(err)
}
func updater(top *text.Text,
             rolled *text.Text) {
  items_saved := []string{}
  for {
    err, items, _ := dockerList()
    panicOnError(err)
    add, remove :=
      diff(items_saved, items)
    for _, item := range add {
      err := rolled.Write(
        fmt.Sprintf("New: %s\n", item))
      panicOnError(err)
    }
    for _, item := range remove {
      err := rolled.Write(
        fmt.Sprintf("Gone: %s\n", item))
      panicOnError(err)
    }
    content := strings.Join(items, "\n")
    if len(content) == 0 {
      content = " " // can't be empty
    }
    err = top.Write(content,
      text.WriteReplace())
    panicOnError(err)
    items_saved = items
    time.Sleep(time.Second)
  }
}

Tag des Maulwurfs

Die ab Zeile 37 asynchron aufgerufene Go-Routine »updater()« definiert die Zeitschleife, die die UI im Abstand von jeweils einer Sekunde mit den neuesten Daten des Docker-Daemons auffrischt. Ab Zeile 67 holt sie die Liste der Container mithilfe der Funktion »dockerList()« aus Listing 2. Das linke Teilfenster mit der Top-Darstellung frischt »top.Write()« in Zeile 92 von Listing 1 mit einem langen String »content« auf, der die einzelnen Containernamen mit zehn Zeichen ihrer ID enthält, durch Zeilenumbrüche getrennt.

Listing 2

dockerlist.go

package main
import (
  "context"
  "fmt"
  "github.com/docker/docker/api/types"
  "github.com/docker/docker/client"
)
func dockerList() (error, []string,
    map[string]types.Container) {
  items := []string{}
  containerMap :=
    make(map[string]types.Container)
  opt :=
    client.WithAPIVersionNegotiation()
  cli, err :=
    client.NewClientWithOpts(opt)
  if err != nil {
    return err, nil, nil
  }
  defer cli.Close()
  containers, err := cli.ContainerList(
      context.Background(),
      types.ContainerListOptions{})
  if err != nil {
    return err, nil, nil
  }
  for _, container := range containers {
    name := fmt.Sprintf("%s-%s",
      container.Image, container.ID[:10])
    items = append(items, name)
    containerMap[name] = container
  }
  return nil, items, containerMap
}

Container, die der Monitor zum ersten Mal sieht, meldet die Funktion »diff()« aus Zeile 75 von Listing 1, zu der wir später in Listing 4 kommen werden. Sie gibt dazu zwei Array-Slices zurück, »add« und »remove«, die »diff()« aus dem Unterschied zwischen dem letzten Container-Listing (»items_saved«) und dem aktuellen (»items«) generiert. Das Ganze steht in einer endlosen For-Schleife, an deren Ende in Zeile 97 der Aufruf »time.Sleep()« eine Sekunde pausiert, bevor es in die nächste Runde geht. Die Schleife und der Sleep-Befehl laufen in einer Go-Routine ab, also asynchron, und dadurch bleibt die UI voll ansprechbar.

Das war es schon für die UI, deren Implementierung ganze 99 Zeilen umfasst. Wie erhält das Go-Programm nun Zugriff auf die Namen der laufenden Container? Die einzelnen Komponenten der Docker-API und deren Funktionen beschreibt die automatisch generierte Dokumentation des Go-Codes im Detail [4].

Allerdings kocht die Firma Docker mit ihrem Open-Source-Project Moby hier ein seltsames Süppchen und hält sich nicht an die in der Go-Community übliche Versionierung, sodass das sonst erfolgreiche »go mod init« für Listing 2 nicht funktioniert. Vielmehr muss der User die Library installieren (Listing 3, erste Zeile) und das Ganze mit allen per »import« hereingezogenen Libraries wiederholen. Erst dann kann er das Dockertop-Binary bauen (zweite Zeile). Nutzt der Entwickler hingegen das moderne Modulverfahren, liefert die Docker-API eine Uraltversion aus, die die verwendeten Funktionen in den Listings noch nicht unterstützt.

Listing 3

Dockertop bauen

$ go get -u github.com/docker/docker/client
$ go build dockertop.go dockerlist.go dockerdiff.go

Hallo Daemon, hier Client

Als simplen Docker-Client, der die Liste aller Container vom Daemon abholt, würde auch ein aus der Shell aufgerufenes »docker ps« taugen, aus dessen Standardausgabe die Namen herauspurzeln. Technisch korrekt, weil beliebig erweiterbar, geht es mit der Docker-Client-API – allerdings mit etwas mehr Aufwand.

Zeile 19 in Listing 2 erzeugt ein neues Client-Objekt und übergibt ihm den Parameter »WithAPIVersionNegotiation«. Der ist enorm wichtig: Ohne ihn meckert der Client auf einem etwas in die Jahre gekommenen Ubuntu-System, dass der Server ihn abweist, weil die Client-Versionsnummer angeblich zu hoch ausfällt. Mit dem Versionsverhandlungsparameter einigen sich die beiden und treten in Kontakt. Mit »ContainerList()« kommt dann eine nach Startdatum sortierte Liste aktiver Container-Objekte zurück, deren verwendetes Docker-Image jeweils im Attribut ».Image« steht.

Damit der Client mehrere parallel laufende Ubuntu-Container voneinander unterscheiden kann, fügt Zeile 34 mit »container.ID[:10]« noch die ersten zehn Zeichen der eindeutigen Container-ID hinzu. Die Namen aller so gefundenen Container hängt Zeile 35 an einen Slice von Strings an, sodass die ursprüngliche Sortierung bestehen bleibt.

Die ebenfalls vorliegenden Zusatzinformationen zu jedem Container landen in der Map »containerMap« unter dem in »items« liegenden Namen. So können andere Programmteile sowohl auf die richtig sortierte Liste als auch bei Bedarf auf mehr Details zugreifen. Beide Datenstrukturen gibt »dockerList()« an den Aufrufer zurück.

Diff als Schmankerl

Schließlich noch zur Historie der Container, die im rechten Teilfenster des Monitors erscheinen: Hierzu vergleicht Listing 4 in der Funktion »diff()« zwei String-Array-Slices und stellt fest, welche Einträge im zweiten neu hinzugekommen sind und welche Einträge zwar im ersten Array stehen, aber es nicht ins zweite geschafft haben.

Listing 4

dockerdiff.go

package main
import "github.com/yudai/golcs"
func diff(old []string,
          new []string) (add []string,
          remove []string) {
  left := make([]interface{}, len(old))
  for i, v := range old {
    left[i] = v
  }
  right := make([]interface{}, len(new))
  for i, v := range new {
    right[i] = v
  }
  l := lcs.New(left, right)
  leftidx := 0
  rightidx := 0
  for _, pair := range l.IndexPairs() {
    for leftidx < len(left) &&
        leftidx <= pair.Left {
      if leftidx < pair.Left {
        remove =
          append(remove, old[leftidx])
      }
      leftidx++
    }
    for rightidx < len(right) &&
        rightidx <= pair.Right {
      if rightidx < pair.Right {
        add = append(add, new[rightidx])
      }
      rightidx++
    }
  }
  for leftidx < len(left) {
    remove = append(remove, old[leftidx])
    leftidx++
  }
  for rightidx < len(right) {
    add = append(add, new[rightidx])
    rightidx++
  }
  return add, remove
}

Das Verfahren kennt der Unix-Fachmann vom Werkzeug Diff, das den Unterschied zwischen zwei Dateien ebenfalls in Zeilen anzeigt, die entweder hinzugekommen oder weggefallen sind. Abbildung 2 illustriert, dass Diff korrekt herausfindet, dass aus der Datei »test1.txt« die Einträge »bar« und »zap« entfernt wurden und dass der Eintrag »pow« in »test2.txt« hinzukam.

Abbildung 2: Der Diff-Algorithmus basiert auf dem LCS-Verfahren.

Abbildung 2: Der Diff-Algorithmus basiert auf dem LCS-Verfahren.

Schlauer Algorithmus

Wie funktioniert dieser Algorithmus? Die Grundlage bildet das LCS-Verfahren [5], das die “longest common subsequence” ermittelt, also die längste gemeinsame Teilsequenz zweier Ketten. Ein naiver Ansatz könnte einfach alle Einträge aus der ersten Kette streichen, um darauf alle der zweiten Kette hinzuzuaddieren, um zum Ziel zu gelangen. Das ist jedoch nicht Sinn der Sache; vielmehr geht es darum, mit möglichst wenigen Schritten von A nach B zu kommen.

Das LCS-Verfahren liefert dazu eine Reihe gemeinsamer Positionen in beiden Ketten. So stellt es fest, dass der erste Eintrag »foo« in beiden Ketten an derselben Stelle vorkommt. Beim Eintrag »baz« liegt hingegen eine Verschiebung vor: In der ersten Kette steht er an der dritten, in der zweiten hingegen an der zweiten Position (Index (2,1)). Der LCS-Algorithmus wird also beim Vergleich der Dateien »test1.txt« und »test2.txt« in Abbildung 2 die Paare »(0,0),(2,1)« ausgeben (Abbildung 3).

Abbildung 3: LCS-Paare der String-Ketten aus <a href="#artRef-f2">Abbildung&nbsp;2</a>.

Abbildung 3: LCS-Paare der String-Ketten aus Abbildung 2.

Den LCS-Algorithmus holt Listing 4 aus dem auf Github liegenden Projekt-»golcs« ab. Aus den gelieferten Paaren errechnet der Code hinzuzufügende (»add«) und zu entfernende (»remove«) Containernamen. Um von der letzten Liste zur aktuellen zu kommen, durchwandert er mittels For-Schleifen schrittweise das alte und neue Array (»left« und »right«), wobei er von Paar zu Paar schreitet.

Eine Schwierigkeit wirft das strenge Typsystem von Go auf: Der LCS-Algorithmus auf Github wurde mit einem generischen Typ (»interface{}«) implementiert, ähnlich wie »void« in C, denn er soll Daten jedes Typus analysieren können. Damit er allerdings Arrays von Strings verarbeiten kann, muss der Programmierer diese vorher mühsam und zeitaufwendig in Arrays von »interface{}«-Typen umwandeln. Andernfalls weigert sich der Compiler, die Library-Funktion aufzurufen. Als Grund geben die Go-Jünger an, dass Strings ein anderes Memory-Layout aufweisen als »interface{}«-Typen. Ein ziemlich übler Schlamassel, den die nächste Go-Version hoffentlich irgendwie auflöst.

Fazit und Ausblick

Zugegeben: Das Verfahren, um die Docker-Historie zu ermitteln, arbeitet nicht hundertprozentig akkurat. So könnte zwischen zwei Abfragen der Docker-Daemon einen Container erzeugt haben, der beim zweiten Kontakt schon wieder verschwunden ist. Einen derartigen Geistercontainer könnte nur ein Subscription-Mechanismus aufspüren, der vom Docker-Daemon bei jedem Ereignis eine Nachricht erhielte. Man kann halt nicht alles haben.

Das Verfahren ist trotzdem gut genug, um einen Blick auf kommende und gehende Container zu werfen und eventuelle Unregelmäßigkeiten aufzuspüren. Da es sich um ein selbstgestricktes Programm handelt, sind der Kreativität des Entwicklers keine Grenzen gesetzt: Ein Mausklick auf einen angezeigten Container, und schon fährt er herunter? Eine alphabetische Sortierung statt einer nach Startdatum? Verschwundene Container in Rot, neue in Grün? Die Lösung liegt wie immer nur ein paar Tastendrücke entfernt. (uba)

Online PLUS

Im Screencast unter http://www.linux-magazin.de/videos/ demonstriert Michael Schilli das vorgestellte Programmierbeispiel.

Der Autor

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 mailto:mschilli@perlmeister.com beantwortet er gerne Fragen.

Infos

  1. Netzwerk-Interfaces anzeigen: Michael Schilli, “Klassiker neu verpackt”, LM 10/2018, S. 78, https://www.linux-magazin.de/ausgaben/2018/10/snapshot-7/

  2. Wochentag zum Datum herausfinden: Michael Schilli, “Magischer Tag”, LM 08/2019, S. 88, https://www.linux-magazin.de/ausgaben/2019/08/snapshot-17/

  3. Verzeichnispfade speichern: Michael Schilli, “Pfadfinder”, LM 09/2019, S. 82, https://www.linux-magazin.de/ausgaben/2019/09/snapshot-18/

  4. Docker API Reference: https://godoc.org/github.com/docker/docker/client#Client

  5. LCS: https://en.wikipedia.org/wiki/Longest_common_subsequence_problem

  6. Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2019/12/snapshot/

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