Wir haben unsere Bilder auf ein CDN verlagert, und der Ursprungsserver lieferte sie weiter aus
Unsere Dokumentation ist voller Screenshots. Jede Portalfunktion, aufgenommen in Hell und Dunkel, in zwei Breiten, über zehn Sprachen hinweg, was sich auf deutlich über hundert Megabyte an Bildern summiert. All das lag im eigenen statischen Bundle der App und wurde vom Ursprungsserver ausgeliefert, was der falsche Ort für hundert Megabyte an Bildern ist, die sich nie ändern. Also haben wir sie zu Cloudflare R2 verlagert und ein CDN davorgeschaltet, eines pro Umgebung: cdn.authagonal.io für die Produktion und einen passenden CDN-Host für unsere Dev-Site.
Die App wählt den richtigen CDN-Host zur Laufzeit, indem sie auf den Hostnamen schaut, unter dem sie ausgeliefert wird. Liegt die Seite auf authagonal.io, kommen die Bilder von cdn.authagonal.io. Einfach, keine Konfiguration zur Build-Zeit, eine einzige Bildreferenz, die in beiden Umgebungen korrekt aufgelöst wird. Wir haben es ausgeliefert, zugesehen, wie der Browser jedes Bild sauber vom CDN zog, und dann auf den Traffic des Ursprungsservers geschaut. Der Ursprungsserver lieferte die Bilder immer noch aus. Jedes einzelne, beim ersten Laden, genau wie zuvor.
Die Seite, die ein Crawler sieht, ist nicht die Seite, die ein Browser baut
Um zu erklären, warum, muss ich erklären, was unsere Marketing-Seiten eigentlich sind. Sie sind eine Single-Page-App, aber eine Single-Page-App ist nur eine leere Hülle, bis JavaScript läuft, und eine leere Hülle ist schlecht für Suchmaschinen und schlecht für die Zeit, die das erste Sinnvolle zum Erscheinen braucht. Also rendern wir zur Build-Zeit vor: Wir starten die Site, öffnen jede Seite in einem Headless-Browser, lassen sie vollständig rendern und speichern das resultierende HTML. Dieses vorgerenderte HTML liefern wir zuerst aus. Es enthält den Inhalt bereits, der Crawler sieht eine echte Seite, der Nutzer sieht sofort Text und Bilder, und dann übernimmt das JavaScript.
Hier steckt das Problem in dieser Beschreibung. Der Prerender lässt die Site auf 127.0.0.1 laufen, einer Loopback-Adresse auf der Build-Maschine. Wenn die App also fragt „Auf welchem Hostnamen bin ich, damit ich ein CDN wählen kann“, lautet die Antwort während des Prerenders „eine lokale IP-Adresse“, die weder zu authagonal.io noch zum Dev-Host passt. Die Host-Ableitung zur Laufzeit, der clevere Teil, fällt still auf ihre einzige verbleibende Option zurück: den ursprungsrelativen Pfad. Und genau dieser Ursprungspfad wird ins vorgerenderte HTML eingefroren.
So bekam jeder Besucher HTML mit fest eingebackenen Ursprungs-Bild-URLs, und der Browser tat das Naheliegende: Er holte sie vom Ursprungsserver, beim ersten Rendern. Einen Moment später renderte das JavaScript die Seite neu, leitete den Hostnamen erneut ab, diesmal den echten, und tauschte die Bilder gegen das CDN. Zwei Abrufe für jedes Bild, der erste traf genau den Server, den wir schonen wollten, und völlig unsichtbar, es sei denn, man beobachtete die Logs des Ursprungsservers, denn die Seite sah perfekt aus und funktionierte perfekt.
Die Lösung, die elf Minuten lang alles schlimmer machte
Die erste Lösung war die verlockende. Wenn das Problem darin besteht, dass eine falsche URL ins vorgerenderte HTML eingefroren wird, dann gib während des Prerenders gar keine URL aus. Lass die Bildquelle im Snapshot leer und lass das JavaScript die korrekte CDN-URL einsetzen, sobald es läuft und den echten Hostnamen kennt. Keine falsche URL, kein Ursprungsabruf.
Es ging live, und es war falsch, und wir ersetzten es etwa elf Minuten später. Die Bildquelle im vorgerenderten HTML leer zu lassen, hebelt den gesamten Grund aus, warum wir überhaupt vorrendern. Der Crawler, der die statische Seite liest, sieht nun kein Bild, und der Browser hat nichts zu zeigen, bis JavaScript gelaufen ist, was genau der langsame Pfad ist, den zu vermeiden wir das vorgerenderte HTML bauen. Wir hatten den doppelten Abruf beseitigt, indem wir das Bild aus der einen Version der Seite entfernten, die vor JavaScript existiert. Das tauscht ein Traffic-Problem gegen ein Such- und First-Paint-Problem, was der schlechtere Tausch ist.
Die Lehre aus diesen elf Minuten: Das vorgerenderte HTML muss eine echte, funktionierende Bildreferenz tragen. Der Fehler war nie, dass eine URL vorhanden war. Er war, dass die falsche URL vorhanden war, und sie leer zu lassen tauscht nur einen Defekt gegen einen anderen. Was wir tatsächlich brauchten, war, dass die korrekte URL zur Prerender-Zeit bekannt sein konnte, und das ist sie nicht, denn zur Prerender-Zeit weiß niemand, welche Umgebung die Seite ausliefern wird.
Im letztmöglichen Moment entscheiden
Die korrekte URL hängt vom Anfrage-Host ab, und der Anfrage-Host ist erst bekannt, wenn es eine Anfrage gibt. Der Prerender geschieht einmal, zur Build-Zeit, und erzeugt eine Datei, die in beiden Umgebungen ausgeliefert wird. Der umgebungsspezifische Teil der URL kann also nicht zur Build-Zeit entschieden werden. Er muss pro Anfrage entschieden werden, und das Einzige im Stack, das jede Anfrage und ihren Host sieht, ist der Webserver am Edge.
Der Prerender schreibt also keine URL und keine Leerstelle mehr. Er schreibt einen Platzhalter, eine wörtliche Sentinel-Zeichenkette, https://cdn.authagonal.io, anstelle des CDN-Hosts. Das vorgerenderte HTML, das den Build verlässt, sagt https://cdn.authagonal.io/screenshots/whatever.webp, was weder eine Ursprungs-URL noch eine leere Quelle ist. Es ist eine URL mit einem Loch darin.
nginx füllt das Loch auf dem Weg nach draußen. Es bildet den Host der Anfrage auf die richtige CDN-Basis ab, den Nicht-Produktions-Host auf sein eigenes Dev-CDN, authagonal.io auf https://cdn.authagonal.io, alles andere auf leer, sodass die URL als sicherer lokaler Rückfall wieder ursprungsrelativ wird. Dann schreibt eine einzige Direktive den Sentinel auf diesen Wert um, während das HTML zum Client streamt:
map $host $asset_cdn_base {
default "";
"~*example\.dev$" "https://cdn.example.dev";
"~*authagonal\.io$" "https://cdn.authagonal.io";
}
sub_filter 'https://cdn.authagonal.io' $asset_cdn_base;
Das statische HTML verlässt den Ursprungsserver nun bereits mit echten CDN-URLs, korrekt für den jeweils anfragenden Host. Der Browser holt beim ersten Rendern direkt vom CDN. Es gibt keinen falschen ersten Abruf rückgängig zu machen, denn es gibt keine falsche URL, und es gab auch nie eine leere. Der Crawler sieht eine vollständige Seite mit echten Bildlinks. Eine Datei bedient beide Umgebungen, und keine von beiden braucht einen separaten Build.
Es liegt eine kleine Eleganz darin, welche Teile der Antwort umgeschrieben werden. Das Umschreiben ist auf HTML beschränkt, was nginx' Standard ist. Das ist wichtig, weil dieselbe Sentinel-Zeichenkette auch im JavaScript-Bundle vorkommt, als Rückfall-Zweig des Host-Ableitungscodes. Auf dem Client ist dieser Zweig toter Code, da der Browser einen echten Hostnamen hat und den Sentinel nie ausgibt, also wollen wir ausdrücklich nicht, dass nginx das Bundle anfasst. Das Umschreiben bei seinem HTML-only-Standard zu belassen, liefert das gratis. Wir haben nebenbei gelernt, dass es schlechter ist, den Standard ausdrücklich hinzuschreiben, als ihn wegzulassen, denn eine redundante Deklaration lässt nginx vor einem Duplikat warnen.
Warum nicht einfach die Datei einmal beim Start umschreiben
Der naheliegende Einwand ist, dass das eine Menge Arbeit pro Anfrage für einen Wert ist, der nur zwei Möglichkeiten hat. Warum nicht das HTML einmal umschreiben, wenn der Container startet, den richtigen Host einbacken und eine statische Datei ohne Filter ausliefern?
Weil der Container nicht in sich selbst schreiben kann. Die statische Site läuft in nginx als Nicht-Root-Nutzer mit schreibgeschütztem Root-Dateisystem und jeder entzogenen Linux-Capability, mit einem einzigen kleinen beschreibbaren Scratch-Verzeichnis für nginx' eigene Temp-Dateien. Das ist eine bewusste Härtungshaltung: Ein Webserver, der nicht vertrauenswürdigen Traffic bedient, sollte die Dateien, die er ausliefert, nicht verändern können, damit ein Angreifer, der einen Fuß in die Tür bekommt, nichts zum Umschreiben findet. Ein Umschreiben des HTML beim Start bräuchte genau den Schreibzugriff, den wir absichtlich entfernt haben. Das schreibgeschützte Dateisystem ist kein Hindernis, das man umgehen muss, es ist eine Eigenschaft, die wir wollen, und es schließt das Umschreiben beim Start sauber aus. Das Umschreiben pro Anfrage fasst überhaupt keine Dateien an, weshalb es mit einem Server kompatibel ist, der nichts besitzt, das er verändern könnte.
Wir wollten auch nicht den CDN-Host zur Build-Zeit einbacken und zwei verschiedene Bild-Bundles erzeugen, eines pro Umgebung, weil das den umgebungsspezifischen Build wieder einführt, den wir gerade losgeworden waren. Der Sinn der Host-Ableitung war ein Artefakt für beide Umgebungen. Der Sentinel hält dieses Versprechen: ein Build, eine Datei, die Umgebung im letztmöglichen Moment am Edge aufgelöst.
Prerendering friert Umgebungswerte ein, also löse sie am Edge auf
Prerendering friert einen Moment ein. Alles, was deine App durch einen Blick auf ihre Umgebung entscheidet, wird zur Prerender-Zeit gegen die Umgebung des Builds entschieden, die eine Loopback-Adresse ist und niemandes echter Host. Ein Wert, der zur Laufzeit korrekt ist, wird also in dem Moment falsch, in dem er als Snapshot festgehalten wird, und er scheitert stillschweigend, weil die vorgerenderte Seite immer noch rendert, nur eben auf die falsche Stelle zeigend.
Die allgemeine Lösung ist, umgebungsspezifische Werte während des Prerenders gar nicht aufzulösen. Gib einen Platzhalter aus, trage ihn unversehrt durch das statische Artefakt, und löse ihn auf der Schicht auf, die die Umgebung tatsächlich sieht, was für einen Wert pro Anfrage der Edge ist. Es ist späte Bindung, angewandt auf eine Zeichenkette in einer HTML-Datei: Lass das Loch durch jede Stufe offen, die die Antwort nicht kennt, und fülle es auf der einen Stufe, die sie kennt.
Wenn Sie möchten, dass die eigene Dokumentation und die Assets Ihres Identity-Providers bereits korrekt vom Edge ausgeliefert werden, ist das eines der vielen kleinen Dinge, die Authagonal bereits ausdiskutiert hat, damit Sie die Diskussion für Ihr eigenes Produkt aufsparen können.