Carnet technique
Entrée N° 00413 min#surveillance #linux #sauvegardes

Surveiller ses tâches cron avec un heartbeat (dead man's switch)

Une sauvegarde qui ne tourne plus ne prévient personne. Heartbeat, délai de grâce, systemd, crontab, GitHub Actions : le montage réel et ses pièges.

Sur mon VPS tournent quelques tâches planifiées : une sauvegarde chaque nuit, un scan de sécurité chaque lundi, un test de restauration deux fois par mois. Ce sont les tâches qu’on installe une fois et qu’on oublie. C’est justement le problème : le jour où l’une d’elles s’arrête, rien ne le signale.

C’est arrivé deux fois. Le scan de sécurité hebdomadaire n’a jamais tourné tout seul pendant plusieurs jours : il était déclaré dans /etc/cron.d/, mais cron n’était pas installé sur le serveur. Personne ne lisait le fichier. Sur une autre machine, un ancien contrôle de sauvegarde appelait une adresse de surveillance qui avait été supprimée, et échouait sans bruit. Dans les deux cas, aucune erreur, aucun mail : la tâche n’échouait pas, elle n’existait plus. Et une absence ne déclenche aucune alerte, sauf si quelque chose l’attend.

C’est le principe du heartbeat, aussi appelé dead man’s switch : la tâche envoie un signe de vie à chaque réussite, et c’est quand le signe de vie n’arrive pas que l’alerte part. À la fin de cet article, vous saurez régler un heartbeat, le brancher sur un script, un service systemd, une crontab ou un workflow GitHub Actions, et éviter deux pièges que j’ai rencontrés : le « tous les mois » qui dure 31 jours, et le heartbeat qui reçoit des signes de vie de la mauvaise source.

La version courte

  • Le problème : une tâche planifiée qui ne tourne plus ne produit aucune erreur. On ne découvre l’absence de sauvegarde que le jour où on en a besoin.
  • La solution : à chaque réussite, la tâche appelle une adresse. Un service de surveillance attend cet appel et alerte s’il n’arrive pas à temps.
  • Le réglage : intervalle = période de la tâche, délai de grâce = durée normale + marge. Calculer l’écart maximal entre deux passages, pas l’écart moyen.

Trois notions avant de commencer

  • Tâche planifiée. Un programme lancé automatiquement à heure fixe. Sous Linux, deux mécanismes : cron (une ligne dans une crontab) et les timers systemd (un fichier .timer qui lance un service). Sur mon VPS, ce sont des timers.
  • Heartbeat, ou ping. Un simple appel HTTP (curl https://…) que la tâche fait à la fin, pour dire « j’ai tourné, et j’ai réussi ». Le service qui le reçoit note l’heure.
  • Délai de grâce. La marge laissée à la tâche avant l’alerte. Une sauvegarde quotidienne peut démarrer quelques minutes en retard ou durer un peu plus : le délai de grâce évite de sonner pour si peu.

Surveiller le résultat, pas la tâche

Une image : un randonneur seul prévient un ami « si je ne t’ai pas appelé à 20 h, donne l’alerte ». L’ami ne cherche pas à savoir ce qui a mal tourné (entorse, batterie vide, orage) : il réagit à l’appel qui ne vient pas. C’est exactement le heartbeat.

La surveillance classique regarde si un processus plante. Elle ne voit pas :

  • le timer ou la crontab qui n’est plus lu (mon cas) ;
  • le serveur éteint à l’heure dite ;
  • le script qui sort en code 0 sans avoir rien fait parce qu’une variable était vide ;
  • la tâche qui reste bloquée pendant des heures sur un montage réseau.

Le heartbeat inverse la logique. On ne demande pas « est-ce qu’il y a eu une erreur ? », mais « est-ce que j’ai reçu la preuve que ça a marché, dans le délai prévu ? ». Tout ce qui empêche la preuve d’arriver, quelle qu’en soit la cause, finit en alerte. Y compris des causes auxquelles on n’a pas pensé.

Le corollaire : on n’est plus prévenu quand ça marche, on l’est quand ça s’arrête. Pas de mail « sauvegarde OK » tous les matins, que plus personne ne lit au bout d’une semaine.

L’outil : Pinguro, et les alternatives

J’utilise Pinguro, l’outil de surveillance que je développe (pinguro.app). Je décris donc ce que je connais de l’intérieur, mais le principe est le même partout. Les alternatives sérieuses :

  • Healthchecks.io : spécialisé dans les heartbeats, open source et auto-hébergeable (image Docker officielle). Il gère un signal de début (/start), un signal d’échec (/fail) et le code de sortie dans l’URL.
  • Cronitor : suit l’état d’une exécution avec ?state=run, complete ou fail.
  • Better Stack : heartbeats intégrés à sa surveillance d’uptime, avec /fail pour signaler un échec.

Côté Pinguro, un monitor de type Heartbeat a trois réglages : un nom, un intervalle (de 1 minute à 43 200 minutes, soit 30 jours) et un délai de grâce (de 0 à 1 440 minutes, soit 24 heures). Il fournit une adresse de la forme :

https://pinguro.app/h/<jeton>

Le jeton fait 32 caractères hexadécimaux. L’adresse accepte GET et POST, répond OK en texte brut, 404 Token invalide sinon, et limite à 10 appels par minute et par jeton. Le jeton peut être régénéré s’il a fuité.

Ce que Pinguro ne fait pas aujourd’hui : pas de ping de début, pas de signal d’échec explicite, pas de code de sortie. Il n’y a qu’un signal, « j’ai réussi ». Si vous voulez mesurer la durée des exécutions ou recevoir une alerte immédiate sur échec, Healthchecks.io ou Cronitor le font.

Dernier détail utile : un heartbeat qui n’a jamais reçu de ping reste en attente pendant 24 heures après sa création, pour laisser le temps de le brancher. Passé ce délai, il passe en panne.

Choisir la période et le délai de grâce

La règle que j’applique :

  • intervalle = période de la tâche ;
  • délai de grâce = durée normale d’exécution + marge pour tout ce qui peut décaler le passage : décalage aléatoire du timer (RandomizedDelaySec), redémarrage automatique nocturne du serveur, rattrapage après une coupure.

L’alerte part quand le temps écoulé depuis le dernier ping dépasse intervalle + grâce. Mes heartbeats réels :

Tâche Déclenchement Intervalle Grâce
Sauvegarde nocturne chaque nuit 02:30 UTC 24 h 2 h
Scan de sécurité lundi 07:00 UTC 7 j (10 080 min) 6 h
Test de restauration le 2 et le 16 du mois, 05:00 UTC 30 j 1 j
Publication de ce site chaque jour 05:05 UTC 24 h 8 h

Le décalage aléatoire se voit directement dans systemctl list-timers, qui liste les timers avec leur dernier et leur prochain passage (extrait) :

$ systemctl list-timers
NEXT                            LEFT LAST                              PASSED UNIT
Wed 2026-09-30 02:34:10 UTC      18h Tue 2026-09-29 02:30:17 UTC 5h 14min ago monprojet-sauvegarde.timer
Mon 2026-10-05 07:04:38 UTC   5 days Mon 2026-09-28 07:00:23 UTC      24h ago scan-securite.timer
…

La sauvegarde est réglée sur 02:30, mais son prochain passage est à 02:34:10 : c’est le RandomizedDelaySec=5min. Avec un TimeoutStartSec=30min en plus, 2 heures de grâce couvrent largement le pire cas. Le scan tourne le lundi matin ; 6 heures couvrent un passage retardé sans laisser traîner une vraie panne.

Un délai trop court donne de fausses alertes, et une alerte qui sonne pour rien finit ignorée. Un délai trop long retarde la détection d’autant. Pour une sauvegarde quotidienne, découvrir le problème en 26 heures au lieu de 24 est sans conséquence ; le découvrir au bout d’une semaine, si.

Le piège du « une fois par mois »

Le test de restauration automatique (décrit dans tester la restauration d’une sauvegarde automatiquement) devait tourner une fois par mois. J’ai créé son heartbeat avec les maximums de Pinguro : 30 jours d’intervalle, 1 jour de grâce. Soit 31 jours avant l’alerte.

Sauf qu’entre le 2 janvier et le 2 février, il y a 31 jours. Le ping arrive à la fin du test, donc 31 jours plus quelques minutes après le précédent si le test dure un peu plus longtemps. Chaque mois de 31 jours devenait une fausse alerte potentielle, à la minute près.

Deux solutions : un intervalle plus long (Pinguro plafonne à 30 jours), ou une tâche plus fréquente. J’ai choisi la seconde : le test tourne maintenant le 2 et le 16 de chaque mois.

# /etc/systemd/system/monprojet-test-restauration.timer
[Unit]
Description=Test de restauration de la sauvegarde (2 fois par mois)

[Timer]
OnCalendar=*-*-02,16 05:00:00 UTC
Persistent=true

[Install]
WantedBy=timers.target

L’écart maximal entre deux passages est maintenant de 17 jours (du 16 d’un mois de 31 jours au 2 du suivant), très loin de la limite. Le monitor est resté à 30 j + 1 j ; le resserrer autour de 18 jours ferait détecter un passage manqué plus tôt.

La leçon générale : « tous les mois » n’est pas une période fixe. Si votre outil raisonne en durée, vérifiez l’écart maximal entre deux exécutions, pas l’écart moyen. Même remarque pour une tâche « les jours ouvrés » : l’écart du vendredi au lundi est de 3 jours.

Ne pinguer qu’en cas de succès

C’est la règle qui donne tout son sens au heartbeat. Une tâche qui échoue ne pingue pas ; l’alerte part à l’expiration du délai de grâce (en plus de l’alerte propre à la tâche, s’il y en a une).

Dans un script bash, le ping se place à la toute fin, après set -euo pipefail qui interrompt le script à la première erreur :

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

# ... la vraie tâche : dump, chiffrement, envoi ...

# Signe de vie, seulement si tout ce qui précède a réussi.
HEARTBEAT_URL="${HEARTBEAT_URL:-}"
if [ -n "$HEARTBEAT_URL" ]; then
    curl -fsS --max-time 10 --retry 3 -o /dev/null "$HEARTBEAT_URL" \
        || echo "heartbeat injoignable (tâche pourtant réussie)" >&2
fi

Les options de curl comptent :

  • -f : un code HTTP d’erreur (404 d’un jeton supprimé, par exemple) fait échouer curl au lieu d’être ignoré, et laisse une trace dans le journal. Un ping vers une adresse supprimée ne passe plus inaperçu.
  • -sS : silencieux, sauf les erreurs.
  • --max-time 10 : le ping ne bloque jamais la tâche plus de 10 secondes par tentative.
  • --retry 3 : nouvel essai sur les erreurs passagères (délai dépassé, 5xx).
  • || echo … : un ping raté est journalisé mais ne fait pas échouer une tâche réussie.

Voici ce que donne -f sur un jeton qui n’existe pas, et la même requête sans -f :

$ curl -fsS -m 10 -o /dev/null https://pinguro.app/h/00000000000000000000000000000000
curl: (22) The requested URL returned error: 404
$ echo $?
22

$ curl -s https://pinguro.app/h/00000000000000000000000000000000
Token invalide

Sans -f, curl affiche la réponse et sort en code 0 : pour le script, tout va bien. C’est exactement ainsi que mon ancien contrôle de sauvegarde a pingué une adresse morte sans que personne le voie.

Le if [ -n … ] rend le heartbeat facultatif : le même script tourne en test sans prévenir la surveillance.

Brancher un service systemd sans toucher au script

Sur mon VPS, les tâches planifiées sont des timers systemd (je consacre un article à pourquoi j’ai remplacé cron par des timers). Pour ajouter un heartbeat à un service existant, un complément (drop-in, un petit fichier qui s’ajoute à la configuration d’un service sans la modifier) suffit : pas besoin de toucher au script.

# /etc/systemd/system/scan-securite.service.d/heartbeat.conf
[Service]
# Fichier d'environnement qui contient HEARTBEAT_URL (600, root).
EnvironmentFile=-/etc/scan-securite.env
ExecStartPost=/bin/sh -c '[ -z "$HEARTBEAT_URL" ] || curl -fsS -m 10 --retry 2 "$HEARTBEAT_URL" >/dev/null || true'

Pourquoi ça marche : pour un service Type=oneshot, ExecStartPost= ne s’exécute que si la dernière commande ExecStart= a réussi (systemd.service). Un échec, un plantage ou un dépassement de TimeoutStartSec : pas de ping. Le || true final évite qu’un Pinguro injoignable fasse passer une tâche réussie en failed.

Installation :

sudo mkdir -p /etc/systemd/system/scan-securite.service.d
sudo cp heartbeat.conf /etc/systemd/system/scan-securite.service.d/
echo 'HEARTBEAT_URL=https://pinguro.app/h/<jeton>' | sudo tee -a /etc/scan-securite.env >/dev/null
sudo chmod 600 /etc/scan-securite.env
sudo systemctl daemon-reload
systemctl cat scan-securite.service   # le complément doit apparaître

systemctl cat affiche le service puis ses compléments, chacun précédé de son chemin. Sur mon serveur (extrait) :

# /etc/systemd/system/scan-securite.service
[Service]
Type=oneshot
ExecStart=/srv/outils/scan-securite.sh
SuccessExitStatus=1
TimeoutStartSec=30min
…

# /etc/systemd/system/scan-securite.service.d/heartbeat.conf
[Service]
EnvironmentFile=-/etc/scan-securite.env
ExecStartPost=/bin/sh -c '[ -z "$HEARTBEAT_URL" ] || curl -fsS -m 10 --retry 2 "$HEARTBEAT_URL" >/dev/null || true'

Si la seconde partie n’apparaît pas, le complément n’est pas lu (mauvais nom de dossier, daemon-reload oublié) et aucun ping ne partira.

L’adresse de ping n’est pas un secret critique : elle permet seulement de dire « j’ai tourné ». Mais quelqu’un qui la connaît peut faire croire qu’une tâche tourne alors qu’elle est morte. Elle reste donc hors de git, dans le fichier d’environnement.

Un détail sur le scan de sécurité : son service déclare SuccessExitStatus=1, parce que le script sort en code 1 quand il a trouvé quelque chose à signaler. Ce code compte comme un succès pour systemd, donc le ping part aussi dans ce cas. Le journal du passage du 28/09 le montre :

$ journalctl -u scan-securite.service
2026-09-28T07:00:23+00:00 systemd[1]: Starting scan-securite.service - Controle de securite hebdomadaire...
2026-09-28T07:00:41+00:00 scan-securite.sh[1607249]: scan-securite: alerte envoyee (1 point(s))
2026-09-28T07:00:42+00:00 systemd[1]: scan-securite.service: Deactivated successfully.
2026-09-28T07:00:42+00:00 systemd[1]: Finished scan-securite.service - Controle de securite hebdomadaire.

Le scan a trouvé un point à signaler et envoyé son alerte, mais le service est terminé « successfully », donc le heartbeat a été pingué. C’est voulu : le heartbeat dit « le scan a tourné », pas « le scan n’a rien trouvé ». Ce sont deux alertes distinctes, et c’est au scan d’envoyer la sienne.

Si votre outil sait recevoir un signal d’échec, ExecStopPost= est une autre option : il s’exécute dans tous les cas et reçoit $SERVICE_RESULT, qui vaut success si tout s’est bien passé. OnSuccess= (systemd 249 et plus) peut aussi lancer une unité dédiée au ping. Pour un simple « j’ai réussi », ExecStartPost= reste le plus court.

En crontab

Sur une machine avec cron, l’enchaînement && fait le même travail : le ping ne part que si la tâche sort en code 0.

# crontab -e
30 2 * * * /usr/local/bin/sauvegarde.sh && curl -fsS -m 10 --retry 3 -o /dev/null https://pinguro.app/h/<jeton>

Deux remarques. L’adresse est alors lisible dans la crontab : sur une machine partagée, préférez un script qui lit un fichier d’environnement. Et vérifiez que cron tourne vraiment. Sur mon VPS, voici ce que ça donnait :

$ systemctl is-active cron
inactive
$ dpkg -s cron
dpkg-query: package 'cron' is not installed and no information is available

Le paquet n’était pas installé du tout sur l’image Debian 13 de mon hébergeur. Un fichier posé dans /etc/cron.d/ n’y était donc lu par personne.

Dans GitHub Actions

Les workflows planifiés méritent un heartbeat plus que tout le reste. La documentation de GitHub le dit : l’événement schedule peut être retardé en cas de forte charge, certains jobs peuvent être abandonnés, et dans un dépôt public les workflows planifiés sont désactivés après 60 jours sans activité.

name: rapport-nocturne
on:
  schedule:
    - cron: "15 3 * * *"   # UTC
  workflow_dispatch:

jobs:
  rapport:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - run: ./scripts/rapport.sh
      - name: Signe de vie
        if: success()
        run: curl -fsS -m 10 --retry 3 -o /dev/null "$HEARTBEAT_URL"
        env:
          HEARTBEAT_URL: ${{ secrets.HEARTBEAT_URL }}

if: success() est le comportement par défaut d’une étape, mais l’écrire rend l’intention explicite. L’adresse va dans les secrets du dépôt, jamais dans le fichier.

Ce site en est l’exemple vécu. Il est reconstruit par un workflow planifié à 05:15 et 09:45 UTC, pour publier les articles programmés. En trois jours, le planificateur de GitHub n’a lancé que trois passages : le 27/09 à 10:33, le 28/09 à 11:41 et à 17:49. Rien le 29/09 au matin, jour de parution d’un article.

Et le heartbeat n’a rien dit, pour une raison que je n’avais pas vue : il était pingué à la fin de chaque construction réussie, y compris celles lancées par un git push. Ces jours-là, je poussais plusieurs fois par jour, et chaque push rassurait la surveillance à la place du planificateur. Le piège général : un heartbeat doit être pingué par la tâche qu’il surveille, et par elle seule. Sinon, une autre source peut masquer sa disparition.

J’ai corrigé les deux. Un timer systemd sur le VPS déclenche désormais le workflow chaque matin par l’API de GitHub (workflow_dispatch, exécuté immédiatement), et seul ce déclenchement pingue le heartbeat :

      - name: Signe de vie
        if: success() && github.event_name == 'workflow_dispatch'

Si vous gardez schedule, comptez une grâce généreuse (plusieurs heures) et ne pinguez que sur github.event_name == 'schedule'.

Mesurer la durée : ping de début et de fin

Certains outils acceptent un ping de début. Avec Healthchecks.io, on appelle <url>/start au lancement et <url> à la fin ; l’outil en déduit la durée de chaque exécution et peut alerter si une tâche démarrée ne finit jamais (documentation) :

URL="https://hc-ping.com/<uuid>"
curl -fsS -m 10 --retry 3 -o /dev/null "$URL/start"
/usr/local/bin/sauvegarde.sh
curl -fsS -m 10 --retry 3 -o /dev/null "$URL"

C’est utile pour repérer une sauvegarde qui passe de 5 à 40 minutes : elle réussit encore, mais quelque chose grossit. Pinguro n’a pas ce signal aujourd’hui ; sur mon VPS, c’est TimeoutStartSec qui tue une tâche bloquée, et l’absence de ping fait le reste.

Vérifier le branchement pour de vrai

Un heartbeat non vérifié ne vaut rien : mon ancien contrôle de sauvegarde pinguait une adresse morte. Après chaque branchement, lancer la tâche à la main et lire son journal :

sudo systemctl start monprojet-sauvegarde.service
journalctl -u monprojet-sauvegarde.service -n 20 --no-pager

Un passage réussi de ma sauvegarde nocturne ressemble à ceci (extrait) :

02:30:24 sauvegarde-b2.sh: archive chiffrée : monprojet-2026-09-29T0230Z.tar.zst.gpg (9990131 octets)
02:30:26 sauvegarde-b2.sh: envoyé : sauvegardes/quotidiennes/monprojet-2026-09-29T0230Z.tar.zst.gpg
02:30:26 sauvegarde-b2.sh: sauvegarde réussie
02:30:26 systemd[1]: monprojet-sauvegarde.service: Deactivated successfully.

Aucune ligne heartbeat injoignable : le ping est parti. Il reste à constater dans l’outil que le monitor est passé UP avec l’heure du ping. C’est ce que j’ai fait pour chaque tâche ; pour le test de restauration, j’ai attendu un passage réel du timer.

Tenez aussi un inventaire : une ligne par tâche, avec sa fréquence, son monitor et son état. Une tâche ajoutée sans heartbeat, c’est de nouveau une tâche qui peut mourir en silence.

Qui surveille le surveillant ?

Pinguro tourne sur le même VPS que les tâches qu’il surveille. Si le serveur tombe, il ne peut pas alerter de l’absence des pings. Ce cas est couvert par une surveillance externe : UptimeRobot interroge https://pinguro.app/readyz et alerte par email si le serveur, la base ou le worker ne répond plus. L’un surveille le surveillant, l’autre surveille le reste. Pour la même raison, l’alerte d’un heartbeat doit passer par un canal qui ne dépend pas de la machine surveillée.

À retenir

  • Une tâche qui ne tourne plus ne génère aucune erreur : seul un heartbeat qui attend son ping la détecte.
  • Intervalle = période de la tâche ; grâce = durée normale + marge (décalage aléatoire, redémarrage, rattrapage).
  • « Tous les mois » dure jusqu’à 31 jours : calculez l’écart maximal entre deux exécutions, pas l’écart moyen.
  • Pinguez seulement en cas de succès : dernière ligne d’un script en set -e, && en crontab, ExecStartPost= en systemd, if: success() en CI.
  • Un heartbeat ne doit être pingué que par la tâche qu’il surveille : une autre source masque sa disparition.
  • curl -fsS --max-time 10 --retry 3 : -f pour voir les 404, un délai pour ne jamais bloquer la tâche.
  • Testez chaque branchement par un passage réel, et faites surveiller votre outil de surveillance depuis l’extérieur.
PartagerLinkedInBlueskyXRedditHacker News

Commentaires et réactions

Les commentaires sont hébergés par GitHub Discussions, via giscus. Rien n'est chargé avant ce clic.