jguillaumesio
prod-opsdevopsdocker

Je grepais du JSON en SSH pendant que la production était en panne

Pendant un incident, ma stack d'observabilité était une session SSH, docker logs et grep. Voici le montage à budget zéro qui l'a remplacée sur le même VPS : Uptime Kuma sur les healthchecks que j'avais déjà, Dozzle pour les logs en direct, et l'alerte qui arrive maintenant sur mon téléphone.

Le message est arrivé vers midi : « Le site est down pour vous aussi ? » Il l’était. J’ai ouvert un terminal, un SSH vers le serveur, lancé docker ps, et commencé le rituel : docker logs api --tail 200, plisser les yeux devant un mur de JSON sur une ligne, grep error, grep 500, scroller, deviner, recommencer pour nginx, recommencer pour db.

Quatorze minutes plus tard j’avais trouvé. Quatorze minutes pendant lesquelles la seule personne qui savait que la production était en panne l’avait appris d’un client, et avait passé tout ce temps à lire des logs de la façon la moins efficace dont un être humain peut lire des logs.

Ceci est la partie 6 d’une série sur le durcissement d’un SaaS construit en solo en production. Le contexte et la liste complète des points faibles sont dans l’article pilier. Celui-ci parle de la différence entre avoir des logs et pouvoir les voir, et ça ne coûte rien qu’une heure et un peu de RAM.

Ce que j’avais, et pourquoi c’était presque suffisant

Je veux être juste avec mon moi passé, parce que les bases étaient là. Chaque service du fichier compose avait des logs bornés :

# docker-compose.yml, élagué et anonymisé
services:
  api:
    logging:
      driver: json-file
      options: { 'max-size': '10m', 'max-file': '3' }
    labels:
      - 'logging'
    healthcheck:
      test: ['CMD', 'bun', '-e',
        "fetch('http://localhost:8000/health').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s

Les fichiers de log bornés sont la seule habitude qui garantissait qu’un conteneur bavard ne remplirait jamais le disque. C’était le boulot du disque, apparemment, et il a trouvé un autre moyen. Les services api et db avaient aussi de vrais healthchecks, que la partie 5 explique en détail, y compris la surprise qu’un healthcheck en échec dans Compose seul ne redémarre jamais rien.

Donc Docker savait que l’API était unhealthy. Il le savait depuis plusieurs minutes avant le message du client. Il ne l’a dit à personne, parce que personne n’écoutait. Et chaque ligne de log dont j’avais besoin était déjà sur le disque, dans un format conçu pour des machines, lue par une personne avec grep.

C’est tout le problème en une phrase : l’information existait et il n’y avait aucun chemin d’elle jusqu’à moi.

La contrainte

Même VPS, pas de nouvelle facture mensuelle. C’est un produit construit en solo pour une petite agence, et « ajoutez Datadog » n’est pas une réponse que je peux leur donner. La stack ci-dessous tourne dans deux conteneurs supplémentaires, utilise environ 150 Mo de RAM à deux, et coûte zéro.

Couche 1 : Uptime Kuma sur les healthchecks que j’avais déjà

Uptime Kuma est une page de statut et un moniteur auto-hébergés. Il pingue des choses et vous prévient quand elles cessent de répondre. Il a aussi un type de moniteur Docker qui lit directement la santé des conteneurs, ce qui veut dire que les healthchecks que j’avais déjà écrits deviennent des alertes sans aucune duplication.

# docker-compose.yml, service ajouté
  uptime-kuma:
    image: louislam/uptime-kuma:1
    volumes:
      - ./uptime-kuma/data:/app/data
      - /var/run/docker.sock:/var/run/docker.sock:ro
    networks:
      my_network:
    restart: unless-stopped
    logging:
      driver: json-file
      options: { 'max-size': '10m', 'max-file': '3' }

Deux choses sur ce bloc. Il monte le socket Docker en lecture seule, ce qui lui permet de voir la santé des conteneurs. Et il ne publie pas de port : on l’atteint via nginx sur un nom d’hôte interne, derrière la même auth que tout le reste, parce qu’un dashboard de statut public pour un produit privé est un cadeau de reconnaissance.

Puis les moniteurs, configurés dans l’interface, cinq minutes en tout :

MoniteurTypeCibleIntervalle
Santé APIHTTPhttp://api:8000/health60 s
Conteneur APIDockerapi (healthy/unhealthy)60 s
Conteneur base de donnéesDockerdb60 s
DashboardHTTP mot-cléhttps://app.example.com, attend le titre de l’app120 s
Certificat TLSHTTPhttps://api.example.com, alerte d’expiration à 14 jours24 h

La dernière ligne existe à cause de la partie 8, où un certificat n’a silencieusement pas été renouvelé et où je l’ai appris par un avertissement de navigateur. Quatorze jours de préavis en auraient fait un non-événement.

Le moniteur HTTP sur /health et le moniteur Docker sur le même conteneur ont l’air redondants. Ils ne le sont pas. Le check HTTP me dit que l’API répond depuis le réseau ; le check Docker me dit ce que Docker en pense. Quand ils divergent, la divergence est elle-même le diagnostic : conteneur sain plus HTTP en échec veut dire nginx ou réseau, pas l’application.

Couche 2 : une alerte qui atteint un téléphone

Un dashboard que personne ne regarde est un fichier de log avec une plus jolie police. Les moniteurs ont besoin d’un canal de notification, et Uptime Kuma en supporte une quatre-vingt-dizaine. J’en utilise deux : l’email pour l’historique, et une notification push pour celles qui comptent.

La règle sur laquelle je me suis fixé, qui a demandé deux fausses alertes pour être bien réglée :

  • Me réveiller (push, immédiat) : santé API down, conteneur base de données unhealthy, certificat sous 14 jours
  • Me notifier (email, groupé) : mot-clé du dashboard absent, n’importe quel moniteur qui revient
  • Rien : un seul check en échec. Chaque moniteur a ses retries à 2 avant de compter comme down, parce qu’un trou de 60 secondes à 3 h du matin ne vaut pas un réveil et qu’une vraie panne sera encore là au troisième check

L’incident de midi, rejoué là-dedans : le healthcheck de l’API échoue trois fois en 90 secondes, Docker marque le conteneur unhealthy, Uptime Kuma voit les deux signaux, et mon téléphone vibre environ deux minutes après le premier échec. Au lieu d’un client qui me le dit au bout de quatorze minutes.

Couche 3 : Dozzle, pour que les logs soient lisibles pendant que ça se passe

La supervision vous dit que quelque chose ne va pas. Les logs vous disent quoi. Et mes logs étaient au pire endroit possible pour un humain : des lignes JSON, un fichier par conteneur, sur un serveur où je devais me connecter en SSH.

Dozzle est un seul conteneur qui suit les logs Docker dans un onglet de navigateur. Pas d’agent, pas de base, pas d’indexation, pas de configuration. Il lit le même socket qu’Uptime Kuma et affiche la sortie de chaque conteneur en direct, avec recherche, filtrage, et la possibilité de regarder plusieurs conteneurs côte à côte.

  dozzle:
    image: amir20/dozzle:latest
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    environment:
      DOZZLE_FILTER: 'label=logging'
    networks:
      my_network:
    restart: unless-stopped

Cette ligne DOZZLE_FILTER: 'label=logging' est la raison pour laquelle le fichier compose avait déjà labels: ['logging'] sur chaque service : elle avait été ajoutée des mois plus tôt, pour exactement cet outil, et l’outil n’a jamais été installé. Mon moi passé avait la bonne idée et s’est arrêté une étape trop tôt.

Dozzle est aussi derrière nginx avec auth, sur un nom d’hôte interne. La différence pendant un incident est difficile à exagérer. docker logs api --tail 200 | grep -i error devient : ouvrir un onglet, taper error, regarder défiler, cliquer sur nginx pour voir si la requête est même arrivée. Les quatorze minutes deviennent deux.

Ce que j’ai délibérément choisi de ne pas installer

Loki plus Grafana était la réponse « propre » évidente, et j’ai écrit le bloc compose avant de le supprimer. Sur un VPS avec 8 Go de RAM, Loki, Promtail et Grafana ajoutent trois conteneurs, un langage de requête à apprendre, une rétention à configurer, et environ 600 Mo de mémoire pour le privilège de chercher dans des logs que je peux déjà chercher dans Dozzle. Quand il y aura plus qu’une poignée de services, ou quand je devrai corréler sur des semaines, l’arbitrage changera. Aujourd’hui non.

Même raisonnement pour Prometheus et les node exporters. Les healthchecks plus Uptime Kuma répondent à la question que j’ai vraiment, qui est « est-ce que c’est up », et l’usage disque est un seul moniteur push d’Uptime Kuma alimenté par une ligne de cron :

# crontab, toutes les 5 minutes : remonter l'usage disque à un moniteur push
*/5 * * * * curl -fsS "http://localhost:3001/api/push/<token>?status=up&msg=$(df --output=pcent / | tail -1 | tr -d ' %')" > /dev/null

Uptime Kuma alerte si le push cesse d’arriver, et je peux lire le dernier message pour voir le pourcentage. C’est rustique. Ça aurait aussi attrapé le disque qui se remplissait une semaine avant qu’il fasse tomber la production, ce qui est plus que ce que les critiques de la solution rustique peuvent dire.

Ce qui n’est toujours pas corrigé

Il n’y a pas de suivi d’erreurs. Dozzle me montre une stack trace si je regarde le bon conteneur au bon moment ; il ne regroupe pas les erreurs, ne les compte pas, et ne me dit pas qu’une nouvelle est apparue après un déploiement. C’est un autre outil et un autre problème, et c’est la partie 7.

Les deux nouveaux conteneurs dépendent de la machine même qu’ils surveillent. Si le VPS lui-même tombe, Uptime Kuma tombe avec et n’envoie rien. La solution est un second moniteur, externe, pointé sur les endpoints publics, et le palier gratuit de n’importe quel service d’uptime hébergé suffit pour ça. Je ne l’ai pas encore fait. C’est sur la liste, et je suis conscient que « le moniteur est sur la chose qu’il surveille » est le genre de phrase qui finit dans un post-mortem.

La leçon

Je traitais l’observabilité comme quelque chose qu’on achète, et donc comme quelque chose qu’un petit produit ne pouvait pas se permettre. Ce qui me manquait vraiment, c’était deux conteneurs et une heure. Les healthchecks, les limites de logs, même les labels logging étaient déjà tous dans le fichier compose. La stack était construite à 90 % et visible à 0 %.

L’incident de midi aurait été une notification push de deux minutes et un onglet Dozzle. À la place, ç’a été un client, une session SSH et grep. La différence n’a jamais été une question d’argent.