Carnet technique
Entrée N° 003mis à jour le 10 min#linux #systemd #cron

Timers systemd : mon scan ne tournait pas, cron n'était pas installé

Un fichier /etc/cron.d que personne ne lisait : cron absent de Debian 13. Passer d'une ligne crontab à un timer systemd, pas à pas, et les pièges à éviter.

Le 20 septembre, j’ai installé un scan de sécurité hebdomadaire sur mon VPS : Trivy sur les images Docker, quelques contrôles de dérive de l’hôte, une alerte Telegram s’il y a du nouveau. Testé à la main de bout en bout, alerte reçue. Pour le déclenchement, j’ai déposé une ligne dans /etc/cron.d/scan-securite : lundi, 7 h UTC. Rien de plus classique.

Le 23, en vérifiant, surprise : le paquet cron n’est pas installé sur ce Debian 13. systemctl is-active cron répond inactive. Le fichier de /etc/cron.d/ était là, bien formé, et personne ne le lisait. Le lundi 21 à 7 h était passé sans le moindre scan. Aucune erreur, aucun journal, rien : un fichier de configuration sans programme pour le lire ne se plaint pas.

J’ai remplacé la ligne cron par un timer systemd. Cet article montre comment convertir une ligne crontab en couple .timer + .service, ce que ça apporte, et les quatre pièges que j’ai rencontrés ou évités de justesse.

La version courte

  • Le problème : une tâche planifiée déclarée dans un fichier cron ne tourne que si le programme cron est installé et en marche. Sinon, rien ne se passe, et rien ne le signale.
  • La solution : passer par un timer systemd, déjà présent sur toute distribution qui démarre avec systemd, et qui donne un journal, un état et un rattrapage des passages manqués.
  • La vérification : systemctl list-timers doit montrer la prochaine échéance. Un fichier sur le disque ne prouve rien.

Trois notions avant de commencer

  • Démon. Un programme qui tourne en permanence en arrière-plan. Cron en est un : il lit ses fichiers et lance les tâches à l’heure dite.
  • systemd. Le programme qui démarre Debian, Ubuntu et la plupart des distributions, et qui gère tous les services. Il sait aussi lancer des tâches à heure fixe.
  • Unité. Un fichier de configuration de systemd. Un .service décrit ce qui s’exécute, un .timer décrit quand.

Une image : une boîte aux lettres. Déposer une lettre dans /etc/cron.d/, c’est la glisser dans la boîte. Si aucun facteur ne passe, la lettre reste là, bien adressée, et personne ne vous prévient qu’elle n’est jamais partie.

Pourquoi un fichier cron peut ne jamais s’exécuter sans rien dire

Cron, c’est un démon. Les fichiers de /etc/cron.d/, /etc/crontab ou /var/spool/cron/ ne sont que des données qu’il lit. Pas de démon, pas d’exécution, et personne pour signaler l’anomalie.

Avec crontab -e, on aurait eu un indice : crontab: command not found. Mais en écrivant directement dans /etc/cron.d/ avec sudo tee, aucune commande cron n’est appelée. Le fichier se crée comme n’importe quel fichier texte.

Sur l’image Debian 13 livrée par mon hébergeur, cron n’y était pas. Sur un Debian installé depuis l’ISO avec les « utilitaires usuels du système », il est souvent présent : ne supposez rien, vérifiez.

# Le paquet est-il installé ?
dpkg-query -W -f='${Status}\n' cron

# Le démon tourne-t-il ?
systemctl is-active cron

Sur mon VPS :

$ dpkg-query -W -f='${Status}\n' cron
unknown ok not-installed
$ systemctl is-active cron
inactive

Attention à la première : elle sort en code 0 même quand le paquet est absent. C’est le texte qui compte (install ok installed s’il est là), pas le code de retour. dpkg -s cron, lui, sort en erreur.

J’aurais pu faire apt install cron et passer à autre chose. Mais systemd est déjà là, déjà en PID 1, et mes sauvegardes nocturnes tournaient déjà avec un timer. Autant n’avoir qu’un seul mécanisme à surveiller.

Ce qu’un timer systemd apporte par rapport à cron

  • Un journal par tâche. Tout ce que le script écrit sur stdout et stderr part dans le journal : journalctl -u scan-securite. Plus besoin de >> /var/log/truc.log 2>&1 ni de MAILTO.
  • Un état. systemctl status scan-securite.service dit quand la tâche a tourné, combien de temps, et avec quel code de sortie. Un échec met le service en failed, visible dans systemctl --failed.
  • Le rattrapage. Avec Persistent=true, si la machine était éteinte à l’heure prévue, la tâche part au démarrage suivant. Cron saute simplement l’exécution.
  • L’étalement. RandomizedDelaySec= ajoute un délai aléatoire, pour ne pas lancer toutes les tâches à la même seconde.
  • Les dépendances. After=docker.service et Requires=docker.service : la tâche ne démarre pas avant Docker.
  • Un lancement manuel identique au lancement planifié. systemctl start scan-securite.service exécute la tâche avec exactement le même environnement que le timer. Fini le « ça marche dans mon terminal mais pas dans cron » à cause d’un PATH différent.
  • Une vue d’ensemble. systemctl list-timers liste toutes les échéances, la dernière et la prochaine.

Le prix : deux fichiers au lieu d’une ligne, et une syntaxe de calendrier à apprendre. On peut vivre avec.

Tutoriel : convertir une ligne crontab en timer systemd

Mon point de départ, la ligne de /etc/cron.d/scan-securite (format cron.d, avec le champ utilisateur) :

# minute heure jour-du-mois mois jour-de-semaine utilisateur commande
0 7 * * 1 root /srv/outils/scan-securite.sh

Autrement dit : chaque lundi à 7 h 00 heure locale du serveur (UTC chez moi), en root.

1. Le service : ce qui s’exécute

Le vrai fichier de mon VPS :

# /etc/systemd/system/scan-securite.service
[Unit]
Description=Controle de securite hebdomadaire

[Service]
Type=oneshot
User=root
ExecStart=/srv/outils/scan-securite.sh
# Le script sort en code 1 des qu'il a trouve quelque chose a signaler.
# Ce n'est pas un echec du service : l'alerte est deja partie sur Telegram.
SuccessExitStatus=1
Nice=10
IOSchedulingClass=idle
TimeoutStartSec=30min

Quelques remarques :

  • Type=oneshot : le service est une tâche qui démarre, travaille et se termine. systemd attend la fin de ExecStart avant de le considérer comme terminé.
  • Pas de section [Install] : ce service n’est jamais activé directement, c’est le timer qui le déclenche.
  • User=root : le scan a besoin de root (il lit la config SSH, interroge Docker, ufw). C’est la valeur par défaut, mais l’écrire rend le choix visible. Pour une tâche qui n’en a pas besoin, mettez User=deploy : mes sauvegardes tournent ainsi.
  • Nice=10 et IOSchedulingClass=idle : le scan passe après le reste du serveur, processeur et disque.
  • TimeoutStartSec=30min : avec Type=oneshot, il n’y a pas de délai par défaut, un script coincé resterait « en cours » indéfiniment.
  • Pas de dépendance à Docker (After=docker.service) : le scan interroge Docker, mais s’il n’est pas démarré, le scan doit le signaler, pas attendre en silence.

SuccessExitStatus=1 mérite sa propre section, plus bas.

2. Le timer : quand ça s’exécute

# /etc/systemd/system/scan-securite.timer
[Unit]
Description=Declenche le controle de securite hebdomadaire

[Timer]
OnCalendar=Mon *-*-* 07:00:00 UTC
Persistent=true
RandomizedDelaySec=5min

[Install]
WantedBy=timers.target

Le timer active par défaut le service de même nom (scan-securite.timer → scan-securite.service). Pour un autre nom, il y a Unit=.

La correspondance entre la syntaxe cron et OnCalendar (format JourSemaine Année-Mois-Jour Heure:Minute:Seconde) :

cron OnCalendar
0 7 * * 1 Mon *-*-* 07:00:00
30 2 * * * *-*-* 02:30:00
0 5 2,16 * * *-*-02,16 05:00:00
*/15 * * * * *:0/15
0 0 * * 0 Sun *-*-* 00:00:00

Il existe aussi des raccourcis : daily, weekly, monthly. Attention, weekly signifie lundi à minuit, heure locale.

3. Vérifier l’expression avant de l’installer

systemd-analyze calendar normalise l’expression et calcule les prochaines échéances. C’est l’équivalent d’un simulateur de crontab, mais officiel et local :

systemd-analyze calendar --iterations=2 'Mon *-*-* 07:00:00 UTC'
Normalized form: Mon *-*-* 07:00:00 UTC
    Next elapse: Mon 2026-09-28 07:00:00 UTC
       From now: 1 day 9h left
   Iteration #2: Mon 2026-10-05 07:00:00 UTC
       From now: 1 week 1 day left

Si l’expression est invalide, la commande le dit tout de suite, au lieu de le découvrir un lundi.

4. Installer et activer

sudo systemctl daemon-reload

# On teste le service à la main D'ABORD : même environnement que le timer
sudo systemctl start scan-securite.service
systemctl status scan-securite.service --no-pager
journalctl -u scan-securite.service -n 20 --no-pager

# Puis on active le TIMER (pas le service)
sudo systemctl enable --now scan-securite.timer

Chez moi, le lancement manuel est sorti en succès et le journal a affiché la dernière ligne du script : scan-securite: rien de nouveau a signaler. Le script l’écrit sur stderr, systemd le range dans le journal sans rien configurer.

5. Vérifier que le timer est vraiment armé

systemctl list-timers scan-securite.timer

Sur mon serveur, le 29 septembre :

NEXT                        LEFT   LAST                         PASSED       UNIT                ACTIVATES
Mon 2026-10-05 07:04:38 UTC 5 days Mon 2026-09-28 07:00:23 UTC 1 day 2h ago scan-securite.timer scan-securite.service

LAST : le passage du lundi 28 a bien eu lieu. NEXT : le prochain est à 07:04:38, pas 07:00:00, à cause du RandomizedDelaySec=5min, tiré au hasard à chaque fois.

La commande doit afficher une ligne avec la colonne NEXT renseignée. Si le timer n’apparaît pas, il n’est pas actif, quel que soit le fichier présent sur le disque. C’est exactement la leçon de l’épisode cron : un fichier de planification ne prouve rien, seul l’état du planificateur compte.

6. Retirer l’ancienne planification

sudo rm /etc/cron.d/scan-securite

Même inerte, je l’ai supprimé : le jour où quelqu’un installe cron, le scan tournerait deux fois.

SuccessExitStatus : ne pas confondre alerte et crash

Mon script a trois issues :

  • exit 0 : rien de nouveau, pas d’alerte ;
  • exit 1 : il a trouvé quelque chose et a envoyé l’alerte ;
  • exit 2 : il a trouvé quelque chose mais l’URL de notification est absente de sa configuration, donc personne n’est prévenu.

Sans SuccessExitStatus=1, chaque alerte passerait le service en failed. Résultat : failed voudrait dire tantôt « le scan a fait son travail et a trouvé un problème », tantôt « le scan a planté ». Un état qui mélange les deux devient illisible, et on finit par l’ignorer.

Avec cette ligne, failed ne signifie plus qu’une chose : le script n’a pas pu faire son travail (plantage, code 2, tué). Le passage du 28 septembre l’illustre : le scan a trouvé un point à signaler, envoyé son alerte et sorti en code 1. systemctl status le lendemain (extrait) :

○ scan-securite.service - Controle de securite hebdomadaire
     Loaded: loaded (/etc/systemd/system/scan-securite.service; static)
    Drop-In: /etc/systemd/system/scan-securite.service.d
             └─heartbeat.conf
     Active: inactive (dead) since Mon 2026-09-28 07:00:42 UTC; 1 day 2h ago
TriggeredBy: ● scan-securite.timer
   Main PID: 1607249 (code=exited, status=1/FAILURE)

status=1/FAILURE rappelle le code de sortie, mais l’état est inactive (dead), pas failed : pour systemd, la tâche s’est terminée normalement. static veut dire « pas de section [Install] », et TriggeredBy montre le timer qui le déclenche. C’est aussi ce qui permet de brancher proprement une surveillance dessus : un ExecStartPost= ne s’exécute que si ExecStart a réussi, codes de SuccessExitStatus compris.

Le détail est dans la documentation de systemd.service.

Les pièges des timers systemd

OnCalendar et le fuseau horaire

Sans fuseau explicite, OnCalendar utilise le fuseau local de la machine. Cron aussi, mais on l’oublie facilement quand on change le fuseau d’un serveur ou qu’on copie un timer d’une machine à l’autre.

J’écris donc toujours le fuseau dans l’expression : UTC pour une heure fixe, ou un nom IANA pour suivre les changements d’heure :

systemd-analyze calendar 'Mon 07:00 Europe/Paris'
  Original form: Mon 07:00 Europe/Paris
Normalized form: Mon *-*-* 07:00:00 Europe/Paris
    Next elapse: Mon 2026-09-28 05:00:00 UTC
       From now: 1 day 7h left

La syntaxe est décrite dans systemd.time(7).

Persistent=true : rattrapage, et pas plus

Persistent=true enregistre sur disque la date du dernier déclenchement. Au démarrage, si une échéance a été manquée pendant l’arrêt, le service part immédiatement. Pour une sauvegarde ou un scan, c’est ce qu’on veut.

Deux nuances :

  • Le rattrapage ne concerne que les timers OnCalendar=. Les timers relatifs (OnBootSec=, OnUnitActiveSec=) ne sont pas concernés.
  • Il ne rattrape qu’une exécution. Une machine éteinte trois semaines lance le scan une fois au redémarrage, pas trois.

Et évidemment, il ne rattrape rien si le timer n’est pas actif. Ce qui mène au piège suivant.

Activer le .timer, pas le .service

Réflexe venu de tous les autres services : systemctl enable --now scan-securite.service. Deux problèmes :

  1. enable n’active rien et se contente d’un avertissement, car le service n’a pas de section [Install]. Le voici, et la commande sort pourtant en code 0 (extrait) :

    The unit files have no installation config (WantedBy=, RequiredBy=, UpheldBy=,
    Also=, or Alias= settings in the [Install] section, and DefaultInstance= for
    template units). This means they are not meant to be enabled or disabled using systemctl.
  2. --now lance le scan une fois, tout de suite, ce qui donne l’illusion que tout marche.

Le timer, lui, reste inactif, et plus rien ne se passe. C’est le même silence que le fichier cron orphelin. Ce qu’il faut activer, c’est le .timer. La vérification par systemctl list-timers n’est pas optionnelle.

Autre oubli classique : modifier le .timer sans systemctl daemon-reload. systemd continue d’utiliser l’ancienne version chargée en mémoire.

Les secrets : EnvironmentFile en 600, pas Environment=

Mon scan a besoin d’une URL de webhook Telegram, qui contient le jeton du bot. Tentation : Environment=NOTIFICATION_WEBHOOK_URL=... dans le .service. Mauvaise idée : les fichiers de /etc/systemd/system/ sont lisibles par tous, et systemctl show affiche les variables.

La bonne pratique : un fichier à part, lisible par root seul.

sudo install -m 600 -o root -g root /dev/null /etc/scan-securite.env
sudoedit /etc/scan-securite.env
# Dans le .service, section [Service]
EnvironmentFile=/etc/scan-securite.env

Le résultat sur mon serveur :

$ ls -l /etc/scan-securite.env
-rw------- 1 root root 200 Sep 23 23:29 /etc/scan-securite.env

C’est systemd (donc root) qui lit ce fichier avant de lancer le processus : il peut rester en 600 root:root même si le service tourne avec User=deploy. Avec un tiret devant (EnvironmentFile=-/etc/...), l’absence du fichier n’est pas une erreur.

Dans mon cas, le script lit aussi ce fichier lui-même (utile pour le lancer à la main avec --tout), et j’y ai ajouté plus tard l’URL de heartbeat, chargée par un complément scan-securite.service.d/heartbeat.conf qui utilise justement EnvironmentFile=.

Et quand le timer lui-même ne tourne plus ?

Tout ce qui précède améliore la visibilité sur la machine. Mais le scénario de départ, une tâche qui ne tourne pas, ne produit par définition aucun journal ni aucune alerte locale. La seule détection fiable vient de l’extérieur : la tâche signale chaque passage réussi, et c’est l’absence de signal qui déclenche l’alerte. J’ai branché le scan de cette façon le lendemain ; je consacre un article à la surveillance des tâches cron par heartbeat.

À retenir

  • Un fichier dans /etc/cron.d/ ne prouve rien : sans démon cron, il ne se passe rien, et rien ne le signale. Vérifiez avec systemctl is-active cron.
  • Un timer systemd, c’est un .service (Type=oneshot, ce qui s’exécute) et un .timer (OnCalendar=, quand). Testez l’expression avec systemd-analyze calendar.
  • Activez le .timer avec enable --now, jamais le .service, puis vérifiez avec systemctl list-timers.
  • Écrivez le fuseau dans OnCalendar=, et mettez Persistent=true pour rattraper une échéance manquée.
  • SuccessExitStatus= sépare « la tâche a trouvé un problème » de « la tâche a planté ».
  • Secrets dans un EnvironmentFile= en 600, jamais dans le fichier d’unité.
  • Une tâche qui ne tourne plus se détecte de l’extérieur, par heartbeat.
PartagerLinkedInBlueskyXRedditHacker News

Commentaires et réactions

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