

Vi kuttet vår gjennomsnittlige API-responstid med 30 % ved å bytte fra Cloud Functions til Cloud Run

Beklager klikk-baity-tittelen, men det er ikke en overdrivelse. Vi kjørte tallene.
Ved begynnelsen av Unloc API, "Make it Work" (fra Kent Becks berømte sitat) var navnet på spillet. Vi ønsket å få det grunnleggende oppe og gå uten for mye fuzz, og å holde det i gang med så lite intervensjon som mulig. Så vi valgte å kjøre den fra Google Cloud Platforms Cloud Functions . Det er flere grunner til at vi tror dette var et fantastisk valg, men jeg vil ikke komme inn på det ennå.
Etter hvert som teknologiteamet vårt vokste, og selskapet som helhet begynte å modnes, tok vi en titt på kodebasen vår og tenkte: «Kodebasen vår begynner å bli uhåndterlig». Det var da vi begynte å refaktorisere til porter og adaptere (aka Hexagonal Architecture, som vi anbefaler på det sterkeste) - og jeg antar at dette også var da vi flyttet fra "Make it Work" til "Make it Right". Dette var og er en interessant prosess, men jeg kommer ikke inn på det nå heller.
Men det jeg skal komme inn på er:
Problemet med Cloud Functions
GCP Cloud-funksjoner er gode for mange ting. Du gir koden din til Google, gir en liten mengde konfigurasjon - og de lover deg mer eller mindre at de kan håndtere hvor mange påkallinger du kaster på dem. Innenfor rimelighetens grenser, selvfølgelig.
Vi bruker fortsatt Cloud Functions til mange ting, men problemet med å bruke Cloud Functions til å betjene en API er at hver instans kun behandler en enkelt forespørsel om gangen, og å starte en ny instans tar et par sekunder. Å starte en ny instans som dette er kjent som en kaldstart. Du kan gjøre oppvarmingssamtaler for å holde en håndfull tilfeller i gang, men det er som å legge til en annen hest i vognen når du skulle ha brukt bil.
Kaldstartene var uproblematiske i våre tidlige dager som oppstart. I dag bruker bedrifter Unloc å utvikle sine egne nettløsninger — så 4-sekunders topper i responstid forstyrrer virkelig brukeropplevelsen.

Det er to grunner til at det kommer til å gå tregt, nesten uansett:
- Hvis du bruker et API som vårt, er det svært sannsynlig at du trenger data fra mer enn ett endepunkt når du først laster inn nettsiden. Du vil ikke at brukerne dine skal vente, du gjør dem alle parallelt. Dette betyr at minst én av dem kommer til å utløse en kaldstart, og legge til 4-5 sekunder til lastetiden. Uff .
- Ettersom brukeren samhandler med nettsiden og fyrer av API-anrop, er det en ubetydelig sjanse for at man får en kaldstart. Dette vil få siden til å føles litt uresponsiv plutselig. Tross alt er det bare så mye du kan gjøre med spinnere.
En treg API føles dårlig å utvikle mot, men enda viktigere, hvis APIen vår er treg, vil alle produkter som bruker APIen vår være trege.
Hvorfor Cloud Run?
Å gjøre HTTP-forespørsler mot et Cloud Functions API er som å få pølser fra en pølsebod som må åpne hver gang noen kommer for å kjøpe en pølse. Du får kanskje kjøpe fra en åpen bod, eller kanskje du må vente på at fyren med den morsomme barten låser opp skapene, slår på pølseovnen og bollebrødristeren, rister ketchupflasken, åpner coleslawen, bretter ut skiltet og jage bort duene. Ingen ønsker å vente på alt det.
Målet vårt kl Unloc er å kunne servere API-ekvivalenten til pølser superrask, og å kunne gjøre det når som helst – hver gang. Så vi spør, hvorfor ikke bare holde boden åpen?
Det er et Google Cloud-produkt som heter Cloud Run . Cloud Run er som en pølsebod som... Never mind, glem pølsemetaforene . Jeg antar at de fleste av dere er utviklere uansett. Cloud Run er en fullt administrert plattform for å kjøre svært skalerbare containeriserte applikasjoner, som høres akkurat ut som det vi ønsker.
Cloud Run og Cloud-funksjonene er både like og forskjellige. Cloud Run er som Cloud Functions i den forstand at du gir koden din til Google, i dette tilfellet som en forhåndsbygd Container i stedet for en Zip-fil – og Google sørger da for at den kjører og skaleres.
Det er ikke som Cloud Functions, og dette er den viktige delen, i den forstand at den håndterer en hel haug med forespørsler på samme tid, hvor du kan angi et minimum antall forekomster som skal kjøres. Dette betyr at vi alltid har en pølsevei.. Cloud Run Instance åpen og klar til å betjene forespørsler, og at flere vil være tilgjengelig på forespørsel.
Flott!
Så hvordan gikk vi frem for denne overgangen?
Migrasjon

De Unloc backend er skrevet i Typescript, og før migreringen ble alle endepunktene våre betjent av Express-apper distribuert til Cloud Functions. Som med de fleste andre oppgaver i denne skalaen utførte vi migreringen i flere trinn.
Trinn 1: Kjør API-ene i containere
Cloud Run krever at hver tjeneste er en separat beholder. Vi hadde allerede Express-appene konfigurert, så alt vi trengte å gjøre var å skrive den mest grunnleggende Dockerfilen (stort rop til den som skrev de utmerkede dockerfile-dokumentene) som kjører Node og starte den riktige Express-appen.
Vi bruker Firebase til å konfigurere og distribuere funksjonene våre, der Firebase SDK godtar parametere som trigger (http) og lytter (Express-appen). Så for Cloud Run-tjenestene måtte vi legge til separate filer som faktisk starter Express-appene. Du vet, app.listen(port, () => osv... For fleksibilitet starter vi hver Express-app fra skript i filen package.json, kalt av CMD i Dockerfilen.
Under denne prosessen brukte vi Docker Compose til å teste lokalt, Docker til å bygge bilder og gcloud-cli for å distribuere til utviklingsmiljøet vårt.
Trinn 2: Logging

Vi kunne ha fortsatt å bruke console.log-uttalelsene våre og kalt det en dag, men vi er ikke sånn. Nei, vi foretrekker våre Yaks glattbarberte, fortrinnsvis luktende av nykuttet Furu (en annen stokkreferanse. Jeg vet, jeg er ganske flink).
Vi ønsket å gå fra flate tekstlogger som er vanskelige å søke til, til strukturerte JSON-logger som er lettere å søke (du kan finne en nyttig veiledning om forskjellen her . Vår første tanke var «Det må finnes et bibliotek som gjør dette» — og se, det var .
Lang historie kort, vi brukte for mye tid på det. Den har mye flere funksjoner enn vi trengte, og vi må bruke console.log for å beholde executionId i Cloud Functions-loggene uansett, så vi droppet det.
Når vi går fra enkeltforespørsel til multiforespørsel, må vi holde styr på hvilke forespørselslogger hva. For å gjøre dette brukte vi Node Async Hooks til å lagre en sporings-id, satt ved hjelp av Express-mellomvare ved starten av hver innkommende forespørsel, som også lar oss logge nyttige data, som klient-ID.
Veldig nyttig for feilsøking.
Trinn 3: Implementer
Da API-en fortsatt var i sin tidligere fase (under "Få det til å fungere"-fasen nevnt tidligere), distribuerte vi fra kommandolinjen ved å bruke Firebase CLIs deploy-kommando - noe som er greit når du ikke har mange funksjoner for å utplassere. Når du har mange funksjoner å distribuere, overskrider du raskt distribusjonsgrensen. Vi skrev et skript for å distribuere i grupper.
Hovedpoenget er at vi først bruker Object.getOwnPropertyNames() for å få alle de eksporterte medlemmene av indeksfilene våre (sørg for å kun eksportere funksjoner), deretter kjører vi firebase deploy --non-interactive --force --only etterfulgt av en liste over funksjonsnavn fra gjeldende batch.
Etter hvert som utviklerteamet vårt vokste, gikk vi bort fra å kjøre skript i CLI, til å kjøre stort sett de samme skriptene ved å bruke Github Actions . Vi vil mye heller trykke på en knapp og glemme det – enn å kjøre et skript lokalt og vente på at det skal fullføres.
Implementering av Cloud Run-tjenestene består av tre trinn:
- Få banen til Dockerfilen for hver tjeneste
- Bygg et bilde fra den Dockerfilen
- Distribuer bildet
1. Siden vi bruker Docker Compose for lokal utvikling, er dette trinnet allerede stort sett gjort. Vi må bare analysere docker-compose.yml og hente stiene derfra.
2. Dette kan gjøres lokalt på Github Actions-forekomsten ved å bruke docker build . Ikke mer drama der.
3. Dette viste seg å være det vanskeligste trinnet. Ikke spesielt vanskelig, bare den vanskeligste av disse tre. Vi bruker gcloud cli-verktøyet for å distribuere (spesielt gcloud run deploy ). Dette fungerer som en sjarm når jeg kjører fra min lokale maskin siden jeg er autentisert som min GCP-bruker, men krever litt fikling for å komme i gang med Github Actions. Vi opprettet en dedikert tjenestekonto for distribusjon, la til de nødvendige rollene og lagret nøkkelen ved å bruke Github Secrets .
Nå, til det siste trinnet.
Trinn 4: Tinkering
Ingen pølse kommer uten en kostnad; den største kostnaden ved å flytte til Cloud Run er at vi må justere samtidighet, antall CPUer og mengde minne selv. For å sikre at disse tallene var riktige, bestemte vi oss for å gjøre noen belastningstesting.
Ideelt sett ville vi ha brukt JMeter eller noe lignende, men vi kunne ikke få det til å kjøre lokalt. Så vi skrev et lite manus for å gå gjennom de fleste av endepunktene våre, med samtidige forespørsler, oppstartstid, arbeidet.
Vi testet høyere samtidighet, forskjellig antall CPUer, lavere samtidighet osv. Men til tross for å endre minne fra 512 MB til 2 GB endte vi i utgangspunktet opp med standardinnstillingene for alle tjenestene, og et notat om å øke minimumsantallet aktive Cloud Run-forekomster ettersom trafikken vår vokser. Med lasttestingen og justeringene utført, satte vi oss for å teste ytelsen til begge oppsettene side ved side. Skulle det være verdt det?
Ja det var.
Vi gjorde tusenvis av testkjøringer med alle slags forskjellige variabler, og over dette store settet med data så vi at gjennomsnittlig responstid var omtrent 30 % lavere for Cloud Run. 31,62 %, for å være nøyaktig. Stor suksess!
Nedenfor er data fra en kjøring som virkelig viser forskjellen:

Dette er gjennomsnittstallene (y-aksen er responstid i ms) fra en håndfull påkallinger alle sendt parallelt. Du kan sikkert forestille deg hvordan denne forskjellen ville føles for en bruker.
Det er selvfølgelig mye flere detaljer i denne prosessen enn det som er skrevet her, men dette blogginnlegget er allerede mye lengre enn jeg planla, så jeg klipper det her.
Ta kontakt med oss nå
Fyll ut skjemaet for å komme i kontakt med en av våre representanter
Innsendingen din er mottatt!