Qu’est-ce que Prometheus ?

Prometheus est une solution de supervision créée par SoundCloud en 2012 et open-sourcée en 2015. En 2016, Prometheus est le deuxième projet ayant rejoint la Cloud Native Computing Foundation (le premier étant Kubernetes).

Prometheus est conçu pour monitorer des métriques provenant d’applications ou de serveurs.

Il se décompose en 3 parties :

  • le requêteur pour récupérer les métriques sur les exporters;
  • la Time Series Database (TSDB) stockant les données à court-terme;
  • le service web permettant de requêter la base de donnée;

Architecture de Prometheus

Il intègre également un outil d’alerting réagissant lorsqu’une métrique dépasse un seuil considéré comme critique.

Cette stack n’utilise pas d’agents à proprement parler, Prometheus se base sur des exporters : des micro-services se chargeant de récupérer des informations sur l’hôte et de les exposer sur une page web dans un format propre à Prometheus.

Exemple:

# HELP node_disk_written_bytes_total The total number of bytes written successfully.
# TYPE node_disk_written_bytes_total counter
node_disk_written_bytes_total{device="sda"} 1.71447477248e+11
node_disk_written_bytes_total{device="sr0"} 0  

Exemple de cas d’usage : Pour monitorer un système Linux ainsi que les conteneurs Docker présents sur celui-ci, il est nécessaire d’installer un premier exporter pour les métriques systèmes et un second pour le daemon Docker. Un (ou plus) serveur Prometheus pourra alors requêter ces deux exporters pour récupérer les données.

Prometheus se charge de récupérer les données en interrogeant les exporters lui-même, il fonctionne en Pull et non en Push !


La base de donnée de Prometheus possède un Index basé sur le temps (Comme InfluxDB) mais également sur des ensembles de couples clé-valeur : des labels. Les labels définissent le contexte d’une information : par qui ? quelle application ? quelle machine ?

La base de Prometheus ne répond pas à du SQL, mais à du PromQL : un langage adaptée à cette notion de label.

Celui-ci se présente sous le format suivant: NOM_METRIQUES{LABEL1="VALEUR",LABEL2="valeur"}[durée]

  • En SQL: SELECT node_memory_MemFree_bytes WHERE instance IS "nodeexporter:9100" AND WHERE TIME >= Dateadd(MINUTE, -5, GETDATE())
  • En PromQL: node_memory_MemFree_bytes{instance="nodeexporter:9100"}[5m]

Les avantages et les inconvénients

Avantages:

  • Peut s’intégrer à de nombreuses solutions grâce aux exporters tiers (+ les exporters sont faciles à créer).
  • Gère les alertes* lui-même.
  • Possible d’avoir x Prometheus agregés par un Prometheus central (ex: un Prometheus par zone/DC).

Inconvénients:

  • Pas de notion de cluster (l’agrégation ne crée pas de la HA).
  • Pas adapté pour stocker des données textuelles (uniquement les métriques).
  • Peu de sécurité (uniquement TLS et authentification en ‘basic_auth’).

Installation de Prometheus

Prometheus est disponible dans la plupart des dépôts :

apt install prometheus
apk add prometheus
yum install prometheus

Il est aussi possible de récupérer une version récente depuis l’onglet Release du dépôt Github officiel.

En démarrant le service Prometheus, un serveur web sera accessible au port 9090.

Information

Je vais utiliser le domaine prometheus.home qui est géré par mon DHCP/DNS local. L’usage d’un nom de domaine est facultatif mais sera essentiel dans une prochaine section (support du TLS).

Mon serveur Prometheus utilise l’adresse suivante : http://server.prometheus.home:9090

Configurer Prometheus

Si vous installez la version sous forme de paquet, vous aurez un fichier de configuration disponible à cet emplacement : /etc/prometheus/config.yml

global:
  scrape_interval: 15s # Set the scrape interval to every 15 seconds. Default is every 1 minute.
  evaluation_interval: 15s # Evaluate rules every 15 seconds. The default is every 1 minute.

alerting:
  alertmanagers:
    - static_configs:
        - targets:
          # - alertmanager:9093

# Load rules once and periodically evaluate them according to the global 'evaluation_interval'.
rule_files:
  # - "first_rules.yml"
  # - "second_rules.yml"

# A scrape configuration containing exactly one endpoint to scrape:
# Here it's Prometheus itself.
scrape_configs:
  # The job name is added as a label `job=<job_name>` to any timeseries scraped from this config.
  - job_name: "prometheus"
    static_configs:
      - targets: ["localhost:9090"]

À ce stade, la partie qui nous interesse est scrape_config puisque c’est à cet emplacement que nous allons ajouter les exporters.

On remarque d’ailleurs que Prometheus se monitore lui-même. Nous pouvons déjà commencer à écrire nos premières requêtes en PromQL.

Information

Pour info, voici les caractéristiques de mon installation:

  • OS: Alpine 3.17.1
  • Prometheus: 2.47.1
  • NodeExporter: 1.6.1 (à voir plus tard)
  • AlertManager: 0.26.0 (à voir plus tard)
  • PushGateway 1.6.2 (à voir plus tard)

Si ce n’est pas déjà fait, il faut démarrer le service Prometheus.

service prometheus start # AlpineOS
systemctl start prometheus

Pour voir les métriques de Prometheus, il suffit de faire une requête sur le chemin suivant : /metrics.

curl http://server.prometheus.home:9090/metrics -s | tail -n 5
# HELP promhttp_metric_handler_requests_total Total number of scrapes by HTTP status code.
# TYPE promhttp_metric_handler_requests_total counter
promhttp_metric_handler_requests_total{code="200"} 20
promhttp_metric_handler_requests_total{code="500"} 0
promhttp_metric_handler_requests_total{code="503"} 0

Depuis l’interface web il est possible d’afficher les métriques disponibles et de les requêter.

Interface web de Prometheus

Information

Il existe un utilitaire nommé promtool permettant de tester la configuration de Prometheus et de faire des requêtes en PromQL.

Exemple de requête via PromTool:

promtool query instant http://localhost:9090 'prometheus_http_requests_total{code="200", handler="/graph", instance="localhost:9090", job="prometheus"}'

 prometheus_http_requests_total{code="200", handler="/graph", instance="localhost:9090", job="prometheus"} => 2 @[1696592187.208]

Notre Premiere Requête

Le PromQL

Qu’est-ce que le PromQL ?

Avant de continuer à ajouter des exporters, je vais faire un petit tour du PromQL.

PromQL, ou Prometheus Query Language, est un langage de requête spécialement conçu pour interroger, analyser et extraire des données à partir d’une base de données de séries temporelles gérée par Prometheus. Ce dernier est un système de surveillance open-source largement utilisé qui collecte et stocke des métriques de performances ainsi que des données de séries temporelles à partir de diverses sources telles que des serveurs, des applications, des services, et plus encore.

PromQL permet aux utilisateurs de formuler des requêtes complexes pour extraire des informations utiles à partir des métriques collectées par Prometheus. Voici quelques-uns des principaux concepts et fonctionnalités de PromQL :

  • Séries temporelles : Les métriques dans Prometheus sont stockées sous forme de séries temporelles, qui sont des flux de points de données indexés par un ensemble de labels (étiquettes). Par exemple, une série temporelle pourrait représenter l’utilisation du CPU d’un serveur à un instant donné.

  • Sélection de métriques : Vous pouvez utiliser PromQL pour sélectionner des métriques spécifiques en fonction de critères tels que les labels, les noms de métriques, etc. Par exemple, vous pouvez interroger toutes les métriques liées au CPU d’une instance particulière en précisant son architecture et/ou son nom.

  • Agrégation : PromQL prend en charge des opérations d’agrégation telles que la somme, la moyenne, le maximum, le minimum, etc. Vous pouvez agréger les données sur une période de temps donnée pour obtenir des statistiques.

  • Opérations mathématiques : Vous pouvez effectuer des opérations mathématiques sur les métriques, ce qui vous permet de créer de nouvelles séries temporelles en combinant ou en transformant les données existantes.

Créer une requête

Une requête PromQL peut ressembler à ceci :

node_network_transmit_bytes_total

Cette règle sélectionne toutes les métriques ayant le nom node_network_transmit_bytes_total, peu importe sa provenance, son contexte, ou ses labels. On obtient alors une liste de réponses provenant de toutes les machines monitorées par Prometheus.

Il nous est alors possible d’ajouter un filtre pour préciser le contexte de la métrique :

node_network_transmit_bytes_total{instance="laptop.prometheus.home", device="wlp46s0"}

Nous ciblons maintenant l’instance laptop.prometheus.home et la carte réseau wlp46s0. Nous obtenons une seule réponse puisque ces précédents filtres ciblent une machine et une carte.

Cibler toutes les métriques possédant un label

Il est possible de faire une requête sur toutes les métriques possédant un label précis. Par exemple, je souhaite récupérer toutes les métriques dont le label instance est laptop.prometheus.home.

{instance="laptop.prometheus.home"}

Regex et négation

Une fonctionnalité très importante du PromQL est l’intégration des regex. Il est possible de faire des requêtes sur des métriques dont le label correspond à une expression régulière en rajoutant un tild (~) avant la valeur.

apt_upgrades_pending{arch=~"^a[a-z]+"}

Sans passer par une regex, on peut exclure un label avec un point d’exclamation (!).

apt_upgrades_pending{arch!="i386"}

On peut même combiner les deux pour exclure une regex !

apt_upgrades_pending{arch!~"i[0-9]{3}"}

Revenir dans le temps

Meme de retour vers le futur

Nous sommes maintenant capable de récupérer des métriques mais également d’en récuperer des plus anciennes avec le mot clé offset.

node_network_transmit_bytes_total{instance="laptop.prometheus.home", device="wlp46s0"} offset 5m

On obtient les métriques de la carte réseau wlp46s0 de la machine laptop.prometheus.home datant de 5 minutes.

Je peux aussi récupérer toutes les valeurs depuis 5 minutes.

node_network_transmit_bytes_total{instance="laptop.prometheus.home", device="wlp46s0"}[5m]

La réponse est la suivante :

87769521 @1696594464.484
87836251 @1696594479.484
87957452 @1696594494.484
88027802 @1696594509.484
88394773 @1696594524.484
88861454 @1696594539.484
90392775 @1696594554.484
91519657 @1696594569.484

(J’ai un peu raccourci le résultat.)

Ces valeurs sont séparées par un @ et sont au format valeur @ timestamp.

Mais cette métrique n’est pas très pertinente puisqu’il est évident que la valeur augmente au fil du temps.

Il est alors possible de faire des opérations mathématiques sur les métriques et notamment d’en sortir un graphique avec la fonction rate qui a pour but de calculer le taux de croissance d’une métrique.

rate(node_network_transmit_bytes_total{instance="laptop.prometheus.home", device="wlp46s0"}[5m])

Graphique

Dans ce cas, on convertit une valeur de type counter en gauge, nous allons voir comment cela fonctionne dans la prochaine section.

Les types de métriques

Il existe 4 types de métriques dans Prometheus :

  • Counter : une valeur qui augmente au fil du temps. Exemple : le nombre de requêtes HTTP 200 sur un serveur web, celui-ci ne peut pas diminuer.
  • Gauge : une valeur qui peut augmenter ou diminuer au fil du temps. Exemple : la quantité de mémoire utilisée par un processus.
  • Histogram : une valeur qui peut augmenter ou diminuer au fil du temps, mais qui est aussi capable de stocker des valeurs dans des buckets (des intervalles). Exemple : le nombre de requêtes HTTP 200 sur un serveur web, mais avec des intervalles de temps (0-100ms, 100-200ms, etc).
  • Summary : identique aux Histogram mais ne nécessitant pas de connaître les intervalles à l’avance.

Il est possible de passer d’un type à un autre en appliquant une fonction comme rate (vu juste au dessus, permettant de créer une courbe à partir d’un taux de croissance)

Notre premier exporter (NodeExporter)

Qu’est-ce que NodeExporter ?

Nous avons un Prometheus qui récupère les métriques d’un exporter (lui-même) à l’adresse : 127.0.0.1:9090.

Nous allons maintenant pouvoir ajouter un exporter plus pertinent qui va récupérer les métriques de notre système.

Habituellement, le premier exporter à installer sur une machine est le NodeExporter.

Je ne le ferai pas pour chaque exporter présenté mais voici les métriques disponibles sur le NodeExporter :

Métriques
  • Processeur :
    • node_cpu_seconds_total : Temps total passé par le processeur en mode utilisateur, système et inactif.
    • node_cpu_usage_seconds_total : Temps total passé par le processeur en mode utilisateur, système et inactif, exprimé en pourcentage.
    • node_cpu_frequency_average : Fréquence moyenne du processeur.
    • node_cpu_temperature : Température du processeur.
  • Mémoire :
    • node_memory_MemTotal_bytes : Quantité totale de mémoire physique disponible.
    • node_memory_MemFree_bytes : Quantité de mémoire physique libre.
    • node_memory_Buffers_bytes : Quantité de mémoire utilisée pour les tampons.
    • node_memory_Cached_bytes : Quantité de mémoire utilisée pour le cache.
    • node_memory_SwapTotal_bytes : Quantité totale de mémoire virtuelle disponible.
    • node_memory_SwapFree_bytes : Quantité de mémoire virtuelle libre.
  • Disque :
    • node_disk_io_time_seconds_total : Temps total passé par les disques en lecture et écriture.
    • node_disk_read_bytes_total : Quantité totale de données lues par les disques.
    • node_disk_write_bytes_total : Quantité totale de données écrites par les disques.
    • node_disk_reads_total : Nombre total de lectures de disque.
    • node_disk_writes_total : Nombre total d’écritures de disque.
  • Réseau :
    • node_network_receive_bytes_total : Quantité totale de données reçues par le réseau.
    • node_network_transmit_bytes_total : Quantité totale de données transmises par le réseau.
    • node_network_receive_packets_total : Nombre total de paquets reçus par le réseau.
    • node_network_transmit_packets_total : Nombre total de paquets transmis par le réseau.
  • Système d’exploitation :
    • node_kernel_version : Version du noyau Linux.
    • node_os_name : Nom de l’OS.
    • node_os_family : Famille de l’OS.
    • node_os_arch : Architecture de l’OS.
  • Matériel :
    • node_machine_type : Type de machine.
    • node_cpu_model : Modèle du processeur.
    • node_cpu_cores : Nombre de cœurs du processeur.
    • node_cpu_threads : Nombre de threads du processeur.
    • node_disk_model : Modèle du disque.
    • node_disk_size_bytes : Taille du disque.
    • node_memory_device : Dispositif mémoire.

Installation du NodeExporter

Installation sur Linux

Pour installer le NodeExporter, il y a plusieurs méthodes. La plus simple est d’utiliser le paquet officiel :

apt install prometheus-node-exporter
apk add prometheus-node-exporter
yum install prometheus-node-exporter

Astuce

Installation sur Docker

La plupart des exporters sont aussi disponibles sous forme de conteneur Docker. C’est le cas du NodeExporter :

docker run -d \
  --net="host" \
  --pid="host" \
  -v "/:/host:ro,rslave" \
  quay.io/prometheus/node-exporter:latest \
  --path.rootfs=/host

Installation manuelle

Il est également possible de récupérer une version récente depuis l’onglet Release du dépôt Github officiel.

Installation sur Windows

Windows ne possède pas le même programme que ses confrères linuxiens. Celui-ci est developpé par la communauté mais ne possède pas les mêmes noms de métriques : Windows Exporter.

Configuration du NodeExporter

Si le service n’est pas démarré automatiquement, il faut le faire :

service prometheus-node-exporter start # AlpineOS
systemctl start prometheus-node-exporter

Ce service va ouvrir un port sur le 9100. Nous pourrons voir nos métriques à l’adresse suivante : http://server.prometheus.home:9100/metrics (en admettant que cet exporter soit sur le même serveur que Prometheus).

On peut voir que les métriques sont bien présentes, mais il faut maintenant les ajouter à notre fichier de configuration /etc/prometheus/config.yml pour que Prometheus en tienne compte.

global:
  scrape_interval: 15s
  evaluation_interval: 15s

alerting:
  alertmanagers:
    - static_configs:
        - targets:
          # - alertmanager:9093
rule_files:
  # - "first_rules.yml"
  # - "second_rules.yml"

scrape_configs:

  - job_name: "prometheus"
    static_configs:
      - targets: ["localhost:9090"]

  - job_name: "node"
    static_configs:
      - targets: ["localhost:9100"]

Après un reload du service Prometheus, nous pouvons voir que les métriques du NodeExporter sont bien présentes sur la page Status->Targets (ou http://server.prometheus.home:9090/targets).

Ajout du node-exporter

Tentons maintenant une requête pour voir si tout fonctionne bien :

round((node_filesystem_size_bytes{device="/dev/vg0/lv_root", instance="localhost:9100"} - node_filesystem_avail_bytes{device="/dev/vg0/lv_root", instance="localhost:9100"}) / node_filesystem_size_bytes{device="/dev/vg0/lv_root", instance="localhost:9100"} * 100)

Cette requête permet de calculer le pourcentage utilisé sur le volume /dev/vg0/lv_root. (taille totale - taille disponible) / taille totale * 100, le tout arrondi à l’entier le plus proche.

On remarque bien que nous précisons l’instance localhost:9100 et le device /dev/vg0/lv_root pour récupérer les métriques du bon volume. Cela veut dire que nous pouvons voir les informations d’une autre machine en modifiant cette valeur par la valeur “instance” correspondant à un autre exporter configuré dans Prometheus.

Je vais d’ores et déjà ajouter un autre exporter pour récupérer les métriques de mon PC portable.

  - job_name: "node"
    static_configs:
      - targets: ["localhost:9100"]
      - targets: ["laptop.prometheus.home:9100"]
        labels:
          owner: "Quentin"
          room: "office"

Requête sur le node-exporter de mon pc portable

➜ df -h | grep "data-root"
/dev/mapper/data-root   460G    408G   29G  94% /

Et comme vous l’avez vu, il est possible d’ajouter des labels pour préciser le contexte de la métrique. Ces labels seront utiles dans notre PromQL.

Je vais compléter notre terrain de jeu en ajoutant d’autres exporters.

Ajout de plusieurs exporters

Par exemple: ((node_network_transmit_bytes_total / node_network_receive_bytes_total) > 0) and {room="office", device!~"(tinc.*)|lo|(tap|tun).*|veth.*|fw.*|br.*"} permet de récupérer le ratio de données envoyées par rapport aux données reçues de toutes les machines présente dans le bureau en excluant les interfaces de type tinc, lo, tap, tun, veth, fw et br.

Comme pour le SQL, il est possible de faire des fonctions de groupes (sum, avg, min, max, count, etc) et des fonctions mathématiques (round, floor, ceil, etc).

Usage de count

Le Relabeling

Le relabeling est une fonctionnalité de Prometheus permettant de modifier les labels d’une métrique sans modifier l’exporter lui-même.

Son fonctionnement est simple:

  • On prend un label existant (source_label);
  • On y applique un traitement (suppression, remplacement, ajout, etc);
  • On stocke le résultat dans un nouveau label (target_label).

Il existe aussi des méta-labels qui rendent accessible certains paramètres de l’exporter.

Par exemple : __address__ contient l’adresse de l’exporter (qui créera le label instance), __scheme__ contient le protocole utilisé (http ou https), etc.

Avant le relabeling

Un relabeling peut être utilisé pour cacher le port utilisé par l’exporter.

  - job_name: "node"
    static_configs:
# ...
      - targets: ["bot.prometheus.home:9100"]
        labels:
          room: "office"
          virtual: "yes"
      - targets: ["proxmox.prometheus.home:9100"]
        labels:
          room: "office"
          virtual: "no"
    relabel_configs:
      - source_labels: [__address__]
        regex: '(.*):[0-9]+'
        replacement: '${1}'
        target_label: instance 

Après relabeling

Cette modification est basique mais permet déjà de simplifier nos requêtes lorsqu’on souhaite cibler une machine précise. Après l’ajout d’autres exporters sur la même machine, l’instance sera la même pour tous les exporters présents sur celle-ci.

Il est aussi possible de modifier le nom d’une métrique :

  - job_name: "node"
    static_configs:
# ...
      - targets: ["bot.prometheus.home:9100"]
        labels:
          room: "office"
          virtual: "yes"
      - targets: ["proxmox.prometheus.home:9100"]
        labels:
          room: "office"
          virtual: "no"
    metric_relabel_configs:
      - source_labels: ['__name__']
        regex: 'node_boot_time_seconds'
        target_label: '__name__'
        replacement: 'node_boot_time'

Avec cette configuration, je cherche toutes les métriques dont le label __name__ est node_boot_time_seconds et je le remplace par node_boot_time.

Metrics relabeling

Monitorer un port/site/IP (Blackbox Exporter)

Jusque là, nous n’avons monitoré que des métriques d’hôtes sur lesquels nous avions un accès pour y installer un exporter. Mais comment monitorer une entité distante sur laquelle nous n’avons pas la main ?

La solution est la suivante : un exporter n’a pas forcément besoin d’être sur l’hôte à monitorer. L’exporter MySQL peut être sur un serveur dédié à la supervision et monitorer une base de données distante.

Il existe alors un exporter permettant de monitorer un port, un site web ou une IP : Blackbox Exporter.

apt install prometheus-blackbox-exporter
apk add prometheus-blackbox-exporter
yum install prometheus-blackbox-exporter

Le fichier de configuration est le suivant : /etc/prometheus/blackbox.yml.

modules:
  http_2xx:
    prober: http
    http:
      preferred_ip_protocol: "ip4"
  http_post_2xx:
    prober: http
    http:
      method: POST
  tcp_connect:
    prober: tcp
  pop3s_banner:
    prober: tcp
    tcp:
      query_response:
      - expect: "^+OK"
      tls: true
      tls_config:
        insecure_skip_verify: false
  grpc:
    prober: grpc
    grpc:
      tls: true
      preferred_ip_protocol: "ip4"
  grpc_plain:
    prober: grpc
    grpc:
      tls: false
      service: "service1"
  ssh_banner:
    prober: tcp
    tcp:
      query_response:
      - expect: "^SSH-2.0-"
      - send: "SSH-2.0-blackbox-ssh-check"
  irc_banner:
    prober: tcp
    tcp:
      query_response:
      - send: "NICK prober"
      - send: "USER prober prober prober :prober"
      - expect: "PING :([^ ]+)"
        send: "PONG ${1}"
      - expect: "^:[^ ]+ 001"
  icmp:
    prober: icmp
  icmp_ttl5:
    prober: icmp
    timeout: 5s
    icmp:
      ttl: 5

Il n’est pas necéssaire de modifier la configuration par défaut. Ce seront des paramètres à préciser dans la requête.

curl "http://server.prometheus.home:9115/probe?target=https://une-tasse-de.cafe&module=http_2xx" -s | tail -n 12
# HELP probe_ssl_last_chain_expiry_timestamp_seconds Returns last SSL chain expiry in timestamp
# TYPE probe_ssl_last_chain_expiry_timestamp_seconds gauge
probe_ssl_last_chain_expiry_timestamp_seconds 1.703592167e+09
# HELP probe_ssl_last_chain_info Contains SSL leaf certificate information
# TYPE probe_ssl_last_chain_info gauge
probe_ssl_last_chain_info{fingerprint_sha256="1ad4423a139029b08944fe9b42206cc07bb1b482b959d3908c93b4e4ccec7ed8",issuer="CN=R3,O=Let's Encrypt,C=US",subject="CN=une-tasse-de.cafe",subjectalternative="une-tasse-de.cafe"} 1
# HELP probe_success Displays whether or not the probe was a success
# TYPE probe_success gauge
probe_success 1
# HELP probe_tls_version_info Returns the TLS version used or NaN when unknown
# TYPE probe_tls_version_info gauge
probe_tls_version_info{version="TLS 1.3"} 1

Les paramètres utilisés sont les suivants:

  • module : le module à utiliser (défini dans le fichier de configuration)
  • target : l’adresse à monitorer

Autre exemple pour ping d’une adresse IP : il faut utiliser le module icmp et préciser l’adresse IP dans le paramètre target.

curl 'http://server.prometheus.home:9115/probe?target=1.1.1.1&module=icmp' -s | tail -n 6
# HELP probe_ip_protocol Specifies whether probe ip protocol is IP4 or IP6
# TYPE probe_ip_protocol gauge
probe_ip_protocol 4
# HELP probe_success Displays whether or not the probe was a success
# TYPE probe_success gauge
probe_success 1

Maintenant, nous pouvons ajouter cet exporter à notre fichier de configuration Prometheus en précisant les cibles à monitorer.

Voici un exemple de configuration qui va monitorer les entités suivantes :

  - job_name: 'websites'
    metrics_path: /probe
    params:
      module: [http_2xx]
    static_configs:
      - targets:
        - https://une-tasse-de.cafe
        - https://une-pause-cafe.fr
    relabel_configs:
      - source_labels: [__address__]
        target_label: __param_target
      - source_labels: [__param_target]
        regex: 'http.://(.*)' # supprimer le http(s) dans l'instance, sinon mettre à (.*) pour garder l'adresse complète
        replacement: '${1}'
        target_label: instance
      - target_label: __address__ # Interroger le blackbox exporter
        replacement: 127.0.0.1:9115

  - job_name: 'icmp'
    metrics_path: /probe
    params:
      module: [icmp]
    static_configs:
      - targets:
        - 1.1.1.1
        - 192.168.1.250
        - 192.168.1.400 # existe pas
    relabel_configs:
      - source_labels: [__address__]
        target_label: __param_target
      - source_labels: [__param_target]
        regex: '(.*)'
        replacement: '${1}'
        target_label: instance
      - target_label: __address__
        replacement: 127.0.0.1:9115

  - job_name: 'tcp_port'
    metrics_path: /probe
    params:
      module: [tcp_connect]
    static_configs:
      - targets:
        - media_server:32400
    relabel_configs:
      - source_labels: [__address__]                                                                                                                                                                                                                          
        target_label: __param_target
      - target_label: __address__
        replacement: 127.0.0.1:9115
      - source_labels: [__param_target]                                                                                                                                                                                                                       
        regex: '(.*)'
        target_label: instance

BlackBox Exporter

Avertissement

Dès lors que nous ajoutons un exporter, nous avons une métrique up qui lui est assignée.

Cette métrique indique si un exporter est accessible ou non, cela ne veut pas dire que l’hôte monitoré est fonctionnel !

Exemple: l’IP 192.168.1.400 n’existe pas, mais l’exporter BlackBox est accessible. La requête up est OK, mais dès que je demande le résultat du ping celui-ci est à 0 (donc l’hôte ne répond pas).

promtool query instant http://localhost:9090 "up{instance='192.168.1.400'}"
  up{instance="192.168.1.250", job="icmp"} => 1 @[1696577858.881]
promtool query instant http://localhost:9090 "probe_success{instance='192.168.1.400'}"
  probe_success{instance="192.168.1.400", job="icmp"} => 0 @[1696577983.835]

Où trouver des exporters ?

La documentation officielle de Prometheus propose une liste d’exporters : prometheus.io/docs/instrumenting/exporters/.

La liste est assez complète, mais vous pouvez également trouver des SDKs et des exemples pour développer vos propres exporters directement sur le Github de Prometheus : github.com/prometheus.

Exemple d’exporter en Golang

Voici un exemple d’exporter en golang permettant de compter le nombre d’adresses IP dans le fichier /etc/hosts. Il ouvre le port 9108 et expose la métrique etc_hosts_ip_count à l’emplacement /metrics.

Code
package main

import (
	"fmt"
	"io/ioutil"
	"net/http"
	"strings"
	"github.com/prometheus/client_golang/prometheus"
	"github.com/prometheus/client_golang/prometheus/promhttp"
)

func main() {
	ipCount := prometheus.NewGauge(prometheus.GaugeOpts{
		Name: "etc_hosts_ip_count",
		Help: "Nombre d'adresses IP dans le fichier /etc/hosts",
	})

	prometheus.MustRegister(ipCount)
	fileContent, err := ioutil.ReadFile("/etc/hosts")
	if err != nil {
		fmt.Println(err)
	}
	ipCountValue := countIPs(string(fileContent))
	ipCount.Set(float64(ipCountValue))
	http.Handle("/metrics", promhttp.Handler())
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
                fmt.Fprintf(w, "Nombre d'adresses IP dans /etc/hosts: %d", ipCountValue)
	})
	fmt.Println("Démarrage du serveur HTTP sur le port :9108")
	fmt.Println(http.ListenAndServe(":9108", nil))
}
func countIPs(content string) int {
	lines := strings.Split(content, "\n")
	ipCount := 0
	for _, line := range lines {
		if strings.TrimSpace(line) == "" || strings.HasPrefix(line, "#") {
			continue
		}
		fields := strings.Fields(line)
		for _, field := range fields {
			if isIPv4(field) || isIPv6(field) {
				ipCount++
			}
		}
	}
	return ipCount
}

func isIPv4(s string) bool {
	parts := strings.Split(s, ".")
	if len(parts) != 4 {
		return false
	}
	for _, part := range parts {
		if len(part) == 0 {
			return false
		}
		for _, c := range part {
			if c < '0' || c > '9' {
				return false
			}
		}
		num := atoi(part)
		if num < 0 || num > 255 {
			return false
		}
	}
	return true
}
func isIPv6(s string) bool {
	return strings.Count(s, ":") >= 2 && strings.Count(s, ":") <= 7
}
func atoi(s string) int {
	n := 0
	for _, c := range s {
		n = n*10 + int(c-'0')
	}
	return n
}

Une fois l’exporter compilé et lancé, nous l’ajoutons à Prometheus :

scrape_configs:
  - job_name: "etc_hosts"
    static_configs:
      - targets: ["laptop.prometheus.home:9108"] 
promtool query instant http://localhost:9090 'etc_hosts_ip_count'
etc_hosts_ip_count{instance="laptop.prometheus.home:9108", job="etc_hosts"} => 4 @[1696597793.314]

Il est évidemment possible de faire la même chose avec un script Python, Java, Node, etc.

Les recording rules

Une recording rule est une simplification d’une requête PromQL. Elle fonctionne de la même manière qu’une requête mais est exécutée en amont et stockée dans une nouvelle métrique.

C’est un moyen d’optimiser les requêtes que l’on exécute régulièrement et ainsi de ne pas surcharger Prometheus (notammment lorsque l’on est dans le cadre d’un dashboard qui va répéter le même code PromQL à chaque intervalle de raffraichissement).

Les recordings rules doivent être créées dans un ou plusieurs fichiers. Par exemple, je vais créer un fichier nodes.yml dans le dossier /etc/prometheus/rules/ (dossier à créer avant) et y ajouter les règles suivantes :

groups:
  - name: node_exporter_recording_rules
    rules:
      - record: node_memory_usage
        expr: (node_memory_MemTotal_bytes - (node_memory_MemFree_bytes + node_memory_Buffers_bytes + node_memory_Cached_bytes)) / node_memory_MemTotal_bytes * 100
      - record: node_cpu_usage
        expr: 100 - (avg by (instance) (rate(node_cpu_seconds_total{job="node"}[5m])) * 100)
      - record: node_network_connections
        expr: avg_over_time(node_network_connections{job="node"}[5m])
      - record: node_disk_usage
        expr: (node_filesystem_size_bytes - node_filesystem_avail_bytes) / node_filesystem_size_bytes * 100

Les noms des règles doivent être unique et respecter la regex [a-zA-Z_:][a-zA-Z0-9_:]* (même règle que pour les métriques classiques).

Après ça, il faut ajouter le fichier de règles dans la configuration de Prometheus :

global:
  scrape_interval: 15s
  evaluation_interval: 15s
# ...
rule_files:  # <--- référence les fichiers de règles
  - "single_file.yml"
  - "rules/*.yml"

Après un reload de Prometheus, nous pouvons voir que les règles sont bien présentes dans l’onglet Status->Rules.

Création des règles

Ces règles sont utilisables dans nos requêtes PromQL, nous pouvons rajouter des filtres comme pour les métriques classiques.

Les alertes

J’en ai parlé au début de cet article : Prometheus est capable de nous alerter lorsqu’une métrique dépasse un certain seuil. C’est une fonctionnalité très importante pour un outil de supervision.

Mais … je vous ai menti. Prometheus va bien déclarer des alertes mais est incapable de nous les envoyer via un quelconque moyen. Il faut donc utiliser un autre outil pour gérer l’envoi des alertes. Le rôle de Prometheus est juste de “déclarer” ses alertes à l’outil choisi une fois les seuils critiques atteints.

Créer une alerte

Les alertes sont déclarées dans les mêmes fichiers que les Les recording rules. Je crée donc le répertoire /etc/prometheus/alerts/ et y ajoute le fichier nodes.yml avec le contenu suivant :

groups:
 - name: node_rules
   rules:
    - alert: any:exporter:down
      expr: > 
       ( up == 0 )
      labels:
        severity: low

    - alert: any:node:down
      expr: > 
       ( up{job="node"} == 0 )
      labels:
        severity: moderate

    - alert: datacenter:burning
      expr: count(up{job="node"}) == 0
      labels:
        severity: world-ending
      for: 5m

Ces alertes sont visibles dans l’onglet Status->Alerts.

  • any:exporter:down : alerte si un exporter est down.
  • any:node:down : alerte si un exporter de node est down.
  • datacenter:burning : alerte si tous les exporters de node sont down.

Voir nos alertes

Si je stoppe le service NodeExporter sur mon PC portable, je vois bien que les alertes any:exporter:down et any:node:down sont déclenchées.

 alertes activées

Maintenant… comment faire pour recevoir ces alertes ?

AlertManager

AlertManager est un outil permettant de gérer les alertes de Prometheus. Il est capable de recevoir les alertes de ce dernier et de les envoyer via différents supports (email, slack, webhook, etc). Il est également capable de gérer les silences et de grouper les alertes.

Installation

AlertManager est disponible dans les dépôts officiels de la plupart des distributions Linux.

apt install prometheus-alertmanager
apk add alertmanager
yum install prometheus-alertmanager

AlertManager est aussi instalable via Docker :

docker run -d -p 9093:9093 \
  --name alertmanager \
  -v /etc/alertmanager/config.yml:/etc/alertmanager/config.yml \
  prom/alertmanager

Ou via l’archive disponible sur le Github officiel : github.com/prometheus/alertmanager/releases.

Une fois démarré, il est accessible sur le port 9093. Il est possible de le configurer via le fichier /etc/alertmanager/config.yml.

AlertManager doit être déclaré dans la configuration de Prometheus :

global:
  scrape_interval: 5s
  evaluation_interval: 15s

alerting:
  alertmanagers:
    - static_configs:
        - targets:
          - server.prometheus.home:9093 # <---- ici !
rule_files:
  - "rules/*.yml"
  - "alerts/*.yml"
# ...

Et à partir du moment où Prometheus sera redémarré, il enverra les alertes à AlertManager dès lors qu’elles dépassent les seuils définis dans les fichiers de règles.

Notre première alerte

Dans mon cas, j’ai configuré des alertes qui envoient des emails via mon serveur SMTP :

receivers:
  - name: 'web.hook'
    webhook_configs:
      - url: 'http://127.0.0.1:5001/'
  - name: 'email'
    email_configs:
    - to: 'contact@mailserver'
      from: 'no-reply@mailserver'
      smarthost: 'mailserver:587'
      auth_username: 'no-reply@mailserver'
      auth_identity: 'no-reply@mailserver'
      auth_password: 'bigpassword'

Mail reçu par AlertManager

Inhibit Rules

Maintenant, l’alerte any:exporter:down n’est pas pertinente si une autre alerte d’une plus grande importance est déclenchée (comme par exemple any:node:down). C’est là qu’interviennent les inhibit rules dans AlertManager.

inhibit_rules:
 - source_matchers:
     - severity = moderate
   target_matchers:
     - severity = low
   equal: ['instance']

Avec cette configuration, si une alerte de severity moderate est déclenchée alors toutes les alertes de severity low seront inhibées pour peu qu’elles aient le même label instance (ce qui necéssite un relabeling si ce n’est pas le même exporter).

Maintenant, si je stoppe le service NodeExporter sur mon PC portable, je vois bien que l’alerte any:exporter:down est inhibée par l’alerte any:node:down.

Sur Prometheus, nos deux alertes sont bien déclenchées : Declenchement d&rsquo;alerte

Sur AlertManager, seule l’alerte any:node:down est visible : Inhibition de l&rsquo;alerte

Routage des alertes

AlertManager est capable de router les alertes en fonction de leur label. Par exemple, je souhaite que les alertes de severity low soient envoyées par un support A, et les alertes de severity moderate soient envoyées sur un support B.

Je considère que les mails sont dédiés aux alertes importantes. Je vais donc créer un nouveau receiver pour les alertes low et modifier le receiver email pour les alertes moderate.

J’ai tendance à utiliser Gotify dans mon usage habituel, mais n’étant pas chez moi durant l’édition de cet article, je vais utiliser Discord comme alternative.

J’ajoute donc discord en tant que receiver dans le fichier /etc/alertmanager/alertmanager.yml :

receivers:
  - name: 'discord'
    discord_configs:
    - webhook_url: 'https://discord.com/api/webhooks/1160256074398568488/HT18QHDiqNOwoQL7P2XFAhnOoASYFyX-bSKtLM1EZMA2812Nb2kUMRzr7BiHmhO1amHY'

Les labels étant déjà présents dans les alertes Prometheus, il suffit de créer une route pour chaque severity dans le fichier /etc/alertmanager/alertmanager.yml :

route:
  group_by: ['alertname']
  group_wait: 20s
  group_interval: 5m
  repeat_interval: 3h 
  receiver: email # par défaut

  routes:
  - matchers:
    - severity =~ "(low|info)"
    receiver: discord

Maintenant, les alertes de severity low et info seront envoyées sur Discord et les autres seront envoyées par mail.

Alertes sur Discord

Personnaliser les alertes

Changer l’objet des emails

Il est possible de personnaliser les alertes en utilisant des templates. Le format est le même que Jinja2 (utilisé par Ansible) ou GoTmpl.

Dans mon cas, la template par défaut pour les emails ne convient pas vraiment. Je souhaite avoir un objet plus explicite et un corps de mail en français.

  • Template par défaut : Template par défaut

Pour cela, le fichier alertmanager.yml doit être mis à jour avec le contenu suivant :

  - name: 'email'
    email_configs:
    - to: 'contact@mailserver'
      from: 'no-reply@mailserver'
      smarthost: 'mailserver:587'
      auth_username: 'no-reply@mailserver'
      auth_identity: 'no-reply@mailserver'
      auth_password: 'bigpassword'
      headers:
        Subject: "[ {{ .Alerts | len }} alerte{{ if gt (len .Alerts) 1 }}s{{ end }}] - {{ range .GroupLabels.SortedPairs }}{{ .Name }}={{ .Value }}{{ end }}"

J’utilise l’header Subject pour modifier l’objet du mail. J’utilise également la fonction len pour compter le nombre d’alertes et ainsi mettre “alerte” au pluriel si besoin.

Changer le corps des emails

Je ne souhaite pas reprendre entièrement la template par défaut pour le corps des emails. Je souhaite juste traduire le texte en français.

Je vais donc télécharger la template par défaut, disponible sur le Github officiel d’AlertManager.

wget https://raw.githubusercontent.com/prometheus/alertmanager/main/template/email.tmpl -O /etc/alertmanager/email.tmpl

Je modifie le nom de la template à l’intérieur du fichier de “email.default.subject” à “email.subject” pour que le nom soit différent de celui de la template par défaut. Je modifie également le contenu de la template pour avoir un corps de mail en français.

Fichier template
{{ define "email.subject" }}{{ template "__subject" . }}{{ end }}
{{ define "email.html" }}
<html xmlns="http://www.w3.org/1999/xhtml" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px;">
<head>
<meta name="viewport" content="width=device-width">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>{{ template "__subject" . }}</title>
<style>
@media only screen and (max-width: 640px) {
  body {
    padding: 0 !important;
  }

  h1,
h2,
h3,
h4 {
    font-weight: 800 !important;
    margin: 20px 0 5px !important;
  }

  h1 {
    font-size: 22px !important;
  }

  h2 {
    font-size: 18px !important;
  }

  h3 {
    font-size: 16px !important;
  }

  .container {
    padding: 0 !important;
    width: 100% !important;
  }

  .content {
    padding: 0 !important;
  }

  .content-wrap {
    padding: 10px !important;
  }

  .invoice {
    width: 100% !important;
  }
}
</style>
</head>

<body itemscope itemtype="https://schema.org/EmailMessage" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; height: 100%; line-height: 1.6em; background-color: #f6f6f6; width: 100%;">

<table class="body-wrap" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; background-color: #f6f6f6; width: 100%;" width="100%" bgcolor="#f6f6f6">
  <tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px;">
    <td style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top;" valign="top"></td>
    <td class="container" width="600" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; display: block; max-width: 600px; margin: 0 auto; clear: both;" valign="top">
      <div class="content" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; max-width: 600px; margin: 0 auto; display: block; padding: 20px;">
        <table class="main" width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; background-color: #fff; border: 1px solid #e9e9e9; border-radius: 3px;" bgcolor="#fff">
          <tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px;">
            {{ if gt (len .Alerts.Firing) 0 }}
            <td class="alert alert-warning" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; vertical-align: top; font-size: 16px; color: #fff; font-weight: 500; padding: 20px; text-align: center; border-radius: 3px 3px 0 0; background-color: #E6522C;" valign="top" align="center" bgcolor="#E6522C">
              {{ .Alerts | len }} alerte{{ if gt (len .Alerts) 1 }}s{{ end }} de {{ range .GroupLabels.SortedPairs }}
                {{ .Name }}={{ .Value }}
              {{ end }}
            </td>
            {{ else }}
            <td class="alert alert-good" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; vertical-align: top; font-size: 16px; color: #fff; font-weight: 500; padding: 20px; text-align: center; border-radius: 3px 3px 0 0; background-color: #68B90F;" valign="top" align="center" bgcolor="#68B90F">
              {{ .Alerts | len }} alerte{{ if gt (len .Alerts) 1 }}s{{ end }} de {{ range .GroupLabels.SortedPairs }}
                {{ .Name }}={{ .Value }} 
              {{ end }}
            </td>
            {{ end }}
          </tr>
          <tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px;">
            <td class="content-wrap" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; padding: 30px;" valign="top">
              <table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px;">
                <tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px;">
                  <td class="content-block" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; padding: 0 0 20px;" valign="top">
                    <a href="{{ template "__alertmanagerURL" . }}" class="btn-primary" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; text-decoration: none; color: #FFF; background-color: #348eda; border: solid #348eda; border-width: 10px 20px; line-height: 2em; font-weight: bold; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; text-transform: capitalize;">Ouvrir {{ template "__alertmanager" . }}</a>
                  </td>
                </tr>
                {{ if gt (len .Alerts.Firing) 0 }}
                <tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px;">
                  <td class="content-block" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; padding: 0 0 20px;" valign="top">
                    <strong style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px;">[{{ .Alerts.Firing | len }}] Firing</strong>
                  </td>
                </tr>
                {{ end }}
                {{ range .Alerts.Firing }}
                <tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px;">
                  <td class="content-block" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; padding: 0 0 20px;" valign="top">
                    <strong style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px;">Labels</strong><br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px;">
                    {{ range .Labels.SortedPairs }}{{ .Name }} = {{ .Value }}<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px;">{{ end }}
                    {{ if gt (len .Annotations) 0 }}<strong style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px;">Annotations</strong><br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px;">{{ end }}
                    {{ range .Annotations.SortedPairs }}{{ .Name }} = {{ .Value }}<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px;">{{ end }}
                    <a href="{{ .GeneratorURL }}" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; color: #348eda; text-decoration: underline;">Source</a><br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px;">
                  </td>
                </tr>
                {{ end }}

                {{ if gt (len .Alerts.Resolved) 0 }}
                  {{ if gt (len .Alerts.Firing) 0 }}
                <tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px;">
                  <td class="content-block" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; padding: 0 0 20px;" valign="top">
                    <br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px;">
                    <hr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px;">
                    <br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px;">
                  </td>
                </tr>
                  {{ end }}
                <tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px;">
                  <td class="content-block" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; padding: 0 0 20px;" valign="top">
                    <strong style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px;">[{{ .Alerts.Resolved | len }}] Resolved</strong>
                  </td>
                </tr>
                {{ end }}
                {{ range .Alerts.Resolved }}
                <tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px;">
                  <td class="content-block" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; padding: 0 0 20px;" valign="top">
                    <strong style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px;">Labels</strong><br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px;">
                    {{ range .Labels.SortedPairs }}{{ .Name }} = {{ .Value }}<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px;">{{ end }}
                    {{ if gt (len .Annotations) 0 }}<strong style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px;">Annotations</strong><br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px;">{{ end }}
                    {{ range .Annotations.SortedPairs }}{{ .Name }} = {{ .Value }}<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px;">{{ end }}
                    <a href="{{ .GeneratorURL }}" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; color: #348eda; text-decoration: underline;">Source</a><br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px;">
                  </td>
                </tr>
                {{ end }}
              </table>
            </td>
          </tr>
        </table>

        <div class="footer" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; width: 100%; clear: both; color: #999; padding: 20px;">
          <table width="100%" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px;">
            <tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px;">
              <td class="aligncenter content-block" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; vertical-align: top; padding: 0 0 20px; text-align: center; color: #999; font-size: 12px;" valign="top" align="center"><a href="{{ .ExternalURL }}" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; text-decoration: underline; color: #999; font-size: 12px;">Sent by {{ template "__alertmanager" . }}</a></td>
            </tr>
          </table>
        </div></div>
    </td>
    <td style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top;" valign="top"></td>
  </tr>
</table>

</body>
</html>

{{ end }}
  • Template modifiée : template modifiée

Modifier les URLs dans l’email reçu

Lorsque vous avez reçu un email d’alerte, vous avez peut-être remarqué que les URLs ne sont pas accessibles puisqu’il vous proposera l’URL http://prometheus:9090 pour Prometheus et http://alertmanager:9093 pour AlertManager.

Notamment sur le bouton Source et Ouvrir AlertManager

Pour cela, il faut démarrer Prometheus et AlertManager avec les options --web.external-url et --web.route-prefix.

Voici les valeurs par défaut :

  • --web.external-url : http://localhost:9090
  • --web.route-prefix : /

En fonction de la méthode via laquelle vous voulez accéder à Prometheus et AlertManager, il faudra adapter ces valeurs (notamment si vous êtes derrière un reverse-proxy).

Dans mon cas, je souhaite accéder à Prometheus et AlertManager via l’URL http://server.prometheus.home:9090 et http://server.prometheus.home:9093.

Il est obligatoire de modifier le service OpenRC ou Systemd pour ajouter les options --web.external-url (et --web.route-prefix si vous le souhaitez).

Pour OpenRC, je modifie le fichier /etc/conf.d/prometheus pour mettre une valeur dans la variable prometheus_arg:

prometheus_config_file=/etc/prometheus/prometheus.yml
prometheus_storage_path=/var/lib/prometheus/data
prometheus_retention_time=15d
prometheus_args="--web.external-url='http://server.prometheus.home:9090'"

output_log=/var/log/prometheus.log
error_log=/var/log/prometheus.log

Dans le cadre d’un service systemd, je modifie le fichier /etc/default/prometheus pour mettre une valeur dans la variable ARGS:

ARGS="--web.external-url='http://server.prometheus.home:9090'"

Pour AlertManager, c’est la même chose. Je modifie le fichier /etc/conf.d/alertmanager pour mettre une valeur dans la variable alertmanager_arg:

alertmanager_args="--web.external-url='http://server.prometheus.home:9093'"
alertmanager_config_file=/etc/alertmanager/alertmanager.yml
alertmanager_storage_path=/var/lib/alertmanager/data

output_log=/var/log/alertmanager.log
error_log=/var/log/alertmanager.log

Dans le mail final : les URLS vers Prometheus et AlertManager ne redirigent plus vers http://prometheus:9090 et http://alertmanager:9093 mais bien vers http://server.prometheus.home:9090 et http://server.prometheus.home:9093.

URLs modifiées

PushGateway

Jusque là nous avons vu comment récupérer des métriques sur des hôtes distants. Mais quid des cas où nous avons besoin de récupérer des métriques sur un hôte qui n’est pas accessible par Prometheus (par exemple une ip dynamique) ou qui n’est pas censé être accessible 24/7 ?

Comme pour un script qui va être exécuté une fois par jour et qui va récupérer des métriques, ou encore un script qui va être exécuté à chaque démarrage d’un conteneur Docker.

À cette problématique il existe une solution : PushGateway

C’est un service qui à pour objectif de récupérer des métriques via des requêtes POST, et de les ajouter à son exporter que Prometheus pourra requêter.

Les tâches utilisant PushGateway ne sont pas contraintes d’être lancée durant un Pull de Prometheus. PushGateway joue le rôle d’un intermédiaire.

Installation

PushGateway est disponible dans les dépôts officiels de la plupart des distributions Linux :

apt install prometheus-pushgateway
apk add prometheus-pushgateway
yum install prometheus-pushgateway

Ou via Docker :

docker run -d -p 9091:9091 prom/pushgateway

Ou via l’archive disponible sur le Github officiel : github.com/prometheus/pushgateway/releases.

Utilisation

Comme expliqué ci-dessus, PushGateway a pour rôle de récupérer des métriques provenant de scripts ou d’applications qui n’ont pas pour objectif d’être accessible 24/7.

Pour cela, nous allons envoyer des requêtes HTTP à PushGateway pour qu’il les stocke. Il est possible de le faire via un script Bash, Python, Node, etc.

/metrics/job/<JOB_NAME>{/<LABEL_NAME>/<LABEL_VALUE>}

Via le code suivant, je souhaite envoyer la métrique disk_temperature_celsius contenant la température de mon disque NVME.

#!/bin/bash
hostname=$(cat /etc/hostname)
kelvin=$(sudo nvme smart-log /dev/nvme0 -o json | jq '.["temperature_sensor_1"]')
celsius=$(echo "$kelvin - 273.15" | bc)

echo "La température du disque /dev/nvme0 est ${celsius}°C"
cat <<EOF | curl --data-binary @- http://server.prometheus.home:9091/metrics/job/disk_temperature_celsius/instance/capteur
# TYPE disk_temperature_celsius gauge
disk_temperature_celsius{instance="${hostname}"} ${celsius}
EOF

Depuis l’interface web nous pouvons voir que la métrique est bien présente :

Métrique envoyée à PushGateway

Chaque métrique pushée est stockée dans PushGateway jusqu’à ce qu’elle soit écrasée par une nouvelle métrique avec le même nom. Il est donc possible de pusher plusieurs métriques avec le même nom mais avec des labels différents.

PushGateway va exposer ces métriques sur le port 9091 avec le chemin /metrics.

curl http://server.prometheus.home:9091/metrics -s | grep "disk_temperature_celsius"


# TYPE disk_temperature_celsius gauge
disk_temperature_celsius{instance="capteur",job="disk_temperature_celsius"} 49.85
push_failure_time_seconds{instance="capteur",job="disk_temperature_celsius"} 0
push_time_seconds{instance="capteur",job="disk_temperature_celsius"} 1.6966899829492722e+09

Maintenant il faut ajouter PushGateway à la configuration de Prometheus :

  - job_name: "PushGateway"
    static_configs:
      - targets: ["server.prometheus.home:9091"]
    relabel_configs:
      - source_labels: [__address__]
        regex: '(.*):[0-9]+'
        replacement: '${1}'
        target_label: instance

Nous pouvons voir que la métrique est bien récupérée par Prometheus :

Métrique récupérée par Prometheus

Prometheus et la sécurité

Ici, nous allons voir comment sécuriser les échanges entre Prometheus et les exporters.

Pour sécuriser un Prometheus, il existe plusieurs solutions :

  • L’authentification basic_auth.
  • Chiffrer les échanges avec TLS.
  • Utiliser un pare-feu pour limiter les accès des exporters (ou reverse proxy).

Nous allons voir comment mettre en place les 2 premières solutions.

Activer l’authentification basic_auth

Sur Prometheus

Dans un premier temps, il est nécessaire de générer le mot de passe chiffré avec la commande htpasswd ou via le script Python gen-pass.py disponible dans la documentation officielle de Prometheus.

import getpass
import bcrypt

password = getpass.getpass("password: ")
hashed_password = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt())
print(hashed_password.decode())

Je génère mon mot de passe (qui sera ici Coffee) :

python3 gen-pass.py   
password: 
$2b$12$W1jWulWeeF2LECdJMZc2G.i5fvJGSl5CE8F6Flz3tW4Uz7yBdUWZ.

Maintenant nous allons créer un fichier yaml qui contiendra la configuration du serveur web de Prometheus. Celui-ci est séparé de la configuration principale et pourra être réutilisé pour d’autres services (exporters, alertmanager, etc).

Création du répertoire:

mkdir -p /etc/prometheus/web

Fichier web.yml:

basic_auth_users:
  admin: $2b$12$W1jWulWeeF2LECdJMZc2G.i5fvJGSl5CE8F6Flz3tW4Uz7yBdUWZ.
  ops: $2b$12$W1jWulWeeF2LECdJMZc2G.i5fvJGSl5CE8F6Flz3tW4Uz7yBdUWZ.

Il faut également modifier les arguments de démarrage de Prometheus pour activer l’authentification basic_auth en ajoutant --web.config.file=/etc/prometheus/web/web.yml comme durant la configuration de l’URL externe.

Je modifie le fichier /etc/conf.d/prometheus pour ajouter notre argument. Voici donc mon fichier final :

prometheus_config_file=/etc/prometheus/prometheus.yml
prometheus_storage_path=/var/lib/prometheus/data
prometheus_retention_time=15d
prometheus_args="--web.external-url='http://server.prometheus.home:9090' --web.config.file=/etc/prometheus/web/web.yml"

output_log=/var/log/prometheus.log
error_log=/var/log/prometheus.log

Si vous utilisez un service systemd, il faudra modifier le fichier /etc/default/prometheus pour ajouter notre argument. Voici un exemple :

ARGS="--web.external-url='http://server.prometheus.home:9090' --web.config.file=/etc/prometheus/web/web.yml"

Après un redémarrage de Prometheus, il est possible de se connecter avec les identifiants admin ou ops :

Basic Auth

Maintenant… je viens de recevoir un mail concernant une certaine alerte any:exporter:down me disant que mon exporter node-exporter est down.

Alerte exporter down

Pourquoi ? Parce que Prometheus n’arrive plus à s’auto-monitorer. Il n’arrive plus à accéder à localhost:9090/metrics et c’est normal puisque nous venons d’activer l’authentification basic_auth.

Nous devons modifier notre inventaire d’exporters pour y ajouter l’authentification :

  - job_name: "prometheus"
    basic_auth:
      username: 'admin'
      password: 'Coffee'
    static_configs:
      - targets: ["localhost:9090"]

Sur les exporters

La plupart des exporters officiels respectent la même syntaxe et peuvent même réutiliser le même fichier de configuration.

Sur chaque machine possédant un exporter, je vais créer le répertoire /etc/exporters/web et y créer le fichier web.yml avec le contenu suivant :

basic_auth_users:
  prom: $2b$12$W1jWulWeeF2LECdJMZc2G.i5fvJGSl5CE8F6Flz3tW4Uz7yBdUWZ.

J’utiliserai alors les identifiants prom et Coffee pour me connecter.

Comme pour Prometheus, il faut ajouter l’argument --web.config.file=/etc/exporters/web/web.yml dans les arguments de démarrage de l’exporter. Sur les Alpines, il faut modifier le fichier /etc/conf.d/node-exporter comme ci-dessous :

# /etc/conf.d/node-exporter

# Custom arguments can be specified like:
#
# ARGS="--web.listen-address=':9100'"

ARGS="--web.config.file=/etc/exporters/web/web.yml"

Si vous utilisez un service systemd, il faudra modifier le fichier /etc/default/prometheus-node-exporter :

# Set the command-line arguments to pass to the server.
# Due to shell scaping, to pass backslashes for regexes, you need to double
# them (\\d for \d). If running under systemd, you need to double them again
# (\\\\d to mean \d), and escape newlines too.
ARGS="--web.config.file=/etc/exporters/web/web.yml"

Avertissement

Attention, j’ai constaté qu’en fonction de la version de votre exporter il est possible que l’argument –web.config.file ne soit pas disponible. Dans ce cas, il faudra utiliser l’argument –web.config.

Pour Node-Exporter 1.6.1:

ARGS="--web.config.file=/etc/exporters/web/web.yml"

Pour Node-Exporter 1.3.1:

ARGS="--web.config=/etc/exporters/web/web.yml"

Après un redémarrage de l’exporter, il faut bien se connecter avec les identifiants prom ; Coffee.

  - job_name: "node"
    basic_auth:
      username: 'prom'
      password: 'Coffee'
    static_configs:
      - targets: ["localhost:9100"]
      - targets: ["dhcp.prometheus.home:9100"]
      - targets: ["bot.prometheus.home:9100"]
      - targets: ["proxmox.prometheus.home:9100"]
    relabel_configs:
      - source_labels: [__address__]
        regex: '(.*):[0-9]+'
        replacement: '${1}'
        target_label: instance

Activer le chiffrement TLS

Dans mon homelab, j’utilise MKCert pour générer des certificats TLS ainsi que des autorités de certification qui seront ajoutés sur les machines de mon réseau.

Installation de MKCert

MKCert est disponible dans les dépôts officiels de la plupart des distributions Linux.

apt install mkcert
apk add mkcert
yum install mkcert

Générer une autorité de certification

Pour que les certificats soient considérés comme valides par Prometheus, il faut que l’autorité de certification soit ajoutée au système de confiance (trust-store) de notre hôte.

mkcert -install
Created a new local CA 💥
The local CA is now installed in the system trust store! ⚡️

Il est possible de copier notre CA pour l’ajouter sur d’autres machines :

mkcert -CAROOT
/root/.local/share/mkcert

Générer un certificat

Deux solutions s’offrent à nous :

  • Générer un certificat pour chaque exporter.
  • Générer un certificat wildcard pour tous les exporteurs.

Pour ma part, est choisie la première solution (plus sécurisé, et réaliste dans un environnement de production).

for subdomain in dhcp proxmox server bot; do mkcert "${subdomain}.prometheus.home" ; done

Chaque certificat sera généré dans le répertoire courant.

prometheus:~/certs# ls -l
total 24
-rw-------    1 root     root          1708 Oct  8 07:52 dhcp.prometheus.home-key.pem
-rw-r--r--    1 root     root          1472 Oct  8 07:52 dhcp.prometheus.home.pem
-rw-------    1 root     root          1704 Oct  8 07:52 proxmox.prometheus.home-key.pem
-rw-r--r--    1 root     root          1476 Oct  8 07:52 proxmox.prometheus.home.pem
-rw-------    1 root     root          1704 Oct  8 07:52 server.prometheus.home-key.pem
-rw-r--r--    1 root     root          1472 Oct  8 07:52 server.prometheus.home.pem

Installer un certificat

Ajouter un certificat sur un exporter

Pour installer un certificat, je vais créer le répertoire /etc/exporters/certs et y copier les certificats.

mkdir -p /etc/exporters/certs
mv *-key.pem /etc/exporters/certs/key.pem
mv *.home.pem /etc/exporters/certs/cert.pem
chown -R prometheus:prometheus /etc/exporters/certs

Maintenant, je peux éditer le fichier /etc/exporters/web/web.yml (déjà créé durant l’étape ajoutant la basic_auth ) pour donner les chemins vers les certificats.

basic_auth_users:
  prom: $2b$12$W1jWulWeeF2LECdJMZc2G.i5fvJGSl5CE8F6Flz3tW4Uz7yBdUWZ.
tls_server_config:
  cert_file: /etc/exporters/certs/cert.pem
  key_file: /etc/exporters/certs/key.pem

L’argument --web.config.file=/etc/exporters/web/web.yml est déjà présent dans la commande de démarrage de l’exporter si vous avez suivi la partie Activer l’authentification basic_auth.

Après un redémarrage de l’exporter je peux tenter un accès via HTTPS :

curl https://server.prometheus.home:9100 -u "prom:Coffee" -o /dev/null -v

Je constate alors que la connexion est bien chiffrée :

* SSL connection using TLSv1.3 / TLS_CHACHA20_POLY1305_SHA256                                                                 
* ALPN: server accepted h2                                                                                                    
* Server certificate:                                                                                                         
*  subject: O=mkcert development certificate; OU=root@prometheus                                                              
*  start date: Oct  8 05:52:53 2023 GMT                                                                                       
*  expire date: Jan  8 06:52:53 2026 GMT                                                                                      
*  subjectAltName: host "server.prometheus.home" matched cert's "server.prometheus.home"                                      
*  issuer: O=mkcert development CA; OU=root@prometheus; CN=mkcert root@prometheus                                             
*  SSL certificate verify ok.

Dernière étape pour que Prometheus puisse se connecter à l’exporter via HTTPS : ajouter scheme: https dans l’inventaire des exporters.

  - job_name: "node"
    scheme: "https" # <--- Ajout de cette ligne
    basic_auth:
      username: 'prom'
      password: 'Coffee'
    static_configs:
      - targets: ["server.prometheus.home:9100"]
      - targets: ["dhcp.prometheus.home:9100"]
      - targets: ["bot.prometheus.home:9100"]
      - targets: ["proxmox.prometheus.home:9100"]
    relabel_configs:
      - source_labels: [__address__]
        regex: '(.*):[0-9]+'
        replacement: '${1}'
        target_label: instance

Après un redémarrage de Prometheus, je constate que les métriques sont bien récupérées via HTTPS !

Ajouter un certificat sur Prometheus

Dans le fichier /etc/prometheus/web/web.yml (créé à l’étape sur les basic_auth) je vais ajouter les chemins vers les certificats, comme pour les exporters.

basic_auth_users:
  admin: $2b$12$W1jWulWeeF2LECdJMZc2G.i5fvJGSl5CE8F6Flz3tW4Uz7yBdUWZ.
  ops: $2b$12$W1jWulWeeF2LECdJMZc2G.i5fvJGSl5CE8F6Flz3tW4Uz7yBdUWZ.
tls_server_config:
  cert_file: /etc/exporters/certs/cert.pem
  key_file: /etc/exporters/certs/key.pem

Après un redémarrage de Prometheus, je peux tenter un accès via HTTPS depuis mon poste. Le certificat n’est pas considéré comme valide puisque je n’ai pas ajouté l’autorité de certification sur ma machine.

➜ curl https://server.prometheus.home:9090
curl: (60) SSL certificate problem: unable to get local issuer certificate
More details here: https://curl.se/docs/sslcerts.html

curl failed to verify the legitimacy of the server and therefore could not
establish a secure connection to it. To learn more about this situation and
how to fix it, please visit the web page mentioned above.

Je récupère alors le rootCA.pem et l’ajoute dans mon système de confiance (trust-store).

scp [email protected]:/root/.local/share/mkcert/rootCA.pem .
sudo cp ./rootCA.pem /usr/local/share/ca-certificates/mkcert.crt
sudo update-ca-certificates
Updating certificates in /etc/ssl/certs...
rehash: warning: skipping ca-certificates.crt,it does not contain exactly one certificate or CRL
1 added, 0 removed; done.
Running hooks in /etc/ca-certificates/update.d...
Adding debian:mkcert.pem
done.
done.

Maintenant, je peux me connecter à Prometheus via HTTPS :

curl https://server.prometheus.home:9090 -u "admin:Coffee"              
<a href="/graph">Found</a>.

Il est aussi possible de ne pas ajouter l’autorité de certification sur le système de confiance et d’uniquement l’importer dans notre Firefox pour pouvoir se connecter à Prometheus via HTTPS. (Settings -> Certificates -> View Certificates -> Authorities -> Import)

Firefox ajouter CA

Agrégation de métriques

Il d’usage de créer un serveur Prometheus par “Zone” ou par “Cluster” (Kube) afin que chaque environnement soit indépendant. Mais avoir une vue d’ensemble de tous ces environnements peut vite devenir compliqué.

C’est pourquoi Prometheus propose une fonctionnalité d’agrégation de métriques qui permet de récupérer les données de plusieurs serveurs Prometheus et de les agréger sur un serveur principal.

Dans mon cas, j’ai :

  • Un Prometheus dans un cluster Kubernetes hébergé sur mes Raspberry Pi.
  • Un deuxième Prometheus dans un cluster Kubernetes dans des machines virtuelles (cluster de test).
  • Un troisième Prometheus qui va monitorer les hôtes (serveurs physiques, VMs, etc).

Je souhaite récupérer les métriques des Prometheus dans les clusters Kubernetes et les agréger sur le Prometheus qui monitore les hôtes.

Avertissement

Je vous conseille de cibler les métriques que vous souhaitez récupérer. En effet, si vous récupérez toutes les métriques de tous les Prometheus, vous risquez de saturer la mémoire vive de votre serveur principal.

  - job_name: 'cloudkube'
    scrape_interval: 15s
    honor_labels: true
    metrics_path: '/federate'
    params:
      'match[]':
        - '{job="kubernetes"}' # pour tout prendre: {job=~".*"}
    static_configs:
      - targets: ['192.168.128.51:30310']
    relabel_configs:
      - replacement: 'cloudkube'
        target_label: instance  

Astuce

    honor_labels: true

Cette instruction permet de gérer les conflits lorsqu’un serveur fedéré possède des métriques avec les mêmes labels qu’un autre serveur.

  • Si la valeur est true, on force l’ajout des labels du serveur fedéré (quite à écraser les labels existants).
  • Si la valeur est false, les métriques du serveur fedéré seront renommées en exported_NOM. (“exported_job”, “exported_instance” etc.)

Conclusion

Je pense que nous avons fait le tour de Prometheus et de ses fonctionnalités principales. Il reste néanmoins beaucoup de choses que je n’ai pas abordées dans cet article (usage d’un service discovery, API d’administration, Stockage long-terme avec Thanos etc.), mais je pense que cette page est suffisament longue.

Personnellement, je trouve que Prometheus est un produit très intéressant et propose une capacité d’adaptation hallucinante. L’usage des exporters fait sa force, et est son principal défaut : si une machine cumule les services, elle nécessitera un grand nombre d’exporters (et sera complexe à administrer). J’ai également été déçu de trouver de nombreux exporters communautaires qui ne respectent pas la même syntaxe (et fichiers de configuration) que les exporters officiels, rendant l’usage du TLS et basic_auth complexes (voire impossibles sur certains).

La documentation de Prometheus est très accessible, bien qu’il manque quand même de nombreux exemples sur certains usages.

N’hésitez pas à essayer Prometheus vous-même afin de vous faire votre propre opinion.

Si vos retours sont bons concernant cet article et que vous souhaitez que j’approfondisse certains points, n’hésitez pas à me le faire savoir afin que nous puissions aborder ces nouvelles thématiques ensemble.