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.
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.
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.
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.
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 |
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.
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!
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.
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.
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.
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.
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 |
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 }
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.
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.
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.
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.
Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2023/10/snapshot/
Hey! The above document had some coding errors, which are explained below:
Unknown directive: =desc