Tester ses applications AWS avec Localstack

Contexte

Chaque jour, un nombre croissant d’applications prennent le virage du Cloud.  En particulier, AWS ( Amazon Web Services) apporte de nombreux services ainsi que des garanties en matière de fiabilité, performance ou encore sécurité. Une question demeure néanmoins en arrière-plan, comment tester de manière efficace l’intégration de ces services de façon simple, rapide et peu onéreuse ? C’est là qu’intervient Localstack, l’outil que je vais présenter dans cet article.

Localstack, accessible à cette URL,est un framework de tests/mocks open-source pour les applications Cloud, à l’origine un projet Atlassian. Il est facile à utiliser et bénéficie d’une grande communauté. Il fournit une partie des fonctionnalités et APIs présents sur l’environnement réel AWS.

Au moment de l’écriture de cet article, pas moins de 24 services sont couverts : Kinesis, DynamoDB, ElasticSearch, S3, Lambda, SQS, API Gateway pour ne citer que les plus utilisés.

Pourquoi utiliser Localstack ?

Il permet de réaliser des tests d’intégration ou d’acceptation.Étant basé sur des appels d’API REST, il peut être utilisé avec n’importe quel langage de programmation.

L’outil s’intègre parfaitement aux pipelines d’intégration continue exécutant les tests sans avoir à redéployer constamment, et permettant de vérifier régulièrement et aisément les comportements attendus. Les principaux avantages sont listés ci-dessous, ceux qui apparaissent comme une évidence, et d’autres, moins manifestes.

Principaux avantages :

  • Pouvoir travailler hors-ligne
  • Pouvoir automatiser les tests d'intégration
  • S’émanciper du logging dans la console AWS: utilisation sans credentials
  • Créer et Supprimer des ressources à la volée
  • Faciliter le débogage (Permet de suivre le flow évènementiel facilement : exemple SQS)
  • Permettre de s’affranchir de la configuration et de la gestion des permissions
  • Gagner du temps : conséquence directe des points précédents
  • Économiser de l’argent :  pas besoin de payer pour l’utilisation d’AWS étant donné que seuls des services mockés sont utilisés

Utilisation en pratique

Il est possible d’utiliser Localstack directement, en récupérant l’image Docker.Le projet Localstack peut être cloné à l’URL suivante.

Voilà un exemple de ce à quoi peut ressembler un fichier docker-compose.yml :

version: '2.1'

services:
  localstack:
    image: localstack/localstack:latest
    ports:
      - "4567-4597:4567-4597"
    environment:
      - SERVICES=s3,es
      - DEBUG=1
      - DATA_DIR=/tmp/localstack/data-dir/
      - DOCKER_HOST=unix:///var/run/docker.sock
    volumes:
      - "${TMPDIR:-/tmp/localstack}:/tmp/localstack"
      - "/var/run/docker.sock:/var/run/docker.sock"

Un peu de détails sur quelques unes de ces lignes :

  • image : Ici, permet de récupérer la dernière image localstack du repo (latest)
  • port : Permet de faire le binding des ports d’entrée 4567 à 4597 sur les ports de sortie 4567 à 4597 au démarrage du conteneur, ports correspondants aux différents services utilisables ; le détail peut se trouver sur le git localstack
  • environment : Les variables d’environnement utilisées par localstack avec notamment SERVICES pour définir les différents services qui seront utilisés dans notre application (Ici S3 et ES)

Une fois la configuration faite, il suffit de lancer l’application avec la commande :

docker-compose up -d

Pour vérifier que tout s’est lancé correctement, il suffit d’appeler directement le endpoint de localstack, exposé par défaut sur le port 8080, ou d’un service particulier, par exemple ElasticSearch sur le port 4571. Alternativement, lancer la commande “docker ps” permet de s’assurer du bon lancement du conteneur.

Utilisation avec le CLI  (Interface en ligne de commande d’Amazon)

Une fois localstack lancé, il est possible d'interagir avec en utilisant le CLI comme on le ferait dans un environnement nominal, en précisant toutefois l’URL du service utilisé, par exemple, pour afficher les buckets présents dans s3, la commande devient :

aws --endpoint-url=http://localhost:4572 s3 ls

/!\ Pro Tip : Il existe maintenant awslocal qui permet d’utiliser les commandes du cli en s’affranchissant de la configuration du endpoint localstack, et donc de connaître le port de chaque service.

Un scénario minime d’utilisation de S3 :

//Lister l’ensemble des buckets, pour l’instant aucun n’est présent
aws --endpoint-url=http://localhost:4572 s3 ls

//Créer un bucket nommé my-test-bucket
aws --endpoint-url=http://localhost:4572 s3 mb s3://my-test-bucket


// Lister l’ensemble des buckets, my-test-bucket est présent
aws --endpoint-url=http://localhost:4572 ls
> my-test-bucket

//Créer un fichier de test
 echo "{\"Text\" : \"Ceci est un fichier de test\"}" > test.json

// Uploader le fichier créé dans le bucket
aws --endpoint-url=http://localhost:4572 s3 cp test.json s3://my-test-bucket

//Afficher le contenu du bucket, le fichier est bien présent
aws --endpoint-url=http://localhost:4572 s3 ls s3://my-test-bucket
> test.json

Le fichier, maintenant présent dans un bucket S3, est alors prêt à être utilisé avec n’importe quel service d’AWS, comme on le ferait dans un environnement nominal.

Intégration avec Junit

Afin d’utiliser localstack dans nos classes de test Java, il faut avoir recours au Runner spécifique (LocalstackTestRunner) dans le cas de Junit 4 ou de l’extension (LocalstackExtension) dans le cas de Junit 5, tous deux présents dans la librairie localstack.

Pour ajouter les différents paramètres, il faut employer l’annotation @LocalstackDockerProperties, également dans la librairie localstack.On y précise alors notamment les services qui seront utilisés ainsi que la référence d’image docker.

Ci-dessous, un extrait du pom contenant les artefacts pour Junit 5 et Localstack :

<dependencies>
       <dependency>
            <groupId>cloud.localstack</groupId>
            <artifactId>localstack-utils</artifactId>
            <version>0.1.22</version>
        </dependency>
       <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-api</artifactId>
    <version>5.3.2></version>
        </dependency>
...
</dependencies>

En pratique, dans mon projet actuel, on s’en sert notamment avec le service managé ElasticSearch d’AWS. On l’utilise, par exemple, pour tester l’indexation à partir de fichiers déposés dans S3. Des cas de tests furent alors implémentés pour tester ce simple scénario ; un fichier est créé, puis uploadé dans S3 et enfin indexé dans ElasticSearch.On peut alors ensuite, s’assurer du fonctionnement nominal, avec des tests d’intégration sur le service de requêtage.

Dans un premier temps, est défini une classe Utils avec les méthodes nous permettant d’émuler les services du SDK Amazon, tels que, dans notre cas AmazonS3 et AWSElasticsearch, ainsi que les propriétés localstack.

import cloud.localstack.TestUtils;
import cloud.localstack.docker.LocalstackDocker;

public class LocalStackUtils {

    public static final String LOCALSTACK_VERSION = "0.10.2";
    public static final String REGION = "eu-central-1";
    public static final String BUCKET_NAME = "my-test-bucket";


    public static AmazonS3 createS3Client(String region) {

        String endpointS3 = setAwsS3EndpointUrlProperty();
        AWSCredentialsProvider credentialsProvider = TestUtils.getCredentialsProvider();

        return AmazonS3ClientBuilder.standard()
                .withEndpointConfiguration(
new AwsClientBuilder.EndpointConfiguration(endpointS3, region))
                .withPathStyleAccessEnabled(true)
                .withCredentials(credentialsProvider)
                .build();

    }

    public static String setAwsS3EndpointUrlProperty() {
        String endpointS3 = LocalstackDocker.INSTANCE.getEndpointS3();
        System.setProperty("aws.s3.endpointUrl", endpointS3);
        return endpointS3;
    }



// ...


// De la même façon on crée une méthode pour la création du client ES ainsi que le set du endpoint

...

    public static void uploadFileToBucket(AmazonS3 s3Client, String filePath, String pathOnS3, String bucketName) {
        try {
            File fileToUpload = new ClassPathResource(filePath).getFile();
            s3Client.putObject(bucketName, pathOnS3, fileToUpload);
        } catch (IOException e) {
            log.error(e.getMessage(), e);
        }
    }
..

}

Voilà à quoi ressemblerait alors, de façon simplifiée, une classe permettant de tester notre service de requêtage dans ElasticSearch :

import cloud.localstack.docker.annotation.LocalstackDockerProperties;

@LocalstackDockerProperties(imageTag = 0.10.2, randomizePorts = true, services = { "s3", "es" })
public class EsSearchServiceIT  {

    @Autowired
    IndexService indexService;

    @Autowired
    EsSearchService esSearchService;

    @BeforeAll
    @Autowired
    JestClient jestClient;


    static void beforeAll() {
        AmazonS3 s3Client = createS3Client(LocalStackITUtils.REGION);
        s3Client.createBucket(LocalStackITUtils.BUCKET_NAME);
        uploadFileToBucket(s3Client, FILE_NAME,FILE_PATH_ON_S3,LocalStackITUtils.BUCKET_NAME);

        createESClient(LocalStackITUtils.REGION);
    }

    @BeforeEach
    public void initIndexation() throws Exception {
        globalIndexService.indexAll(true, true, "index_one");

    }

    @AfterEach
    public void destroyIndices() throws IOException {
        jestClient.execute(new DeleteIndex.Builder("*").build());
    }


    @Test
    public void should_return_single_specific_document_matching_input_query() {
        SearchQuery searchQuery = buildSearchQuery();
        List<Document> resultDocuments = esSearchService.searchQuery(searchQuery);
        assertThat(resultDocuments).isNotEmpty();
        assertThat(companies.size()).isEqualTo(1);
    }
}

Ces deux cas simples, montrent donc comment il est possible de simuler les services AWS avant de vérifier la réalisation pratique de cas précis ; soit en utilisant le CLI soit par une intégration JUnit.

Limites

Malgré toutes ses qualités, il faut garder à l’esprit que c’est un outil open-source, non développé par Amazon, destiné exclusivement à en simuler les services, et donc ne suivant pas infailliblement  - ou du moins avec un certain retard -  les évolutions des services Amazon, voire ne les prenant pas du tout en compte, pouvant ainsi conduire à des bugs non reproductibles, ou, à l’inverse, empêchant de simuler correctement son application.

De plus, la documentation des APIs n’est pas complète et la version gratuite n’offre qu’une portion limitée des services proposés.Il faut donc, autant que possible, recourir aux environnements Cloud pour garantir la pertinence de ses tests d’intégration en conditions réelles, assurée par l’adéquation des fonctionnalités et comportements entre le développement local et l’environnement Cloud.

Conclusion

Localstack s’avère donc très utile pour émuler facilement et rapidement les services AWS et les intégrer élégamment dans des tests d’intégration, afin de tester des pipelines et comportements applicatifs. Pour moi, il est actuellement une référence parmi les autres solutions existantes et a su démontrer son efficacité et pertinence. Utiliser localstack, c’est à la fois gagner en temps et en qualité dans son développement  C’est pourquoi, sur mon projet actuel, il est utilisé profusément à la fois pour vérifier rapidement la faisabilité d’une solution et mettre en place des tests d’intégration efficaces, ce qui était difficilement réalisable avant.

Il faut toutefois garder à l’esprit qu’il  reste un outil auxiliaire permettant d’ajouter une lame de plus à son couteau-suisse de qualité logicielle et doit donc être nécessairement utilisé comme tel, comme un framework de test, efficace pour évaluer rapidement le fonctionnement de son code, grâce à des scénarios adéquats.

Comme n’importe quel outil, il est optimal et judicieux, lorsque utilisé dans des cas appropriés.