Terug

Your Worst-case Serverless Scenario Deel II: Magie met getallen

Niels van Bree

8 min read

In deze tweede aflevering van de reeks "Your Worst-case Serverless Scenario" leggen we uit hoe een geplande hoeveelheid van 100k tabelrecords veranderde in 56 miljoen records. Dit zal worden uitgelegd aan de hand van enkele pseudo functies van onze code, een kleine case studie en een visualisatie die je zal helpen om het concept beter te begrijpen. Als je het eerste deel (Invocation Hell) van deze serie nog niet hebt gelezen, raden we je aan om dat te doen, aangezien we in dit deel een deel van het besproken materiaal gebruiken.

Hackerman: Hoe 100k veranderde in 56 miljoen

Deze is heel interessant. Ook al veroorzaakte het niet direct veel problemen, het had zeker niet mogen gebeuren. Voordat we ingaan op wat hier fout ging, is het waarschijnlijk goed om wat pseudocode te laten zien van het deel van de functie dat dit veroorzaakte. Er gebeurde hier nog veel meer, maar voor een goed begrip van de problematiek is deze vereenvoudiging van de code voldoende.

Laten we dit even kort doornemen. De hackerMan functie neemt één argument amountOfItemsLeftToWrite, wat staat voor het aantal items dat we nog naar onze tabel moeten schrijven. Vervolgens bereiden we de items voor in het juiste formaat en voegen ze in DynamoDB in met een batchWriteItem operatie en door deze invoegingen op een array van promises te zetten. Daarna wordt amountOfItemsToWriteNow afgetrokken van amountOfItemsLeftToWrite, dat wordt doorgegeven aan de volgende aanroep als er nog items over zijn om te schrijven. Deze aanroep wordt ook op deze array van beloften gezet en zal uiteindelijk worden afgewacht. Als er ergens een fout optreedt, hebben we wat generieke foutafhandeling op zijn plaats en moet de functie (keten) hier stoppen.

Allereerst, er is 1 ding verkeerd waarvan we niet in details zullen treden, aangezien het buiten de scope van het verhaal valt, maar we zouden het wel willen benoemen. Namelijk: als een error voorkomt, dat de ketting zal stoppen. Echter, aangezien je geen actie of iets dergelijks ziet op de items die al zijn geschreven in de database. Wat uiteraard niet goed is om toe te staan aangezien we nu zitten met een functie die deels zijn doel vervuld en er geen protocol is om ons te vertellen hoe we zullen moeten omgaan met het deel dat is gelukt.

Belangrijke informatie

Laten we nu eens kijken waarom 100k items in bijna 56 miljoen items veranderden. Om dit te begrijpen, moet je hier een paar belangrijke componenten begrijpen: batchWriteItem & DynamoDB, asynchrone code en asynchrone Lambda-uitvoeringen. Dat laatste hebben we in de vorige paragraaf al besproken: als er een fout optreedt, of dat nu een time-out of iets anders is, probeert Lambda de functie tot 2 keer opnieuw. Wat de asynchrone code in onze functie betreft, hebben we in principe het invoegen en het aanroepen van de nieuwe functie tegelijk uitgevoerd, wat belangrijk is, want dat betekent dat zelfs als er een fout optreedt tijdens het invoegen van items, er al een nieuwe functie is aangeroepen die niets van die fout weet. Tenslotte is het belangrijk op te merken dat DynamoDB's batchWriteItem een fout kan gooien, die in sommige gevallen een timeout fout kan zijn. Een time-out fout is wat er in dit scenario gebeurde, wat betekent dat de Lambda-uitvoeringscontexten langer dan gebruikelijk bezet waren, waardoor de gelijktijdige Lambda-aanroepingslimiet die in de vorige sectie is beschreven, werd verergerd. Bovendien betekent deze time-out fout, of een ander soort fout op batchWriteItem, niet dat geen van de items naar de tabel is geschreven. Dat betekent dat in ons geval 0 tot 24 items door die functie naar de database zijn geschreven als er een fout op de batchWriteItem operatie is opgetreden.

Een kleine casestudy

Als je al het bovenstaande hebt gelezen en nog steeds kunt volgen, heb je misschien al een vermoeden van wat er aan de hand is. Zo niet, maak je geen zorgen, we zullen ons best doen om te illustreren wat er op een kleinere schaal gebeurt. Stel je een keten van 4 identieke functies voor, elk met een doel om 25 items naar de origin table te schrijven en met het gezamenlijke doel om 100 items naar de origin table te schrijven. Als alles goed gaat en er geen fouten worden gegooid, is dit doel bereikt en is iedereen tevreden. Maar wat als er een fout optreedt bij de tweede aanroep tijdens het uitvoeren van de batchWriteItem operatie? Op dat moment zijn er 25 items succesvol naar de origin tabel geschreven. En dat niet alleen; de derde invocatie zal al zijn aangeroepen, omdat deze code asynchroon werd uitgevoerd samen met de batchWriteItem operatie die pas later een fout veroorzaakte. Als we aannemen dat deze derde en vierde aanroep geen foutmelding geven, betekent dit dat ze nog eens 50 items naar de origin tabel schrijven bovenop de eerste 25, wat het totaal op 75 items brengt. Dus, wat gebeurde er precies met de tweede aanroep? Zoals we hierboven al zeiden, zouden er tussen de 0 en 24 items naar de origin table geschreven zijn voordat er een foutmelding kwam. Om het eenvoudig te houden, nemen we aan dat het 10 items heeft geschreven voordat de fout werd gegooid, wat het totale aantal op 85 items brengt. Wat er nu gebeurt is cruciaal voor het eindigen met meer in plaats van minder items: omdat de eerste functie de tweede functie aanriep en aan deze aanroep de optieparameter Event InvocationType was gekoppeld, betekent dit dat Lambda deze functie automatisch tot 2 keer opnieuw probeert.

Okay, het totaal aantal items is dus al 85, maar nu wordt de tweede aanroep nog een keer herhaald. Nogmaals, voor de eenvoud veronderstellen we dat vanaf nu alles zal gaan zoals gepland en er geen verdere fouten zullen worden gegooid. Deze functie schrijft nog eens 25 items naar de origin tabel en zal nu de volgende functie aanroepen met de parameter amountOfItemsLeftToWrite ingesteld op 50, omdat deze functie zelf de parameter met de waarde 75 heeft gekregen. Omdat deze functie geen logica heeft om retries af te handelen, zal hij niets weten over zijn eerdere mislukking en gewoon doorgaan met de parameter die hij oorspronkelijk kreeg. De volgende functie ontvangt de waarde 50, schrijft 25 items en roept de volgende functie op, die ook 25 items schrijft en concludeert dat de keten voorbij is, omdat amountOfItemsLeftToWrite de waarde 0 heeft bereikt. Dit brengt het totale aantal items nu op 85 + 3*25 = 160.

Dus zelfs met een zeer kleine keten van slechts 4 functies in de happy flow en 1 fout tijdens de tweede aanroep, kunnen we eindigen met 60 items meer, wat een toename van 60% is! Natuurlijk hangt de echte toename altijd af van waar in de keten de fouten optreden, hoeveel fouten er optreden, hoeveel herhalingen zijn uitgevoerd door Lambda, hoeveel items de batchWriteItem heeft ingevoegd voor de fout, de batchgrootte, de ketengrootte en wanneer je de keten geforceerd afsluit. In ons geval werd ~ 100k ~ 56 miljoen, dat is een schokkende toename van 55900%! Grappig genoeg had de toename nog hoger kunnen zijn als er niet zoiets was als de Lambda concurrent execution limit, want die heeft het aantal gelijktijdige executies van deze functie ook drastisch vertraagd. Zonder die limiet zouden we onze enorme fout ook veel later hebben opgemerkt, met als gevolg een langer lopende keten.

Hoe deze puinhoop zo snel mogelijk te herontwerpen?

Nu je een duidelijker beeld hebt van wat er precies fout ging en welke bouwstenen verantwoordelijk waren voor deze problemen, laten we eens kijken hoe we dit alles kunnen herstellen op een manier dat we niet van nul moeten beginnen. Merk op dat dit nog steeds niet de meest wenselijke manier is om met dergelijke functionaliteit om te gaan, maar het laat zien hoe je de meeste van de grotere problemen kunt voorkomen met slechts een paar aanpassingen.

  1. Het wegwerken van de asynchrone code. Je doet iets asynchroon omdat het uitvoeringstijd bespaart en het is mogelijk als alle betrokken delen de resultaten van de anderen niet nodig hebben. Het is echter duidelijk dat het functie-aanroepende deel het resultaat van de invoeging moet weten, omdat we niet met een andere functie verder willen gaan als deze fout gaat. Als we dat wel doen, zal de parameter amountOfItemsLeftToWrite ook foutief zijn. De eerste stap is dus om te wachten op het resultaat van insertItemsIntoDynamoDB(items) voordat we iets aftrekken van amountOfItemsLeftToWrite en een andere functie aanroepen.
  2. Beter gebruikmaken van Lambda's uitvoeringstijd. Lambda kan tot 15 minuten per aanroep lopen. Als de gebruiker niet op het antwoord hoeft te wachten (in dit geval is dat niet zo), waarom zou je dan meer afzonderlijke containers laten draaien dan nodig is? Lambda's context heeft een ingebouwde functie die je vertelt hoeveel tijd er nog rest voordat hij wordt beëindigd. Door goed gebruik te maken van deze functie, kun je het proces zo lang mogelijk laten draaien voordat je de volgende functie aanroept. Dit zal het aantal (gelijktijdige) Lambda's dat je aanroept drastisch verminderen.
  3. Bouw een sleep functie in om te voorkomen dat je wild gaat. Dit hangt er vanaf of je het je kunt veroorloven om meer tijd te nemen. In onze use case was het niet nodig om alle items in de tabel meteen of zo snel mogelijk in te voegen, maar kan het net zo goed een paar uur of zelfs een halve dag duren. Dus om DynamoDB throttling errors te voorkomen omdat auto scaling niet snel genoeg kan opschalen, kun je ook wat meer tijd nemen tussen elke (batch) insert.

De nieuwe functie zou er in pseudocode ongeveer zo uitzien als hieronder.

We hopen dat alles tot zover duidelijk is. In het derde en laatste deel van deze serie zullen we het hebben over een 'onzichtbaar' DynamoDB proces dat tabellen doet disfunctioneren en ronden we dit verhaal af.

Visuals in deze post zijn gemaakt door David Kempenaar.