Écrire soit même son outil de monitoring, partie 2

illustration de l'article

Cet article fait suite à la partie 1, écrire soi même un outil de monitoring.

Architecture de l’outil de monitoring

flowchart RL; A1[Agent 1]-- Send metric--->M[ ]; A2[Agent 2]-- Send metric--->M[ ]; A3[Agent 3]-- Send metric--->M[ ]; subgraph Monitoring server M[Server]-- Save on disk --->F[(Filer)] end

Ce schéma décrit l’architecture minimale pour monitorer : l’outil ne peut pas accéder directement aux informations des machines.
Il faut installer, sur chaque machine, un agent qui aura la charge de lire les métriques et de l’envoyer vers le serveur. On retrouve ce mécanisme chez Elastic avec les “beats” : metricbeat, heartbeat.

Pour gérer le heartbeat, une goroutine tournera à intervalle régulier pour vérifier si les urls renvoient une 200.
Cela implique d’avoir, pour chaque service, une url de test qui renvoie toujours un code HTTP 200.

🔺 Les valeurs de la mémoire, cpu, l’espace disque et la température dépendent des OS. Vous pouvez utiliser les contraintes au build pour écrire du code spécifique à votre OS.

Solutions pour écrire à la main

Voici différentes façon d’écrire des données en gardant en mémoire cette règle : une écriture rapide implique souvent une lecture lente, et vice versa.

Sérialisation JSON

Très rapide à mettre à place et supportée par de nombreux langages, les inconvénients sont nombreux dans notre cas :

  • L’écriture est lente de base (introspection)
  • Le stockage est plus important à cause de la redondance de la structure
  • Impossible de streamer la lecture pour traiter au fil de l’eau
  • Impossible d’ajouter des données, il faut tout réécrire

Sérialisation langage

Gobencode en go, serialisable en Java :

  • Dépend du langage, ne peut pas être lu dans un autre langage

  • Illisible par un humain

  • Impossible d’ajouter des données, il faut tout réécrire

  • Go propose une solution pour écrire les données avec un buffer en implémentant io.writer mais elle est plutôt complexe à mettre en œuvre.

Chante Sloubi ou l’écriture manuelle

La dernière solution est l’écriture manuelle où on est libre de définir notre structure et nos propres critères :

  • Faible utilisation des resources (CPU & Ram) pour écrire
  • Ne pas tout réécrire à chaque fois toutes les valeurs d’une métrique
  • Écrire uniquement les informations nécessaires sans overload

On est d’accord, cette solution est la plus sympa, allons-y.

Ecrire ses fichiers à la main

On ne souhaite pas stocker pas pour stocker, mais en réfléchissant à comment on souhaite accéder aux données. Dans mon cas, je souhaite afficher, pour chaque machine, une ou plusieurs métriques d’une journée. Cela implique, si je veux une recherche efficace, de stocker ensemble les métriques de la machine pour une même journée dans un même fichier. Au niveau de la volumétrie, pour 4 machines, j’aurai 120 fichiers créés pour un mois.

Voici une proposition de structure. Le fichier se découpe en deux parties :

  • Un header de taille fixe en début de fichier avec les informations sur chaque métrique stockée et où les trouver
    • La taille fixe permet de lire le header en une seule fois, beaucoup plus rapide. Pour cela, il faut fixer le nombre maximum de header
    • Chaque header de métrique va pointer vers la position du fichier où se trouvent les données
    • On fixe le nombre de métriques que l’on veut afin d’un header avec une taille fixe
  • Le corps du fichier avec les valeurs pour chaque métrique.
    • On stocke les métriques dans des blocs : l’espace est réservé et toutes les valeurs seront contigües
    • S’il n’y a plus d’espace dans le bloc pour cette métrique

On peut représenter le fichier avec des structures :

type headerFileMetrics struct {
  name string	// max length 48
  headerMetrics []*headerMetric
}

type headerMetric struct {
	name string
	firstBlockPosition int64  // first block of data, used to read values
	currentBlockPosition int64	// last block of metric. Used to know where to write new metrics
}

type MetricPoint struct{
	Timestamp int64
	Value float32
}

// Header in each block
type blockHeader struct {
  nbPoints int32 // current points in block
  nextBlock int64 // position of next block if this is full
  positionInFile  int64 // position of this block in file
  pointsByBlock  int  // nb points max by block
}

type block struct{
  header blockHeader
  points []MetricPoint
}

La structure du fichier avec le détail pour chaque champ :
header

L’écriture se fera en une seule fois en pré-calculant le bloc à écrire : le header sera gardé en mémoire, modifié et écrit en une fois. Chaque nouveau bloc (ou mise à jour) sera écrit en une fois en réservant tout l’espace nécessaire (même s’il n’y a pas de valeur encore).

flowchart TD; subgraph Header Metric 1 H1[Header 1]-->FB1[First block] FB1-->CB1[Current block] end subgraph Header Metric 2 H2[Header 2]-->FB2[First block] FB2-->CB2[Current block] end subgraph Data 1 B11[Block 1]-->BB12[Block 2] FB1-->B11 CB1-->BB12 end subgraph Data 2 FB2-->B21[Block 1] CB2-->B21 end

Go fournit plusieurs fonctions pour écrire des données octet par octet :

  • String : Pour écrire une chaine de caractères de longueur n, on écrit d’abord la taille de la chaine (1o pour longueur de 255 par ex)
  • Entier : choix de la taille, int8 (1 o) / 255 valeurs, int 16 (2 o) / 65000, int32 (4 o) / 4 milliards ou int64 (8 o). Attention au bit de poids fort LittleEndian / BigEndian
  • Float : Go permet de représenter les floats sous forme d’entier de 32 bits :
math.Float32bits(float32(3.2)) // => 1078774989
math.Float32frombits(1078774989) // => 3.2

Voici un exemple d’algorithme pour écrire les points dans un block de données.

const pointsByBlock = 1440

func writePointsInBlock(f *os.File,bh *blockHeader,points []model.MetricPoint, hm * headerMetric){
	size := bh.availableSpace() // available nb points in block
	pointsToWrite := points     
	// If no enough space, juste write some point
	if size < len(points){
		pointsToWrite = points[0:size]
	}
	// Write points in block
	f.WriteAt(getPointsAsBytes(pointsToWrite),bh.getPositionInBlock())
	bh.updateNbPoints(len(pointsToWrite))
	// If no enough space for all metrics, create new block and write inside
	if size < len(points){
		nextBlock := createNewBlockHeader(getEndPosition(f),pointsByBlock)
        // Reserve space in file for while block
		f.WriteAt(make([]byte,bh.getSizeBlock()),nextBlock.positionInFile)
		// Link actual block to next block
		bh.nextBlock = nextBlock.positionInFile
		// Update header to link current block to the new one
		hm.currentBlockPosition = nextBlock.positionInFile
		writePointsInBlock(f,nextBlock,points[size:],hm)
	}
	bh.flushHeader(f)
}

Désormais je suis capable d’écrire mes métriques à la main. La prochaine étape va consister à aller les lire.

Lançons une requête

Voici le déroulé d’une requête :

sequenceDiagram participant User participant Server participant Memory participant Disk participant File User->>Server:ask metrics Server->>Memory:Get last metrics not flushed Memory-->>Server:Return metrics Server->>Disk:find file note right of Server:by instance and date File-->>Server:return good file Server->>File:read header (971B) Server->>Server:Find data position in header Server->>File:read metric block File-->>Server:return list of timestamp/value Server->>Server:Aggregate note right of Server:Aggregation of metrics from memory and disk Server-->>User:return values

La subtilité consiste à agréger les métriques provenant du fichier ainsi que les dernières métriques en mémoire (si elles correspondent à la date recherchée).

Et ensuite ?

Une petite API en Go pour lancer des requêtes, un front léger en svelte pour afficher les graphiques et voici le résultat :

monitoring

Mon besoin de créer un outil de monitoring était guidé par les performances : peu d’utilisation des resources aussi bien pour le serveur que pour les agents.

Instance CPU Mémoire
Serveur 1% 1Mo
Agent 1 3% 1Mo
Agent 2 2.6% 1Mo
Agent 3 1% 1Mo

L’empreinte mémoire et CPU sont très faibles et c’est exactement ce que je voulais !

Whaou

Pour aller plus loin

J’ai pensé à plusieurs améliorations possibles :

  • Stocker plusieurs journées dans un même fichier (par ex la semaine) afin de limiter le nombre de fichiers générés
    • On devra changer la structure de header pour inclure les dates
    • On pourra trier les éléments du header pour rechercher rapidement dedans (sans devoir parser toutes les valeurs du header)
  • Mettre en place un système d’alerting pour éviter de regarder régulièrement les métriques

Vous pouvez retrouver tout le code du projet sur mon compte github.

Date

Auteur

Avatar Jonathan BARANZINI

Jonathan BARANZINI

Développeur

Tags

#Golang #Monitoring