AWS Lambda Cold Start in C# e...

di Andrea Zani, in AWS,

Visto che nel post precedente avevo utilizzato per la demo una Lambda scritta in C#, in questo post mi dedicherò proprio a loro anche perché ne ho discusso dei pro e contro con qualche amico ultimamente. Non farò sviolinate a favore della Lambda in AWS perché sarebbero inutili, visto che i vantaggi che portano sono innegabili sia a livello prestazionale, sia per l'autoscaling di cui dispongono, sia per la loro economicità. Una volta configurate non c'è bisogno di altro: il cloud di AWS darà le risorse necessarie per il loro utilizzo al momento della richiesta senza dover configurare server o altro. Tutto bello e perfetto dunque?

Lambda, qualche dettaglio

Tralasciando il linguaggio di programmazione utilizzato o la tecnologia scelta, quando viene richiesta una Lambda, un servizio di AWS avvia una microVM specifica per la tecnologia utilizzata e idonea a fare girare il codice, quindi copia il codice della Lambda e lo esegue in modo che possa elaborare la richiesta. microVM sta per virtual micro machine, ed è una tecnologia di virtualizzazione utilizzata da AWS per la Lambda e per i servizi Fargate.

Ognuna di queste istanze è in grado di gestire una sola chiamata alla volta. AWS non invia altre richieste ad una istanza se è ancora in fase di elaborazione. Solo in caso di nessuna istanza libera, il servizio AWS avvia la creazione di un'altra microVM, installa il codice della Lambda e lo avvia assegnandogli la richiesta pendente. Quando non sono presenti richieste, dopo un tempo variabile (la media è dieci minuti), AWS spegne la microVM fino alla prossima richiesta. Questo dettaglio sentenzia una realtà: l'utilizzo del pattern async/await è totalmente inutile.

L'avvio di una microVM e del codice della Lambda non è immediato perché, anche se minimo, c'è sempre un minimo di latenza per il tempo necessario di creare l'istanza e avviarla. Questo lasso di tempo tra l'avvio e la capacità della Lambda di poter rispondere viene definito come Cold Start.

Nel grafico seguente una tentativo di mostrare quanto appena scritto:

La prima chiamata - R1 - crea una nuova istanza per la Lambda (Istanza 1). Il rettangolo ha un blocco ha all'interno una sezione rossa che è il tempo utilizzato per il Cold Start. Quando ancora questa Lambda non ha risposta alla richiesta, viene fatta una seconda richiesta, di conseguenza AWS crea una nuova istanza - Istanza 2 - per R2, che a sua volta, essendo appena avviata ha il problema del Cold Start. La terza richiesta - R3 - ha la prima istanza disponibile perché ha già finito la prima elaborazione, e riesce ad elaborare velocemente la richiesta, cosa che non può la richiesta successiva - R4 - perché, non avendo istanze libere della Lambda, obbliga AWS ad avviare una nuova istanza. Le richieste successive - R5...R8 - trovano sempre un'istanza della Lambda libera e vengono elaborate molto velocemente.

Ora controllo se quanto detto è vero. Provo il prossimo codice in C# e Net 6 che simula una richiesta fittizia con una attesa che simula una elaborazione e ridà come risposta l'Id univoco dell'istanza:

using Amazon.Lambda.Core;

// Assembly attribute to enable the Lambda function's JSON input to be converted into a .NET class.
[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))]

namespace AWSLambdaMongoDbTest1;

public class Function
{
    public string Id = Guid.NewGuid().ToString();
    
    public string FunctionHandler(ILambdaContext context)
    {
        Thread.Sleep(400);
        return Id;
    }
}

Simulando dieci chiamate sequenziali si avrà il risultato aspettato: la stessa istanza ha elaborato tutte le richieste:

"59e9b0f1-063a-4ff0-9aed-bbcd2e38de23"
"59e9b0f1-063a-4ff0-9aed-bbcd2e38de23"
"59e9b0f1-063a-4ff0-9aed-bbcd2e38de23"
"59e9b0f1-063a-4ff0-9aed-bbcd2e38de23"
"59e9b0f1-063a-4ff0-9aed-bbcd2e38de23"
"59e9b0f1-063a-4ff0-9aed-bbcd2e38de23"
"59e9b0f1-063a-4ff0-9aed-bbcd2e38de23"
"59e9b0f1-063a-4ff0-9aed-bbcd2e38de23"
"59e9b0f1-063a-4ff0-9aed-bbcd2e38de23"
"59e9b0f1-063a-4ff0-9aed-bbcd2e38de23"

Ora sovrappongo le dieci chiamate e le eseguo in parallelo:

"59e9b0f1-063a-4ff0-9aed-bbcd2e38de23"
"b42a4be3-f3b7-4b56-b213-d741ec275c00"
"a0010a3e-261b-4546-aa5f-8e6bef9afbd3"
"fcdf78bb-eacc-4654-8608-1c92ec80b25e"
"0a12b6ac-b22c-4175-b25c-cc85f3c34d68"
"b1d02c07-f7b7-4544-9f6e-3f29144c730d"
"cc46fad8-77e0-4234-9b60-af8ed139d026"
"c56e4eba-83cd-4988-ad88-417aa4af396f"
"b604c916-3c79-4f46-8af4-8aad404b9dd8"
"ced76fc4-57cc-4587-9968-6e187c4059a2"

Com'è prevedibile, AWS ha creato altre nove istanze. Ora AWS ha dieci attive istanze che saranno chiuse in automatico in una decina di minuti.

Cold Start

Il tempo utilizzato dai servizi di AWS ad avviare quanto necessario ai servizi perché una istanza della Lambda possa elaborare una richiesta è definita Cold Start. E questo tempo di avvio varia e soprattutto con tecnologie come Net Core (Java per dirne un altro linguaggio) soffrono in modo marcato di questo problema. Fortunatamente dalla prima versione a cui ho messo mano nella Lambda con Net Core (era la versione 2.1) all'attuale (la 6) le cose sono migliorate notevolmente ma il problema è ancora presente anche se più limitato maggiore è la memoria assegnata.

Ora prenderò un esempio un po' più reale e utilizzabile anche nel mondo vero: nel codice utilizzato nella prossima Lambda farò una richiesta ad una tripla istanza di MongoDb in Replica Set e restituirò la sua risposta. Niente di complicato, ma almeno basta, per il momento, con 'sto Hello World!

Il codice è tutto qua:

using Amazon.Lambda.Core;
using System.Diagnostics;

[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))]

namespace AWSLambdaMongoDbTest1;

public class Function
{
    
    public string FunctionHandler(ILambdaContext context)
    {
        var sw = Stopwatch.StartNew();
        Console.WriteLine("Before MongoDb Request");
        var result = MongoDbHelper.Instance.Value.GetDocument("MyCollection", "identifier", "x00001");
        sw.Stop();
        Console.WriteLine($"After MongoDb Request: {sw.ElapsedMilliseconds}ms");
        return result;
    }
}

Con la funzione apposita:

using MongoDB.Bson;
using MongoDB.Driver;

public class MongoDbHelper
{
    private readonly string _connectionString;
    private readonly string _databaseName;

    public static Lazy<MongoDbHelper> Instance = new Lazy<MongoDbHelper>(() => new MongoDbHelper(
        "mongodb+srv://#######:################@cluster0.#####.mongodb.net/?retryWrites=true&w=majority&tlsAllowInvalidCertificates=true",
        "MyDatabase"
        ));

    private MongoDbHelper(string connectionString, string databaseName)
    {
        _connectionString = connectionString;
        _databaseName = databaseName;
    }

    public string GetDocument(string collection, string key, string value)
    {
        var dbClient = new MongoClient(_connectionString);
        IMongoDatabase db = dbClient.GetDatabase(_databaseName);
        var cars = db.GetCollection<BsonDocument>(collection);
        var filter = Builders<BsonDocument>.Filter.Eq(key, value);
        var doc = cars.Find(filter).First();
        return doc.ToString();
    }
}

Attivando nella configurazione della Lambda l'X-Ray per avere dettagli della richiesta, la Lambda qui sopra, configurata con 256MB, mostrerà questo risultato:

Con la durata della chiamata totale, quindi i dettagli. Initialization è solo una parte del Cold Start. Qui un dettaglio di un'altra richiesta:

Il rettangolo rosso è il Cold Start da calcolare per l'avvio dell'istanza della Lambda. La parte iniziale è utilizzata per creare la microVM, e a seconda parte è il tempo dell'avvio del mio codice. Solo dopo questa zona viene elaborata realmente la richiesta. Notare come il tempo di avvio sia molto alto, e per migliorare la situazione è sufficiente assegnare alla Lambda una maggiore quantità di RAM. Ecco alcune personalissime misurazioni del Cold Start con diversi tagli di memoria:

256 512 1024 1576 2048
x86_64 540ms 412ms 380ms 327ms 355ms

In alcuni paesi è possibile anche selezionare la tipologia di microprocessore ARM - sfortunatamente nei data center in Italia non è possibile. Ecco lo stesso test fatto con questa opzione nei data center di Francoforte (anche le misurazione fatte sopra erano state fatte in Germania), da prendere in considerazione anche perché più economico come servizio:

256 512 1024 1576 2048
ARM 391ms 399ms 380ms 325ms 287ms

I dati qui sopra hanno poca importanza perché sono molto altalenanti, inoltre il Cold Start delle Lambda soffrono molto la complessità del codice e delle DLL incluse. Più la Lambda è complessa con moltissime Reference (incluse nello zip), e maggiore sarà il tempo di avvio. In un progetto reale in Net Core 6 abbastanza complesso, con 256MB di ram, ho registrato anche tempi superiori ai dieci secondi per il Cold Start, tempo che ritornava ad essere competitivo solo oltre il giga di memoria assegnato - per mia esperienza personale il quantitativo minimo di RAM da assegnare alle Lambda in Net 6 è 2048MB. In ogni caso è sempre meglio fare prove e test accurati con carichi progressivi di richieste prima di innalzare le Lambda di AWS come soluzione di tutti i problemi.

Soluzione del problema Cold Start

E' possibile risolvere questo problema? Da anni si una il trucco di fare una richiesta fittizia ogni x minuti in modo da tenere almeno una istanza sempre attiva. La cosa funziona, finché le richieste sono poche e il tempo di elaborazione molto veloce. Ma è sufficiente qualche richiesta in parallelo che il problema, ovviamente, si ripresenta.

Una opzione che risolve parzialmente il problema è Provisioned concurrency configurations. Dal pannello di Configurazione -> Concurrency si possono selezionare impostazioni interessanti sia per il Cold Start sia per mantenere l'utilizzo delle risorse dedicato alle Lambda controllato:

Nella parte superiore è possibile selezionare il numero massimo di istanze per la Lambda che AWS può avviare, ne parlerò anche in seguiro, ma interessante è la sezione Provisioned concurrency configuration. Qui è possibile selezionare il numero minimo di istanza della nostra Lambda da tenere sempre attivo (evitando così il Cold Start), e solo in caso il numero di richieste in parallelo dovesse essere superiore avvia nuove istanze. Il giro per attivarlo è un po' macchinoso ma nulla di complicato. Innanzitutto si deve creare andare nel tab Versions della Lambda e cliccando su Publish new version, assegnare un nome alla versione attuale della Lambda. Se tutto è stato fatto correttamente, ora dalla sezione Provisioned concurrency configuration sarà possibile aggiungere una nuova configurazione:

Non tralasciare mai il fattore costo che si può vedere sopra al numero delle istanze create. Cliccato su Save, AWS avvierà in poco meno di un minuto le istanze desiderate che saranno subito disponibili alla richieste.

Nello stesso pannello è possibile assegnare un numero massimo di istanze:

Impostando, come nell'esempio, il valore cinque, AWS non permetterà che venga creato un numero superiore di istanze. E cosa succede se ora riprovassi il testo iniziale con dieci richieste contemporanee?

The remote server returned an error: (500) Internal Server Error.
The remote server returned an error: (500) Internal Server Error.
The remote server returned an error: (500) Internal Server Error.
The remote server returned an error: (500) Internal Server Error.
The remote server returned an error: (500) Internal Server Error.
"d81fd267-9cfb-4066-a357-026b7c7e381e"
"0a12b6ac-b22c-4175-b25c-cc85f3c34d68"
"a0010a3e-261b-4546-aa5f-8e6bef9afbd3"
"cc46fad8-77e0-4234-9b60-af8ed139d026"
"b1d02c07-f7b7-4544-9f6e-3f29144c730d"

Come ci si poteva aspettare ecco il blocco delle richieste oltre a quelle prestabilite - AWS non accoda le richieste ma scarta immediatamente le richieste se non sono disponibili istanze per l'elaborazione.

Un po' di dettagli tecnici

Non è solo Net Core a soffrire del problema Cold Start. Anche altre tecnologie ne soffrono anche se in modo molto ridotto. NodeJs, Go e Python, per esempio, hanno un ridottissimo Cold Start (nei vari test che si possono trovare in rete) riescono a stare tranquillamente sotto i 300ms.

Personalmente ho trovato interessante il funzionamento dietro le quinte delle Lambda. Nella documentazione ufficiale di AWS si trova tutto. E' possibile utilizzare anche i Custom Runtime per fare girare qualsiasi linguaggio di programmazione, ed è interessante, per comprenderne il funzionamento, vedere l'esempio incluso nei tutorial in cui si mostra come vengono gestite le richieste e le risposte alle Lambda con uno script in Bash con il Custom runtime on Amazon Linux 2, che qui mostro semplificato in un unico file (che deve avere il nome bootstrap):

#!/bin/sh

set -euo pipefail

# Processing
while true
do
  HEADERS="$(mktemp)"
  # Get an event. The HTTP request will block until one is received
  EVENT_DATA=$(curl -sS -LD "$HEADERS" -X GET "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/next")

  # Extract request ID by scraping response headers received above
  REQUEST_ID=$(grep -Fi Lambda-Runtime-Aws-Request-Id "$HEADERS" | tr -d '[:space:]' | cut -d: -f2)
  CUSTOM_RESPONSE="Hello World!"

  # Send the response
  curl -X POST "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/$REQUEST_ID/response"  -d "$CUSTOM_RESPONSE"
done

Nel ciclo while true viene fatta una richiesta HTTP GET ad un Endpoint interno di AWS che rimane in attesa fino alla risposta contenente l'Event Data della richiesta. Ora è possibile controllare il suo contenuto, ma io lo ignoro e preparo il messaggio di output "Hello World!" - non mi ero lamentato di questo esempio poco fa? - messaggio che invio in modalità POST all'Endpoint apposito, il quale sarà restituito al richiedente (il REQUEST_ID viene usato per permettere ad AWS di collegare la richiesta asincrona ricevuta con il client e la risposta da inviargli). Alla fine è banale, no?

Nella variabile $HEADERS è presente un path di un file da utilizzare per le richieste, per esempio /tmp/tmp.yCW4gp5MRe. Visualizzando il contenuto di questo file trovo le info della Lambda:

HTTP/1.1 200 OK
Content-Type: application/json
Lambda-Runtime-Aws-Request-Id: f947184d-d35c-4ce9-a98f-563d77f5cce9
Lambda-Runtime-Deadline-Ms: 1654784504908
Lambda-Runtime-Invoked-Function-Arn: arn:aws:lambda:eu-central-1:############:function:custombash
Lambda-Runtime-Trace-Id: Root=1-62a121f5-564bbaa91c4ad0b625124b58;Parent=c40a2fbf3c55c49b;Sampled=1
Date: Thu, 09 Jun 2022 14:21:41 GMT
Content-Length: 56

In EVENT_DATA trovo il contenuto della richiesta. Nel mio esempio ho lasciato l'esempio di default dalla Console di AWS:

{
  "key1": "value1",
  "key2": "value2",
  "key3": "value3"
}

Oltre agli Endpoint per la gestione delle richieste sono pure disponibili i servizi per il Log e l'X-Ray. I runtime dedicati ai vari linguaggi di programmazione non fanno altro che mettere a disposizione in modo più semplice queste richieste HTTP. Fine.

Conclusioni, per ora...

Le banalità spiegate qui - chi conosce e ha lavorato con le Lambda conoscerà quanto scritto sopra - mi porteranno al prossimo post, dove proverò a utilizzare questi Custom Runtime in C++ prendendo come spunto il codice fornito da AWS per provare a replicare quando fatto dal codice in C# e Net 6 - forse.

Risolverò il problema del Cold Start? E' inutile dare false speranze. Il codice sorgente dell'esempio lo inserirò(!?) insieme al codice del prossimo post.

Commenti

Visualizza/aggiungi commenti

| Condividi su: Twitter, Facebook, LinkedIn

Per inserire un commento, devi avere un account.

Fai il login e torna a questa pagina, oppure registrati alla nostra community.

Nella stessa categoria
I più letti del mese