AWS Lambda Custom Rutime in C++

di Andrea Zani, in AWS,

Ogni tanto è bello sperimentare. Dopo il post precedente dove avevo scritto qualche annotazione sull'uso dei Custom Runtine in AWS per le Lambda, in questo post mi dedicherò ad un test reale. Premetto che non sono un mago in C++ però non nego che come linguaggio mi è sempre piaciuto - in questo mio blog avevo scritto in passato alcuni post dedicati a questo linguaggio.

Verso la fine dello scorso post avevo mostrato l'esempio banale del funzionamento di una Lambda con uno script Bash, preso dalla documentazione di AWS e semplificato. Da quell'esempio si evince come il funzionamento interno delle Lambda non è nulla di complesso, perché espone alla microVM creata dove sarà eseguito il codice, un Endpoint HTTP per fornire alla stessa i dettagli della richiesta con una chiamata GET e la modalità POST dove inviare la risposta.

Le librerie fornite da AWS per le varie tecnologie semplificano queste transazioni richiedendo all'Endpoint informazioni sulla richiesta che saranno poi tradotte in un formato compatibile al linguaggio prescelto, quindi prederà la risposta in output del nostro codice e la tradurrà nel formato compatibile per inviarlo in modalità POST all'Endpoint apposito di AWS. Questo mostra come in verità si possa creare il tutto anche da soli: nulla vieta di scrivere da zero il codice della Lambda che esegue tutte queste operazioni. Il problema principale è che tale codice dovrà poi girare nella microVM che utilizza una versione di Linux custom - Custom runtime on Amazon Linux 2 - con tutte le problematiche annesse: versioni delle librerie installate o la loro mancanza, versione del Kernel, etc... Ah, non è possibile farlo da Windows.

AWS mette a disposizione pubblicamente il codice sorgente delle librerie con cui poi poter interagire con il Cloud, inoltre mette a disposizione un altro repository con un esempio facile per la creazione di una Lambda in C++. Il problema ora è un altro per chi non è proprio a suo agio con C o C++: tutte queste librerie sono da compilare a mano.

Compilare con quale distribuzione Linux?

La prima versione che ho usato con continuità del mondo Linux fu la distribuzione Fedora di Redhat. Solo dopo la nascita della versione Ubuntu dalla Debian mi spostai su questa distribuzione (con varie escursioni verso altre distribuzioni come la OpenSuse e la Arch). Ad un certo punto, forse dovuto all'età, ho smesso di provare le distribuzioni che mi sembravano poter essere interessanti e mi sono legato alla sicurezza delle versioni LTS di Ubuntu (più precisamente la versione Mate), perché il mio tempo lo volevo usare per qualcos'altro che provare tutte le decine di versioni di Linux più o meno utili che si presentano come rivoluzionarie.

Solo ultimamente sono migrato alla versione LTS 22.04 e proprio poco dopo mi sono messo a fare le prime prove con l'SDK di AWS e le Lambda in C++. Che tempismo!!! Infatti questa versione di Ubuntu non è compatibile e non è possibile compilare il codice sorgente dell'SDK di AWS per degli aggiornamenti alle librerie di sistema. Personalmente ci ho perso poco tempo per capire se si poteva risolvere: in rete si trovavano suggerimenti che non funzionavano e così ho desistito. Quindi il primo suggerimento se si vuole fare degli esperimenti in merito è cercare una distribuzione Linux compatibile. Io ho risolto con la versione 20.04 LTS di Ubuntu virtualizzata - e pensare che l'avevo aggiornata poco prima!

AWS SDK

Il primo passo è compilare l'SDK di AWS. Partendo dalla macchina appena configurata è necessario installare il minimo indispensabile con:

sudo apt install build-essential

E in seguito:

sudo apt install cmake
sudo apt install zip
sudo apt install libcurl4-openssl-dev
sudo apt install libssl-dev
sudo apt install cmake
sudo apt-get install zlib1g-dev

Ora è il momenti di scaricare il codice sorgente, con questo comando:

git clone --recurse-submodules https://github.com/aws/aws-sdk-cpp

Ora sarà possibile compilare:

cd aws-sdk-cpp
mkdir build
cd build
cmake .. -DBUILD_ONLY="core" \
  -DCMAKE_BUILD_TYPE=Release \
  -DBUILD_SHARED_LIBS=OFF \
  -DENABLE_UNITY_BUILD=ON \
  -DCUSTOM_MEMORY_MANAGEMENT=OFF \
  -DENABLE_UNITY_BUILD=ON
make
make install

Il comando make sarà quello più impegnativo - sulla mia macchina virtuale ha concluso la compilazione in circa cinque minuti. In ogni caso verificare sempre eventuali errori emessi dai comandi qui sopra: la mancanza di qualche libreria è sempre fattibile, ma in ogni caso verificare sempre eventuali incompatibilità.

Con il comando make install si installeranno le librerie compilate in /usr/lib. Se tutto ha funzionato correttamente si passa al secondo passo. Info sono presenti nel repository ufficiale.

AWS C++ Lambda Runtime

AWS mette a disposizione un repository con la Lambda Runtime che utilizzerò per il mio esempio. Anche questo è da compilare. Come al solito si inizia con il codice sorgente (da scaricare in un'altra directory):

git clone https://github.com/awslabs/aws-lambda-cpp-runtime.git

Ora i comandi per la compilazione:

cd aws-lambda-cpp-runtime
mkdir build
cd build
cmake .. -DCMAKE_BUILD_TYPE=Release \
  -DBUILD_SHARED_LIBS=OFF
make
make install

Rimando a questo url per maggiori dettagli. Consiglio, per verificare che funzioni tutto la prima volta, di aggiungere una directory con il codice sorgente presente nel repository per la Lambda e seguire le istruzioni per la compilazione. Nel dettaglio è sufficiente creare questi due file. Il primo CMakeLists.txt usato per la compilazione con CMake:

cmake_minimum_required(VERSION 3.9)
set(CMAKE_CXX_STANDARD 11)
project(demo LANGUAGES CXX)
find_package(aws-lambda-runtime)
add_executable(${PROJECT_NAME} "main.cpp")
target_link_libraries(${PROJECT_NAME} PRIVATE AWS::aws-lambda-runtime)
target_compile_features(${PROJECT_NAME} PRIVATE "cxx_std_11")
target_compile_options(${PROJECT_NAME} PRIVATE "-Wall" "-Wextra")

# this line creates a target that packages your binary and zips it up
aws_lambda_package_target(${PROJECT_NAME})

E il file main.cpp con il codice sorgente vero e proprio:

#include <aws/lambda-runtime/runtime.h>

using namespace aws::lambda_runtime;

static invocation_response my_handler(invocation_request const& req)
{
    if (req.payload.length() > 42) {
        return invocation_response::failure("error message here"/*error_message*/,
                                            "error type here" /*error_type*/);
    }

    return invocation_response::success("json payload here" /*payload*/,
                                        "application/json" /*MIME type*/);
}

int main()
{
    run_handler(my_handler);
    return 0;
}

Passo successivo, creare in questa directory la directory build e lanciare, in sequenza, questi comandi:

$ mkdir build
$ cd build
$ cmake .. -DCMAKE_BUILD_TYPE=Release
$ make
$ make aws-lambda-package-demo

Se tutto ha funzionato si troverà nella directory un file ZIP contenente l'eseguibile da inviare ad AWS. Il codice è semplice: ricevuta la richiesta che non deve avere una dimensione in byte superiore a 42, invia un messaggio come risposta. Per provare questa Lambda in AWS è necessario creare una Role apposita così come le normali Lambda. Si può fare il tutto dalla Console di AWS oppure creando la role con un editor di testi con il nome trust-policy.json:

{
 "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": ["lambda.amazonaws.com"]
      },
      "Action": "sts:AssumeRole"
    }
  ]
}

Come da documentazione prima linkata, si deve configurare la Role per la Lambda con questi comandi:

$ aws iam create-role --role-name lambda-demo --assume-role-policy-document file://trust-policy.json
$ aws iam attach-role-policy --role-name lambda-demo --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
    

E' possibile creare la Lambda sempre da Console oppure con questo comando da terminale:

$ aws lambda create-function --function-name demo \
--role arn:aws:iam::############:role/lambda-demo  \
--runtime provided --timeout 15 --memory-size 128 \
--handler demo --zip-file fileb://demo.zip

Ora è possibile andare nella Console Web di AWS e testare che funzioni richiamando la Lambda in modalità Test. Ora mi tengo stretto il codice qui usato di esempio perché sarà usato come partenza per il mio codice.

MongoDb in C++

Raggiunto l'obbiettivo di vedere la Lambda scritta in C++ funzionare correttamente si è solo a metà strada. Il mio obbiettivo in questo post era ripetere quando avevo fatto con la Lambda scritta in C# e Net6: eseguire una query ad una triplice istanza di MongoDb in Replica set e visualizzare il risultato.

Quanto fatto sopra per l'SDK lo faccio ora con le librerie per l'accesso a MongoDb. In questo caso non è solo una la libreria da compilare, ma addirittura due. Ma niente di complicato, anzi, è stato più facile che l'SDK di AWS visto che ho potuto farlo con qualsiasi versione di Ubuntu. Il punto di partenza è questa pagina. Seguendo pedissequamente quando spiegato qui per la compilazione della versione per Linux inizio compilando la libreria libmongoc che è usata come dipendenza della libreria principale che dovrò usare.

Controllo di avere il tutto con il comando consigliato:

$ sudo apt-get install cmake libssl-dev libsasl2-dev

Scarico il codice sorgente in una nuova directory:

$ wget https://github.com/mongodb/mongo-c-driver/releases/download/1.21.2/mongo-c-driver-1.21.2.tar.gz

Quindi eseguo le operazioni necessarie per preparare la compilazione:

$ tar xzf mongo-c-driver-1.21.2.tar.gz
$ cd mongo-c-driver-1.21.2
$ mkdir cmake-build
$ cd cmake-build
$ cmake -DCMAKE_BUILD_TYPE=Release \
    -DENABLE_AUTOMATIC_INIT_AND_CLEANUP=OFF ..

Se non si ricevono errori, ecco i classici comandi per compilare e installare le librerie sulla macchina:

$ cmake --build .
$ sudo cmake --build . --target install

Primo passo completato. Ora è possibile compilare la libreria mongocxx (libmongoc è una dipendenza necessaria per la compilazione). In una nuova directory scarico il codice:

curl -OL https://github.com/mongodb/mongo-cxx-driver/releases/download/r3.6.7/mongo-cxx-driver-r3.6.7.tar.gz

E con i prossimi due comandi decomprimo l'archivio e vado nella directory corretta per poter compilare:

tar -xzf mongo-cxx-driver-r3.6.7.tar.gz
cd mongo-cxx-driver-r3.6.7/build

Inizio la configurazione con cmake:

cmake .. \
    -DCMAKE_BUILD_TYPE=Release \
    -DCMAKE_INSTALL_PREFIX=/usr/local

Quindi compilo e installo anche questa libreria con i suoi file per il supporto alla compilazione (anche questa operazione necessita di qualche minuto):

sudo cmake --build . --target EP_mnmlstc_core
cmake --build .
sudo cmake --build . --target install

Ora sono pronto per il mio esempio - era ora.

La mia Lambda in C++

Riprendendo l'esempio della Lambda, ecco il mio codice nel file main.cpp:

#include <iostream>
#include <cassert>
#include <aws/lambda-runtime/runtime.h>
#include <aws/core/utils/json/JsonSerializer.h>
#include <aws/core/utils/memory/stl/SimpleStringStream.h>
#include "mongoDbHelper.h"

using namespace aws::lambda_runtime;
using namespace Aws::Utils::Json;

static invocation_response my_handler(invocation_request const& request)
{
    [[maybe_unused]] JsonValue json_request(request.payload);

    try {
        const auto message = MongoDbHelper::Instance().FindOne("MyCollection", "identifier", "x00001");
        if (message) {
            JsonValue response{*message};
            return invocation_response::success(response.View().WriteCompact(), "application/json");
        }
        else {
            return invocation_response::failure("Requesto to MongoDb", "NotFound");
        }
    }
    catch(const std::exception& e) {
        return invocation_response::failure("Generic error", e.what());
    }
}

int main() {
    run_handler(my_handler);
    return 0;
}

Il codice modificato è nella funzione invocation_response. Questa volta del parametro di input - json_request -non ne faccio nulla e richiamo l'istanza di una mia classe che esegue effettivamente la richiesta a MongoDb. Questa funzione ritorna un oggetto di tipo std::optional che permette di avere come contenuto sia la risposta (una stringa) o un valore nullo. In caso sia presente la stringa la utilizzo come risposta della Lambda in formato JSON, altrimenti ritorno un messaggio di errore. Tutto questo codice è all'interno del blocco try..catch per poter ritornare una risposta valida anche in caso di problemi di connessione al database.

Eviterò di postare anche l'header file della classe MongoDbHelper, che ha questo contenuto:

#include "mongoDbHelper.h"

MongoDbHelper& MongoDbHelper::Instance() {
    static MongoDbHelper _instance{
        Helper::GetEnvironmentVariable("MongoDbConnectionString", "mongodb+srv://#######:################@cluster0.#####.mongodb.net/?retryWrites=true&w=majority&tlsAllowInvalidCertificates=true"),
        Helper::GetEnvironmentVariable("MongoDbDatabase", "MyDatabase")
    };

    return _instance;
}

MongoDbHelper::MongoDbHelper(const std::string& connectionString, const std::string& database)
    : _database(database) {
    _inst = std::make_unique<mongocxx::instance>();
    _uri  = std::make_unique<mongocxx::uri>(connectionString);
    _pool = std::make_unique<mongocxx::pool>(*_uri.get());
}

std::optional<std::string> MongoDbHelper::FindOne(
    const std::string& collection,
    const std::string& key,
    const std::string& value) const
{

    auto conn = _pool.get()->acquire();
    auto db = (*conn)[_database];
    auto coll = db[collection];

    auto cursor1 = coll.find_one({
        bsoncxx::builder::stream::document{}
        << key << value
        << bsoncxx::builder::stream::finalize
    });

    if (cursor1) {
        std::string returnString{bsoncxx::to_json(cursor1.value())};
        return {returnString};
    }
    else {
        return std::nullopt; 
    }
}

La funzione FindOne esegue effettivamente le richiesta utilizzando poi l'oggetto std::optional in caso sia presente la risposta o meno. La grande differenza nel codice, se confrontata con la versione in C#, è nella configurazione della connection pool per MongoDb che ho dovuto fare io da codice mentre nella versione in C# è attiva di default:

auto conn = _pool.get()->acquire();

Il vantaggio in termini di prestazioni è enorme, si passa dai 300ms a pochissimi millesimi di apertura per le successive richieste di connessione.

Un altro grande cambiamento è nel file CMakeLists.txt. Qui ho dovuto inserire le librerie aggiuntive per poter compilare correttamente il mio codice con i relativi path dei file da includere:

cmake_minimum_required(VERSION 3.12) 
set(CMAKE_CXX_STANDARD 20) 
project(demo LANGUAGES CXX) 
find_package(aws-lambda-runtime) 
find_package(AWSSDK COMPONENTS core) 
find_package(mongocxx REQUIRED) 
find_package(bsoncxx REQUIRED) 

include_directories(${LIBMONGOCXX_INCLUDE_DIR}) 
include_directories(${LIBBSONCXX_INCLUDE_DIR}) 

add_executable(${PROJECT_NAME} "main.cpp" "helper.cpp" "mongoDbHelper.cpp") 
target_link_libraries(${PROJECT_NAME} PRIVATE AWS::aws-lambda-runtime ${AWSSDK_LINK_LIBRARIES}) 
target_link_libraries(${PROJECT_NAME} PRIVATE mongo::bsoncxx_shared) 
target_link_libraries(${PROJECT_NAME} PRIVATE mongo::mongocxx_shared) 
 
target_compile_features(${PROJECT_NAME} PRIVATE "cxx_std_20") 
target_compile_options(${PROJECT_NAME} PRIVATE "-Wall" "-Wextra" "-O3" "-mavx2") 

aws_lambda_package_target(${PROJECT_NAME})

Ho modificato anche la versione di C++ da utilizzare e aggiunto qualche opzione per la compilazione. Arrivati a questo punto, è il momento di controllare che la compilazione funzioni. Nella directory dov'è presente il codice creo una nuova directory build da cui lancerò i comandi:

mkdir build
cd build

Quindi, come per la Lambda di esempio vista prima:

cmake .. -DCMAKE_BUILD_TYPE=Release
make
make aws-lambda-package-demo

Verificato che non ci siano errori si troverò infine un nuovo file zip con il nome demo.zip. Prima di caricarlo in AWS una veloce occhiata al suo contenuto:

Come scritto anche nel post precedente tutto parte dal file bootstrap. Dovrebbe essere un Bash file, e aperto con un text editor:

#!/bin/bash
set -euo pipefail
export AWS_EXECUTION_ENV=lambda-cpp
exec $LAMBDA_TASK_ROOT/lib/ld-linux-x86-64.so.2 --library-path $LAMBDA_TASK_ROOT/lib $LAMBDA_TASK_ROOT/bin/demo ${_HANDLER}

Tutta qua(!?), alla finfine AWS lancerà questa Lambda come se fosse un esebuibile (presente nella directory bin):

Il perché si usi ld-linux-x86-64.so.2 per lanciare l'eseguibile è ben spiegato in questo video che consiglio se si è interessanti all'argomento. Nella directory lib si trovano tutte le librerie utilizzate dalla Lambda, tra cui anche quelle compilate da me:

AWS, Lambda in C++ e Cold Start

Ecco la prova del nove. E' il momento di caricare il codice compilato in AWS e verificare se ci sono vantaggi per il Cold Start. Taglio corto per non creare troppa aspettativa: no. Anzi, per meglio dire... deludente! Aspettative troppo alte? Prima di guardare il bicchiere mezzo vuoto un po' di ottimismo: le prestazioni sono ottime a regime (numero di istanze della Lambda avviate), invece per il Cold Start c'è delusione perché le prestazioni sono altalenanti - si va dai 200ms ai 600ms. Un esempio in cui il Cold Start è all'incirca di 467ms:

E anche in questo caso sembra che con l'assegnazione di un quantitativo maggiore di RAM migliori i tempi di avvio anche se il consumo di memoria della Lambda è veramente esiguo se confrontato con la versione Net6. Con dei test soggettivi di centinaia di richieste con molteplici richieste contemporanee si è comportata meglio della versione in C#, ma con un vantaggio risicato.

Tra il dire e il fare...

Il codice presentato funziona. Va bene, ma la fase di sviluppo? A parte il problema di usare una versione di Linux compatibile con le librerie AWS - sempre che il problema sia stato già risolto - e l'impossibilità di lanciare direttamente il codice com'è possibile con lo stesso progetto in Net Core, in C++ si possono usare altri trucchi. Ho già premesso che non sono un guru i con questo linguaggio - o per meglio dire, non sono in guru in niente - e tra i vari trucchi semplici che ho utilizzato è il creare un nuovo file dove inserire la funzione main del codice.

Per esempio, ho creato il file main_local.cpp dove richiamo la funzione per la richiesta dei dati a MongoDb, e per compilare un eseguibile scrivo:

g++ -mavx2 -O3 --std=c++2a main_local.cpp mongoDbHelper.cpp helper.cpp -o a.out \
  $(pkg-config --cflags --libs libmongocxx)

Quindi si può avviare con il comando:

./a.out

Oppure eseguire il debug da VsCode. E' inutile che aggiungo ancora che probabilmente ci sono tecniche migliori: per ora non le conosco.

Conclusioni

Personalmente non ho trovato vantaggi così superiori della versione in C++ su quella in C# per proclamare che la soluzione di tutti i problemi della versione Net Core sono un ricordo del passato grazie alla versione in C++. Probabilmente l'utilizzo di altre tecnologie e linguaggi come Go per avere prestazioni superiori sarebbe la scelta migliore per le Lambda in AWS. Ma non posso sentenziare verità assolute.

Qui il codice di questo 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