DEV Community

Dev du Sud
Dev du Sud

Posted on

Votre cron est mort depuis trois semaines et personne ne vous a rien dit

Un job planifié ne tombe pas en panne comme un site web. Un site web qui tombe, on le voit :
les visiteurs écrivent, les statistiques s'effondrent. Un cron qui tombe, lui, produit exactement
la même chose qu'un cron qui n'avait rien à faire : du silence.

Je l'ai découvert en ouvrant le fichier des tâches planifiées de ma machine. Une publication
automatique d'articles était en état « pause » depuis le 16 octobre à 14 h 26. Treize exécutions
consécutives en échec avant ça, du 4 au 16 octobre. Personne n'avait été prévenu — et surtout,
rien ne s'était arrêté : les autres jobs continuaient, la machine était en bonne santé, tout
avait l'air normal.

Pourquoi un job meurt sans bruit

Trois causes, et dans mon cas les trois en même temps.

1. La panne réseau. Les journaux montrent une résolution DNS impossible au moment du
déclenchement : URLError, puis Name or service not known. Le job a échoué treize fois
de suite, à la même heure, chaque matin.

2. L'ordonnanceur qui se protège. Après un certain nombre d'échecs consécutifs, un
scheduler correct met le job en pause. C'est la bonne décision : ça évite de marteler une API
morte. Mais la mise en pause n'est pas forcément visible pour un humain, et rien ne garantit
qu'on la remarque un jour.

3. La sortie que personne ne lit. Un script qui écrit dans un fichier de log ne prévient
personne. Un script qui sort en code 0 alors qu'il n'a rien fait prévient encore moins.

Il existe un quatrième cas, plus vicieux : le script tourne, sort en 0, et ne fait rien. Le
cas classique, c'est le dossier vide.

if not todo:
    print("file vide")
    sys.exit(0)          # ← le drame est ici
Enter fullscreen mode Exit fullscreen mode

Tout va bien. Surtout pour le monitoring.

Garde-fou 1 — un battement de cœur horodaté

Un fichier, écrit uniquement en cas de succès réel, avec l'horodatage à l'intérieur.

from datetime import datetime, timezone
import pathlib

HEARTBEAT = pathlib.Path.home() / ".local/state/mon-job-last-success"

def marquer_succes(note: str = "") -> None:
    HEARTBEAT.parent.mkdir(parents=True, exist_ok=True)
    HEARTBEAT.write_text(f"{datetime.now(timezone.utc).isoformat()} {note}\n")
Enter fullscreen mode Exit fullscreen mode

Règle : on écrit le heartbeat à la fin, après l'action réussie. Pas au début, pas « à peu
près », pas « si tout s'est bien passé jusqu'ici ». Si le script plante au milieu, aucun
heartbeat — et c'est précisément l'information qu'on veut.

Garde-fou 2 — le chien de garde qui surveille le chien de garde

Un second job, indépendant, qui ne fait qu'une chose : mesurer l'âge du heartbeat et crier
si l'âge dépasse le seuil.

#!/usr/bin/env bash
set -euo pipefail

HB="$HOME/.local/state/mon-job-last-success"
SEUIL_H=$(( 3 * 24 ))   # 3 jours pour une publication quotidienne

if [ ! -f "$HB" ]; then
  notify-send "Job: aucun heartbeat n'a jamais été écrit" || true
  exit 1
fi

AGE_H=$(( ( $(date +%s) - $(stat -c %Y "$HB") ) / 3600 ))

if [ "$AGE_H" -gt "$SEUIL_H" ]; then
  notify-send "Job muet depuis ${AGE_H} h (seuil ${SEUIL_H} h)"
  exit 1
fi

echo "ok — dernier succès il y a ${AGE_H} h"
Enter fullscreen mode Exit fullscreen mode

Ce garde-fou ne répare rien : il révèle. Dans mon cas, il aurait parlé le 16 octobre au
soir au lieu du 24.

Le seuil est la seule décision qui compte. Trop court, on crie pour un job hebdomadaire
parfaitement normal ; trop long, on découvre la panne quand le stock est déjà épuisé. Pour une
publication quotidienne, trois jours laissent une marge honnête : un week-end de panne réseau
ne déclenche rien, trois jours de silence déclenchent tout.

Si vous ne voulez pas écrire ce garde-fou, les services de dead man's switch existent
(healthchecks.io, ou Uptime Kuma en auto-hébergé) : le job envoie un ping en cas de succès, le
service crie quand le ping manque. C'est la même idée, hébergée ailleurs — et c'est plus
robuste, puisque la surveillance ne meurt pas avec la machine surveillée.

Garde-fou 3 — distinguer « rien à faire » de « je n'ai pas pu faire »

C'est tout le piège du file vide. Un job qui n'avait rien à faire et un job cassé doivent
produire deux signaux différents.

if not todo:
    print("ALERTE file vide")
    sys.exit(3)          # ni succès, ni panne : problème de stock
Enter fullscreen mode Exit fullscreen mode

Avec un code de sortie non nul, le scheduler marque l'exécution en échec et la remonte. Trois
codes suffisent pour se comprendre entre humains :

Code Sens Réaction attendue
0 Succès, quelque chose a été fait Rien
1 Erreur technique (réseau, API, disque) Regarder les logs, réessayer
3 Rien à faire, et c'est anormal Recharger la file, réapprovisionner

La différence entre 1 et 3 est la plus utile du lot : elle dit si on doit réparer la
plomberie ou remplir le réservoir.

Garde-fou 4 — tester le contrat, pas seulement le script

Un script testé une fois en septembre ne dit rien de son comportement en novembre. Deux
vérifications automatiques, une fois par semaine :

# 1) le script est-il encore démarrable ?
python3 -m py_compile "$HOME/.hermes/scripts/mon-job.py"

# 2) a-t-il encore de quoi travailler ?
ls -1 "$HOME/devto-articles"/*.md 2>/dev/null | wc -l
Enter fullscreen mode Exit fullscreen mode

Si le second nombre vaut zéro, le job va tourner pour rien. C'est exactement ce qui s'est
passé chez moi : les fichiers déjà publiés avaient été déplacés dans un sous-dossier
publies/, et le script ne regarde que la racine du dossier. Zéro candidat, tous les jours,
sans la moindre erreur.

Ajoutez flock pour éviter deux exécutions simultanées, et set -euo pipefail pour que
l'échec d'une commande intermédiaire ne soit pas avalée :

flock -n /tmp/mon-job.lock /usr/bin/python3 "$HOME/.hermes/scripts/mon-job.py"
Enter fullscreen mode Exit fullscreen mode

Le détail qui m'a coûté le plus de temps

Deux runs sur treize ont échoué pour une raison différente du réseau :
422 — Title has already been used in the last five minutes. L'API refuse un titre identique
dans une fenêtre de cinq minutes, et mon script réessayait avec le même titre. Un type d'échec
sur deux est invisible dans un compteur global, et il suffit à casser un job.

Leçon : un échec n'est pas un échec. 503 (serveur fatigué), 422 (contrat violé), 401
(clé expirée) demandent trois réactions opposées — réessayer, corriger, réveiller un humain.
Les traiter pareil, c'est garantir qu'aucun des trois ne sera bien traité.

La checklist que j'applique maintenant

  • [ ] Un heartbeat écrit après le succès, jamais avant.
  • [ ] Un garde-fou indépendant qui mesure l'âge du heartbeat.
  • [ ] Trois codes de sortie : 0 succès, 1 panne, 3 stock vide.
  • [ ] Un test hebdomadaire du contrat : le script compile, la file n'est pas vide.
  • [ ] flock sur chaque job qui touche à un état partagé.
  • [ ] La sortie du job lue par quelque chose, jamais seulement écrite sur disque.
  • [ ] Un job mis en pause par le scheduler déclenche une alerte, pas un silence.

Ce que ça change

Rien de spectaculaire : un heartbeat, un chien de garde, trois codes de sortie, un test
hebdomadaire. Une quarantaine de lignes en tout. Mais depuis, le silence est devenu une
information.

Un job planifié n'est pas un service qui tourne. C'est une promesse renouvelée chaque nuit —
et une promesse non tenue ne fait aucun bruit. C'est à vous d'installer le bruit.

Top comments (0)