Déployer un backend TypeScript full-serverless avec SAM (1/3)

Partie 1 - Objectifs, structure du repo et processus de build

Cette série de trois articles a pour objectif de présenter l’usage d’un blueprint pour déployer un backend full-serverless développé avec typescript. Cet article, le premier de la série, aura pour but d’expliquer pourquoi utiliser ce blueprint, ainsi que les besoins qui nous ont poussé à le développer. Il abordera également en détail la structure de l’application, plus particulièrement le processus de build et de packaging du code avant un déploiement sur AWS.

Pourquoi utiliser un blueprint pour mes projets full-serverless ?

Plusieurs besoins nous ont poussés à réaliser ces blueprints. Le nombre de use-cases serverless étant de plus en plus important, le besoin principal que nous avons identifié est de pouvoir démarrer rapidement un projet full-serverless ou tout du moins de proposer un point de départ pour démarrer ce type de projet. Cela implique de pouvoir créer et déployer facilement une architecture full serverless en production. De plus, certains use-cases étant assez récurrents, il nous semblait pertinent de proposer une solution clé en main nécessitant uniquement de faire du développement spécifique à l’application souhaitée.

Un autre point important que nous souhaitions aborder est la mise en place de best practices. Pour les architectures récurrentes évoquées précédemment, notre but est de permettre que tout le monde ait le même socle de départ respectant les best-practices d’AWS, pour éviter d’avoir de nombreuses architectures différentes pour mettre en place un service similaire. Cela implique de mettre en place des best practices au niveau de la sécurité de l’architecture pour chacun des use-cases, et d’optimiser et minimiser l’espace occupé sur le service Lambda par l’application déployée. Cela passe par éviter les duplications de code, et le packager correctement.

Le dernier besoin auquel nous souhaitions répondre est celui de l’environnement de développement. Nous souhaitions créer un environnement de développement adapté à un langage choisi. Ici, nous avons opté pour un développement en TypeScript. L’idée est de pouvoir profiter des avantages de TypeScript, plus adapté à un projet de grande envergure que le JavaScript afin d’éviter des erreurs courantes notamment grâce à l’inférence de type. Le développeur pourrait ainsi créer son code en TypeScript, et le déployer facilement sur AWS Lambda, qui ne supporte pas nativement ce langage.

Structure de l’architecture et de l’environnement de développement

Architecture mise en place

L’architecture que nous allons mettre en place dans cette série d’articles pour illustrer ces blueprints est assez classique et consiste en une API sécurisée, cachée derrière un CloudFront, pouvant déclencher des fonctions Lambda pouvant, elles, lire ou écrire dans une table DynamoDB. Voici un schéma de l’architecture finale vers laquelle nous souhaitons nous diriger :

  • Amazon Cognito nous servirait d’identity provider, et permettrait au client de s’authentifier auprès du CloudFront
  • Nous nous servirons du CDN CloudFront pour les nombreux avantages qu’il présente (notamment en termes de sécurité). La distribution CloudFront ajouterait un header ayant pour valeur le secret stocké dans Secrets Manager à la requête originale du client afin d’être acceptée par le WAF.
  • Le secret manager permet lui de stocker la valeur d’un secret et de la modifier périodiquement de façon sécurisée grâce à une fonction lambda de rotation.
  • Le WAF permet quant à lui de vérifier qu’une requête est bien issue du CloudFront en vérifiant que le header “customisé” a bien été ajouté à la requête avec la valeur adéquate. Il permet ainsi d’autoriser l’appel à l’API ou non, en renvoyant un code 403 forbidden.
  • Nous nous servirons également de la fonctionnalité de layers proposée par AWS Lambda afin de gérer plus efficacement le code partagé entre les différentes fonctions.

Plus de détails sur ces ressources et leur fonctionnement seront apportés dans les paragraphes et articles suivants.

Notons que l’architecture finale que nous souhaitons mettre en place présente également un WAF placé devant l’API, un Secrets Manager, un Cognito ainsi que d’un CloudFront. Ces éléments seront abordés plus en détail dans les articles suivants, leur mise en place faisant partie des best-practices d’AWS en termes de sécurité quant à la création d’un tel backend.

Structure de l’environnement de développement

Intéressons nous maintenant à la partie applicative : les fonctions Lambda. Cette partie de l’architecture sera modifiée selon le besoin utilisateur.  Cependant nous avons mis en place une structure de code qui nous semble intuitive pour le développement en TypeScript ainsi qu’un processus de build/deploy simple à mettre en place dans un environnement de production.

Comme expliqué précédemment, nous souhaitons pouvoir utiliser TypeScript dans notre environnement de développement bien que celui-ci ne soit pas directement pris en charge par l’environnement AWS Lambda.

L’idée étant que le développeur puisse écrire ses fonctions en TypeScript, en pouvant réutiliser du code partagé entre ses fonctions, qu’avec une simple commande il puisse builder son projet, le packager, puis le déployer.

Nous avons donc dû créer une structure de projet assez particulière pour pouvoir supporter ces contraintes. Nous avons donc choisi de diviser en plusieurs parties le projet.

Dépendances liées au développement

Dans cette structure, toutes les dépendances liées au build de l’application sont définies dans le dossier build.

Dans le template que nous proposons de base, comme nous n’utilisons que typescript et webpack comme outils de développement, le dossier build ne contiendra que le fichier de configuration de typescript et un package.json listant les dépendances nécessaires au build de l’application. L'intérêt d'avoir centralisé les dépendances du build dans son propre package est d’éviter d’avoir à répéter la même configuration partout et de n’avoir qu’une seule déclaration des versions des dépendances, cela dans un souci de cohérence et de maintenance du code. Il faut cependant bien noter que les dépendances définies dans ce dossier ne seront pas incluses dans la stack finale déployée sur AWS, celles-ci n’étant pas nécessaires au runtime.

Le code partagé

Un problème récurrent lors du développement d’un projet full serverless est la réutilisation du code. Comme le travail est réalisé au niveau de la fonction, cela peut paraitre idiot d’avoir à répéter la déclaration d’un même morceau de code dans chacune des fonctions. Cela rend le code moins propre et plus difficilement maintenable. C’est pour cela que nous avons créé ce dossier.

Ainsi, le code partagé entre plusieurs fonctions est défini dans le dossier shared. Le code présent dans ce dossier sera déployé sous forme de layer sur AWS afin de prendre le moins de place possible et d’éviter que le code soit dupliqué sur le service AWS Lambda. Ainsi, le code ne sera ni dupliqué dans l’environnement local du développeur, ni dans l’environnement distant une fois la stack déployée. Dans le package.json de ce dossier on retrouve les dépendances nécessaires au développement et à l’exécution des différentes fonctions de notre application.

Pour ce qui est de la structure même de ce dossier, nous avons choisi de diviser le code source en plusieurs fichiers. Un par type de dépendance, par exemple nous avons un fichier logger.ts qui va définir un logger pino, et un fichier aws-sdk.ts qui va lui exporter les objets liés au aws-sdk utilisés dans différentes fonctions (un client dynamoDB pour nos fonctions par exemple) pour éviter d’avoir à les re-déclarer dans chacune d’elles. Ce dossier contient également un fichier index.ts qui va lui exporter tout le code défini dans les autres fichiers, ceci afin de ne pas avoir à modifier le package.json à chaque fois que du code est ajouté.

Le code des fonctions

Le code source de toutes les fonctions est défini dans le dossier function. Ce code source, une fois le développeur satisfait, va être packagé avec webpack pour créer des fichiers JS les plus petits et optimisés possibles avant de les déployer. Pour le packaging des fonctions, nous avons choisi de nous servir d’un unique package.json afin de pouvoir gérer plus efficacement les dépendances (i.e. ne pas avoir à modifier 20 fichiers pour changer une simple dépendance), pour la cohérence du code et l’accélération du build des fonctions.

Afin d’illustrer le tout, nous avons mis en place 2 fonctions très simples:

  • click: cette fonction va ajouter (ou mettre à jour) un item compteur qui a pour clé la date du jour dans la table dynamoDB. Elle a vocation à illustrer une opération en écriture dans la table.
  • get-total-clicks: cette fonction va retourner le nombre de “click” effectué le jour même, cette fonction a donc vocation à illustrer une opération en lecture dans la table DynamoDB.

Le dossier layers

Ce dossier n’a normalement jamais besoin d’être modifié. Il contient uniquement le package.json nécessaire à la création du layer Lambda, celui-ci reprenant uniquement le code du dossier shared pour créer le layer contenant l’ensemble du code partagé.

Nous reviendrons plus en détail sur le fonctionnement et la mise en place des layers dans l’article suivant à propos du framework SAM.

Au final, comment build le projet et le packager?

Donc pour résumer, cela va permettre au développeur de coder avec TypeScript en local et de profiter de tous ses avantages, notamment l’inférence de type. Il peut mettre à jour les dépendances et build son projet dans les différents dossiers en se servant de la commande custom yarn build, qui va donc installer les dépendances dans les différents dossiers et transpiler les fichiers TypeScript en JS.

Une fois que le développeur a fini de travailler sur son code, d’une simple commande yarn bundle, il peut packager tout le code grâce à webpack, dont le fichier de configuration webpack.config.ts est le suivant.

Ce fichier, écrit en TypeScript, indique à webpack quels fichiers aller chercher dans l'arborescence afin de les packager. On va notamment lui indiquer de chercher uniquement dans les dossiers finissant par “-function” et de packager le fichier “/src/app.ts” (point d’entrée des fonctions) de ces dossiers. Ce fichier de configuration définit également l’output de la commande et où se trouveront les fichiers bundle.js qui seront utilisés par SAM pour build les fonctions Lambda. Enfin, nous avons utilisé une librairie complémentaire, webpack-node-externals, qui permet, par la simple utilisation de la fonction nodeExternals(), de spécifier à webpack de ne pas packager le dossier node_modules. Cette opération permet de rendre les fichiers de sortie beaucoup plus légers et de fortement accélérer le processus.

Dans l’article suivant, nous vous expliquerons le fonctionnement de l’outil SAM, comment nous l’utilisons dans ce projet, et comment s’en servir pour déployer l’architecture décrite ci-dessus sur AWS.