Une sauvegarde nocturne qui tournait en quelques minutes s’est mise à durer plus de 7 heures, avec des erreurs de quota Google. Voici le diagnostic et la correction : remplacer des milliers de petits fichiers par une seule archive compressée.
Cet article s’inscrit dans les séries projets Ubuntu et Domotique Home Assistant, et fait suite à l’article Sauvegarder ses containers Docker automatiquement avec rclone.
Le symptôme
La sauvegarde nocturne d’un dossier Docker (une dizaine de containers, environ 1,9 Go au total) copiait chaque nuit l’intégralité du dossier vers Google Drive, avec une rétention GFS (Grand-Père / Père / Fils, une stratégie classique qui conserve des sauvegardes quotidiennes, hebdomadaires, mensuelles et annuelles). Cette sauvegarde aurait dû se faire en quelques minutes. Un jour, elle a mis plus de 7 heures à se terminer, sans plantage visible, juste un processus qui semblait ne plus avancer.
Diagnostiquer un dépassement de quota API
Premier réflexe : regarder le fichier de log. Il s’arrêtait net, sans nouvelle ligne depuis des heures. Le processus rclone tournait pourtant toujours (ps aux | grep rclone le confirmait), mais avec un temps CPU (temps de calcul processeur réellement utilisé) dérisoire par rapport à sa durée de vie, signe qu’il passait le plus clair de son temps à attendre.
Relancer une simple commande de lecture a permis de voir l’erreur clairement :
rclone size gdrive:/daily
googleapi: Error 403: Quota exceeded for quota metric 'Queries' and limit 'Queries per minute' of service 'drive.googleapis.com'
Le quota en cause n’est pas l’espace de stockage, mais le nombre de requêtes envoyées à l’API (interface de programmation, le canal par lequel un logiciel comme rclone communique avec les serveurs de Google) Google Drive en une minute. Chaque fichier transféré, vérifié ou supprimé individuellement génère plusieurs requêtes. Un compte pour voir venir le problème :
find /home/USER/docker -type f | wc -l
Résultat : plus de 10 000 fichiers, dans un seul dossier quotidien. Multiplié par une rétention de 7 jours (plus les copies hebdomadaires, mensuelles et annuelles), le script devait gérer chaque nuit plusieurs dizaines de milliers d’opérations individuelles sur l’API. Ce n’est pas le volume de données (1,9 Go, rien d’énorme) qui posait problème, c’est le nombre d’objets.
L’effet de bord du sudo sur la configuration rclone
Un deuxième problème s’est ajouté en cours de route : le fichier de configuration rclone (rclone.conf) changeait régulièrement de propriétaire, passant de l’utilisateur normal à root, ce qui bloquait toute utilisation manuelle de rclone en dehors du script.
La cause : le script utilisait sudo rclone pour contourner des permissions sur certains fichiers Docker appartenant à root. Mais lancer rclone entièrement en root a un effet de bord : le jeton d’accès Google expire régulièrement (environ toutes les heures), et rclone le renouvelle automatiquement en réécrivant le fichier de configuration. Réécrit par un processus root, ce fichier devient la propriété de root, et l’utilisateur normal perd l’accès à sa propre configuration.
La correction : ne plus jamais faire tourner rclone lui-même avec sudo. Seule la commande qui a besoin de lire les fichiers appartenant à root (tar, dans la solution ci-dessous) utilise sudo, sans aucun rapport avec la configuration rclone.
La solution : une archive compressée plutôt que des milliers de fichiers
Plutôt que de copier des milliers de fichiers un par un, le script compresse désormais l’intégralité du dossier en une seule archive avant l’envoi. Une archive à transférer, c’est une poignée de requêtes API au lieu de dizaines de milliers.
La logique GFS reste identique, mais la rotation hebdomadaire, mensuelle et annuelle se fait par copie du fichier archive directement entre deux emplacements sur Google Drive (côté serveur, sans repasser par le Mini PC), plutôt que par un nouveau balayage de milliers de fichiers.
Le nettoyage des sauvegardes obsolètes change aussi de nature : supprimer un fichier unique par jour dépassé, au lieu de parcourir et supprimer des milliers de fichiers individuellement.
Le script complet
#!/bin/bash
# ═══════════════════════════════════════════════════════════════
# backup-docker.sh — Sauvegarde Docker vers Google Drive
# v5 - archive unique compressée (fin du problème de quota API)
# export automatique des YAML depuis l'API Portainer
# ═══════════════════════════════════════════════════════════════
# -- Configuration ────────────────────────────────────────────────
SOURCE="/home/USER/docker"
REMOTE="gdrive:"
LOG="/home/USER/rclone-logs/backup-docker.log"
RCLONE_CONF="/home/USER/.config/rclone/rclone.conf"
PORTAINER_URL="https://192.168.1.100:9443"
PORTAINER_TOKEN=$(cat /home/USER/docker/scripts/.portainer-token)
DATE=$(date +%Y-%m-%d)
DAY=$(date +%u) # 1=lundi ... 7=dimanche
DOM=$(date +%d) # jour du mois (01-31)
MONTH=$(date +%m) # mois (01-12)
ARCHIVE_NAME="docker-$DATE.tar.gz"
TMP_ARCHIVE="/tmp/$ARCHIVE_NAME"
# -- Création du dossier de log si nécessaire ─────────────────────
mkdir -p /home/USER/rclone-logs
# -- Fonction de log ──────────────────────────────────────────────
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG"
}
# -- Commande rclone de base ──────────────────────────────────────
# Important : plus de sudo ici. rclone tourne toujours en tant que
# l'utilisateur normal, son fichier de config n'est donc plus
# jamais réécrit par root. Seul tar (plus bas) a besoin de sudo
# pour lire les fichiers appartenant à root dans les volumes
# Docker.
RCLONE="rclone --config $RCLONE_CONF --log-file=$LOG"
log "=== Début sauvegarde ==="
# -- Export automatique des YAML (format de fichier de configuration
# lisible, utilisé par Docker Compose) depuis l'API Portainer ─────
log "Export des YAML depuis Portainer..."
STACKS_JSON=$(curl -sk \
-H "X-API-Key: $PORTAINER_TOKEN" \
"$PORTAINER_URL/api/stacks")
if [ -z "$STACKS_JSON" ] || [ "$STACKS_JSON" = "null" ]; then
log "ERREUR : impossible de contacter l'API Portainer - les YAML ne seront pas mis à jour."
else
STACK_COUNT=$(echo "$STACKS_JSON" | python3 -c "
import sys, json
stacks = json.load(sys.stdin)
print(len(stacks))
" 2>/dev/null)
log "$STACK_COUNT stack(s) trouvée(s) dans Portainer."
echo "$STACKS_JSON" | python3 -c "
import sys, json
stacks = json.load(sys.stdin)
for s in stacks:
print(s['Id'], s['Name'])
" 2>/dev/null | while read stack_id stack_name; do
YAML_CONTENT=$(curl -sk \
-H "X-API-Key: $PORTAINER_TOKEN" \
"$PORTAINER_URL/api/stacks/$stack_id/file" \
| python3 -c "
import sys, json
data = json.load(sys.stdin)
print(data.get('StackFileContent', ''))
" 2>/dev/null)
if [ -n "$YAML_CONTENT" ]; then
DEST="$SOURCE/$stack_name/docker-compose.yml"
mkdir -p "$SOURCE/$stack_name"
echo "$YAML_CONTENT" > "$DEST"
log "YAML exporté : $stack_name/docker-compose.yml"
else
log "ATTENTION : YAML vide ou inaccessible pour la stack $stack_name"
fi
done
fi
# -- Vérification des docker-compose.yml manquants ────────────────
# Seuls les containers gérés comme des stacks Portainer ont un
# docker-compose.yml généré automatiquement ci-dessus. Certains
# dossiers ne sont pas des containers et doivent être exclus de
# cette vérification.
log "Vérification des docker-compose.yml..."
for dir in "$SOURCE"/*/; do
logiciel=$(basename "$dir")
# "scripts" : pas un container Docker
# à adapter : tout dossier qui n'est pas une stack Portainer
# (par exemple un environnement virtuel Python) doit être
# ajouté ici pour éviter une fausse alerte à chaque exécution
if [ "$logiciel" = "scripts" ]; then
continue
fi
compose="$dir/docker-compose.yml"
if [ ! -f "$compose" ]; then
log "ATTENTION : docker-compose.yml manquant dans $logiciel - à créer manuellement !"
fi
done
# -- Création de l'archive compressée ─────────────────────────────
# sudo est nécessaire ici car certains fichiers dans les volumes
# Docker appartiennent à root. tar n'a aucun rapport avec la config
# rclone, donc rclone.conf n'est jamais impacté par ce sudo.
log "Création de l'archive $ARCHIVE_NAME..."
sudo tar --exclude="*.db-shm" --exclude="*.db-wal" -czf "$TMP_ARCHIVE" -C "$SOURCE" .
sudo chown USER:USER "$TMP_ARCHIVE"
log "Archive créée : $(du -h "$TMP_ARCHIVE" | cut -f1)"
# -- Envoi de l'archive vers Google Drive (sauvegarde quotidienne) ─
log "Copie vers daily/..."
$RCLONE copy "$TMP_ARCHIVE" "$REMOTE/daily/"
# -- Copie hebdomadaire (chaque dimanche) ──────────────────────────
# Copie côté serveur, aucun ré-upload depuis le poste local
if [ "$DAY" -eq 7 ]; then
log "Dimanche : copie hebdomadaire"
$RCLONE copyto "$REMOTE/daily/$ARCHIVE_NAME" "$REMOTE/weekly/$ARCHIVE_NAME"
fi
# -- Copie mensuelle (le 1er de chaque mois) ───────────────────────
if [ "$DOM" -eq 01 ]; then
log "1er du mois : copie mensuelle"
$RCLONE copyto "$REMOTE/daily/$ARCHIVE_NAME" "$REMOTE/monthly/$ARCHIVE_NAME"
fi
# -- Copie annuelle (le 1er janvier) ───────────────────────────────
if [ "$DOM" -eq 01 ] && [ "$MONTH" -eq 01 ]; then
log "1er janvier : copie annuelle"
$RCLONE copyto "$REMOTE/daily/$ARCHIVE_NAME" "$REMOTE/yearly/$ARCHIVE_NAME"
fi
# -- Nettoyage du fichier temporaire local ─────────────────────────
rm -f "$TMP_ARCHIVE"
# -- Nettoyage des anciennes sauvegardes sur Drive ─────────────────
# Suppression de fichiers uniques, plus de parcours fichier par fichier
log "Nettoyage daily > 7 jours..."
$RCLONE delete "$REMOTE/daily" --min-age 7d --include "*.tar.gz"
log "Nettoyage weekly > 28 jours..."
$RCLONE delete "$REMOTE/weekly" --min-age 28d --include "*.tar.gz"
log "Nettoyage monthly > 365 jours..."
$RCLONE delete "$REMOTE/monthly" --min-age 365d --include "*.tar.gz"
# Annuelles : conservées indéfiniment, pas de nettoyage automatique
log "=== Sauvegarde terminée ==="
Résultat mesuré après cette réécriture : script complet exécuté en 8 minutes, contre plus de 7 heures avant correction.
Un point de vigilance si tu reprends ce script : les fichiers docker-compose.yml gérés par Portainer sont écrasés chaque nuit avec la version stockée dans Portainer. Toute modification doit se faire dans l’interface Portainer, jamais directement dans le fichier local, sous peine d’être effacée à la prochaine exécution.
Un point à ne pas manquer au déploiement : ce sudo tar doit être explicitement autorisé sans mot de passe, sans quoi il échoue silencieusement lorsqu’il est lancé par cron (qui n’a pas de terminal interactif pour saisir un mot de passe). L’échec ne remonte alors aucune erreur exploitable dans le log applicatif, l’archive reste simplement vide.
Plutôt que d’autoriser tar sans restriction (ce qui donnerait un accès complet en lecture et écriture à tout le système à quiconque contrôle ce compte), la règle sudoers peut être limitée à la commande exacte utilisée par le script :
sudo visudo -f /etc/sudoers.d/backup-docker
puis remplacer le contenu par :
USER ALL=(root) NOPASSWD: /usr/bin/tar --exclude=*.db-shm --exclude=*.db-wal -czf /tmp/docker-*.tar.gz -C /home/USER/docker .
Le joker (*) dans le nom de fichier autorise uniquement la variation de la date, tout le reste de la commande doit correspondre au caractère près à ce que le script envoie. Si le script évolue plus tard (nouvelle option, chemin différent), cette règle doit être mise à jour en même temps, sinon le même échec silencieux se reproduira.
Se faire alerter automatiquement avec Home Assistant
Le script écrit un journal détaillé, mais rien ne prévenait activement en cas de souci, il fallait aller consulter le fichier manuellement. Un capteur Home Assistant permet de surveiller ce journal et de déclencher une notification par email en cas d’anomalie.
Monter le dossier de logs dans le container Home Assistant, dans la section volumes du fichier de configuration de la stack (via Portainer) :
- /home/USER/rclone-logs:/rclone-logs:ro
Le :ro (lecture seule) suffit, Home Assistant n’a pas besoin d’écrire dans ce dossier.
Créer le capteur. Depuis la version 2023.8 de Home Assistant, la syntaxe sensor: - platform: command_line ne fonctionne plus, le capteur doit être déclaré sous une clé command_line: dédiée :
command_line:
- sensor:
name: "Sauvegarde Docker Anomalies"
unique_id: sauvegarde_docker_anomalies
command: >
awk '/=== Début sauvegarde ===/{buf=""} {buf=buf $0 ORS} END{printf "%s", buf}' /rclone-logs/backup-docker.log > /tmp/dernier-run.log;
if grep -q "=== Sauvegarde terminée ===" /tmp/dernier-run.log; then
COUNT=$(grep -E "ATTENTION|ERROR" /tmp/dernier-run.log | grep -vc "directory not found");
echo "$COUNT";
else
echo "99";
fi
scan_interval: 3600
Ce capteur isole la dernière exécution du script (entre le dernier === Début sauvegarde === et la fin du fichier), puis renvoie soit le nombre de lignes d’alerte ou d’erreur trouvées, soit 99 si la ligne === Sauvegarde terminée === est absente, ce qui signale un script resté bloqué ou interrompu avant la fin.
Un piège à connaître sur cette commande : grep -c compte des lignes, mais son code de sortie (au sens Unix, pas sa sortie affichée) vaut 1 dès que ce compte est à zéro, 0 seulement s’il trouve au moins une ligne. L’intégration command_line traite tout code de sortie différent de zéro comme un échec de la commande, et dans ce cas, elle n’écrit jamais la valeur dans le capteur. Résultat contre-intuitif : le capteur reste bloqué sur unknown précisément quand tout va bien (zéro anomalie trouvée). La solution, déjà intégrée ci-dessus, consiste à isoler le comptage dans une variable puis à l’afficher avec un echo séparé, qui réussit toujours quel que soit le chiffre affiché.
Créer l’automatisation qui envoie l’alerte par email :
automation:
- alias: "Alerte anomalie sauvegarde Docker"
id: alerte_anomalie_sauvegarde_docker
trigger:
- platform: time
at: "03:30:00"
condition:
- condition: numeric_state
entity_id: sensor.sauvegarde_docker_anomalies
above: 0
action:
- service: notify.email_alertes
data:
title: "[home ALD] Anomalie sauvegarde Docker"
message: >
Une anomalie a été détectée dans la sauvegarde Docker de cette nuit (code {{ states('sensor.sauvegarde_docker_anomalies') }}).
Automatisation déclenchante : automation.alerte_anomalie_sauvegarde_docker
Consulter le log complet sur le Mini PC : /home/USER/rclone-logs/backup-docker.log
Le déclenchement à 3h30 laisse une marge confortable après le lancement du script à 3h00, largement suffisante avec un temps d’exécution ramené à quelques minutes.
Attention si tu ajoutes une automatisation dans un fichier automations.yaml existant : chaque automatisation est un élément de liste, le tiret - qui la démarre doit être collé à gauche, sans indentation, sous peine d’erreur de chargement.
Pour aller plus loin
- Série projets Ubuntu
- Série Domotique Home Assistant
- Article initial : Sauvegarder ses containers Docker automatiquement avec rclone
Commentaires récents