Tutoriel localstack (partie 1) - Comment répliquer AWS en local

Logo localstack

Introduction

Cet article à été mis à jour début 2020 en utilisant la version 0.10.7 de localstack

J’ai passé pas loin de 2 années en tant qu’ingénieur dans une startup de Nouvelle-Zélande. Notre environnement de développement tournait sous Vagrant pendant un certain temps. A l’époque, 3 services AWS étaient en production : Ec2 (les VMs), Cache Cluster (redis) et RDS (mysql). Ces services étaient installés dans notre environnement local (la machine virtuelle Vagrant).

Puis nous nous avons commencé à utiliser le service de stockage S3, et donc créer des bucket S3. Nous avons rapidement trouvé un moyen de ne pas perturber nos environnements de développement en créant des buckets “locaux” qui n’étaient en réalité que des bucket AWS bien en ligne, simplement nommés “locaux”. Puis nous avons utilisés les services AWS Lambdas, DynamoDB, suivi de SNS puis SES.

Au fur et à mesure que la complexité de notre environnement de production augmentait, notre capacité à déployer du code rapidement et en tout confiance se dégradait. Nous nous sommes rapidement retrouvé bloqué avec un environnement de développement mal adapté à nos contraintes de production, en ayant du mal à suivre le rythme de tous ces nouveaux services AWS, et en étant forcé du modifier notre code pour pallier aux services AWS manquants en local.

Jusqu’à ce que nous tombions sur un nouveau problème. Une fonction lambda nécessitait une connexion directe à la base de donnée Mysql, ce qui était impossible à répliquer ou à contourner dans nos machines virtuelles locales.

La première solution envisagée fut de délocaliser nos environnements de développement dans le cloud, mais cela aurait été très couteux.

Cet article présente la solution que nous avons mis en place à l’aide de localstack. Il s’agit d’un article technique et vous aurez besoin d’être familier avec docker, docker-compose ainsi que quelques services AWS pour le suivre.

Pour illustrer notre architecture ce tutoriel s’appuie sur une application web très simple mais qui pose toutes les bases des problèmes que nous avons eu à surmonter. Il montre comment localstack fonctionne, et comment il est possible d’interagir avec de la même manière que vous le feriez avec des resources AWS, mais tout ceci en local.

Il est tout a fait possible de travailler sur un environnement de développement “hybride” avec localstack fonctionnant sous docker et le reste en machine virtuelle ou physique, mais cela complexifierait la configuration réseau.

Dans ce tutoriel, tout est dockerisé.

Tous les fichiers de l’installation complète et condensée sont disponibles sur ce repository github

Installation

Tout d’abord, créons le fichier docker-compose.yml qui composera nos services:

version: '3.3'

networks:

  default:
    external:
      name: localstack-tutorial

volumes:

  localstack:

services:

  localstack:
    image: localstack/localstack:0.9.0
    ports:
      - 8080:8080
      - 4569:4569 # dynamodb
      - 4574:4574 # lamba
    environment:
      - DATA_DIR=/tmp/localstack/data
      - DEBUG=1
      - DEFAULT_REGION=ap-southeast-2
      - DOCKER_HOST=unix:///var/run/docker.sock
      - LAMBDA_EXECUTOR=docker-reuse
      - PORT_WEB_UI=8080
      - SERVICES=lambda,dynamodb
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - localstack:/tmp/localstack/data

Prenez le temps de lire cette configuration.

Dans ce scénario, le réseau docker (localstack-tutorial) est un réseau “externe” et doit donc être créer au préalable. Il est tout à fait possible de laisser docker-compose créer un réseau par défaut, auquel cas le nom de ce réseau sera basé sur le nom du dossier contenant le fichier docker-compose.yml. Si vous travaillez en équipe, chaque personne pourrait avoir un nom de dossier différent. Le nom du réseau serait alors différent entre les membres de l’équipe. Pour interagir avec les services de localstack, votre application va devoir connaître ce réseau. Avoir un nom de reseau local defini identiquement pour tous permet ainsi d’éviter une couche de configuration supplémentaire par poste de développement.

Pour créer ce réseau, exécutez la commande suivante:

docker network create localstack-tutorial

Regardons les autres parties du fichier docker-compose:

  • Le port 8080 expose une interface web permettant d’avoir un aperçu des ressources déployées.
  • Les autres ports permettent de rendre les services accessibles depuis la machine hôte. La liste de tous les services est disponible ici: https://github.com/localstack/localstack#overview
  • Les données persistentes seront accessibles dans un volume nommé (“named volume” en anglais), faisant référence au chemin /tmp/localstack/ à l’intérieur du conteneur localstack.
  • La variable d’environnement LAMBDA_EXECUTOR est définie à docker-reuse. De cette manière un conteneur docker sera crée et réutilisé pour chaque fonction lambda invoquée. De cette manière l’appel aux fonction lambda sera grandement accéléré. Si une fonction n’est pas invoquée pendant 10 minutes, le conteneur est détruit par localstack.

Le reste du fichier s’explique par lui même.

Mainenant exécutez docker-compose up -d suivi de docker-compose logs -f localstack, puis attendez que localstack soit prêt.

Vous pouvez accéder à l’interface web de localstack via http://localhost:8080.

Deployer une table DynamoDB

Il est temps de déployer notre première ressource. Pour cela vous avez besoin du cli AWS installé sur votre machine. https://aws.amazon.com/cli/

Créons une table DynamoDB. Elle sera nommée table_1 et aura un attribut requis - id - qui fera office de clé de hash:

aws dynamodb create-table \
  --endpoint-url http://localhost:4569 \
  --table-name table_1 \
  --attribute-definitions AttributeName=id,AttributeType=S \
  --key-schema AttributeName=id,KeyType=HASH \
  --provisioned-throughput ReadCapacityUnits=20,WriteCapacityUnits=20

Remarquez l’url localhost:4569 faisant référence à notre service DynamoDB présent via le conteneur localstack, et exposé sur le port 4569. Naviguer sur http://localhost:8080 à nouveau. Vous devriez voir la table nouvellement créée. Encore mieux, vous pouvez utiliser le cli comme vous le feriez pour interagir avec des vraies ressources AWS sur le cloud.

aws dynamodb list-tables --endpoint-url http://localhost:4569

Ceci devrait afficher:

{
    "TableNames": [
        "table_1"
    ]
}

Déploiement d’une fonction lambda

Une bonne manière d’illustrer comment localstack peut servir comme un véritable environnement AWS est de faire interagir des ressources entres elles.

Prenons l’exemple d’une fonction lambda (en nodejs) qui nécessiterait de lire des données venant d’une table DynamoDB.

Créez un répertoire lambda/ contenant un fichier main.js.

mkdir lambda
touch lambda/main.js

Puis copiez-collez le contenu suivant dans le fichier main.js:

'use strict';

const AWS = require('aws-sdk');

AWS.config.update({
    region: 'ap-southeast-2',
    endpoint: 'http://localstack:4569'
});

class DynamoDBService {
    constructor() {
        this.docClient = new AWS.DynamoDB.DocumentClient({ apiVersion: '2012-08-10' });
    }

    async increment(id) {
        return new Promise(async (resolve, reject) => {
            try {
                const count = await this.getCount(id);
                var params = {
                    TableName: 'table_1',
                    Item: {
                        count: count + 1,
                        id: id
                    }
                };

                this.docClient.put(params, function(err, data) {
                    if (err) {
                        reject(err);
                    } else {
                        resolve(data);
                    }
                });
            } catch (err) {
                reject(err);
            }
        });
    }

    async getCount(id) {
        return new Promise(async (resolve, reject) => {
            var params = {
                TableName: 'table_1',
                Key: {id}
            };

            this.docClient.get(params, function(err, data) {
                if (err) {
                    reject(err);
                } else {
                    resolve(data['Item'] ? data['Item']['count'] : 0);
                }
            });
        });
    }
}

exports.handler = async (event, context, callback) => {
    try {
        const dynamoDBService = new DynamoDBService();
        await dynamoDBService.increment(event.id);
        callback(null, {});
    } catch (error) {
        callback(error);
    }
}

Cette fonction lambda est assez stupide en soi. Elle met à jour un élément de la table table_1. Si cet élément existe, sa valeur count est incrémentée. Sinon l’élément est créé et la valeur de count est définie à 0.

Remarquez dans le code l’url du service DynamoDB définie à http://localstack:4569 et non pas http://localhost:4569. http://localstack fait référence au nom du service docker-compose. C’est grâce à cela que le conteneur lambda peut résoudre l’adresse IP du conteneur localstack.

Créons une archive à partir de ce fichier, puis déployons la :

cd lambda
zip -r ../lambda.zip .
cd ..
aws lambda create-function \
  --function-name counter \
  --runtime nodejs8.10 \
  --role fake_role \
  --handler main.handler \
  --endpoint-url http://localhost:4574 \
  --zip-file fileb://$PWD/lambda.zip

Invoquons la fonction lambda, et vérifions ce qu’il se passe :

aws lambda invoke --function-name counter --endpoint-url=http://localhost:4574 --payload '{"id": "test"}' output.txt
# in another terminal
docker-compose logs -f localstack

Si tout fonctionne correctement… vous devriez voir l’erreur suivant dans l’ensemble des logs :

Inaccessible host: localstack. This service may not be available in the ap-southeast-2 region.

De quoi s’agit-il? C’est simplement car localstack démarre un conteneur en dehors du réseau localstack-tutorial. De fait, le conteneur lambda et le conteneur localstack ne peuvent pas communiquer. Heureusement depuis novembre 2018, vous pouvez définir la variable LAMBDA_DOCKER_NETWORK pour les connecter entre eux.

Le conteneur lambda va essayer d’exécuter la fonction 3 fois de suite avant d’échouer. Il s’agit du comportement par défaut dans AWS, qui a été répliqué dans le service lambda.

Mettons à jour le fichier docker-compose.yml en ajoutant la variable d’environnement au service localstack : LAMBDA_DOCKER_NETWORK=localstack-tutorial

Exécutez docker-compose up -d pour appliquer ces modifications. Vérifiez les logs pour être certain que localstack soit prêt. Malheureusement les fonctions lambdas ne sont pas persistés sur le disque. Cela signifie qu’il faut déployer les fonctions à chaque redémarrage. Exécutez la commande précédente create-function une fois encore, puis invoquez la fonction lambda une seconde fois. Plus d’erreur désormais! Vous pouvez même scanner la table DynamoDB pour vérifier que tout est ok.

aws dynamodb scan --endpoint-url http://localhost:4569 --table-name table_1

Cela devrait afficher:

{
    "Count": 1,
    "Items": [
        {
            "count": {
                "N": "1"
            },
            "id": {
                "S": "test"
            }
        }
    ],
    "ScannedCount": 1,
    "ConsumedCapacity": null
}

Invoquez la fonction lambda autant de fois que vous le voulez pour voir le compteur s’incrémenter.

Et la suite ?

J’espère que ce tutoriel vous permet de comprendre les possibilités que localstack offre. Jy trouve cependant quelques inconvéniences :

  • Créer des resources n’est vraiment pas pratique. Il n’y a pas d’interface web comme AWS. Il est cependant possible de trouver des outils dédiés à chaque ressources. (Jetez un coup d’oeil à https://github.com/aaronshaf/dynamodb-admin pour un example).
  • Toutes les ressources ne sont pas persistées, ce qui oblige à en recréer certaines à chaque redémarrage du projet.

Vous avez peut être des idées pour contourner ces problèmes. Suivez la 2ème partie de ce tutoriel si vous voulez en savoir plus!

Si cet article vous a rendu service, n’hésitez pas à starrer ce repo en guise de remerciement ! ⭐