Aus Linux-Magazin 11/2023

Körpergewicht im Blick mit CGI-Skript und Go

© Daniil Peshkov / 123RF.com

Mike Schilli steigt jede Woche auf die Waage und erfasst seine Gewichtsschwankungen als Zeitreihe. Dabei hilft ihm ein CGI-Skript in Go, das auch noch schöne Charts malt.

Datenpunkte aus Zeitreihen zu erfassen und grafisch aufzumöbeln, ist normalerweise die Domäne von Tools wie Prometheus. Er holt sich in regelmäßigen Abständen den Status überwachter Systeme ab und speichert die Daten als Zeitreihe. Fallen Ausreißer auf, schlägt der Götterbote Alarm. Darstellungswerkzeuge 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.

Allerdings erlaubt mein Billigheimer-Hoster es mir nicht, auf meinem gemieteten virtuellen Server 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 entgegennimmt und die daraus erstellte Zeitreihe, schön als Grafik formatiert, im PNG-Format zum Browser zurückschickt?

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,5 Kilogramm an, genügt der Aufruf »curl ‘…/cgi/minipro?add=82.5&apikey=Key‘« um den Wert unter dem aktuellen Datum in die nun auf dem Server verwaltete Zeitreihe einzuspeisen. Ersetze ich in der URL »add=…« durch »chart=1«, kommt die Grafik aller bislang eingespeisten Werte zurück.

Abbildung 1: Die Gewichtsschwankungen des Autors über die Jahre.

Abbildung 1: Die Gewichtsschwankungen des Autors über die Jahre.

Jurassic Tech

Dabei ist das CGI-Protokoll echte Sauriertechnologie aus den 90er-Jahren 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 Importingenieur aus Germany aufzufrischen – damals live auf einem einzigen Server und ohne Netz oder doppelten Boden. Dort zeigte ein CGI-Skript oben auf der Portalseite das gerade aktuelle Datum an. Das ließ allerdings den (einzigen!) Server unter der Last der doch ganz beachtlichen User-Zahlen zusammenkrachen, weil bei jedem Aufruf ein Perl-Interpreter starten musste. Mit einem kompilierten C-Programm brachte ich die Maschine wieder auf Vordermann. Später kamen persistente Umgebungen wie »mod_perl« in Mode und machten das Ganze noch tausendmal schneller.

All inclusive

Heute ist CGI verpönt, weil ein Skript eventuell ein Sicherheitsloch im Server aufreißt und die Startup-Kosten eines externen Programms, das bei jedem eingehenden Request startet, immens ausfallen. Für mein Gewichtsbarometer, bei dem der Server vielleicht zwei Requests pro Tag erhält, ist das Design jedoch vertretbar. In einer Skriptsprache wie 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 naselang mit Pip3 eine Python-Bibliothek zum Malen von Charts aufzufrischen, ist auch kein Leben. Einmal kompiliert – und gern auf einer anderen Plattform crosskompiliert – läuft ein statisch gelinktes Programm bis zum Sankt-Nimmerleins-Tag. Selbst wenn es dem Hoster einfiele, die Linux-Distro auf eine neue Version anzuheben und infolgedessen irgendwelche Bibliotheken plötzlich verschwänden, liefe das All-inclusive-Go-Binary immer noch.

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 Umgebungsvariable »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 etwa die URL in »REQUEST_URI«, deren Pfad auch alle CGI-Form-Parameter einschließt. Die Antwort schreibt das Skript einfach per »print()« nach Stdout. Den Textstrom greift der Webserver ab und schickt ihn an den anfragenden Client zurück.

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

Listing 1

cgi-test.go

package main
import (
  "fmt"
  "net/http"
  "net/http/cgi"
)
func main() {
  handler := func(w http.ResponseWriter, r *http.Request) {
    qp := r.URL.Query()
    fmt.Fprintf(w, "Hello\n")
    for key, val := range qp {
      fmt.Fprintf(w, "key=%s=%s\n", key, val)
    }
  }
  cgi.Serve(http.HandlerFunc(handler))
}

Dazu nimmt sie als Parameter eine Handler-Funktion entgegen (ab Zeile 8), die einen Writer für die Ausgabe und einen Reader für die Request-Daten als Parameter akzeptiert. Der Aufruf der Library-Funktion »Query()« auf die hereingereichte Request-URL gibt eine Map zurück, die die Namen der hereinkommenden CGI-Parameter den 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.

Für immer statisch

Kompiliert und gelinkt entsteht aus Listing 1 ein Binary, das man ausführbar ins Verzeichnis »cgi/« des Webservers kopiert. Der 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 Webclients zurückschickt. Abbildung 2 zeigt das Ergebnis aus Sicht des mittels eines Browsers anfragenden Users.

Abbildung 2: Das Go-Programm aus <a href="#artRef-l1">Listing&nbsp;1</a> als CGI-Skript in Aktion.

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

So weit, so gut – aber wie kompiliert man nun Listing 1? Schließlich soll dabei ein Binary herauskommen, das auf der Linux-Distro des Hosters läuft, und die ist unter Umständen inkompatibel zur Build-Umgebung, 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

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

Allerdings hinkt 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 aus dem Netz und pflanzt dessen Inhalt ins Root-Verzeichnis. Hinzu kommen noch einige Tools wie Git (Go nutzt Git zum Einholen von Github-Paketen) und Make für den Build, und fertig ist die Frankenstein-Distro.

Gut vorbereitet

Zum Kompilieren von Go-Sourcen muss der Go-Compiler oft Pakete aus dem Netz nachziehen und übersetzen. Ein Docker-Image, das diesen Schritt noch nicht enthält, trödelt bei jedem Übersetzungslauf teilweise minutenlang in der Vorbereitungsphase herum, die es bei jeder kleinen Änderung am Source-Code wieder und wieder ausführt. Zur Beschleunigung dieser Phase kopiert Zeile 11 in Listing 2 die Go-Quellen und die Moduldateien in das Image, und »go mod tidy« in Zeile 12 kompiliert schon einmal 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 machen das Entwickeln und die Fehlersuche wieder Spaß.

Das Makefile aus Listing 3 baut unter dem Target »docker« (ab Zeile 9) das Image zusammen und weist ihm das Tag »cgi-test« zu. Zum Kompilieren des Codes rufen Sie später das Target »remote« auf (ab Zeile 5), das mit »docker run« einen Container startet und das Build-Verzeichnis im Container auf das aktuelle Verzeichnis auf dem Host einhängt. So ist das erzeugte Binary später auch außerhalb des Containers verfügbar.

Listing 3

Makefile.cgi-test

DOCKER_TAG=cgi-test
SRCS=cgi-test.go
BIN=cgi-test
REMOTE_PATH=some.hoster.com/dir/cgi
remote: $(SRCS)
        docker run -v `pwd`:/build -it $(DOCKER_TAG) \
        bash -c "go build $(SRCS)" && \
        scp $(BIN) $(REMOTE_PATH)
docker:
        docker build -t $(DOCKER_TAG) .

Den eigentlichen Build-Prozess übernimmt das Shell-Kommando in Zeile 7, das »go build« aufruft. Klappt das fehlerfrei, installiert eine Secure Shell via Scp das Endprodukt im aktuellen Verzeichnis (aber außerhalb des Containers) auf dem Ziel-Host. Dessen Adresse gibt Zeile 4 mit »REMOTE_PATH« vor.

Kein Pillepalle

Nun aber genug mit dem Pillepalle. 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 Benutzer entgegen und speichert sie auf dem Server unter dem Zeitstempel der aktuellen Uhrzeit in der CSV-Datei »weight.csv« ab. Das erledigt die Funktion »addToCSV()« ab Zeile 37.

Listing 4

minipro.go

package main
import (
  "fmt"
  "net/http"
  "net/http/cgi"
  "regexp"
)
const CSVFile = "weight.csv"
const APIKeyRef = "3669d95841f6d20ff6a5067a2f2919db4fca6e82"
func main() {
  handler := func(w http.ResponseWriter, r *http.Request) {
    qp := r.URL.Query()
    params := map[string]string{}
    for key, val := range qp {
      if len(val) > 0 {
        params[key] = val[0]
      }
    }
    apiKey := params["apikey"]
    if apiKey != APIKeyRef {
      fmt.Fprintf(w, "AUTH FAIL\n")
      return
    }
    if len(params["chart"]) != 0 {
      points, err := readFromCSV()
      if err != nil {
        panic(err)
      }
      chart := mkChart(points)
      w.Write(chart)
    } else if len(params["add"]) != 0 {
      sane, _ := regexp.MatchString(`^[.\d]+$`, params["add"])
      if !sane {
        fmt.Fprintf(w, "Invalid\n")
        return
      }
      err := addToCSV(params["add"])
      if err == nil {
        fmt.Fprintf(w, "OK\n")
      } else {
        fmt.Fprintf(w, "NOT OK (%s)\n", err)
      }
    }
  }
  cgi.Serve(http.HandlerFunc(handler))
}

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. Das Programm bearbeitet den Request nur weiter, wenn der Schlüssel mit dem hartkodierten Wert übereinstimmt, sonst ist ab Zeile 21 Schluss.

Da man CGI-Parametern allgemein 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

Um ein Diagramm der Zeitreihe der bislang eingespeisten Werte zu sehen, setzen Sie den CGI-Parameter »chart« auf einen beliebigen Wert. Daraufhin reagiert der Abschnitt ab Zeile 24 in Listing 4, erzeugt mit »mkChart()« (siehe Listing 6) eine neue Chart-Datei im PNG-Format und gibt die Binärdaten der Grafik in Zeile 30 per »w.Write()« an den anfragenden 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 das Verwalten der CSV-Datei. Deren Inhalt besteht aus den Fließkommawerten der Wiegemessungen, denen jeweils pro Zeile nach einem Komma ein Zeitstempel im Epoch-Format beiliegt. Abbildung 3 illustriert einen Teil der gespeicherten Daten.

Abbildung 3: Die Wiegemessungen als Flie&szlig;kommawerte mit Zeitstempel.

Abbildung 3: Die Wiegemessungen als Fließkommawerte mit Zeitstempel.

Listing 5

csv.go

package main
import (
  "encoding/csv"
  "fmt"
  "os"
  "time"
)
func addToCSV(val string) error {
  f, err := os.OpenFile(CSVFile,
    os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
  if err != nil {
    return err
  }
  defer f.Close()
  _, err = fmt.Fprintf(f, "%s,%d\n", val, time.Now().Unix())
  return err
}
func readFromCSV() ([][]string, error) {
  points := [][]string{}
  file, err := os.Open(CSVFile)
  if err != nil {
    if os.IsNotExist(err) {
      return points, nil
    } else {
      return points, err
    }
  }
  defer file.Close()
  reader := csv.NewReader(file)
  points, err = reader.ReadAll()
  return points, err
}

Schreiben mit Garantie

Das Einspeisen von neuen Werten übernimmt in Listing 5 die Funktion »addToCSV()« ab Zeile 8. Sie öffnet die CSV-Datei im Modus »O_APPEND«, sodass die Schreibfunktion »fmt.Fprintf()« in Zeile 15 neue Werte mit anhängendem aktuellen Zeitstempel stets am Ende der Datei hinzufügt.

Dieser Modus hat einen schönen Nebeneffekt: Er sorgt dafür, dass unter Posix-kompatiblen Unix-Systemen Zeilen, die nicht länger als »PIPE_BUF« sind (unter Linux in der Regel 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 einen manuell gesetzten Lock) die Datei schnell korrupt.

Umgekehrt liest »readFromCSV()« ab Zeile 18 die Zeilen aus der CSV-Datei, und das Standardpaket »encoding/csv« fieselt die kommaseparierten Einträge auseinander. Auf diesem Weg kommt ein zweidimensionales Array-Slice von Strings zurück, mit zwei Einträgen pro Zeile für Wert und Zeitstempel.

Grafisch aufgehübscht

Diese Matrix mit Datenpunkten nimmt »mkChart()« ab Zeile 8 in Listing 6 entgegen und produziert daraus einen Graphen wie den aus 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.

Zeile 28 erzeugt aus den Datenpunkten in »xVals« (Zeitstempel) und »yVals« (Gewichtsmessungen) eine Struktur vom Typ »chart.TimeSeries«. Die baut nun die Struktur »chart.Chart« ab Zeile 37 in ein Diagramm ein. Die Funktion »Render()« in Zeile 43 formt daraus die Binärdaten einer PNG-Datei mit dem Diagramm.

Dazu legt Zeile 42 in der Variablen »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 6

chart.go

package main
import (
  "bytes"
  "github.com/wcharczuk/go-chart/v2"
  "strconv"
  "time"
)
func mkChart(points [][]string) []byte {
  xVals := []time.Time{}
  yVals := []float64{}
  header := true
  for _, point := range points {
    if header {
      header = false
      continue
    }
    val, err := strconv.ParseFloat(point[0], 64)
    if err != nil {
      panic(err)
    }
    added, err := strconv.ParseInt(point[1], 10, 64)
    if err != nil {
      panic(err)
    }
    xVals = append(xVals, time.Unix(added, 0))
    yVals = append(yVals, val)
  }
  mainSeries := chart.TimeSeries{
    Name: "data",
    Style: chart.Style{
      StrokeColor: chart.ColorBlue,
      FillColor:   chart.ColorBlue.WithAlpha(100),
    },
    XValues: xVals,
    YValues: yVals,
  }
  graph := chart.Chart{
    Width:  1280,
    Height: 720,
    Series: []chart.Series{mainSeries},
  }
  w := bytes.NewBuffer([]byte{})
  graph.Render(chart.PNG, w)
  return w.Bytes()
}

Um die drei Quelldateien zu einem statischen Binary zusammenzufügen, baut das Makefile aus Listing 7 ähnlich wie vorher unter dem Target »docker« mit demselben Dockerfile ein neues Image mit dem Tag »minipro« zusammen. Ist das geschafft, startet »make remote« erst den Container, hängt zum späteren Einsammeln des fertigen Binarys dessen Arbeitsverzeichnis ein, und startet dann mit »go build« den Compile- und Link-Prozess.

Listing 7

Makefile.build

DOCKER_TAG=minipro
SRCS=minipro.go chart.go csv.go
BIN=minipro
REMOTE_PATH=some.hoster.com/dir/cgi
remote: $(SRCS)
        docker run -v `pwd`:/build -it $(DOCKER_TAG) \
        bash -c "go build $(SRCS)" && \
        scp $(BIN) $(REMOTE_PATH)
docker:
        docker build -t $(DOCKER_TAG) .

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. (uba)

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 gern Ihre Fragen.

DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 6 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