BT

Diffuser les Connaissances et l'Innovation dans le Développement Logiciel d'Entreprise

Contribuez

Sujets

Sélectionner votre région

Accueil InfoQ Articles Implémentation De Microservicilités Avec Istio

Implémentation De Microservicilités Avec Istio

Favoris

Points Clés

  • Lors du développement d'une architecture microservices, de nouveaux défis doivent être relevés, tels que l'évolutivité, la sécurité et l'observabilité.
  • Les microservicilités fournissent une liste de préoccupations transversales pour mettre en œuvre correctement les microservices.
  • Kubernetes est un bon début pour mettre en œuvre ces microservicilités, mais il existe des lacunes.
  • Un service mesh est une couche d'infrastructure dédiée pour rendre la communication de service à service sûre, rapide et fiable.
  • Istio est un service mesh mettant en œuvre certaines des microservicilités requises de manière non invasive.

Dans une architecture microservices, une application est formée de plusieurs services interconnectés où tous travaillent ensemble pour produire les fonctionnalités métier requises. Ainsi, une architecture de microservices d'entreprise typique ressemble à ceci :

Au début, il peut sembler facile d'implémenter une application utilisant une architecture microservices. Mais le faire correctement n'est pas facile car il y a des défis à relever qui n'étaient pas présents avec une architecture monolithique. Certains d'entre eux sont la tolérance aux pannes, la découverte de services, la mise à l'échelle, la journalisation ou le traçage, pour n'en citer que quelques-uns.

Pour résoudre ces problèmes, chaque microservice doit implémenter ce que nous appelons chez Red Hat les « Microservicilités ». Le terme fait référence à une liste de préoccupations transversales qu'un service doit mettre en œuvre en dehors de la logique métier pour résoudre ces défis.

Ces préoccupations sont résumées dans le schéma suivant :

La logique métier peut être implémentée dans n'importe quel langage (Java, Go, JavaScript) ou n'importe quel framework (Spring Boot, Quarkus), mais autour de la logique métier, les préoccupations suivantes doivent également être implémentées :

API : le service est accessible via un ensemble défini d'opérations d'API. Par exemple, dans le cas des API Web RESTful, HTTP est utilisé comme protocole. De plus, l'API peut être documentée à l'aide d'outils tels que Swagger.

Découverte : les services doivent découvrir d'autres services.

Invocation : une fois qu'un service est découvert, il doit être invoqué avec un ensemble de paramètres et éventuellement renvoyer une réponse.

Élasticité : l'une des caractéristiques cruciales d'une architecture de microservices est que chacun des services est élastique, ce qui signifie qu'il peut être augmenté et/ou réduit indépendamment en fonction de certains paramètres comme la criticité du système ou en fonction sur la charge de travail actuelle.

Résilience : dans une architecture microservices, nous devons développer en gardant à l'esprit l'échec, en particulier lors de la communication avec d'autres services. Dans une application monolithique, l'application, dans son ensemble, est en haut ou en bas. Mais lorsque cette application est décomposée en une architecture microservices, l'application est composée de plusieurs services. Tous sont interconnectés par le réseau, ce qui implique que certaines parties de l'application peuvent être en cours d'exécution tandis que d'autres échouent. Il est important de contenir l'échec pour éviter de propager l'erreur via les autres services. La résilience (ou résilience des applications) est la capacité d'une application/d'un service à réagir aux problèmes tout en fournissant le meilleur résultat possible.

Pipeline : un service doit être déployé indépendamment sans aucune forme d'orchestration de déploiement. Pour cette raison, chaque service doit avoir son pipeline de déploiement.

Authentification : L'un des aspects essentiels d'une architecture microservices est de savoir comment authentifier/autoriser les appels entre les services internes. Les jetons Web (et les jetons en général) sont le moyen préféré pour représenter les claims en toute sécurité parmi les services internes.

Journalisation : la journalisation est simple dans les applications monolithiques car tous les composants s'exécutent dans le même nœud. Les composants sont désormais répartis sur plusieurs nœuds sous forme de services ; par conséquent, un système de journalisation/collecteur de données unifié est requis pour avoir une vue complète des traces de journalisation.

Surveillance : une méthode pour mesurer les performances de votre système, comprendre la santé globale de l'application ou alerter en cas de problème pour assurer le bon fonctionnement d'une application basée sur des microservices. La surveillance est un aspect clé du contrôle de l'application.

Traçage : le traçage est utilisé pour visualiser le flux d'un programme et la progression des données. C'est particulièrement utile lorsque, en tant que développeur/opérateur, nous devons vérifier le parcours de l'utilisateur à travers l'ensemble de l'application.

Kubernetes devient l'outil de facto pour le déploiement de microservices. Il s'agit d'un système open source pour l'automatisation, l'orchestration, la mise à l'échelle et la gestion des conteneurs.

Cependant, seulement trois des dix microservicilités sont couverts lors de l'utilisation de Kubernetes.

Découverte est mis en œuvre avec le concept de service Kubernetes. Il permet de regrouper les pods Kubernetes (agissant comme un seul) avec une adresse IP virtuelle et un nom DNS stables. Pour découvrir un service, il suffit de faire des requêtes en utilisant le nom du service Kubernetes comme nom d'hôte.

L'invocation des services est facile avec Kubernetes car la plate-forme fournit le réseau requis pour invoquer l'un des services.

L'élasticité (ou la mise à l'échelle) est quelque chose que Kubernetes a en tête depuis le tout début. Par exemple, en exécutant la commande kubectl scale deploy myservice --replicas=5, le déploiement myservice s'adapte à cinq réplicas ou instances. La plate-forme Kubernetes s'occupe de trouver les nœuds appropriés, de déployer le service et de maintenir le nombre souhaité de réplicas opérationnels à tout moment.

Mais qu'en est-il du reste des microservicilités ? Kubernetes n'en couvre que trois, alors comment pouvons-nous implémenter le reste ?

Dans la première partie de cette série, j'ai couvert la mise en œuvre de ces problèmes en les intégrant dans le service à l'aide de Java.

Le service avec les préoccupations transversales implémentées dans le même code ressemble au schéma suivant :

Cette approche fonctionne et présente plusieurs avantages, comme démontré dans l'article précédent, mais elle présente quelques inconvénients. Citons les principaux :

  • Le code de base du service est un mélange de logique métier (ce qui donne de la valeur à l'entreprise) et de code d'infrastructure (nécessaire en raison des microservices).
  • Les services d'une architecture microservices peuvent être développés dans différents langages, par exemple, le service A en Java et le service B en Go. Le défi d'avoir des services polyglottes est d'apprendre à mettre en œuvre ces microservicilités pour chaque langue. Par exemple, quelle bibliothèque peut être utilisée pour implémenter la résilience en Java, en Go, etc.
  • Dans le cas de Java, nous pouvons ajouter de nouvelles bibliothèques (avec toutes ses dépendances transitives) pour chacune des « microservicilités », telles que Resiliency4J pour la résilience, Jaeger pour le traçage, ou Micromètre pour la surveillance. Bien qu'il n'y ait rien de mal à cela, nous augmentons les chances d'avoir des conflits de classpath lors de l'ajout de différents types de bibliothèques dans le classpath. De plus, la consommation de mémoire et les temps de démarrage sont également augmentés. Enfin et surtout, il y a le problème de maintenir les versions des bibliothèques alignées sur tous les services Java, de sorte qu'ils exécutent tous la même version.

Alors en arrivant à ce stade, on peut se demander pourquoi il faut mettre en place toutes ces microservicilités ?

Dans une architecture microservices, une application est formée de plusieurs services interconnectés où tous travaillent ensemble pour produire les fonctionnalités métier requises. Tous ces services sont interconnectés à l'aide du réseau, nous mettons donc en œuvre efficacement un modèle de traitement distribué. Et comme elle est distribuée, l'observabilité (surveillance, traçage, journalisation) devient un peu plus compliquée car toutes les données sont réparties sur plusieurs services. Parce que le réseau n'est pas fiable ou que la latence n'est pas nulle, les services doivent être résilients face à ces défaillances.

Supposons donc que des microservicilités soient requises en raison de décisions prises au niveau de l'infrastructure (services distribués communiquant en utilisant le réseau contrairement à un monolithe). Pourquoi devons-nous implémenter ces microservicilités au niveau applicatif plutôt qu'au niveau infrastructure ? C'est ici que se trouve le problème. C'est une bonne question qui a une réponse simple : Service Mesh.

Qu'est-ce qu'un Service Mesh et Istio ?

Un Service Mesh est une couche d'infrastructure dédiée pour gérer la communication de service à service qui est sûre, rapide et fiable.

Un service mesh est généralement implémenté en tant que proxy réseau léger déployé avec le code de service interceptant de manière transparente tout le trafic réseau entrant/sortant du service.

Istio est une implémentation open source d'un service mesh pour Kubernetes. La stratégie utilisée par Istio pour intégrer un proxy de trafic réseau dans un pod Kubernetes est réalisée à l'aide d'un conteneur sidecar. Il s'agit d'un conteneur exécuté avec le conteneur de service dans le même pod. Comme ils s'exécutent dans le même pod, les deux conteneurs partagent l'IP, le cycle de vie, les ressources, le réseau et le stockage.

Istio utilise le proxy Envoy comme proxy réseau à l'intérieur du conteneur side-car et configure le Pod pour envoyer tout le trafic entrant/sortant via le proxy Envoy (conteneur side-car).

Lorsque vous utilisez Istio, la communication entre les services n'est pas directe. Pourtant, sur le conteneur side-car (proxy Envoy), lorsque le service A demande le service B, la demande est envoyée au conteneur proxy du service A en utilisant son nom DNS. Ensuite, le conteneur proxy du service A envoie la demande au conteneur proxy du service B, qui invoque finalement le vrai service B. Le chemin inverse est suivi pour la réponse.

Le conteneur side-car proxy Envoy implémente les fonctionnalités suivantes :

  • Routage intelligent et équilibrage de charge entre les services
  • Fault injection.
  • Résilience : nouvelles tentatives et disjoncteur.
  • Observabilité et télémétrie : métriques et traçage.
  • Sécurité : chiffrement et autorisation.
  • Application des règles Fleet-wide.

Comme vous pouvez le voir dans le schéma suivant, les fonctionnalités implémentées par le conteneur side-car correspondent parfaitement à cinq des microservicilités : découverte, résilience, authentification, surveillance et traçage.

Il y a plusieurs avantages à avoir une logique de microservicilités dans le conteneur :

  • Le code métier est totalement isolé des microservices.
  • Tous les services utilisent l'implémentation exacte car ils utilisent le même conteneur.
  • Son code est indépendant. Un service peut être mis en œuvre dans n'importe quelle langue, mais ces préoccupations transversales sont toujours les mêmes.
  • Le processus de configuration et les paramètres sont les mêmes dans tous les services.

Mais comment fonctionne Istio en interne et pourquoi avons-nous besoin d'Istio et pas seulement du proxy Envoy ?

Architecture

Le proxy Envoy est un proxy réseau léger qui peut être utilisé de manière autonome, mais lorsque des dizaines de services sont déployés, des dizaines de proxys Envoy doivent être configurés. Les choses peuvent devenir un peu complexes et lourdes. Istio simplifie ce processus.

D'un point de vue architectural, un service mesh Istio est composé d'un data plane et d'un control plane.

Le data plane est composé d'un proxy Envoy déployé en side-car. Ce proxy intercepte toutes les communications réseau entre les services. Il collecte et rapporte également la télémétrie sur tout le trafic maillé.

Le control plane gère et configure les proxys Envoy.

Le schéma suivant résume les deux composants :

Installation d'Istio

Nous avons besoin d'un cluster Kubernetes pour installer Istio. Pour cet article, nous utilisons Minikube, mais tout autre cluster Kubernetes peut être valide.

Exécutez la commande suivante pour démarrer le cluster :

minikube start -p istio --kubernetes-version='v1.19.0' --vm-driver='virtualbox' --memory=4096

  [istio] minikube v1.17.1 on Darwin 11.3
  Kubernetes 1.20.2 is now available. If you would like to upgrade, specify: --kubernetes-version=v1.20.2
  minikube 1.19.0 is available! Download it: https://github.com/kubernetes/minikube/releases/tag/v1.19.0
  To disable this notice, run: 'minikube config set WantUpdateNotification false'

✨ Utilisation du pilote virtualbox en fonction du profil existant

❗ Vous ne pouvez pas modifier la taille de la mémoire d'un cluster minikube existant. Veuillez d'abord supprimer le cluster.

  Starting control plane node istio in cluster istio
  Restarting existing virtualbox VM for "istio" ...
  Preparing Kubernetes v1.19.0 on Docker 19.03.12 ...
  Verifying Kubernetes components...
  Enabled addons: storage-provisioner, default-storageclass
  Done! kubectl is now configured to use "istio" cluster and "" namespace by default

Une fois le cluster Kubernetes opérationnel, téléchargez l'outil CLI istioctl pour installer Istio dans le cluster. Dans ce cas, nous téléchargeons Istio 1.9.4 à partir de la page release.

Une fois l'outil istioctl installé, nous pouvons procéder au déploiement d'Istio au sein du cluster. Istio est livré avec différents profiles, mais pour commencer avec Istio, le profil demo est parfait.

istioctl install --set profile=demo -y

Detected that your cluster does not support third party JWT authentication. Falling back to less secure first party JWT. See https://istio.io/docs/ops/best-practices/security/#configure-third-party-service-account-tokens for details.

✔ Istio core installed

✔ Istiod installed

✔ Egress gateways installed

✔ Ingress gateways installed

✔ Addons installed

✔ Installation complete

Wait until all Pods in the istio-system namespace are in running state.

kubectl get pods -n istio-system

NAME                                   READY   STATUS    RESTARTS   AGE
grafana-b54bb57b9-fj6qk                1/1     Running   2          171d
istio-egressgateway-68587b7b8b-m5b58   1/1     Running   2          171d
istio-ingressgateway-55bdff67f-jrhpk   1/1     Running   2          171d
istio-tracing-9dd6c4f7c-9gcx9          1/1     Running   3          171d
istiod-76bf8475c-xphgd                 1/1     Running   2          171d
kiali-d45468dc4-4nbl4                  1/1     Running   2          171d
prometheus-74d44d84db-86hdr            2/2     Running   4          171d

Pour tirer parti de toutes les capacités d'Istio, les pods dans le mesh doivent exécuter un proxy side-car Istio.

Il existe deux manières d'injecter un side-car Istio dans un pod : manuellement à l'aide de la commande istioctl ou automatiquement lorsque le pod est déployé dans un espace de noms configuré à cette fin.

Par souci de simplicité, l'injection side-car automatique est configurée dans l'espace de noms default en exécutant la commande suivante :

kubectl label namespace default istio-injection=enabled

namespace/default labeled

Istio est maintenant installé dans le cluster Kubernetes et il est prêt à être utilisé dans l'espace de noms default.

Dans la section suivante, nous verrons un aperçu de l'application à « istioiser » et nous la déploierons.

L'application

L'application est composée de deux services, un service de réservation et un service de notation. Le service de livre renvoie les informations d'un livre avec ses notes. Le service d'évaluation renvoie les évaluations d'un livre donné. Il existe deux versions dans le cas du service de notation : la v1 renvoie un numéro de notation fixe pour tout livre (1), tandis que la v2 renvoie un numéro de notation aléatoire.

Déploiement

Étant donné que l'injection side-car automatique est activée, nous n'avons pas besoin de modifier quoi que ce soit dans les fichiers de déploiement Kubernetes. Déployons ces trois services sur l'espace de noms "istioized".

Par exemple, le fichier de déploiement Kubernetes du book service est :

---
apiVersion: v1
kind: Service
metadata:
 labels:
   app.kubernetes.io/name: book-service
   app.kubernetes.io/version: v1.0.0
 name: book-service
spec:
 ports:
 - name: http
   port: 8080
   targetPort: 8080
 selector:
   app.kubernetes.io/name: book-service
   app.kubernetes.io/version: v1.0.0
 type: LoadBalancer
---
apiVersion: apps/v1
kind: Deployment
metadata:
 labels:
   app.kubernetes.io/name: book-service
   app.kubernetes.io/version: v1.0.0
 name: book-service
spec:
 replicas: 1
 selector:
   matchLabels:
     app.kubernetes.io/name: book-service
     app.kubernetes.io/version: v1.0.0
 template:
   metadata:
     labels:
       app.kubernetes.io/name: book-service
       app.kubernetes.io/version: v1.0.0
   spec:
     containers:
     - env:
       - name: KUBERNETES_NAMESPACE
         valueFrom:
           fieldRef:
             fieldPath: metadata.namespace
       image: quay.io/lordofthejars/book-service:v1.0.0
       imagePullPolicy: Always
       name: book-service
       ports:
       - containerPort: 8080
         name: http
         protocol: TCP

Comme on peut le voir, rien de spécifique à Istio ni au conteneur sidecar n'est présent dans le fichier. L'injection des capacités d'Istio se fait automatiquement par défaut.

Déployons l'application sur le cluster Kubernetes :

kubectl apply -f rating-service/src/main/kubernetes/service.yml -n default
kubectl apply -f rating-service/src/main/kubernetes/deployment-v1.yml -n default
kubectl apply -f rating-service/src/main/kubernetes/deployment-v2.yml -n default
kubectl apply -f book-service/src/main/kubernetes/deployment.yml -n default

Après quelques secondes, l'application sera opérationnelle. Pour valider, exécutez la commande suivante et gardez un œil sur le nombre de conteneurs appartenant au Pod:

.

kubectl get pods -n default

NAME                                READY   STATUS    RESTARTS   AGE
book-service-5cc59cdcfd-5qhb2       2/2     Running   0          79m
rating-service-v1-64b67cd8d-5bfpf   2/2     Running   0          63m
rating-service-v2-66b55746d-f4hpl   2/2     Running   0          63m

Notez que chaque Pod contient deux conteneurs en cours d'exécution, un conteneur est le service lui-même, et l'autre est le proxy Istio.

.

Si nous décrivons le Pod, nous remarquerons que :

kubectl describe pod rating-service-v2-66b55746d-f4hpl

Name:         rating-service-v2-66b55746d-f4hpl
Namespace:    default
…
Containers:
  rating-service:
    Container ID:   docker://cda8d72194ee37e146df7bf0a6b23a184b5bfdb36fed00d2cc105daf6f0d6e85
    Image:          quay.io/lordofthejars/rating-service:v2.0.0
…
  istio-proxy:
    Container ID:  docker://7f4a9c1f425ea3a06ccba58c74b2c9c3c72e58f1d805f86aace3d914781e0372
    Image:         docker.io/istio/proxyv2:1.6.13

Puisque nous utilisons Minikube et que le service Kubernetes est de type LoadBalancer, l'IP de Minikube et le port du service sont nécessaires pour accéder à l'application. Pour les trouver, exécutez la commande suivante:

.

minikube IP -p istio
192.168.99.116

kubectl get services -n default
NAME           TYPE           CLUSTER-IP       EXTERNAL-IP   PORT(S)          AGE
book-service   LoadBalancer   10.106.237.42    <pending>     8080:31304/TCP   111m
kubernetes     ClusterIP      10.96.0.1        <none>        443/TCP          132m
rating         LoadBalancer   10.109.106.128   <pending>     8080:31216/TCP   95m

Et exécutons quelques commandes curl sur le service:

.

curl 192.168.99.116:31304/book/1
{"bookId":1,"name":"Book 1","rating":1}
curl 192.168.99.116:31304/book/1
{"bookId":1,"name":"Book 1","rating":3}
curl 192.168.99.116:31304/book/1
{"bookId":1,"name":"Book 1","rating":1}
curl 192.168.99.116:31304/book/1
{"bookId":1,"name":"Book 1","rating":3}

Nous pouvons voir à partir de la sortie que la valeur d'évaluation change, de 1 à 3, pour le même identifiant de livre. Par défaut, Istio équilibre les appels à l'aide d'une approche à tour de rôle entre les services. Dans cet exemple, les demandes sont équilibrées entre rating :v1 (note fixe à 1) et rating :v2 (note aléatoire calculée au démarrage ; dans ce cas, 3 pour le livre Identité 1).

L'application est maintenant déployée et « istioisée », mais aucune microservice n'est encore activée. Commençons par créer des ressources Istio pour activer et configurer les microservices sur les conteneurs proxy Istio.

Microservicilités Istio

Découverte

Un service Kubernetes implémente le concept de découverte. Il fournit un moyen de regrouper les pods Kubernetes (agissant comme un seul) avec une adresse IP virtuelle et un nom DNS stables. Les pods accèdent à d'autres pods en utilisant le nom du service Kubernetes comme nom d'hôte. Cependant, cela ne nous permet que de mettre en œuvre des stratégies de découverte de base, mais lorsque des stratégies de découverte/déploiement plus avancées sont nécessaires, telles que des versions Canary, des lancements sombres ou du trafic shadowing, les services Kubernetes ne suffisent pas.

Istio vous permet de contrôler facilement le flux de trafic entre les services à l'aide de deux concepts : DestinationRule et VirtualService.

Une DestinationRule définit les politiques de service du trafic une fois le routage effectué. Certaines des choses que nous configurons dans une règle de destination sont :

  • Politique de trafic.
  • Règle d'équilibrage de charge.
  • Paramètres du pool de connexions.
  • mTLS.
  • Résilience.
  • Spécifiez des sous-ensembles du service à l'aide d'étiquettes. Ces sous-ensembles sont utilisés dans VirtualService.

Créons un fichier nommé destination-rule-v1-v2.yml pour enregistrer deux sous-ensembles : un pour le service de notation v1 et un autre pour le service de notation v2 :

apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
 name: rating
spec:
 host: rating
 subsets:
 - labels:
     app.kubernetes.io/version: v1.0.0
   name: version-v1
 - labels:
     app.kubernetes.io/version: v2.0.0
   name: version-v2

Nous définissons le champ host à rating car c'est le nom DNS spécifié dans le service Kubernetes. Ensuite, dans la section subsets, nous définissons les sous-ensembles en utilisant l'ensemble labels comme les ressources Kubernetes et en les regroupant sous un name "virtual". Par exemple, deux groupes sont créés dans le cas précédent : un groupe pour la version 1 et un autre groupe pour les services de notation de la version 2.

kubectl apply -f src/main/kubernetes/destination-rule-v1-v2.yml -n default
destinationrule.networking.istio.io/rating created

Un VirtualService vous permet de configurer la manière dont les requêtes sont acheminées vers un service au sein du service mesh Istio. Grâce aux services virtuels, il est simple de mettre en œuvre des stratégies telles que les tests A/B, les déploiements Blue/Green, les déploiements canary ou les lancements sombres.

Créons un fichier nommé virtual-service-v1.yml pour envoyer tout le trafic vers le rating v1 :

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
 name: rating
spec:
 hosts:
 - rating
 http:
 - route:
   - destination:
       host: rating
       subset: version-v1
     weight: 100

Dans le fichier précédent, nous configurons toute requête pour atteindre l'hôte rating qui doit être envoyée aux Pods de notation appartenant au sous-ensemble version-v1. Rappelez-vous, le fichier DestinationRule a créé ce sous-ensemble.

.

kubectl apply -f src/main/kubernetes/virtual-service-v1.yml -n default
virtualservice.networking.istio.io/rating created

Exécutez maintenant quelques commandes curl sur le service à nouveau, mais maintenant la différence la plus significative est la sortie car toutes les demandes sont envoyées à rating v1.

.

curl 192.168.99.116:31304/book/1
{"bookId":1,"name":"Book 1","rating":1}
curl 192.168.99.116:31304/book/1
{"bookId":1,"name":"Book 1","rating":1}
curl 192.168.99.116:31304/book/1
{"bookId":1,"name":"Book 1","rating":1}

Evidemment, nous pouvons créer un autre fichier de service virtuel pointant vers le classement v2 à la place :

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
 name: rating
spec:
 hosts:
 - rating
 http:
 - route:
   - destination:
       host: rating
       subset: version-v2
     weight: 100
kubectl apply -f src/main/kubernetes/virtual-service-v2.yml -n default
virtualservice.networking.istio.io/rating configured

Et le trafic est envoyé à rating v2:

curl 192.168.99.116:31304/book/1
{"bookId":1,"name":"Book 1","rating":3}
curl 192.168.99.116:31304/book/1
{"bookId":1,"name":"Book 1","rating":3}

Le champ rating n'est plus mis à 1 car la requête a été traitée par la version 2.

.

Une release canary est effectuée en modifiant le champ weight dans le service virtuel.

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
 name: rating
spec:
 hosts:
 - rating
 http:
 - route:
   - destination:
       host: rating
       subset: version-v1
     weight: 75
   - destination:
       host: rating
       subset: version-v2
     weight: 25
kubectl apply -f src/main/kubernetes/virtual-service-v1-v2-75-25.yml -n default
virtualservice.networking.istio.io/rating configured

Maintenant, exécutez quelques commandes curl sur l'application :

.

curl 192.168.99.116:31304/book/1
{"bookId":1,"name":"Book 1","rating":1}
curl 192.168.99.116:31304/book/1
{"bookId":1,"name":"Book 1","rating":1}
curl 192.168.99.116:31304/book/1
{"bookId":1,"name":"Book 1","rating":1}
curl 192.168.99.116:31304/book/1
{"bookId":1,"name":"Book 1","rating":3}

La cote v1 est accédée plus de fois que la cote v2 suivant la proportion définie dans le champ weight.

Supprimons la ressource du service virtuel pour revenir au comportement par défaut (la stratégie round-robin) :

kubectl delete -f src/main/kubernetes/virtual-service-v1-v2-75-25.yml -n default
virtualservice.networking.istio.io "rating" deleted

Résilience

Dans une architecture de microservices, nous devons développer en pensant à l'échec, en particulier lors de la communication avec d'autres services. Dans une application monolithique, votre application, dans son ensemble, est active ou inactive, mais dans une architecture de microservices, ce n'est pas le cas, car certaines peuvent être actives et d'autres peuvent être inactives. La résilience (ou résilience des applications) est la capacité d'une application/d'un service à réagir aux problèmes tout en fournissant le meilleur résultat possible.

Voyons comment Istio nous aide à mettre en œuvre des stratégies de résilience et comment les configurer.

Les échecs

Le service d'évaluation implémente un endpoint particulier qui fait que le service commence à renvoyer un code d'erreur HTTP 503 lors de l'accès.

Exécutez la commande suivante (en changeant le nom du pod avec le vôtre) pour que le service rating v2 commence à échouer lors de l'accès :

kubectl get pods -n default

NAME                                READY   STATUS    RESTARTS   AGE
book-service-5cc59cdcfd-5qhb2       2/2     Running   4          47h
rating-service-v1-64b67cd8d-5bfpf   2/2     Running   4          47h
rating-service-v2-66b55746d-f4hpl   2/2     Running   4          47h

kubectl exec -ti rating-service-v2-66b55746d-f4hpl -c rating-service -n default curl localhost:8080/rate/misbehave

Ratings endpoint returns 503 error.

Nouvelles tentatives

Actuellement, Istio est configuré sans service virtuel, ce qui signifie qu'il équilibre les demandes entre les deux versions.

Faisons quelques requêtes et validons que rating v2 échoue :

curl 192.168.99.116:31304/book/1
{"bookId":1,"name":"Book 1","rating":1}

curl 192.168.99.116:31304/book/1

curl 192.168.99.116:31304/book/1
{"bookId":1,"name":"Book 1","rating":1}

Une requête ne produit aucune réponse car rating v2 ne renvoie pas de réponse valide, mais une erreur.

Les relances sont prises en charge par Istio et configurées dans une ressource VirtualService. Créez un nouveau fichier nommé virtual-service-retry.yml avec le contenu suivant :

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
 name: rating
spec:
 hosts:
 - rating
 http:
 - route:
   - destination:
       host: rating
   retries:
     attempts: 2
     perTryTimeout: 5s
     retryOn: 5xx

Nous sommes configurés pour exécuter deux tentatives automatiques si le service rating (toute version) renvoie un code d'erreur HTTP 5XX.

.

kubectl apply -f src/main/kubernetes/virtua-service-retry.yml -n default
virtualservice.networking.istio.io/rating created

Faisons quelques demandes et examinons le résultat :

curl 192.168.99.116:31304/book/1
{"bookId":1,"name":"Book 1","rating":1}

curl 192.168.99.116:31304/book/1
{"bookId":1,"name":"Book 1","rating":1}

curl 192.168.99.116:31304/book/1
{"bookId":1,"name":"Book 1","rating":1}

Nous voyons maintenant toutes les demandes répondre à rating v1. La raison est simple. Lorsque des demandes au service rating sont envoyées à v1, une réponse valide est fournie. Mais lorsque les requêtes sont envoyées à v2, une erreur se produit et une nouvelle tentative automatique est exécutée.

Étant donné que les appels sont équilibrés entre les deux services, la demande de nouvelle tentative est envoyée à v1, produisant une réponse valide.

Pour cette raison, chaque requête précédente renvoie une réponse de la v1.

Disjoncteur

Les tentatives automatiques sont un excellent moyen de gérer les pannes de réseau ou les erreurs sporadiques, mais que se passe-t-il lorsque plusieurs utilisateurs simultanés envoient des demandes à un système défaillant avec des tentatives automatiques ?

Simulons ce scénario en utilisant Siege, un utilitaire de test de charge HTTP, mais d'abord, examinons les journaux de rating v2 à l'aide de la commande kubectl :

kubectl get pods -n default

NAME                                READY   STATUS    RESTARTS   AGE
book-service-5cc59cdcfd-5qhb2       2/2     Running   4          47h
rating-service-v1-64b67cd8d-5bfpf   2/2     Running   4          47h
rating-service-v2-66b55746d-f4hpl   2/2     Running   4          47h

kubectl logs rating-service-v2-66b55746d-f4hpl -c rating-service -n default

…
Request 31
Request 32
Request 33
Request 34

Ces lignes de journal montrent le nombre de demandes traitées par ce service. Actuellement, le service a traité 34 demandes.

Pour simuler quatre utilisateurs simultanés, envoyant chacun dix requêtes à l'application, exécutez la commande siege suivante :

.

siege -r 10 -c 4 -v -d 1 192.168.99.116:31304/book/1

HTTP/1.1 200     0.04 secs:      39 bytes ==> GET  /book/1
HTTP/1.1 200     0.03 secs:      39 bytes ==> GET  /book/1

Transactions:		          40 hits
Availability:		      100.00 %
Elapsed time:		        0.51 secs
Data transferred:	        0.00 MB
Response time:		        0.05 secs
Transaction rate:	       78.43 trans/sec
Throughput:		        0.00 MB/sec
Concurrency:		        3.80
Successful transactions:          40
Failed transactions:	           0
Longest transaction:	        0.13
Shortest transaction:	        0.01

Bien sûr, aucun échec n'est envoyé à l'appelant car il y a des tentatives automatiques, mais inspectons à nouveau les journaux de rating v2 :

kubectl logs rating-service-v2-66b55746d-f4hpl -c rating-service -n default

…
Request 56
Request 57
Request 58
Request 59

Bien que rating v2 n'ait pas pu générer de réponse valide, le service a été consulté 25 fois, ce qui a eu un impact considérable sur l'application car :

  1. Si le service est surchargé, envoyer plus de requêtes semble être une mauvaise idée pour le laisser récupérer. La meilleure approche serait probablement de mettre l'instance du service en quarantaine.
  2. Si le service échoue simplement à cause d'un bug, une nouvelle tentative n'améliorera pas la situation.
  3. Pour chaque nouvelle tentative, une socket est créée, certains descripteurs de fichiers sont alloués ou certains paquets sont envoyés via le réseau pour aboutir à un échec. Ce processus impacte les autres services s'exécutant dans le même nœud (CPU, mémoire, descripteurs de fichiers, etc.) ou utilisant le réseau (augmentation du trafic inutile, latence, etc.).

Pour résoudre ce problème, nous devons trouver un moyen d'arrêter automatiquement lorsque l'exécution échoue à plusieurs reprises. Le pattern disjoncteur et les patterns bulkhead sont des solutions à ce problème. Le premier fournit une stratégie d'échec rapide lorsque des erreurs simultanées se produisent, et le second limite le nombre d'exécutions simultanées.

Créez maintenant un nouveau fichier nommé destination-rule-circuit-breaker.yml avec le contenu suivant :

apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
 name: rating
spec:
 host: rating
 subsets:
 - labels:
     version: v1
   name: version-v1
 - labels:
     version: v2
   name: version-v2
 trafficPolicy:
   connectionPool:
     http:
       http1MaxPendingRequests: 3
       maxRequestsPerConnection: 3
     tcp:
       maxConnections: 3
   outlierDetection:
     baseEjectionTime: 3m
     consecutive5xxErrors: 1
     interval: 1s
     maxEjectionPercent: 100

La première chose à remarquer est que DestinationRule configure le disjoncteur. Outre la configuration des paramètres du disjoncteur, des sous-ensembles doivent être spécifiés. La limitation des connexions simultanées est définie dans le champ connectionPool.

Pour configurer le disjoncteur, utilisez la section outlierDetection. Pour cet exemple, le circuit sera ouvert si une erreur se produit dans une fenêtre d'une seconde, déclenchant le service pendant trois minutes. Après cette période, le circuit sera semi-ouvert, ce qui signifie que la logique réelle est exécutée. Si l'erreur se reproduit, le circuit reste ouvert ; sinon, il est fermé.

kubectl apply -f src/main/kubernetes/destination-rule-circuit-breaker.yml
destinationrule.networking.istio.io/rating configured

Nous avons configuré le pattern disjoncteur dans Istio ; exécutons à nouveau la commande siege et inspectons les journaux de rating v2.

.

siege -r 10 -c 4 -v -d 1 192.168.99.116:31304/book/1

HTTP/1.1 200     0.04 secs:      39 bytes ==> GET  /book/1
HTTP/1.1 200     0.03 secs:      39 bytes ==> GET  /book/1

Transactions:		          40 hits
Availability:		      100.00 %

Inspectez à nouveau les journaux. Rappelez-vous que lors de l'exécution précédente, nous nous sommes arrêtés à la requête 59.

kubectl logs rating-service-v2-66b55746d-f4hpl -c rating-service -n default

kubectl logs rating-service-v2-66b55746d-f4hpl -c rating-service -n default

…
Request 56
Request 57
Request 58
Request 59
Request 60

Rating v2 ne reçoit qu'une seule requête car la première requête traitée a renvoyé une erreur, le circuit a été ouvert et aucune autre requête n'a été envoyée à rating v2.

Nous avons maintenant vu la résilience en action en utilisant Istio. Au lieu d'implémenter cette logique dans le service en même temps que la logique métier, c'est le conteneur sidecar qui l'implémente.

Finalement, exécutez la commande suivante pour ramener rating v2 à l'état précédent.

kubectl exec -ti rating-service-v2-66b55746d-f4hpl -c rating-service curl localhost:8080/rate/behave
Back to normal

Authentification

L'un des problèmes que nous pouvons rencontrer lors de la mise en œuvre d'une architecture de microservices est de savoir comment protéger les communications entre les services internes. Doit-on utiliser mTLS ? Doit-on authentifier les requêtes ? Et faut-il les autoriser ? La réponse à ces questions est OUI !. Étape par étape, nous allons voir comment Istio peut nous aider avec cela.

Authentification

Istio met automatiquement à niveau tout le trafic entre les proxys et les charges de travail vers mTLS sans rien modifier dans le code de service. Pendant ce temps, en tant que développeurs, nous implémentons des services utilisant le protocole HTTP. Lorsque le service est « istioisé », la communication entre les services se produit avec HTTPS. Istio se charge de la gestion des certificats, des autorités de certification ou de la révocation/renouvellement des certificats.

Pour valider que mTLS est activé, nous pouvons utiliser l'outil istioctl en exécutant la commande suivante :

istioctl experimental authz check book-service-5cc59cdcfd-5qhb2 -a

LISTENER[FilterChain]     HTTP ROUTE          ALPN        mTLS (MODE)          AuthZ (RULES)

...
virtualInbound[5] inbound|8080|http|book-service.default.svc.cluster.local             istio,istio-http/1.0,istio-http/1.1,istio-h2 noneSDS: default     yes (PERMISSIVE)     no (none)

…

L'hôte Book-service sur le port 8080 a mTLS configuré avec une stratégie permissive.

Autorisation

Voyons comment activer l'authentification des utilisateurs finaux avec Istio à l'aide du format JSON Web Token (JWT).

La première chose à faire est d'appliquer une ressource RequestAuthentication. Cette politique garantit que si l'en-tête Authorization contient un jeton JWT, il doit être valide, non expiré, émis par le bon émetteur et non manipulé.

apiVersion: "security.istio.io/v1beta1"
kind: "RequestAuthentication"
metadata:
 name: "bookjwt"
 namespace: default
spec:
 selector:
   matchLabels:
     app.kubernetes.io/name: book-service
 jwtRules:
 - issuer: "testing@secure.istio.io"
   jwksUri: "https://gist.githubusercontent.com/lordofthejars/7dad589384612d7a6e18398ac0f10065/raw/ea0f8e7b729fb1df25d4dc60bf17dee409aad204/jwks.json"

Les champs essentiels sont :

  • issuer : Émetteur valide du jeton. Si le jeton fourni ne spécifie pas cet émetteur dans le champ iss JWT, alors le jeton est invalide.
  • jwksUri : URL du fichier jwks où sont enregistrées les clés publiques pour valider la signature du jeton.
kubectl apply -f src/main/kubernetes/request-authentication-jwt.yml -n default
requestauthentication.security.istio.io/bookjwt created

Exécutez maintenant la commande curl à nouveau avec un jeton invalide :

.

curl 192.168.99.116:31304/book/1 -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IkRIRmJwb0lVcXJZOHQyenBBMnFYZkNtcjVWTzVaRXI0UnpIVV8tZW52dlEiLCJ0eXAiOiJKV1QifQ.eyJleHAiOjQ2ODU5ODk3MDAsImZvbyI6ImJhciIsImlhdCI6MTUzMjM4OTcwMCwiaXNzIjoidGVzdGluZ0BzZWN1cmUuaXN0aW8uaW8iLCJzdWIiOiJ0ZXN0aW5nQHNlY3VyZS5pc3Rpby5pbyJ9.CfNnxWP2tcnR9q0vxyxweaF3ovQYHYZl82hAUsn21bwQd9zP7c-LS9qd_vpdLG4Tn1A15NxfCjp5f7QNBUo-KC9PJqYpgGbaXhaGx7bEdFWjcwv3nZzvc7M__ZpaCERdwU7igUmJqYGBYQ51vr2njU9ZimyKkfDe3axcyiBZde7G6dabliUosJvvKOPcKIWPccCgefSj_GNfwIip3-SsFdlR7BtbVUcqR-yv-XOxJ3Uc1MI0tz3uMiiZcyPV7sNCU4KRnemRIMHVOfuvHsU60_GhGbiSFzgPTAa9WTltbnarTbxudb_YEOx12JiwYToeX0DCPb43W1tzIBxgm8NxUU"

Jwt verification fails

Puisque le jeton n'est pas valide, la requête est rejetée avec un code HTTP/1.1 401 Unauthorized.

.

Répétez la requête précédente avec un jeton valide :

curl 192.168.99.116:31304/book/1 -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IkRIRmJwb0lVcXJZOHQyenBBMnFYZkNtcjVWTzVaRXI0UnpIVV8tZW52dlEiLCJ0eXAiOiJKV1QifQ.eyJleHAiOjQ2ODU5ODk3MDAsImZvbyI6ImJhciIsImlhdCI6MTUzMjM4OTcwMCwiaXNzIjoidGVzdGluZ0BzZWN1cmUuaXN0aW8uaW8iLCJzdWIiOiJ0ZXN0aW5nQHNlY3VyZS5pc3Rpby5pbyJ9.CfNnxWP2tcnR9q0vxyxweaF3ovQYHYZl82hAUsn21bwQd9zP7c-LS9qd_vpdLG4Tn1A15NxfCjp5f7QNBUo-KC9PJqYpgGbaXhaGx7bEdFWjcwv3nZzvc7M__ZpaCERdwU7igUmJqYGBYQ51vr2njU9ZimyKkfDe3axcyiBZde7G6dabliUosJvvKOPcKIWPccCgefSj_GNfwIip3-SsFdlR7BtbVUcqR-yv-XOxJ3Uc1MI0tz3uMiiZcyPV7sNCU4KRnemRIMHVOfuvHsU60_GhGbiSFzgPTAa9WTltbnarTbxudb_YEOx12JiwYToeX0DCPb43W1tzIBxgm8NxUg"

{"bookId":1,"name":"Book 1","rating":3}

Nous voyons maintenant une réponse valide car le jeton est correct.

Jusqu'ici, nous ne faisons qu'authentifier les requêtes (seul un jeton valide est requis), mais Istio prend également en charge l'autorisation suivant un modèle de contrôle d'accès basé sur les rôles (RBAC). Créons un AuthorizationPolicy pour n'autoriser que les demandes avec un jeton Web JSON valide avec le claim role défini sur customer. Créez un fichier avec le nom authorization-policy-jwt.yml:

.

apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
 name: require-jwt
 namespace: default
spec:
 selector:
   matchLabels:
     app.kubernetes.io/name: book-service
 action: ALLOW
 rules:
 - from:
   - source:
      requestPrincipals: ["testing@secure.istio.io/testing@secure.istio.io"]
   when:
   - key: request.auth.claims[role]
     values: ["customer"]
kubectl apply -f src/main/kubernetes/authorization-policy-jwt.yml
authorizationpolicy.security.istio.io/require-jwt created

Exécutez ensuite exactement la même commande curl que précédemment:

.

curl 192.168.99.116:31304/book/1 -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IkRIRmJwb0lVcXJZOHQyenBBMnFYZkNtcjVWTzVaRXI0UnpIVV8tZW52dlEiLCJ0eXAiOiJKV1QifQ.eyJleHAiOjQ2ODU5ODk3MDAsImZvbyI6ImJhciIsImlhdCI6MTUzMjM4OTcwMCwiaXNzIjoidGVzdGluZ0BzZWN1cmUuaXN0aW8uaW8iLCJzdWIiOiJ0ZXN0aW5nQHNlY3VyZS5pc3Rpby5pbyJ9.CfNnxWP2tcnR9q0vxyxweaF3ovQYHYZl82hAUsn21bwQd9zP7c-LS9qd_vpdLG4Tn1A15NxfCjp5f7QNBUo-KC9PJqYpgGbaXhaGx7bEdFWjcwv3nZzvc7M__ZpaCERdwU7igUmJqYGBYQ51vr2njU9ZimyKkfDe3axcyiBZde7G6dabliUosJvvKOPcKIWPccCgefSj_GNfwIip3-SsFdlR7BtbVUcqR-yv-XOxJ3Uc1MI0tz3uMiiZcyPV7sNCU4KRnemRIMHVOfuvHsU60_GhGbiSFzgPTAa9WTltbnarTbxudb_YEOx12JiwYToeX0DCPb43W1tzIBxgm8NxUg"

RBAC: access denied

La réponse est légèrement différente cette fois-ci. Bien que le jeton soit valide, l'accès est refusé parce que le jeton n'a pas de rôle revendiqué défini sur customer.

Utilisons le jeton suivant :

curl 192.168.99.116:31304/book/1 -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IkRIRmJwb0lVcXJZOHQyenBBMnFYZkNtcjVWTzVaRXI0UnpIVV8tZW52dlEiLCJ0eXAiOiJKV1QifQ.eyJleHAiOjI1NDkwNTY4ODgsImlhdCI6MTU0OTA1Njg4OSwiaXNzIjoidGVzdGluZ0BzZWN1cmUuaXN0aW8uaW8iLCJyb2xlIjoiY3VzdG9tZXIiLCJzdWIiOiJ0ZXN0aW5nQHNlY3VyZS5pc3Rpby5pbyJ9.VM9VOHD2NwDjQ6k7tszB3helfAn5wcldxe950BveiFVg43pp7x5MWTjMtWQRmQc7iYul19PXsmGnSSOiQQobxdn2UnhHJeKeccCdX5YVgX68tR0R9xv_wxeYQWquH3roxHh2Xr2SU3gdt6s7gxKHrW7Zc4Z9bT-fnz3ijRUiyrs-HQN7DBc356eiZy2wS7O539lx3mr-pjM9PQtcDCDOGsnmwq1YdKw9o2VgbesfiHDDjJQlNv40wnsfpq2q4BgSmdsofAGwSNKWtqUE6kU7K2hvV2FvgwjzcB19bbRYMWxRG0gHyqgFy-uM5tsC6Cib-gPAIWxCdXDmLEiqIdjM3w"

{"bookId":1,"name":"Book 1","rating":3}

Nous voyons maintenant une réponse valide car le jeton est correct et contient une valeur de rôle valide.

Observabilité

Istio est livré avec quatre composants installés pour répondre aux exigences d'observabilité :

Obtenez tous les pods de l'espace de noms istio-system :

kubectl  get pods -n istio-system
NAME                                   READY   STATUS         RESTARTS   AGE

grafana-b54bb57b9-k5qbm                1/1     Running        0          178m
istio-egressgateway-68587b7b8b-vdr67   1/1     Running        0          178m
istio-ingressgateway-55bdff67f-hlnqw   1/1     Running        0          178m
istio-tracing-9dd6c4f7c-44xhk          1/1     Running        0          178m
istiod-76bf8475c-xphgd                 1/1     Running        7          177d
kiali-d45468dc4-fl8j4                  1/1     Running        0          178m
prometheus-74d44d84db-zmkd7            2/2     Running        0          178m

Monitoring

Istio s'intègre à Prometheus pour envoyer toutes sortes d'informations liées au trafic réseau et aux services. De plus, il fournit une instance Grafana permettant de visualiser toutes les données collectées.

Pour accéder à Grafana, exposons le Pod en utilisant la commande port-forward :

kubectl port-forward -n istio-system grafana-b54bb57b9-k5qbm 3000:3000
Forwarding from 127.0.0.1:3000 -> 3000
Forwarding from [::1]:3000 -> 3000

Ouvrir un navigateur et accéder au tableau de bord Grafana en naviguant vers locahost:3000.

.

Kiali est un autre outil exécuté au sein d'Istio pour gérer Istio et observer les paramètres des services mesh, tels que la façon dont les services sont connectés, leurs performances et les ressources Istio enregistrées.

Pour accéder à Kiali, exposons le Pod en utilisant la commande port-forward :

kubectl port-forward -n istio-system kiali-d45468dc4-fl8j4 20001:20001
Forwarding from 127.0.0.1:20001 -> 20001
Forwarding from [::1]:20001 -> 20001

Ouvrir un navigateur, accéder au tableau de bord d'Istio, puis naviguer vers locahost:20001.

Traçage

Le traçage est utilisé pour visualiser le flux d'un programme et la progression des données. Istio intercepte toutes les requêtes/réponses et les envoie à Jaeger.

Au lieu d'utiliser la commande port-forward, nous pouvons utiliser istioctl pour exposer le port et ouvrir la page automatiquement :

istioctl dashboard jaeger

Conclusions

Développer et implémenter une architecture de microservices est un peu plus difficile que développer une application monolithique. Nous pensons que les microservicilités peuvent vous conduire à créer des services correctement en termes d'infrastructure applicative.

Istio implémente certaines de ces microservices dans un conteneur side-car, les rendant réutilisables dans tous les services indépendamment du ou des langages de programmation utilisés pour l'application.

De plus, l'approche Istio permet de modifier le comportement des services sans avoir à les redéployer.

Si vous envisagez de développer des microservices et de les déployer sur Kubernetes, Istio est une solution viable car elle s'intègre facilement à Kubernetes.

Le code source démontré dans cet article peut être trouvé sur ce GitHub référentiel et le code source pour la première partie de cette série peut être trouvée sur ce repository GitHub.

A propos de l'auteur

Alex Soto est Director of Developer Experience chez Red Hat. Il est passionné par le monde Java, l'automatisation des logiciels, et il croit au modèle du logiciel libre. Alex Soto est le co-auteur de Manning | Testing Java Microservices et O'Reilly | Quarkus Cookbook et contributeur à plusieurs projets open-source. Champion de Java depuis 2017, il est également conférencier international et enseignant à l'université Salle URL. Vous pouvez le suivre sur Twitter (Alex Soto ⚛️) pour rester au courant de ce qui se passe dans le monde de Kubernetes et de Java.

 

Evaluer cet article

Pertinence
Style

Contenu Éducatif

Bonjour étranger!

Vous devez créer un compte InfoQ ou cliquez sur pour déposer des commentaires. Mais il y a bien d'autres avantages à s'enregistrer.

Tirez le meilleur d'InfoQ

Html autorisé: a,b,br,blockquote,i,li,pre,u,ul,p

Commentaires de la Communauté

Html autorisé: a,b,br,blockquote,i,li,pre,u,ul,p

Html autorisé: a,b,br,blockquote,i,li,pre,u,ul,p

BT