Général 7 min de lecture · 1 441 mots

Mes scripts de backup restic, écrits (et corrigés) avec Claude Code

Estimated reading time: 7 minutes

Proxmox Backup Server s’occupe déjà de mes VMs et LXC dans leur ensemble – j’en ai parlé dans un article précédent. Mais les données applicatives à l’intérieur (la base Vaultwarden, les documents Paperless-ngx, la photothèque Immich) méritent une sauvegarde séparée, à un endroit différent. Si heighliner prend feu littéralement, je veux pouvoir restaurer mes mots de passe et mes photos sans dépendre d’un seul snapshot PBS sur le même réseau.

J’ai écrit ce script avec Claude Code sur trois soirées. Voilà le résultat, et le bug qui serait passé inaperçu pendant des mois si je n’avais pas testé la restauration.

Le besoin

  • Sauvegarder les volumes Docker de Vaultwarden, Paperless-ngx et Immich (bases de données + fichiers) depuis heighliner
  • Destination : mon Synology DS414 (disques WD Red), en SMB monté sur le LXC
  • Rétention raisonnable : 7 jours, 4 semaines, 6 mois – pas besoin de plus pour un usage perso
  • Chiffré, parce que ça part sur un NAS partagé avec d’autres usages familiaux
  • Notification en cas d’échec, parce qu’un backup qui échoue silencieusement n’est pas un backup

Pourquoi restic plutôt qu’un simple rsync

J’utilisais rsync avant. Ça marchait, mais sans déduplication ni versionning : chaque backup était une copie complète, et je n’avais qu’un seul point de restauration à la fois sans y penser sérieusement.

Restic fait de la déduplication par blocs et garde un historique de snapshots consultable. Le prompt de départ à Claude Code :

J'ai besoin d'un script bash pour sauvegarder trois répertoires Docker
(/opt/vaultwarden/data, /opt/paperless/data, /opt/immich/upload) avec
restic vers un repository sur un partage SMB déjà monté sur
/mnt/nas-backup. Rétention : 7 daily, 4 weekly, 6 monthly. Le script
doit chiffrer avec un mot de passe lu depuis un fichier, pas en clair
dans le script. Notification par webhook Telegram en cas d'échec
uniquement (pas de bruit si tout va bien).

Le script généré, et ce que j’ai dû ajuster

Premier jet de Claude Code, propre syntaxiquement :

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

export RESTIC_REPOSITORY="/mnt/nas-backup/restic-repo"
export RESTIC_PASSWORD_FILE="/etc/restic/password"

TELEGRAM_WEBHOOK="$(cat /etc/restic/telegram-webhook)"
TARGETS=("/opt/vaultwarden/data" "/opt/paperless/data" "/opt/immich/upload")

notify_failure() {
    curl -s -X POST "$TELEGRAM_WEBHOOK" -d "text=Backup restic échoué : $1" >/dev/null
}

trap 'notify_failure "erreur ligne $LINENO"' ERR

restic backup "${TARGETS[@]}" --tag homelab-daily
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune

Correct dans l’idée. J’ai testé, ça a tourné, aucune erreur. Mais avant de le mettre en cron, j’ai demandé une deuxième passe :

Relis ce script avec un œil critique. Qu'est-ce qui pourrait échouer
silencieusement, malgré le set -euo pipefail et le trap ERR ?

Réponse qui m’a fait réfléchir :

« set -euo pipefail avec trap ERR capture les échecs de commandes qui retournent un code non-zéro, mais restic peut terminer avec un code 0 tout en signalant des avertissements dans sa sortie (fichiers ignorés, permissions refusées sur certains fichiers) sans faire échouer la commande globalement. Vérifiez --tag et la sortie de restic backup pour des lignes ‘error’ ou ‘Ignoring’ même quand le code de retour est 0. Par ailleurs, si un backup précédent a été interrompu (coupure réseau vers le NAS, par exemple), restic peut laisser un verrou (lock) actif sur le repository – la prochaine exécution attendra indéfiniment ou échouera selon la configuration, et votre trap ne le détectera pas forcément de façon claire. »

Le point du lock m’a interpellé – j’avais eu une coupure réseau vers le NAS deux semaines plus tôt, pile pendant une fenêtre de backup. Je ne l’avais jamais vérifié.

restic list locks
2 locks trouvés, dont un daté d'il y a 12 jours.

Douze jours. Le job cron avait continué à tourner chaque nuit, avait probablement échoué à chaque fois sans que mon trap le détecte correctement (parce que la commande restic backup avec un verrou bloquant peut se comporter différemment selon le contexte – timeout, retry silencieux, ou échec avec un message que mon trap ERR capturait mais dont le message n’atteignait jamais le webhook Telegram, à cause d’un bug distinct dans ma fonction notify_failure qui utilisait une variable pas encore définie à ce stade du script). Douze jours sans backup fonctionnel, et zéro notification.

Un trap ERR associé à set -e capture les échecs de commande, mais ne garantit pas que VOTRE code de notification s’exécute correctement dans tous les cas d’erreur – surtout si l’erreur survient avant que les variables nécessaires à la notification soient définies. Testez explicitement le chemin d’échec, pas seulement le chemin de succès.

Le script corrigé

#!/usr/bin/env bash
set -uo pipefail  # pas de -e ici : je gère les codes de retour explicitement

RESTIC_REPOSITORY="/mnt/nas-backup/restic-repo"
export RESTIC_REPOSITORY
export RESTIC_PASSWORD_FILE="/etc/restic/password"
TELEGRAM_WEBHOOK="$(cat /etc/restic/telegram-webhook)"
TARGETS=("/opt/vaultwarden/data" "/opt/paperless/data" "/opt/immich/upload")
LOG_FILE="/var/log/restic-backup.log"

notify_failure() {
    curl -s -X POST "$TELEGRAM_WEBHOOK" -d "text=Backup restic échoué : $1" >/dev/null 2>&1 || true
}

# Nettoyer un verrou orphelin de plus de 2h avant de commencer
STALE_LOCKS=$(restic list locks 2>/dev/null | wc -l)
if [ "$STALE_LOCKS" -gt 0 ]; then
    restic unlock 2>&1 | tee -a "$LOG_FILE"
    notify_failure "verrou orphelin détecté et supprimé avant le backup - vérifier la raison"
fi

OUTPUT=$(restic backup "${TARGETS[@]}" --tag homelab-daily 2>&1)
BACKUP_STATUS=$?
echo "$OUTPUT" >> "$LOG_FILE"

if [ "$BACKUP_STATUS" -ne 0 ] || echo "$OUTPUT" | grep -qi "error"; then
    notify_failure "code retour $BACKUP_STATUS, voir $LOG_FILE"
    exit 1
fi

restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune >> "$LOG_FILE" 2>&1

Les changements clés : détection explicite d’un verrou orphelin avant de commencer (avec notification, même en cas de succès du unlock, pour que je sache que quelque chose d’anormal s’est produit), et vérification du contenu de la sortie restic en plus du code de retour.

Vérifier que ça restaure vraiment

Un backup qu’on ne restaure jamais n’est qu’une théorie. Test mensuel, sur un LXC de test à part :

restic restore latest --target /tmp/restore-test --path /opt/vaultwarden/data
diff -rq /opt/vaultwarden/data /tmp/restore-test/opt/vaultwarden/data

La première fois, diff a montré des différences sur deux fichiers – pas une erreur restic, juste des fichiers temporaires SQLite (.sqlite3-wal, .sqlite3-shm) présents dans la source mais absents du snapshot au moment précis de la sauvegarde. Normal, pas un vrai problème, mais ça m’a appris à ne jamais faire confiance à un backup avant de l’avoir restauré au moins une fois.

Coût réel

Le repository restic sur le NAS occupe environ 4,2 Go après déduplication (données Vaultwarden + Paperless + Immich, avec l’historique de rétention complet). Rien de coûteux : le Synology DS414 tournait déjà pour d’autres usages, donc le coût marginal de ce backup est nul en matériel. Le seul coût réel est le temps que j’y ai mis – trois soirées, dont la moitié à comprendre le bug du verrou.

Ce que je ferais différemment

Tester le chemin d’échec avant de mettre le script en cron, pas après douze jours de silence. C’est la leçon qui revient le plus souvent dans mes scripts d’automatisation : je teste que « ça marche », rarement que « ça me prévient quand ça ne marche pas ».

Une remarque, un retour ?

Cet article est vivant - corrections, contre-arguments et retours de production sont les bienvenus. Trois canaux, choisissez celui qui vous convient.

Laisser un commentaire