Monitoring HTTP from scratch — du POC a la prod en quelques iterations
Comment j'ai reconstruit un monitoring HTTP propre en partant d'un POC bricole, ce qui a casse en route, et les choix d'archi qui me paraissent evidents aujourd'hui mais que j'ai mis trois iterations a accepter.
J'ai un petit service de monitoring HTTP qui surveille mes propres sites et quelques apps clientes. Il existe depuis un an, j'en suis a la v2, et la v2 ressemble enfin a quelque chose qu'on peut laisser tourner sans avoir peur. Cet article raconte le chemin entre la v1 jetable et la v2 stable.
V1 : un script cron qui marchait
La v1 etait un script Python lance par cron toutes les 5 minutes. Il lisait une liste d'URLs depuis un YAML, faisait un requests.get(...), comparait le code HTTP a un attendu, et envoyait un mail si ca cassait.
Ca a marche six mois. C'etait suffisant pour mon usage. Puis il a fallu :
- ajouter des checks plus fins (latence, contenu, chaine d'attestation TLS),
- gerer des fenetres de maintenance,
- afficher un historique quelque part (un mail par check est intenable au-dela de 20 cibles),
- alerter sans flooder en cas de panne reseau cote sonde,
- ouvrir le service a des clients (donc multi-tenant).
A ce moment-la, le script cron etait fini. Pas parce qu'il ne marchait pas — parce qu'il etait inevolutif.
Les choix structurants de la v2
J'ai redemarre la v2 from scratch. Quatre choix structurants ont ete pris en premier, avant la moindre ligne de code, parce qu'ils etaient impossibles a corriger plus tard.
1. Separer la sonde et l'orchestrateur
Une sonde fait un check et renvoie un resultat. Un orchestrateur decide quoi sonder, quand, par qui, et que faire du resultat. Mettre les deux dans le meme processus etait l'erreur de la v1.
Concretement :
- l'orchestrateur (un service web) tient la verite : liste des checks, planning, historique, etat,
- les sondes sont des workers stateless qui pollent l'orchestrateur pour savoir quoi faire et lui renvoient le resultat,
- on peut deployer N sondes sur N geographies sans dupliquer la logique metier.
Cette separation a ete contre-intuitive a accepter : "pourquoi compliquer pour un service qui surveille 30 URLs ?". Reponse : parce que des qu'on veut sonder depuis deux endroits, le script monolithique meurt.
2. Tout passe par une API, y compris l'UI
L'orchestrateur expose une API REST. L'UI web est un client comme un autre. Resultat :
- ajouter une integration (Slack, webhook, autre dashboard) ne demande pas de toucher au coeur,
- je peux tester chaque endpoint independamment,
- la prochaine app cliente (mobile, CLI, plugin) consomme la meme API.
Cout : une heure de plus au demarrage. Benefice : tout le reste devient plus simple.
3. Etat = base de donnees, jamais filesystem
La v1 ecrivait des fichiers de status dans un dossier. C'est seduisant parce que c'est inspectable au cat. C'est aussi le debut de la fin : des qu'on a deux processus qui ecrivent, on a des races. Des qu'on backup, on backup mal.
V2 : tout est en Postgres. Les checks, les resultats, les fenetres de maintenance, les utilisateurs, les tokens d'API. Backup = pg_dump. Migration de schema = prisma migrate. Pas d'ambiguite.
4. Decoupler l'envoi de notifications du check
Quand un check echoue, on n'envoie pas un mail. On insere une notification en attente dans une table. Un worker separe lit cette table et envoie.
Pourquoi : si le service mail tombe, les checks continuent. Si une rafale de 50 sites tombe d'un coup (panne reseau cote sonde), on peut regrouper en une seule notification au lieu d'en envoyer 50. Et on a un historique propre de ce qui a ete envoye, a qui, quand.
Ce qui a casse en route
Trois incidents qui valent la peine d'etre racontes.
Le healthcheck nginx-alpine qui ne demarre jamais
Au premier deploiement, le container reste en starting indefiniment. Traefik ne pique jamais le service. Symptome : curl http://localhost/health depuis le container retourne Connection refused.
Cause : localhost resout sur ::1 (IPv6) en priorite sur Alpine, mais le listen 80 par defaut de nginx ne bind que IPv4. Solution : utiliser explicitement 127.0.0.1 dans les healthchecks Docker pour nginx-alpine.
Lecon retenue : toujours 127.0.0.1 dans les healthchecks. Ca m'a aussi pique sur d'autres projets depuis.
La sonde qui se sondait elle-meme
J'ai voulu, par paresse, faire surveiller le statut de la sonde par l'orchestrateur, et le statut de l'orchestrateur par la sonde. En cas de panne d'une des deux, l'autre alerte.
Probleme : quand le reseau entre les deux casse, les deux se croient en panne, les deux alertent. J'ai recu 47 mails d'alerte en 15 minutes pendant un hoquet reseau.
Correctif : un check d'auto-sante ne peut alerter qu'en passant par un tiers (ici, un cron sur un autre host qui pingue les deux). Et tout systeme d'alerte doit avoir une fenetre de coalescence (regroupement des alertes sur 1-2 minutes avant envoi).
Le rate limit Docker Hub en CI
Le runner GitLab partage par mes projets se faisait jeter par Docker Hub avec toomanyrequests sur les pulls anonymes. Symptome : builds qui passent 9 fois sur 10, casses pour rien au hasard.
Solution : router tous les FROM via le Dependency Proxy GitLab. C'est un cache transparent que GitLab fournit gratuitement aux groupes. Le Dockerfile devient :
ARG NODE_IMAGE=node:20-bookworm-slim
ARG NGINX_IMAGE=nginx:1.27-alpine
FROM ${NODE_IMAGE} AS builder
# ...
FROM ${NGINX_IMAGE} AS runtimeEt le CI passe --build-arg NODE_IMAGE=$CI_DEPENDENCY_PROXY_DIRECT_GROUP_IMAGE_PREFIX/node:20-bookworm-slim. Local build inchange.
Lecon : les images de base sont une dependance comme une autre. Elles meritent d'etre cachees et versionnees.
L'archi qui m'a stabilise
Apres ces trois iterations, voici la photo de la v2 :
┌──────────────────┐
│ Postgres │
│ - checks │
│ - results │
│ - maintenance │
│ - users │
└────────┬─────────┘
│
┌────────────────┐ ┌────────▼─────────┐
Sonde A ──→ │ │ │ │
(Suisse) │ Orchestrateur │──│ Notif worker │──→ SMTP / Slack
Sonde B ──→ │ (HTTP API) │ │ │
(France) └────────┬───────┘ └──────────────────┘
│
│ HTTPS
▼
┌──────────┐
│ Web UI │
└──────────┘- une sonde = un container Docker autonome, deploye dans une zone reseau,
- l'orchestrateur tient l'etat, expose l'API, sert l'UI,
- le worker de notifications est un process Python a part qui lit Postgres,
- tout est instrumente : metriques exposees en
/metrics, logs structures en JSON.
Ce que je ferais differemment
Si je devais redemarrer aujourd'hui :
- Partir directement sur multi-tenant, meme pour un usage perso. Retrofitter le multi-tenant sur un service mono-utilisateur, c'est trois fois plus de travail qu'en partir.
- Ecrire les sondes en Go plutot qu'en Python. Pas pour la perf — pour le binaire unique, deployable sans runtime. Trois containers Python sur trois VMs, c'est trois mises a jour de version, trois
apt-geta maintenir. Un binaire Go, c'estscpet c'est tout. - Mettre en place les fenetres de maintenance des le jour 1. Pas le jour 1 d'usage en prod — le jour 1 de design. Si la donnee "ce check est en maintenance" n'existe pas dans le schema des le debut, on bricole ensuite.
Ce qui reste
La v2 est stable mais pas finie. Il manque :
- une vraie page de status publique (a la Statuspage.io) que les clients peuvent embarquer,
- des SLO/SLA configurables par check, avec calcul d'uptime mensuel,
- des sondes synthetiques HTTP plus riches (multi-step, assertion JSON, comparaison de captures d'ecran),
- et probablement de l'observabilite OpenTelemetry pour suivre la latence de bout en bout du pipeline d'alerte.
Si tu veux discuter monitoring, infrastructure, ou si tu cherches a en mettre en place un dans le meme esprit pour ton infra, contacte-moi : julien@tscherrig.com.