Version 1.2: Détection automatique première sync + double protection anti-écrasement

- Détection automatique snapshots en commun (sync incrémentale vs première sync)
  - Gestion automatique Sanoid (activation/désactivation selon nœud actif)
  - Protection #1: Comparaison tailles source/destination (ratio >= 50%)
  - Protection #2: Historique des tailles avec tolérance ±20%
  - Prévention écrasement accidentel lors remplacement disque
  - Logs explicites avec actions recommandées en cas de blocage
This commit is contained in:
Tellsanguis 2025-11-14 19:08:35 +01:00
parent d5c2357487
commit 2b7d1c5500
2 changed files with 279 additions and 4 deletions

View file

@ -112,9 +112,45 @@ Sanoid crée des snapshots selon un calendrier défini :
- **Mensuel** : 3 snapshots (rétention de 3 mois)
- **Annuel** : 1 snapshot (rétention de 1 an)
**Activation intelligente** : Sanoid est automatiquement activé uniquement sur le nœud actif (celui qui héberge le LXC) et désactivé sur le nœud passif, évitant ainsi les conflits de snapshots.
### Détection Automatique de Première Synchronisation
Le script détecte automatiquement s'il s'agit d'une première synchronisation ou d'une réplication incrémentale :
**Réplication incrémentale** (snapshots en commun détectés) :
- Utilise les snapshots existants pour une synchronisation efficace
- Transfert uniquement des deltas (modifications depuis le dernier snapshot)
- Rapide et économe en bande passante
**Première synchronisation** (aucun snapshot en commun) :
- Active automatiquement le mode `--force-delete` de syncoid
- Déclenche les vérifications de sécurité avancées avant toute opération
- Réutilise les blocs de données existants pour éviter un transfert complet
### Protections Anti-Écrasement
Le script intègre un système de sécurité à deux niveaux pour éviter la perte de données lors d'une première synchronisation :
**Protection 1 : Comparaison source/destination**
- Vérifie que les tailles des datasets sont cohérentes entre les nœuds
- Refuse la synchronisation si la source est significativement plus petite que la destination (ratio < 50%)
- Détecte les scénarios de disque de remplacement vide devenu actif par erreur
**Protection 2 : Historique des tailles**
- Enregistre les tailles de tous les datasets après chaque synchronisation réussie
- Compare avec l'historique lors des synchronisations suivantes
- Refuse si variation anormale détectée (> 20% depuis la dernière synchronisation)
- Fichier d'état : `/var/lib/zfs-nfs-replica/last-sync-sizes.txt`
Ces protections garantissent qu'un disque vide ne pourra jamais écraser accidentellement des données existantes.
## Fonctionnalités
- **Réplication bidirectionnelle automatique** : S'adapte aux migrations Proxmox HA sans intervention manuelle
- **Détection automatique première sync/incrémentale** : Bascule automatiquement entre mode initial et mode incrémental
- **Gestion automatique de Sanoid** : Active/désactive Sanoid selon le nœud actif pour éviter les conflits de snapshots
- **Double protection anti-écrasement** : Vérifications de cohérence des tailles et historique pour prévenir toute perte de données
- **Synchronisation récursive du pool** : Tous les datasets sous `zpool1` sont automatiquement inclus
- **Contrôle de concurrence par verrou** : Empêche les tâches de réplication simultanées
- **Gestion d'erreurs complète** : Valide la connectivité SSH, l'existence du pool et les opérations ZFS
@ -194,7 +230,7 @@ ha-manager migrate ct:103 elitedesk
- **RPO** : Un intervalle de réplication de 10 minutes signifie une perte de données potentielle jusqu'à 10 minutes dans des scénarios catastrophiques
- **Bande passante USB** : Vitesse de réplication limitée par le débit USB 3.0 (adapté aux données froides)
- **Synchronisation initiale manuelle** : La première réplication depuis le nœud rempli doit être initiée manuellement (voir INSTALLATION.md)
- **Première synchronisation** : La détection automatique et les protections de sécurité peuvent nécessiter 10-20 minutes lors de la première exécution
- **Point unique de défaillance** : Une panne du nœud actif nécessite une migration HA avant que les données ne soient accessibles
- **Dépendance réseau** : La réplication nécessite une connectivité réseau stable entre les nœuds

View file

@ -11,7 +11,7 @@
# - Gère l'activation/désactivation de Sanoid selon le nœud actif
#
# Auteur : BENE Maël
# Version : 1.1
# Version : 1.2
#
set -euo pipefail
@ -23,6 +23,10 @@ ZPOOL="zpool1" # Pool entier à répliquer (tous les datasets)
CHECK_DELAY=2 # Délai entre chaque vérification (secondes)
LOG_FACILITY="local0"
SSH_KEY="/root/.ssh/id_ed25519_zfs_replication"
STATE_DIR="/var/lib/zfs-nfs-replica"
SIZES_FILE="${STATE_DIR}/last-sync-sizes.txt"
SIZE_TOLERANCE=20 # Tolérance de variation en pourcentage (±20%)
MIN_REMOTE_RATIO=50 # Le distant doit avoir au moins 50% de la taille du local
# Fonction de logging
log() {
@ -121,6 +125,206 @@ manage_sanoid() {
fi
}
# Vérification de l'existence de snapshots en commun
check_common_snapshots() {
local remote_ip="$1"
local pool="$2"
log "info" "Vérification des snapshots en commun entre les nœuds..."
# Récupérer les snapshots locaux
local local_snaps
local_snaps=$(zfs list -t snapshot -r "$pool" -o name -H 2>/dev/null | sort || true)
# Récupérer les snapshots distants
local remote_snaps
remote_snaps=$(ssh -i "$SSH_KEY" "root@${remote_ip}" "zfs list -t snapshot -r ${pool} -o name -H 2>/dev/null | sort" || true)
# Si pas de snapshots distants, c'est une première sync
if [[ -z "$remote_snaps" ]]; then
log "warning" "Aucun snapshot trouvé sur le nœud distant"
return 1
fi
# Si pas de snapshots locaux (ne devrait pas arriver avec Sanoid actif)
if [[ -z "$local_snaps" ]]; then
log "warning" "Aucun snapshot trouvé sur le nœud local"
return 1
fi
# Chercher des snapshots en commun
local common_snaps
common_snaps=$(comm -12 <(echo "$local_snaps") <(echo "$remote_snaps"))
if [[ -n "$common_snaps" ]]; then
local count
count=$(echo "$common_snaps" | wc -l)
log "info" "${count} snapshot(s) en commun trouvé(s)"
return 0
else
log "warning" "✗ Aucun snapshot en commun trouvé"
return 1
fi
}
# Récupération des tailles de datasets
get_dataset_sizes() {
local target="$1" # "local" ou "remote:IP"
local pool="$2"
if [[ "$target" == "local" ]]; then
zfs list -r "$pool" -o name,used -Hp 2>/dev/null || true
else
local remote_ip="${target#remote:}"
ssh -i "$SSH_KEY" "root@${remote_ip}" "zfs list -r ${pool} -o name,used -Hp 2>/dev/null" || true
fi
}
# Sauvegarde des tailles de datasets après sync réussie
save_dataset_sizes() {
local pool="$1"
log "info" "Sauvegarde des tailles de datasets dans ${SIZES_FILE}"
# Créer le répertoire si nécessaire
mkdir -p "$STATE_DIR"
# Sauvegarder avec timestamp
{
echo "timestamp=$(date '+%Y-%m-%d_%H:%M:%S')"
get_dataset_sizes "local" "$pool" | awk '{print $1"="$2}'
} > "$SIZES_FILE"
log "info" "✓ Tailles sauvegardées"
}
# Vérification de sécurité des tailles avant --force-delete
check_size_safety() {
local remote_ip="$1"
local pool="$2"
log "info" "=== Vérifications de sécurité avant --force-delete ==="
# Récupérer les tailles actuelles
local local_sizes
local_sizes=$(get_dataset_sizes "local" "$pool")
local remote_sizes
remote_sizes=$(get_dataset_sizes "remote:${remote_ip}" "$pool")
if [[ -z "$local_sizes" ]] || [[ -z "$remote_sizes" ]]; then
log "error" "✗ Impossible de récupérer les tailles des datasets"
return 1
fi
# Vérifier si un historique existe (indique que ce nœud a déjà été actif)
local has_history=false
if [[ -f "$SIZES_FILE" ]]; then
has_history=true
fi
# === SÉCURITÉ 1 : Comparaison source/destination ===
# Cette vérification s'applique UNIQUEMENT s'il existe un historique
# (pour éviter de bloquer la première installation légitime)
if [[ "$has_history" == "true" ]]; then
log "info" "Vérification #1: Comparaison des tailles source/destination (historique détecté)"
local dataset size_local size_remote ratio
while IFS=$'\t' read -r dataset size_local; do
# Trouver la taille correspondante sur le distant
size_remote=$(echo "$remote_sizes" | grep "^${dataset}" | awk '{print $2}')
if [[ -n "$size_remote" ]]; then
# Protection contre division par zéro et source plus petite que destination
if [[ "$size_local" -eq 0 ]] && [[ "$size_remote" -gt 0 ]]; then
log "error" "✗ SÉCURITÉ: Dataset ${dataset}"
log "error" " Local (source): 0B (VIDE)"
log "error" " Distant (destination): $(numfmt --to=iec-i --suffix=B "$size_remote")"
log "error" " Le nœud LOCAL est vide alors que le DISTANT contient des données"
log "error" " Cela indiquerait un disque de remplacement vide devenu actif par erreur"
log "error" " REFUS de --force-delete pour éviter d'écraser les données du nœud distant"
return 1
fi
if [[ "$size_local" -gt 0 ]]; then
# Calculer le ratio distant/local (le distant doit avoir au moins 50% du local)
ratio=$((size_remote * 100 / size_local))
if [[ $ratio -lt $MIN_REMOTE_RATIO ]]; then
log "error" "✗ SÉCURITÉ: Dataset ${dataset}"
log "error" " Local (source): $(numfmt --to=iec-i --suffix=B "$size_local")"
log "error" " Distant (destination): $(numfmt --to=iec-i --suffix=B "$size_remote")"
log "error" " Ratio: ${ratio}% (minimum requis: ${MIN_REMOTE_RATIO}%)"
log "error" " Le nœud distant semble avoir des données incomplètes ou vides"
log "error" " REFUS de --force-delete pour éviter la perte de données"
return 1
fi
# Vérification inverse : source ne doit pas être significativement plus petite que destination
local inverse_ratio
inverse_ratio=$((size_local * 100 / size_remote))
if [[ $inverse_ratio -lt $MIN_REMOTE_RATIO ]]; then
log "error" "✗ SÉCURITÉ: Dataset ${dataset}"
log "error" " Local (source): $(numfmt --to=iec-i --suffix=B "$size_local")"
log "error" " Distant (destination): $(numfmt --to=iec-i --suffix=B "$size_remote")"
log "error" " Ratio inverse: ${inverse_ratio}% (minimum requis: ${MIN_REMOTE_RATIO}%)"
log "error" " La SOURCE est plus petite que la DESTINATION"
log "error" " Cela indiquerait un disque de remplacement devenu actif par erreur"
log "error" " REFUS de --force-delete pour éviter d'écraser des données avec un disque vide"
return 1
fi
fi
fi
done <<< "$local_sizes"
log "info" "✓ Vérification #1 réussie: Les tailles sont cohérentes entre les nœuds"
else
log "info" "Vérification #1 ignorée: Première activation de ce nœud (pas d'historique)"
log "info" " Il est normal que le nœud distant soit vide lors de la première installation"
fi
# === SÉCURITÉ 2 : Comparaison avec historique ===
if [[ "$has_history" == "true" ]]; then
log "info" "Vérification #2: Comparaison avec l'historique des tailles"
local previous_timestamp
previous_timestamp=$(grep "^timestamp=" "$SIZES_FILE" | cut -d= -f2)
log "info" "Dernière synchronisation réussie: ${previous_timestamp}"
local dataset size_remote size_previous diff_percent
while IFS=$'\t' read -r dataset size_remote; do
# Récupérer la taille précédente
size_previous=$(grep "^${dataset}=" "$SIZES_FILE" | cut -d= -f2)
if [[ -n "$size_previous" ]] && [[ "$size_previous" -gt 0 ]]; then
# Calculer la différence en pourcentage
local diff
diff=$((size_remote > size_previous ? size_remote - size_previous : size_previous - size_remote))
diff_percent=$((diff * 100 / size_previous))
if [[ $diff_percent -gt $SIZE_TOLERANCE ]]; then
log "error" "✗ SÉCURITÉ: Dataset ${dataset}"
log "error" " Taille précédente: $(numfmt --to=iec-i --suffix=B "$size_previous")"
log "error" " Taille actuelle: $(numfmt --to=iec-i --suffix=B "$size_remote")"
log "error" " Variation: ${diff_percent}% (tolérance: ±${SIZE_TOLERANCE}%)"
log "error" " Variation anormale détectée depuis la dernière sync"
log "error" " REFUS de --force-delete pour éviter la perte de données"
return 1
fi
fi
done <<< "$remote_sizes"
log "info" "✓ Vérification #2 réussie: Les tailles sont cohérentes avec l'historique"
else
log "info" "Vérification #2 ignorée: Pas d'historique de tailles (première activation)"
fi
log "info" "=== ✓ Toutes les vérifications de sécurité sont passées ==="
log "info" "=== Autorisation de --force-delete accordée ==="
return 0
}
# Détermination du nœud local et distant
LOCAL_NODE=$(hostname)
log "info" "Démarrage du script sur le nœud: ${LOCAL_NODE}"
@ -197,15 +401,50 @@ if ! ssh -i "$SSH_KEY" "root@${REMOTE_NODE_IP}" "zpool list ${ZPOOL}" &>/dev/nul
exit 1
fi
# Réplication avec syncoid (récursive pour tous les datasets)
# Vérification des snapshots en commun et choix de la stratégie de réplication
log "info" "Début de la réplication récursive: ${ZPOOL}${REMOTE_NODE_NAME} (${REMOTE_NODE_IP}):${ZPOOL}"
# Syncoid utilise les options SSH via la variable d'environnement SSH
export SSH="ssh -i ${SSH_KEY}"
if syncoid --recursive --no-sync-snap --quiet "$ZPOOL" "root@${REMOTE_NODE_IP}:$ZPOOL"; then
# Déterminer si c'est une première synchronisation
SYNCOID_OPTS="--recursive --no-sync-snap --quiet"
if check_common_snapshots "$REMOTE_NODE_IP" "$ZPOOL"; then
# Snapshots en commun : réplication incrémentale normale
log "info" "Mode: Réplication incrémentale (snapshots en commun détectés)"
else
# Pas de snapshots en commun : première synchronisation avec --force-delete
log "warning" "Mode: Première synchronisation détectée"
log "warning" "Utilisation de --force-delete pour écraser les datasets incompatibles"
# SÉCURITÉ : Vérifier les tailles avant d'autoriser --force-delete
if ! check_size_safety "$REMOTE_NODE_IP" "$ZPOOL"; then
log "error" "╔════════════════════════════════════════════════════════════════╗"
log "error" "║ ARRÊT DE SÉCURITÉ ║"
log "error" "║ Les vérifications de sécurité ont échoué. ║"
log "error" "║ --force-delete REFUSÉ pour éviter une perte de données. ║"
log "error" "║ ║"
log "error" "║ Actions possibles : ║"
log "error" "║ 1. Vérifier manuellement les tailles des datasets ║"
log "error" "║ 2. Si changement de disque : supprimer ${SIZES_FILE}"
log "error" "║ 3. Vérifier que le bon nœud est actif (LXC sur le bon nœud) ║"
log "error" "╚════════════════════════════════════════════════════════════════╝"
exit 1
fi
log "info" "Note: Les blocs de données existants seront réutilisés (pas de transfert complet)"
SYNCOID_OPTS="${SYNCOID_OPTS} --force-delete"
fi
# Lancer la réplication avec les options appropriées
if syncoid $SYNCOID_OPTS "$ZPOOL" "root@${REMOTE_NODE_IP}:$ZPOOL"; then
log "info" "✓ Réplication récursive réussie vers ${REMOTE_NODE_NAME} (${REMOTE_NODE_IP})"
log "info" " Tous les datasets de ${ZPOOL} ont été synchronisés"
# Sauvegarder les tailles après sync réussie
save_dataset_sizes "$ZPOOL"
exit 0
else
log "error" "✗ Échec de la réplication vers ${REMOTE_NODE_NAME} (${REMOTE_NODE_IP})"