Auch im Zeitalter von neumodischen Schlagwörtern wie "Kubernetes" schätzen altgediente Systemadministratoren noch Kommandozeilen-Utilies wie das gute alte top
, das laufende Prozesse in Echtzeit anzeigt. Und da auch ich im meinem mittlerweile schon fast 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-Dämon, 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
natürlich auf Go.
Die Idee ist simpel: Das Programm befragt in regelmäßigen Abständen den Docker-Dämon 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 der zweigeteilten Screen auch noch eine rollende Historie der Container an. Jedes Mal, wenn es einen neuen entdeckt, schreibt das Programm "New: <name>" hinein, und wenn einer seit dem letzten Aufruf abhanden gekommen ist, wohl weil er zwischenzeitlich den Geist aufgegeben hat, lautet der Log-Eintrag: "Gone: <name>" (Abbildung 1). So erhält der Sysadmin auch auf einem heftig rödelnden System mit vielen Containern einen Eindruck davon, ob die Container eher nach dem Prinzip "Hase" oder "Igel" operieren. Je nach dem, 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.
Abbildung 1: Die linke Spalte der Terminal-UI zeigt aktive Docker-Container, die rechte den zeitlichen Verlauf gefundener und verlorener Container. |
01 package main 02 03 import ( 04 "context" 05 "fmt" 06 "github.com/mum4k/termdash" 07 tco "github.com/mum4k/termdash/container" 08 "github.com/mum4k/termdash/linestyle" 09 "github.com/mum4k/termdash/terminal/termbox" 10 "github.com/mum4k/termdash/terminal/terminalapi" 11 "github.com/mum4k/termdash/widgets/text" 12 "strings" 13 "time" 14 ) 15 16 func panicOnError(err error) { 17 if err != nil { 18 panic(err) 19 } 20 } 21 22 func main() { 23 t, err := termbox.New() 24 panicOnError(err) 25 defer t.Close() 26 27 ctx, cancel := 28 context.WithCancel(context.Background()) 29 30 top, err := text.New() 31 panicOnError(err) 32 33 rolled, err := text.New( 34 text.RollContent(), text.WrapAtWords()) 35 panicOnError(err) 36 37 go updater(top, rolled) 38 39 c, err := tco.New( 40 t, 41 tco.Border(linestyle.Light), 42 tco.BorderTitle(" PRESS Q TO QUIT "), 43 tco.SplitVertical( 44 tco.Left( 45 tco.PlaceWidget(top), 46 ), 47 tco.Right( 48 tco.Border(linestyle.Light), 49 tco.BorderTitle(" History "), 50 tco.PlaceWidget(rolled), 51 ), 52 ), 53 ) 54 panicOnError(err) 55 56 quit := func(k *terminalapi.Keyboard) { 57 if k.Key == 'q' || k.Key == 'Q' { 58 cancel() 59 } 60 } 61 62 err = termdash.Run(ctx, t, c, 63 termdash.KeyboardSubscriber(quit)) 64 panicOnError(err) 65 } 66 67 func updater(top *text.Text, 68 rolled *text.Text) { 69 items_saved := []string{} 70 for { 71 err, items, _ := dockerList() 72 panicOnError(err) 73 74 add, remove := 75 diff(items_saved, items) 76 77 for _, item := range add { 78 err := rolled.Write( 79 fmt.Sprintf("New: %s\n", item)) 80 panicOnError(err) 81 } 82 for _, item := range remove { 83 err := rolled.Write( 84 fmt.Sprintf("Gone: %s\n", item)) 85 panicOnError(err) 86 } 87 88 content := strings.Join(items, "\n") 89 if len(content) == 0 { 90 content = " " // can't be empty 91 } 92 err = top.Write(content, 93 text.WriteReplace()) 94 panicOnError(err) 95 96 items_saved = items 97 time.Sleep(time.Second) 98 } 99 }
Der Programmier-Snapshot hat in zurückliegenden Ausgaben schon mehrfach Terminal-UIs vorgestellt: termui
([2], [3]), sowie promptui
([4]), und heute 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 Listing 1 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 und nicht gleich das Programm abbrechen, falls etwas schief geht. Aber in Zeitschriftenartikeln sparen Autoren so Platz.
Das in Zeile 28 ins Leben gerufene Context
-Konstrukt ist 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 in Abbildung 1 für die Top-Darstellung (top
) und das rollende Logfenster (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, also 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 reagieren kann, wird sie 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 in die Shell mehr eingeben könnte und gut beraten wäre, ein neues Terminal zu öffnen.
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-Dämons auffrischt. Ab Zeile 67 holt sie die Liste der Container mit Hilfe der Funktion dockerList()
aus Listing 2. Das linke Teilfenster mit der Top-Darstellung frischt mit top.Write()
in Zeile 92 das linke Fenster mit einem langen String content
auf, der die einzelnen Containernamen mit 10 Zeichen ihrer ID durch Zeilenumbrüche getrennt enthält. Container, die der Monitor zum ersten Mal sieht, meldet die Funktion diff()
aus Zeile 75, zu der wir später in Listing 3 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, also asynchron ab, und dadurch bleibt die UI voll ansprechbar.
Das war's schon für die UI, deren Implementierung ganze 99 Zeilen lang ist. Wie nun erhält das Go-Programm Zugriff auf die Namen der laufenden Container? Die einzelnen Komponenten der Docker-API und ihre Funktionen beschreibt die automatisch generierte Dokumentation des Go-Codes im Detail ([5]). 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 mit
$ go get -u github.com/docker/docker/client
(und das Ganze wiederholt mit allen per "import" hereingezogenen Libraries) installieren, bevor er mit
$ go build dockertop.go dockerlist.go dockerdiff.go
das dockertop
-Binary bauen kann. Nutzt der Entwickler hingegen das moderne Modul-Verfahren, liefert die Docker-API eine Uralt-Version aus, die die verwendeten Funktionen in den Listings noch nicht unterstützt.
01 package main 02 03 import ( 04 "context" 05 "fmt" 06 "github.com/docker/docker/api/types" 07 "github.com/docker/docker/client" 08 ) 09 10 func dockerList() (error, []string, 11 map[string]types.Container) { 12 items := []string{} 13 containerMap := 14 make(map[string]types.Container) 15 16 opt := 17 client.WithAPIVersionNegotiation() 18 cli, err := 19 client.NewClientWithOpts(opt) 20 if err != nil { 21 return err, nil, nil 22 } 23 defer cli.Close() 24 25 containers, err := cli.ContainerList( 26 context.Background(), 27 types.ContainerListOptions{}) 28 if err != nil { 29 return err, nil, nil 30 } 31 32 for _, container := range containers { 33 name := fmt.Sprintf("%s-%s", 34 container.Image, container.ID[:10]) 35 items = append(items, name) 36 containerMap[name] = container 37 } 38 39 return nil, items, containerMap 40 }
Als simplen Docker-Client, der die Liste aller Container vom Dämon abholt, würde auch ein aus der Shell aufgerufenes docker ps
taugen, auf dessen Standardausgabe die Namen purzeln. 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, denn 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 ist. Mit dem Versionsverhandlungs-Parameter einigen sich die beiden und treten in Kontakt. Mit ContainerList()
kommt dann eine nach Startdatum sortierte Liste aktiver Containerobjekte zurück, deren verwendetes Docker-Image jeweils in im Attribut .Image
steht.
Damit der Client mehrere parallele laufende Ubuntu-Container voneinander unterscheiden kann, fügt Zeile 33 mit container.ID[:10]
noch die ersten zehn Zeichen der eindeutigen Container-ID hinzu. Die Namen aller so gefundenen Container hängt Zeile 34 an einen Slice von Strings an, so bleibt die ursprüngliche Sortierung bestehen. Die ebenfalls vorliegenden Zusatzinformationen zu jedem Container landen in der Map containerMap
unter dem in items
liegenden Namen, also 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.
01 package main 02 03 func diff(old []string, new []string) ( 04 add[]string, remove[]string) { 05 06 leftmap := make(map[string]bool) 07 rightmap := make(map[string]bool) 08 09 for _, leftval := range old { 10 leftmap[leftval] = true 11 } 12 for _, rightval := range new { 13 rightmap[rightval] = true 14 } 15 16 for _, leftval := range old { 17 if !rightmap[leftval] { 18 remove = append(remove, leftval) 19 } 20 } 21 for _, rightval := range new { 22 if !leftmap[rightval] { 23 add = append(add, rightval) 24 } 25 } 26 27 return add, remove 28 }
Schließlich noch zu der Historie der Container, die im rechten Teilfenster des Monitors erscheinen. Hierzu vergleicht Listing 3 in der Funktion diff()
zwei String-Array-Slices und stellt fest, welche Einträge neu im zweiten hinzugekommen sind und welche Einträge zwar im ersten Array stehen, aber es nicht in den zweiten geschafft haben. Das Verfahren kennt der Unix-Fachmann von der diff
-Utility, die 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 der Eintrag pow
in text2.txt
hinzu kam.
Abbildung 2: Der Diff-Algorithmus basiert auf dem LCS-Verfahren. |
Abbildung 3: LCS-Paare der Stringketten in Abbildung 2. |
Wie funktioniert dieser Algorithmus, bei dem einfach jeder mit muss? Grundlage ist das LCS-Verfahren ([6]), das die "Longest Common Subsequence", also die längste gemeinsame Teilsequenz zweier Ketten ermittelt. Ein naiver Ansatz könnte zwar 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 soll es darum gehen, mit möglichst wenig Schritten von A nach B zu kommen. Das LCS-Verfahren liefert dazu eine Reihe gemeinsamer Positionen in beiden Ketten. So stellt es zum Beispiel fest, dass der erste Eintrag "foo"
in beiden Ketten vorhanden ist. Beim Eintrag baz
liegt hingegen eine Verschiebung fest, in der ersten Kette liegt er an der dritten, in der zweiten hingegen an der zweiten Position (Index (2,1)). Der LCS-Algorithms wird also beim Vergleich der Dateien text1.txt
und text2.txt
in Abbildung 2 die Paare (0,0),(2,1)
ausgeben (Abbildung 3).
Den LCS-Algorithmus holt Listing 3 aus dem auf Github liegenden Projekt-golcs
ab, und aus den gelieferten Paaren errechnet Listing 3 hinzuzufügende (add
) und zu entfernende (remove
) Containernamen aus, um von der letzten Liste zur aktuellen zu kommen, in dem es mittels for
-Schleifen den alten und den neuen Array (genannt left
und right
) schrittweise durchwandert, von Paar zu Paar schreitend. Eine Schwierigkeit wirft das strenge Typsystem von Go auf: Der LCS-Algorithmus auf Github ist mit einem generischen Typ (interface{}
) implementiert, ähnlich wie void
in C, denn er soll Daten jedwegen Typus analysieren können. Damit er allerdings Arrays von Strings verarbeiten kann, muss der Programmierer diese vorher mühevoll und zeitaufwendig in Arrays von interface{}
-Typen umwandeln, sonst 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 übles Schlamassel, das hoffentlich in der nächsten Go-Version irgendwie gelöst wird.
Und, zugegeben, das Verfahren, um die Docker-Historie zu ermitteln, ist nicht 100% akkurat, denn zwischen zwei Abfragen könnte der Docker-Dämon einen Container erzeugt haben, der beim zweiten Kontakt schon wieder verschwunden ist -- so einen Geist-Container könnte nur ein Subscription-Mechanismus aufspüren, der vom Docker-Dämon bei jedem Ereignis eine Nachricht erhielte. Man kann nicht alles haben.
Das Verfahren ist "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 wie immer keine Grenzen gesetzt. Ein Mausklick auf einen angezeigten Container, und schon fährt er herunter? Alphabetische Sortierung statt nach Startdatum? Verschwundene Container in Rot, neue in Grün? Die Lösung ist wie immer nur ein paar Tastendrücke entfernt.
Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2019/12/snapshot/
Michael Schilli, "Klassiker neu verpackt": Linux-Magazin 10/2018, S.XXX, <U>https://www.linux-magazin.de/ausgaben/2018/10/snapshot-7/</U>
Michael Schilli, "Magischer Tag": Linux-Magazin 08/2019, S.XXX, <U>https://www.linux-magazin.de/ausgaben/2019/08/snapshot-17/</U>
Michael Schilli, "Pfadfinder": Linux-Magazin 09/2019, S.XXX, <U>https://www.linux-magazin.de/ausgaben/2019/09/snapshot-18/</U>
Docker API Reference: https://godoc.org/github.com/docker/docker/client#Client
"LCS, Longest Common Subsequence Algorithm", https://en.wikipedia.org/wiki/Longest_common_subsequence_problem
Hey! The above document had some coding errors, which are explained below:
Unknown directive: =desc