Het webserver-deploymentmodel faalt op hobby-schaal
Laten we beginnen met een basislijn, die we zullen uitbreiden naarmate de vereisten veranderen:
- Iets dat TLS afhandelt.
- Dit moet gescheiden zijn van de applicatie, zodat je privé-sleutels niet breder worden blootgesteld dan noodzakelijk.
- Het moet mogelijk maken om te wijzigen hoe je certificaten verkrijgt, zonder dat dit invloed heeft op de applicatiecode.
(Toevoeging: er wordt uiteraard van uitgegaan dat dit al is ingesteld. Ik had niet verwacht dat dit deel voor zoveel verwarring zou zorgen; het is bijna alsof het hele punt van dit bericht juist gaat over het werken binnen de beperkingen van een bestaande opstelling).
Vervolgens hebben we de applicatie zelf. In vrijwel elk geval zal het eerste onderdeel waarschijnlijk ook een relatief capabele webserver zijn die reverse proxying als een van de vele functies bevat. Een zeer algemene functie is het serveren van statische bestanden, wat momenteel door je applicatie wordt afgehandeld.
Statische bestanden
Het is geen slecht idee om het serveren van statische bestanden uit te besteden aan de reverse proxy. De implementatie daarvan zal waarschijnlijk veel beter zijn dan wat er ook meegeleverd komt met dat hippe nieuwe framework dat je hebt gekozen, en zelfs als dat niet zo is, zullen statische bestanden niet langer het beperkte aantal gelijktijdige verzoeken blokkeren die jouw Python- of Node-webserver kan afhandelen. Je probeert dit te implementeren.
Alsstijds blijkt echter: zodra een van beide — je app of de reverse proxy — gecontaineriseerd of anderszins gesegmenteerd is, moet elke beheerder die jouw applicatie installeert nu gaten prikken in één of beide van deze compartimenten om de reverse proxy toegang te geven tot de statische bestanden die bij de app horen.
Zodra je app eindelijk is uitgebracht, krijg je een bugrapport van iemand wiens reverse proxy een van die "cloud-native" varianten is die niets anders doet dan reverse proxying en alleen wordt gedistribueerd als onderdeel van Kubernetes. Verslagen voeg je opnieuw een optie toe waarmee de app zelf de bestanden kan serveren, iets wat iedereen direct inschakelt zodra ze beseffen dat het bestaat. Je huilt wanneer je beseft dat je hebt bijgedragen aan de inefficiëntie die het potentieel van de minder krachtige hardware waar je doelgroep toegang toe heeft, verder belemmert.
Op "professionele" schaal worden statische bestanden volledig apart geïmplementeerd, waarschijnlijk naar een S3-bucket of iets dergelijks, met een Actual Cdn™ ervoor. Hierdoor komt dit probleem nooit voor.
Caching zonder authenticatie
Je beseft dat je applicatie veel ongeauthentiseerde bezoeken zal krijgen, zowel van mensen als bots. Ongeauthentiseerde bezoeken krijgen altijd hetzelfde antwoord, dus er is geen reden om deze antwoorden opnieuw te berekenen als er niets is veranderd. Je bepaalt dat een uur een adequate tijd is voor een "stale" pagina in jouw context.
Je kijkt met weemoed naar de Varnish cache-website, wetende dat je dit niet echt kunt vereisen omdat je slechts een onderdeel bent van de reeds bestaande Rube Goldberg-machine die een hobbyist als webserver gebruikt.
Neerslachtig voeg je de nodige cache-control en vary-headers toe aan je endpoints. Je ontdekt no-vary-search en realiseert je dat dit alleen voor Chrome is. Je kent de exacte set queryparameters die het antwoord zullen veranderen, maar vanaf jouw positie kun je daar niets aan doen. Als je op Varnish kon vertrouwen, zou je de ongebruikte queryparameters kunnen trimmen, zodat irritante mensen die een dode grap over link-previews willen nieuw leven inblazen, je cache niet kunnen omzeilen door ?qweqweawe achter je links te plakken.
Je ontdekt dat je webserver-bibliotheek een caching-middleware heeft. Je leest de ondersteunde functies en beseft dat het niets ondersteunt behalve de TTL van cache-control en misschien vary, als je geluk hebt. Je voegt het toe, met niets meer dan een sprankje hoop in je hart dat niemand iets geks probeert. Je plaatst een configuratieschakelaar en documentatie in de hoop dat beheerders die al een goede cache hebben ingesteld, dit zullen aanpassen voor maximale efficiëntie. Dat doen ze niet.
Zodra je app is uitgebracht, krijg je een melding van een Caddy-gebruiker, omdat zij vertrouwden op de ongelooflijk middelmatige caching-plugin van Caddy, die met plezier antwoorden corrumpeert als hij daar zin in heeft. Iemand anders heeft de cache van Nginx ingeschakeld, maar hun installatie blijft traag omdat ze vergaten proxycachelock in te schakelen. Weer iemand anders heeft hun site achter Cloudflare geplaatst, maar ook jouw cache-optie ingeschakeld omdat ze geen cache hadden ingesteld op hun eigen machine. Je krimpt ineen bij de hoeveelheid verspild geheugen.
Je realiseert je ook dat je ingebouwde caching-middleware je statische bestanden cached. Het serveren daarvan is relatief triviaal en waarschijnlijk al gecached door de disk-cachingfaciliteiten van het OS, dus je vergroot alleen maar onnodig je geheugengebruik. Omdat je middleware alleen naar cache-control kijkt, kun je niets doen zonder te voorkomen dat browsers ook jouw best-practice immutable statische bestanden aan de client-kant cachen. Je huilt.
Op "professionele" schaal controleren ze de volledige machine, dus kunnen ze inderdaad Varnish of iets soortgelijks instellen en dit afstemmen op het exacte cachegedrag dat ze willen. Nou ja, waarschijnlijk besteden ze het uit aan een CDN dat er een middelmatig werk van maakt, maar ze hebben in ieder geval de optie om caching zo granulair te beheren.
Caching met authenticatie
Je beseft dat je applicatie mogelijk ook veel geauthentiseerde bezoeken krijgt. Misschien doe je iets gefedereerds, waarbij externe instanties zich bij jou authenticeren. De gegevens die zij ophalen zullen nog steeds niet vaak veranderen, en zeker niet tussen verschillende aanvragers. Er is geen reden om deze ook opnieuw te berekenen.
Je bepaalt dat een kortere timeout, zeg één minuut, adequaat is voor een "stale" antwoord. Deze cache is echt alleen bedoeld om pieken in het aantal verzoeken op te vangen. Omdat je weet dat je je code sowieso moet uitvoeren vóór de cache, ben je er zeker van dat je de gecachete gegevens kunt ongeldig maken wanneer deze veranderen.
Je realiseert je al snel dat als je externe caches wilt ondersteunen, je enige opties zijn:
- Plugins schrijven voor elke cache-middleware.
- Of, realistischer, slechts één ondersteunde cache-middleware implementeren en de rest "overlatten aan de community" (waar niemand zich mee bezig zal maken).
- Een reverse proxy schrijven die je authenticatie afhandelt en het resultaat op passende wijze doorgeeft aan de cache-middleware, met een header of iets anders waar je op kunt variëren.
Je kiest geen van beide, omdat beide opties de deployment van je software compliceren. Je bent er zeer alert op dat mensen zich niet met de installatie van je software bezig zullen houden als de instructies meer bevatten dan:
- Draai het programma.
- Richt je reverse proxy erop.
Dit is gegaan tot het punt dat er een heel ecosysteem is van "one-click deployment" middleware en besturingssystemen, die alles verpakken in een mooie point-and-click interface voor nieuwkomers. Je ziet de voordelen hiervan; meer mensen controle laten hebben over hun eigen data is een goed idee. Je steunt het concept, ook al huil je bij de bestaande implementaties ervan.
Dus neem je de enige resterende optie. Je stopt met het ondersteunen van externe cache-middleware en verplaatst de authenticatie-middleware zodat deze vóór de caching-middleware in de code van je applicatie wordt uitgevoerd. Je huilt, want de cache-middleware in je framework is slecht.
Op "professionele" schaal controleren ze opnieuw de volledige machine. Ze kunnen elke oplossing kiezen en deze werkend krijgen.
De beste programmeertaal voor de klus
Je hebt genoeg interactiviteit om een single-page-application (SPA) als frontend te rechtvaardigen. Je wilt echter ook dat mensen zonder ingeschakelde JavaScript de delen kunnen lezen die geen interactie vereisen. Misschien is deze interactiviteit alleen zinvol voor ingelogde gebruikers. Je besluit een server-side-rendered SPA-framework te gebruiken.
Elke competent optie is geschreven in JavaScript. Dit is logisch; je wilt niet dezelfde code twee keer schrijven, zowel voor de backend als voor de frontend. Jouw applicatie is echter niet geschreven in JavaScript.
Je hebt kortstondig een heel slecht idee: Node.js in je applicatie embedden. Technisch gezien werkt dit, maar het voelt erg fragiel. De single-threaded natuur van Node.js betekent bovendien dat je meerdere frontend-processen wilt om tussen te load-balancen. Daarnaast wil je dat je frontend en applicatie gescheiden zijn in diverse resource-gebruikmetrieken, zodat je kunt zien waar een optimalisatie het meest effectief zal zijn.
Je hebt kortstondig een tweede slecht idee: je applicatie de Node.js-processen zelf laten starten. Je realiseert je dat je de helft van systemd, openrc, dinit of runit opnieuw moet uitvinden, alleen om de deployment eenvoudiger te maken, terwijl je elke sysadmin irriteert die graag zijn bestaande service manager zou gebruiken om de frontend te beheren.
Je enige optie is om de frontend als een volledig aparte applicatie te deployen, waardoor deze met de backend communiceert zoals elke andere applicatie communiceert met je publiek beschikbare API (die je hebt, omdat je een goede open source-burger bent).
Je ziet dat de reverse proxying-regels die je nodig zou hebben ingewikkelder zijn dan gebruikelijk. Je kunt niet simpelweg /api doorsturen naar je applicatie en de rest naar de frontend, vanwege diverse redenen buiten jouw controle. Er ontstaat een idee in je hoofd: laat je applicatie fungeren als zijn eigen reverse proxy naar de frontend. Misschien zou je hem zelfs kunnen voorzien van enkele initiële gegevens om paginaladingen te versnellen.
Je realiseert je al snel dat het webframework van je keuze geen manier heeft om dat te doen. Je zult die functionaliteit zelf moeten bouwen. Tijdens het doen hiervan stop je om na te denken: zouden deze verzoeken naar de frontend mijn applicatie niet ook verstoppen? Een request-slot dat niets anders doet dan wachten tot Node wat HTML rendert, wat vervolgens zelf weer een verzoek terugstuurt naar je applicatie via een ander slot, zou in plaats daarvan gebruikt kunnen worden om iets nuttigs te doen.
Verslagen geef je toe en splits je de frontend volledig af. Je documenteert wat er gereverse-proxied moet worden en geeft voorbeelden voor Caddy en Nginx, waarbij je herhaaldelijk vloekt op Nginx omdat ze geen bruikbare if hebben. Wanneer iemand om hulp vraagt bij een andere reverse proxy, kun je niets anders doen dan de issue sluiten met "patches welcome".
Op "professionele" schaal was de frontend vanaf het begin gesplitst, omdat deployment nooit een zorgpunt was.
De webserver is niet de enige afhankelijkheid!
Je krijgt een melding van een Japanner. Ze zijn dol op je app, maar het zoeken naar tekst duurt erg lang en verbruikt veel systeembronnen. Dat is vreemd; je gebruikt Postgres met tsvector of een GIN-index. Bij jou is het razendsnel. Je vraagt verder door: ze zoeken in het Japans.
Dat is een probleem. De ingebouwde full-text-search functies van Postgres werken niet buiten het Latijnse schrift. Je zoekt naar een oplossing. Je gaat geen Elasticsearch of Meilisearch gebruiken, omdat je niet alleen geeft om het resourcegebruik van je applicatie alleen, maar ook wanneer deze samen met andere applicaties op één machine staat. Sonic ziet er interessant uit, maar dat is weer een andere index die gesynchroniseerd moet worden en een andere service die beheerders moeten onderhouden. Dit voelt niet goed.
Je komt pgroonga tegen. Het claimt het probleem op te lossen terwijl het gewoon een Postgres-extensie is, en daardoor gebruik kan maken van de reeds bestaande waarden in je database in plaats van dat documenten er expliciet aan gevoed moeten worden.
Je applicatie is nu afhankelijk van een Postgres-extensie. De meeste hobby-setups delen één Postgres voor elke app om de overhead van het draaien van meerdere Postgres-instanties te beperken. Je vraagt deze beheerders nu om in te grijpen in de gedeelde Postgres-installatie, alleen voor jouw applicatie, wat mogelijk vereist dat de extensie vanuit broncode wordt gecompileerd. Vooral gezien het feit dat Postgres-updates al lastig zijn, realiseer je je dat de eenvoudige deployment die je als doel stelde, allang verdwenen is. Je huilt.
Op "professionele" schaal is er één Postgres en is het enige doel daarvan het bedienen van de applicatie. Extensies zijn geen enkel probleem.
Het voorspelbare einde
Je werpt nog een blik op de puinhoop die je net hebt gebouwd, de roman die je hebt geschreven om alleen al uit te leggen hoe je het moet installeren, en alle supportverzoeken die je moest beantwoorden voor elke combinatie van externe afhankelijkheden. Je herinnert je je oorspronkelijke doel: iets maken dat eenvoudig te deployen is, zodat mensen het daadwerkelijk gebruiken. Je ziet verschillende "vibe-coded" installatiescripts verschijnen die dit alles abstraheren en het slechter doen dan jij zou doen als je deze beperking gewoon zou opgeven. Je realiseert je wat er gedaan moet worden.
Je verplaatst de deployment-documentatie naar de map voor ontwikkelaars, waar het kan wegrotten. Je maakt een Dockerfile die al het volgende bundelt:
- runit (om dit alles te starten)
- je favoriete reverse proxy
- de cache, perfect geconfigureerd voor je applicatie
- je herschreven authenticatie-middleware die als plugin voor de cache werkt, voor maximale efficiëntie
- Postgres, met alle vereiste extensies, en een script om het bij de eerste boot te tunen omdat je constant meldingen kreeg over trage queries van mensen die geen idee hadden dat je Postgres moest tunen
- de frontend
- de backend
Zo hoeft de beheerder alleen maar hun bestaande reverse proxy naar jouw container te richten. Je brengt een nieuwe release uit en schrijft in de release notes:
BELANGRIJK: Deployments die geen gebruik maken van Docker worden niet langer ondersteund. Volg aub de Docker-migratiehandleiding voordat u bijwerkt.
De gebruiker maakt een HTTP-verzoek. De reeds bestaande reverse proxy parseert het verzoek en schrijft een nieuw HTTP-verzoek naar de container. De tweede reverse proxy in de container parseert dit nieuwe verzoek en schrijft een nieuw HTTP-verzoek naar de cache. De cache parseert dit nieuwe verzoek, vindt het antwoord niet in zijn cache en schrijft een nieuw HTTP-verzoek naar de backend of de frontend. Deze parseert vervolgens weer dit nieuwe verzoek. Als dit de frontend is, kunnen er meer interne verzoeken naar de backend worden getriggerd, maar tenminste hoeven ze niet boven de cache uit te gaan. Het antwoord wordt geschreven. De cache parseert het antwoord, slaat het indien nodig op en schrijft een nieuw antwoord. De gecontaineriseerde reverse proxy parseert dit antwoord, doet het niets dat hij is geconfigureerd om (niet) te doen, en schrijft een gloednieuw antwoord. De reeds bestaande reverse proxy parseert dit antwoord, voegt misschien een header of twee toe, en schrijft het uiteindelijke antwoord dat naar je gebruiker wordt verzonden.
Is dit alles echt nodig?
De gebruiker was, zoals later bleek, een scraper die via een residentiële proxy werkte. Deze haalde de allereerste revisie op van een pagina die voor het laatst relevant was twee jaar geleden. De scraper zal nu doorgaan met het ophalen van de tweede revisie, waarbij blindelings alle links in het geschiedenis-tabblad van de pagina worden gevolgd, vanaf een ander IP-adres.
Groetjes,