Comment appliquer les concepts d’observabilité sur une application Kotlin basée sur Spring Boot

Comment appliquer les concepts d’observabilité sur une application Kotlin basée sur Spring Boot
Comment appliquer les concepts d’observabilité sur une application Kotlin basée sur Spring Boot

Par Kevork Sayad, SRE/DevOps/Développeur chez Gologic

Dans ce tutoriel, je vais vous démontrer comment un SRE peut venir améliorer la fiabilité de vos systèmes, et par le fait même, la satisfaction de vos clients !

Mais avant tout, j’aimerais mettre en contexte certains faits et explications sur la maturité DevOps actuelle des organisations, les enjeux qu’elles rencontrent et comment le rôle du SRE vient les aider.

La transformation DevOps

Plusieurs d’entre vous travaillent ou ont travaillé auparavant au sein d’organisations comprenant plusieurs escouades de Dev en mode agile avec des membres DevOps ou alors des escouades Dev avec une escouade DevOps.

Dans ces modèles d’entreprises, chaque équipe a son PO (Product Owner de la méthodologie agile) qui cherche à livrer le plus de récits utilisateurs ou « user story » possibles. Les personnes DevOps ou alors l’escouade DevOps sont là pour combler les besoins des Dev, assurer la livraison, supporter l’infrastructure, les outils et les processus requis pour développer et livrer rapidement du code.

On y trouve du succès jusqu’à un certain point avec des pratiques qui minimisent les actions manuelles :

  • Les processus d’intégration et de déploiement (avec des pipelines) sont automatisés. Le code est livré rapidement en production.
  • Le virage vers du « As Code » est encouragé : Infra as Code, Pipeline as Code, Config as Code, etc.  Une traçabilité des changements facilitant l’audit et le retour en arrière en cas de problèmes est ajoutée. 
  • L’automatisation des actions répétitives est fortement recommandée.

La production

Ces bénéfices sont bien intéressants pour rapidement bâtir de l’infrastructure et effectuer de la livraison continue. En effet, la pratique de livraison continue permet aux Dev de livrer de nouvelles fonctionnalités (du nouveau code) en prod le plus fréquemment possible. Mais, il ne faut cependant pas oublier la réalité de la prod :

« La prod c’est de la prod… qui est de la prod ».

Ceci sonne très redondant comme langage, mais sans blague, la prod c’est sérieux ! Une fois le code livré en prod, on a de vrais clients qui n’ont pas peur de nous juger avec un maximum de transparence. Est-ce que c’est le bon moment de découvrir que notre service ne performe pas adéquatement ? On se retrouve alors avec un produit qui est aussi médiocre qu’avant, sinon même plus médiocre, et ce à cause de notre automatisation !

  1. Plus le nombre de fonctionnalités livrées augmente. 
  2. Plus le nombre de changements introduits augmente.
  3. Plus le nombre de chances de briser quelque chose augmente.

Les SRE

Alors, suite à l’introduction de la livraison continue et des autres pratiques d’automatisation, comment peut-on assurer la fiabilité de chaque mise en production ? C’est ici qu’entre en scène le rôle du SRE ou Site Reliability Engineer (Ingénieur de fiabilité de site). Est-ce un Ops ? Est-ce un Dev ? Et si je vous dis que c’est un peu des deux ? Une personne à la fois Ops et Dev, avec la capacité de supporter la prod, investiguer la prod, effectuer du root cause analysis (RCA), suggérer et mettre en place des solutions (en collaboration avec les Dev + Ops).

Comme documenté dans les livres SRE de Google, cette personne met l’accent sur la fiabilité qui est un besoin non fonctionnel et qui peut négativement impacter une partie ou l’ensemble même d’une application. Les organisations doivent donc savoir à quel moment prioriser la fiabilité, mais aussi contrôler la cadence de livraison des fonctionnalités.

Comment ? En identifiant des métriques d’intérêts (des SLI : ou service level indicators) et en plaçant un objectif sur cette métrique (des SLO : service level objective). Pour s’y faire, il faut d’abord s’assurer que nos applications et tout leur écosystème sont observables, c’est-à-dire avoir une traçabilité des évènements. Pourquoi ? Imaginons une simple application frontend + backend + BD :

  1. Admettons que la transaction de BD prend 30 secondes pour obtenir une liste de produits.
  2. Supposons aussi qu’il n’y a aucune trace ou preuve que cette requête pourrait prendre 30 secondes.
  3. Les seules rétroactions disponibles sont celles des clients qui nous communiquent la lenteur de rafraîchissement des pages.
  4. Il y a un travail maintenant à faire pour identifier quelles sont toute la mécanique et l’intégration requise pour charger la page qui est identifiée comme lente. 
  5. Ensuite, il faut s’assurer que chaque étape dans ce processus (interaction frontend au backend, le service backend, la requête backend à la BD, le retour de la BD au backend, le retour backend au frontend) soit tracée et mesurée.
  6. Une fois en place, on demande de reproduire ce flux qui est lent, et avec les traces enregistrées, on observe des faits. On peut alors questionner si les délais d’exécution sont normaux, si certaines erreurs se produisent, si la BD est optimisée… Il peut y avoir plusieurs RCA possibles, et sans observabilité, cette étape serait très difficile à effectuer.

Me voyez-vous venir ? C’est grâce à l’observabilité qu’on pourra savoir exactement ce qui s’est produit et c’est le SRE qui met l’emphase sur ces bonnes pratiques pour y arriver. 

L’observabilité

Une des pratiques à mettre en place par le SRE est l’observabilité ou la traçabilité des évènements dans le but d’éventuellement définir des SLI/SLO pour mesurer le service du point de vue client. Si nos applications ne nous communiquent pas, alors on ne peut pas les analyser ou alors effectuer du RCA.

Les utilisateurs qui en bénéficient sont une multitude d’acteurs qui participent à la livraison d’un produit, jusqu’au client final qui aura entière satisfaction.

Mais que veut-on dire par observabilité ? Dans ce tutoriel, je démontre des implémentations concrètes et certaines bonnes pratiques à mettre en place en tant que Dev, et ce, pour ces trois types d’évènements :

Les logsLes évènements uniques qui produisent un message souvent trop long, technique, et parfois difficile à comprendre pour toute personne qui n’est pas un Dev.
Les traces (distribuées)Une action unique qui rattache plusieurs évènements dans le fil. Celle-ci débute souvent en tant que requête HTTP (accéder une page web/effectuer GET).
Les métriquesLa notion de rendre des évènements mesurables (performance CPU, nombre de transactions dans les dernières 24 heures, % de transactions complétées < 2 secondes).

Tous ces types d’évènements sont complémentaires comme on peut le voir dans ce simple diagramme :

  • En jaune, on trouve les logs : des traces souvent très détaillées pour aider les Dev.
  • En vert, on trouve la trace et les spans de la trace en question. Chaque span (par défaut) représente un morceau (un système/composante/etc.) du flux d’une requête unique de l’utilisateur.
  • En rouge, on trouve les métriques qui sont disponibles par composantes, ou alors pour une partie du flux (exemple : latence composante backend + requête BD), ou alors le flux au complet (temps de transaction de bout en bout).

Comme on peut le voir, il est important d’identifier le flux d’une requête de bout en bout, car plusieurs composantes peuvent communiquer différents types d’observabilités et à des moments différents.

Tutoriel

Dans ce tutoriel, on va appliquer les concepts de l’observabilité sur une application basée sur la JDK, écrit en Kotlin dans le framework Spring Boot. 

Prérequis

Sur votre poste, assurez-vous :

  • De supporter JDK17 (eh non, pas Java 8 !) : télécharger OpenJDK 18 ici : https://jdk.java.net/
  • Installer Docker.
  • Utiliser l’IDE de votre choix pour développer en Kotlin. IntelliJ Idea Community est gratuit et open source.
  • Un client git de votre choix (ligne de commande git, plugin git du IDE, GitKraken, etc.).
  • Et si vous ne comprenez pas trop comment Spring Framework fonctionne, ce n’est pas grave ! Le point central du tutoriel reste sur les concepts présentés ici. Pour en savoir plus sur Spring, vous pouvez rapidement voir ses capacités ici : https://spring.io/

L’application

Télécharger le code qui se trouve dans notre organisation github et commencer avec le README.md pour comprendre comment préparer et démarrer votre environnement local :

git clone https://github.com/DevOps-opensource/observable-api.git 

Logger

En ce qui concerne les logs, leur gestion est assurée par les niveaux de logs (ou Log Level) afin de s’adresser à des audiences spécifiques :

  • Les DEBUG pour les Dev/Ops/SRE (très techniques/détaillés).
  • Les INFOS surtout pour les QA/analystes (moins techniques/détaillées). Exemple :

Identifier votre audience

fun getPreparedData (carsEntities: List<Car>) : List<CarDto> {
   val carsDtos = ArrayList<CarDto>()
   logger.debug("getPreparedData() START - Prepare entities into DTOs")
   logger.info("BUSINESS RULE CD01 - Begin reviewing cars inventory")

CarDelivery.kt

  • Les deux logs se produisent au même moment dans le code, pour autant que la cible des audiences est différente.
  • Le logger. debug indique le début d’une méthode tandis que l’information indique le début d’une logique de règle d’affaires.

Pensez toujours à votre audience lorsque vous développez et écrivez un log : le message s’adresse à qui ? Pensez à votre équipe et les équipes parties prenantes. Est-ce que quelqu’un aurait un intérêt à cette observabilité ? 

Dans l’exemple de cette appli, on dit qu’on veut que les analystes d’affaires et/ou les analystes en assurance qualité puissent valider à haut niveau le flux d’une requête et vérifier si les règles d’affaires ont été effectuées avec succès.

Sécurité

Avec du logging, on est capable d’implémenter de l’audit, pour identifier par qui ou quand une requête a été effectuée :

@Bean
fun route (carHandler: CarsHandler) : RouterFunction<ServerResponse> {

   logger.info (« Initialised routes »)

   return route (GET("/cars")) {

       AuditRequest.audit(it)
       carHandler.cars(it)
   }
   . andRoute (POST (« /cars/reserve »)) {
       AuditRequest.audit(it)
       carHandler.reserve(it)
   }

ApiRoute.kt

fun audit (request: ServerRequest) {
   logger.info("User {} Queried {}",
       request.headers().header(HEADER_USERNAME),
       request.requestPath())
}

AuditRequest.kt

Pour chaque requête HTTP/REST, on log le nom de l’utilisateur et la requête en question. Exemple :

2022-04-01 14:39:33.828  INFO … User [kev] Queried /cars

Logger via du AOP

Le concept d’Aspect Oriented Programming (ou Programmation Orientée Aspect) est intéressant du point de vue logging applicatif. Ce paradigme permet d’implémenter du code de façon transversale pour standardiser un comportement voulu à travers plusieurs classes. Ceci se fait dans Spring AOP avec le concept de Spring IoC (Inversion of Control ou Inversion de contrôle).

@Around("@annotation (LogFlow)")
@Throws(Throwable::class)
fun logFunctionCall(joinPoint : ProceedingJoinPoint): Any {

   val funcSignature = joinPoint. signature

   logger.debug("BEGIN - Method [$funcSignature] - Inputs = [${joinPoint.args}] ")

   val stopWatch = StopWatch()
   stopWatch.start()

   val output = joinPoint.proceed()

   if (output is Mono<*>) {

       return output.flatMap(Function {
           stopWatch.stop()
           logger.debug("END Method [$funcSignature] ($ {stopWatch.totalTimeMillis} ms) - Return = [$it]")
           Mono.just(it)
       })

   } else if (output is Flux<*>) {

       return output.collectList()
           .flatMapMany {
               stopWatch.stop()
               logger.debug("END - Method [$funcSignature] ($ {stopWatch.totalTimeMillis} ms) - Return = [$it]")
               Flux.fromIterable(it)
           }

   } else {
       stopWatch.stop()
       logger.debug("END Method [$funcSignature] ($ {stopWatch.totalTimeMillis} ms) - Return = [$ output]")
       return output
   }
}

LoggingAspect.kt

Cette fonction qui paraît compliquée est pourtant simple à comprendre dans son fonctionnement :

  • On log les entrées d’une fonction au début de son appel.
  • Ensuite, on démarre un chronomètre (StopWatch) pour mesurer le temps d’exécution.
  • On exécute la fonction.
  • Une fois la fonction exécutée, on arrête le chronomètre.
  • Et finalement, on log la sortie de cette fonction avec son temps d’exécution.

Pour appliquer cela, on utilise une annotation que nous avons définie comme étant LogFlow, applicable aux fonctions Kotlin, et Spring AOP s’assure d’exécuter cette fonction pour chaque appel de fonction annotée avec LogFlow (voir la classe CarService.kt fun getAllCars).

Utiliser de la programmation aspect peut aider à standardiser le format des messages des logs. Cette standardisation facilite la lisibilité et minimise la mauvaise interprétation lors d’investigations par les Dev/Ops/SRE. Le but ? Rapidement comprendre ce qui se passe pour accélérer l’identification du RCA.

Tracer

Pour assurer, une visibilité d’une trace de façon bout en bout, on utilise une implémentation d’OpenTracing (https://opentracing.io/). Notez que OpenTracing est en cours de remplacement par OpenTelemetry (https://opentelemetry.io/), cependant l’implémentation de OpenTelemetry reste encore expérimentale du côté de Spring.

Par défaut, sans aucun code autre que de la configuration (dans application.yaml), on obtient des traces qui sont composées de quelques spans : 1 span pour l’API en question, et 1 span pour la BD PostgreSQL. Cette observabilité pourrait suffire à vos besoins, cependant pour démontrer les capacités de personnalisation, ici on dit dans l’application que l’on veut définir un nouveau span pour visualiser une partie du flux : la fonction getAllCars (qui interroge la BD via le repository)

@LogFlowfun getAllCars(): List<CarDto> {

   logger.debug("Create a new trace span")
   val svcSpan = this.trace.buildSpan("car-svc-getAllCars").start()

   logger.debug("About to call carRepository")
   val carsEntities: List<Car> = carRepository.findAll()
   var carsDtos: List<CarDto> = ArrayList()

   if (carsEntities.isNotEmpty()) {

       val carsCount: String = carsEntities.size.toString()
       logger.info("Cars found in database $carsCount")

       carsEntities.forEach { c -> logger.debug("   Car = $ c") }

       svcSpan. setTag ("getAllCars-count", carsCount)

       logger.debug("carsEntities returned. Map entities to DTOs and apply business rules")
       carsDtos = carDelivery.getPreparedData(carsEntities);

       logger.debug("carsDtos mapped.")
       carsDtos.forEach { c -> logger.debug("   CarDto = $c") }

   } else {
       logger.warn("No cars found in the database. This could be normal. Try querying the table Car directly from an RDBMS client")
   }

   logger. debug (« Finish the newly created trace span »)
   svcSpan. finish ()

   return carsDtos
}

CarService.kt

  • Ici, dans cette fonction, nous créons un nouveau span « car-svc-getAllCars ».
  • Lorsque la fonction effectue la requête BD et retourne l’inventaire des automobiles, le compte de cette liste est enrichi à la trace via un tag (la ligne setTag).
  • Ensuite, avant de retourner le résultat, on termine le span.

Lorsqu’on effectue la requête, on va accéder à Jaeger et voir comment la trace se visualise :

  1. curl -v -H “X-USER : kev” localhost:8080/cars
  2. Ouvrir Jaeger : http://localhost:16686

Jaeger : Notre trace avec le nouveau span

Comme on peut le voir, notre trace se trouve ici, et on y trouve 3 spans :

  1. Le span de l’API (/cars).
  2. Le span qu’on a créé (car-svc-getAllCars) (celle indiquée avec une flèche ci-dessus).
  3. La requête vers la BD Postgres.

Lorsqu’on clique dessus pour obtenir plus de détails, on peut trouver le tag qu’on a ajouté à la trace :

Jaeger : Notre nouveau span avec son tag getAllCars-count

La raison pour laquelle nous voulons personnaliser les traces et les spans dans ce contexte est de rendre visible la logique d’affaires et rapidement voir des mesures telles que le temps d’exécution de ~111ms (indiqué par une flèche). On pourrait aussi implémenter cette observabilité en logs, cependant l’avantage ici, réside dans le visuel de bout en bout d’une requête (une image vaut vraiment 1000 mots) et les métriques automatiquement disponibles, telles que les latences d’exécutions.

Mesurer

Les métriques dans l’application sont implémentées en utilisant la fonction de Spring Actuator. Les données sont exposées dans un format compatible avec Prometheus (https://prometheus.io/).

Au démarrage de l’application (voir le README.md pour assurer de démarrer les Docker containers nécessaires par l’application), on peut accéder au endpoint d’actuator pour rapidement voir les métriques en format brute :

http://localhost:8080/actuator/prometheus

Ce qui est intéressant et que vous allez remarquer, ce sont les nombreuses métriques liées à la JVM, aux connexions BD, mais aussi aux métriques que nous avons personnalisées pour nos besoins :

# HELP cd02_delivery_total The number of occurrences a request for inventory has successfully been enriched with delivery information# TYPE cd02_delivery_total counter
cd02_delivery_total{businessrule=" cd02 »,} 0.0

Dans le code, on définit une nouvelle métrique applicative nommée « cd02_delivery » pour indiquer le nombre de fois que la logique d’affaires (la fonction getPreparedData) a été appelée.

init {

   deliveryCounter = Counter.builder("cd02-delivery")
       .tag("businessrule", "cd02")
       .description("The number of occurrences a request for inventory has successfully been enriched with delivery information")
       .register(meterRegistry)
}
…
fun getPreparedData (carsEntities: List<Car>) : List<CarDto> {
…
logger.info("BUSINESS RULE CD02 - Done adding supplementary info")
deliveryCounter.increment()
…

CarDelivery.kt

  • À l’initialisation, on crée la métrique (à l’aide du framework Micrometer).
  • Ensuite, dans la fonction getPreparedData, une fois que la règle d’affaires CD02 s’effectue avec succès, on incrémente l’indicateur +1.
  • Cela veut dire qu’à chaque appel qui exige l’inventaire des automobiles (/cars), on s’assure que l’inventaire a été traité par la règle d’affaire « CD02 ».
  • Cette métrique pourrait être intéressante pour les analystes, les Dev ou encore les SRE pour valider si un traitement particulier a été effectué.

La suite

Maintenant que nous avons couvert quelques concepts de l’observabilité et vu comment ces derniers peuvent être implémentés, restez à l’affut, car d’autres tutoriels et lunch & learn sont en cours pour continuer sur la mission du SRE. À présent que nous avons une visibilité, la question devient : 

« Comment accéder et gérer cela pour optimiser la surveillance, l’investigation, et l’amélioration continue ? »

Merci et n’hésitez-pas à contribuer ou à nous faire parvenir vos commentaires sur notre compte Github.

Par Kevork Sayad, SRE/DevOps/Développeur chez Gologic

« Axé sur le client, analytique et multidisciplinaire, Kevork est un développeur senior passionné par l’automatisation et la simplification des environnements basés sur des applications JDK (Java, Kotlin) qui sont intégrés à l’aide d’orchestrateurs tels que GitHub Actions, Jenkins et déployés à l’aide de conteneurs orchestrés par Kubernetes. L’automatisation des tests est également l’une de ses grandes priorités. Avec une spécialisation dans les pratiques SRE telles que la résilience et l’observabilité, Kevork apporte une expertise qui englobe le développement de processus, y compris l’automatisation et la fiabilité pour assurer une livraison et une amélioration continues. Il est aussi un mordu des technologies open source et aime suivre les tendances dans l’industrie. »

Ressources

Suivez-nous et partagez

Laisser un commentaire