Was geht ab? (Linux-Magazin, April 2020)

Als jüngstens das lang ersehnte Performance-Analyse-Buch von Brendan Gregg [2] erschien, verschlang ich es gleich gierig, ja, ich hatte sogar Zugriff auf eine Vorabversion, um den Lesern des Linux-Magazins im November einige Tipps zu den "Berkley Package Filters" genannten Kernelsonden zur Durchsatz-Messung mitzugeben ([3]). Performance-Fuchs Gregg erläutert außerdem im Buch an einleitender Stelle, wie er oft mittels klassischer Kommandozeilen-Tools herausfindet, woran ein schlapper Server nun krankt.

Zehn Performance-Gebote

Diese Liste von zehn Kommandos (Listing 1), die jedes Unix-System versteht, klopft erst ganz alltägliche Dinge ab, wie zum Beispiel wie lange das System schon ohne Reboot läuft (uptime) oder ob irgendetwas Auffälliges im System-Log steht (dmesg). vmstat schaut nach wartenden Prozessen und ob alles RAM belegt ist und die Kiste wild Dateien ins RAM ein und auslagert, ähnlich wie free noch verfügbares RAM anzeigt. pidstat zeigt die Verteilung von Prozessen auf die CPUs des Rechners, iostat, ob der Datenaustausch mit der Festplatte einen Engpass darstellt. mpstat illustriert die Auslastung einzelner CPUs, und ob ein einzelner Prozess eine ganze CPU dauerhaft blockiert. Die sar-Kommandos sammeln Statistiken über die Netzwerkaktivitäten des Rechners, und ob die verbindende Datenröhre gut durchzieht oder schon verstopft ist. top ist wohl das bekannteste dieser Tools, und zeigt einen Überblick über verbrauchtes RAM sowie laufende Prozesse an, sollte aber laut Gregg erst als letztes zum Einsatz kommen.

Listing 1: brendan-gregg-commands

    01 uptime
    02 dmesg | tail
    03 vmstat 1
    04 mpstat -P ALL 1
    05 pidstat 1
    06 iostat -xz 1
    07 free -m
    08 sar -n DEV 1
    09 sar -n TCP,ETCP 1
    10 top

Da die Sprache Go sowohl exzellente Anbindung an extern abgefeuerte Prozesse bietet als auch schön anzusehende Terminal-UIs zaubern kann, kam mir die Idee, alle Analyse-Kommandos quasi gleichzeitig abzufeuern und die Ergebnisse schön strukturiert in separaten Gucklöchern anzuzeigen (Abbildung 1). Listing 2 zeigt die Funktion runit(), die ein Kommando in Form einer Reihe von Strings entgegennimmt, es ausführt und den Inhalt der Standardausgabe an den Aufrufer zurückreicht. Die Anbindung passiert über die Funktion Command() aus dem Paket exec in Gos Standardfundus, die ein Kommando mit Parametern entgegennimmt. Auf das von Command() zurückkommende Objekt greift das nachgestellte Output() zu, führt das verlangte Programm mit den angegebenen Argumenten aus und liefert dessen Ausgabe als String zurück.

Explosion im Fehlerfall

Da verschiedene Kommandozeilentools ihre Ausgaben mit Tab-Zeichen strukturieren, aber die Widgets der Terminal-UI damit nicht klarkommen, definiert Zeile 17 einen regulären Ausdruck, der auf Tab-Zeichen anspringt. Da das Tab-Zeichen in regulären Ausdrücken als \t notiert und der Ausdruck in doppelten Anführungszeichen steht, muss Listing 2 den Backslash mit \\t aufdoppeln, damit dieser innerhalb der doppelten Anführungszeichen standhält. Go muss reguläre Ausdrücke kompilieren, bei einem einfachen Tab-Zeichen kann dabei nichts schiefgehen, also nutzt Zeile 17 MustCompile(), das keinen Fehlercode liefert, sondern explodiert, falls sich ein regulärer Ausdruck nicht kompilieren lässt. ReplaceAllString() ersetzt dann im Byte-Array out alle Tabs durch Leerzeichen und runit() gibt das Ergebnis als String zurück an den Aufrufer.

Listing 2: runit.go

    01 package main
    02 
    03 import (
    04   "fmt"
    05   "os/exec"
    06   "regexp"
    07 )
    08 
    09 func runit(argv []string) string {
    10   out, err := exec.Command(
    11     argv[0], argv[1:]...).Output()
    12 
    13   if err != nil {
    14     return fmt.Sprintf("%v\n", err)
    15   }
    16 
    17   r := regexp.MustCompile("\\t")
    18   return r.ReplaceAllString(
    19     string(out), " ")
    20 }

Listing 3 zeigt das Hauptprogramm main(), das den greggalizer startet, wie ich das Analyseprogramm Brendan Gregg zu Ehren genannt habe. In den anfänglichen import-Anweisungen zieht der Code das Termdash-Projekt vom Github-Server herein, das die Terminal-UI zeichnet und verwaltet. Das Slice von String-Slices commands ab Zeile 16 definiert die verschiedenen Kommandos, die das Programm ausführt, mitsamt ihren Parametern.

Kommandieren mit System

Mehrere Kommandos, wie zum Beispiel pidstat, nehmen sowohl ein Update-Intervall entgegen als auch, optional, die maximale Anzahl der Schleifendurchgänge entgegen. So druckt pidstat 1 im Sekundentakt die Anzahl der aktuell abgearbeiteten Tasks im Kernel aus, und zwar in einer Endlosschleife. Ein zweiter Parameter gibt die Maximalzahl der Aufrufe an, also terminiert pidstat 1 1 nach dem ersten Ergebnis, so wie sich dies der Greggalizer wünscht.

Abbildung 1: Das fertige Tool feuert alle Analyse-Kommandos ab und stellt die Ergebnisse in einem Rutsch dar.

Das Kommando top nutzt genau wie der Greggalizer Terminal-Escape-Sequenzen, um eine Terminal-UI zu zaubern, deshalb ist seine Ausgabe nicht direkt zur Darstellung im Greggalizer geeignet. Das Kommando dmesg | tail -10 kann exec nicht direkt verarbeiten, denn es kann nur simple ausführbare Programme mit Argumenten zur Ausführung bringen. Zwei mit einer Pipe verknüpfte Kommandos versteht nur die Shell, deswegen reicht Zeile 18 die ganze Chose einfach als String mit bash -c an eine Bash-Shell zur Ausführung weiter.

Listing 3: greggalizer.go

    01 package main
    02 
    03 import (
    04   "context"
    05   "fmt"
    06   "github.com/mum4k/termdash"
    07   "github.com/mum4k/termdash/cell"
    08   "github.com/mum4k/termdash/container"
    09   "github.com/mum4k/termdash/linestyle"
    10   "github.com/mum4k/termdash/terminal/termbox"
    11   "github.com/mum4k/termdash/terminal/terminalapi"
    12   "github.com/mum4k/termdash/widgets/text"
    13 )
    14 
    15 func main() {
    16   commands := [][]string{
    17     {"/usr/bin/uptime"},
    18     {"/bin/bash", "-c",
    19       "dmesg | tail -10"},
    20     {"/usr/bin/vmstat", "1", "1"},
    21     {"/usr/bin/mpstat", "-P", "ALL"},
    22     {"/usr/bin/pidstat", "1", "1"},
    23     {"/usr/bin/iostat", "-xz", "1", "1"},
    24     {"/usr/bin/free", "-m"},
    25     {"/usr/bin/sar",
    26       "-n", "DEV", "1", "1"},
    27     {"/usr/bin/sar",
    28       "-n", "TCP,ETCP", "1", "1"},
    29   }
    30 
    31   t, err := termbox.New()
    32   if err != nil {
    33     panic(err)
    34   }
    35   defer t.Close()
    36 
    37   ctx, cancel := context.WithCancel(
    38     context.Background())
    39 
    40   widgets := []container.Option{
    41     container.ID("top"),
    42     container.Border(linestyle.Light),
    43     container.BorderTitle(
    44       " Greggalizer ")}
    45 
    46   panes := []*text.Text{}
    47 
    48   for _, command := range commands {
    49     pane, err := text.New(
    50       text.RollContent(),
    51       text.WrapAtWords())
    52     if err != nil {
    53       panic(err)
    54     }
    55 
    56     red := text.WriteCellOpts(
    57       cell.FgColor(cell.ColorRed))
    58     pane.Write(
    59       fmt.Sprintf("%v\n", command), red)
    60     pane.Write(runit(command))
    61 
    62     panes = append(panes, pane)
    63   }
    64 
    65   rows := panesSplit(panes)
    66 
    67   widgets = append(widgets, rows)
    68 
    69   c, err := container.New(t, widgets...)
    70   if err != nil {
    71     panic(err)
    72   }
    73 
    74   quit := func(k *terminalapi.Keyboard) {
    75     if k.Key == 'q' || k.Key == 'Q' {
    76       cancel()
    77     }
    78   }
    79 
    80   err = termdash.Run(ctx, t, c,
    81     termdash.KeyboardSubscriber(quit))
    82   if err != nil {
    83     panic(err)
    84   }
    85 }

Ein neues virtuelles Terminal definiert termbox.New() in Zeile 31, der defer-Aufruf in Zeile 35 faltet es bei Programmende sauber zusammen. Die Widgets im Fenster definiert das Slice widgets in Zeile 40, und belegt es mit dem Haupt-Widget "top" vor, das einen Rahmen zeichnet und den Titel des Programms darüber schreibt.

Pointer auf die verschiedenen übereinander aufgestapelten Text-Widgets im Top-Fenster definiert das Slice panes in Zeile 46. Die For-Schleife ab Zeile 48 erzeugt für jedes der aufgelisteten Kommandos ein Text-Widget mit rollendem Inhalt, sodass diese auch längere Ausgaben verarbeiten können ohne gleich auszuflippen oder Inhalte zu vergessen. Der User kann jeweils mit der Maus darauf deuten und mit dem Rädchen nach oben rollen, um zurückzufahren.

Bauklötze stapeln

In jedes dieser Text-Widgets schreibt Zeile 58 zunächst in Rot den Namen des ausgeführten Kommandos und gibt dieses dann an runit() in Listing 1 weiter, um es ausführen zu lassen, die Ausgabe abzufangen und dem Text-Widget zuzuführen. Alle Widgets landen im Slice panes, jeweils hinten angehängt mit dem append-Kommando in Zeile 62.

Zeile 69 übergibt die Elemente im Slice dann einzeln mittels des "..."-Operators an die Funktion container.New() der termdash-Bibliothek. Die Run()-Funtion ab Zeile 80 baut die UI auf und verwaltet sie, bis der User die Taste "q" drückt, um den Reigen zu beenden. Diesen Event fäng der Keyboard-Handler ab Zeile 74 ab und ruft die cancel()-Funktion des vorher in Zeile 37 definierten Background-Contexts ab, der der Event-Schleife den Teppich unter den Füßen wegzieht.

Aber wie nun stapelt die grafische Oberfläche die Einzelwidgets übereinander und räumt jedem gleich viel Platz ein, egal wieviele Kommandos der User definiert? Dazu muss ein Trick her, denn als Layout-Verfahren zum vertikalen Stapeln zweier Widgets kennt termdash nur die Funktion SplitHorizontal, die zwei Widgets entgegennimmt, sowie einen Prozentwert, der festlegt, wieviel Platz das obere Widget im Verhältnis zum unteren bekommt.

Abbildung 2: Aufbau der Panels in Zweierschritten.

Abbildung 2 zeigt, wie sich beliebig viele Widgets in Zweierschritten übereinander anordnen lassen: Oben steht in jedem Schritt das Konglomerat aller bislang gestapelten Widgets, und unten das neue Widget, das der Algorithmus anhängt. Der Prozentwert, der das Verhältnis der oberen Widgethöhe zur unteren festlegt ändert sich dabei dynamisch, wenn wirklich alle Einzelwidgets gleich groß erscheinen sollen. Ist oben nur ein Widget, bekommen es genau wie das untere exakt 50% des Platzes (grüner Kasten). Sind oben aber zum Beispiel schon drei Widgets verpackt, und unten kommt eines hinzu, bekommt die Widgetgruppe oben 75% des Platzes und das untere 25%, damit alle Einzelwidgets gleich groß werden (blauer Kasten).

Listing 4: pane-splitter.go

    01 package main
    02 
    03 import (
    04   "github.com/mum4k/termdash/container"
    05   "github.com/mum4k/termdash/widgets/text"
    06   "github.com/mum4k/termdash/linestyle"
    07 )
    08 
    09 func panesSplit(
    10   panes []*text.Text) container.Option {
    11   var rows container.Option
    12 
    13   if len(panes) > 0 {
    14     rows =
    15       container.PlaceWidget(panes[0])
    16     panes = panes[1:]
    17   }
    18 
    19   for idx, pane := range panes {
    20     itemsPacked := idx + 2
    21 
    22     rows = container.SplitHorizontal(
    23       container.Top(rows),
    24       container.Bottom(
    25         container.PlaceWidget(pane),
    26         container.Border(
    27           linestyle.Light),
    28       ),
    29       container.SplitPercent(
    30         100*(itemsPacked-1)/itemsPacked),
    31     )
    32   }
    33 
    34   return rows
    35 }

Dementsprechend nimmt die Funktion panesSplit() aus Listing 4 ein Slice von Text-Widgets entgegen und initialisiert das resultierende Gruppenwidget rows mit dem ersten Text-Widget vor. Über die restlichen zu verpackenden Widgets iteriert die For-Schleife ab Zeile 19 und zählt in itemsPacked mit, wieviele Widgets im oberen Teil schon verpackt wurden. Jeder Aufruf von SplitHorizontal() in Zeile 22 erhält nun in container.Top() die obenliegende Widget-Gruppe (rows) und in container.Bottom() das neu hinzukommende Widget mit einem dünnen Rand zur Abgrenzung. Die Platzverteilung bestimmt SplitPercent() in Zeile 29 nach der Formel 100*(n-1)/n wenn n die Anzahl der bereits oben verpackten Widgets ist. Für n=2 ergibt dies 50%, für n=3 66% und für n=4 75%, ganz wie vom Doktor verschrieben.

Auf geht's

Alle drei Listings lassen sich mit

    $ go mod init greggalizer
    $ go build greggalizer.go \
      runit.go pane-splitter.go

kompilieren. Der zweite Aufruf erzeugt ein Executable greggalizer, das unabhängig von irgendewelchen Shared Libraries läuft, und sich einfach auf verwandte Architekturen kopieren lässt, um dort klaglos Dienst zu tun.

Noch besser

Der Aufruf von greggalizer von der Kommandozeile nimmt wie in Abbildung 1 gezeigt das Terminal in Beschlag, spaltet es in vertikal übereinanderliegende Kästen auf und druckt die ausgeführten Kommandos mit Parametern und dem Ausgabetext hinein. Die Performance-Analyse kann beginnen.

Als Verbesserung des vorgestellten Skripts böte sich eine Parallelisierung der Kommandos an, damit der User vor der leeren Screen nicht ein paar Sekunden warten muss, bis diese sich mit Ergebnissen füllt. Statt dessen könnte das Programm die UI zuerst zeichnen, nebenher externe Prozesse quasi gleichzeitig über Goroutinen abfeuern und über Channels die Text-Widgets stetig mit neuen Ausgaben füllen. Heraus käme ein Tool wie top, das seine Widgets regelmäßig auffrischen könnte.

Infos

[1]

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

[2]

"BPF Performance Tools", Brendan Gregg, Addison-Wesley, 2019, https://www.amazon.com/BPF-Performance-Tools-Brendan-Gregg/dp/0136554822

[3]

Michael Schilli, "Auf Herz und Nieren": Linux-Magazin 11/2019, S.90, <U>https://www.linux-magazin.de/ausgaben/2019/11/snapshot-20/<U>

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