Tutoriel : architecture serverless avec AWS Lambda et Terraform

Tutoriel : architecture serverless avec AWS Lambda et Terraform

Objectifs

Il y a des modes en informatique et l’utilisation de “BuzzWord” est fréquente. Dans les derniers de ceux-ci, un terme revient souvent : “serverless”. Celui-ci n’est pas juste un concept abstrait ou une idée. En effet, certains fournisseurs de Cloud l’ont développé il y a quelques années. Qu’est-ce que c’est ? Est-ce une bonne chose ? Dois-je l’utiliser dans mon infrastructure ?

L’objectif de ce tutoriel est d’apprendre à monter une architecture serverless sur AWS. Nous verrons dans une première partie quelques définitions et principes généraux de cette infrastructure, puis nous verrons l’implémentation chez AWS et dans une dernière partie quelques astuces pour aller plus loin.

Le choix d’AWS peut être discuté, il est possible de refaire cette infrastructure sur GCP (Google Cloud Platform) en utilisant “Cloud Functions”.

Définitions & principes généraux

Serverless

Le premier point, quand on parle de “serverless” est que c’est très évidemment un abus de langage ! En effet, il y a forcément des serveurs à un endroit pour exécuter du code. Au-delà de ce constat, il faut comprendre par “serverless” qu’il n’y aura pas de serveurs à créer, installer, gérer, monitorer et surtout payer. Et c’est déjà pas mal !

Si l’on regarde l’évolution des services dans le cloud, c’est l’étape finale (pour l’instant) de l’évolution commencée avec la virtualisation. Il était possible de créer alors un serveur sans avoir besoin de le “racker”, de lier des câbles, d’espérer qu’il reste de la place… Ensuite, les conteneurs sont apparus et ont permis de rationaliser nos coûts d'infrastructure et de densifier nos systèmes d’information mais ils nécessitent quand même de créer et gérer des serveurs virtuels.

Avec une application serverless, je stocke du code c’est à dire une fonction ou plusieurs fichiers avec leurs librairies “dans le cloud”. “Dans le cloud” est une expression pour dire “quelque part” dans une région cloud, Ce code sera ensuite exécuté “à un moment donné”, c’est-à-dire quand on l'appellera, avec des paramètres d’entrées et nous délivrera un retour sous la forme d’un dictionnaire ou d’une chaîne de caractères.

Attention, en aucun cas il ne s’agit de dire qu’il faut utiliser en permanence la dernière “couche” technique. Pour simplifier le débat, je résumerai le choix d’utilisation pour une application finale ainsi :

  • serveur “réel” : besoin important de rapidité et de maîtrise du matériel pour accélérer les calculs (gros algorithmes, minage, traitement vidéo lourd)
  • serveur “virtuel” : applications statefull, monolithe ou webservices lourd, microservice avec des exigences de performances fortes
  • conteneur : microservice, applications stateless
  • serverless : nanoservice, application peu utilisée, service sans exigence de performance

Par exemple, dans mes affaires je gagne un ou deux clients par mois. Dans mon application interne, la fonction d’ajout d’un client n’est que peu utilisée : je peux utiliser une infrastructure serverless pour l’ajout des clients.

Il est également important de noter que vos fonctions ne sont pas immédiatement accessibles . Lors de leur utilisation, elles sont “chargées” dans le cloud de votre fournisseur et accessibles après chargement (on parle aussi de “cold start” ou de “warm-up”). Ce temps de chargement résulte dans un délai plus long lors de la première exécution dans un contexte. En cas de parallélisme, chaque exécution concurrente devra passer par ce “cold start”. Pour plus d’information, un excellent article sur la question est disponible ici.

Bien d’autres points peuvent être pris en compte : temps de développement, coût, matériels disponibles … N’hésitez pas à donner votre avis en commentaire.

AWS Lambda

Ce fut l’un des premiers fournisseurs de service serverless. AWS Lambda permet de stocker son code source en Java, Node.js, C# et Python (et même bientôt Golang).

Dans cet exemple, nous utiliserons Python qui semble avoir les meilleures performances dans cette liste de langages.
Une fonction Lambda peut être invoquée par différents outils AWS : SNS, API Gateway, Cloudwatch… de nouvelles possibilités arrivant tous les jours je ne les citerai pas toutes ici.

La gestion des logs et du monitoring est directement présente dans AWS Cloudwatch avec un minimum de configuration.

Une fonction Lambda peut même être stockée dans le “Edge” d’AWS c’est à dire au plus près de vos utilisateurs.Plus d’informations sur Lambda edge par ici.

Par exemple en python, un “hello world” :

#!python

def handler(event, _):
    ret = {}
    ret['statusCode'] = 200
    ret['body'] = json.dumps({‘hello’:’world’})
    ret['headers'] = {"Content-Type": "application/json"}
    return ret

La fonction, pour un webservice doit renvoyer un dictionnaire avec quelques clefs : 'statusCode', 'body' et 'headers'.

Terraform

Terraform est un outil permettant de créer des objets dans le cloud, ici utilisé avec le cloud AWS. Cela simplifie l’utilisation d’AWS et rend nos scripts d’”Infrastructure as Code” idempotents.

Un langage avec un formalisme simple permet de créer et configurer des objets.

Des objets “output” me permettent d’exposer des sorties à mon exécution.

Par exemple pour créer un VPC dans AWS :

resource "aws_vpc" "demo_vpc" {
    cidr_block = "${var.vpc_cidr}"
    enable_dns_hostnames = true
    enable_dns_support = true
}

Le choix de Terraform pour créer cette infrastructure peut être discuté : certains outils permettent d’automatiser les créations d’infrastructure serverless. Cependant ces outils permettent moins de configuration - en tout cas pas celle dont j’avais besoin au moment où j’ai regardé - et j’aurais ajouté encore un outil de plus car Terraform était de toute façon nécessaire pour créer le reste de l’infrastructure.

Pour scripter les actions après l’exécution de Terraform en récupérant les valeurs de retour de l’outil et ses “outputs” on peut utiliser : python-terraform.

AWS API Gateway

AWS propose sa version d’une API Gateway. Cet outil permet d’établir un point d’entrée dans une infrastructure AWS et va pouvoir gérer un routage HTTP, une terminaison SSL, une authentification des requêtes voire des conversions de données d’entrée et de sortie.

C’est dans cet outil que nous allons définir l’ensemble de notre routage HTTP, de nos paths et de nos verbes. C’est également lui qui va déclencher nos fonctions serverless, leur communiquer les données d’entrée et récupérer puis transférer les sorties des fonctions.

Vous l’aurez compris : il est impossible d’utiliser des Lambda en tant que webservices sans passer au minimum par un point d’entrée dans API Gateway.

Implémentation et développement

Dans ce chapitre nous allons parler du développement de l’infrastructure et d’un webservice ainsi que des moyens possibles d’établir une intégration et un déploiement en continu.

L’objectif de cette application est un webservice permettant une gestion des utilisateurs, plusieurs paths et verbes sont définis :

  • GET /demo/users: récupère la liste des utilisateurs
  • POST /demo/users: crée un utilisateur
  • DELETE /demo/users: supprime tous les utilisateurs
  • GET /demo/users/{userId}: récupère les données d’un utilisateur
  • DELETE /demo/users/{userId}: supprime un utilisateur
  • PATCH /demo/users/{userId}: modifie partiellement un utilisateur

{userId} est une variable passée dans l’URL de notre appel.
Pour cet exemple, les données seront stockées dans un Redis.

Infrastructure

Nous pouvons définir deux étapes dans la construction de notre infrastructure : une première est la création de l’ensemble des objets AWS nécessaire à la sécurité, aux données et à l’accès à nos Lambda.

La deuxième étape sera de créer nos Lambda et l’ensemble des objets d’API Gateway et de faire les liens entre eux.

Base de l’infrastructure

Nous allons donc commencer par créer :

  • un VPC,
  • une internet gateway,
  • une NAT gateway, cette instance est nécessaire pour que les Lambda puissent être accessibles depuis l’API Gateway
  • des réseaux “publics”, c’est à dire qu’ils peuvent accéder à des objets sur internet via l’internet gateway et des réseaux mais également qu’on pourrait y accéder depuis internet selon certains critères
  • des réseaux “privés”, où nous pourrons mettre nos bases de données
  • enfin on définit les groupes de sécurité qui définissent nos règles de firewalling
resource "aws_vpc" "demo_vpc" {
    …
}
resource "aws_internet_gateway" "demo_vpc_igtw" {
    vpc_id = "${aws_vpc.demo_vpc.id}"
}
resource "aws_eip" "demo_nat_gw_eip" {
  vpc      = true
}
resource "aws_nat_gateway" "demo_nat_gw" {
  subnet_id     = "${aws_subnet.demo_sn_public_a.id}"
  ...
}

Il n’y a aucune différence entre un réseau “public” et “privé” dans leur création.

resource "aws_subnet" "demo_sn_public_a" {
    vpc_id = "${aws_vpc.demo_vpc.id}"
    …
}
resource "aws_subnet" "demo_sn_private_a" {
    vpc_id = "${aws_vpc.demo_vpc.id}"
   ...
}

Le réseau public pourra ainsi accéder à l’internet gateway.

resource "aws_route_table" "demo_vpc_rt_public" {
    vpc_id = "${aws_vpc.demo_vpc.id}"
    route {
        cidr_block = "0.0.0.0/0"
        gateway_id = "${aws_internet_gateway.demo_vpc_igtw.id}"
    }
}
resource "aws_route_table_association" "demo_vpc_rta_public_a" {
    subnet_id = "${aws_subnet.demo_sn_public_a.id}"
    route_table_id = "${aws_route_table.demo_vpc_rt_public.id}"
}

Le réseau “privé” accédera à la NAT gateway (donc nos Lambda).

resource "aws_route_table" "demo_vpc_rt_private" {
    vpc_id = "${aws_vpc.demo_vpc.id}"
    route {
        cidr_block = "0.0.0.0/0"
        nat_gateway_id = "${aws_nat_gateway.demo_nat_gw.id}"
    }
}
resource "aws_route_table_association" "demo_vpc_rta_private_a" {
    subnet_id = "${aws_subnet.demo_sn_private_a.id}"
    route_table_id = "${aws_route_table.demo_vpc_rt_private.id}"
}

Par exemple, nous voulons autoriser nos Lambda à accéder à un Redis :

resource "aws_security_group" "demo_lambda_sg" {
    vpc_id = "${aws_vpc.demo_vpc.id}"
}
resource "aws_security_group_rule" "demo_lambda_sg_allow_redis_ingress" {
  type            = "ingress"
  from_port   = 6379
  to_port       = 6379
  protocol     = "tcp"
  source_security_group_id = "${aws_security_group.demo_sg_private.id}"
  security_group_id = "${aws_security_group.demo_lambda_sg.id}"
}
resource "aws_security_group_rule" "demo_lambda_sg_allow_redis_egress" {
  type            = "egress"
  from_port   = 6379
  to_port       = 6379
  protocol     = "tcp"
  source_security_group_id = "${aws_security_group.demo_sg_private.id}"
  security_group_id = "${aws_security_group.demo_lambda_sg.id}"
}

Il faut également créer des rôles IAM pour autoriser nos lambdas à :

  • écrire dans Cloudwatch
  • créer une interface réseau au sein de nos réseaux privés
  • échanger avec API Gateway.
    Nous devons donc définir une “policy” :
statement {
    actions = [
          "logs:CreateLogGroup",
          "logs:CreateLogStream",
          "logs:PutLogEvents",
          "ec2:CreateNetworkInterface",
          "ec2:DescribeNetworkInterfaces",
          "ec2:DeleteNetworkInterface",
          "apigateway:Get",
      ]
    effect = "Allow"
  }
  statement {
    actions = ["lambda:InvokeFunction"]
    effect = "Allow"
  }

et une “assume-role policy” :

 statement {
    actions = ["sts:AssumeRole"]
    effect = "Allow"
    principals {
      type        = "Service"
      identifiers = ["lambda.amazonaws.com", "apigateway.amazonaws.com"]
    }
  }

Pour qu’API Gateway puisse écrire des “events” dans Cloudwatch, nous devons également créer un rôle et le configurer dans API Gateway :

resource "aws_api_gateway_account" "demo_account_settings" {
  cloudwatch_role_arn = "${aws_iam_role.demo_role_cloudwatch_for_apigateway.arn}"
}

Vous trouverez l’exemple complet ici, comme vous le constatez un réseau existe dans chaque Availability-Zone disponible pour gérer la haute disponibilité et la résilience de nos infrastructures.

L’infrastructure API Gateway et Lambda

Pour commencer nous devons déclarer notre API Rest dans AWS API Gateway.

resource "aws_api_gateway_rest_api" "demo" {
  name = "demo"
}

Nous allons définir trois niveaux de ressource dans notre API : demo, users et {userId}. Ici “demo_ressource” définit en parent_id notre API Rest défini précédemment.

resource "aws_api_gateway_resource" "demo_ressource" {
  rest_api_id = "${aws_api_gateway_rest_api.demo.id}"
  parent_id = "${aws_api_gateway_rest_api.demo.root_resource_id}"
  path_part = "demo"
}

resource "aws_api_gateway_resource" "demo_users_resource" {
  rest_api_id = "${aws_api_gateway_rest_api.demo.id}"
  parent_id = "${aws_api_gateway_resource.demo_ressource.id}"
  path_part = "users"
}

resource "aws_api_gateway_resource" "demo_users_userid_resource" {
  rest_api_id = "${aws_api_gateway_rest_api.demo.id}"
  parent_id = "${aws_api_gateway_resource.demo_users_resource.id}"
  path_part = "{userId}"
}

Pour chaque couple “path” et “verbe” nous devons définir une Lambda et une méthode puis autoriser et intégrer l’un à l’autre.

Pour définir la Lambda :

resource "aws_lambda_function" "demo_users_get_function" {
  runtime = "python3.6"
  role = "${data.terraform_remote_state.layer_base.iam_lambda_role}"
  handler = "index_get.handler"
  publish = true
  timeout = 120

  vpc_config = {
    subnet_ids = [
      "${data.terraform_remote_state.layer_base.sn_private_a_id}", 
      "${data.terraform_remote_state.layer_base.sn_private_b_id}", 
      "${data.terraform_remote_state.layer_base.sn_private_c_id}", 
    ]
    security_group_ids = [
      "${data.terraform_remote_state.layer_base.sg_sn_lambda_id}", 
    ]
  }
  environment = {
    variables = {
      "REDIS_DNS" = "${aws_elasticache_cluster.demo_redis.cache_nodes.0.address}"
      "REDIS_PORT" = "${aws_elasticache_cluster.demo_redis.port}"
    }
  }
}

Elle sera exécutée avec le runtime python3.6 sur les réseaux privés et les droits firewall sont indiqués par le groupe de sécurité créé précédemment.

Le handler définit le fichier et la fonction à exécuter : ici dans le fichier “index_get.py” et la fonction “handler”. Nous verrons comment déployer un peu plus loin.

À noter que pour que la fonction puisse lire et écrire dans Redis, l’adresse et le port sont définis dans des variables d’environnement très facilement récupérable en python.

Il faut ensuite créer le verbe HTTP dans API Gateway. Ici le verbe GET est lié à la ressource “/demo/users”.

resource "aws_api_gateway_method" "demo_users_get_method" {
  rest_api_id = "${aws_api_gateway_rest_api.demo.id}"
  resource_id = "${aws_api_gateway_resource.demo_users_resource.id}"
  http_method = "GET"
  authorization = "NONE"
}

Dans le cas où un paramètre est définit dans le path, il faut ajouter :

resource "aws_api_gateway_method" "demo_users_userid_delete_method" {
  ...
  request_parameters = {
    "method.request.path.userId" = true
  }
}

Ensuite, il faut définir une intégration entre la Lambda et l’appel à l’API Gateway. Dans ce cas le lien entre le verbe “GET” et la ressource “/demo/users” sera de type “AWS_PROXY” pour configurer que ces deux services AWS puissent échanger entre eux.

Attention : dans le cas d’un appel à Lambda, le paramètre “integration_http_method” doit toujours être un “POST” car c’est une invocation de la Lambda, donc une création. À ne pas confondre avec le “GET” défini dans la méthode.

resource "aws_api_gateway_integration" "demo_users_get_integration" {
  rest_api_id = "${aws_api_gateway_rest_api.demo.id}"
  resource_id = "${aws_api_gateway_resource.demo_users_resource.id}"
  http_method = "${aws_api_gateway_method.demo_users_get_method.http_method}"
  type = "AWS_PROXY"
  uri = "arn:aws:apigateway:${var.region}:lambda:path/2015-03-31/functions/arn:aws:lambda:${var.region}:${var.account_id}:function:${aws_lambda_function.demo_users_get_function.function_name}/invocations"
  integration_http_method = "POST"
}

Toujours dans le cas où un paramètre est défini dans le path, il faut ajouter :

resource "aws_api_gateway_integration" "demo_users_userid_delete_integration" {
  ...
  request_templates = {                  
    "application/json" =  <<REQUEST_TEMPLATE
      {
        "userId": "$input.params('userId')"
      }
    REQUEST_TEMPLATE
  }
}

Pour que ces appels puissent fonctionner il faut autoriser ces objets de l’API Gateway à discuter avec cette Lambda :

resource "aws_lambda_permission" "demo_users_get_function_allow_api_gateway" {
  depends_on = [
    ...
  ]
  function_name = "${aws_lambda_function.demo_users_get_function.function_name}"
  statement_id = "AllowExecutionFromApiGateway_users_get"
  action = "lambda:InvokeFunction"
  principal = "apigateway.amazonaws.com"
  source_arn = "arn:aws:execute-api:${var.region}:${var.account_id}:${aws_api_gateway_rest_api.demo.id}/${aws_api_gateway_deployment.demo_env.stage_name}/${aws_api_gateway_method.demo_users_get_method.http_method}${aws_api_gateway_resource.demo_users_resource.path}"
}

Enfin, nous déployons sur un environnement, il est recommandé de définir une dépendance vers toutes les méthodes et intégrations.

resource "aws_api_gateway_deployment" "demo_env" {
  depends_on = [
    # users get
    "aws_api_gateway_method.demo_users_get_method",
    "aws_api_gateway_integration.demo_users_get_integration",
    ...
  ]
  rest_api_id = "${aws_api_gateway_rest_api.demo.id}"
  stage_name = "dev"
}

Nous configurons notre environnement pour avoir des events dans Cloudwatch :

resource "aws_api_gateway_method_settings" "demo_env_settings" {
  rest_api_id = "${aws_api_gateway_rest_api.demo.id}"
  stage_name  = "${aws_api_gateway_deployment.demo_env.stage_name}"
  method_path = "*/*"
  settings {
    metrics_enabled = true
    logging_level   = "INFO"
  }
}

Attention : lorsque vous relancez Terraform pour modifier votre structure API Gateway, il ne redéploie pas par défaut dans votre environnement. Vous devez le faire soit via la console, soit via l’awscli en shell soit via la librairie boto3 en python.

Vous trouverez l’exemple entier ici.

Développement

Nous devons maintenant écrire le code de nos Lambda.
Comme vu précédemment, nous avons à définir une fonction “handler” qui renvoie un dictionnaire.

Les variables d’environnement sont disponibles grâce au dictionnaire “os.environ”.

#!python
"""
file for /demo/users GET
"""
import os
import json
import uuid
import redis
from redis.exceptions import ConnectionError

def handler(event, _):
    """
    handler for /demo/users GET
    """
    ret = {}

    try:
        redis_conn = redis.StrictRedis(host=str(os.environ['REDIS_DNS']), port=os.environ['REDIS_PORT'], db=0, decode_responses=True )
        users = []
        usersid = redis_conn.smembers("users")
        if usersid:
            for userid in list(usersid):
                user = redis_conn.get("user_"+userid)
                if user:
                    users.append(json.loads(user))

        ret['statusCode'] = 200
        ret['body'] = json.dumps(users)

    except ConnectionError as inst:
        ret['statusCode'] = 500
        ret['body'] = json.dumps({
            "error": inst.args
        })

    ret['headers'] = {"Content-Type": "application/json"}
    return ret

Pour récupérer les variables passées dans l’URL, il faut utiliser la variable “event” qui nous est passée par la signature du handler.

…
def handler(event, context):
    """
    GET /users/{userId}
    """
    userid = event['pathParameters']['userId']
    ...

Tout le code pour le webservice est disponible ici.

CI-CD

Nous voulons maintenant packager nos fonctions pour permettre un déploiement automatisable. Vous pouvez voir les fonctions ici.
Lambda permet d’utiliser des archives compressées depuis un bucket S3. C’est la solution de stockage objet d’AWS.

Pour gérer les dépendances, il suffit de compresser avec les fichiers l’ensemble de nos librairies. Pour cela vous pouvez éditer un fichier “requirements.txt” :

redis == 2.10.6

et d’installer localement :

pip install -r requirements.txt -t .

Ensuite il suffit de compresser et de copier votre archive sur S3.

Lors de la création de notre Lambda, nous allons indiquer que le code de celle-ci est disponible sur un bucket S3.

resource "aws_lambda_function" "demo_users_get_function" {
  function_name = "demo_users_get_function"
  handler = "index_get.handler"
  s3_bucket = "${var.s3_bucket_package}"
  s3_key = "users/users-lambda-${var.version_users}.zip"
  ...
}

Lors du redéploiement d’une Lambda, plusieurs solutions sont possibles :

  • un paramètre “hash” est modifié dans la fonction Lambda qui entraine un redéploiement
  • un appel à l’API d’AWS via awscli ou boto3

La première méthode nécessite de modifier le code Terraform à chaque déploiement. La deuxième est plus simple à mettre en oeuvre :

aws lambda update-function-code \
  --function-name demo_users_get_function \
  --s3-bucket demo-handson-serverless-package \
  --s3-key $project/$project-lambda-$version.zip \
  --publish

Pour aller plus loin

Gestion des DNS

Pour une meilleure intégration avec vos outils ou avec vos clients, il est utile de lier votre URL fournie par API Gateway avec un nom de domain stable.

En fin de configuration ci dessus, l’URL ressemblera à : https://0zl15u01pi.execute-api.eu-west-1.amazonaws.com/dev où “0zl15u01pi” est l’id de votre API Rest dans API Gateway et “/dev” est le nom du déploiement dans l’API Gateway.

Je cherche à avoir le nom suivant: “https://dev.wescale.fr/users” pour mon API. Je pourrais avoir en production “https://wescale.fr/users” qui redirige automatique sur “https://prd.wescale.fr/users”. Note pour ceux qui ont copié les liens : ces chemins n’existent pas c’est un exemple ;-)

On commence par récupérer le domaine défini dans Route53.

C’est l’outil qui gère les DNS chez AWS.

data "aws_route53_zone" "aws_public_zone" {
  name         = "wescale.fr."
  private_zone = false
}

On crée un alias dans API Gateway vers notre choix d’URL.
Pour être compatible avec l’utilisation de HTTPS, vous devez fournir un certificat.

Attention : il sera utilisé par Cloudfront et doit donc toujours être dans la région “us-east-1” même si votre infrastructure est dans une autre zone.

resource "aws_api_gateway_domain_name" "dns_public_env_name" {
  domain_name = "dev.wescale.fr"
  certificate_arn = "arn:aws:acm:us-east-1:***:certificate/***"
}

Puis, on crée un lien entre le déploiement de l’API Gateway et l’URL choisie.

resource "aws_api_gateway_base_path_mapping" "dns_public_env_name_mapping" {
  api_id      = "${aws_api_gateway_rest_api.dms.id}"
  stage_name  = "${aws_api_gateway_deployment.demo_env.stage_name}"
  domain_name = "${aws_api_gateway_domain_name.dns_public_env_name.domain_name}"
}

Dernière étape : on crée un enregistrement DNS sur l’alias défini par l’API Gateway.

resource "aws_route53_record" "dns_public_env_name" {
  zone_id = "${data.aws_route53_zone.aws_public_zone.zone_id}"
  name    = "${aws_api_gateway_domain_name.dns_public_env_name.domain_name}"
  type    = "A"
  alias {
    name = "${aws_api_gateway_domain_name.dns_public_env_name.cloudfront_domain_name}"
    zone_id = "${aws_api_gateway_domain_name.dns_public_env_name.cloudfront_zone_id}"
    evaluate_target_health = true
  }
}

Autorisation

Il existe plusieurs types d’”authorizer” pour vos architectures serverless dans AWS. API Gateway propose plusieurs types d'autorisation :

  • Aucune authentification, ce qui est fait dans les exemples précédents.
  • Vous pouvez lier l’autorisation à AWS Cognito.
  • Vous pouvez utiliser une Lambda “custom”. Dans ce modèle vous pouvez juste vérifier un TOKEN ou bien authentifier chaque REQUEST via des données dans un header.

C’est ce dernier modèle que nous allons développer ci-dessous. J’ai choisi ce cas d’utilisation parce que l’utilisateur ne fait que peu d’appels à ce webservice et ne souhaite pas générer de token à chaque appel.

Le principe de fonctionnement est assez simple :

  • On récupère le header contenant l’authentification.
  • Si l’utilisateur n’existe pas dans notre modèle, une exception est levée. Elle provoque un code de retour 401 “Unauthorized”.
  • La fonction génère ensuite un json ressemblant à une policy IAM spécifiant tous les droits de cet utilisateur pour chaque route et chaque verbe.
  • API Gateway compare la demande initiale avec cette structure, si l’utilisateur n’a pas les droits sur cette route ou sur ce verbe, il reçoit une erreur 403 “Forbidden”.

Dans la suite nous allons créer une infrastructure et développer notre Lambda “custom”.

Infrastructure

Nous devons commencer par créer une fonction Lambda. Rien de particulier lors de cette création par rapport à nos autres Lambda. À noter que j’ai choisi ici d’utiliser le même rôle IAM associé mais ce n’est pas une obligation.

resource "aws_lambda_function" "authorizer" {
  runtime = "python3.6"
  role = "${aws_iam_role.demo_role.arn}"
  function_name = "authorizer"
  handler = "index.handler"
  s3_bucket = "..."
  s3_key = "..."
  ...
}

On doit créer un rôle permettant à l’”authorizer” d’être exécuté depuis l’API Gateway. En effet c’est ce produit qui invoque la Lambda pour être notifié de son statut.

data "aws_iam_policy_document" "dms_assume_role_policy_doc" {
  statement {
    actions = ["sts:AssumeRole"]
    effect = "Allow"
    principals {
      type        = "Service"
      identifiers = ["apigateway.amazonaws.com"]
    }
  }
}
data "aws_iam_policy_document" "dms_authorizer_invocation_policy_doc" {
  statement {
    actions = ["lambda:InvokeFunction"]
    effect = "Allow"
    resources = ["${aws_lambda_function.authorizer.arn}"]
  }
}

Puis on déclare l’”authorizer” dans l’API Gateway. On le lie à notre API REST et au rôle créé précédemment. Comme expliqué ci dessous, l’”authorizer” est de type “REQUEST” et non “TOKEN”.

resource "aws_api_gateway_authorizer" "authorizer" {
  name                   = "authorizer"
  rest_api_id            = "${aws_api_gateway_rest_api.demo.id}"
  authorizer_uri         = "${aws_lambda_function.authorizer.invoke_arn}"
  authorizer_credentials = "${aws_iam_role.authorizer_invocation_role.arn}"
  type = "REQUEST"
}

Enfin, vous devez ajouter sur chaque verbe (method) de votre API le lien vers l’authorizer.

resource "aws_api_gateway_method" "demo_users_get_method" {
  ...
  # authorization = "NONE"
  authorization = "CUSTOM"
  authorizer_id = "${aws_api_gateway_authorizer.authorizer.id}"
}

Développement

La première des étapes est de récupérer les données dans un header, par exemple :

def handler(event, context):
    userEncoded = event['headers']['Authorization']

Après un test de cette autorisation, s’il n’y a pas d’utilisateur correspondant, il suffit de lever l’exception.

raise Exception('Unauthorized')

Pour la génération du json de policy, vous trouverez un exemple simple,comprenant de nombreuses fonctions utiles sur le Gitlab d’AWSLABS.

Conclusion

L’avenir des architectures serverless est plein de promesses. C’est une nouvelle gamme de services Cloud permettant de s’adapter toujours plus au besoin mais également d’optimiser vos coûts d’infrastructure. Il faudra, comme toujours, faire attention au contexte et au cas d’utilisation pour en profiter au mieux.

Cette architecture reporte un grand nombre de problèmes des développeurs métiers aux développeurs d’infrastructure. En cela, elle rend indispensable l’émergence de pratiques DevOps, voire noOps si votre infrastructure est complètement serverless.

Dû au nombre important d’objets AWS générés, je vous recommande d’utiliser le layering dans Terraform, pour mieux comprendre les avantages et les inconvénients, je vous recommande cet article.

L’ensemble du code source est disponible ici.