Blog

  • Hosting per dominio .si: guida e strategia SEO per i provider

    13 ott 2026 · @Matteo

    Scheda SEO

    La keyword principale è hosting dominio .si. Questo post è la parte commerciale della coppia: parte dal boom dei domini e porta il lettore alla scelta dell’hosting.

    ElementoProposta
    Title tag (≈58 caratteri)Hosting per dominio .si: come scegliere e cosa serve
    Meta description (≈150 caratteri)Hai registrato un dominio .si? Ecco cosa serve per metterlo online: DNS, SSL, email e una strategia SEO per i provider di hosting.
    Slug/hosting-dominio-si-guida-seo
    Keyword principalehosting dominio .si
    Keyword secondarieregistrare dominio .si, hosting per startup AI, hosting per domini ccTLD, SSL e DNS per dominio .si
    Intento prevalenteTransazionale e comparativo
    Link internoPost 1: Boom dei domini .SI, perché succede e cosa insegna al marketing

    A settembre 2026 le registrazioni del dominio .si sono passate da 3.515 in agosto a 46.066 in un solo mese, trascinate dal fatto che “SI” è anche la sigla di super intelligence (l’analisi completa è nel post precedente sul boom dei domini .si). Ogni dominio registrato è però un sito che deve ancora nascere.

    Questo articolo si rivolge a due lettori: chi ha appena registrato un .si e cerca dove ospitarlo, e chi vende hosting e vuole trasformare questa ondata di attenzione in clienti e visibilità organica.

    Perché il boom dei domini interessa a chi vende hosting

    Registrare un dominio è solo il primo passo: un nome senza server, DNS configurati e certificato SSL non genera traffico né vendite. Ogni ondata di registrazioni crea quindi una domanda a valle di servizi tecnici, e il .si ne ha generata una nuova in pochissimo tempo.

    I profili che compaiono dopo un boom di questo tipo sono tre, ciascuno con bisogni diversi:

    ProfiloCosa cercaServizio da proporre
    Domain investorUna pagina “in vendita” veloce per ogni nome, a costi bassi, su molti domini insiemeHosting economico multi-dominio, landing page di vendita, gestione DNS
    Startup e brand AIUn sito credibile e veloce per il lancio, email professionale, sicurezzaHosting performante, SSL incluso, email con dominio .si, assistenza
    Aziende e agenzieProteggere il marchio e non lasciare nomi in mano ad altriRegistrazione e gestione di portafoglio, monitoraggio, rinnovi automatici

    Il primo profilo è il più numeroso oggi, ma è anche il più volatile: secondo Netcraft circa il 52% dei .si registrati su Dynadot tra il 19 e il 22 settembre è stato messo in vendita subito dopo (CircleID), e molti nomi potrebbero non essere rinnovati. Il secondo è meno numeroso e più redditizio, perché usa davvero il sito.

    Per chi opera in Italia c’è un’opportunità da verificare: le ricerche in italiano legate a “hosting dominio .si” potrebbero avere ancora pochi contenuti di qualità a rispondere. Un controllo della SERP prima di pubblicare dirà quanto la nicchia è aperta.

    Strategia SEO per un provider di hosting

    L’obiettivo è trasformare l’attenzione sul .si in traffico qualificato verso le pagine commerciali. Servono pochi contenuti ben collegati, pubblicati subito, invece di molti articoli generici.

    1. Mappa le keyword per intento

    I volumi vanno verificati con Search Console o Keyword Planner prima di investire: i numeri dipendono dal momento e dal paese.

    IntentoKeyword esempioContenutoObiettivo
    Informativodominio .si cos’è, perché .si super intelligencePost 1 sul boom dei dominiTraffico e autorevolezza
    Informativo-praticochi può registrare un dominio .si, costo dominio .siGuida passo passo con requisiti e prezziIntercettare chi sta per comprare
    Transazionaleregistrare dominio .si, hosting dominio .siLanding page prodottoConversione
    Comparativomiglior hosting per startup AI, hosting per domini ccTLDConfronto con tabellaChi sceglie tra più provider
    Brand protectionproteggere il marchio con domini, rinnovo automatico dominiPagina servizio portafoglio dominiClienti aziendali

    2. Pubblica in ordine di valore commerciale

    1. Landing page “Hosting per dominio .si” con prezzi, SSL incluso, DNS gestiti, email e un’unica call to action chiara.
    2. Guida “Come registrare un dominio .si” con requisiti, costi e passaggi, che rimanda alla landing.
    3. Post di analisi sul boom, per attirare link e condivisioni dal settore.
    4. Confronto “hosting per startup AI” con criteri misurabili (velocità, uptime, supporto).
    5. FAQ ottimizzate per snippet in evidenza (vedi sezione dedicata).

    3. Ottimizza on-page e tecnica

    • Title e meta description come nella scheda SEO, con la keyword all’inizio.
    • Struttura H1-H2-H3 che rispecchia le domande degli utenti, una keyword secondaria per sezione.
    • Dati strutturati FAQ e Article, per aumentare le possibilità di snippet.
    • Link interni tra i due post, la guida e la landing, con anchor descrittive (“hosting per dominio .si”), non “clicca qui”.
    • Velocità e Core Web Vitals: per un provider di hosting il sito è la prova del prodotto, e una pagina lenta contraddice il messaggio.
    • Fonti citate e data di aggiornamento visibili, perché il tema cambia ogni settimana.

    4. Sfrutta il tempismo

    Il picco di interesse è legato a un evento recente. Pubblicare entro pochi giorni, aggiornare i numeri ogni settimana e ripubblicare con data aggiornata aiuta a restare in SERP finché la ricerca resta alta. Se il fenomeno si sgonfia, le pagine di guida e la landing continuano comunque a servire chi registra un .si per motivi ordinari.

    5. Comunica con prudenza

    Il valore dei .si dipende dall’adozione reale della sigla SI, e Netcraft segnala rischi di cybersquatting e frodi legati all’ondata di registrazioni (Netcraft). Per un provider la comunicazione più sicura vende un servizio tecnico concreto, non un guadagno sui domini, e prevede una policy sugli abusi.

    Domande frequenti su .si e hosting

    Serve un hosting speciale per un dominio .si? No. Un dominio .si si collega a qualsiasi hosting configurando i DNS. Contano velocità, affidabilità, SSL incluso e assistenza, come per qualsiasi altro dominio.

    Chi può registrare un dominio .si? Secondo le fonti consultate, chiunque può registrarlo. Verifica sempre requisiti e costi aggiornati sul sito del registro o del registrar scelto.

    Che cosa serve per mettere online un dominio .si? Un hosting o un server, i DNS puntati correttamente, un certificato SSL e, se serve, una casella email sul dominio.

    Conviene investire in domini .si? È un’operazione speculativa. Una parte consistente dei nomi viene messa in vendita subito dopo la registrazione e il valore dipende dall’adozione reale della sigla SI. Valuta solo budget che puoi permetterti di perdere.

    Perché i domini .si sono tornati di moda a settembre 2026? Perché “SI” coincide con la sigla di super intelligence, adottata dal governo USA al posto di “AI”. Nel post sul boom dei domini .si trovi i dati e il contesto.

    Conclusione: dal dominio al sito online

    Il valore per un provider di hosting non sta nella speculazione sui domini, ma nel servizio che serve dopo: DNS affidabili, SSL, prestazioni, email e assistenza per chi vuole davvero mettere online un sito.

    Il piano è semplice: pubblica la landing per il .si, affianca la guida alla registrazione, aggiorna i dati ogni settimana e comunica un servizio tecnico, non un’opportunità di guadagno. Se il fenomeno si sgonfia, restano contenuti utili e una pagina che converte.

    Vuoi ospitare il tuo dominio .si? Scopri l’hosting con SSL incluso, DNS gestiti ed email professionale, attivo in pochi minuti.

    Fonti

    Dati verificati il 13 ottobre 2026.

  • Perché ho scritto questo script

    Capita a tutti prima o poi: il disco del server si riempie, l’allarme scatta e la domanda è sempre la stessa. Quale database sta occupando tutto questo spazio?

    Io mi ci sono trovato di fronte dopo un’ottimizzazione. Avevo lanciato OPTIMIZE TABLE sperando di recuperare spazio, ma il filesystem non scendeva come previsto. Senza un “prima” da confrontare con il “dopo” era impossibile capire se l’ottimizzazione avesse funzionato, quale database fosse il vero responsabile e se lo spazio mancante fosse addirittura da un’altra parte.

    Gli strumenti che avevo a disposizione rispondevano solo a metà del problema:

    • du mostra i file su disco, ma non dice a quale tabella appartengono;
    • information_schema (o pg_class su PostgreSQL) dà le dimensioni logiche, ma non vede binlog, ibdata1, undo log o WAL;
    • entrambi fotografano un solo istante: il confronto nel tempo resta un lavoro manuale, fatto con copia e incolla e un foglio di calcolo.

    Mi serviva una cosa semplice: fotografare lo stato di tutti i database in un dato momento, rifare la fotografia più tardi e farmi dire cosa è cambiato, ordinando le differenze dalla più grande alla più piccola. Così il colpevole salta subito all’occhio.

    Ne è uscito uno script Bash unico, senza dipendenze oltre al client del database e agli strumenti standard di Linux (awk, du, df). Funziona con MySQL/MariaDB e con PostgreSQL. Lo condivido qui nella speranza che faccia risparmiare a qualcuno lo stesso pomeriggio di frustrazione.

    Cosa fa

    Lo script ha quattro comandi:

    • snapshot salva lo stato attuale (database, tabelle, file nella data directory, spazio del filesystem);
    • compare confronta due fotografie e mostra le differenze, le più grandi in cima;
    • report mostra i database e le tabelle più grandi di una singola fotografia;
    • list elenca le fotografie salvate.

    Il confronto è diviso in quattro sezioni, per non lasciare angoli bui:

    • dimensione di ogni database (dati + indici);
    • spazio libero o recuperabile (data_free su MySQL, stima delle tuple morte su PostgreSQL);
    • dimensione di ogni tabella;
    • file reali nella data directory: cartelle dei database, ibdata1, binlog, undo, pg_wal.

    L’ultima sezione è spesso quella che risolve il mistero. Molte volte lo spazio “anomalo” non appartiene a nessun database, ma ai log binari o ai file condivisi di InnoDB.

    Requisiti

    • Linux con Bash 4 o superiore, awk, du, df (tutto di serie);
    • il client mysql oppure psql, con un utente che possa leggere i metadati di tutti i database;
    • i permessi di root (o dell’utente mysql/postgres) se vuoi misurare anche i file su disco; senza, quella sezione viene saltata con un avviso.

    Installazione

    Salva lo script (lo trovi in fondo all’articolo) in un file e rendilo eseguibile:

    nano db_space_compare.sh
    chmod +x db_space_compare.sh

    Credenziali

    Per MySQL/MariaDB il modo più pulito è un file ~/.my.cnf con le credenziali, così non compaiono nella cronologia dei comandi:

    [client]
    user=root
    password=LA_TUA_PASSWORD

    In alternativa puoi passare le opzioni con una variabile d’ambiente:

    export MYSQL_OPTS="-u root -pPASSWORD -h 127.0.0.1"

    Per PostgreSQL funzionano ~/.pgpass, le variabili PG* oppure PG_OPTS:

    export PG_OPTS="-U postgres -h localhost"

    Come si usa

    Il flusso tipico è prima, ottimizzazione, dopo, confronto:

    # 1. fotografia prima dell'ottimizzazione
    sudo ./db_space_compare.sh snapshot prima
    
    # 2. esegui l'ottimizzazione (OPTIMIZE TABLE, VACUUM FULL, ...)
    
    # 3. fotografia dopo
    sudo ./db_space_compare.sh snapshot dopo
    
    # 4. confronto
    sudo ./db_space_compare.sh compare prima dopo

    Per PostgreSQL basta aggiungere l’opzione -e postgres:

    sudo ./db_space_compare.sh -e postgres snapshot prima
    sudo ./db_space_compare.sh -e postgres snapshot dopo
    sudo ./db_space_compare.sh -e postgres compare prima dopo

    Le opzioni disponibili sono:

    • -e mysql|postgres sceglie il motore (predefinito: mysql);
    • -d DIR cambia la cartella in cui salvare le fotografie (predefinita: ./db_snapshots);
    • -n N imposta quante righe mostrare per sezione (predefinito: 20).

    Se non indichi un’etichetta, la fotografia prende come nome la data e l’ora. Questo è comodo per monitorare la crescita nel tempo.

    Esempio di output

    Ecco un confronto su dati di prova, costruiti apposta per mostrare un caso tipico: il database shop si riduce dopo l’ottimizzazione, ma il database log cresce e i binlog raddoppiano.

    Confronto 'prima' (2026-10-11 20:00:00)  ->  'dopo' (2026-10-11 22:00:00)
    Filesystem /var: usato 46.6 GiB -> 48.4 GiB (+1.9 GiB)
    
    === Database - dimensione totale ===
      NOME                                                      PRIMA         DOPO    DIFFERENZA  NOTE
      log                                                    21.4 GiB     27.5 GiB      +6.1 GiB  +28.3%
      shop                                                    5.6 GiB      3.6 GiB      -2.0 GiB  -35.0%
    
    === Tabelle - dimensione (dati + indici) ===
      NOME                                                      PRIMA         DOPO    DIFFERENZA  NOTE
      log.eventi                                             21.4 GiB     27.5 GiB      +6.1 GiB  +28.3%
      shop.ordini                                             5.6 GiB      3.6 GiB      -2.0 GiB  -35.0%
      log.nuova                                                   0 B      1.0 KiB      +1.0 KiB  NUOVO
    
    === File nella data directory (cartelle DB, ibdata1, binlog, undo, WAL) ===
      NOME                                                      PRIMA         DOPO    DIFFERENZA  NOTE
      log                                                    21.4 GiB     27.5 GiB      +6.1 GiB  +28.3%
      mysql-bin.*                                             3.7 GiB      8.4 GiB      +4.7 GiB  +125.0%
      shop                                                    5.7 GiB      3.6 GiB      -2.0 GiB  -36.1%
      ibdata1                                                11.2 GiB     11.2 GiB           0 B

    Qui il colpevole è evidente: la tabella log.eventi è cresciuta di oltre 6 GiB, e i file mysql-bin.* sono passati da 3,7 a 8,4 GiB. L’ottimizzazione su shop ha funzionato, ma il guadagno è stato mangiato dal resto.

    Come leggere i risultati

    • In cima a ogni sezione ci sono le voci cambiate di più. Un database o una tabella che non cala (o addirittura cresce) dopo l’ottimizzazione è il primo sospetto.
    • Se le tabelle calano ma il filesystem no, lo spazio è altrove: guarda la sezione sui file nella data directory (binlog, ibdata1, undo, pg_wal).
    • MySQL/MariaDB: ibdata1 non si restringe mai, nemmeno con OPTIMIZE TABLE. Con innodb_file_per_table=OFF il valore di data_free è condiviso e poco indicativo.
    • PostgreSQL: un VACUUM semplice non restituisce spazio al sistema operativo, serve VACUUM FULL (o pg_repack). Lo spazio “recuperabile” mostrato è una stima basata sulle tuple morte.

    Monitorare la crescita nel tempo

    Se il problema non è legato a una singola ottimizzazione ma a una crescita graduale, conviene automatizzare le fotografie con cron, una all’ora, e poi confrontare due istanti qualsiasi:

    # crontab -e (come root)
    0 * * * * /usr/local/bin/db_space_compare.sh -d /var/lib/db_snapshots snapshot >/dev/null 2>&1

    Senza etichetta, ogni fotografia prende come nome la data e l’ora (per esempio 20261011-140000). Con list vedi quelle disponibili e con compare ne confronti due. In pochi giorni emerge quale database cresce in modo anomalo.

    ./db_space_compare.sh -d /var/lib/db_snapshots list
    ./db_space_compare.sh -d /var/lib/db_snapshots compare 20261011-140000 20261012-140000

    Lo script completo

    Ecco il codice. Copialo in un file chiamato db_space_compare.sh, rendilo eseguibile e sei pronto.

    #!/usr/bin/env bash
    #
    # db_space_compare.sh
    # Fotografa l'occupazione di spazio di tutti i database (MySQL/MariaDB o
    # PostgreSQL) e confronta due fotografie (es. prima/dopo l'ottimizzazione)
    # per capire quale database, tabella o file occupa spazio in modo anomalo.
    #
    # Uso:
    #   db_space_compare.sh [opzioni] snapshot [etichetta]   (default: data-ora)
    #   db_space_compare.sh [opzioni] compare  <prima> <dopo>
    #   db_space_compare.sh [opzioni] report   <etichetta>
    #   db_space_compare.sh [opzioni] list
    #
    # Opzioni:
    #   -e mysql|postgres   motore del database (default: mysql)
    #   -d DIR              cartella delle fotografie (default: ./db_snapshots)
    #   -n N                righe mostrate per sezione (default: 20)
    #
    # Credenziali:
    #   MySQL/MariaDB: ~/.my.cnf oppure MYSQL_OPTS="-u root -pPASSWORD -h 127.0.0.1"
    #   PostgreSQL:    ~/.pgpass, variabili PG* oppure PG_OPTS="-U postgres -h localhost"
    #                  (non mettere -d in PG_OPTS: lo script si collega a ogni database)
    #
    # Per misurare anche i file su disco (cartelle dei DB, ibdata1, binlog, undo,
    # pg_wal) va eseguito come root o come utente mysql/postgres; altrimenti
    # quella parte viene saltata con un avviso.
    #
    # Esempio:
    #   sudo ./db_space_compare.sh snapshot prima
    #   ... ottimizzazione (OPTIMIZE TABLE / VACUUM FULL / ...) ...
    #   sudo ./db_space_compare.sh snapshot dopo
    #   sudo ./db_space_compare.sh compare prima dopo
    
    set -euo pipefail
    export LC_ALL=C
    
    ENGINE=mysql
    OUTDIR=./db_snapshots
    TOPN=20
    SNAP_IN_PROGRESS=""
    
    die()  { echo "ERRORE: $*" >&2; exit 1; }
    warn() { echo "ATTENZIONE: $*" >&2; }
    usage() { sed -n '2,/^$/{s/^# \{0,1\}//;p}' "$0"; exit "${1:-0}"; }
    
    cleanup() {
      if [[ -n $SNAP_IN_PROGRESS ]]; then
        rm -rf -- "$SNAP_IN_PROGRESS"
        echo "Fotografia incompleta rimossa: $SNAP_IN_PROGRESS" >&2
      fi
    }
    trap cleanup EXIT
    
    while getopts ":e:d:n:h" opt; do
      case $opt in
        e) ENGINE=$OPTARG ;;
        d) OUTDIR=$OPTARG ;;
        n) TOPN=$OPTARG ;;
        h) usage 0 ;;
        *) usage 1 ;;
      esac
    done
    shift $((OPTIND - 1))
    
    [[ $ENGINE == mysql || $ENGINE == postgres ]] || die "motore non valido: '$ENGINE' (usa mysql o postgres)"
    [[ $TOPN =~ ^[0-9]+$ ]] || die "-n deve essere un numero"
    
    read -r -a MYSQL_ARGS <<< "${MYSQL_OPTS:-}"
    read -r -a PG_ARGS    <<< "${PG_OPTS:-}"
    
    my() { mysql "${MYSQL_ARGS[@]+"${MYSQL_ARGS[@]}"}" --batch --skip-column-names "$@"; }
    pg() { psql  "${PG_ARGS[@]+"${PG_ARGS[@]}"}" -X -q -A -t -F $'\t' -v ON_ERROR_STOP=1 "$@"; }
    
    # Funzioni awk comuni: formattazione in KiB/MiB/GiB, chiave e valore di riga.
    #   nk = numero di colonne che formano il nome (1 = database, 2 = db.tabella)
    #   vc = colonne da sommare per il valore (es. "4,5" = dati + indici)
    AWK_LIB='
    function h(b,   a, i, u) {
      split("B KiB MiB GiB TiB PiB", u, " ")
      a = (b < 0) ? -b : b; i = 1
      while (a >= 1024 && i < 6) { a /= 1024; i++ }
      if (i == 1) return sprintf("%s%d B", (b < 0 ? "-" : ""), a)
      return sprintf("%s%.1f %s", (b < 0 ? "-" : ""), a, u[i])
    }
    function hs(b) { return (b > 0 ? "+" : "") h(b) }
    function key(   k, i) { k = $1; for (i = 2; i <= nk; i++) k = k "." $i; return k }
    function val(   n, c, i, s) { n = split(vc, c, ","); s = 0; for (i = 1; i <= n; i++) s += $(c[i]); return s }
    '
    
    meta_get() { awk -F= -v k="$2" '$1 == k { sub(/^[^=]*=/, ""); print; exit }' "$1/meta.txt"; }
    
    # ---------------------------------------------------------------- raccolta dati
    
    # Occupazione reale su disco delle voci di primo livello della data directory:
    # cartelle dei database, ibdata1, binlog, undo, redo log, pg_wal, base, ...
    # I file numerati (mysql-bin.000123, relay-log.000045, ...) sono sommati in una voce.
    collect_disk() {
      local d=$1 out=$2
      : > "$out"
      if [[ -z $d || ! -r $d || ! -x $d ]]; then
        warn "data directory '${d:-?}' non leggibile: salto i file su disco (esegui come root per includerli)"
        return 0
      fi
      { du -sb -- "$d"/* 2>/dev/null || true; } |
        awk -F'\t' -v p="$d/" '
          { n = $2
            if (index(n, p) == 1) n = substr(n, length(p) + 1)
            if (n ~ /\.[0-9][0-9][0-9][0-9][0-9][0-9]$/) sub(/[0-9]+$/, "*", n)
            s[n] += $1 }
          END { for (n in s) printf "%s\t%.0f\n", n, s[n] }' > "$out"
    }
    
    write_meta() {
      local dir=$1 datadir=$2 version=$3
      {
        echo "timestamp=$(date '+%Y-%m-%d %H:%M:%S')"
        echo "engine=$ENGINE"
        echo "host=$(hostname)"
        echo "version=$version"
        echo "datadir=$datadir"
        if [[ -n $datadir && -e $datadir ]]; then
          { df -P -B1 -- "$datadir" 2>/dev/null || true; } |
            awk 'NR == 2 { print "fs_mount=" $6; print "fs_used=" $3; print "fs_avail=" $4 }'
        fi
      } > "$dir/meta.txt"
    }
    
    # File prodotti da ogni fotografia (separati da TAB):
    #   tables.tsv     db, tabella, engine, dati, indici, libero/recuperabile, righe
    #   databases.tsv  db, dati, indici, libero/recuperabile, totale
    #   disk.tsv       voce della data directory, byte su disco
    #   meta.txt       data, host, versione, filesystem
    
    snapshot_mysql() {
      local dir=$1 pre="" datadir version
      command -v mysql >/dev/null 2>&1 || die "client 'mysql' non trovato"
      my -e "SELECT 1" >/dev/null || die "connessione a MySQL/MariaDB fallita (controlla ~/.my.cnf o MYSQL_OPTS)"
    
      # MySQL 8 tiene in cache le statistiche di information_schema fino a 24 ore:
      # senza questo, i valori "dopo" l'ottimizzazione potrebbero essere quelli vecchi.
      if [[ -n $(my -e "SHOW VARIABLES LIKE 'information_schema_stats_expiry'") ]]; then
        pre="SET SESSION information_schema_stats_expiry = 0;"
      fi
    
      my -e "$pre
        SELECT table_schema, table_name, IFNULL(engine, '-'),
               IFNULL(data_length, 0), IFNULL(index_length, 0), IFNULL(data_free, 0), IFNULL(table_rows, 0)
          FROM information_schema.TABLES
         WHERE table_type IN ('BASE TABLE', 'SYSTEM VERSIONED')
           AND table_schema NOT IN ('information_schema', 'performance_schema', 'sys')" > "$dir/tables.tsv"
    
      my -e "$pre
        SELECT s.schema_name,
               IFNULL(SUM(t.data_length), 0), IFNULL(SUM(t.index_length), 0), IFNULL(SUM(t.data_free), 0),
               IFNULL(SUM(t.data_length), 0) + IFNULL(SUM(t.index_length), 0)
          FROM information_schema.SCHEMATA s
          LEFT JOIN information_schema.TABLES t
                 ON t.table_schema = s.schema_name
                AND t.table_type IN ('BASE TABLE', 'SYSTEM VERSIONED')
         WHERE s.schema_name NOT IN ('information_schema', 'performance_schema', 'sys')
         GROUP BY s.schema_name" > "$dir/databases.tsv"
    
      datadir=$(my -e "SELECT @@datadir"); datadir=${datadir%/}
      version=$(my -e "SELECT VERSION()")
      collect_disk "$datadir" "$dir/disk.tsv"
      write_meta "$dir" "$datadir" "$version"
    }
    
    snapshot_postgres() {
      local dir=$1 maindb=${PGDATABASE:-postgres} datadir version db
      command -v psql >/dev/null 2>&1 || die "client 'psql' non trovato"
      pg -d "$maindb" -c "SELECT 1" >/dev/null || die "connessione a PostgreSQL fallita (controlla PG_OPTS, PGUSER, ~/.pgpass)"
    
      pg -d "$maindb" -c "SELECT datname FROM pg_database WHERE datallowconn AND NOT datistemplate ORDER BY 1" > "$dir/.dblist"
    
      # Per ogni database: dimensione di ogni tabella (catalogo incluso, es. pg_largeobject)
      # e stima dello spazio occupato da tuple morte (bloat recuperabile con VACUUM FULL).
      : > "$dir/tables.tsv"
      while IFS= read -r db; do
        [[ -n $db ]] || continue
        pg -d "$db" -c "
          SELECT current_database(), s.schemaname || '.' || s.relname, 'table',
                 pg_table_size(s.relid), pg_indexes_size(s.relid),
                 CASE WHEN s.n_live_tup + s.n_dead_tup > 0
                      THEN (pg_table_size(s.relid)::numeric * s.n_dead_tup / (s.n_live_tup + s.n_dead_tup))::bigint
                      ELSE 0 END,
                 s.n_live_tup
            FROM pg_stat_all_tables s
           WHERE s.schemaname <> 'information_schema'
             AND s.schemaname NOT LIKE 'pg_toast%'" < /dev/null >> "$dir/tables.tsv" \
          || warn "impossibile leggere le tabelle del database '$db'"
      done < "$dir/.dblist"
    
      pg -d "$maindb" -c "SELECT datname, pg_database_size(oid) FROM pg_database WHERE datallowconn AND NOT datistemplate" > "$dir/.dbsize"
      awk -F'\t' '
        FILENAME == ARGV[1] { d[$1] += $4; i[$1] += $5; f[$1] += $6; next }
        { printf "%s\t%.0f\t%.0f\t%.0f\t%s\n", $1, d[$1], i[$1], f[$1], $2 }' \
        "$dir/tables.tsv" "$dir/.dbsize" > "$dir/databases.tsv"
    
      datadir=$(pg -d "$maindb" -c "SHOW data_directory" 2>/dev/null || true)
      version=$(pg -d "$maindb" -c "SHOW server_version")
      collect_disk "$datadir" "$dir/disk.tsv"
    
      # Dettaglio di base/<oid> con il nome del database al posto dell'oid
      if [[ -n $datadir && -r $datadir/base && -x $datadir/base ]]; then
        pg -d "$maindb" -c "SELECT oid, datname FROM pg_database" > "$dir/.oidmap"
        { du -sb -- "$datadir"/base/* 2>/dev/null || true; } |
          awk -F'\t' '
            FILENAME == ARGV[1] { name[$1] = $2; next }
            { o = $2; sub(/.*\//, "", o); printf "base/%s\t%s\n", ((o in name) ? name[o] : o), $1 }' \
            "$dir/.oidmap" - >> "$dir/disk.tsv"
      fi
      write_meta "$dir" "$datadir" "$version"
    }
    
    # --------------------------------------------------------------- presentazione
    
    print_fs() {
      local d=$1 used avail mount
      used=$(meta_get "$d" fs_used); avail=$(meta_get "$d" fs_avail); mount=$(meta_get "$d" fs_mount)
      [[ -n $used ]] || return 0
      awk -v u="$used" -v a="$avail" -v m="$mount" "$AWK_LIB"'
        BEGIN { printf "Filesystem %s: usato %s, libero %s\n", m, h(u), h(a) }'
    }
    
    top_section() {
      local f=$1 nk=$2 vc=$3 title=$4
      echo
      echo "=== $title ==="
      if [[ ! -s $f ]]; then echo "  (nessun dato)"; return 0; fi
      awk -F'\t' -v nk="$nk" -v vc="$vc" "$AWK_LIB"'
        { v = val(); printf "%.0f\t%s\t%s\n", v, key(), h(v) }' "$f" |
        sort -t $'\t' -k1,1nr -k2,2 |
        awk -F'\t' -v top="$TOPN" 'NR <= top && $1 > 0 { printf "  %-60s %12s\n", $2, $3 }'
    }
    
    compare_section() {
      local fa=$1 fb=$2 nk=$3 vc=$4 title=$5
      [[ -f $fa ]] || fa=/dev/null
      [[ -f $fb ]] || fb=/dev/null
      echo
      echo "=== $title ==="
      if [[ ! -s $fa && ! -s $fb ]]; then echo "  (nessun dato)"; return 0; fi
      printf '  %-50s %12s %12s %13s  %s\n' "NOME" "PRIMA" "DOPO" "DIFFERENZA" "NOTE"
      awk -F'\t' -v nk="$nk" -v vc="$vc" "$AWK_LIB"'
        FILENAME == ARGV[1] { k = key(); A[k] = val(); S[k] = 1; next }
                            { k = key(); B[k] = val(); S[k] = 1 }
        END {
          for (k in S) {
            inA = (k in A); inB = (k in B)
            a = inA ? A[k] : 0; b = inB ? B[k] : 0; d = b - a
            if (!inA)                 note = "NUOVO"
            else if (!inB)            note = "RIMOSSO"
            else if (a > 0 && d != 0) note = sprintf("%+.1f%%", 100 * d / a)
            else                      note = ""
            printf "%.0f\t%s\t%s\t%s\t%s\t%s\n", (d < 0 ? -d : d), k, h(a), h(b), hs(d), note
          }
        }' "$fa" "$fb" |
        sort -t $'\t' -k1,1nr -k2,2 |
        awk -F'\t' -v top="$TOPN" 'NR <= top { printf "  %-50s %12s %12s %13s  %s\n", $2, $3, $4, $5, $6 }'
    }
    
    # -------------------------------------------------------------------- comandi
    
    cmd_snapshot() {
      local label=${1:-$(date '+%Y%m%d-%H%M%S')}
      [[ $label =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || die "etichetta non valida: usa solo lettere, numeri, . _ -"
      local dir=$OUTDIR/$label
      [[ ! -e $dir ]] || die "la fotografia '$label' esiste già ($dir)"
      mkdir -p -- "$dir"
      SNAP_IN_PROGRESS=$dir
      echo "Fotografia '$label' ($ENGINE) in corso..."
      "snapshot_$ENGINE" "$dir"
      rm -f -- "$dir/.dblist" "$dir/.dbsize" "$dir/.oidmap"
      SNAP_IN_PROGRESS=""
      echo "Salvata in $dir ($(wc -l < "$dir/tables.tsv") tabelle)"
      print_fs "$dir"
      top_section "$dir/databases.tsv" 1 5 "Database più grandi"
    }
    
    cmd_report() {
      local label=${1:-}
      [[ -n $label ]] || die "uso: report <etichetta>"
      local d=$OUTDIR/$label
      [[ -f $d/meta.txt ]] || die "fotografia '$label' non trovata in $OUTDIR"
      echo "Fotografia '$label' del $(meta_get "$d" timestamp) - $(meta_get "$d" engine) $(meta_get "$d" version) su $(meta_get "$d" host)"
      print_fs "$d"
      top_section "$d/databases.tsv" 1 5   "Database più grandi (dati + indici)"
      top_section "$d/tables.tsv"    2 4,5 "Tabelle più grandi (dati + indici)"
      top_section "$d/tables.tsv"    2 6   "Tabelle con più spazio recuperabile (data_free / tuple morte stimate)"
      top_section "$d/disk.tsv"      1 2   "Voci più grandi nella data directory"
    }
    
    cmd_compare() {
      local a=${1:-} b=${2:-}
      [[ -n $a && -n $b ]] || die "uso: compare <etichetta_prima> <etichetta_dopo>"
      [[ $a != "$b" ]] || die "le due etichette sono uguali"
      local da=$OUTDIR/$a db=$OUTDIR/$b
      [[ -f $da/meta.txt ]] || die "fotografia '$a' non trovata in $OUTDIR"
      [[ -f $db/meta.txt ]] || die "fotografia '$b' non trovata in $OUTDIR"
      [[ $(meta_get "$da" engine) == "$(meta_get "$db" engine)" ]] || warn "le due fotografie sono di motori diversi"
    
      echo "Confronto '$a' ($(meta_get "$da" timestamp))  ->  '$b' ($(meta_get "$db" timestamp))"
      local ua ub m
      ua=$(meta_get "$da" fs_used); ub=$(meta_get "$db" fs_used); m=$(meta_get "$db" fs_mount)
      if [[ -n $ua && -n $ub ]]; then
        awk -v a="$ua" -v b="$ub" -v m="$m" "$AWK_LIB"'
          BEGIN { printf "Filesystem %s: usato %s -> %s (%s)\n", m, h(a), h(b), hs(b - a) }'
      fi
    
      compare_section "$da/databases.tsv" "$db/databases.tsv" 1 5   "Database - dimensione totale"
      compare_section "$da/databases.tsv" "$db/databases.tsv" 1 4   "Database - spazio libero/recuperabile"
      compare_section "$da/tables.tsv"    "$db/tables.tsv"    2 4,5 "Tabelle - dimensione (dati + indici)"
      compare_section "$da/disk.tsv"      "$db/disk.tsv"      1 2   "File nella data directory (cartelle DB, ibdata1, binlog, undo, WAL)"
    
      cat <<'EOF'
    
    Come leggere il risultato:
      - In cima a ogni sezione ci sono le voci cambiate di più: un database o una
        tabella che dopo l'ottimizzazione non cala, o addirittura cresce, è il
        primo sospetto.
      - Se le tabelle calano ma il filesystem no, lo spazio sta altrove: guarda la
        sezione "File nella data directory" (binlog, ibdata1, undo, pg_wal).
      - MySQL/MariaDB: ibdata1 non si restringe mai, nemmeno con OPTIMIZE; con
        innodb_file_per_table=OFF il data_free è condiviso e poco indicativo.
      - PostgreSQL: VACUUM semplice non restituisce spazio al disco, serve
        VACUUM FULL (o pg_repack); lo spazio "recuperabile" è una stima.
    EOF
    }
    
    cmd_list() {
      if [[ ! -d $OUTDIR ]]; then echo "Nessuna fotografia in $OUTDIR"; return 0; fi
      local d found=0
      for d in "$OUTDIR"/*/; do
        [[ -f $d/meta.txt ]] || continue
        found=1
        printf '%-24s %s  %-8s  %s\n' "$(basename "$d")" "$(meta_get "$d" timestamp)" \
          "$(meta_get "$d" engine)" "$(meta_get "$d" host)"
      done
      [[ $found -eq 1 ]] || echo "Nessuna fotografia in $OUTDIR"
    }
    
    cmd=${1:-}
    shift || true
    case $cmd in
      snapshot) cmd_snapshot "$@" ;;
      compare)  cmd_compare "$@" ;;
      report)   cmd_report "$@" ;;
      list)     cmd_list ;;
      ""|help)  usage 0 ;;
      *)        die "comando sconosciuto: '$cmd' (usa snapshot, compare, report, list)" ;;
    esac

    Note finali

    • Lo script è in sola lettura sui database: esegue solo SELECT sui metadati. Scrive esclusivamente nella cartella delle fotografie.
    • Su MySQL 8 imposta information_schema_stats_expiry = 0 nella sessione, perché altrimenti le statistiche in cache (fino a 24 ore) potrebbero mostrare i valori “vecchi” anche dopo l’ottimizzazione.
    • Se una fotografia fallisce a metà, viene rimossa automaticamente per non lasciare dati incompleti.
    • Prima di usarlo in produzione, provalo su un ambiente di test e verifica i permessi dell’utente del database.

    Se lo usi e trovi un caso che non copre, o hai idee per estenderlo (altri motori, esportazione in CSV, avvisi via email), scrivimi nei commenti.

  • MySQL 5.7 in Docker su un server moderno, protetto con nftables

    Premessa

    Ci siamo trovati nella necessità di avere un database MySQL 5.7 su un server che, per il resto, gira con una distribuzione recente. Alcune applicazioni legacy non sono compatibili con MySQL 8 (cambio del plugin di autenticazione predefinito, modalità SQL più rigide, parole riservate nuove) e migrarle in tempi brevi non era un’opzione.

    Installare una versione così datata direttamente sull’host comporta diversi problemi: i repository ufficiali delle distribuzioni attuali non la offrono più, i pacchetti di terze parti rischiano di entrare in conflitto con il resto del sistema, e un eventuale aggiornamento dell’OS potrebbe romperla.

    Abbiamo quindi deciso di isolarla in un container Docker: l’host resta pulito e aggiornato, la versione del database è fissata e riproducibile, e dati e configurazione stanno in una cartella facile da salvare e spostare.

    Questo articolo racconta come l’abbiamo configurata e, soprattutto, gli ostacoli incontrati lungo la strada: l’esposizione involontaria della porta 3306, un errore di iptables al riavvio e la scrittura di un firewall con nftables che lasci passare solo gli IP autorizzati.

    Nota: MySQL 5.7 è giunto a fine vita (EOL) a ottobre 2023 e non riceve più patch di sicurezza. È una soluzione ponte: l’obiettivo resta migrare a MySQL 8.x o MariaDB. Proprio per questo è ancora più importante non lasciarlo esposto.


    1. Struttura della cartella

    /opt/mysql57/
    ├── docker-compose.yml
    ├── .env
    ├── conf.d/        # configurazioni personalizzate (my.cnf aggiuntivi)
    ├── data/          # dati del database (volume persistente)
    └── logs/
    

    Il file .env contiene le credenziali, così non finiscono nel compose:

    TZ=Europe/Rome
    MYSQL_ROOT_PASSWORD=una_password_molto_lunga_e_casuale
    MYSQL_DATABASE=nome_db
    MYSQL_USER=nome_utente
    MYSQL_PASSWORD=altra_password_robusta
    

    Proteggilo con chmod 600 .env.

    2. Il docker-compose.yml

    services:
      mysql57:
        image: mysql:5.7
        container_name: mysql57
        restart: unless-stopped
        env_file:
          - .env
        ports:
          - "3306:3306"
        environment:
          TZ: "${TZ:-Europe/Rome}"
          MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD}"
          MYSQL_DATABASE: "${MYSQL_DATABASE}"
          MYSQL_USER: "${MYSQL_USER}"
          MYSQL_PASSWORD: "${MYSQL_PASSWORD}"
        command:
          - --default-authentication-plugin=mysql_native_password
        volumes:
          - ./data:/var/lib/mysql
          - ./conf.d:/etc/mysql/conf.d:ro
          - ./logs:/var/log/mysql
        healthcheck:
          test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p$$MYSQL_ROOT_PASSWORD --silent"]
          interval: 20s
          timeout: 5s
          retries: 10
          start_period: 45s
        ulimits:
          nofile:
            soft: 65535
            hard: 65535
    

    Alcune scelte da notare:

    • restart: unless-stopped: il database riparte da solo dopo un riavvio del server.
    • Volumi con bind mount: dati, configurazione e log sono sull’host, quindi backup e migrazioni sono semplici copie di cartelle.
    • Healthcheck: permette agli altri servizi di aspettare che MySQL sia davvero pronto ($$ serve a far espandere la variabile dentro il container e non da Compose).
    • ulimits nofile: evita l’errore “too many open files” sotto carico.

    3. Primo problema: la porta è aperta a tutto il mondo

    La riga - "3306:3306" equivale a 0.0.0.0:3306:3306: Docker pubblica la porta su tutte le interfacce dell’host, compresa quella pubblica.

    La parte più insidiosa è che Docker scrive le proprie regole direttamente nel firewall del kernel e queste vengono valutate prima di quelle di UFW o firewalld. Avere un firewall attivo non basta: la porta resta raggiungibile.

    Le strade possibili sono:

    1. Solo localhost: "127.0.0.1:3306:3306", se il database serve solo all’host o tramite tunnel SSH.
    2. Nessuna porta pubblicata: se i client sono altri container, basta una rete Docker condivisa.
    3. IP specifico: pubblicare solo su un indirizzo di una rete privata/VPN.
    4. Porta pubblica con filtro per IP: il nostro caso, perché un client esterno (un’istanza di phpMyAdmin su un altro server) deve poter collegarsi.

    Per il caso 4 serve un firewall che filtri correttamente il traffico delle porte pubblicate da Docker.

    4. Secondo problema: errore di iptables all’avvio

    Dopo aver toccato il firewall, docker compose up ha restituito:

    failed to create network mysql57_default: Error response from daemon:
    Failed to Setup IP tables: Unable to enable ACCEPT OUTGOING rule:
    (iptables failed: iptables --wait -t filter -A DOCKER-FORWARD -i br-... -j ACCEPT:
    iptables: No chain/target/match by that name.
    

    L’errore non dipende dal compose: la catena DOCKER-FORWARD (introdotta nelle versioni recenti di Docker) non esisteva più. Era stata cancellata da qualcosa che aveva ripulito le regole dopo l’avvio del daemon. Un riavvio di Docker (systemctl restart docker) la ricrea, ma il problema si ripresenta finché il vero colpevole non viene eliminato.

    Il colpevole: flush ruleset

    Il nostro /etc/nftables.conf iniziava con:

    flush ruleset
    

    Questo comando cancella tutte le tabelle nftables, comprese quelle create da Docker. Inoltre il file conteneva una copia statica delle regole di Docker, con il nome del bridge (br-278550235c3f) e l’IP del container (172.18.0.2) scritti a mano. Ma il nome del bridge cambia ogni volta che la rete Compose viene ricreata, quindi la copia diventava subito obsoleta, e la tabella ip nat entrava in conflitto con quella gestita da Docker.

    Regola d’oro: con Docker, nel file nftables non si usa flush ruleset e non si copiano le sue regole. Si gestisce solo il proprio firewall, in tabelle dedicate.

    5. Il firewall con nftables

    Il principio è questo: le porte pubblicate da Docker vengono riscritte con DNAT e poi inoltrate dall’host verso il container, quindi attraversano la catena forward, non input. Una regola drop in una nostra catena forward con priorità leggermente più bassa (quindi valutata prima) scarta il pacchetto a prescindere dalle regole accept di Docker.

    Per non toccare le tabelle altrui, usiamo il pattern table ... + delete table ...: rende il file rieseguibile senza errori e senza flush.

    /etc/nftables.conf:

    #!/usr/sbin/nft -f
    
    # NIENTE "flush ruleset": cancellerebbe anche le tabelle di Docker
    
    # ---------------- firewall host ----------------
    table inet filter
    delete table inet filter
    
    table inet filter {
    
        chain input {
            type filter hook input priority filter; policy drop;
            ct state established,related accept
            iifname "lo" accept
            ip6 nexthdr icmpv6 accept
            iifname "enp7s0" accept                       # rete privata
            ip saddr 203.0.113.20 tcp dport 22 accept     # SSH da host fidato
            ip saddr 203.0.113.30 tcp dport 22 accept     # SSH da VPN
            ip saddr 203.0.113.20 icmp type echo-request accept
            tcp dport { 80, 443 } accept
        }
    
        chain output {
            type filter hook output priority filter; policy accept;
        }
    }
    
    # ---------------- guardia MySQL (porta pubblicata da Docker) ----------------
    table ip mysql_guard
    delete table ip mysql_guard
    
    table ip mysql_guard {
        set allowed {
            type ipv4_addr
            flags interval
            elements = {
                203.0.113.10        # phpMyAdmin
            }
        }
    
        chain forward {
            type filter hook forward priority filter - 5; policy accept;
            iifname "enp7s0" accept
            ct status dnat tcp dport 3306 ip saddr @allowed accept
            ct status dnat tcp dport 3306 counter drop
        }
    }
    

    Come funziona la guardia:

    • ct status dnat seleziona solo i pacchetti passati dal DNAT, cioè quelli diretti alle porte pubblicate da Docker.
    • La prima regola accetta chi è nel set allowed; la seconda scarta tutti gli altri e li conta (counter).
    • Il traffico locale (127.0.0.1) e quello tra container sulla stessa rete non passa dal forward del router, quindi non viene toccato.

    Compatibilità: in una prima versione usavamo ct original proto-dst 3306, ma alcune versioni di nft rispondono con “Can’t parse symbolic invalid expressions”. Nel hook forward il DNAT è già avvenuto e la porta del container coincide con quella pubblicata (3306:3306), quindi tcp dport 3306 combinato con ct status dnat è equivalente e funziona ovunque. Se cambi il mapping (es. 3307:3306), la regola va adattata alla porta interna.

    Applicare le modifiche senza chiudersi fuori

    Tieni aperta una sessione SSH di riserva, poi:

    nft -c -f /etc/nftables.conf     # solo verifica della sintassi
    nft delete table ip nat          # una tantum: elimina la vecchia copia statica
    nft -f /etc/nftables.conf
    systemctl restart docker         # Docker ricrea le sue tabelle pulite
    cd /opt/mysql57 && docker compose up -d
    

    Per il caricamento al boot, assicurati che il servizio sia attivo (systemctl enable --now nftables) e che /etc/nftables.conf non contenga flush ruleset.

    Gestire gli IP a runtime

    nft add element ip mysql_guard allowed { 203.0.113.50 }
    nft delete element ip mysql_guard allowed { 203.0.113.50 }
    

    Per renderlo permanente, aggiorna anche il set nel file.

    6. Verifica

    Dal server autorizzato:

    nc -zv IP_PUBBLICO_DB 3306        # deve rispondere "succeeded"
    

    Da qualsiasi altro IP:

    nmap -Pn -p 3306 IP_PUBBLICO_DB   # deve risultare "filtered" / timeout
    

    Sul server, il contatore della regola di drop deve crescere a ogni tentativo non autorizzato:

    nft list chain ip mysql_guard forward
    ss -tlnp | grep 3306
    

    7. Seconda barriera: gli utenti MySQL

    Il firewall non sostituisce i permessi del database. Crea l’utente con host limitato all’IP del client:

    CREATE USER 'pma_user'@'203.0.113.10' IDENTIFIED BY 'password_robusta';
    GRANT ALL ON nome_db.* TO 'pma_user'@'203.0.113.10';
    FLUSH PRIVILEGES;
    

    Se la connessione TCP riesce ma MySQL risponde Access denied, il problema è qui e non nel firewall.

    8. Cose da ricordare

    • Docker e firewall dell’host non si ignorano a vicenda: capire quale catena attraversa il traffico (input o forward) è la chiave di tutto.
    • Mai flush ruleset su un host con Docker; usa tabelle dedicate.
    • Non copiare le regole generate da Docker nei tuoi file: nomi di bridge e IP cambiano.
    • Se hai già esposto la porta, controlla i log in ./logs e valuta di cambiare le password, soprattutto quella di root.
    • Dove possibile, preferisci VPN (WireGuard) o tunnel SSH all’esposizione pubblica di un database.
    • Pianifica la migrazione da MySQL 5.7 a una versione supportata.

    Hai domande o una soluzione diversa per lo stesso problema? Scrivilo nei commenti.

  • Dopo un aggiornamento di Debian 12 (Bookworm) può succedere che MySQL non si avvii più e che nei log compaia un riferimento a MariaDB, anche se sul server è installato solo MySQL Community Server 8.0. In questa guida vediamo come riconoscere il problema, capirne la causa e risolverlo in pochi minuti, senza toccare i dati dei database.

    Il sintomo: mysql.service in stato failed

    Il servizio risulta fallito e il log riporta l’errore Can't read dir of '/etc/mysql/mariadb.conf.d/':

    # service mysql status
    × mysql.service - MySQL Community Server
         Loaded: loaded (/lib/systemd/system/mysql.service; enabled; preset: enabled)
         Active: failed (Result: exit-code)
         Status: "Server startup in progress"
    
    mysqld: Can't read dir of '/etc/mysql/mariadb.conf.d/' (OS errno 2 - No such file or directory)
    mysqld: [ERROR] Stopped processing the 'includedir' directive in file /etc/mysql/my.cnf at line 29.
    mysqld: [ERROR] Fatal error in defaults handling. Program aborted!
    systemd[1]: Failed to start mysql.service - MySQL Community Server.

    Controllando i pacchetti installati, MySQL è regolarmente presente (pacchetti mysql-community-server 8.0 dal repository ufficiale Oracle):

    dpkg -l | grep mysql

    La causa: l’alternativa my.cnf punta a mariadb.cnf

    Su Debian il file /etc/mysql/my.cnf non è un file vero e proprio, ma un collegamento gestito da update-alternatives. Più pacchetti possono registrare la propria versione e, in modalità automatica, vince quella con la priorità più alta.

    Durante l’aggiornamento è stato installato (e poi rimosso) il pacchetto mariadb-common, che registra /etc/mysql/mariadb.cnf con priorità 500, più alta di /etc/mysql/mysql.cnf (priorità 300). Il pacchetto è stato rimosso ma non eliminato del tutto (stato rc), quindi la sua configurazione è rimasta attiva. Alla riga 29 di quel file c’è l’inclusione di /etc/mysql/mariadb.conf.d/, una cartella che non esiste: MySQL legge la configurazione, non trova la directory e si interrompe.

    Buona notizia: i dati in /var/lib/mysql non sono stati toccati. È solo un problema di configurazione.

    Diagnosi: verificare dove punta my.cnf

    ls -l /etc/mysql/my.cnf /etc/alternatives/my.cnf
    update-alternatives --display my.cnf
    sed -n '25,32p' /etc/mysql/my.cnf
    dpkg -l | grep -i mariadb

    Se il problema è questo, l’output sarà simile al seguente:

    /etc/alternatives/my.cnf -> /etc/mysql/mariadb.cnf
    /etc/mysql/my.cnf -> /etc/alternatives/my.cnf
    
    my.cnf - modalità automatica
      la versione migliore del collegamento è /etc/mysql/mariadb.cnf
      il collegamento attualmente punta a /etc/mysql/mariadb.cnf
    /etc/mysql/mariadb.cnf - priorità 500
    /etc/mysql/my.cnf.fallback - priorità 100
    /etc/mysql/mysql.cnf - priorità 300
    
    !includedir /etc/mysql/conf.d/
    !includedir /etc/mysql/mariadb.conf.d/
    
    rc  mariadb-common   1:10.11.18-0+deb12u1   all   MariaDB common configuration files

    Soluzione passo passo

    1. Fare un backup della configurazione

    cp -a /etc/mysql /root/etc-mysql-backup-$(date +%F)

    2. Eliminare i residui di mariadb-common

    Il purge rimuove mariadb.cnf e la relativa voce in update-alternatives:

    apt purge mariadb-common

    3. Ripuntare my.cnf su mysql.cnf

    update-alternatives --remove my.cnf /etc/mysql/mariadb.cnf 2>/dev/null
    update-alternatives --set my.cnf /etc/mysql/mysql.cnf

    4. Verificare la configurazione

    update-alternatives --display my.cnf
    cat /etc/mysql/my.cnf
    my_print_defaults mysqld

    Ora my.cnf deve puntare a /etc/mysql/mysql.cnf e includere /etc/mysql/conf.d/ e /etc/mysql/mysql.conf.d/. Se my_print_defaults mysqld stampa i parametri senza errori, la configurazione è corretta.

    5. Avviare MySQL

    systemctl start mysql
    systemctl status mysql
    journalctl -u mysql -n 50 --no-pager

    Se my.cnf non è un collegamento simbolico

    Se /etc/mysql/my.cnf è un file normale oppure /etc/mysql/mysql.cnf non esiste, si può ricreare la configurazione a mano:

    mv /etc/mysql/my.cnf /etc/mysql/my.cnf.mariadb.bak
    cat > /etc/mysql/my.cnf <<'EOF'
    !includedir /etc/mysql/conf.d/
    !includedir /etc/mysql/mysql.conf.d/
    EOF
    mkdir -p /etc/mysql/conf.d
    systemctl start mysql

    Soluzione rapida provvisoria

    Se serve rimettere online il database subito, basta creare la cartella mancante. È però solo un tampone: MySQL continuerebbe a leggere la configurazione pensata per MariaDB, quindi è meglio applicare la soluzione completa appena possibile.

    mkdir /etc/mysql/mariadb.conf.d && systemctl start mysql

    Come evitare che si ripresenti

    Conviene capire quale aggiornamento ha installato MariaDB, consultando lo storico di apt:

    grep -i -B3 -A3 mariadb /var/log/apt/history.log*

    Per impedire che mariadb-common venga reinstallato in futuro si può bloccarlo con un pin APT a priorità negativa:

    cat > /etc/apt/preferences.d/no-mariadb <<'EOF'
    Package: mariadb-*
    Pin: release *
    Pin-Priority: -1
    EOF

    Dopo ogni aggiornamento importante è buona abitudine controllare anche lo stato dell’alternativa con update-alternatives --display my.cnf.

    Domande frequenti

    Perdo i dati dei database?

    No. Il problema riguarda solo i file di configurazione in /etc/mysql; i dati in /var/lib/mysql restano intatti.

    Perché Debian ha scelto mariadb.cnf?

    Perché l’alternativa my.cnf era in modalità automatica e mariadb.cnf ha priorità 500, più alta dei 300 di mysql.cnf. Con update-alternatives --set la scelta diventa manuale e non cambia più da sola.

    Vale anche per Ubuntu?

    Sì, il meccanismo di update-alternatives per my.cnf è lo stesso su Ubuntu e sulle altre distribuzioni derivate da Debian.

    Conclusione

    L’errore “Can’t read dir of /etc/mysql/mariadb.conf.d/” dopo un aggiornamento di Debian non indica un danno a MySQL, ma una configurazione residua di MariaDB rimasta attiva tramite update-alternatives. Eliminando mariadb-common con apt purge e reimpostando my.cnf su mysql.cnf, MySQL torna a funzionare regolarmente.