Du code Ada pour AWS Lambda

Temps de lecture : 13 minutes

Parmi les idées bizarres qui me viennent parfois, je me suis demandé un jour en retrouvant du code que j’avais écrit à l’université s’il pourrait tourner dans AWS lambda. C’était du code écrit en Ada. Ça paraissait faisable, alors je l’ai fait.

Nicolas c’est comme un super-héros, mais en pas du tout impressionnant

Cet article a été écrit dans un but purement récréatif et n’est probablement pas suffisamment détaillé pour constituer un tutoriel. Si vous qui lisez ces lignes, avez besoin de quelqu’un pour porter du code Ada dans AWS Lambda ou bien faire d’autres trucs bizarres dans Lambda, je me ferai un plaisir d’utiliser cette compétence « pour de vrai » pour vous aider, il vous suffit de contacter Revolve 🙂

Voici donc ci-dessous la création (improbable) d’une Lambda AWS écrite et compilée avec le langage ADA 🙂

Exécution d’un binaire dans lambda

Depuis l’été 2020, il est possible de fournir son propre runtime pour AWS lambda. Un tutoriel écrit par AWS explique même comment faire. En gros, j’ai besoin :

  • D’un fichier bootstrap qui récupère les différents éléments de lambda, notamment les paramètres de l’événement, et qui lance mon exécutable
  • D’un exécutable compilé pour la plateforme AWS Lambda

Il faut donc compiler mon code Ada pour linux et puis avec deux ou trois bout de ficelles ça devrait fonctionner.

L’expression « faire tenir un programme informatique avec des bouts de ficelles » tient son origine dans le fait que les chaises de bureau ayant été inventées plusieurs années après les ordinateurs, les premiers informaticiens devaient donc se suspendre à l’horizontale pour les utiliser.

Comme preuve de concept dans un premier temps, je prend un HelloWorld écrit en Ada:

with Ada.Text_IO; use Ada.Text_IO;
procedure Hello is
begin
   Put_Line ("Hello WORLD!");
end Hello;

Le bootstrap qui exécute le code compilé est celui du tutoriel AWS, à 2-3 détails près :

#!/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)
  # Run the binary function
  RESPONSE=$("$LAMBDA_TASK_ROOT/$_HANDLER" "$EVENT_DATA")
  # Send the response
  curl -X POST "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/$REQUEST_ID/response"  -d "$RESPONSE"
done

Compilation

Pour compiler du code Ada sous linux c’est super facile (vous pensiez pas ?) il y a déjà tout qui est prévu dans la plupart des distributions, ça se résume globalement à installer gnat : apt-get install gnat

Il me suffit donc d’écrire mon script qui compile le code, et l’envoie dans un zip avec le fichier de bootstrap.

# Compile function
cd app/runtime
gnatmake -vh -gnatv hello.adb
chmod 755 hello bootstrap
# Package binary and bootstrap
zip -9 "${OUTPUT_DIRECTORY}/hello.zip" hello bootstrap

Et puis comme ça ne va probablement pas marcher de suite, je déploie ma lambda avec Terraform :

resource "aws_lambda_function" "main" {
  filename         = "hello.zip"
  function_name    = "ada-lambda-main"
  role             = aws_iam_role.lambda.arn
  handler          = "hello"
  runtime          = "provided"
}

Allez, roulez jeunesse, ça déploie :

Alive ! He is alive !

Ok, ça a l’air de marcher… Je ne peux pas en lire le contenu depuis l’éditeur de code, mais on s’en fout. Premier essai d’invocation :

$ aws --region eu-west-1 lambda invoke --function-name ada-lambda-main --payload '{"text":"Hello"}' response.txt

Et premier échec:

/var/task/function: error while loading shared libraries: libgnat-9.so.1: cannot open shared object file: No such file or directory
En vrai, les oreilles de Nicolas sont beaucoup moins poilues

Il semblerait que j’y suis allé un peu trop yolo sur la compilation, c’est vrai que je n’ai même pas regardé ce qu’il m’avait fait. Regardons donc un peu ce qu’il m’a branché au moment du link :

$ ldd hello
 linux-vdso.so.1 (0x00007ffd4f1e9000)
 libgnat-9.so.1 => /usr/lib/x86_64-linux-gnu/libgnat-9.so.1 (0x00007f1b20a16000)
 libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f1b20824000)
 libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f1b2081e000)
 libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007f1b20803000)
 /lib64/ld-linux-x86-64.so.2 (0x00007f1b20dac000)

Ok. Je compile dans Ubuntu, et il a trouvé libgnat-9.so.1 dans les librairies partagées. Du coup par défaut il m’a fait une liaison dynamique pour utiliser la librairie. Manque de bol, dans le runtime AWS la librairie libgnat n’est pas présente de base (ça aurait été étonnant).

Recommençons, cette fois avec une option de bind statique pour qu’il m’inclue le nécessaire dans l’exécutable. La ligne de compilation du script que je vous ai montré précédemment est modifiée comme ainsi:

gnatmake -v hello.adb -bargs -static

J’aurai également pu copier libgnat.so dans le zip de la fonction, mais cette librairie fait 3.5Mo et je voulais économiser un peu la taille du package : inutile d’embarquer les fonctions de la librairie que je n’utilise pas. Désormais, je n’ai plus libgnat dans les librairies partagées, et le binaire fait environ 300 Ko :

$ ldd hello
 linux-vdso.so.1 (0x00007ffe675d2000)
 libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007fcb4dcaf000)
 libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007fcb4dc94000)
 libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fcb4daa2000)
 /lib64/ld-linux-x86-64.so.2 (0x00007fcb4dd11000)

Mais ça foire encore. La version de libc que j’ai utilisé pour compiler sous Ubuntu n’est pas disponible dans le runtime Lambda, probablement qu’elle est trop récente.

/var/task/function: /lib64/libc.so.6: version `GLIBC_2.28' not found (required by /var/task/libgnat-9.so.1)

Troisième essai avec un bind et un link complètement statique, cette fois je suis sûr de n’avoir plus de dépendances à charger :

gnatmake -v hello.adb -bargs -static -largs -static

Et… ça marche !

$ aws --region eu-west-1 lambda invoke --function-name ada-lambda-main --payload '{"text":"Hello"}' response.txt
{
    "StatusCode": 200,
    "ExecutedVersion": "$LATEST"
}
$ cat response.txt
Hello WORLD!

Bon, le binaire fait 1.5Mo, ce qui est pas ouf. Très probablement, je pourrais m’appuyer sur les librairies C qui sont dans le runtime lambda, mais pour cela, il faut que je compile réellement avec la librairie C qui est présente dans le runtime lambda. Dans tous les cas, c’est une bonne idée de compiler au plus proche du système cible, je ne suis pas à l’abri d’avoir des surprises en compilant sur Ubuntu et en exécutant dans l’écosystème Lambda qui est un RedHat-like.

Allez, on compile vraiment pour AWS Lambda cette fois.

Compilation avec l’écosystème lambda

Pour compiler au plus proche de la « machine lambda », AWS fournit des images Docker avec tout ce qui est embarqué dans la microvm lambda. Il suffit donc que je tire l’image, que je compile mon code dedans, et ça utilisera la librairie C du runtime sans que j’ai à embarquer du code complètement statique (bind+link). Ecrivons donc un nouveau script de compilation qui tire l’image :

docker pull public.ecr.aws/lambda/provided:latest
docker run --rm -it -v $(pwd):/src -w /src --entrypoint bash public.ecr.aws/lambda/provided:latest /src/internal-package.sh

Et à l’intérieur, on compile comme on faisait avant :

# Install compiler and packaging tools
yum install -y gcc-gnat zip
# Compile
cd runtime
gnatmake -vh -gnatv hello.adb
chmod 755 hello bootstrap
# Package binary and bootstrap
zip -9 "hello.zip" hello bootstrap

A noter que l’argument -bargs -static ne semble pas obligatoire, le compilateur gcc présent dans les paquets du runtime lambda est configuré par défaut pour faire du binding statique. En analysant le binaire, on pas de dépendance à libgnat:

$ ldd hello
 linux-vdso.so.1 (0x00007ffd9e78f000)
 libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f758df49000)
 /lib64/ld-linux-x86-64.so.2 (0x00007f758e155000)

Un second déploiement, très similaire au premier.

Jeff, we are in the matrix

Et ça fonctionne !

$ aws --region eu-west-1 lambda invoke --function-name ada-lambda-main --payload '{"text":"Hello"}' response.txt
{
    "StatusCode": 200,
    "ExecutedVersion": "$LATEST"
}
$ cat response.txt
Hello WORLD!

Bootstrap complet en Ada

Bref. Je suis content mais il faut bien avouer que quand même, entre 200 et 300 millisecondes pour lancer un « Hello world » c’est quand même un peu cher payé.

Capture d’écran des logs de ma première preuve de concept en Ada

Ma problématique principale est très probablement que le script shell est « lent ». On le voit très bien dans les logs : les appels curl c’est pas très véloce 🙁

Représentation graphique de la performance d’un script Shell en train de faire des appels CURL avant de lancer mon programme Ada compilé

Puisque c’est ça, je vais écrire le bootstrap en Ada. Après tout c’est juste télécharger depuis l’API Lambda les paramètres de la prochaine invocation, répondre à une autre API avec ce qu’a fait la fonction, et enfin mettre tout ça dans une boucle infinie.

Pour faire ces appels HTTP j’ai besoin d’un nouvel ami, il s’appelle Ada Web Server. Ada Web Server est une implémentation en Ada du protocole HTTP/1.1, et couvre autant la partie serveur que la partie cliente. Ici, seule la version cliente sera utilisée.

Du coup, j’ai besoin d’installer Ada Web Server dans le Docker qui contient l’environnement de build, mais ce n’est pas si simple. En particulier parce que pour compiler Ada Web Server depuis les sources j’ai besoin de gprbuild mais le support CentOS n’est pas officiellement supporté par l’équipe du projet et il n’est donc pas présent dans les dépôts AmazonLinux. Je n’ai que gnatmake dans gcc-gnat 🙁

J’ai fini par installer gnat directement depuis l’installeur AdaCore, en version 2017 car à partir de 2018 il n’existe plus de version headless de l’installeur ! Une fois fait, il suffit d’installer les quelques dépendances et puis compiler Ada Web Server. Ci dessous mon Dockerfile:

FROM public.ecr.aws/lambda/provided:latest
ADD expect-install.sh /tmp/
RUN yum install -y gcc-gnat zip
RUN yum install -y zip tar gzip make expect && \
     curl -L -o gnat.tar.gz https://community.download.adacore.com/v1/9682e2e1f2f232ce03fe21d77b14c37a0de5649b?filename=gnat-gpl-2017-x86_64-linux-bin.tar.gz && \
     tar -zxf gnat.tar.gz && \
     /tmp/expect-install.sh && \
     rm -Rf gnat-gpl-2017-x86_64-linux-bin && \
     rm gnat.tar.gz
ENV PATH="/usr/gnat/bin:${PATH}"
RUN yum install -y openssl glibc && \
     yum install -y git openssl-devel which glibc-devel && \
     git config --global advice.detachedHead false && \
     cd /tmp && \
     git clone https://github.com/AdaCore/xmlada.git && \
     cd xmlada && \
     ./configure --prefix=/usr/gnat && \
     make all install && \
     cd /tmp && \
     rm -Rf xmlada && \
     git clone --recursive https://github.com/AdaCore/aws.git && \
     cd aws && \
     git checkout 17.2 && \
     make DEBUG=true SOCKET=openssl setup build && \
     make prefix=/usr/gnat install && \
     cd /tmp && \
     rm -Rf aws
ENTRYPOINT ["/bin/bash"]

Remarquez l’utilisation de expect car même sur la version 2017 il n’existe pas d’installateur non interactif, il faut répondre « yes » à la main durant l’installation.

Maintenant que j’ai un environnement de build prêt à utiliser Ada Web Server pour faire des requêtes HTTPS, et pour le coup, quand on sait écrire de l’ADA c’est fastoche :

with Ada.Environment_Variables; use Ada.Environment_Variables;
with Ada.Text_IO; use Ada.Text_IO;
with AWS.Client;
with AWS.Response;
with Ada.Strings.Unbounded;
with App;
procedure Bootstrap is
   package String_U renames Ada.Strings.Unbounded;
   Invoke_Data    : AWS.Response.Data;
   Response_Data  : AWS.Response.Data;
Lambda_Invocation_Url : String := "http://" & Value("AWS_LAMBDA_RUNTIME_API") & "/2018-06-01/runtime/invocation";
   Invoke_data_body     : String_U.Unbounded_String := String_U.Null_Unbounded_String;
   Lambda_Request_Id    : String_U.Unbounded_String := String_U.Null_Unbounded_String;
   Application_Response : String_U.Unbounded_String := String_U.Null_Unbounded_String;
begin
   loop
      Invoke_Data       := AWS.Client.Get (URL => Lambda_Invocation_Url & "/next");
      Invoke_Data_Body  := AWS.Response.Message_Body (Invoke_Data);
      Lambda_Request_Id := String_U.To_Unbounded_String (AWS.Response.Header (Invoke_Data, "Lambda-Runtime-Aws-Request-Id"));
      Application_Response := String_U.To_Unbounded_String(App.Lambda_Handler(String_U.To_String(Invoke_Data_Body)));
      Response_Data := AWS.Client.Post (
        URL => Lambda_Invocation_Url & "/" & String_U.To_String(Lambda_Request_Id) & "/response",
        Data => String_U.To_String (Application_Response));
   end loop;
end Bootstrap;

Bon, ce n’est probablement pas le code Ada du siècle. En particulier, j’utilise beaucoup Unbounded_String parce que ça fait longtemps que je n’ai plus fait de C et que les tailles fixes pour les string c’est relou en fait. On améliorera plus tard.

Je peux désormais réécrire mon HelloWorld dans un fichier à côté du bootstrap :

package body App is
   function Lambda_Handler(Event : String) return String is
   begin
      return "Hello from full ADA runtime!";
   end Lambda_Handler;
end App;

Et… BOUM ! Moins de 2 millisecondes 😮

Capture d’écran des logs Lambda avec un bootstrap Ada complet

Un petit tableau comparatif :

Lambda Ada avec bootstrap ShellLambda Ada avec bootstrap Ada
Temps d’initialisation31.01 ms52.73 ms
Temps d’exécution305.08 ms1.52 ms
Taille du package213.8 kB3.0 MB

La légère différence d’initialisation est probablement liée au fait que mon package Ada complet est plus volumineux car il contient les éléments Ada Web Server dont j’ai besoin, mais cela reste rentable au regard du temps passé dans le script shell.

Amazon API Gateway en Ada

Bon. J’ai bien bricolé et ça fonctionne, c’est le moment de porter mon code dans AWS Lambda ! Le projet écrit à l’époque était un testeur de réseau de Petri. Il recevait un fichier XML décrivant un réseau de Petri et il répondait quelques propriétés de celui-ci (vivant, non-vivant, borné, non-borné). C’est pas grand chose, mais je pourrais en faire une API !

Faire une API Gateway + Lambda avec Terraform c’est fastoche donc je vous épargne le code, globalement je configure une API en mode « Lambda Proxy » car je veux faire l’expérience de tout gérer dans la lambda, je branche mon « Hello World », et comme prévu, ça ne fonctionne pas.

Fri May 14 20:08:18 UTC 2021 : Execution failed due to configuration error: Malformed Lambda proxy response

Je m’attendais évidemment à ce comportement, voyez-vous, les réponses d’une Lambda derrière une API en mode proxy ne peuvent pas être juste « Hello WORLD ». La lambda doit respecter un format bien spécifique car c’est elle qui répond le code HTTP, les en-têtes et le body. Je vous invite à lire la documentation sur la résolution de l’erreur « Malformed Lambda proxy response » et apprendre par coeur le format de sortie de Lambda pour API proxy.

Il faut donc dans un premier temps pouvoir répondre du JSON, et il faudra lire du JSON pour récupérer les paramètres d’entrée. Commençons par faire fonctionner notre « Hello World » derrière l’API en sortie, en mode quick&dirty, c’est à dire en écrivant la réponse JSON « Hello World » en dur.

package body App is
   function Lambda_Handler(Event : String) return String is
   begin
      return "{""isBase64Encoded"":false,""statusCode"":200,""body"":""Hello WORLD""}";
   end Lambda_Handler;
 end App;

Et donc, ça fonctionne. Pas de problématique technique particulière sur ce point.

$ curl -X POST -d '' https://abcdef.execute-api.eu-west-1.amazonaws.com/api/network
Hello WORLD

Maintenant, pour pouvoir récupérer les paramètres depuis l’API, notamment le XML décrivant mon réseau de Petri, il va falloir récupérer le champ « body » en provenance de l’événement API Proxy vers Lambda, qui lui est en JSON. Il va donc falloir lire et écrire du JSON en Ada sans trop galérer à tokeniser les chaines de caractère. Heureusement, il existe la GNAT Component Collection qui a un module JSON tout fait !

Je vous l’accorde, cet article est beaucoup trop long, mais j’ai bientôt fini.

Grâce au composant JSON de GNAT Component Collection, c’est encore une fois fastoche :

with GNATCOLL.JSON; use GNATCOLL.JSON;
with Ada.Strings.Unbounded;
with Petri_App;
package body App is
   function Lambda_Handler(Event : String) return String is
      package String_U renames Ada.Strings.Unbounded;
      Event_Data     : JSON_Value                := Read (Event);
      Event_Body     : String_U.Unbounded_String := Get (Event_Data, "body");
      Response_Data : JSON_Value;   Response_Body : String_U.Unbounded_String;
   begin
      Response_Body := Petri_App.run (Event_Body)
      Response_Data := Create_Object;
      Set_Field (Response_Data, "isBase64Encoded", Create(false));
      Set_Field (Response_Data, "statusCode", Create(Integer(200)));
      Set_Field (Response_Data, "body", String_U.To_String (Response_Body));
      return Write (Response_Data);
   end Lambda_Handler;
end App;

Vous noterez que j’utilise le module JSON pour la lecture et la réponse. Dans l’idéal, il faudrait probablement que je gère lorsque les données en entrée sont encodées en Base64 et que je propage des événements XRay, mais cet article est déjà suffisamment long comme ça ! Je vous épargne également le code qui traite le XML et fait des recherches sur le réseau de Petri, il n’a pas d’intérêt dans cet article 😉

Conclusion

Voilà, j’ai donc un combo API Gateway + Lambda qui fait tourner du code que j’avais écrit en Ada il y a quasiment 10 ans. Personnellement j’avais trouvé ça rigolo à faire à l’époque, et amusant à adapter aujourd’hui.

J’en profite pour saluer les différents professeurs d’université dont j’ai eu le privilège de suivre les cours. Dix années plus tard, je constate que savoir écrire du code Ada ne m’est toujours pas utile « tel quel », et que connaître le fonctionnement d’un compilateur n’a pas d’intérêt « au quotidien ». Néanmoins, ce sont des connaissances qui sont toujours réjouissantes de posséder car j’en arrive souvent à devoir marcher sur le bord de l’autoroute pour faire fonctionner mes systèmes. Toutes ces choses « inutiles » que j’ai apprises contribuent à ma culture technique sur les langages de programmation, la compilation, les systèmes temps réels, l’embarqué, la sécurité et je serai bien démuni sans !

Le saviez-vous ? Certains enseignants-chercheurs ont des sabre laser dans leur bureau.

Commentaires :

A lire également sur le sujet :