Dynamisk schemalägging av AWS Lambdas

I den här artikeln går jag igenom hur du kan åstadkomma dynamisk schemaläggning av AWS Lambdas med hjälp av Step Functions. Jag kommer att demonstrera med kod och använda mig av AWS CDK, ett ramverk för att definiera AWS-infrastruktur. Det är inte ett krav att kunna CDK för att förstå, men bra dokumentation finns att tillgå om du vill göra en grunduppsättning och exekvera koden själv.

Jag har en hobbyapplikation där jag hämtar sportresultat för analys i realtid. Det är en Spring Boot-app som jag är i färd med att migrera till serverless. En viktig funktion i appen är att bestämma när hämtning av resultat ska ske. Under pågående match vill jag hämta med täta intervaller, när inga matcher spelas ska frekvensen gå ner avsevärt.

I Spring Boot-varianten använder jag mig av Quartz Scheduler. Fördelen här är att jag kan styra helt dynamiskt när hämtning ska ske -- enklare varianter som fixed rate (var tionde minut till exempel) eller cron ger inte tillräckligt bra kontroll. Jag vill ju kunna dra ner poll-frekvensen direkt när en match tar slut, hantera flyttad avspark, övertid och så vidare. Därför utvärderar jag alltid nuläget för att schemalägga nästa exekvering -- då får jag den precision jag vill ha.

Det var här jag stötte på patrull i migrationen till AWS. Funktionaliteten för att hämta resultat är en snabb och trivial operation, en lambda passar perfekt här. Men, när jag sökte information om hur man åstadkommer dynamisk schemaläggning av lambdas hittade jag inget enkelt svar (fixed rate och cron finns, men det räcker alltså inte). Jag diskuterade frågan med mina kollegor och vi landade till slut i Step Functions.

Step Functions låter dig skapa en state machine där olika steg exekveras enligt regler du satt upp. I mitt användningsfall vill jag ha en loop som kör exekvera lambda -> vänta önskad tid -> exekvera lambda i all oändlighet (nåja, en state machine kan köras i max ett år, men i princip).

Det här ska jag nu demonstrera med en lambda vars enda uppgift är att svara med en slumpmässig delay. Den använder vi sedan i vår state machine för att sätta väntetiden innan nästa exekvering av samma lambda.

Vi börjar med att initiera en tom CDK-app. Jag skapar mappen CygniExample och exekverar där

cdk init app --language typescript

Nästa steg blir att skriva en funktion som returnerar en godtycklig delay, ett nummer mellan 10 och 20. I roten (jämte bin och lib) skapar vi mappen lambda och lägger där delay.js med följande innehåll:

exports.handler = async function() {
    let delay = Math.floor(Math.random() * 11) + 10; 
    return { delay: delay };
};

Varje anrop ska rendera i {delay: n} där n är det antal sekunder vi tänker oss att vår state machine ska vänta innan nästa exekvering.

I den genererade cygni_example-stack.ts fyller vi sedan på så att det ser ut såhär:

import * as cdk from '@aws-cdk/core';
import * as lambda from '@aws-cdk/aws-lambda';
import * as sfn from '@aws-cdk/aws-stepfunctions';
import * as tasks from '@aws-cdk/aws-stepfunctions-tasks';

export class CygniExampleStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
    
    // The code that defines your stack goes here
    
    const delayFn = new lambda.Function(this, 'HelloHandler', {
      runtime: lambda.Runtime.NODEJS_14_X,
      code: lambda.Code.fromAsset('lambda'),
      handler: 'delay.handler'
    });

    const lambdaTask = new tasks.lambdaInvoke(this, "lambdaInvoke", {
      lambdaFunction: delayFn,
      outputPath: "$.Payload"
    })

    const waitState = new sfn.Wait(this, "Wait", {
      time: sfn.WaitTime.secondsPath("$.delay")
    })

    const choiceState = new sfn.Choice(this, "LoopOrDie")
        .when(sfn.Condition.isNotNull("$.delay"), waitState.next(lambdaTask))
        .otherwise(new sfn.Fail(this,"No Delay found. Fail!"))

    new sfn.StateMachine(this, "StateMachine", {
      definition : lambdaTask.next(choiceState)
    })

  }
}

Det var mycket på en gång. Låt oss gå igenom en sak i taget.

const delayFn = new lambda.Function(this, 'HelloHandler', {
  runtime: lambda.Runtime.NODEJS_14_X,
  code: lambda.Code.fromAsset('lambda'),
  handler: 'delay.handler'
});

Här definierar vi vår lambda på CDK-vis. Vi väljer runtime, pekar ut var koden ligger (i mappen lambda) och vilken funktion som ska exekveras.

const lambdaTask = new tasks.lambdaInvoke(this, "lambdaInvoke", {
  lambdaFunction: delayFn,
  outputPath: "$.Payload"
})

Lambdan i sig gör inte mycket nytta i en state machine om den inte utförs i ett state. Vi skapar därför en Task som går ut på att exekvera vår lambda (delayFn).

Lägg också märke till outputPath. Den tar ett JsonPath-uttryck som filtrerar vad som ska skickas vidare till nästa steg. I det här fallet säger vi att vi bara är intresserade av Payload. Payload är nämligen den del i lambda-responsen som innehåller funktionens returvärde, alltså {delay: n}. Eftersom det är det enda vi bryr oss om, filtrerar vi bort HTTP-headers och liknande som annars skulle hänga med även till nästa steg.

Det finns ett par olika sätt att processa den data som skickas in till och ut från ett state, det går att läsa mer om här och det finns en bra simulator här. Prova!

const waitState = new sfn.Wait(this, "Wait", {
  time: sfn.WaitTime.secondsPath("$.delay")
})

Nu har vi kommit till definitionen av ett Wait state, vars enda uppgift är att fördröja nästa steg en angiven tid. secondsPath() är det mest relevanta att titta på. Precis som med outputPath rör det sig om ett JsonPath-uttryck. Här slår vi fast att vi vill använda värdet av delay. Det är också möjligt att ange exakt timestamp iställlet för relativ väntetid, då gäller metoden timestampPath().

const choiceState = new sfn.Choice(this, "LoopOrDie")
    .when(sfn.Condition.isNotNull("$.delay"), waitState.next(lambdaTask))
    .otherwise(new sfn.Fail(this,"No Delay found. Fail!"))

Den här kanske känns lurig, men häng med så ska jag försöka förklara. Vi har vår lambda task, vi har vår wait task och vi behöver konstruera en loop av dem. Hela poängen med vår övning är ju lambda -> wait -> lambda -> wait -> lambda osv.

Problemet är att en state machine måste ha ett end state. Vi kan alltså inte bara ha en loop, utan vi måste ha minst en möjlig väg som resulterar i ett terminal state.

Därför lägger vi in ett Choice state, där loopen bara exekveras om delay är satt. Om inte delay är satt, skickas vi till ett Fail state. Den observante inser snabbt att delay alltid är satt, följaktligen är otherwise()-utvägen bara en chimär. Men den fyller en viktig funktion -- den ger oss en giltig state machine.

Fast vänta nu, vadå loopen exekveras? Den kör ju bara waitState och sen lambdaTask, vad får du loop ifrån?

new sfn.StateMachine(this, "StateMachine", {
  definition : lambdaTask.next(choiceState)
})

Jo! Till sist så skapar vi en state machine med en definition där allt knyts ihop. Definitionen blir kortfattad -- "exekvera lambdaTask och sen choiceState".

Nu är loopen på plats. lambdaTask -> choiceState -> waitState -> lambdaTask kommer oavbrutet att snurra. I teorin kan vi hamna i Fail (om vi justerar vår lambda), men i praktiken kommer vi att loopa.

Hänger du med? lambdaTask är första steget i vår state machine, och vi definierar att choiceState ska köras därefter. choiceState kommer i sin tur att lämna över till waitState innan lambdaTask körs igen och allt börjar om. Spolier: Du kommer snart att få se det grafiskt, mycket tydligare.

Men först, deploy. Vi kör cdk synth och sen cdk deploy. CDK skapar då upp allt vi har definierat, det tar en liten stund.

I Step Functions Management console bör en state machine finnas med när cdk deploy tuggat färdigt. Klickar vi på den och sedan tabben Definition finns en grafisk representation som visar att vi fått precis vad vi vill ha.

Här finns också samma state machine uttryckt i Amazon States Language, vilket är hur en state machine i grunden definieras.

Nu går vi till tabben Executions och väljer där Start execution för att se att allt fungerar som väntat. Om du provar på egen hand, glöm inte att också stänga av (Stop Execution), annars har du en state machine som kommer att köras i ett år...

Efter (och under) exekvering kan vi se en diger eventhistorik med alla olika stateförändringar.

För att bevisa att delay-tekniken fungerar tittar vi i Elapsed Time (ms)-kolumnen och tar fram tiden mellan WaitStateEntered och WaitStateExited. Då ser vi att i första fallet är det 14 sek (14620ms - 620ms, steg 9 och 10) och i nästa fall 10 sek (25046ms - 15046ms, steg 18 och 19). Låt oss kontrollera att det faktiskt var 14 resp 10 sek som vår lambda-funktion returnerade i de fallen.

Nybörjartur.

Jo, det verkar lira!

Värt att notera är att AWS inte debiterar för väntetiden, däremot för state transitions (samt exekveringen av lambdas). Kostnaden är dock ganska blygsam -- för en dollar får du 40.000 state transitions. Se följande länk för mer information.

Glöm inte cdk destroy om du vill ta bort de resurser som skapats.