Bonjour à tous, quand on administre un serveur Linux qui héberge une vingtaine de conteneurs Docker en production, on finit vite écartelé entre deux extrêmes. Soit on délègue tout à des mises à jour automatiques aveugles (ce que je vous racontais récemment dans mon article sur MariaDB et les surprises de Watchtower et comment ça peut casser silencieusement un schéma de base de données), soit on reprend le contrôle en manuel… au risque d’oublier de vérifier les correctifs pendant trois semaines et de se faire poncer bien comme il faut ! Pour sortir de ce dilemme, j’ai construit un script de veille serveur, léger, en Python, piloté par systemd, qui inspecte l’état réel de la machine deux fois par jour sans jamais toucher à la production.
L’idée de départ est très simple : je voulais un observateur strictement en lecture seule (observer-only), capable de croiser en quelques secondes les paquets APT en attente, les images Docker (qu’elles viennent de Docker Hub, de GitHub ou d’un build local), les extensions WordPress, la fraîcheur des sauvegardes et les unités systemd. Surtout, je voulais qu’il respecte une règle d’or : se taire quand tout va bien, mais ne jamais rater une vraie mise à jour de sécurité.
Bon, sur le papier, écrire un collecteur en Python appelé par un timer systemd ressemble à un bricolage d’une heure. En pratique, entre un docker ps qui bloquait mystérieusement au bout de douze secondes, des correctifs CVE Docker qui se cachent hors des dépôts de sécurité Debian, et une virgule systemd un peu trop créative… La mise au point a réservé quelques belles surprises. Je vous partage la méthode, les trois pièges dans lesquels je suis tombé, et le code pour mettre ça en place chez vous.
Pourquoi un script de veille serveur en lecture seule (et muet quand tout va bien) ?
On a tous bricolé un jour une tâche cron qui envoie chaque matin un rapport complet par e-mail. Et on connaît tous la suite : au bout de quinze jours à recevoir « Status OK, 0 mise à jour disponible », on ne lit plus le mail. Et au bout d’un mois, on crée une règle dans son client mail pour ranger automatiquement ces messages dans un sous-dossier obscur. Et le jour où le disque racine passe à 92 % d’occupation ou qu’une unité systemd plante, l’alerte part directement aux oubliettes.
Pour que ce script de veille serveur reste réellement utile dans la durée, je l’ai découpé en deux briques bien distinctes : un collecteur (collector.py) qui produit un état structuré (status.json et un résumé Markdown), et un orchestrateur (runner.py) qui applique quatre règles de notification inspirées de la philosophie Unix :
- Silence radio sur le succès (quiet on success) : si tous les contrôles sont au vert (
OK) et qu’aucune mise à jour APT, Docker ou WordPress n’est en attente, le script écrit une ligne dans son journal d’audit local (audit.jsonl) et quitte avec le code0. Aucun mail n’est envoyé. - Alerte actionnable sur changement : dès qu’un contrôle passe en
WARN/CRITou qu’une mise à jour apparaît, un e-mail part avec le résumé exécutif et le runbook associé. - Anti-fatigue par empreinte SHA-256 : si cinq paquets arrivent le mardi matin mais que je prévois de les appliquer le mercredi soir, je n’ai pas envie de recevoir quatre fois le même mail. Le runner calcule un condensat SHA-256 de l’état exact des mises à jour et des alertes, et bloque les doublons pendant 24 heures. En revanche, si un sixième paquet ou une CVE s’ajoute entre-temps, l’empreinte change et la notification repart immédiatement.
- Dead man’s switch intégré : un script silencieux parce que tout va bien, c’est parfait ; un script silencieux parce qu’il plante en boucle depuis trois jours, c’est dangereux. Si
collector.pydépasse son délai de 120 secondes d’exécution ou lève une exception Python, lerunner.pyintercepte la sortie d’erreur et envoie un mail[CRIT] Observer Failedavec la trace complète.
Autre choix assumé : même si j’utilise au quotidien un assistant IA en ligne de commande sur le serveur (voir Codex en terminal avec un daemon local), la tâche planifiée qui tourne toute seule deux fois par jour est du pur Python des familles bien déterministe, sans appel LLM et avec surtout zéro droit de modification sur le système. Elle observe, elle qualifie, elle alerte ; la décision de patcher ou de redémarrer un service reste 100 % à moi.
Trois pièges bien réels découverts en production
C’est justement grâce au dead man’s switch et à une passe de vérification manuelle que j’ai découvert trois comportements sournois que l’on croise rarement dans les tutoriels théoriques.
Piège n°1 : docker ps –format ‘{{json .}}’ et le calcul caché de taille OverlayFS
Pour lister proprement l’état des conteneurs en Python sans découper du texte à la hache, mon premier réflexe avait été d’appeler docker ps -a --format '{{json .}}' avec un timeout de sécurité de 12 secondes par commande externe. En test manuel un soir calme, ça passait. En exécution planifiée le matin à 06h00 UTC, la commande expirait systématiquement après 12 secondes, faisant remonter un état UNKNOWN sur toute la brique Docker !
La cause est bonne à connaître : lorsque vous demandez à Docker de sérialiser l’objet complet avec {{json .}}, le démon évalue tous les champs de la structure interne, y compris le champ .Size (l’équivalent de docker ps --size). Pour calculer cette valeur, Docker doit parcourir l’intégralité de la couche d’écriture OverlayFS de chaque conteneur. Sur un serveur qui fait tourner un conteneur Splunk ou Elasticsearch avec des dizaines de milliers de petits fichiers, l’appel met plus de 12 secondes…
La correction tient en une ligne : demander explicitement au moteur de template Go de Docker uniquement les quatre clés JSON utiles, sans jamais toucher à .Size :
# Avant : force le calcul disque OverlayFS (.Size) -> >12 secondes !
sudo -n docker ps -a --format '{{json .}}'
# Après : extraction ciblée sans calcul de taille -> 0,25 seconde
sudo -n docker ps -a --format '{"Names":{{json .Names}},"Status":{{json .Status}},"State":{{json .State}},"Image":{{json .Image}}}'Résultat : le temps d’inspection des 27 conteneurs est tombé à 0,25 seconde.
Piège n°2 : pourquoi le filtre Debian security rate 80 % des CVE sur un hôte Docker
Le deuxième piège est apparu quand cinq mises à jour APT se sont présentées un matin : containerd.io, docker-ce, docker-ce-cli, docker-ce-rootless-extras et docker-buildx-plugin. Dans sa première version, mon script vérifiait si la version candidate provenait de la branche trixie-security dans la sortie de apt-cache policy. Comme ces cinq paquets venaient du dépôt officiel tiers download.docker.com (branche trixie/stable), le rapport affichait fièrement : « Security-flagged candidates: 0 ; all package candidates: 5 ».
Autant dire que se fier uniquement au nom du dépôt *-security donne un faux sentiment de sécurité dès qu’on utilise des dépôts éditeurs (Docker, CrowdSec, dépôts backports ou mises à jour de point-release Debian). Donc sans installer d’usine à gaz, j’ai ajouté une fonction d’enrichissement à double détente dans collector.py :
- Pour les paquets Debian (hors
trixie-security) : le script interrogeapt-get changelog <paquet>(qui fonctionne sans droit root), découpe uniquement les entrées comprises entre la version installée et la version candidate, et cherche par expression régulière les identifiantsCVE-YYYY-NNNN,DSA-NNNNou les en-têtesurgency=(high|critical|emergency). - Pour les dépôts tiers connus (Docker, containerd, CrowdSec) : le script associe le paquet à son dépôt GitHub officiel (
moby/moby,containerd/containerd,docker/buildx,crowdsecurity/crowdsec), récupère les notes de version via l’API publique GitHub Releases et extrait les identifiantsCVE-*etGHSA-*.
Dès que j’ai relancé le collecteur sur mes cinq paquets Docker « ordinaires », le changement de décor a été immédiat dans le rapport :
Security-flagged candidates: 4 (Debian trixie-security: 0); all package candidates: 5.
- [SECURITY] containerd.io (vendor:docker): 2.3.6 -> 2.4.1 : SECURITY (CVE-2026-53493, ghsa-pg57-6jwg-q645)
- [MAINT] docker-buildx-plugin (vendor:docker): 0.37.1 -> 0.38.0 : Maintenance / feature update (no CVE listed)
- [SECURITY] docker-ce (vendor:docker): 5:29.8.2 -> 5:29.9.0 : SECURITY (CVE-2026-56857, CVE-2026-78659, CVE-2026-78663, CVE-2026-78669, CVE-2026-97032)Sur les cinq mises à jour qui semblaient purement fonctionnelles, quatre corrigeaient en réalité six vulnérabilités ! Désormais, l’objet du mail affiche directement [WARN] 5 Update(s) Available (4 Security) et monte la priorité de recommandation en HIGH.
Piège n°3 : la virgule traîtresse de OnCalendar=06:00,18:00 UTC dans systemd
Le troisième piège est un grand classique de systemd qui mérite un post-it sur l’écran. Pour faire tourner l’observateur deux fois par jour (à 06h00 et 18h00 UTC), le fichier .timer contenait initialement la ligne suivante :
# Piège : ne tourne PAS à 06h00 et 18h00 !
OnCalendar=06:00,18:00 UTCEn regardant les journaux à 19h00, je me suis rendu compte que le passage de 18h00 n’avait jamais eu lieu. Pourquoi ? Parce que dans la grammaire de la documentation officielle systemd.time, un bloc horaire suit le format hh:mm:ss où les deux-points séparent les heures, les minutes et les secondes, tandis que la virgule énumère plusieurs valeurs à l’intérieur d’un même champ. Écrire 06:00,18:00 signifie donc : heure 06, minutes 00 et 18, seconde 00 ! Mon script tournait consciencieusement à 06h00 puis à… 06h18 du matin.
Avant de valider un timer systemd, prenez toujours dix secondes pour passer votre expression à la moulinette de systemd-analyze calendar, qui révèle immédiatement comment le parseur interprète votre règle :
# Vérifier les 4 prochaines exécutions avec la bonne syntaxe (06,18 dans le champ des heures)
systemd-analyze calendar --iterations=4 "*-*-* 06,18:00:00 UTC"
# Sortie validée :
# Normalized form: *-*-* 06,18:00:00 UTC
# Next elapse: Sun 2026-10-11 06:00:00 UTC
# Iteration #2: Sun 2026-10-11 18:00:00 UTCTrier les images Docker et sonder WordPress sans alourdir le serveur
Côté conteneurs, comparer toutes les images en cours d’exécution avec le tag latest de Docker Hub est le meilleur moyen de recevoir de fausses alertes permanentes. Dans ce script de veille serveur, j’ai donc classé les images suivies en trois familles :
- Les images amont épinglées (pinned upstream) : pour Nginx Proxy Manager, Authentik ou Portainer CE, le script interroge l’API Docker Hub v2 ou GitHub Releases pour comparer la version déployée avec la dernière release stable. Petite astuce pour Portainer lorsqu’il est déployé sur le tag glissant
portainer/portainer-ce:lts: un simpledocker exec portainer /portainer --versionpermet de lire la vraie version binaire active (par exemple2.45.2) et de la comparer au dernier tag publié. - Les images construites localement (custom builds) : mon conteneur PHP-FPM personnalisé (compilé avec Brotli et Redis) ou mon conteneur Kali n’existent pas sur un registre public, mais son basé sur un conteneur classique. Pour eux, le script inspecte la date de création locale de l’image (
docker image inspect --format '{{.Created}}') et la compare dynamiquement au dernier tag officielphp:8.5.x-fpm-bookwormou au digest dekalilinux/kali-rolling:latest. - Les bases de données : MariaDB suit uniquement la branche LTS officielle (actuellement
12.3.x), tandis que les bases PostgreSQL ou MongoDB embarquées dans une stack applicative restent volontairement verrouillées sur la version exigée par le fichier Compose de l’éditeur.
Même philosophie « zéro surcharge » pour vérifier si WordPress ou l’une de ses 19 extensions a une mise à jour en attente : plutôt que d’exécuter un binaire wp-cli qui charge toute la pile PHP à chaque passe, le collecteur lit directement les deux options de cache interne (transients) que WordPress maintient déjà dans MariaDB :
sudo -n docker exec geekeries_db sh -c \
'mariadb -u root -p"$MYSQL_ROOT_PASSWORD" wordpress -s -N -e \
"SELECT option_name, option_value FROM wp_options WHERE option_name IN (\"_site_transient_update_plugins\", \"_site_transient_update_core\");"'La requête répond en quelques millisecondes, ne génère aucun appel réseau externe supplémentaire et indique immédiatement le nombre et le nom des extensions à mettre à jour.
Mettre en place ce script de veille serveur chez vous (How-to)
Si vous souhaitez déployer ce script de veille serveur sur votre propre machine Debian/Ubuntu avec Docker, voici le squelette complet et reproductible, pensé pour rester simple (KISS) et sécurisé.
1. Le module d’enrichissement APT et CVE (extrait de collector.py)
Plutôt que de simplement compter les lignes de apt list --upgradable, voici la fonction Python autonome (sans dépendance externe hors bibliothèque standard) qui interroge apt-cache policy, les changelogs Debian et les notes de version GitHub pour qualifier chaque paquet en [SECURITY] ou [MAINT] :
import json, re, subprocess, urllib.request
VENDOR_GITHUB_REPOS = {
"containerd.io": "containerd/containerd",
"docker-ce": "moby/moby",
"docker-ce-cli": "moby/moby",
"docker-buildx-plugin": "docker/buildx",
"docker-compose-plugin": "docker/compose",
"crowdsec": "crowdsecurity/crowdsec",
}
def check_apt_security(pkg, installed, candidate, policy_output):
cves = set()
is_sec = "debian-security" in policy_output or "-security" in policy_output
# 1. Dépôts tiers (Docker, CrowdSec) via GitHub Releases API
repo = VENDOR_GITHUB_REPOS.get(pkg)
if repo:
url = f"https://api.github.com/repos/{repo}/releases?per_page=5"
req = urllib.request.Request(url, headers={"User-Agent": "sysadmin-observer/1.0"})
try:
with urllib.request.urlopen(req, timeout=5) as resp:
for rel in json.load(resp):
body = rel.get("body") or ""
for m in re.findall(r"\b(?:CVE-\d{4}-\d{4,7}|GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4})\b", body, re.I):
cves.add(m.upper())
except Exception:
pass
# 2. Paquets Debian via apt-get changelog (sans sudo)
else:
proc = subprocess.run(["apt-get", "changelog", pkg], capture_output=True, text=True, timeout=8)
if proc.returncode == 0 and proc.stdout:
delta = proc.stdout.split(f"({installed})")[0]
for m in re.findall(r"\b(?:CVE-\d{4}-\d{4,7}|DSA-\d{4,5}-\d+)\b", delta, re.I):
cves.add(m.upper())
if re.search(r"urgency=(?:high|critical|emergency)", delta, re.I):
is_sec = True
if cves:
is_sec = True
return is_sec, sorted(cves)2. Isoler le mot de passe SMTP et durcir l’unité systemd
Ne stockez jamais votre mot de passe SMTP en clair dans un script Python ni dans une variable d’environnement globale. Systemd propose une directive native très propre, LoadCredential, qui lit un fichier détenu par root (en mode 0400) et l’expose uniquement au processus du service dans un système de fichiers temporaire en mémoire ($CREDENTIALS_DIRECTORY/smtp_password) le temps de l’exécution :
# 1. Créer le secret SMTP accessible uniquement par root
sudo install -d -m 0700 /etc/sysadmin-observer
sudo sh -c 'printf "%s\n" "VOTRE_MOT_DE_PASSE_SMTP" > /etc/sysadmin-observer/smtp_password'
sudo chmod 0400 /etc/sysadmin-observer/smtp_passwordCôté Python dans runner.py, il suffit de lire le fichier injecté par systemd :
import os
from pathlib import Path
def load_smtp_password() -> str:
creds_dir = os.environ.get("CREDENTIALS_DIRECTORY")
if creds_dir:
secret_file = Path(creds_dir) / "smtp_password"
if secret_file.is_file():
return secret_file.read_text(encoding="utf-8").strip()
raise RuntimeError("SMTP credential unavailable")On crée ensuite le service /etc/systemd/system/sysadmin-observer.service (avec UMask=0077 pour que les rapports JSON/Markdown et le fichier audit.jsonl générés soient automatiquement privés en 0600) ainsi que son timer associé :
# /etc/systemd/system/sysadmin-observer.service
[Unit]
Description=Sysadmin Observer & Notifier
After=network-online.target docker.service
Wants=network-online.target
[Service]
Type=oneshot
User=debian
Group=debian
WorkingDirectory=/opt/sysadmin-observer
LoadCredential=smtp_password:/etc/sysadmin-observer/smtp_password
ExecStart=/usr/bin/python3 /opt/sysadmin-observer/runner.py
TimeoutStartSec=3min
PrivateTmp=yes
UMask=0077
# /etc/systemd/system/sysadmin-observer.timer
[Unit]
Description=Run Sysadmin Observer twice daily (06:00 and 18:00 UTC)
[Timer]
OnCalendar=*-*-* 06,18:00:00 UTC
RandomizedDelaySec=5m
Persistent=true
[Install]
WantedBy=timers.target3. Activation, test d’envoi et rollback
Avant d’activer le timer en production, validez la syntaxe calendaire, rechargez systemd et déclenchez une exécution manuelle pour vérifier le journal et la réception du mail :
# Vérifier le calendrier, activer le timer et lancer un test immédiat
systemd-analyze calendar --iterations=2 "*-*-* 06,18:00:00 UTC"
sudo systemctl daemon-reload
sudo systemctl enable --now sysadmin-observer.timer
sudo systemctl start sysadmin-observer.service
journalctl -u sysadmin-observer.service -n 15 --no-pagerEt si vous souhaitez désactiver la veille automatique ou revenir en arrière (pendant une grosse maintenance par exemple), le rollback est immédiat et sans impact sur les services hébergés :
sudo systemctl disable --now sysadmin-observer.timer
sudo systemctl reset-failed sysadmin-observer.serviceVerdict après mise en route
Ce que j’apprécie le plus avec cette approche, c’est l’équilibre entre visibilité et sérénité. Quand je reçois un e-mail de Pwny à 06h02 du matin, je sais avant même de l’ouvrir qu’il y a vraiment quelque chose à regarder, et je vois dès l’objet du message si l’on parle d’une simple mise à jour de confort ou de quatre paquets corrigeant des CVE. À l’inverse, une fois les cinq paquets Docker patchés cet après-midi (sans même avoir besoin de rebooter le serveur, d’ailleurs !), l’exécution suivante a simplement logué silent_nominal dans audit.jsonl et m’a laissé tranquille.
Et vous, comment gérez-vous la veille des mises à jour APT et Docker sur vos serveurs personnels sans finir noyés sous les notifications quotidiennes ? Vous êtes plutôt cron maison, outils dédiés comme Diun, ou vérification manuelle autour du café du dimanche matin ? Et rappelez vous, chez les geeks : on ne réfléchit pas on patch!


