Centraliser et normaliser ses logs avec Vector

Lecture 16 min. ‱


Table des matiĂšres

Bonjour! 😀

Mon nombre de VMs a explosĂ© au cours de ces derniers mois. Je ne m’en plains pas, mais il devient de plus en plus fastidieux d’aller consulter mes logs.

Un autre de mes problÚmes est que mon infra est constituée de programmes écrits dans différents langages qui ont chacun leur maniÚre de rédiger leurs logs.

Une autre complication est l’utisation de Docker et Kubernetes qui enrobe les processus et rajoute une couche de complexitĂ© dans la comprĂ©hension du run des applications.

J’avais tout d’abord commencĂ© Ă  regarder le produit “Syslog” qui a fait ces preuves. Mais celui-ci ne convenait pas Ă  mes besoins car les messages gĂ©nĂ©rĂ©s contenanaient trop d’informations parasites, ne permettant pas leur exploitation directe.

C’est alors que durant un de mes lives sur twitch l’un de mes spectateurs m’a indiquĂ© l’existence d’un produit nommĂ© Vector.

Installation

Vector est un binaire qui peut-ĂȘtre installĂ© de diverses maniĂšres.

Je vais utiliser la mĂ©thode de l’installation par package.

curl -1sLf \
  'https://repositories.timber.io/public/vector/cfg/setup/bash.deb.sh' \
| sudo -E bash
apt install vector

Principes de fonctionnement

Vector possĂšde une architecture fonctionnelle plutĂŽt directe.

Il peut prendre une ou plusieurs de sources de données, réaliser une suite de transformations sur ces évÚnements puis les distribuer vers une ou plusieurs sorties.

L’ensemble de ce processus est appelĂ© un pipeline.

Il est dĂ©crit dans un fichier de configuration utilisant le langage de description TOML. On peut le faire en YAML et en JSON mais ne parlons pas des sujets qui fĂąchent. 😁

Sources

Les entrées du pipeline sont appelées des sources.

Vector vous proposent un vaste choix de sources qui vont des plus haut-niveau comme directement se connecter Ă  l’API Docker pour rĂ©cupĂ©rer les logs des containers Ă  des choses bien plus bas-niveau comme la lecture de socket ou de fichiers.

Une source est défini ainsi:

[source.ma_source]
type = "type de la source"

Vous pouvez définir autant de sources que vous le désirez.

L’identifiant ma_source est le nom de votre entrĂ©e et permettra de l’identifier au sein du pipeline.

Une source a pour sortie un Ă©vĂšnement sous la forme d’un message au format JSON. Chaque source possĂšde sa structure de sortie qui est documentĂ©e pour chacune d’elle.

Transformations

Chaque source peut bĂ©nĂ©ficier d’une ou plusieurs transformations. Les transformations sont des opĂ©rations qui sont rĂ©alisĂ©es sur les Ă©vĂšnements provenant des sources.

Une transformation peut prendre une ou plusieurs sources.

De mĂȘme que pour les sources, Vector propose lĂ  aussi des transformations dĂ©jĂ  existantes.

Une transformation se décrit sous cette forme.

[transforms.ma_transformation]
type = "type de la transformation"
inputs = ["ma_source_1", "ma_source_2"]

Le champ inputs est la collection de flux d’entrĂ©es dĂ©finis par un ou plusieurs identifiants.

Il est aussi possible de chaĂźner les transformations pour rafiner les traitements.

[transforms.ma_transformation]
type = "type de la transformation"
inputs = ["ma_source_1", "ma_source_2"]

[transforms.ma_transformation_2]
type = "type de la transformation"
inputs = ["ma_transformation"]

Remap

Dans les transformations, il en existe une qui est plus spĂ©ciale que les autres. Il s’agit de la transformation remap.

Celle ci a un format un peu différent qui prend un paramÚtre source.

Dans celui ci, il est possible de dĂ©finir des transformations personnalisĂ©es au moyen d’un langage de manipulation de donnĂ©es appelĂ© le VRL.

Ce langage est ensuite converti en du Rust, permettant de bénéficier de toutes les garanties concernant le typage des données manipulé ainsi que la faillibilité des appels de fonctions et de routines de traitement.

Le langage est complet et propose tous les outils nĂ©cessaire comme l’affectatation de variable, la notion de condition, ainsi que de contextes et de blocs.

Sinks

Dans la terminologie de Vector les sorties de pipelines sont appelés des sinks.

De mĂȘme une sĂ©lection de sinks prĂ©-construits existe.

Un sink prend une collection de flux d’évĂ©nements et les dĂ©livre Ă  sa destination.

Votre pipeline peut possĂ©der autant de sinks que vous dĂ©sirez et chaque sinks prend autant d’entrĂ©es que voulu.

[sinks.ma_sortie]
type = "type de ma sortie"
inputs = ["ma_source", "ma_transformation"]

Cas pratique

Cahier des charges

Mon cas d’utilisation est le suivant. Je possĂšde une sĂ©rie de VMs qui tournent Ă  l’intĂ©rieur d’un rĂ©seau.

Chacune de ces VMs possÚde une stack Docker décrite par un docker-compose.

Le but est de centraliser les logs de toutes ces VMs, en les regroupant par container et par jours.

Par exemple si nous avons 2 machines appelées respectivement:

Et possédant chacune le docker-compose suivant:

services:
  tomcat:
    image: tomcat
  jetty:
    image: jetty
  js:
    image: custom-js

Alors l’aborescence de logs finaux devra ressembler à :

logs
└── by-host
    ├── docker-001
    │   └── docker
    │       ├── json
    │       │   ├── tomcat
    │       │   │   ├── 13-09-2021.log
    │       │   │   └── 14-09-2021.log
    │       │   ├── jetty
    │       │   │   ├── 13-09-2021.log
    │       │   │   └── 14-09-2021.log
    │       │   └── js
    │       │       ├── 13-09-2021.log
    │       │       └── 14-09-2021.log
    │       └── text
    │           ├── tomcat
    │           │   ├── 13-09-2021.log
    │           │   └── 14-09-2021.log
    │           ├── jetty
    │           │   ├── 13-09-2021.log
    │           │   └── 14-09-2021.log
    │           └── js
    │               ├── 13-09-2021.log
    │               └── 14-09-2021.log
    └── docker-002
        └── docker
            ├── json
            │   ├── tomcat
            │   │   ├── 13-09-2021.log
            │   │   └── 14-09-2021.log
            │   ├── jetty
            │   │   ├── 13-09-2021.log
            │   │   └── 14-09-2021.log
            │   └── js
            │       ├── 13-09-2021.log
            │       └── 14-09-2021.log
            └── text
                ├── tomcat
                │   ├── 13-09-2021.log
                │   └── 14-09-2021.log
                ├── jetty
                │   ├── 13-09-2021.log
                │   └── 14-09-2021.log
                └── js
                    ├── 13-09-2021.log
                    └── 14-09-2021.log

Notre but va ĂȘtre de construire une topologie de centralisation et de normalisation de logs qui rĂ©ponde Ă  ce besoin.

Nous utiliseront bien évidemment Vector pour la plupart des opérations.

Descriptif des contraintes

Tomcat et Jetty sont des serveurs HTTP Ă©crits tous les deux en Java mais ne gĂ©nĂšrent pas leur logs sous le mĂȘme format.

Une ligne de log Tomcat ressemblera Ă  :

14-Sep-2021 06:48:31.489 INFO [main] org.apache.catalina.startup.Catalina.start Server startup in [102] milliseconds

Alors que Jetty renverra plutĂŽt des messages de logs ainsi:

2021-09-14 06:48:34.560:INFO:oejs.AbstractConnector:main: Started ServerConnector@11245489{HTTP/1.1, (http/1.1)}{0.0.0.0:8080}

Nous avons aussi dans notre stack une application NodeJs qui possĂšde de mĂȘme, sa propre façon de crĂ©er ses logs.

Vous pouvez retrouvez dans ce projet les sources de l’image NodeJS.

Cette image écrira ses logs sous cette forme:

2021-09-14T06:52:52.480Z [main] info: Application started

Comme vous pouvez le voir chacun de ces logs signifie que l’application a dĂ©marrĂ© mais aucune de ces lignes n’est identique d’un container Ă  un autre, ce qui complexifie grandement le traitement des logs de notre stack Docker.

Nous allons devoir normaliser tout ça! 😀

Une autre contrainte est que les stacks Docker sont séparées les unes des autres dans des VMs pour des raisons de sécurité et scalabilité.

Il va donc falloir rappatrier ces logs normalisĂ©s au niveau d’un point unique qui se chargera de leur aggrĂ©gation.

Topologie

Une des rÚgles que nous allons essayer de nous astreindre est de ne pas générer inutilement de SPOF.

Ceci signifie que la stack Docker doit-ĂȘtre Ă  mĂȘme de fonctionner lorsque le pipeline de logs est defectueux.

Donc avant d’ĂȘtre poussĂ© vers l’aggrĂ©gateur, les logs normalisĂ©s seront conservĂ©s sur les VMs et rĂ©guliĂšrement effacĂ©s par un logrotate.

Récupérer les logs de ses containers

La premiĂšre difficultĂ© Ă  laquelle nous allons ĂȘtre confrontĂ©, est de pouvoir rĂ©cupĂ©rer les logs qui sont gĂ©nĂ©rĂ©s par les applications encapsulĂ©es dans les containers.

Vector possĂšde une source Docker mais de l’aveu mĂȘme de la documentation, il est fortement dĂ©conseillĂ© de l’utiliser.

Nous allons donc devoir trouver une maniÚre détournée de récupérer ces logs.

Heureusement, Docker nous fourni une série de maniÚres de réaliser cette extraction.

Notre cahier des charges nous impose plusieurs contraintes. Il ne faut pas qu’un dĂ©faut dans le systĂšme de pipeline de logs n’interdise le fonctionnement de la stack Docker.

Cette difficultĂ© exclut d’emblĂ©e tous les systĂšmes d’envoie de logs par protocole rĂ©seau. En effet si le pipeline dĂ©cĂšde ou est innacessible, les containers ne dĂ©marreront pas.

Les éliminés sont donc:

On peut aussi sortir les drivers spécifiques au GAFAM: AWS et GCP.

ETW étant pour windows, il ne nous aidera pas non plus sur le coup.

Il nous reste les stockages de logs locaux:

Local est trop simple et ne permet pas de gérer les métadata. Et le stockage JSON ne permet pas une recherche efficace en local.

Nous allons donc nous tourner vers un stockage de logs en utilisant journald.

Pour plus d’informations:

Comme expliqué dans ces ressources, la grande force de journald est de permettre le stockage des méta-données des messages à des fins de filtrages.

Examinons tout d’abord ce que nous fourni le driver de logging journald de Docker.

En plus du message, nous avons accĂšs Ă  plusieurs meta-data, dont:

Ceci constituera nos logs brutes.

En plus de ces mĂ©ta-data propres au driver docker. Il existe une sĂ©rie de mĂ©ta-datas constitutive du protocole journald dont la liste exhaustive est dĂ©finie dans l’exemple suivant.

Ceux qui vont particuliÚrement nous intéresser sont:

Configurons nos container pour envoyer leur logs vers journald.

version: '3.7'

services:
  tomcat:
    image: tomcat
    logging:
      driver: journald
      options:
        tag: "java"
  jetty:
    image: jetty
    logging:
      driver: journald
      options:
        tag: "java"
  js:
    image: custom-js
    logging:
      driver: journald
      options:
        tag: "js"

Nous rajoutons un tag qui nous permettra de traiter les messages plus facilement. Cette valeur sera définie dans la méta-donnée SYSLOG_IDENTIFIER.

Nous pouvons maintenant commencer la crĂ©ation de notre pipeline 😁

Pour cela crée un fichier docker_logs_pipeline_normalization.toml

[sources.journald_docker_unit]
type = "journald"
include_units = ["docker"]
data_dir = "${PWD}/data"

journald_docker_unit sera l’identifiant de notre source. Puis on dĂ©finit cette source comme Ă©tant du type journald.

On filtre par seulement l’unit docker et on dĂ©fini un dossier qui contiendra le checkpoint de lecture du journal.

Ceci permet de ne pas retraiter plusieurs fois les mĂȘme logs.

Normaliser les évÚnements

Nous allons normaliser cet Ă©vĂšnement pour le rendre moins spĂ©cifique Ă  docker. Ceci nous permettra dans l’avenir de plus facilement gĂ©rer d’autres sources.

Pour cela nous allons utiliser notre premiĂšre transformation.

[transforms.normalized_journald_docker_events]
inputs = ["journald_docker_unit"]
type = "remap"
source = '''
service_identifier = .IMAGE_NAME+"/"+.CONTAINER_ID ?? "unknown"
. = {
    "message" : .message,
    "timestamp": .timestamp,
    "unix_timestamp" : .__REALTIME_TIMESTAMP,
    "hostname" : .host,
    "meta" : {
        "type" : "docker",
        "image" : .IMAGE_NAME,
        "container_id" : .CONTAINER_ID,
        "log_topology" : .SYSLOG_IDENTIFIER
    }
}
.service_identifier = service_identifier
'''

Cette transformation prend un évÚnement du type:

{
  "CONTAINER_ID":"306bbf1dd696",
  "CONTAINER_ID_FULL":"306bbf1dd69616a0435dec35fa2e3a7d73725915e7cd9f331f5da90dafd400f9",
  "CONTAINER_NAME":"docker_tomcat_1",
  "CONTAINER_TAG":"java",
  "IMAGE_NAME":"tomcat",
  "PRIORITY":"6",
  "SYSLOG_IDENTIFIER":"java",
  "_BOOT_ID":"79f4683a192e41218842e2c683972993",
  "_CAP_EFFECTIVE":"3fffffffff",
  "_CMDLINE":"/usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock",
  "_COMM":"dockerd",
  "_EXE":"/usr/bin/dockerd",
  "_GID":"0",
  "_MACHINE_ID":"c9f961f08f12487d833a6198ff3d4ffe",
  "_PID":"966",
  "_SELINUX_CONTEXT":"unconfined\n",
  "_SOURCE_REALTIME_TIMESTAMP":"163182514785904",
  "_SYSTEMD_CGROUP":"/system.slice/docker.service",
  "_SYSTEMD_INVOCATION_ID":"fc3ebfb5002d41eb9d0e364f60610d94",
  "_SYSTEMD_SLICE":"system.slice",
  "_SYSTEMD_UNIT":"docker.service",
  "_TRANSPORT":"journal",
  "_UID":"0",
  "__MONOTONIC_TIMESTAMP":"254749478898",
  "__REALTIME_TIMESTAMP":"1631825147859",
  "host":"docker-001.example.com",
  "message":"16-Sep-2021 20:45:47.859 INFO [main] org.apache.catalina.startup.Catalina.start Server startup in [146] milliseconds",
  "source_type":"journald",
  "timestamp":"2021-09-16T20:45:47.859335Z"
}

Et le transforme en un document de ce type:

{
   "hostname":"docker-001.example.com",
   "message":"16-Sep-2021 20:45:47.859 INFO [main] org.apache.catalina.startup.Catalina.start Server startup in [146] milliseconds",
   "meta":{
      "container_id":"306bbf1dd696",
      "image":"tomcat",
      "log_topology":"java",
      "type":"docker"
   },
   "service_identifier":"tomcat/306bbf1dd696",
   "timestamp":"2021-09-16T20:45:47.859335Z",
   "unix_timestamp":1631825147859
}

De cette maniĂšre les logs peuvent venir de n’importe quel service on ne conserve, les traces de docker que dans les mĂ©ta-donnĂ©es de l’évĂšnement.

Router les messages

En fonction du type d’application qui produit le log, nous devons effectuer des traitements diffĂ©rents.

Pour se faire, nous allons diviser le flux de messages en fonction qu’il vienne d’une application Java ou NodeJs.

Nous allons utiliser une autre transformation: le routing.

[transforms.split_stream_by_log_topology]
type = "route"
inputs = ["normalized_journald_docker_events"]

    [transforms.split_stream_by_log_topology.route]
    js = '.meta.log_topology == "js"'
    java = '.meta.log_topology == "java"'

Cette transformation créé deux flux, un contenant tous les messages qui ont comme identifiant le tag java et un autre avec le tag js.

Extraire les données des messages de logs

Pour le moment nos messages de logs sont trop bruts pour pouvoir ĂȘtre exploitĂ©s par un systĂšme automatisĂ©.

Il faut extraire les informations de celui-ci.

Généralement les bibilothÚques de log dans les différents langages de programmation permettent de définir la sévérité des messages (INFO, WARN, ERROR, FATAL) ainsi que la partie de code qui a produit ce log.

Nous allons tenter d’extraire ces donnĂ©es.

Les logs Java

Nous avons deux types de containers; du Jetty et du Tomcat. Chacun d’eux Ă  sa façon propre de gĂ©rer ses logs.

Une ligne de log Tomcat ressemblera Ă :

13-Sep-2021 05:47:49.513 INFO [main] org.apache.coyote.AbstractProtocol.init Initializing ProtocolHandler ["http-nio-8080"]

Alors que Jetty générera plutÎt ceci:

2021-09-13 05:47:47.584:INFO:oejs.AbstractConnector:main: Started ServerConnector@41dddee8{HTTP/1.1, (http/1.1)}{0.0.0.0:8080}

Nous allons utiliser une autre transformation remap pour réaliser ce travail. Le VRL propose tout une série de parsers déjà intégrés.

Malheureusement, aucun d’eux n’est adaptĂ© aux logs tel que dĂ©finis par mes containers.

Il faut donc se rabattre sur nos bonne vieilles regexp. 😛

Voici la transformation que je vous propose, il y aurait surement des axes d’amĂ©liorations possibles.

[transforms.parse_message_body_java]
inputs = ["split_stream_by_log_topology.java"]
type = "remap"
source = '''
    structured = parse_regex(.message, r'^\d{2}-\w{3}-\d{4}\s(?:\d{2}:){2}\d{2}.\d{3}\s(?P<severity>\w+)\s\[[\w-]+\]\s(?P<path>[^\s]+)\s(?P<message>.*)$') ??
                 parse_regex(.message, r'^\d{4}-\d{2}-\d{2}\s(?:\d{2}:){2}\d{2}.\d{3}:(?P<severity>\w+):(?P<path>[\w\.-]+):(?P<section>[\w-]+):\s(?P<message>.*)$') ??
                 { "message": .message }
    .message_extracted = structured.message
    .path = structured.path
    .severity = "info"

    if is_string(.severity) {
        .severity = downcase(.severity)
    }
'''

Un mot sur la création de la variable structured. Elle est constituée de plusieurs parties.

Si on schématise cela donne:

structured = A ?? B ?? default

A et B sont appelĂ©s des fonctions faillibles, cela signifie que lorsque qu’elle Ă©choue une action doit ĂȘtre entreprise.

Il existe plusieurs moyen de gĂ©rer la faillibilitĂ© d’une fonction.

La premiĂšre est de la rendre infaillible en ajoutant un !.

structured = A!

Dans ce cas si une erreur survient, le message est abandonnĂ© et ne continuera pas dans le pipeline. C’est la maniĂšre la plus rapide de gĂ©rer les erreurs mais la plus propre. c’est l’équivalant de lever une exception dans le code.

Une deuxiĂšme solution est de traiter l’erreur Ă  la maniĂšre de Golang en analysant l’erreur retournĂ©e.

structured, err = A

if err != null {
  # on traite l'erreur
}

Le VRL possùde son propre systùme de codes d’erreur.

La troisiĂšme et derniĂšre solution qui est Ă  mon avis la plus propre, est la coalescence, il s’agit d’un opĂ©rateur qui permet de chaĂźner des fonctions faillibles.

structured = A ?? B ?? default

Ici, si A Ă©choue alors on Ă©xĂ©cute B et si B Ă©choue alors on prend la valeur de default. default doit absolument ĂȘtre infaillible sinon ça ne compilera pas.

Dans notre cas l’idĂ©e est d’essayer de parser le corps du message en supposant qu’il s’agit d’un message Tomcat. Si la regex ne match pas, la fonction parse_regex Ă©choue et l’on passe au parse de message Jetty. Et si ça Ă©choue aussi on créé un document par dĂ©faut qui ne contient que le message.

Nous allons utiliser ce systĂšme de gestion d’erreurs pour gĂ©rer plusieurs types de formats de logs au sein d’une mĂȘme transformation.

13-Sep-2021 05:47:49.513 INFO [main] org.apache.coyote.AbstractProtocol.init Initializing ProtocolHandler ["http-nio-8080"]

2021-09-13 05:47:47.584:INFO:oejs.AbstractConnector:main: Started ServerConnector@41dddee8{HTTP/1.1, (http/1.1)}{0.0.0.0:8080}

Vont respectivement donner ces valeurs Ă  la variable structured:

{"message":"Initializing ProtocolHandler [\"http-nio-8080\"]","severity":"INFO","path":"org.apache.coyote.AbstractProtocol.init"}
{"message":"Started ServerConnector@41dddee8{HTTP/1.1, (http/1.1)}{0.0.0.0:8080}","severity":"INFO","path":"oejs.AbstractConnector"}

On en profite pour normaliser la sĂ©vĂ©ritĂ© du log. Mais on doit prendre quelques prĂ©cautions. En effet, toutes les lignes ne possĂ©deront pas de sĂ©vĂ©ritĂ©. Or la mĂ©thode VRL downcase prend exclusivement des chaĂźne de caractĂšres comme paramĂštre d’entrĂ©e.

Comme on peut le voir les données sont extraites et normalisées sous une forme commune qui facilite leur traitement ultérieur.

Finalement la sortie finale sera:

{
   "hostname":"docker-001.example.com",
   "message":"16-Sep-2021 20:45:47.859 INFO [main] org.apache.catalina.startup.Catalina.start Server startup in [146] milliseconds",
   "message_extracted":"Server startup in [146] milliseconds",
   "meta":{
      "container_id":"306bbf1dd696",
      "image":"tomcat",
      "log_topology":"java",
      "type":"docker"
   },
   "path":"org.apache.catalina.startup.Catalina.start",
   "service_identifier":"tomcat/306bbf1dd696",
   "severity":"info",
   "timestamp":"2021-09-16T20:45:47.859335Z",
   "unix_timestamp":1631825147859
}

Les logs NodeJs

De la mĂȘme maniĂšre que pour les logs venant des containers java. Nous allons utiliser une transformation qui va venir extraire les donnĂ©es venant de nos lignes de logs.

[transforms.parse_message_body_js]
type = "remap"
inputs = ["split_stream_by_log_topology.js"]
source = '''
    structured = parse_regex(.message, r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z\s\[(?P<path>[\w_/-]+)\]\s(?P<severity>\w+):\s(?P<message>.*)$') ??
                { "message": .message }
    .message_extracted = structured.message
    .path = structured.path
    .severity = "info"

    if is_string(.severity) {
        .severity = downcase(.severity)
    }
'''

Pour rappel un log NodeJs de notre application ressemble Ă :

2021-09-14T06:52:52.480Z [main] info: Application started

AprĂšs transformation:

{
   "hostname":"docker-001.example.com",
   "message":"2021-09-16T20:45:45.357Z [main] info: Application started",
   "message_extracted":"Application started",
   "meta":{
      "container_id":"2ccdfff60bd2",
      "image":"js",
      "log_topology":"js",
      "type":"docker"
   },
   "path":"main",
   "service_identifier":"js/2ccdfff60bd2",
   "severity":"info",
   "timestamp":"2021-09-16T20:45:45.357827Z",
   "unix_timestamp":1631825145357
}

On a ici aussi fait en sorte que les messages aient des topologies identiques. Nous pourrons désormais traiter de maniÚre unique les logs venant du Java et du NodeJS.

Exporter nos logs normalisĂ©s vers l’aggrĂ©gateur

Jusqu’à prĂ©sent nous avons normalisĂ© nos logs mais il reste toujours sur notre machine, nous ne rĂ©solvons toujours pas le souci de la centralisation des logs.

Nous allons devoir les exporter vers une destination qui se chargera de leur aggrégation.

Et quoi de mieux que d’envoyer vers un autre pipeline Vector! 😀

Ce choix technique nous permettra de pouvoir réaliser tous les post-traitements voulus. Au contraire de les envoyer directement dans un store comme Elasticsearch ou autre.

Pour cela nous allons utiliser un sink Vector.

Sur la machine contenant la stack docker dont on veut extraire les logs,

on rajoute au pipeline:

[sinks.vector_aggregator]
type = "vector"
inputs = ["parse_message_body_java", "parse_message_body_js"]
address = "192.168.9.63:9000"
version = "1"

Nous allons cette fois-ci prendre 2 flux en entrée, les messages java et NodeJs extraits et normalisés.

En suite on dĂ©finit l’addresse de notre instance Vector qui sert d’aggrĂ©gateur.

Et c’est tout 😁

Vous pouvez dĂ©sormais envoyer d’une multitude de noeuds vos logs vers un point central.

DĂ©finition du pipeline d’aggrĂ©gation

Je vous ai dit que nous allions pousser nos logs vers un autre Vector, lui aussi va avoir besoin d’une configuration.

Créons un fichier aggregator.toml

Tout d’abord la source, une source Vector.

[sources.vector_server]
type = "vector"
address = "0.0.0.0:9000"
version = "1"

On écoute sur le port 9000.

Étant donnĂ© qu’il s’agit juste de dĂ©montrer la faisabilitĂ© de la centralisation des logs. Nous allons nous contenter d’une simple sortie fichier. Mais il est bien Ă©videmment possible de faire des choses bien plus complexes, incluant des transformations, des aggrĂ©gations et des rĂ©ductions.

Déclarons donc un sink File.

[sinks.file_json_output]
inputs = ["vector_server"]
type = "file"
encoding = "ndjson"
path = "./logs/by-host/{{ hostname }}/{{ meta.type }}/json/{{ service_identifier }}/%Y-%m-%d.log"

Nous allons dĂ©finir un autre sinks, qui nous permettra de lire les logs comme si nous Ă©tions dans le stdout/err de l’application.

[sinks.file_text_output]
inputs = ["vector_server"]
type = "file"
encoding = "text"
path = "./logs/by-host/{{ hostname }}/{{ meta.type }}/text/{{ service_identifier }}/%Y-%m-%d.log"

Lift-off ! 🚀

On a tous nos pipelines! Il est temps de lancer tout ça! 😁

Tout d’abord, installez Vector sur tout vos serveurs, aussi bien ceux qui contiendront les stack dont on veut rĂ©cupĂ©rer les logs que le noeud d’aggrĂ©gation.

Copiez y respectivement les fichiers docker_logs_pipeline_normalization.toml et aggregator.toml.

Lancez dans l’ordre

vector --config aggregator.toml

Puis

vector --config docker_logs_pipeline_normalization.toml

Si vous faites l’inverse vous aurez une erreur car le pipeline d’extraction de logs essaie de se connecter au port 9000. Or celui n’est pas encore en Ă©coute.

Ceci me permet de vous prĂ©venir que si votre rĂ©seau n’est pas suffisamment stable, il se peut que des problĂšmes de connectivitĂ© apparaissent et des erreurs surviennent.

Ceci avait motivĂ© notre choix dĂšs le dĂ©but de confier la gestion des logs en premier lieu Ă  journald puis Ă  Vector. Si Vector tombe les logs ne seront pas perdu et la stack Docker continuera de tourner comme si de rien n’était.

D’une maniĂšre gĂ©nĂ©ral, un couplage faible entre systĂšmes est toujours a prĂ©fĂ©rĂ© lorsque que les contraintes le permettent. Ici par exemple nous avons perdu en rĂ©activitĂ© mais gagnĂ© en rĂ©sillience. Tout est une histoire de balance entre performance et sĂ©curitĂ©, c’est un peu la dĂ©finition de l’ingĂ©niĂ©rie. 😁

Maintenant si vous lancez vos docker-compose up, vous devriez voir les fichiers de logs apparaĂźtre sur votre noeud d’aggrĂ©gation.

On résume

Il est temps de faire le bilan.

On possĂšde deux pipelines:

docker_logs_pipeline_normalization.toml

[sources.journald_docker_unit]
type = "journald"
include_units = ["docker"]
data_dir = "${PWD}/data"

[transforms.normalized_journald_docker_events]
inputs = ["journald_docker_unit"]
type = "remap"
source = '''
    service_identifier = .IMAGE_NAME+"/"+.CONTAINER_ID ?? "unknown"
    . = {
        "message" : .message,
        "timestamp": .timestamp,
        "unix_timestamp" : .__REALTIME_TIMESTAMP,
        "hostname" : .host,
        "meta" : {
            "type" : "docker",
            "image" : .IMAGE_NAME,
            "container_id" : .CONTAINER_ID,
            "log_topology" : .SYSLOG_IDENTIFIER
        }
    }
    .service_identifier = service_identifier
'''

[transforms.split_stream_by_log_topology]
type = "route"
inputs = ["normalized_journald_docker_events"]

    [transforms.split_stream_by_log_topology.route]
    js = '.meta.log_topology == "js"'
    java = '.meta.log_topology == "java"'


[transforms.parse_message_body_java]
inputs = ["split_stream_by_log_topology.java"]
type = "remap"
source = '''
    structured = parse_regex(.message, r'^\d{2}-\w{3}-\d{4}\s(?:\d{2}:){2}\d{2}.\d{3}\s(?P<severity>\w+)\s\[[\w-]+\]\s(?P<path>[^\s]+)\s(?P<message>.*)$') ??
                 parse_regex(.message, r'^\d{4}-\d{2}-\d{2}\s(?:\d{2}:){2}\d{2}.\d{3}:(?P<severity>\w+):(?P<path>[\w\.-]+):(?P<section>[\w-]+):\s(?P<message>.*)$') ??
                 { "message": .message }
    .message_extracted = structured.message
    .path = structured.path
    .severity = "info"

    if is_string(.severity) {
        .severity = downcase(.severity)
    }
'''

[transforms.parse_message_body_js]
type = "remap"
inputs = ["split_stream_by_log_topology.js"]
source = '''
    structured = parse_regex(.message, r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z\s\[(?P<path>[\w_/-]+)\]\s(?P<severity>\w+):\s(?P<message>.*)$') ??
                { "message": .message }
    .message_extracted = structured.message
    .path = structured.path
    .severity = "info"

    if is_string(.severity) {
        .severity = downcase(.severity)
    }
'''

[sinks.vector_aggregator]
type = "vector"
inputs = ["parse_message_body_java", "parse_message_body_js"]
address = "192.168.9.63:9000"
version = "1"

Et

aggregator.toml

[sources.vector_server]
type = "vector"
address = "0.0.0.0:9000"
version = "1"

[sinks.file_json_output]
inputs = ["vector_server"]
type = "file"
encoding = "ndjson"
path = "./logs/by-host/{{ hostname }}/{{ meta.type }}/json/{{ meta.type }}/{{ service_identifier }}/%Y-%m-%d.log"

[sinks.file_text_output]
inputs = ["vector_server"]
type = "file"
encoding = "text"
path = "./logs/by-host/{{ hostname }}/{{ meta.type }}/text/{{ service_identifier }}/%Y-%m-%d.log"

L’intĂ©gralitĂ© des sources peut-ĂȘtre retrouvĂ© sur le projet.

Petits dessins parce que c’est cool les dessins. 😉

vector node

Voici ce qui se passe schématiquement sur un noeud.

Les applications crachent leurs logs sur journald.

Vector vient lire journald pour en extraire les lignes de logs.

Normalise et extrait les informations pertinentes puis retransmet par réseau à un autre Vector.

vector aggreagator.

Les logs normalisés des différents noeuds transitent par réseau vers notre aggrégateur.

Nous pouvous tout Ă  fait crĂ©er des docker-003, 004, ou 100. La topologie restera la mĂȘme.

Tant que votre rĂ©seau tient le traffic et que votre noeud d’aggrĂ©gation aussi il n’y aura pas de souci.

Si votre trafic devient trop important, il faudra peut-ĂȘtre penser Ă  une topologie diffĂ©rente qui fera apparaitre un broker de messages qui tiendra lieu de buffer pour gĂ©rer la back-pressure de votre traffic de logs.

Conclusion

Cet article est le premier d’une sĂ©rie sur l’ObservabilitĂ©, une discipline de l’informatique qui se base sur plusieurs concepts dont la gestion des logs.

Nous avons fait que la moitiĂ© du chemin concernant l’étude de nos logs, nous les avons normalisĂ©s et centralisĂ©s mais pas encore traitĂ©s et rendus intelligents.

Dans la suite des articles, nous tenteront de définir une logique de recherche de logs permettant la détection et la résolution de bugs.

Je n’ai pas encore dĂ©cidĂ© de la technologie qui sera mis en place mais peut-ĂȘtre un couple Elasticsearch-Kibana ou des produits plus exotiques comme Loki.

J’espĂšre que cette petite prĂ©sentation des bases de la rĂ©alisation d’un systĂšme de pipeline de logs vous a plu et l’on se dit Ă  plus tard pour la suite de nos aventures dans le monde merveilleux normalisĂ© et automatisĂ© de l’ObservabilitĂ©! đŸ€©

Merci de m’avoir lu! 💖