L’une des forces d’AWS Lambda c’est son intégration avec les autres services d’AWS. Plus particulièrement, la capacité de déclencher l’exécution d’une fonction Lambda à partir d’un événement d’un service tiers est une fonctionnalité très intéressante qui permet d’enrichir ces services.

Un cas d’usage classique est par exemple le traitement automatique d’images déposées dans un bucket S3. Dans ce cas, la fonction Lambda peut redimensionner l’image, convertir le format, appliquer un watermark, appliquer des contrôle de contenu, etc… Comme ce cas a déjà été traité de nombreuses fois, nous allons prendre un autre exemple : l’analyse de documents HTML.

Dans l’exemple qui suit, nous allons mettre en place un bucket S3 dans lequel pourront être déposés des fichiers HTML. L’arrivée d’un nouveau fichier HTML va déclencher l’exécution d’une fonction Lambda qui va analyser le fichier pour en extraire des informations et les stocker.

Implémentation

Les fichiers HTML à analyser auront le format suivant :

<html>
<head>
  <title>Result search</title>
</head>
<body>
  <ul class="result">
    <li><a href="https://mycompany.com/item1.html">Item 1</a></li>
    <li><a href="https://mycompany.com/item2.html">Item 2</a></li>
    <li><a href="https://mycompany.com/item3.html">Item 3</a></li>
  </ul>
</body>
</html>

Pour analyser les documents HTML, la fonction Lambda utilisera le composant cheerio. De même, le composant async sera utilisé pour ordonnancer les traitements asynchrones :

  • récupération du fichier HTML depuis S3
  • analyse du fichier HTML
  • envoi vers S3 des fichiers JSON pour chaque élément trouvé

Le code Node.js de la fonction est donc le suivant :

var async = require('async');
var AWS = require('aws-sdk');
var util = require('util');
var cheerio = require('cheerio');
var s3 = new AWS.S3();

exports.handler = function(event, context, callback) {
  console.log('reading options from event: ' + util.inspect(event, {depth: 5}));
  var srcBucket = event.Records[0].s3.bucket.name;
  // calcul du bucket de destination
  var dstBucket = srcBucket.replace('downloader-inbox','json');
  var srcKey = decodeURIComponent(event.Records[0].s3.object.key.replace(/\+/g, " "));
    var typeMatch = srcKey.match(/\.([^.]*)$/);
    if (!typeMatch) {
        callback("Could not determine the file type.");
        return;
    }
  var fileExt = typeMatch[1];
  if (fileExt != "html") {
    callback('Unsupported file type: ${fileExt}');
    return;
  }
  var dstKey = srcKey.substr(0, srcKey.length - 5) + '.json';
  console.log('bucket=' + srcBucket + ',key=' + srcKey);

  // Télécharge le fichier HTML depuis S3, analyse le document et envoie vers S3 un fichier JSON par élément trouvé dans un bucket différent
  async.waterfall([
      function download(next) {
          // téléchargement du fichier depuis S3
          s3.getObject({
                  Bucket: srcBucket,
                  Key: srcKey
              },
              next);
          },
      function parse(response, next) {
          // recherche tous les résultats de recherche dans le document HTML
          var $ = cheerio.load(response.Body);
          var items = [];
          $('ul.result li a').each(function(i, element) {
            var id = i;
            var title = $(this).text();
            var link = $(this).attr('href');
            items.push({id: id, title : title, link : link});
          });
          next(null, 'application/json', items);
      },
      function upload(contentType, data, next) {
        // envoie vers S3 des fichiers JSON pour chaque résultat trouvé
          async.forEach(data, function(item, next) {
            console.log('putting item: ' + util.inspect(item, {depth: 1}));
            s3.putObject({
                Bucket: dstBucket,
                Key: item.id + '_' + dstKey,
                Body: JSON.stringify(item),
                ContentType: contentType
            },
            next);
          }
        }
      ], function (err) {
          if (err) {
              console.error(
                  'Unable to parse ' + srcBucket + '/' + srcKey +
                  ' and upload to ' + dstBucket + '/' + dstKey +
                  ' due to an error: ' + err
              );
          } else {
              console.log(
                  'Successfully parsed ' + srcBucket + '/' + srcKey +
                  ' and uploaded to ' + dstBucket + '/' + dstKey
              );
          }

          callback(null, "message");
      }
  );};

En considérant que cette fonction se trouve dans le fichier html_parser.js, il faut récupérer les dépendances et empaqueter le tout dans une archive ZIP :

$ npm install async cheerio
$ zip html_parser.zip html_parser.js

Déploiement

Comme vu pour le gestionnaire de téléchargement serverless, le déploiement nécessite de créer la fonction Lambda et lui donner les droits d’accès S3 :

# création du rôle pour la lambda
$ aws iam create-role \
>   --role-name myproject-lambdas \
>   --assume-role-policy-document file://./lambdas-role.json
{
    "Role": {
        "AssumeRolePolicyDocument": {
            "Version": "2012-10-17",
            "Statement": {
                "Action": "sts:AssumeRole",
                "Effect": "Allow",
                "Principal": {
                    "Service": "lambda.amazonaws.com"
                }
            }
        },
        "RoleId": "AROAJM7KLYMPXTRBEU74M",
        "CreateDate": "2016-07-28T21:29:35.709Z",
        "RoleName": "myproject-lambdas",
        "Path": "/",
        "Arn": "arn:aws:iam::client-id:role/myproject-lambdas"
    }
}

# attachement de la policy pour l'accès à S3
$ aws iam attach-role-policy \
  --role-name myproject-lambdas \
  --policy-arn "arn:aws:iam::aws:policy/AWSLambdaExecute"

# ajout de la policy pour l'accès aux logs
$ aws iam put-role-policy \
  --role-name myproject-lambdas \
  --policy-name myproject-lambdas-policy \
  --policy-document file://./lambdas-policy.json

$ export CLIENT_ID=client-id

# déploiement de la fonction Lambda
$ aws lambda create-function \
  --function-name MyHtmlParser  \
  --zip-file fileb://./html_parser.zip \
  --role arn:aws:iam::$CLIENT_ID:role/myproject-lambdas  \
  --handler html_parser.handler \
  --runtime nodejs4.3
{
    "CodeSha256": "lIpSULeUb/x3RrjfhW1iPCEajiUw73hMaLXjPF2jpe8=",
    "FunctionName": "MyHtmlParser",
    "CodeSize": 426,
    "MemorySize": 128,
    "FunctionArn": "arn:aws:lambda:eu-west-1:client-id:function:MyHtmlParser",
    "Version": "$LATEST",
    "Role": "arn:aws:iam::client-id:role/myproject-lambdas",
    "Timeout": 3,
    "LastModified": "2016-07-28T21:49:59.667+0000",
    "Handler": "html_parser.handler",
    "Runtime": "nodejs4.3",
    "Description": ""
}

Il est également nécessaire de créer 2 buckets S3 :

  • my-project-downloader-inbox : pour le stockage des fichiers HTML à analyser
  • my-project-json : pour le stockage du résultat de l’analyse au format JSON

Les buckets sont créés de cette façon :

# création du bucket S3 qui doit recevoir les fichiers HTML
$ aws s3 mb my-project-downloader-inbox
make_bucket: s3://my-project-downloader-inbox/

# création du bucket S3 qui doit recevoir les fichiers JSON
$ aws s3 mb my-project-json
make_bucket: s3://my-project-json/

Une fois la fonction lambda et les buckets créés, il faut configurer les notifications de réception de fichier dans le bucket et autoriser l’exécution de la fonction lambda par ce dernier. La configuration des notifications se fait via un fichier notification.json :

{
  "LambdaFunctionConfigurations": [
    {
      "Id": "MyHtmlToParseNotification",
      "LambdaFunctionArn": "arn:aws:lambda:eu-west-1:client-id:function:MyHtmlParser",
      "Events": ["s3:ObjectCreated:*"]
    }
  ]
}

Le paramétrage des notifications et l’application des droits d’exécution se fait ainsi :

# autorise le bucket à invoquer la fonction lambda
$ aws lambda add-permission \
  --function-name MyHtmlParser \
  --statement-id myproject-lambdas-permission \
  --action "lambda:InvokeFunction" \
  --principal s3.amazonaws.com \
  --source-arn arn:aws:s3:::my-project-downloader-inbox \
  --source-account $CLIENT_ID
{
    "Statement": "{\"Condition\":{\"StringEquals\":{\"AWS:SourceAccount\":\"client-id\"},\"ArnLike\":{\"AWS:SourceArn\":\"arn:aws:s3:::my-project-downloader-inbox\"}},\"Action\":[\"lambda:InvokeFunction\"],\"Resource\":\"arn:aws:lambda:eu-west-1:client-id:function:MyHtmlParser\",\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"s3.amazonaws.com\"},\"Sid\":\"myproject-lambdas-permission\"}"
}

# vérifie l’application de la policy
$ aws lambda get-policy --function-name MyHtmlParser

# ajoute les notifications sur le dépôt de fichier pour exécuter la fonction lambda
$ aws s3api put-bucket-notification-configuration \
  --bucket my-project-downloader-inbox \
  --notification-configuration file://./notification.json

Utilisation

Nous avons vu que l’exécution de cette fonction Lambda se déclenche sur la réception d’un fichier dans le bucket my-project-downloader-inbox. Il suffi donc de déposer un fichier HTML au bon format dans ce bucket pour déclencher l’exécution du traitement. Le résultat de ce traitement doit être disponible sous la forme de plusieurs fichiers JSON dans le bucket my-project-json.

# dépôt du fichier HTML dans le bucket source
$ aws s3 cp result.html s3://my-project-downloader-inbox
upload: .\result.html to s3://my-project-downloader-inbox/result.html

# vérification du résultat dans le bucket de destination
$ aws s3 ls my-project-json
2016-07-29 02:37:10         67 0_result.json
2016-07-29 02:37:10         67 1_result.json
2016-07-29 02:37:10         67 2_result.json

Nous pouvons constater que la fonction lambda a été exécutée automatiquement pour le fichier result.html où elle y a trouvé 3 résultats et qu’elle a stocké dans 3 fichiers JSON dans le second bucket S3.

Nettoyage

Si vous souhaitez supprimer tout ce qui a été créé pour cet exemple, il faut exécuter les commandes suivantes :

$ aws s3 rm s3://my-project-downloader-inbox  --recursive
$ aws s3 rm s3://my-project-json  --recursive
$ aws s3 rb s3://my-project-downloader-inbox
$ aws s3 rb s3://my-project-json

$ aws logs delete-log-group \
  --log-group-name /aws/lambda/MyHtmlParser

$ aws lambda delete-function \
  --function-name MyHtmlParser

$ aws iam delete-role-policy \
  --role-name myproject-lambdas \
  --policy-name myproject-lambdas-policy     

$ aws iam detach-role-policy \
  --role-name myproject-lambdas \
  --policy-arn "arn:aws:iam::aws:policy/AWSLambdaExecute"

$ aws iam delete-role \
  --role-name myproject-lambdas

Conclusion

Cet exemple simpliste a permis d’expérimenter deux fonctionnalités importantes d’AWS Lambda : déclenchement de l’exécution depuis un événement (ici S3) et l’intégration de dépendance pour le code de la lambda (cheerio et async pour ce cas).
Alors que le support de dépendance externe permet d’étendre les possibilités de traitement des fonctions Lambda, le déclenchement de ces dernières sur un événement d’un service AWS permet d’étendre le comportement de la plate-forme d’Amazon. Dès lors, l’ensemble des possibles se limite à votre créativité !