From db31f111d37585de28d684da9e53a0d3581d4922 Mon Sep 17 00:00:00 2001 From: "marco.locatelli@steamware.net" Date: Tue, 31 Mar 2026 12:06:05 +0200 Subject: [PATCH] gestione errore non-retryable nei backup logici --- .../mariadb-backup-logic-per-database.sh | 286 +++++++++++------- 1 file changed, 173 insertions(+), 113 deletions(-) diff --git a/mariadb-backup/mariadb-backup-logic-per-database.sh b/mariadb-backup/mariadb-backup-logic-per-database.sh index 660e014..62916ed 100644 --- a/mariadb-backup/mariadb-backup-logic-per-database.sh +++ b/mariadb-backup/mariadb-backup-logic-per-database.sh @@ -1,163 +1,223 @@ #!/bin/bash -# Backup logico per-database con retry e cleanup automatico +# Backup logico per-database con retry globale e per-database, atomicità e cleanup # Sicuro per cluster Galera ad alto carico # Autore: Marco + Copilot +# Uscita immediata in caso di: +# - comando con exit code != 0 (set -e) +# - uso di variabile non definita (set -u) +# - errore in una pipe (set -o pipefail) set -euo pipefail -# Aumenta i file descriptor disponibili (mysqldump può aprirne molti) +# Aumenta i file descriptor disponibili (mariadb-dump può aprirne molti) ulimit -n 65536 -# Directory base dei backup +# Directory base dove verranno create le cartelle dei backup logici BACKUP_BASE=/var/backups/tscale01 -# Prefisso per distinguere i backup logici di questo nodo +# Prefisso per distinguere i backup logici da altri tipi di backup PREFIX=eqn-bck-logic- -# Timestamp per la directory del backup -TIMESTAMP=$(date +%Y%m%d-%H%M%S) - -# Directory finale del backup -TARGET="$BACKUP_BASE/${PREFIX}${TIMESTAMP}" - -# File di log generale +# File di log condiviso per tutti i backup MariaDB LOGFILE=/var/log/mariadb-backup.log -# Lockfile per evitare esecuzioni concorrenti +# Lockfile usato da flock per evitare esecuzioni concorrenti dello script LOCKFILE=/var/lock/mariadb-backup.lock -# Nome del server per i log (utile in ambienti multi-nodo) +# Nome breve del server, utile per distinguere i log in ambienti multi-nodo SERVER_NAME=$(hostname -s) -# Binari necessari +# Numero massimo di tentativi globali dell’intero backup logico +GLOBAL_MAX_RETRIES=5 + +# Secondi di attesa tra un tentativo globale e il successivo +GLOBAL_RETRY_SLEEP=30 + +# Numero massimo di tentativi per ogni singolo database (in caso di deadlock 1213) +DB_MAX_RETRIES=10 + +# Secondi di attesa tra un tentativo e l’altro per lo stesso database +DB_RETRY_SLEEP=20 + +# Percorso del client mariadb (per eseguire query come SHOW DATABASES) MYSQL_BIN=$(command -v mariadb || true) + +# Percorso di mariadb-dump (per eseguire i dump logici) MYSQLDUMP_BIN=$(command -v mariadb-dump || true) -# Opzioni dump ottimizzate per Galera (no lock, snapshot consistente) +# Opzioni di mariadb-dump ottimizzate per Galera: +# - --single-transaction: snapshot consistente senza lock globali +# - --quick: stream delle righe senza caricare tutto in RAM +# - --skip-lock-tables: evita lock sulle tabelle MYSQLDUMP_OPTS="--user=root --single-transaction --quick --skip-lock-tables" -# Numero massimo di retry per ogni database (deadlock = retry) -MAX_RETRIES=10 - -# Secondi di attesa tra un retry e l'altro -RETRY_SLEEP=30 - -# Assicura che il logfile esista e abbia permessi sicuri +# Assicura che il logfile esista touch "$LOGFILE" -chown root:root "$LOGFILE" +# Permessi restrittivi sul logfile (solo root può leggere/scrivere) chmod 600 "$LOGFILE" -( - # FLOCK: evita che due backup partano insieme - flock -n 9 || { echo "[$(date '+%F %T')] [$SERVER_NAME] SKIP: another backup is running" >> "$LOGFILE"; exit 0; } +# Funzione che esegue un singolo tentativo completo di backup logico +run_backup() { + # Timestamp usato per nominare la directory del backup + TIMESTAMP=$(date +%Y%m%d-%H%M%S) - # Controllo binari - if [ -z "$MYSQL_BIN" ]; then - echo "[$(date '+%F %T')] [$SERVER_NAME] ERROR: mariadb client not found in PATH" >> "$LOGFILE" - exit 1 - fi + # Directory di destinazione per questo tentativo di backup + TARGET="$BACKUP_BASE/${PREFIX}${TIMESTAMP}" - if [ -z "$MYSQLDUMP_BIN" ]; then - echo "[$(date '+%F %T')] [$SERVER_NAME] ERROR: mariadb-dump not found in PATH" >> "$LOGFILE" - exit 1 - fi + # File temporaneo per catturare stderr di mariadb-dump + TMPLOG=$(mktemp /tmp/mariadb-backup-logic.XXXXXX) - # Crea directory del backup - mkdir -p "$TARGET" - chown mysql:mysql "$TARGET" - chmod 750 "$TARGET" + # Crea la directory del backup e imposta owner e permessi + mkdir -p "$TARGET" + chown mysql:mysql "$TARGET" + chmod 750 "$TARGET" - # File temporaneo per catturare errori del dump - TMPLOG=$(mktemp /tmp/mariadb-backup-logic.XXXXXX) + # Separatore visivo nel logfile + echo "---------------------" >> "$LOGFILE" + # Log di inizio backup logico + echo "[$(date '+%F %T')] [$SERVER_NAME] START logic backup $TARGET" >> "$LOGFILE" - echo "---------------------" >> "$LOGFILE" - echo "[$(date '+%F %T')] [$SERVER_NAME] START logic backup (per-database) $TARGET" >> "$LOGFILE" + # Elenco dei database utente (esclude schemi di sistema) + DB_LIST=$($MYSQL_BIN -N -e "SHOW DATABASES" | grep -vE '^(information_schema|performance_schema|mysql|sys)$' || true) - # Elenco database utente (escludiamo schemi di sistema) - DB_LIST=$($MYSQL_BIN -N -e "SHOW DATABASES" | grep -vE '^(information_schema|performance_schema|mysql|sys)$' || true) + # Flag globale di fallimento (0 = tutto ok, 1 = almeno un DB fallito) + FAILED=0 + # Elenco dei database che hanno avuto errori + FAILED_DBS="" - if [ -z "$DB_LIST" ]; then - echo "[$(date '+%F %T')] [$SERVER_NAME] WARNING: no user databases found to dump" >> "$LOGFILE" - fi + # Ciclo su ogni database utente + for DB in $DB_LIST; do + # File di destinazione per il dump di questo database + DUMPFILE="$TARGET/${DB}.sql" + # Log di inizio dump per il singolo database + echo "[$(date '+%F %T')] [$SERVER_NAME] START dump database '$DB' -> $DUMPFILE" >> "$LOGFILE" - FAILED=0 - FAILED_DBS="" + # Contatore dei tentativi per questo database + ATTEMPT=1 + # Flag di successo per questo database (0 = non riuscito, 1 = riuscito) + SUCCESS=0 - # Loop su ogni database - for db in $DB_LIST; do - DUMPFILE="$TARGET/${db}.sql" - echo "[$(date '+%F %T')] [$SERVER_NAME] START dump database '$db' -> $DUMPFILE" >> "$LOGFILE" + # Ciclo di retry per il singolo database + while [ "$ATTEMPT" -le "$DB_MAX_RETRIES" ]; do + # Pulisce il file temporaneo degli errori + : >"$TMPLOG" - attempt=1 - success=0 + # Esegue il dump del database, redirezionando stderr su TMPLOG + if $MYSQLDUMP_BIN $MYSQLDUMP_OPTS "$DB" >"$DUMPFILE" 2>"$TMPLOG"; then + # Dump riuscito: log e segna successo + echo "[$(date '+%F %T')] [$SERVER_NAME] END dump database '$DB' (attempt $ATTEMPT)" >> "$LOGFILE" + SUCCESS=1 + # Esce dal ciclo di retry per questo database + break + else + # Dump fallito: log dell’errore e ultime righe del TMPLOG + echo "[$(date '+%F %T')] [$SERVER_NAME] ERROR dumping database '$DB' (attempt $ATTEMPT). See $TMPLOG" >> "$LOGFILE" + tail -n 50 "$TMPLOG" >> "$LOGFILE" - # Retry intelligente in caso di deadlock (errore 1213) - while [ "$attempt" -le "$MAX_RETRIES" ]; do - : >"$TMPLOG" # pulisce il file temporaneo + # Se l’errore è un deadlock 1213, consideriamo il problema temporaneo e riproviamo + if grep -q "Error 1213" "$TMPLOG"; then + echo "[$(date '+%F %T')] [$SERVER_NAME] RETRY for '$DB' in ${DB_RETRY_SLEEP}s due to deadlock (1213)" >> "$LOGFILE" + sleep "$DB_RETRY_SLEEP" + else + # Errore non recuperabile: non ha senso riprovare questo database + echo "[$(date '+%F %T')] [$SERVER_NAME] NON-RETRYABLE error for '$DB'" >> "$LOGFILE" + # Esce dal ciclo di retry per questo database + break + fi + fi - if $MYSQLDUMP_BIN $MYSQLDUMP_OPTS "$db" >"$DUMPFILE" 2>"$TMPLOG"; then - echo "[$(date '+%F %T')] [$SERVER_NAME] END dump database '$db' (attempt $attempt)" >> "$LOGFILE" - success=1 - break - else - echo "[$(date '+%F %T')] [$SERVER_NAME] ERROR dumping database '$db' (attempt $attempt). See $TMPLOG" >> "$LOGFILE" - tail -n 50 "$TMPLOG" >> "$LOGFILE" + # Incrementa il contatore dei tentativi per questo database + ATTEMPT=$((ATTEMPT + 1)) + done - # Se è un deadlock, riproviamo - if grep -q "Error 1213" "$TMPLOG"; then - echo "[$(date '+%F %T')] [$SERVER_NAME] RETRY for database '$db' in ${RETRY_SLEEP}s due to deadlock (1213)" >> "$LOGFILE" - sleep "$RETRY_SLEEP" - else - # Errori non recuperabili → stop retry - echo "[$(date '+%F %T')] [$SERVER_NAME] NON-RETRYABLE error for database '$db'" >> "$LOGFILE" - break + # Se dopo tutti i tentativi il database non è stato dumpato con successo + if [ "$SUCCESS" -ne 1 ]; then + # Segna che almeno un database è fallito + FAILED=1 + # Aggiunge il nome del database alla lista dei falliti + FAILED_DBS="$FAILED_DBS $DB" fi - fi - - attempt=$((attempt + 1)) done - # Se dopo i retry non è andata → segna come fallito - if [ "$success" -ne 1 ]; then - FAILED=1 - FAILED_DBS="$FAILED_DBS $db" + # Calcola la dimensione apparente (somma dei byte logici dei file) + SIZE_APPARENT_BYTES=$(find "$TARGET" -type f -printf '%s\n' | awk '{s+=$1} END{print s+0}') + # Converte la dimensione apparente in formato leggibile (es. MiB, GiB) + SIZE_APPARENT_HUMAN=$(numfmt --to=iec --suffix=B "$SIZE_APPARENT_BYTES") + + # Calcola lo spazio effettivo su disco (byte allocati) + SIZE_DISK_BYTES=$(du -s --block-size=1 "$TARGET" | cut -f1) + # Converte la dimensione su disco in formato leggibile + SIZE_DISK_HUMAN=$(numfmt --to=iec --suffix=B "$SIZE_DISK_BYTES") + + # Log delle dimensioni del backup + echo "[$(date '+%F %T')] [$SERVER_NAME] SIZE apparent: $SIZE_APPARENT_HUMAN ($SIZE_APPARENT_BYTES bytes)" >> "$LOGFILE" + echo "[$(date '+%F %T')] [$SERVER_NAME] SIZE on-disk: $SIZE_DISK_HUMAN ($SIZE_DISK_BYTES bytes)" >> "$LOGFILE" + + # Se tutti i database sono stati dumpati correttamente + if [ "$FAILED" -eq 0 ]; then + # Log di successo complessivo + echo "[$(date '+%F %T')] [$SERVER_NAME] RESULT: OK" >> "$LOGFILE" + # Rimuove il file temporaneo degli errori + rm -f "$TMPLOG" + # Ritorna 0 (successo) al chiamante (ciclo globale) + return 0 + else + # Log di fallimento complessivo con elenco dei database problematici + echo "[$(date '+%F %T')] [$SERVER_NAME] RESULT: FAILED, databases with errors:$FAILED_DBS" >> "$LOGFILE" + # Log della pulizia della directory incompleta + echo "[$(date '+%F %T')] [$SERVER_NAME] CLEANUP: removing incomplete backup directory $TARGET" >> "$LOGFILE" + # Rimuove la directory del backup incompleto (atomicità: o tutto o niente) + rm -rf "$TARGET" + # Rimuove il file temporaneo degli errori + rm -f "$TMPLOG" + # Ritorna 1 (fallimento) al chiamante (ciclo globale) + return 1 fi - done +} - # Calcolo dimensioni del backup - size_apparent_bytes=$(find "$TARGET" -type f -printf '%s\n' 2>/dev/null | awk '{s+=$1} END{print s+0}') - size_apparent_human=$(numfmt --to=iec --suffix=B "$size_apparent_bytes" 2>/dev/null || echo "${size_apparent_bytes}B") +( + # FLOCK: evita che due istanze dello script girino contemporaneamente + flock -n 9 || { + echo "[$(date '+%F %T')] [$SERVER_NAME] SKIP: another backup is running" >> "$LOGFILE" + exit 0 + } - size_disk_bytes=$(du -s --block-size=1 "$TARGET" 2>/dev/null | cut -f1 || echo 0) - size_disk_human=$(numfmt --to=iec --suffix=B "$size_disk_bytes" 2>/dev/null || echo "${size_disk_bytes}B") + # Ciclo di retry globale: ripete l’intero backup logico fino a GLOBAL_MAX_RETRIES volte + for GLOBAL_ATTEMPT in $(seq 1 $GLOBAL_MAX_RETRIES); do + # Log del tentativo globale corrente + echo "[$(date '+%F %T')] [$SERVER_NAME] GLOBAL attempt $GLOBAL_ATTEMPT/$GLOBAL_MAX_RETRIES" >> "$LOGFILE" - echo "[$(date '+%F %T')] [$SERVER_NAME] SIZE apparent: $size_apparent_human ($size_apparent_bytes bytes) for $TARGET" >> "$LOGFILE" - echo "[$(date '+%F %T')] [$SERVER_NAME] SIZE on-disk: $size_disk_human ($size_disk_bytes bytes) for $TARGET" >> "$LOGFILE" + # Esegue un tentativo completo di backup logico + if run_backup; then + # Se il tentativo ha successo, log e uscita dal ciclo globale + echo "[$(date '+%F %T')] [$SERVER_NAME] GLOBAL RESULT: SUCCESS" >> "$LOGFILE" + break + else + # Se il tentativo fallisce e non siamo all’ultimo tentativo globale + if [ "$GLOBAL_ATTEMPT" -lt "$GLOBAL_MAX_RETRIES" ]; then + # Log del prossimo retry globale e attesa + echo "[$(date '+%F %T')] [$SERVER_NAME] GLOBAL RETRY in ${GLOBAL_RETRY_SLEEP}s" >> "$LOGFILE" + sleep "$GLOBAL_RETRY_SLEEP" + else + # Se anche l’ultimo tentativo globale fallisce, log e exit 1 + echo "[$(date '+%F %T')] [$SERVER_NAME] GLOBAL RESULT: FAILED after $GLOBAL_MAX_RETRIES attempts" >> "$LOGFILE" + exit 1 + fi + fi + done - # Risultato finale con cleanup in caso di fallimento - if [ "$FAILED" -eq 0 ]; then - echo "[$(date '+%F %T')] [$SERVER_NAME] RESULT: OK, all databases dumped successfully" >> "$LOGFILE" - else - echo "[$(date '+%F %T')] [$SERVER_NAME] RESULT: FAILED, databases with errors:$FAILED_DBS" >> "$LOGFILE" - echo "[$(date '+%F %T')] [$SERVER_NAME] CLEANUP: removing incomplete backup directory $TARGET" >> "$LOGFILE" - rm -rf "$TARGET" - rm -f "$TMPLOG" - exit 1 - fi + # Rotazione dei backup logici: rimuove le directory più vecchie di 7 giorni + TO_DELETE=$(find "$BACKUP_BASE" -maxdepth 1 -type d -name "${PREFIX}*" -mtime +7) - rm -f "$TMPLOG" - - # Rotazione: elimina backup più vecchi di 7 giorni - TO_DELETE=$(find "$BACKUP_BASE" -maxdepth 1 -type d -name "${PREFIX}*" -mtime +7 -print 2>/dev/null || true) - - if [ -n "$TO_DELETE" ]; then - echo "[$(date '+%F %T')] [$SERVER_NAME] ROTATE: removing old logic backups:" >> "$LOGFILE" - echo "$TO_DELETE" >> "$LOGFILE" - echo "$TO_DELETE" | tr '\n' '\0' | xargs -0 -r rm -rf -- - else - echo "[$(date '+%F %T')] [$SERVER_NAME] ROTATE: no logic backups to remove (<=7 days)" >> "$LOGFILE" - fi - - echo " " >> "$LOGFILE" + # Se ci sono directory da cancellare + if [ -n "$TO_DELETE" ]; then + # Log delle directory che verranno rimosse + echo "[$(date '+%F %T')] [$SERVER_NAME] ROTATE: removing old logic backups:" >> "$LOGFILE" + echo "$TO_DELETE" | tr '\n' '\0' | xargs -0 -r rm -rf -- + else + # Log se non c’è nulla da ruotare + echo "[$(date '+%F %T')] [$SERVER_NAME] ROTATE: no logic backups to remove (<=7 days)" >> "$LOGFILE" + fi +# File descriptor 9 associato al LOCKFILE per flock ) 9>"$LOCKFILE"