

Vi minskade vår genomsnittliga API-svarstid med 30 % när vi bytte från Cloud Functions till Cloud Run.

Jag är ledsen för den klickvänliga titeln, men den är inte överdriven. Vi har gjort en beräkning.
Vid starten av Unloc API var "Make it Work" (från Kent Becks berömda citat) det som gällde. Vi ville få grunderna att fungera utan alltför stora problem och hålla dem igång med så lite ingrepp som möjligt. Därför valde vi att köra den via Google Cloud Platforms Cloud Functions. Det finns flera anledningar till att vi anser att detta var ett fantastiskt val, men jag ska inte gå in på det ännu.
När vårt tekniska team växte och företaget som helhet började mogna tog vi en titt på vår kodbas och tänkte: Vi tänkte: "Vår kodbas börjar bli otymplig". Det var då vi började refaktorisera till Ports and Adapters (även kallad Hexagonal Architecture, som vi rekommenderar starkt) - och jag antar att det också var då vi gick från "Make it Work" till "Make it Right". Detta var och är en intressant process, men jag ska inte heller gå in på det nu.
Jag kommer dock att gå in på följande:
Problemet med molnfunktioner
GCP Cloud-funktioner är bra för många saker. Du ger din kod till Google, tillhandahåller en liten mängd konfigurationsuppgifter - och de lovar dig mer eller mindre att de kan hantera hur många anropningar som helst. Inom rimliga gränser, förstås.
Vi använder fortfarande Cloud Functions för många saker, men problemet med att använda Cloud Functions för att betjäna ett API är att varje instans bara behandlar en enda begäran åt gången och att det tar några sekunder att starta en ny instans. Att starta en ny instans på det här sättet kallas kallstart. Du kan göra uppvärmningsanrop för att hålla en handfull instanser igång, men det är som att lägga ytterligare en häst till vagnen när du borde ha använt en bil.
Kallstarterna var oproblematiska under våra första dagar som startup. I dag använder företag Unloc för att utveckla sina egna webblösningar - så 4 sekunders spikar i svarstid stör verkligen användarupplevelsen.

Det finns två anledningar till att det kommer att gå långsamt, nästan oavsett vad som händer:
- Om du använder ett API som vårt är det mycket troligt att du behöver data från mer än en slutpunkt när du laddar webbsidan första gången. Du vill inte att användarna ska vänta, utan gör allt parallellt. Detta innebär att minst en av dem kommer att utlösa en kallstart, vilket lägger till 4-5 sekunder till laddningstiden. Usch.
- När användaren interagerar med webbplatsen och skickar API-anrop finns det en icke försumbar chans att han eller hon får en kallstart. Detta gör att webbplatsen helt plötsligt känns lite okontaktbar. När allt kommer omkring finns det bara så mycket man kan göra med spinners.
Ett långsamt API känns dåligt att utveckla mot, men ännu viktigare är att om vårt API är långsamt kommer alla produkter som använder vårt API att vara långsamma.
Varför Cloud Run?
Att göra HTTP-förfrågningar till ett API för molnfunktioner är som att köpa korv från en korvkiosk som måste öppna varje gång någon kommer för att köpa en korv. Du kanske får köpa från ett öppet stånd, eller så måste du vänta på att killen med den roliga mustaschen ska låsa upp skåpen, sätta igång korvvärmaren och brödrosten, skaka ketchupflaskan, öppna kålsalladen, veckla ut skylten och jaga bort duvorna. Ingen vill vänta på allt detta.
Vårt mål på Unloc är att kunna servera API-ekvivalenten av korv super snabbt, och att kunna göra det när som helst - alltid. Så vi frågar oss: Varför inte bara hålla båset öppet?
Det finns en Google Cloud-produkt som heter Cloud Run. Cloud Run är som en korvkiosk som... Glöm det, glöm korvmetaforen. Jag antar att de flesta av er ändå är utvecklare. Cloud Run är en fullt hanterad plattform för att köra mycket skalbara containeriserade applikationer, vilket låter precis som det vi vill ha.
Cloud Run och Cloud Functions är både lika och olika. Cloud Run liknar Cloud Functions i den meningen att du ger Google din kod, i det här fallet som en förbyggd behållare i stället för en zip-fil, och Google ser sedan till att den körs och skalas.
Det är inte som Cloud Functions, och detta är den viktiga delen, i den bemärkelsen att det hanterar en hel massa förfrågningar samtidigt, där du kan ställa in ett minsta antal instanser som ska vara igång. Detta innebär att vi alltid har en varmkorv st... Cloud Run-instans öppen och redo att betjäna förfrågningar, och att fler kommer att vara tillgängliga vid behov.
Bra!
Så hur gick vi till väga för att genomföra denna övergång?
Migration

Baksidan av Unloc är skriven i Typescript, och före migreringen betjänades alla våra slutpunkter av Express-appar som distribuerades till Cloud Functions. Som med de flesta andra uppgifter av den här omfattningen gjorde vi migreringen i flera steg.
Steg 1: Kör API:erna i behållare
Cloud Run kräver att varje tjänst är en separat behållare. Vi hade redan konfigurerat Express-apparna, så allt vi behövde göra var att skriva den enklaste Dockerfile (en stor eloge till den som skrivit den utmärkta dockerfile-dokumentationen) för att köra Node och starta rätt Express-app.
Vi använder Firebase för att konfigurera och distribuera våra funktioner, där Firebase SDK accepterar parametrar som trigger (http) och listener (Express-appen). Så för Cloud Run-tjänsterna var vi tvungna att lägga till separata filer som faktiskt startar Express-apparna. Du vet, app.listen(port, () => etc... För flexibilitetens skull startar vi varje Express-app från skript i vår package.json-fil, som kallas med CMD i Dockerfilen.
Under den här processen använde vi Docker Compose för att testa lokalt, Docker för att bygga avbildningar och gcloud cli för att distribuera till vår utvecklingsmiljö.
Steg 2: Loggning

Vi kunde ha fortsatt att använda våra console.log-meddelanden och ha slutat med det, men så är vi inte. Nej, vi föredrar våra yaks rakade och helst luktade nyklippt tall (ännu en loggreferens. Jag vet, jag är ganska smart).
Vi ville gå från svårsökta platta textloggar till strukturerade JSON-loggar som är lättare att söka i (du hittar en bra guide om skillnaden här). Vår första tanke var "Det måste finnas ett bibliotek som gör detta" - och se där, det fanns det.
En lång historia kortfattat: vi ägnade för mycket tid åt det. Den har fler funktioner än vi behövde, och vi måste använda console.log för att behålla executionId i loggarna för Cloud Functions ändå, så vi släppte den.
När vi går från en enda förfrågan till flera förfrågningar måste vi hålla reda på vilken förfrågan som loggar vad. För att göra detta använde vi Node Async Hooks för att lagra ett trace-id, som vi fastställde med hjälp av Express middleware i början av varje inkommande begäran, vilket också gör att vi kan logga användbara data, som klient-ID.
Mycket användbart för felsökning.
Steg 3: Distribuera
När API:et fortfarande befann sig i en tidigare fas (under "Make it Work"-fasen som nämndes tidigare) distribuerade vi från kommandoraden med hjälp av Firebase CLI:s deploy-kommando - vilket är bra när du inte har så många funktioner att distribuera. När du har många funktioner att distribuera överskrider du snabbt gränsen för deploy. Vi skrev ett skript för att distribuera i omgångar.
Det viktigaste är att vi först använder Object.getOwnPropertyNames() för att få fram alla exporterade medlemmar i våra indexfiler (se till att endast funktioner exporteras) och sedan kör firebase deploy --non-interactive --force --only följt av en lista med funktionsnamn från den aktuella satsen.
När vårt utvecklarteam växte flyttade vi från att köra skript i CLI till att köra i stort sett samma skript med hjälp av Github Actions. Vi trycker hellre på en knapp och glömmer det - än att köra ett skript lokalt och vänta på att det ska slutföras.
Distributionen av Cloud Run-tjänsterna består av tre steg:
- Hämta sökvägen till Dockerfilen för varje tjänst
- Bygg en avbildning från den Dockerfilen
- Distribuera avbildningen
1. Eftersom vi använder Docker Compose för lokal utveckling är det här steget redan i stort sett klart. Vi behöver bara analysera docker-compose.yml och hämta sökvägarna därifrån.
2. Detta kan göras lokalt på Github Actions-instansen med hjälp av docker build. Ingen mer dramatik där.
3. Detta visade sig vara det svåraste steget. Inte särskilt svårt, bara det svåraste av dessa tre. Vi använder gcloud cli-verktyget för att distribuera (specifikt gcloud run deploy). Detta fungerar som en charm när det körs från min lokala maskin eftersom jag är autentiserad som min GCP-användare, men kräver lite fifflande för att få det att fungera på Github Actions. Vi skapade ett dedikerat servicekonto för distribution, lade till de nödvändiga rollerna och lagrade nyckeln med hjälp av Github Secrets.
Nu kommer vi till det sista steget.
Steg 4: Fiffling
Ingen varmkorv kommer utan kostnad; den största kostnaden för att flytta till Cloud Run är att vi själva måste justera samtidighet, antal CPU:er och mängden minne. För att vara säkra på att dessa siffror var korrekta bestämde vi oss för att göra några belastningstester.
Helst skulle vi ha använt JMeter eller något liknande, men vi kunde inte få det att köras lokalt. Så vi skrev ett litet skript för att gå igenom de flesta av våra slutpunkter, med samtidiga förfrågningar, uppstartstid och så vidare.
Vi testade högre samtidighet, olika antal CPU:er, lägre samtidighet osv. Men trots att vi ändrade minnet från 512 MB till 2 GB fick vi i princip standardinställningarna för alla tjänster och en anteckning om att vi ska öka det minsta antalet aktiva Cloud Run-instanser när vår trafik ökar. När belastningstesterna och justeringarna var klara började vi testa prestandan för de båda inställningarna sida vid sida. Skulle det vara värt det?
Ja, det var det.
Vi gjorde tusentals testkörningar med alla möjliga olika variabler, och i denna stora uppsättning data såg vi att den genomsnittliga svarstiden var cirka 30 % lägre för Cloud Run. 31,62 %, för att vara exakt. Stor framgång!
Nedan finns data från en körning som verkligen visar skillnaden:

Detta är genomsnittssiffrorna (y-axeln är svarstiden i ms) från en handfull anrop som alla skickats parallellt. Du kan förmodligen föreställa dig hur denna skillnad skulle kännas för en användare.
Det finns naturligtvis många fler detaljer i denna process än vad som står här, men det här blogginlägget är redan mycket längre än jag tänkt mig, så jag avbryter det här.
Ta kontakt med oss nu
Fyll i formuläret för att komma i kontakt med en av våra representanter.
Vi har mottagit din ansökan!