Prometheus Junior (Linux-Magazin, November 2023)

Datenpunkte aus Zeitreihen zu erfassen und grafisch aufzumöbeln ist normalerweise die Domäne von Tools wie Prometheus. Der Götterbote holt sich in regelmäßigen Abständen den Status überwachter Systeme ab und speichert diese Daten als Zeitreihe. Fallen Ausreißer auf, schlägt der Götterbote Alarm. Darstellungs-Tools wie Grafana stellen die gesammelten Zeitreihen auf Wunsch in Dashboards über die letzte Woche oder das letzte Jahr verteilt als Graphen dar, sodass auch das höhere Management auf einen Blick weiß, wo der Hase langläuft.

Leider erlaubt mein Billigheimer-Hoster es mir allerdings nicht, auf meinem gemieteten virtuellen Server dort nach Belieben Softwarepakete zu diesem Zweck zu installieren. Auch wäre mir die Wartung derartig komplizierter Produkte mit ihren dauernd fälligen Updates zu umständlich. Doch es gibt auf dem Webserver eine CGI-Schnittstelle aus den 90er-Jahren. Wie schwer wäre es wohl, in Go ein CGI-Programm zu schreiben, das im Sinne einer API gemessene Werte per HTTPS entgegen nimmt, und die daraus erstellte Zeitreihe schön als Grafik formatiert im .png-Format zum Browser zurückschickt?

Abbildung 1: Gewichtsschwankungen des Autors ├╝ber die Jahre

Abbildung 1 zeigt den Graphen einer Zeitreihe, die mein Gewicht in Kilogramm über die letzten Jahre (für den Abdruck möglicherweise geschönt) als Grafik ausgibt. Das gleiche CGI-Skript nimmt auch neue Daten entgegen. Zeigt meine Waage zum Beispiel eines Tages 82.5kg an, genügt ein Aufruf von

    $ curl '.../cgi/minipro?add=82.5&apikey=xxx'

um den Wert unter dem aktuellen Datum in die nun auf dem Billigheimer-Host verwaltete Zeitreihe einzuspeisen. Ersetze ich in der URL add=... durch chart=1, kommt die Grafik aller bislang eingespeisten Werte zurück.

Saurier-Technologie

Dabei ist das CGI-Protokoll echte Sauriertechnologie aus den 90ern des vergangenen Jahrhunderts. Damals kamen die ersten dynamischen Webseiten in Mode, nachdem die User, auf den Geschmack gekommen, nach mehr als statischem HTML zu lechzen begannen. Ich erinnere mich noch genau an diese Zeit, damals arbeitete ich bei AOL, deren Webauftritt auf aol.com ich die Ehre hatte, als frischgebackener Import-Ingenieur aus Germany aufzufrischen, natürlich live auf einem einzigen Server und ohne Netz oder doppeltem Boden. Dort zeigte ein CGI-Skript oben auf der Portal-Seite das gerade aktuelle Datum an, was allerdings den (einzigen!) Server unter der Last der doch ganz beachtlichen User-Zahlen zusammenkrachen ließ, weil bei jedem Aufruf ein Perl-Interpreter starten musste. Mit einem kompilierten C-Programm brachte ich den Server wieder auf Vordermann. Später kamen persistente Umgebungen wie mod_perl in Mode und machten das Ganze noch 1000 Mal schneller.

All-Inclusive

Heute ist CGI verpönt, weil ein Skript schnell mal ein Sicherheitsloch im Server aufreißen kann und die Startup-Kosten eines externen Programms, das bei jedem eingehenden Request startet, immens sind. Im vorliegenden Fall meines Gewichtsbarometers, in dem der Server vielleicht zwei Requests pro Tag erhält, ist das Design jedoch vertretbar. In einer Skriptsprache with Python wäre das Ganze auch ruckzuck implementiert. Allerdings empfand ich es als Herausforderung, beide Funktionen in ein statisches Go-Binary zu packen, das keinerlei Abhängigkeiten aufweist. Alle näslang mit pip3 eine Python-Bibliothek zum Malen von Charts aufzufrischen ist auch kein Leben. Einmal compiliert, und auch gerne auf einer anderen Plattform cross-compiliert, läuft ein statisch gelinktes Programm bis zum Sanktnimmerleinstag. Selbst wenn es dem Hoster einfiele, die Linux-Distro auf eine neue Version anzuheben, und infolgedessen irgendwelche shared Libraries plötzlich verschwinden, wird das all-inclusive Go-Binary immer noch laufen.

CGI zum Warmwerden

Stellt ein Webserver fest, dass er einen Request aufgrund seiner Konfiguration mit einem externen CGI-Skript beantworten muss, setzt er unter anderem die Environment-Variable REQUEST_URI auf die URL des Requests und ruft das Programm oder Skript auf. Letzteres holt dann die zur Bearbeitung des Requests notwendigen Informationen aus den Umgebungsvariablen. Bei einem GET-Request genügt zum Beispiel der URL in REQUEST_URI, dessen Pfad auch alle CGI-Form-Parameter eincodiert. Die Antwort schreibt das Skript einfach mit print nach Stdout. Den Textstrom greift der Webserver ab und schickt ihn an den anfragenden Client zurück.

Listing 1 zeigt in minimales CGI-Programm in Go. Es nutzt die Standardbibliothek "net/http/cgi" dessen Funktion Serve() in Zeile 15 den eingehenden Request analysiert und dann die Antwort an den Server zurückschickt.

Listing 1: cgi-test.go

    01 package main
    02 import (
    03   "fmt"
    04   "net/http"
    05   "net/http/cgi"
    06 )
    07 func main() {
    08   handler := func(w http.ResponseWriter, r *http.Request) {
    09     qp := r.URL.Query()
    10     fmt.Fprintf(w, "Hello\n")
    11     for key, val := range qp {
    12       fmt.Fprintf(w, "key=%s=%s\n", key, val)
    13     }
    14   }
    15   cgi.Serve(http.HandlerFunc(handler))
    16 }

Dazu nimmt sie als Parameter eine Handler-Funktion entgegen, die Listing 1 ab Zeile 8 definiert. In diese wiederum kommen ein Writer für die Ausgabe und ein Reader für die Requestdaten als Parameter herein. Der Aufruf der Library-Funktion Query() auf den hereingereichten Request-URL gibt eine Map zurück, die die Namen der hereinkommenden CGI-Parameter deren Werten zuweist. Die For-Schleife ab Zeile 11 iteriert über alle Einträge in der Hashmap und gibt deren Namen und Werte jeweils an den Writer w aus.

Abbildung 2: Das Go-Programm aus Listing 1 als CGI-Skript in Aktion

Für immer statisch

Compiliert und gelinkt entsteht aus Listing 1 ein Binary, das ausführbar ins cgi-Verzeichnis des Webservers kopiert wird. Dieser ist so konfiguriert, dass er bei einem eingehenden Request auf cgi/cgi-test das Programm cgi-test aufruft und dessen Ausgabe an den Browser des anfragenden Web-Clients zurückschickt. Abbildung 2 zeigt das Ergebnis aus Sicht des mittels eines Browsers anfragenden Users.

Soweit so gut, aber wie wird nun Listing 1 compiliert? Schließlich soll dabei ein Binary herauskommen, das auf der Linux-Distro des Hosters läuft, und die ist unter Umständen inkompatibel zur Umgebung während des Builds, weil ihr shared Libraries in bestimmten, vielleicht mittlerweile veralteten Versionen fehlen. Go-Binaries brauchen normalerweise nur die libc des Hostsystems in einer akzeptablen Version. Zu Hilfe kommt Docker! Mein Hoster nutzt Ubuntu 18.04, also startet das Dockerfile in Listing 2 die Umgebung mit diesem Basis-Image.

Listing 2: Dockerfile

    01 FROM ubuntu:18.04
    02 ENV DEBIAN_FRONTEND noninteractive
    03 RUN apt-get update
    04 RUN apt-get install -y curl
    05 RUN apt-get install -y vim make
    06 RUN apt-get install -y git
    07 RUN curl https://dl.google.com/go/go1.21.0.linux-amd64.tar.gz >go1.21.0.linux-amd64.tar.gz
    08 RUN tar -C /usr/local -xzf go1.21.0.linux-amd64.tar.gz
    09 ENV PATH="${PATH}:/usr/local/go/bin"
    10 WORKDIR /build
    11 COPY *.go *.mod *.sum /build
    12 RUN go mod tidy

Allerdings hängt die Version des Pakets golang immer notorisch hinterher, bei einem schon in die Jahre gekommenen Ubuntu ist sie nicht zu gebrauchen. Also zieht das Dockerfile in Zeile 7 den Tarball des aktuellen Go 1.21 vom Netz und pflanzt dessen Inhalt ihn ins Rootverzeichnis. Dann noch einige Tools wie git (Go nutzt git zum Einholen von Github-Paketen) oder make für den Build, und fertig ist die Frankenstein-Distro!

Gut vorbereitet

Zum Compilieren von Go-Sourcen muss der Go-Compiler oft Pakete vom Netz hereinziehen und übersetzen. Ein Docker-Image, das diesen Schritt noch nicht enthält tandelt bei jedem Übersetzungslauf teilweise minutenlang in der Vorbereitungsphase herum, die es mit jeder kleinen Änderung am Source-Code wieder und wieder ausführt. Zur Beschleunigung dieser Phase kopiert Zeile 11 in Listing 2 die Go-Sourcen und die Moduldateien in das Docker-Image und go mod tidy in Zeile 12 kompiliert schon mal alles vor. Startet anschließend ein Container basierend auf dem Image, muss go nur noch die Sourcen lokal übersetzen und alles zusammenlinken, was in Sekunden passiert. So macht das Entwickeln und Fehlersuchen wieder Spaß!

Das Makefile in Listing 3 baut unter der Target docker (ab Zeile 9) das Image zusammen, und weist ihm den Tag cgi-test zu. Zum Compilieren des Codes rufen Entwickler später die Target remote (ab Zeile 5) auf, die mit docker run einen Container startet und das Build-Verzeichnis im Container auf das aktuelle Verzeichnis auf dem Host mounted, damit das erzeugte Binary später auch außerhalb des Containers verfügbar ist.

Listing 3: Makefile.cgi-test

    01 DOCKER_TAG=cgi-test
    02 SRCS=cgi-test.go
    03 BIN=cgi-test
    04 REMOTE_PATH=some.hoster.com/dir/cgi
    05 remote: $(SRCS)
    06 	docker run -v `pwd`:/build -it $(DOCKER_TAG) \
    07 	bash -c "go build $(SRCS)" && \
    08 	scp $(BIN) $(REMOTE_PATH)
    09 docker:
    10 	docker build -t $(DOCKER_TAG) .

Der eigentliche Build-Prozess ist das Shell-Kommando in Zeile 7, das go build aufruft. Klappt dies fehlerfrei, installiert eine secure Shell das Endprodukt im aktuellen Verzeichnis (aber außerhalb des Containers) mit scp auf dem Zielhost, dessen Adresse in Zeile 4 mit REMOTE_PATH definiert ist.

Kein Pille-Palle

Nun aber genug mit dem Pille-Palle. Das eigentliche CGI-Programm, das neue Werte für die Zeitreihe entgegennimmt und später grafisch anzeigt heißt minipro und steht in Listing 4. Es nimmt neue Wiegemessungen über die CGI-Schnittstelle mit dem Parameter add vom User entgegen und speichert sie unter dem Zeitstempel der aktuellen Uhrzeit in der CSV-Datei weight.csv serverseitig ab. Das passiert mit der Funktion addToCSV() ab Zeile 37 in Listing 4.

Listing 4: minipro.go

    01 package main
    02 import (
    03   "fmt"
    04   "net/http"
    05   "net/http/cgi"
    06   "regexp"
    07 )
    08 const CSVFile = "weight.csv"
    09 const APIKeyRef = "3669d95841f6d20ff6a5067a2f2919db4fca6e82"
    10 func main() {
    11   handler := func(w http.ResponseWriter, r *http.Request) {
    12     qp := r.URL.Query()
    13     params := map[string]string{}
    14     for key, val := range qp {
    15       if len(val) > 0 {
    16         params[key] = val[0]
    17       }
    18     }
    19     apiKey := params["apikey"]
    20     if apiKey != APIKeyRef {
    21       fmt.Fprintf(w, "AUTH FAIL\n")
    22       return
    23     }
    24     if len(params["chart"]) != 0 {
    25       points, err := readFromCSV()
    26       if err != nil {
    27         panic(err)
    28       }
    29       chart := mkChart(points)
    30       w.Write(chart)
    31     } else if len(params["add"]) != 0 {
    32       sane, _ := regexp.MatchString(`^[.\d]+$`, params["add"])
    33       if !sane {
    34         fmt.Fprintf(w, "Invalid\n")
    35         return
    36       }
    37       err := addToCSV(params["add"])
    38       if err == nil {
    39         fmt.Fprintf(w, "OK\n")
    40       } else {
    41         fmt.Fprintf(w, "NOT OK (%s)\n", err)
    42       }
    43     }
    44   }
    45   cgi.Serve(http.HandlerFunc(handler))
    46 }

Damit nicht Hinz und Kunz auf die Schnittstelle zugreifen kann, fordert das CGI-Programm einen API key, der hartkodiert in Zeile 9 steht. Der anfragende API-User legt ihn unter dem CGI-Parameter apikey dem Request bei, und nur wenn er mit dem hartkodierten Wert übereinstimmt, bearbeitet das Programm den Request weiter, sonst ist ab Zeile 21 Schluss. Da man CGI-Parametern im allgemeinen nicht trauen kann, empfiehlt es sich, sie mit regulären Ausdrücken auf ihre Gültigkeit zu prüfen. So beschnuppert Zeile 32 den Parameter add darauf, ob der String wirklich wie eine Fließkommazahl aussieht, also nur aus Ziffern und Punkten besteht. Falls ja, steht die Variable sane auf true, falls nein, bricht Zeile 34 mit einer Fehlermeldung ab.

Gut geraten

Wollen User hingegen ein Diagramm der Zeitreihe der bislang eingespeisten Werte sehen, setzen sie den CGI-Parameter chart auf einen beliebigen Wert. Daraufhin springt Zeile 24 in Listing 1 an, erzeugt mit mkChart() später in Listing 6 eine neue Chart-Datei im .png-Format und w.Write() in Zeile 30 schreibt die Binärdaten der Grafik an den anfrangenden Browser zurück. Zum Glück ist die Library net/http/cgi so schlau, dass sie den einleitenden HTTP-Header auf Content-Type: image/png setzt, wenn sie die ersten paar Bytes des Stroms untersucht und dort Sequenzen findet, die auf ein PNG-Image hindeuten.

Listing 5 übernimmt die Verwaltung der .csv-Datei, deren Inhalt aus Fließkommawerten der Wiegemessungen besteht, denen jeweils pro Zeile nach einem Komma ein Zeitstempel im Epoch-Format beiliegt. Abbildung 3 illustriert einen Teil der gespeicherten Daten.

Abbildung 3: Wiegemessungen als Flie├čkommawerte mit Zeitstempel

Listing 5: csv.go

    01 package main
    02 import (
    03   "encoding/csv"
    04   "fmt"
    05   "os"
    06   "time"
    07 )
    08 func addToCSV(val string) error {
    09   f, err := os.OpenFile(CSVFile,
    10     os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
    11   if err != nil {
    12     return err
    13   }
    14   defer f.Close()
    15   _, err = fmt.Fprintf(f, "%s,%d\n", val, time.Now().Unix())
    16   return err
    17 }
    18 func readFromCSV() ([][]string, error) {
    19   points := [][]string{}
    20   file, err := os.Open(CSVFile)
    21   if err != nil {
    22     if os.IsNotExist(err) {
    23       return points, nil
    24     } else {
    25       return points, err
    26     }
    27   }
    28   defer file.Close()
    29   reader := csv.NewReader(file)
    30   points, err = reader.ReadAll()
    31   return points, err
    32 }

Schreiben mit Garantie

Das Einspeisen von neuen Werten übernimmt addToCSV() ab Zeile 8, die die .csv-Datei im Modus O_APPEND öffnet, sodass die Schreibfunktion fmt.Fprintf() in Zeile 15 neue Werte mit anhängendem Zeitstempel der aktuellen Zeit stets ans Ende der Datei anhängt. Dieser Modus hat auch noch einen schönen Nebeneffekt: Er sorgt nämlich dafür, dass unter POSIX-compatiblen Unix-Systemen Zeilen, die nicht länger als PIPE_BUF sind (auf Linux im allgemeinen 4048 Bytes), immer vollständig geschrieben werden, ohne dass ein anderer Prozess dazwischenfunken und die Zeile ruinieren kann. Im vorliegenden Fall ist das nicht so wichtig, da kaum Requests eintreffen, aber auf einem heftig schwitzenden Webserver wäre ohne diese Garantie (oder ein manuell gesetztes Lock) schnell die Datei im Eimer -- weil korrupt.

Umgekehrt liest readFromCSV() ab Zeile 18 die Zeilen aus der .csv-Datei und das Standardpaket encoding/csv fieselt die kommaseparierten Einträge auseinander, sodass ein zweidimensionales Array-Slice von Strings zurückkommt, mit zwei Einträgen pro Zeile für Wert und Zeitstempel, zu diesem Zeitpunkt noch im Stringformat.

Grafisch aufgehübscht

Diese Matrix mit Datenpunkten nimmt nun die mkChart() ab Zeile 8 in Listing 6 entgegen und produziert daraus einen Graphen nach Abbildung 1. Die Umrechnung der Zeitstempel aus dem Unix-Format in ein gut lesbares Format für die X-Achse übernimmt das Paket go-chart von Github automatisch. Zeile 4 in Listing 6 zieht es herein und Zeile 28 erzeugt aus den Datenpunkten in xVals (Zeitstempel) und yVals (Gewichtsmessungen) eine Struktur vom Typ chart.TimeSeries, die wiederum die Struktur chart.Chart ab Zeile 37 in ein Diagramm einbaut. Die Funktion Render() in Zeile 43 formt daraus die in Binärdaten einer .png-Datei mit dem Diagramm.

Listing 6: chart.go

    01 package main
    02 import (
    03   "bytes"
    04   "github.com/wcharczuk/go-chart/v2"
    05   "strconv"
    06   "time"
    07 )
    08 func mkChart(points [][]string) []byte {
    09   xVals := []time.Time{}
    10   yVals := []float64{}
    11   header := true
    12   for _, point := range points {
    13     if header {
    14       header = false
    15       continue
    16     }
    17     val, err := strconv.ParseFloat(point[0], 64)
    18     if err != nil {
    19       panic(err)
    20     }
    21     added, err := strconv.ParseInt(point[1], 10, 64)
    22     if err != nil {
    23       panic(err)
    24     }
    25     xVals = append(xVals, time.Unix(added, 0))
    26     yVals = append(yVals, val)
    27   }
    28   mainSeries := chart.TimeSeries{
    29     Name: "data",
    30     Style: chart.Style{
    31       StrokeColor: chart.ColorBlue,
    32       FillColor:   chart.ColorBlue.WithAlpha(100),
    33     },
    34     XValues: xVals,
    35     YValues: yVals,
    36   }
    37   graph := chart.Chart{
    38     Width:  1280,
    39     Height: 720,
    40     Series: []chart.Series{mainSeries},
    41   }
    42   w := bytes.NewBuffer([]byte{})
    43   graph.Render(chart.PNG, w)
    44   return w.Bytes()
    45 }

Dazu legt Zeile 42 in der Variable w einen neuen Write-Puffer an, in den die Chart-Funktion hineinschreibt, und Bytes() in Zeile 44 gibt dessen rohe Bytes an den Aufrufer der Funktion, also das Hauptprogramm, zurück.

Listing 7: Makefile.build

    01 DOCKER_TAG=minipro
    02 SRCS=minipro.go chart.go csv.go
    03 BIN=minipro
    04 REMOTE_PATH=some.hoster.com/dir/cgi
    05 remote: $(SRCS)
    06 	docker run -v `pwd`:/build -it $(DOCKER_TAG) \
    07 	bash -c "go build $(SRCS)" && \
    08 	scp $(BIN) $(REMOTE_PATH)
    09 docker:
    10 	docker build -t $(DOCKER_TAG) .

Um die drei Source-Dateien zu einem statischen Binary zusammenzufügen, baut das Makefile in Listing 7 ähnlich wie vorher unter der Target docker mit dem gleichen Dockerfile wie vorher ein neues Image mit dem Tag minipro zusammen. Ist dies geschafft, startet make remote erst den Container, mounted dessen Arbeitsverzeichnis zum späteren Einsammeln des fertigen Binaries, und started dann mit go build den Compile- und Linkprozess. Klappt das ohne Fehler, kopiert Die secure Shell das Binary mit scp in das CGI-Verzeichnis des in REMOTE_PATH eingestellten Hosters. Von dort kann ein Browser oder ein curl-Skript dann die Funktionen abrufen, mit add neue Datenpunkte hinzufügen und mit chart den bestehenden Datensatz grafisch aufmöbeln und illustrieren.

[1]

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

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