AWS Lambda : Optimiser les lambdas Java — Partie 1
Dans cet article, nous allons nous focaliser sur le service AWS Lambda qui permet d’exécuter du code à la demande en tant que FaaS (Function as a Service). Nous parlerons de “lambdas” dans la suite de l’article pour désigner les fonctions du service AWS Lambda.
En s’appuyant sur des environnements d’exécution dédiés, les lambdas peuvent être implémentées en utilisant divers langages tels que Node.js, Python 3, Java et bien d’autres.
Nous allons présenter le cas d’une lambda implémentée en Java ainsi que les problèmes rencontrés en ce qui concerne ses performances. Puis, nous verrons comment résoudre ces problèmes en vue de développer des lambdas optimales.
Dans un premier temps, rappelons les principes de base et le cycle de fonctionnement du service AWS Lambda.
Rappels sur AWS Lambda
Les fonctions du service AWS Lambda se caractérisent par les principes suivants :
- Elles sont déclenchées suite à un événement dans le cadre de l’architecture Event-Driven. En effet, des événements peuvent être émis par les services AWS en fonction des actions de l’utilisateur. La suite de l’article présente un cas d’utilisation avec l’émission d’un événement depuis le service API Gateway.
- Lorsque la lambda est invoquée, son code est exécuté. Cela dit, l’exécution du code est précédée de plusieurs étapes permettant d’initialiser la lambda. Ces étapes sont détaillées dans la suite de l’article.
- Le temps d’exécution est limité à 15 minutes. En effet, le temps d’exécution d’une lambda n’est pas indéfinie contrairement aux services EC2 (similaires aux machines virtuelles) pouvant héberger des applications et les exposer sans limite de temps.
Les lambdas ont aussi un cycle de fonctionnement précis, de l’émission de l’événement jusqu’à la fin de l’exécution du code :
- Téléchargement du code
Le code de la lambda est téléchargé depuis un bucket S3 dédiée. - Création de l’environnement d’exécution
Le code doit s’exécuter dans un environnement correspondant à la configuration de la lambda (runtime, architecture, …). - Initialisation du code
Il s’agit de l’initialisation du code statique à l’extérieur du handler. - Exécution du code (Handler)
Un handler dans une lambda représente son point d’entrée pour exécuter son code. C’est bien l’objectif de cette étape de l’exécuter.
Présentation du cas d’utilisation
Le cas d’utilisation est simple, il s’agit d’obtenir une liste de Talk
en appelant l’endpoint GET /carbon/talks
. Cet endpoint sera accessible à la seule condition de s’être authentifié auprès du service Amazon Cognito et d’avoir transmis un access token en tant que Bearer Token.
L’endpoint GET /carbon/talks
sera exposé à travers l’API Gateway et son appel aura pour effet d’invoquer la lambda GetTalkList
. En effet, cette lambda sera chargée d’interroger la base de données (hébergée par le service RDS) en vue de renvoyer la liste de Talk
attendus.
Suivons en détail les étapes de notre cas d’utilisation :
1) Le client envoie une requête d’authentification “initiateAuth” auprès du service Amazon Cognito.
curl --location 'https://cognito-idp.eu-west-3.amazonaws.com' \
--header 'X-Amz-Target: AWSCognitoIdentityProviderService.InitiateAuth' \
--header 'Content-Type: application/x-amz-json-1.1' \
--data-raw '{
"AuthFlow": "USER_PASSWORD_AUTH",
"AuthParameters": {
"USERNAME": "username",
"PASSWORD": "password"
},
"ClientId": "clientid"
}'
Une fois authentifié, le client reçoit une réponse de ce type :
{
"AuthenticationResult": {
"AccessToken": "accessToken",
"ExpiresIn": 3600,
"IdToken": "idToken",
"RefreshToken": "refreshToken",
"TokenType": "Bearer"
},
"ChallengeParameters": {}
}
2) Le client interroge l’endpoint GET /carbon/talks
.
curl --location 'https://private-domain/carbon/talks' \
--header 'Authorization: Bearer accessToken'
Et la réponse suivante doit être renvoyée :
[
{
"title": "Amazon Cognito",
"date": "2022-10-13T19:00:00",
"speaker": "kevin.llps",
"description": "Retour d'expérience sur Amazon Cognito"
},
{
"title": "AWS Lambda",
"date": "2023-09-07T19:00:00",
"speaker": "kevin.llps",
"description": "Voyons comment optimiser les lambdas Java"
}
]
Solution 1 : NodeGetTalkList en Node.js
Cette première solution n’est pas basée sur Java mais sur Node.js (18.x.x). En effet, il s’agit ici de disposer d’un comparatif pour les solutions dans la suite de l’article.
Voici le handler de cette lambda :
Pour analyser les solutions de cet article, nous nous appuyons sur X-Ray qui a pour objectif de surveiller les services AWS en vue de présenter leur temps de réponse. Dans le cadre de cet article, nous avons activé X-Ray au niveau de l’API Gateway, le Secrets Manager ainsi que la lambda. D’autre part, nous avons exploité le SDK Javascript pour enregistrer des sous-segments de traces. Dans X-Ray, les sous-segments permettent d’enregistrer des traces des traitements, des appels d’API et des services AWS.
Dans le diagramme ci-dessous, nous pouvons observer que le temps de réponse le plus important correspond à celui de la lambda (2,44 s). D’autre part, l’appel au Secrets Manager (460 ms) n’est pas négligeable, il peut être optimisé. En effet, le mot de passe stocké dans le Secrets Manager n’est pas supposé changer à chaque appel de la lambda (rotation des secrets). Une optimisation possible consisterait à mettre en place un cache, sous réserve de pouvoir l’actualiser en prévision de la rotation des secrets.
Le tableau suivant montre la durée du “Cold Start” ainsi que celle correspondant à son invocation (exécution du code). Nous analyserons dans la suite de l’article les temps de réponse spécifiques aux autres solutions.
Lorsque nous analysons en détail les sous-segments, la lambda suit le cycle de fonctionnement décrit précédemment :
Solution 2 : JavaGetTalkList implémentée “naïvement” en Java
Cette solution est implémentée uniquement en Java 17 sans utiliser aucun framework.
Nous constatons que la lambda Java a été très lente pour s’exécuter avec 9,05 s contre 1,40 s pour la lambda en Node.js.
Cette lenteur s’explique principalement par le fait que pour réduire l’utilisation de la mémoire, la JVM retarde l’initialisation de libraries jusqu’à ce que la librairie soit appelée pour la première fois dans une application. Ce délai peut provoquer des latences importantes.
D’autre part, en ce qui concerne le “Cold Start”, dans notre situation il est quasiment identique à celui de la lambda en Node.js. Ceci ne montre pas que le “Cold Start” des lambdas Java est aussi court que celui des lambdas Node.js. En effet, ces ordres de grandeur restent spécifiques à notre cas d’utilisation. En revanche, lorsque l’on réalise un “benchmark” complet selon plusieurs situations, le “Cold Start” des lambdas Java est nettement plus important comme décrit dans cet article.
Solution 3 : QuarkusGetTalkListNative optimisée par le couple GraalVM / Quarkus
Nous allons nous baser sur GraalVM pour générer un exécutable natif et de cette manière éviter la lenteur de la JVM à l’exécution. Pour réaliser cela, Quarkus peut de son côté interagir avec GraalVM.
De plus, nous pouvons tirer partie de Quarkus pour ses fonctionnalités telles que la déclaration de beans injectables grâce au CDI (Contexts and Dependency Injection).
Un autre avantage de Quarkus est de pouvoir s’appuyer notamment sur l’extension Quarkiverse en vue de pouvoir interroger les services AWS.
Nous pouvons le voir dans les dépendances du pom.xml.
Suite à l’appel de la lambda, nous pouvons constater que le temps de réponse de la lambda est similaire à celle présentée précédemment en Node.js.
Cela dit, en ce qui concerne la phase d’invocation, sa durée d’exécution est nettement inférieure aux autres solutions.
De ce fait, en tirant partie de GraalVM et de Quarkus, nous pouvons implémenter des lambdas Java optimales.
Solution 4 (Bonus) : JavaGetTalkList optimisée par le couple provisioned concurrency / EventBridge Scheduler
Cette solution permet dans un premier temps d’initialiser en amont les environnements d’exécution dédiés aux lambdas grâce au provisioned concurrency. Puis, dans un second temps l’EventBridge nous permet d’initialiser les lambdas mises à disposition dans les environnements d’exécution évoqués précédemment. Cette initialisation est réalisée en provoquant l’appel aux lambdas. De cette manière, lorsque la lambda sera appelé suite à une action utilisateur, aucune initialisation ne sera requise et l’appel sera optimisé.
Cependant, cette solution n’a pas été expérimentée dans le cadre de cet article au vu de son inconvénient majeur. Il s’agit des coûts supplémentaires pour le compte AWS, ce qui n’est pas négligeable.
Conclusion
Du fait des performances moins importantes des lambdas Java, leur utilisation a toujours eu des inconvénients en comparaison des autres langages tels que Node.js et Python 3. Des solutions sont couramment mises en place dans les projets pour optimiser les lambdas Java, notamment en s’appuyant sur le couple provisioned concurrency / EventBridge Scheduler. Bien que cette solution permette d’initialiser en amont la lambda et d’éviter un temps de réponse important pour l’utilisateur, elle a l’inconvénient d’être plus coûteuse en termes de facturation pour le compte AWS.
En revanche, la solution basée sur le couple GraalVM / Quarkus n’ajoute aucun coût supplémentaire et permet de résoudre l’initialisation lente de la lambda. Ceci est possible en se reposant sur un exécutable natif. D’autre part, en tant que développeur, nous pouvons tirer partie des fonctionnalités de Quarkus pour faciliter les développements notamment par l’injection de dépendances, les couches d’abstraction ainsi qu’en nous appuyant sur les extensions de Quarkiverse permettant d’interagir avec les services AWS.
Par conséquent, en vue d’optimiser les lambdas Java, le couple GraalVM / Quarkus est l’une des solutions recommandées. En effet, d’autres solutions existent telles que GraalVM / Spring Boot 3 (intégrant Spring Native) et SnapStart. La première solution est similaire à celle basée sur GraalVM / Quarkus à la seule différence qu’elle repose sur Spring Boot 3. Puis, la seconde est dédiée spécifiquement à l’optimisation du “Cold Start” des lambdas Java. Elle s’avère plus efficace que la solution GraalVM / Quarkus, sous réserve de certaines limitations. C’est justement lorsque SnapStart est victime de ses limitations que la solution GraalVM / Quarkus peut rentrer en action.
Mais quelque soit la solution à mettre en place pour optimiser les lambdas Java, il ne fait aucun doute que dans une équipe de développeurs, il sera nécessaire d’investir du temps dans la prise en main de ces solutions. Or, en implémentant les lambdas en Node.js ou bien en Python 3, cet effort d’optimisation ne sera pas indispensable.
En ce qui concerne SnapStart, un article sera publié prochainement pour analyser la solution ainsi que ses limites.