Gatus
Prometheus mesure ce que les services exposent d'eux-mêmes : consommation CPU, latence des requêtes reçues, erreurs comptées par l'application. Ces métriques internes ne disent rien d'un reverse proxy qui ne route plus, d'un certificat expiré ou d'un DNS qui ne résout plus : si aucune requête n'atteint l'application, aucun compteur d'erreurs n'augmente. Une supervision externe, dite boîte noire, interroge les services comme le ferait un client et constate directement leur disponibilité. Uptime Kuma remplit ce rôle mais se configure dans son interface web : les sondes vivent dans sa base de données, hors de tout dépôt, sans revue ni historique. Gatus déclare ces mêmes sondes dans un fichier YAML versionné. Cet article fait suite à l'article Alertmanager et décrit Gatus, son modèle de configuration, son déploiement sous Docker Compose et Kubernetes, et les pièges rencontrés en pratique.
Boîte blanche et boîte noire
Les deux approches répondent à des questions différentes :
| Boîte blanche (Prometheus) | Boîte noire (Gatus) | |
|---|---|---|
| Source | Métriques exposées par le service | Requêtes émises vers le service |
| Question | Comment le service se comporte-t-il ? | Le service répond-il correctement ? |
| Dépend du trafic réel | Oui, pour les métriques de requêtes | Non, la sonde génère son propre trafic |
| Détecte une panne en amont (proxy, DNS, TLS) | Non | Oui |
| Explique la cause | Souvent | Rarement |
La documentation de Gatus résume l'argument par une question : si le load balancer tombait maintenant, une alerte existante se déclencherait-elle ? Sans trafic, les métriques d'erreurs restent stables et ce sont les utilisateurs qui signalent la panne. Une sonde périodique, à l'inverse, échoue dès que le chemin client vers le service est rompu.
blackbox_exporter apporte aussi des sondes externes à Prometheus, mais la page de statut, les seuils de déclenchement et les notifications sont alors à construire avec Grafana, des règles d'alerte et Alertmanager. Gatus réunit les trois dans un seul binaire et un seul fichier.
Configuration dans l'interface ou dans un fichier
Uptime Kuma stocke chaque sonde dans sa base SQLite. Ajouter un service revient à remplir un formulaire ; la seule trace exploitable dans un dépôt est un export JSON, difficile à relire en revue et figé à la date de l'export. Reconstruire l'instance sur un autre hôte impose de réimporter cet export ou de tout ressaisir.
Gatus lit un fichier YAML. Ajouter une sonde devient un commit relu dans une pull request et déployé par la CI comme n'importe quel autre changement, à la manière de l'infrastructure décrite en code. L'historique des sondes passe par git log et la reconstruction de l'instance se réduit à un redéploiement.
Le modèle de configuration
Endpoints
L'unité de base est l'endpoint : une cible, un intervalle et une liste de conditions. Le préfixe de l'URL détermine le type de sonde :
endpoints:
- name: api
group: applications
url: "https://api.example.com/health"
interval: 1m # 60s par défaut
conditions:
- "[STATUS] == 200"
- "[RESPONSE_TIME] < 300" # en millisecondes
- "[BODY].status == UP" # chemin JSON dans la réponse
- "[CERTIFICATE_EXPIRATION] > 168h"
- name: database
group: infra
url: "tcp://postgres:5432"
conditions:
- "[CONNECTED] == true"
- name: gateway
group: infra
url: "icmp://192.0.2.1"
conditions:
- "[CONNECTED] == true"
- name: dns-local
group: infra
url: "192.0.2.53" # serveur DNS interrogé
dns:
query-name: "app.example.com"
query-type: "A"
conditions:
- "[DNS_RCODE] == NOERROR"
- "[BODY] == 192.0.2.10" # pour une requête A, [BODY] est l'adresse obtenue
Gatus prend aussi en charge udp://, sctp://, tls://, starttls://, ssh://, ws:///wss:// et grpc:///grpcs://. Le délai d'expiration par défaut est de 10 s pour HTTP, TCP et ICMP, modifiable dans le bloc client de l'endpoint.
Conditions
Une condition compare un placeholder à une valeur avec ==, !=, <, <=, > ou >=. Les placeholders disponibles dépendent du type de sonde :
| Placeholder | Valeur | Types |
|---|---|---|
[STATUS] | Code HTTP | HTTP |
[RESPONSE_TIME] | Durée en ms | Tous |
[CONNECTED] | Connexion établie | Tous |
[IP] | Adresse résolue de la cible | HTTP, ICMP et la plupart des autres |
[BODY] | Corps de la réponse, chemins JSON acceptés ([BODY].data[0].id) | HTTP, DNS, TCP/TLS avec body |
[CERTIFICATE_EXPIRATION] | Durée avant expiration du certificat | HTTPS, TLS, STARTTLS |
[DOMAIN_EXPIRATION] | Durée avant expiration du nom de domaine | HTTP |
[DNS_RCODE] | Code de réponse DNS (NOERROR, NXDOMAIN...) | DNS |
Quatre fonctions complètent le langage : len() (longueur d'un tableau ou d'une chaîne du corps), has() (existence d'un chemin JSON), pat() (motif avec *) et any() (liste de valeurs acceptées). Toutes les conditions d'un endpoint doivent être vraies pour qu'il soit sain : elles se combinent par un ET implicite. Le langage n'a pas d'opérateur OU entre conditions ; any() n'offre une alternative que sur les valeurs d'un même placeholder ([STATUS] == any(200, 401)). Aucune arithmétique n'est possible non plus. Cette limite a des conséquences pratiques, détaillées plus bas.
Une condition n'a pas à exiger un succès. Une API S3 interrogée sans authentification répond 403 : la condition [STATUS] == 403 sur l'URL publique vérifie alors que toute la chaîne (DNS, reverse proxy, TLS, service) répond, sans disposer d'identifiants.
Groupes et page de statut
Le champ group regroupe les endpoints sur le tableau de bord. Gatus en dérive une clé <groupe>_<nom> (espaces, points et autres séparateurs remplacés par -) qui identifie l'endpoint dans le stockage, l'API (/api/v1/endpoints/{key}/statuses) et les badges SVG (/api/v1/endpoints/{key}/uptimes/7d/badge.svg). Renommer un endpoint ou changer son groupe crée donc une nouvelle clé, sans historique.
La section ui personnalise la page : title, header, logo, default-sort-by: group, et custom-css pour aligner l'apparence sur une charte graphique.
Alerting
Fournisseurs et seuils
Un fournisseur se configure une fois sous alerting.<type> (une quarantaine sont disponibles : Slack, Teams, Matrix, ntfy, Gotify, email, PagerDuty, webhook personnalisé...). Chaque endpoint liste ensuite les alertes qui le concernent :
alerting:
ntfy:
url: "http://ntfy"
topic: "uptime"
click: "https://status.example.com" # ouvert au clic sur la notification
default-alert:
failure-threshold: 3 # 3 échecs consécutifs avant l'alerte
success-threshold: 2 # 2 succès consécutifs pour la résolution
send-on-resolved: true # false par défaut
endpoints:
- name: wiki
group: apps
url: "http://wiki:3000/health"
conditions:
- "[STATUS] == 200"
alerts: &ntfy
- type: ntfy
- name: git
group: apps
url: "http://git:3000/api/healthz"
conditions:
- "[STATUS] == 200"
alerts: *ntfy
default-alert évite de répéter les seuils, mais ne dispense pas de déclarer type dans chaque endpoint : un endpoint sans bloc alerts ne notifie jamais. Une ancre YAML (&ntfy, puis *ntfy) factorise ce bloc répété. failure-threshold joue le rôle du for d'une règle Prometheus : une sonde qui échoue une fois ne déclenche rien. minimum-reminder-interval (5 min au minimum) active des rappels tant que l'incident dure ; par défaut, une seule notification part. Le bloc overrides du fournisseur change le topic ou la priorité pour un groupe donné.
Envoyer les alertes de Gatus et celles d'Alertmanager sur deux topics ntfy distincts indique dès la notification quelle chaîne a détecté le problème : indisponibilité constatée de l'extérieur d'un côté, symptôme interne de l'autre.
Secrets
Gatus substitue les variables d'environnement dans son fichier ($VAR ou ${VAR}) ; un $ littéral s'écrit $$. Un webhook Slack, qui vaut un jeton, reste ainsi hors du fichier versionné :
alerting:
slack:
webhook-url: "${SLACK_WEBHOOK_URL}"
Un fournisseur mal configuré n'interrompt pas le démarrage : la documentation précise que toutes les alertes de ce type sont alors ignorées. Forcer une sonde en échec après chaque changement du bloc alerting reste le seul moyen de vérifier la chaîne de notification.
Stockage
Gatus conserve les résultats récents de chaque endpoint, les événements (passages sain / en panne) et les statistiques d'uptime. Trois backends existent :
storage.type | Persistance | Usage |
|---|---|---|
memory (défaut) | Aucune : tout est perdu au redémarrage | Essais |
sqlite | Fichier désigné par storage.path | Instance unique |
postgres | Base désignée par l'URL de connexion storage.path | Base existante, données partagées |
maximum-number-of-results (100 par défaut) et maximum-number-of-events (50) bornent la taille de l'historique affiché par endpoint.
Métriques Prometheus
Avec metrics: true, Gatus expose /metrics sur son port web. Les séries portent les labels key, group, name et type :
| Métrique | Type | Contenu |
|---|---|---|
gatus_results_total | counter | Résultats par endpoint, label success |
gatus_results_endpoint_success | gauge | 1 si la dernière sonde a réussi, 0 sinon |
gatus_results_duration_seconds | gauge | Durée de la dernière sonde |
gatus_results_certificate_expiration_seconds | gauge | Secondes avant expiration du certificat |
gatus_results_code_total | counter | Résultats par code HTTP ou DNS |
Ces séries alimentent des tableaux de bord Grafana sur une durée plus longue que la rétention de Gatus, ou des règles d'alerte combinant disponibilité et métriques internes. Le champ extra-labels d'un endpoint ajoute des labels personnalisés (environment: staging).
# Disponibilité sur 7 jours par service, indépendante du pod qui a produit les mesures
avg_over_time(max by (group, name) (gatus_results_endpoint_success)[7d:5m])
Exemple pratique
Docker Compose
Sur un hôte Docker Compose, Gatus rejoint le réseau partagé avec le reverse proxy et sonde les services par leur nom DNS interne :
services:
gatus:
image: twinproduction/gatus:v5.37.0
environment:
- GATUS_CONFIG_PATH=/config # répertoire : tous les *.yaml sont fusionnés
ports:
- "127.0.0.1:8081:8080" # API locale pour d'autres outils de l'hôte
volumes:
- ./config:/config:ro # répertoire monté, pas le fichier seul
- gatus_data:/data
networks:
- proxy
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.http.routers.gatus.rule=Host(`status.example.com`)"
- "traefik.http.routers.gatus.entrypoints=websecure"
- "traefik.http.routers.gatus.tls.certresolver=letsencrypt"
- "traefik.http.routers.gatus.middlewares=forward-auth@file"
- "traefik.http.services.gatus.loadbalancer.server.port=8080"
volumes:
gatus_data:
networks:
proxy:
external: true
# config/config.yaml
storage:
type: sqlite
path: /data/data.db
ui:
header: Disponibilité
default-sort-by: group
Lorsque GATUS_CONFIG_PATH désigne un répertoire, Gatus fusionne tous les fichiers *.yaml et *.yml qu'il contient : les listes endpoints s'additionnent, les objets fusionnent en profondeur. Un fichier par groupe de services reste alors possible. Gatus n'a pas d'authentification activée par défaut ; il dispose d'un mode Basic et d'un mode OIDC (security.basic, security.oidc), mais un middleware de forward auth devant le routeur Traefik applique la politique d'accès commune aux autres services.
Kubernetes avec Helm
Le dépôt de charts de l'auteur de Gatus fournit un chart (twin/gatus). Un chart maison de quelques templates suffit aussi : la configuration Gatus vit dans values.yaml et un ConfigMap la rend telle quelle.
# templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "gatus.fullname" . }}
data:
config.yaml: |
{{- toYaml .Values.config | nindent 4 }}
# values.yaml (extrait)
config:
metrics: true
storage:
type: sqlite
path: /data/data.db
alerting:
slack:
webhook-url: "${SLACK_WEBHOOK_URL}" # transmis tel quel par Helm, substitué par Gatus
default-alert:
failure-threshold: 3
success-threshold: 2
send-on-resolved: true
endpoints:
- name: webapp
group: Applications
url: http://webapp.apps.svc.cluster.local:8080/health
conditions: ["[STATUS] == 200"]
alerts: [{type: slack}]
- name: node-01
group: Nœuds
url: icmp://192.0.2.21
conditions: ["[CONNECTED] == true"]
alerts: [{type: slack}]
Le Deployment porte l'essentiel des choix d'exploitation :
spec:
replicas: 1
strategy:
type: Recreate # volume RWO + SQLite : jamais deux pods simultanés
template:
metadata:
annotations:
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: /metrics
spec:
securityContext:
runAsNonRoot: true
runAsUser: 65534
runAsGroup: 65534
fsGroup: 65534
sysctls:
- name: net.ipv4.ping_group_range # ping ICMP sans root ni NET_RAW
value: "65534 65534"
containers:
- name: gatus
image: "twinproduction/gatus:v5.37.0"
env:
- name: GATUS_CONFIG_PATH
value: /config/config.yaml
envFrom:
- secretRef:
name: gatus # SLACK_WEBHOOK_URL
volumeMounts:
- { name: config, mountPath: /config, readOnly: true }
- { name: data, mountPath: /data }
Les annotations checksum/* changent avec le contenu du ConfigMap et du Secret, ce qui déclenche un nouveau déploiement à chaque modification de sonde ou de webhook. Les annotations prometheus.io/* sont lues par un job kubernetes-pods de Prometheus (voir l'introduction à Prometheus). Le webhook n'apparaît jamais dans le dépôt : la CI le passe depuis un secret de pipeline avec helm upgrade --set-literal 'secret.SLACK_WEBHOOK_URL=...', --set-literal transmettant la valeur sans l'interpréter (virgules, antislashs). Les mécanismes de Secret et de ConfigMap sont détaillés dans l'article Kubernetes : Secrets et ConfigMaps.
Pièges
Un volume monté ne rend pas l'historique persistant
Sans section storage, Gatus utilise le backend memory, quel que soit le volume monté sur /data. Le conteneur démarre, le tableau de bord se remplit, et l'historique disparaît au premier redémarrage. La documentation précise en outre que, sans stockage persistant, une modification de configuration équivaut à un redémarrage : avec un mécanisme qui recrée le conteneur à chaque changement de fichier, chaque nouvelle sonde efface l'historique de toutes les autres. Vérifier la présence de data.db dans le volume après le premier démarrage suffit à détecter l'oubli.
Rechargement à chaud et fichier monté
Gatus surveille son fichier de configuration et le recharge en cours d'exécution ; une configuration invalide arrête le processus, sauf avec skip-invalid-config-update: true qui conserve l'ancienne. Monter le fichier seul plutôt que son répertoire empêche souvent la détection (issue #151) : un éditeur ou un git pull remplace le fichier par un nouvel inode, que le bind mount ne suit pas, et le conteneur continue de voir l'ancienne version. Deux parades : monter le répertoire, ou recréer explicitement le conteneur à chaque changement (hash de la configuration dans une variable d'environnement sous Compose, annotation de checksum sous Kubernetes).
Sonder le Service, pas une adresse
Une sonde sur une ClusterIP ou sur <ip-du-nœud>:<NodePort> casse dès que le Service est recréé ou que l'exposition change. Lorsqu'une interface est sortie d'un NodePort pour passer derrière un reverse proxy, la sonde qui visait le port du nœud s'est mise à échouer alors que l'application fonctionnait. Le nom DNS du Service (<service>.<namespace>.svc.cluster.local) survit à ces changements. Sous Compose, le nom du service sur le réseau partagé joue le même rôle ; une IP en dur n'est justifiée que pour une cible hors de ce réseau (conteneur en network_mode: host, nœud distant), et devient une donnée à maintenir.
Un port ouvert n'est pas un service fonctionnel
[CONNECTED] == true sur tcp:// garantit seulement qu'un processus écoute. Un reverse proxy sondé en TCP reste vert alors qu'il ne route plus rien. Pour un composant de chaîne, une sonde HTTP qui traverse toute la chaîne jusqu'à une route connue (comme la requête anonyme qui doit renvoyer 403) mesure ce que voit réellement un client.
Services mis en veille
Un service arrêté après inactivité par Sablier apparaît en panne à chaque mise en veille, et une sonde qui passe par le reverse proxy le réveille. La condition correcte, « en panne si le service est censé tourner et ne répond pas », exige un OU ou une multiplication, absents du langage de Gatus.
Les suites (fonction en alpha) exécutent des endpoints en séquence et sautent les étapes suivantes après un échec, sauf always-run: true. Une première étape qui vérifie que le service est censé être actif peut ainsi conditionner la sonde de santé : l'alerte est évitée, mais l'étape de garde échoue à chaque sommeil et le tableau de bord affiche du rouge en permanence. Les suites ne portent pas non plus d'alerte au niveau de la suite.
La solution qui tient déplace le calcul dans Prometheus : blackbox_exporter sonde le service, Sablier expose l'état du groupe, et une expression PromQL vaut 1 uniquement si le groupe est actif et la sonde en échec. Gatus interroge l'API HTTP de Prometheus et compare le résultat à 0 :
- name: wiki
group: apps
# sum(sablier_group_active_instances{group="wiki"} or vector(0)) * (1 - sum(probe_success{instance="http://wiki:3000/health"}))
url: "http://prometheus:9090/api/v1/query?query=sum(sablier_group_active_instances%7Bgroup%3D%22wiki%22%7D%20or%20vector(0))%20*%20(1%20-%20sum(probe_success%7Binstance%3D%22http%3A%2F%2Fwiki%3A3000%2Fhealth%22%7D))"
conditions:
- "[BODY].data.result[0].value[1] == 0"
alerts: *ntfy
La requête doit être encodée dans l'URL. value[1] est la valeur de l'unique échantillon renvoyé. Si la requête ne renvoie rien (série absente), le chemin JSON n'existe pas et la condition échoue : l'endpoint passe au rouge, ce qui signale cette fois un problème de supervision plutôt qu'un faux négatif silencieux. Sablier lui-même garde une sonde directe, puisqu'il doit toujours tourner.
ICMP sans privilèges
Depuis la version 5.31.0, Gatus envoie des pings non privilégiés (sockets ICMP de type datagramme) lorsqu'il ne tourne pas en root. Le noyau Linux ne les autorise qu'aux groupes listés dans net.ipv4.ping_group_range. Docker élargit ce paramètre à tous les groupes dans ses conteneurs ; un runtime Kubernetes comme containerd ne le fait pas, et les sondes icmp:// échouent alors toutes. Le sysctl, classé safe par Kubernetes, se déclare dans le securityContext du pod avec le GID du processus, sans CAP_NET_RAW ni root.
Labels instables dans Prometheus
Le scrape par annotations ajoute le label pod aux séries de Gatus. Chaque redéploiement crée un nouveau pod, donc de nouvelles séries : un graphique ou un calcul d'uptime brut se fragmente à chaque mise à jour de configuration. Agréger par group et name (max by (group, name) (...)) rend les séries continues.
Une seule source de disponibilité
Plusieurs outils savent afficher un état : pastilles de statut d'un tableau de bord d'accueil comme Dashy, règle Prometheus up == 0, sonde Gatus. Les cumuler produit plusieurs notifications par panne et des états parfois contradictoires, chacun sondant à son rythme et par son propre chemin. Retirer les pastilles du tableau de bord et la règle up == 0 laisse Gatus seul juge de la disponibilité, Prometheus se concentrant sur les symptômes internes.
Limites
- Pas de calcul : les conditions comparent des valeurs, sans OU entre conditions ni arithmétique. Toute logique composite passe par une source externe (API Prometheus, endpoint applicatif dédié).
- Une instance : avec SQLite, une seule réplique. Gatus ne coordonne pas plusieurs instances ; deux instances sondant les mêmes cibles notifient deux fois.
- Point de vue unique : Gatus sonde depuis l'endroit où il tourne. Une sonde interne au cluster ne voit ni le DNS public ni l'accès depuis Internet ; une instance externe ou des sondes sur les URL publiques couvrent ce chemin.
- Concurrence bornée : trois sondes au plus s'exécutent simultanément (
concurrency: 3). Des cibles lentes ou en timeout (10 s par défaut) retardent les autres ; l'intervalle se compte à partir de la fin de la sonde précédente. - Surveillance du superviseur : si Gatus ou son canal de notification tombe, aucune alerte ne part. Une sonde Gatus sur le serveur ntfy détecte sa panne mais ne peut pas la notifier par ce même canal ; une alerte Prometheus sur la disparition des métriques de Gatus (
absent(gatus_results_endpoint_success)) ou une intégration domotique lisant son API locale fournissent ce second regard. - Exposition : pas de routage par chemin (
example.com/statusn'est pas pris en charge), un sous-domaine dédié est nécessaire.
Conclusion
Gatus répond à une question que les métriques internes ne posent pas : le service est-il joignable et correct du point de vue d'un client ? Sa force tient au format de sa configuration plus qu'à ses sondes : un fichier YAML versionné, relu et déployé comme le reste de l'infrastructure, là où Uptime Kuma enferme la même information dans sa base. Le langage de conditions reste volontairement simple, ce qui oblige à déplacer toute logique composite vers Prometheus. Les pièges observés relèvent surtout de l'intégration : stockage persistant à déclarer explicitement, rechargement de configuration à vérifier, cibles nommées plutôt qu'adressées, sysctl ICMP sous Kubernetes et labels de pod à agréger.
Application / Projet lié
403 attendu et surveille les services mis en veille par Sablier au travers de l'API Prometheus.Docker ComposeTraefikAutheliaTailscaleGitHub ActionsRenovatePrometheusgit-crypt