HUGO mit NGINX nutzen

pexels-yury-kim-585419.jpg

Inhaltsverzeichnis

Das HUGO-Framework ist zwar besonders effizient bei der Generierung statischer Inhalte und Webseiten, doch nicht spezialisiert um diese Dateien auch tatsächlich zu hosten.

Gehen wir gemeinsam die Schwächen und möglichen Lösungen durch die bei einem Hosting über NGINX auftreten können.

Technische Voraussetzungen

Dieser Beitrag setzt Grundkenntnisse in Linux, HUGO, NGINX und Docker voraus. Viele der hier dargestellten Docker-Lösungen lassen sich auch vereinfacht auf einem Linux-Server realisieren. Viele Ansätze sind vereinfacht, um den Lösungsansatz zu zeigen, anstatt sich in technischen Details zu verlieren.

Warum NGINX nutzen

Viele Firmen besitzen bereits einen Provider und/oder Server für ihre Webseiten. Dies kann ein Root-Server, ein Shared-Hosting oder sogar eine virtuelle Instanz bei einem Cloud-Anbieter handeln. Um das Hosting zentral zu halten oder sogar als Origin-Quelle für ein CDN verwendbar zu machen, lohnt es sich Optimierungen direkt dort anzuwenden.

NGINX bietet eine sehr gute Performance für statische Inhalte, mit einer einfachen Konfiguration und minimalistischen Docker-Images. Somit können wir schnell und effizient neue Regeln und Lösungen schaffen.

Die Schwächen von HUGO

HUGO wird mit einem Server ausgeliefert der über hugo server gestartet werden kann. Primär sollte dieser aber nur für die Entwicklung und Prüfung von Inhalten verwendet werden. Durch diverse Probleme eignet sich dieser Modus nicht für einen produktiven Betrieb:

MIME-Typen sind nicht leicht zu ergänzen. Viele Dateitypen werden einfach mit Content-Type: text/plain ausgespielt. Egal ob PGP-Schlüssel als .asc oder SSH-Schlüssel als .pub, alles wird direkt als Text ausgespielt. Ein Download lässt sich nicht erzwingen. Eine Ergänzung ist ohne schwierige Anpassungen vor der Kompilierung von HUGO leider nicht möglich.

HTTP-Header lassen sich nicht dynamisch beeinflussen oder gar ergänzen. Wichtige Werkzeuge wie Link-Header können nicht über den Response-Header ausgespielt werden. Dies hat zur Folge, dass sich CDN-Features, wie z. B. Cloudflare HTTP2 Push oder Cloudflare Workers unnutzbar werden. Während Sie die Antwort-Header verarbeiten und abfragbar machen, bleibt man berechtigterweise gezwungen den eigentlichen Inhalts-Payload, als Stream, durchzureichen.

Wichtige Dateien pushen und streamen

Es gibt zwei Ansätze die infrage kommen:

Die Nutzung der HTTP2 Server-Push -Technik, ehemals auch als qQUIC bekannt.

Content-Preloading über die Link-Header und -Tags einer HTTP-Response.

HTTP2 Server-Push bald veraltet?

Leider gibt es derzeit einige Vorschläge diese Technik wieder zu entfernen. Gehen wir den Ansatz und die Gründe warum kurz durch:

NGINX unterstützt HTTP2-Push über das ngx_http_v2_module welches mitgeliefert wird. Hierbei gibt es zwei Direktiven, die uns eine Lösung ermöglichen:

http2_push orientiert sich an einer festen NGINX-Konfiguration und ermöglicht den Push ohne weitere Analyse der eigentlichen Antwort. Dieser Ansatz ist nur sinnvoll wenn:

  • HTTPS/SSL-Endpunkte und Zertifikate auch im NGINX konfiguriert werden.
  • Keinen CDN oder anderen Reverse-Proxy verwendet.

In jedem anderen Szenario geht der Push-Versuch verloren, da nur wenige Proxy-Server damit umgehen können.

http2_push_preload ist hier ähnlich, ermöglicht es aber auf Link-Header in der Response zu reagieren. Es muss im Header der Response passieren, Link-Tags im HTML selbst werden nicht verarbeitet.

Anforderungen für HTTP2
Um HTTP2-Push verwenden und testen zu können, muss HTTPS eingesetzt werden. Dies trifft auch zu, wenn ihr auf localhost eure Anwendung entwickelt und ausprobiert.

All diese Szenarien haben eines gemeinsam: Sie sind umständlich mit einem statischen Generator wie HUGO zu integrieren. Dies ist auch ein Hauptgrund warum HTTP2-Push so wenig Anwendung findet, denn die meisten Webseiten liegen hinter einem Reverse-Proxy oder CDN.

Es ist also einfacher und zukunftsorientierter, wenn wir uns direkt mit Content-Preloading über Link-Header auseinandersetzen. Sie haben den Vorteil das der Browser proaktiv entscheiden kann, was in einem HTTP2-Stream abgerufen werden kann.

Fangen wir mit einem Beispiel an: Gehen wir vereinfacht davon aus das unsere Pipeline eine /minified/app.js und /minified/app.css erzeugt, die wir streamen wollen.

Um das Ziel zu erreichen, benötigt der moderne Browser-Client einen Header der wie folgt aufgebaut ist:

Link: </minified/app.css>; rel=preload; as=style, </minified/app.js>; rel=preload; as=script

Aufgrund von CSP-Regelungen ist es wichtig as anzugeben. Die Anweisung preload zeigt dem Browser an, das es sich um eine wichtige Datei handelt und diese vorausgeladen werden sollte.

Die einfachste Lösung für NGINX wäre also eine conf.d/push.conf-Datei mit folgendem Inhalt:

add_header Link "</minified/app.css>; rel=preload; as=style, </minified/app.js>; rel=preload; as=script";

Nutzt man jedoch HUGO-Pipes werden die Dateien natürlich mit einem Fingerprint versehen, was super als Cache-Buster fungiert. Wie kommen wir also zu dieser Konfiguration, wenn unsere Dateien z. B. /minified/app.a2f1.js und /minified/app.c3g1.css heißen?

Wie wäre es mit einem Bash-Script das man manuell vor dem Deploy oder in die CI integrieren kann? Hier ein Ansatz den wir als build_pushable.sh verpackt haben:

#!/bin/bash

pushable=()

cd public

# css files
for f in $(find assets/critical -name '*.css'); do
  pushable+=("$(printf "</%s>; rel=preload; as=style" "$f")")
done

# js files
for f in $(find assets/critical -name '*.js'); do
  pushable+=("$(printf "</%s>; rel=preload; as=script" "$f")")
done

line=$(printf ", %s" "${pushable[@]}")
line=${line:2}

header=$(printf 'add_header Link "%s";' "$line")

echo $header > conf.d/push.conf

Wir haben hierbei extra ein assets/critical-Verzeichnis verwendet, denn wir wollen nicht alles dem Browser als wichtig anzeigen. In diesem Verzeichnis liegen nur Inhalte die in 90 % aller Seitenaufruf notwendig sind.

Nun noch sicherstellen, dass die Push-Konfiguration geladen wird.

server {
    location / {
        # ...
        include conf.d/push.conf;
    }
}
Nicht vergessen
Die add_header-Anweisung von NGINX gilt immer pro Location. Setzt eine andere Location eigene Header, werden alle anderen Header (auch die man sonst als vererbt vermutet) verworfen.

Caching optimieren

Die generierten, statischen, Inhalte können in mindestens zwei unterschiedliche Caches aufgeteilt werden. Betrachten wir beide und passen das entsprechende HTTP-Caching an, welches von den Browsern berücksichtigt wird:

Die Index-Datei die das statische HTML ausliefert, z. B. /technik/index.html.

Die Assets, welche mit einem Fingerabdruck versehen wurden, z. B. /static/app.f2b.css

Fassen wir diese beiden Inhalte zusammen:

location / {
    # Falls Zurück-Navigation und erneute Seitenaufrufe
    # auch nicht fürs Logging wichtig sind: 
    # add_header Cache-Control "public, max-age=60";
    
    # Ansonsten wird der Expiry, E-Tag oder Last-Modified
    # Header gesetzt, basierend auf der Datei.  

    try_files $uri $uri/ =404;
}

location ~* \.(?:ico|css|js|gif|jpe?g|png|svg)$ {
    add_header Cache-Control "public, max-age=31536000";
    access_log off;
    try_files $uri =404;
}

Wir gehen davon aus das statische Assets nicht im Logging auftauchen sollen. Es gibt weder einen statistischen Zweck diese zu erfassen, noch ist es die CPU- und Speichernutzung wert. Im Fehlerfalle würden diese aber trotzdem weiter ins error.log fließen.

Da die statischen Assets mit einem Fingerabdruck versehen wird, kann es nicht zu kollisionen kommen und die Cache-Zeit könnte unendlich sein.

Text-Inhalte vorkomprimieren

Statische Seiten leben von Text-Inhalten, egal ob .html, .svg, .css oder .js. Viele Webserver bieten an, Dateien mit GZIP oder Brotli auf Anfrage-Basis zu komprimieren.

Da wir aber statische Dateien ausspielen, warum die CPU dauerhaft damit belasten? Wir können diese Dateien im Voraus komprimieren und so die CPU-Last und Antwortzeit reduzieren.

Nachdem die statischen Inhalte über hugo generiert wurden, kann man mit dem folgenden Ansatz alle statischen Inhalte vorkomprimieren:

#!/bin/bash -ex

# Für Debian/Ubuntu :
# apt-get install zopfli brotli

for f in $(find ./public -name '*.html' -or -name '*.css' -or -name '*.js' -or -name '*.map' -or -name '*.log' -or -name '*.svg' -or -name '*.xml'); do
  nice -n 19 zopfli -c --i100 $f > $f.gz &
  nice -n 19 brotli -c -q 11 $f > $f.br &
done

wait

Wir stellen mit nice -n 19 sicher, dass die ganzen Aufgaben mit so wenig Priorität wie möglich die CPU verbrennen. Die Kombination aus & und wait führen dazu das alle Jobs gleichzeitig gestartet werden und erst nach deren Abschluss das Script beendet wird. Dies beschleunigt den Vorgang beachtlich, besonders auf Maschinen die viele CPU-Kerne haben. Bei sehr vielen Dateien sollte man aber zuerst schauen, ob es zu anderen Problemen kommt. Eine Fehlerquelle könnten unter Linux die limits werden.

Die Dateien .txt nehmen wir generell aus. Selten sind diese größer als 2 KB und eine clientseitige Dekomprimierung wäre nicht mehr zweckmäßig. Eine robots.txt die erst kostspielig dekomprimiert werden muss hilft niemandem.

Die Konfiguration von NGINX steht als Nächstes an.

NGINX und brotli
Das brotli-Modul wird nicht von Haus aus mit NGINX ausgeliefert, lediglich GZIP kann man direkt nutzen. Hier würden wir empfehlen ein vorgefertigtes Image von Docker Hub zu verwenden oder die Anweisungen von Google zur Einkompilierung zu verwenden.

Die folgenden Anweisungen sind das minimum um beide Inhalte statische sauber an moderne Browser zu übermitteln:

server {
    gzip_static on;
    brotli_static on;
}

Der NGINX-Server wird ab sofort index.html.br, dann index.html.gz-Dateien bevorzugt ausliefern, wenn der Accept-Encoding-Header des Clients diese anzeigt. Sollte der Client diese nicht akzeptieren, so fällt er auf die index.html zurück.

Falls ein CDN oder Reverse-Proxy eingesetzt wird, sollte unbedingt ein korrekter Vary: Accept-Encoding-Header eingestellt werden!

Optimierung von Bildern

Wir unterscheiden zwei Stufen der Optimierung:

Preflight findet vor der Einbindung eines neuen Bildes statt. Die Bilder sind meist nicht für Web optimiert und eine andauernde Optimierung durch die CI nicht immer sinnvoll.

Deshalb bietet es sich an Bilder vor dem Einbau zu optimieren:

  • Für .jpg nutzen wir guezli
  • Für .png greifen wir zu zopfli
  • Für .svg nehmen wir svgo

Da einige dieser Tools nicht immer für jede Linux-Distribution über den Paketmanager verfügbar sind, nutze ich persönlich mein eigenes Docker-Image dafür. Aus dem dortigen Dockerfile kann man sich gerne selber zusammenstellen wie es auch ohne Docker gehen könnte.

Hier die beiden Varianten:

# Über das Docker-Image
alias mopt='docker run -it --rm -v `pwd`:/app adrianrudnik/media-optimizer'

mopt optimize-svg ./assets
mopt optimize-png ./assets
mopt optimize-jpg ./content

# Über die einfachen Befehle
cd hugo-folder
shopt -s globstar
for i in ./assets/**/*.svg; do svgo "$i"; done
for i in ./assets/**/.png; do zopflipng -y -m "$i" "$i"; done
for i in ./content/**/*.jpg; do guetzli "$i" "$i"; done

Hierbei gehen wir generell davon aus das .svg und .png-Dateien eher zu den Assets gehören und .jpg eher beim jeweiligen Artikel-Inhalt liegen. Die Pfade einfach entsprechend den eigenen Ansprüchen anpassen.

Wichtig ist auch das guetzli nicht mehrfach verwendet wird, da es verlustbehaftet ist. svgo und zopflipng hingegen sind idempotent und können mehrfach über die gleichen Dateien laufen.

Bilder-Alternativen in WEBP anbieten

Wie schon bei den vorkomprimierten Text-Inhalten, ist dies auch für JPGs möglich. Da im Image-Processing von HUGO viele Bilder in verschiedenen Größen und Qualitäten entstehen können, binden wir dies generell in die CI ein, nachdem durch hugo die Bildvarianten erzeugt wurden:

#!/bin/bash -ex

# Für Ubuntu/Debian:
# apt-get install webp

for f in $(find ./public -name '*.jpg'); do
  nice -n 19 cwebp -quiet $f -o $f.webp &
done

wait

Dies erzeugt zu jedem JPG eine Variante im WEBP-Format. Diese sind kleiner und somit auch schneller ausgeliefert, bei ungefähr gleichwertiger visueller Qualität.

Mit NGINX können wir diese bevorzugt an moderne Clients ausliefern:

server {
    map $http_accept $webp_suffix {
        default "";
        "~*webp" ".webp";
    }
    
    location ~* \.(?:ico|css|js|gif|jpe?g|png|svg)$ {
        add_header Vary Accept;
        try_files $uri$webp_suffix $uri =404;
    }
}

Testbar ist die Auslieferumg im Chrome seit Version 88 möglich:

Screenshot der Chrome-Einstellungen der WEBP/AVIF-Bildvarianten
Screenshot der Chrome-Einstellungen zum testen der WEBP/AVIF-Formate.

Anmerkungen zum AVIF-Format

Die Schritte zum WEBP-Format lassen sich auch für das AVIF-Format anwenden, doch bitte prüft vorher sorgsam, ob es sinnvoll ist.

  • Habt ihr Bildmaterial das von AVIF profitiert, sprich Wide Color Gamut oder High Dynamic Range nutzt?
  • Liegen das Quellmaterial im AVIF oder HEIC-Format vor?

Wenn beides zutrifft, empfehle ich euch Tests mit go-avif durchzuführen.

Um noch einmal Gewicht und Meinung in das AVIF-Format und seinen praktischen Nutzen zu legen: Eine Konvertierung bzw. Aufbereitung dauert äußerst lange. Wir konten keinen praktischen Mehrwert in einer Konvertierung von JPG nach AVIF feststellen, die Ausgabedateien waren immer größer bei gleicher visueller Qualität und/oder hatten Verschiebungen im Farbraum.

Nutzt das Format wenn euer Bildmaterial es hergibt, spart euch die Zeit wenn nicht.

Umleitungen konfigurieren

Hier haben wir zwei Ansätze:

Wird die Struktur der Artikel innerhalb HUGO umgestellt, so empfiehlt es sich die Alias-Funktion im Front-Matter zu nutzen:

--- 
aliases:
- /alter-pfad/zum/artikel
- /archivierter-pfad/artikel
- /2020/01/20/artikel
--- 

Damit werden auch gleich die Canonicals verwaltbar.

Für alle andere Arten von Redirects kann eine zusätzliche NGINX-Konfiguration innerhalb der Location genutzt werden:

rewrite ^/sitemap.txt$ /sitemap.xml permanent;
rewrite ^/robot.txt$ /robots.txt permanent;
rewrite ^/datenschutz$ /rechtlich/datenschutz/ permanent;
rewrite ^/datenschutz/$ /rechtlich/datenschutz/ permanent;
rewrite ^/impressum$ /rechtlich/impressum/ permanent;
rewrite ^/impressum/$ /rechtlich/impressum/ permanent;
rewrite ^/hackers.txt$ /.well-known/security.txt permanent;