Provisioned Concurrency: est-ce vraiment la fin du cold start sur AWS Lambda ?

le 05/02/2020 par Thomas Schersach
Tags: Software Engineering

Si vous avez comme moi suivi de près les nombreuses annonces de la RE:Invent 2019 vous devriez être satisfait par les nouveautés autour du service Lambda.

Parmi ces fonctionnalités on retrouve la possibilité de spécifier des destinations comme SNS ou SQS en cas d’erreurs ou de succès du traitement ou encore d’affiner le comportement de rejeu sur erreur. Ces ajouts démontrent une certaine maturité du service AWS Lambda qui se focalise désormais plus sur la stabilité et le contrôle. Mais la fonctionnalité qui nous intéresse dans le cadre de cet article est celle de Provisioned Concurrency.

La fonctionnalité est annoncée par la majorité des blogs et des sites spécialisés dans les architectures Serverless comme une petite révolution qui permettrait enfin de se débarrasser du problème de cold start des lambdas. Yan Cui une des références dans le milieu du Serverless a écrit un article très complet sur le sujet. J’ai eu l'occasion de tester cette fonctionnalité en production et je vais donc vous livrer mon avis et tout ce que j’ai pu découvrir autour du sujet.

Qu’est ce que c’est exactement la Provisioned Concurrency ?

Le but de cet article n’est pas d’expliquer le cold start. Je vais donc présumer que vous êtes déjà à l’aise avec le terme et tout ce qu’il implique. Si vous avez besoin d’un rappel sur le sujet, je vous recommande un de nos articles qui traite du cold start en profondeur : Tour d’horizon du Cold Start avec AWS Lambda.

Pour revenir sur la Provisioned Concurrency, c’est simplement un moyen de configurer un nombre prédéfini d’instances de lambda qui seront dites “chaudes”. Une instance de lambda chauffée est prête à répondre avec une latence minimale à la requête entrante. C’est une fonctionnalité qui était très attendue car elle permet d’améliorer considérablement l'expérience utilisateur sur les workflows qui sont sensibles à la latence. C’est le cas, par exemple, d’un frontend qui fait des requêtes sur un backend constitué d’une API Gateway qui délègue les traitements à des fonctions lambdas.

Si l’on continue sur cet exemple, sans Provisioned Concurrency, le client fait son premier appel et il pourra observer une latence plus élevée que si la lambda était chaude. S’il fait une deuxième requête sur le même endpoint de l’API peu de temps après, il observa une latence réduite. Le conteneur de la lambda est réutilisé pendant environ 15 minutes.

Avec l’arrivée de la Provisioned Concurrency, il est désormais possible de spécifier un nombre d’instances qui seront initialisées en permanence et donc prêtes à répondre aux requêtes avec un minimum de latence.

Si je provisionne 3 instances de lambdas alors lorsque 4 clients font des requêtes simultanées, les trois premiers auront une latence minimale car ce seront les lambdas chaudes qui vont traiter la requête. Le quatrième client devra attendre un peu plus longtemps car il va tomber sur une instance froide, non initialisée.

Il est important de bien comprendre que le comportement exposé dans ce deuxième schéma est un comportement logique et intuitif. Quand j’utilise la Provisioned Concurrency je m’attends à ce que le comportement quand je fais une requête en dehors du pool de lambda préchauffée soit le même que le comportement habituel du service. Nous verrons par la suite, que ce n’est malheureusement pas le cas actuellement.

Comment faisait-on avant pour limiter le cold start ?

Afin de fournir une vision complète de ce qu’apporte cette nouvelle fonctionnalité, je trouve qu’il est important de parler des précédentes manières de mitiger le cold start.

Il existe plusieurs solutions pour réduire le temps d’initialisation d’un conteneur d’une lambda. Les bonnes pratiques sont bien connues, il faut réduire la taille de son code (attention aux dépendances) ou encore privilégier un langage avec un temps d’initialisation court.

Mais il est aussi possible de déployer de l’infrastructure pour simuler des invocations régulières afin de garder les instances chaudes quitte à bafouer certains principes fondamentaux du Serverless.

Le principe est simple, grâce au système des CloudWatch Events, il est possible de programmer une invocation régulière d’une fonction lambda. Si vous vous souvenez bien de la première partie, une lambda chaude qui ne reçoit plus de requêtes reste disponible pendant environ 15 minutes. Je précise bien que la lambda ne reçoit plus de requêtes car si vous connaissez bien le sujet, un conteneur chaud qui reçoit régulièrement des requêtes peut le rester pendant plusieurs heures.

Pour éviter qu’une lambda peu sollicitée soit souvent non initialisée, il est donc nécessaire de faire un appel toutes les 15 minutes.

L’astuce pour diminuer les coûts est de faire en sorte que l’event CloudWatch déclenche ce qu’on appelle un early return dans le code de la lambda pour ne pas payer cette invocation artificielle.

Le dispositif est plutôt simple mais il devient assez lourd si vous possédez de nombreuses lambdas. En effet, il est nécessaire de déployer un event par lambda. Si vous avez plus d’une centaine de lambdas vous allez très vite tomber sur la soft limit du nombre d’event CloudWatch par compte AWS. Et, si vous utilisez Terraform vous allez aussi pouvoir constater qu’il s'emmêle rapidement les pinceaux dans le déploiement de toutes ces ressources et il n’est pas rare qu’il crash par exemple quand il doit créer plus de 50 CloudWatch Events.

Pour finir, cette solution naïve ne peut pas être utilisée à l’échelle car une seule instance de lambda est chauffée. Si vous avez une charge avec des pics de requêtes espacés dans le temps, vos clients ne ressentiront aucun bénéfice. Il existe cependant des solutions plus élaborées basées sur le même principe. Vous pouvez retrouver ce type de solutions dans des plugins pour le framework Serverless ou encore avec le paquet Node lambda-warmer.

Déploiement avec Terraform

Parmi les nombreuses annonces de la RE:Invent, la Provisioned Concurrency est une des rares à avoir un support immédiat sur les outils tiers. Il était ainsi possible de déployer avec Terraform  des lambdas pré-chauffées directement après l’annonce .

Mais pour ce faire il faut d’abord bien comprendre les limites et le fonctionnement de la Provisioned Concurrency.

Console AWS actuelle pour les fonctionnalités de concurrence d’une Lambda

Au niveau de la console on comprend rapidement les limites de base de la fonctionnalité. Le pool de lambda provisionnées est partagé avec le pool de la fonctionnalité de Reserved Concurrency. La limite de base étant de 1000 par compte AWS. Par exemple, en configurant 50 instances de lambda provisionnées, il ne restera plus que 950 instances utilisables pour la Provisioned/Reserved concurrency.

La seconde limitation concerne la notion de version et d’alias des fonctions lambdas. Pour rappel, l’alias d’une lambda est simplement un nom donné à une version. Une lambda basique qui vient d’être créée possède une version de base nommée $LATEST et aucun alias. L’utilisation d’un alias ou d’une version pour configurer la Provisioned Concurrency est obligatoire. Il est pourtant impossible de configurer la Provisioned Concurrency sur la version $LATEST d’un lambda. Cela rend la tâche un peu laborieuse, même si il est possible de simuler un alias latest fonctionnel.

En déclarant un alias nommé latest qui pointe toujours vers la dernière version, on dispose d’un nom qui est toujours le même pour la dernière version d’une lambda. On peut alors utiliser facilement cet alias latest au niveau de la configuration de notre API Gateway.

Nous allons ensuite voir deux manières de configurer la Provisioned Concurrency avec Terraform: une manière naïve et une manière plus efficace.

Prérequis

Le seul prérequis est d’avoir une ressource pour créer la fonction lambda et son alias. On notera que l’attribut publish est présent. Cela permet à Terraform de publier automatiquement une nouvelle version de la lambda quand le code source change. L’alias latest qui correspond sera automatiquement mis à jour pour pointer sur la nouvelle version.

https://gist.github.com/Tirke/75bb3839ea48a5eeb59320a023e251bd

Déploiement basique

https://gist.github.com/Tirke/f172cd0035e30e718d0463906521f85f

Ici, on configure simplement 1 unité de Provisioned Concurrency pour l’alias latest en utilisant la nouvelle ressource Terraform aws_lambda_provisioned_concurrency_config. Attention à la documentation Terraform qui est fausse pour l’instant, vous ne pouvez pas utiliser l’ARN de la lambda pour le champ function_name.

Cette configuration est simple et fonctionnelle mais, malheureusement, le déploiement prend plusieurs minutes. En effet, le provisionnement d’une instance chaude de lambda prend entre 3 et 4 minutes. Le fonctionnement de base de Terraform est d’attendre qu’une ressource soit correctement déployée avant de rendre la main.

Imaginons maintenant que nous ayons des centaines de lambdas et qu’il faut provisionner une instance pour chacune d’entre elles. J’ai fait le test pour vous, il faut environ 30 minutes pour provisionner une vingtaine d’instances chaudes de lambda avant que Terraform n’émette un panic et que le programme crash. Il est donc peu envisageable d’utiliser cette solution en production et fort heureusement il existe une alternative à la fois robuste et permettant de minimiser les coûts.

Déploiement programmé

Certains services d’AWS ne sont pas visibles sur la console. On ne peut les utiliser qu’en passant par le SDK ou la CLI. C’est le cas du service Application Auto Scaling qui permet de faire du scaling automatique de certaines ressources AWS comme des machines EC2, des tables DynamoDB et depuis peu de la Provisioned Concurrency des lambdas. Terraform supporte l’API du service depuis longtemps donc toute nouveauté est disponible immédiatement.

https://gist.github.com/Tirke/6e67681c824d9fbebea5ff2175793229

La configuration est un peu plus longue. Elle permet d’avoir une instance de lambda provisionnée pendant les heures de travail en semaine. On fait donc un scale down, un retour à 0 unité provisionnée, le soir et un scale up vers une unité de lambda provisionné le matin.

Cette configuration a pour avantage d’être déployée immédiatement avec Terraform. En effet, le scaling de la Provisioned Concurrency est géré de manière asynchrone et transparente par le service Application Auto Scaling. Le temps de provisionnement d’une instance chaude est toujours présent mais il ne nous impacte plus durant le déploiement.

Utiliser Application Auto Scaling en combinaison avec la Provisioned Concurrency des lambdas est vraiment un combo gagnant. On exploite pleinement le côté temporaire et dynamique du besoin d’instances chaudes de Lambda. Il est bon de noter qu’il est aussi possible de faire du scaling en fonction de métriques CloudWatch ce qui permettrait d’affiner encore plus l’utilisation de la fonctionnalité de Provisioned Concurrency.

Killer feature ou flop ?

Nous avons vu qu’au premier abord, il n’est pas évident d’utiliser la Provisioned Concurrency. Mais une fois que les limitations sont bien comprises, et que le cadre nécessaire est mis en place, il devient facile de tirer parti de cette nouvelle fonctionnalité.

Peut-on donc officiellement enterrer la problématique de cold start sur AWS ?

Je pense qu’il est encore trop tôt et ce pour plusieurs raisons.

Le premier frein à l’adoption de la fonctionnalité semble être le coût d’utilisation. Pour commencer, le free tier est désactivé dès que l’on active la Provisioned Concurrency. C’est une conséquence logique car chaque instance de lambda provisionnée est facturée comme une lambda qui tourne à plein temps.

Le calcul pour notre exemple récurrent d’un backend avec 100 lambdas qui ont une unité de Provisioned Concurrency pendant les horaires de bureau est le suivant :

Le prix de la Provisioned Concurrency est de $0.000004646 par GB-s pour la région Ireland (eu-west-1)

Temps total durant lequel la Provisioned Concurrency est activée (secondes)  = 1,116,000 (31j * 10h * 3600)

Nombre total de Provisioned Concurrency configurée (1 unité par lambda * 100 lambda * 512 Mo de mémoire par lambda) (GB): 1 * 100 * 512MB/1024MB = 50 GB

Total Provisioned Concurrency amount (GB-s) = 50 GB * 1,116,000 seconds = 55,800,000 GB-s

Monthly Provisioned Concurrency charges = 55,800,000 * $0.000004646 = $259.22

Le prix final de 260 dollars est uniquement le coût pour utiliser la fonctionnalité, auquel il faudrait ajouter le prix pour les exécutions et éventuellement le prix des exécutions qui dépassent le niveau de Provisioned Concurrency configuré.

Vous l’avez bien compris, la facture se complexifie mais surtout elle augmente de manière non négligeable. N’oublions pas que ce coût est pour une utilisation minimale de la fonctionnalité. Pour vous donner un exemple de coût d’utilisation en production, sans la Provisioned Concurrency, sur une charge faible/moyenne de 5 millions de requêtes ($1.03) et 11 millions de GB-Seconds (188$) le coût d’activation de la fonctionnalité est presque le double du coût total d’utilisation classique du service Lambda.

La seconde raison qui retient l’adoption massive de la fonctionnalité serait la stabilité du service.

Comme nous l’avons vu, le déploiement est long (plusieurs minutes) et cela peut à la fois handicaper le déploiement mais aussi l’utilisation. Même si on fait de l’autoscaling, il y a une fenêtre de temps d’activation des lambdas provisionnées qu’il faut prendre en compte.

Je tiens aussi à signaler que j’ai trouvé un bug assez important lors de l’utilisation de valeurs faibles de la Provisioned Concurrency comme celles que j’utilise dans l’article. Cette découverte n’est pas inédite, mais elle a peu de visibilité et j’ai pu aller au bout du sujet.

Implémentation défectueuse ?

Pour retracer l’histoire de cette découverte, je vais vous donner un peu plus de contexte.

Notre équipe a décidé de tester l’utilisation de 1 unité de Provisonned Concurrency pour toutes nos lambdas qui servent les requêtes du frontend.

Lors de l'exécution des tests end-to-end avant la mise en production, je me rends compte que l’outil de test plante de manière aléatoire avec des erreurs 408 Request Timeout.

En investiguant sur les métriques CloudWatch je constate que certaines lambdas sont throttle, je constate aussi la valeur de la nouvelle métrique Provisioned Concurrency Spillover Invocations qui est assez haute. Je décide d’investiguer mais les ressources sur la manière dont les lambdas scalent quand la Provisioned Concurrency est activée sont quasi inexistantes.

C’est un comportement qui est pour moi anormal car le maximum de requêtes concurrentes (“Lambda ConcurrentExecutions Maximum”) est plutôt bas à 19. Pour rappel une lambda est censée pouvoir scaler sur toute les instances de concurrence du compte (2000 dans notre cas). Il faut donc en temps normal bien plus de 19 exécutions concurrentes avant d’observer du throttling.

En premier lieu, je pensais avoir mal compris un élément de scaling lié à l’utilisation de la nouvelle fonctionnalité. Mais en réfléchissant, je me rends compte que ce qu’il se passe est certainement lié à la notion de Spill Over Invocations. Selon moi, toutes les requêtes concurrentes qui ne peuvent pas être servies par une instance provisionnée déclenchent une invocation en spillover et une étape de scaling par la création d’une nouvelle instance provisionnée. Et comme nous le savons déjà, le processus de provisionnement d’une lambda est long, d’où le potentiel fort de throttling sur une charge aussi basse.

Ce comportement me semble alors anormal et je décide d’écrire un ticket au support d’Amazon.

On me confirme en moins de 5 heures que la fonctionnalité est bien défectueuse. Pour vous résumer l’échange, mes suppositions étaient correctes, une lambda avec de la Provisioned Concurrency devrait bien scaler comme une lambda normale. Pourtant quand la valeur configurée de concurrence est inférieure à 500 unités et que la fonction a des invocations en spillover il y a un risque intermittent de throttling. La recommandation est donc d’utiliser la fonctionnalité en configurant 500 unités ou plus (la moitié du pool disponible de base) ou de ne pas utiliser la fonctionnalité du tout.

L’équipe d’AWS Lambda travaille sur le sujet mais aucune roadmap ou timeline ne peut être partagée.

Après avoir désactivé la fonctionnalité nous pouvons constater un retour à la normale immédiat.

Conclusion

Je pense que l’ajout de la Provisioned Concurrency ne marque pas encore la fin du cold start. La fonctionnalité est buggée et ne scale pas, les prix sont assez rédhibitoires et l’expérience développeur n’est pas très convaincante notamment car le déploiement est lent et peu intuitif.

On peut cependant compter sur Amazon pour faire progresser cette fonctionnalité et pour apporter d’autres améliorations au problème du cold start. En l’état, la fonctionnalité a peu d’utilité et se limite à des cas d’usage assez spécifiques. Une fois le problème de throttling réglé on peut imaginer utiliser la fonctionnalité de manière efficace pour anticiper de manière dynamique une charge lourde et ponctuelle. Il ne faut cependant pas oublier les clients avec des charges moyennes voires faibles et irrégulières et qui souhaitent principalement réduire la latence de leur API serverless.