<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>MySQL - Matteo Cisilino</title>
	<atom:link href="https://www.cisilino.com/tag/mysql/feed/" rel="self" type="application/rss+xml" />
	<link>https://www.cisilino.com</link>
	<description>Guide su WordPress, server e sicurezza</description>
	<lastBuildDate>Sat, 31 Oct 2026 10:03:56 +0000</lastBuildDate>
	<language>it-IT</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.1.4</generator>
	<item>
		<title>Rotazione delle credenziali database dopo una violazione ISPConfig</title>
		<link>https://www.cisilino.com/2026/10/31/rotazione-credenziali-database-violazione-ispconfig/</link>
					<comments>https://www.cisilino.com/2026/10/31/rotazione-credenziali-database-violazione-ispconfig/#respond</comments>
		
		<dc:creator><![CDATA[matteocisilino]]></dc:creator>
		<pubDate>Sat, 31 Oct 2026 10:03:56 +0000</pubDate>
				<category><![CDATA[Server e database]]></category>
		<category><![CDATA[ISPConfig]]></category>
		<category><![CDATA[MySQL]]></category>
		<category><![CDATA[risposta agli incidenti]]></category>
		<category><![CDATA[rotazione credenziali]]></category>
		<category><![CDATA[script bash]]></category>
		<category><![CDATA[server management]]></category>
		<guid isPermaLink="false">https://www.cisilino.com/?p=1414</guid>

					<description><![CDATA[<p>Perché la rotazione delle credenziali database diventa urgente dopo una violazione ISPConfig sospetta, e come lo script a tre fasi la gestisce con backup integrati.</p>
<p>The post <a href="https://www.cisilino.com/2026/10/31/rotazione-credenziali-database-violazione-ispconfig/">Rotazione delle credenziali database dopo una violazione ISPConfig</a> first appeared on <a href="https://www.cisilino.com">Matteo Cisilino</a>.</p>]]></description>
										<content:encoded><![CDATA[<p class="wp-block-paragraph">Ruoto le credenziali nel momento in cui sospetto una violazione. Non dopo averla confermata. La rotazione delle credenziali database su un server ISPConfig suona come manutenzione di routine. Smette di essere routine il giorno in cui trovi una web shell su un sito ospitato. O un file di configurazione che non sai spiegare, finito in un backup pubblico. A quel punto, ogni password legata a quello stack di database deve essere nuova. Verificata. Documentata. Non semplicemente presunta a posto.</p>



<figure class="wp-block-image size-large"><img fetchpriority="high" decoding="async" src="https://www.cisilino.com/wp-content/uploads/2026/10/database-credential-rotation-ispconfig.webp" alt="rotazione delle credenziali database: lucchetto e chiave sopra un database ISPConfig" width="1200" height="630"/></figure>



<h2 class="wp-block-heading">Quando la rotazione non è più facoltativa?</h2>



<p class="wp-block-paragraph">Ci sono alcuni scenari che tornano a sufficienza da farmi tenere pronto un piano di rotazione. Preferisco non improvvisarne uno sotto pressione.</p>



<p class="wp-block-paragraph">Un plugin o un CMS su un sito ospitato viene compromesso. Ho raccontato un caso reale su questo tipo di server in <a href="https://www.cisilino.com/2026/10/29/php2shell-cve-2026-109618-mitigazione-server/">PHP2Shell: come ho protetto i server in attesa della patch</a>, una vulnerabilità PHP che permetteva a un attaccante di piazzare una web shell prima che esistesse una patch. Una volta che una web shell viene eseguita sotto un utente di hosting, spesso può leggere il config.inc.php di quel sito. Su un box ISPConfig condiviso, quel file sta proprio accanto alle credenziali del database del pannello di controllo stesso. Non solo la credenziale che l&#8217;attaccante stava effettivamente cercando.</p>



<p class="wp-block-paragraph">Un file di configurazione finisce dove non dovrebbe. Un archivio di backup lasciato in una cartella pubblica. Un repository pubblicato con un file fuori posto. Un log più leggibile di quanto chiunque intendesse. config.inc.php e mysql_clientdb.conf non hanno motivo di stare in nessuno di questi posti. Gli errori succedono comunque, compresi i miei.</p>



<p class="wp-block-paragraph">Un amministratore di sistema lascia il team. Per quanto amichevole sia l&#8217;addio, le credenziali database condivise che deteneva sono credenziali di cui non mi fido più del tutto. La fiducia non si trasferisce automaticamente quando qualcuno esce dalla porta.</p>



<p class="wp-block-paragraph">Un&#8217;intrusione sospetta, anche non confermata. Questo è il caso più difficile da spiegare a chi non ha mai gestito un server sotto sospetto di attacco. Non si aspetta la prova prima di agire. Si presume la compromissione e si ruota. Una rotazione non necessaria costa un pomeriggio. Una saltata, dopo una violazione reale, può costare ogni sito ospitato dal server.</p>



<h2 class="wp-block-heading">Perché la rotazione delle credenziali database manuale è così incline all&#8217;errore</h2>



<p class="wp-block-paragraph">La prima volta che ho ruotato a mano le credenziali database di ISPConfig, ho sottovalutato quanti file fossero coinvolti. Devono tutti cambiare insieme, altrimenti qualcosa si rompe silenziosamente poche ore dopo.</p>



<p class="wp-block-paragraph">C&#8217;è config.inc.php sul lato server, e una seconda copia sul lato interfaccia. Entrambe devono avere gli stessi nuovi valori. C&#8217;è mysql_clientdb.conf, che ISPConfig legge per gestire il database di ogni sito ospitato, non solo la propria manutenzione interna. Dovecot, Pure-FTPd e Postfix mantengono ciascuno una propria copia di alcune delle stesse credenziali. Mail e FTP possono smettere di autenticarsi silenziosamente se aggiorni l&#8217;utente del database e ne dimentichi anche solo uno dei tre.</p>



<p class="wp-block-paragraph">Poi c&#8217;è la topologia Master/Slave che alcune delle mie installazioni usano. Un nodo Slave si autentica al database del suo Master sulla rete, con una password separata. Quella password va ruotata sul Master stesso, non solo in locale. Sbaglia la sequenza, e uno Slave può chiudersi fuori dallo stesso Master da cui dovrebbe replicare.</p>



<p class="wp-block-paragraph">Niente di tutto questo è concettualmente difficile. Sono tanti file. Diversi servizi da riavviare, in un ordine sensato. Quasi nessuna tolleranza per un errore di digitazione, specialmente mentre lo fai sotto pressione, appena dopo aver trovato qualcosa che non volevi trovare. Questa combinazione è esattamente il motivo per cui la rotazione delle credenziali database continuava a sembrarmi più rischiosa a mano di quanto il compito stesso dovrebbe essere. È per questo che ho scritto reset_DB_password.sh, dopo aver vissuto il problema io stesso più di una volta.</p>



<h2 class="wp-block-heading">Cosa fa davvero reset_DB_password.sh</h2>



<p class="wp-block-paragraph">reset_DB_password.sh rileva se il nodo è Standalone, Master o Slave. Poi esegue solo le fasi che quel ruolo richiede.</p>



<p class="wp-block-paragraph">La Fase A ruota la password root locale di MySQL. È la password che ISPConfig stesso usa per gestire il database di ogni sito ospitato. Lo script aggiorna mysql_clientdb.conf per farlo corrispondere al nuovo valore.</p>



<p class="wp-block-paragraph">La Fase B ruota la password dell&#8217;utente di controllo ispconfig. Aggiorna config.inc.php sia sul lato server che sul lato interfaccia. Poi aggiorna le credenziali corrispondenti dentro Dovecot, Pure-FTPd e Postfix, così mail e FTP continuano ad autenticarsi senza un passaggio manuale separato su altri tre file di configurazione.</p>



<p class="wp-block-paragraph">La Fase C viene eseguita solo su uno Slave. Si connette da remoto al database del Master. Ruota lì la password dell&#8217;utente Slave, poi aggiorna dbmaster_password in locale, così la replica continua ad autenticarsi correttamente con il nuovo valore.</p>



<p class="wp-block-paragraph">Dopo la Fase B, lo script offre di riavviare Dovecot, Pure-FTPd e Postfix per te. Dico quasi sempre di sì. Una rotazione delle credenziali che lascia un servizio di posta in esecuzione con la vecchia password non è finita, è solo silenziosamente rotta. Riavviare nella stessa sessione, subito dopo il cambio dei file di configurazione, mantiene tutto dentro una sola breve finestra di manutenzione invece di tre scoperte separatamente più avanti.</p>



<h2 class="wp-block-heading">Backup prima che qualcosa cambi</h2>



<p class="wp-block-paragraph">Prima di toccare un solo file, lo script esegue una copia rsync completa di /usr/local/ispconfig/ in una cartella con timestamp. Tenta anche un mysqldump non bloccante dei database mysql e dbispconfig. Non bloccante di proposito: preferisco finire una rotazione delle credenziali database piuttosto che restare in attesa di un dump bloccato per qualche motivo non collegato. Ogni file di configurazione che modifica riceve prima una propria copia .bak, prima che qualsiasi stringa al suo interno venga sostituita.</p>



<p class="wp-block-paragraph">Niente di tutto questo sostituisce una strategia di backup indipendente. Continuo a fare uno snapshot separato, o un backup completo, prima di eseguire questo script su qualsiasi server che conta davvero. Il disclaimer dello script stesso dice la stessa cosa, in modo più diretto di quanto abbia appena fatto io.</p>



<h2 class="wp-block-heading">Dry run prima, sempre</h2>



<p class="wp-block-paragraph">Ogni fase chiede conferma prima di cambiare qualcosa. &#8211;dry-run simula un&#8217;intera esecuzione senza toccare un solo file. Uso il dry-run prima su ogni server, ogni volta. Anche su quelli su cui ho già ruotato le credenziali, e penso di conoscere bene. &#8211;skip-local-root salta la Fase A, per quando serve ruotare solo la password dell&#8217;utente di controllo. &#8211;role=master o &#8211;role=slave forzano il rilevamento automatico, per il raro caso in cui non legga correttamente da solo la topologia di un server.</p>



<p class="wp-block-paragraph">Le password che genera sono di 32 caratteri, solo lettere e cifre. Lunghe abbastanza da resistere a un tentativo di indovinare. Semplici abbastanza che i parser di MySQL e PHP non si inceppino mai su un simbolo fuori posto in una stringa di connessione. Ho testato lo script su ISPConfig 3.3.1p1. Non l&#8217;ho eseguito contro rami più vecchi o più nuovi. Non darei per scontato un comportamento identico lì, senza prima verificarlo io stesso.</p>



<p class="wp-block-paragraph">Il ruolo di un nodo non è sempre ovvio a colpo d&#8217;occhio, specialmente su un server che hai ereditato invece di costruire tu stesso. È per questo che esiste &#8211;role, e perché l&#8217;output del dry-run merita di essere letto riga per riga la prima volta, non scorso velocemente. Preferirei passare due minuti a confermare che uno Slave è davvero uno Slave, piuttosto che scoprirlo nel modo peggiore a metà rotazione.</p>



<h2 class="wp-block-heading">Cosa ti direi prima che tu lo esegua</h2>



<p class="wp-block-paragraph"><strong>Avviso</strong>: lo script è fornito così com&#8217;è, a scopo informativo. L&#8217;uso è sotto la tua esclusiva responsabilità: non mi assumo alcuna responsabilità per perdita di dati, interruzioni di servizio, o qualsiasi errore generato da un utilizzo scorretto o da un contesto diverso da quello in cui lo eseguo io stesso. Fai sempre un backup indipendente o uno snapshot VM prima di eseguirlo in produzione, indipendentemente da ciò che il passaggio di backup dello script già catturi per te.</p>



<p class="wp-block-paragraph">È software libero. Puoi ridistribuirlo e modificarlo, con attribuzione a me come autore originale. Il codice sorgente di reset_DB_password.sh, e il resto della collezione ispconfig-fast-tools, è su GitHub su <a href="https://github.com/matteocisilino/ispconfig-fast-tools" target="_blank" rel="noopener">github.com/matteocisilino/ispconfig-fast-tools</a>.</p>



<h2 class="wp-block-heading">La rotazione delle credenziali database è un&#8217;abitudine, non un lavoro di pulizia</h2>



<p class="wp-block-paragraph">La rotazione delle credenziali database dopo una violazione confermata è semplicemente una risposta agli incidenti competente. La maggior parte degli amministratori lo farebbe senza bisogno di sentirlo due volte. L&#8217;abitudine più difficile è farlo dopo una violazione solo sospettata, prima che ci sia una prova. È l&#8217;unica versione del piano che limita davvero il danno, mentre è ancora limitabile. Uno script che esegue le fasi giuste per il ruolo di un server, fa prima i propri backup, e mi permette di simulare l&#8217;intera esecuzione prima di toccare qualcosa di reale. È quello che rende l&#8217;abitudine praticabile un martedì sera, invece che teorica in un articolo di blog.</p>



<p class="wp-block-paragraph">ISPConfig, MySQL, PHP, Dovecot, Pure-FTPd, Postfix e gli altri nomi citati sono marchi dei rispettivi proprietari. Sono usati solo per identificare i prodotti.</p><p>The post <a href="https://www.cisilino.com/2026/10/31/rotazione-credenziali-database-violazione-ispconfig/">Rotazione delle credenziali database dopo una violazione ISPConfig</a> first appeared on <a href="https://www.cisilino.com">Matteo Cisilino</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://www.cisilino.com/2026/10/31/rotazione-credenziali-database-violazione-ispconfig/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Quale database occupa spazio sul disco? Lo script per MySQL e PostgreSQL</title>
		<link>https://www.cisilino.com/2026/10/20/script-spazio-disco-database-mysql/</link>
					<comments>https://www.cisilino.com/2026/10/20/script-spazio-disco-database-mysql/#respond</comments>
		
		<dc:creator><![CDATA[matteocisilino]]></dc:creator>
		<pubDate>Tue, 20 Oct 2026 07:53:00 +0000</pubDate>
				<category><![CDATA[Server e database]]></category>
		<category><![CDATA[MySQL]]></category>
		<category><![CDATA[PostgreSQL]]></category>
		<category><![CDATA[script bash]]></category>
		<category><![CDATA[spazio disco]]></category>
		<guid isPermaLink="false">https://www.cisilino.com/?p=51</guid>

					<description><![CDATA[<p>Il disco del server si riempie e non sai quale database sia il responsabile? Uno script per misurare lo spazio di MySQL e PostgreSQL e confrontarlo nel tempo.</p>
<p>The post <a href="https://www.cisilino.com/2026/10/20/script-spazio-disco-database-mysql/">Quale database occupa spazio sul disco? Lo script per MySQL e PostgreSQL</a> first appeared on <a href="https://www.cisilino.com">Matteo Cisilino</a>.</p>]]></description>
										<content:encoded><![CDATA[<p class="wp-block-paragraph">Capita a tutti prima o poi: lo spazio su disco del server finisce, l’allarme scatta. La domanda è sempre la stessa. <strong>Quale database sta occupando tutto questo spazio?</strong></p>



<figure class="wp-block-image size-large"><img decoding="async" src="https://www.cisilino.com/wp-content/uploads/2026/10/spazio-disco-database-mysql-postgresql.webp" alt="Grafico ad anello in quattro settori con tre barre di legenda: lo spazio su disco" width="1200" height="630"/></figure>



<p class="wp-block-paragraph">Io mi ci sono trovato di fronte dopo un’ottimizzazione. Avevo lanciato <code>OPTIMIZE TABLE</code> 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 e quale database fosse il vero responsabile. O se lo spazio mancante fosse addirittura da un’altra parte.</p>



<p class="wp-block-paragraph">Gli strumenti che avevo a disposizione rispondevano solo a metà del problema:</p>



<ul class="wp-block-list">
<li><code>du</code> mostra i file su disco, ma non dice a quale tabella appartengono.</li>



<li><code>information_schema</code> (o <code>pg_class</code> su PostgreSQL) dà le dimensioni logiche, ma non vede binlog, <code>ibdata1</code>, undo log o WAL.</li>



<li>entrambi fotografano un solo istante: il confronto nel tempo resta un lavoro manuale, fatto con copia e incolla e un foglio di calcolo.</li>
</ul>



<p class="wp-block-paragraph">Mi serviva una cosa semplice: <strong>fotografare lo stato di tutti i database in un dato momento, rifare la fotografia più tardi e farmi dire cosa è cambiato</strong>, ordinando le differenze dalla più grande alla più piccola. Così il colpevole salta subito all’occhio.</p>



<p class="wp-block-paragraph">Ne è uscito uno script Bash unico. Non ha dipendenze oltre al client del database e agli strumenti standard di Linux (<code>awk</code>, <code>du</code>, <code>df</code>). Funziona con <strong>MySQL/MariaDB</strong> e con <strong>PostgreSQL</strong>. Lo condivido qui nella speranza che faccia risparmiare a qualcuno lo stesso pomeriggio di frustrazione.</p>



<h2 class="wp-block-heading">Cosa fa</h2>



<p class="wp-block-paragraph">Lo script ha quattro comandi:</p>



<ul class="wp-block-list">
<li><code>snapshot</code> salva lo stato attuale (database, tabelle, file nella data directory, spazio del filesystem).</li>



<li><code>compare</code> confronta due fotografie e mostra le differenze, le più grandi in cima.</li>



<li><code>report</code> mostra i database e le tabelle più grandi di una singola fotografia.</li>



<li><code>list</code> elenca le fotografie salvate.</li>
</ul>



<p class="wp-block-paragraph">Il confronto è diviso in quattro sezioni, per non lasciare angoli bui:</p>



<ul class="wp-block-list">
<li><strong>dimensione di ogni database</strong> (dati + indici).</li>



<li><strong>spazio libero o recuperabile</strong> (<code>data_free</code> su MySQL, stima delle tuple morte su PostgreSQL).</li>



<li><strong>dimensione di ogni tabella</strong>.</li>



<li><strong>file reali nella data directory</strong>: cartelle dei database, <code>ibdata1</code>, binlog, undo, <code>pg_wal</code>.</li>
</ul>



<p class="wp-block-paragraph">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.</p>



<h2 class="wp-block-heading">Requisiti</h2>



<ul class="wp-block-list">
<li>Linux con Bash 4 o superiore, <code>awk</code>, <code>du</code>, <code>df</code> (tutto di serie).</li>



<li>il client <code>mysql</code> oppure <code>psql</code>, con un utente che possa leggere i metadati di tutti i database.</li>



<li>i permessi di root (o dell’utente <code>mysql</code>/<code>postgres</code>) se vuoi misurare anche i file su disco; senza, quella sezione viene saltata con un avviso.</li>
</ul>



<h2 class="wp-block-heading">Installazione</h2>



<p class="wp-block-paragraph">Salva lo script (lo trovi in fondo all’articolo) in un file e rendilo eseguibile.</p>



<pre class="wp-block-code"><code>nano db_space_compare.sh
chmod +x db_space_compare.sh</code></pre>



<h3 class="wp-block-heading">Credenziali</h3>



<p class="wp-block-paragraph">Per MySQL/MariaDB il modo più pulito è un file <code>~/.my.cnf</code> con le credenziali, così non compaiono nella cronologia dei comandi.</p>



<pre class="wp-block-code"><code>&#91;client]
user=root
password=LA_TUA_PASSWORD</code></pre>



<p class="wp-block-paragraph">In alternativa puoi passare le opzioni con una variabile d’ambiente.</p>



<pre class="wp-block-code"><code>export MYSQL_OPTS="-u root -pPASSWORD -h 127.0.0.1"</code></pre>



<p class="wp-block-paragraph">Per PostgreSQL funzionano <code>~/.pgpass</code>, le variabili <code>PG*</code> oppure <code>PG_OPTS</code>.</p>



<pre class="wp-block-code"><code>export PG_OPTS="-U postgres -h localhost"</code></pre>



<h2 class="wp-block-heading">Come si usa</h2>



<p class="wp-block-paragraph">Il flusso tipico è <em>prima, ottimizzazione, dopo, confronto</em>.</p>



<pre class="wp-block-code"><code># 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</code></pre>



<p class="wp-block-paragraph">Per PostgreSQL basta aggiungere l’opzione <code>-e postgres</code>.</p>



<pre class="wp-block-code"><code>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</code></pre>



<p class="wp-block-paragraph">Le opzioni disponibili sono:</p>



<ul class="wp-block-list">
<li><code>-e mysql|postgres</code> sceglie il motore (predefinito: <code>mysql</code>).</li>



<li><code>-d DIR</code> cambia la cartella in cui salvare le fotografie (predefinita: <code>./db_snapshots</code>).</li>



<li><code>-n N</code> imposta quante righe mostrare per sezione (predefinito: 20).</li>
</ul>



<p class="wp-block-paragraph">Se non indichi un’etichetta, la fotografia prende come nome la data e l’ora. Questo è comodo per monitorare la crescita nel tempo.</p>



<h2 class="wp-block-heading">Esempio di output</h2>



<p class="wp-block-paragraph">Ecco un confronto su dati di prova, costruiti apposta per mostrare un caso tipico. Il database <code>shop</code> si riduce dopo l’ottimizzazione, ma il database <code>log</code> cresce e i binlog raddoppiano.</p>



<pre class="wp-block-code"><code>Confronto 'prima' (2026-10-11 20:00:00)  -&gt;  'dopo' (2026-10-11 22:00:00)
Filesystem /var: usato 46.6 GiB -&gt; 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</code></pre>



<p class="wp-block-paragraph">Qui il colpevole è evidente: la tabella <code>log.eventi</code> è cresciuta di oltre 6 GiB. I file <code>mysql-bin.*</code> sono passati da 3,7 a 8,4 GiB. L’ottimizzazione su <code>shop</code> ha funzionato, ma il guadagno è stato mangiato dal resto.</p>



<h2 class="wp-block-heading">Come leggere i risultati e ritrovare lo spazio su disco</h2>



<ul class="wp-block-list">
<li>In cima a ogni sezione ci sono le voci cambiate di più. Un database o una tabella che <strong>non cala</strong> (o addirittura cresce) dopo l’ottimizzazione è il primo sospetto.</li>



<li>Se le tabelle calano ma il filesystem no, lo spazio su disco è altrove. Guarda la sezione sui file nella data directory (binlog, <code>ibdata1</code>, undo, <code>pg_wal</code>).</li>



<li><strong>MySQL/MariaDB:</strong> <code>ibdata1</code> non si restringe mai, nemmeno con <code>OPTIMIZE TABLE</code>. Con <code>innodb_file_per_table=OFF</code> il valore di <code>data_free</code> è condiviso e poco indicativo.</li>



<li><strong>PostgreSQL:</strong> un <code>VACUUM</code> semplice non restituisce spazio al sistema operativo, serve <code>VACUUM FULL</code> (o <code>pg_repack</code>). Per capire come PostgreSQL misura le dimensioni, la <a href="https://www.postgresql.org/docs/current/diskusage.html" target="_blank" rel="noopener">documentazione ufficiale sull’uso del disco</a> spiega come controllare lo spazio di tabelle e indici. Lo spazio “recuperabile” mostrato è una stima basata sulle tuple morte.</li>
</ul>



<h2 class="wp-block-heading">Monitorare la crescita nel tempo</h2>



<p class="wp-block-paragraph">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.</p>



<pre class="wp-block-code"><code># crontab -e (come root)
0 * * * * /usr/local/bin/db_space_compare.sh -d /var/lib/db_snapshots snapshot &gt;/dev/null 2&gt;&amp;1</code></pre>



<p class="wp-block-paragraph">Senza etichetta, ogni fotografia prende come nome la data e l’ora (per esempio <code>20261011-140000</code>). Con <code>list</code> vedi quelle disponibili e con <code>compare</code> ne confronti due. In pochi giorni emerge quale database cresce in modo anomalo.</p>



<pre class="wp-block-code"><code>./db_space_compare.sh -d /var/lib/db_snapshots list
./db_space_compare.sh -d /var/lib/db_snapshots compare 20261011-140000 20261012-140000</code></pre>



<h2 class="wp-block-heading">Lo script completo</h2>



<p class="wp-block-paragraph">Ecco il codice. Copialo in un file chiamato <code>db_space_compare.sh</code>, rendilo eseguibile e sei pronto.</p>



<pre class="wp-block-code"><code>#!/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 &#91;opzioni] snapshot &#91;etichetta]   (default: data-ora)
#   db_space_compare.sh &#91;opzioni] compare  &lt;prima&gt; &lt;dopo&gt;
#   db_space_compare.sh &#91;opzioni] report   &lt;etichetta&gt;
#   db_space_compare.sh &#91;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: $*" &gt;&amp;2; exit 1; }
warn() { echo "ATTENZIONE: $*" &gt;&amp;2; }
usage() { sed -n '2,/^$/{s/^# \{0,1\}//;p}' "$0"; exit "${1:-0}"; }

cleanup() {
  if &#91;&#91; -n $SNAP_IN_PROGRESS ]]; then
    rm -rf -- "$SNAP_IN_PROGRESS"
    echo "Fotografia incompleta rimossa: $SNAP_IN_PROGRESS" &gt;&amp;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))

&#91;&#91; $ENGINE == mysql || $ENGINE == postgres ]] || die "motore non valido: '$ENGINE' (usa mysql o postgres)"
&#91;&#91; $TOPN =~ ^&#91;0-9]+$ ]] || die "-n deve essere un numero"

read -r -a MYSQL_ARGS &lt;&lt;&lt; "${MYSQL_OPTS:-}"
read -r -a PG_ARGS    &lt;&lt;&lt; "${PG_OPTS:-}"

my() { mysql "${MYSQL_ARGS&#91;@]+"${MYSQL_ARGS&#91;@]}"}" --batch --skip-column-names "$@"; }
pg() { psql  "${PG_ARGS&#91;@]+"${PG_ARGS&#91;@]}"}" -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 &lt; 0) ? -b : b; i = 1
  while (a &gt;= 1024 &amp;&amp; i &lt; 6) { a /= 1024; i++ }
  if (i == 1) return sprintf("%s%d B", (b &lt; 0 ? "-" : ""), a)
  return sprintf("%s%.1f %s", (b &lt; 0 ? "-" : ""), a, u&#91;i])
}
function hs(b) { return (b &gt; 0 ? "+" : "") h(b) }
function key(   k, i) { k = $1; for (i = 2; i &lt;= nk; i++) k = k "." $i; return k }
function val(   n, c, i, s) { n = split(vc, c, ","); s = 0; for (i = 1; i &lt;= n; i++) s += $(c&#91;i]); return s }
'

meta_get() { awk -F= -v k="$2" '$1 == k { sub(/^&#91;^=]*=/, ""); 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
  : &gt; "$out"
  if &#91;&#91; -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&gt;/dev/null || true; } |
    awk -F'\t' -v p="$d/" '
      { n = $2
        if (index(n, p) == 1) n = substr(n, length(p) + 1)
        if (n ~ /\.&#91;0-9]&#91;0-9]&#91;0-9]&#91;0-9]&#91;0-9]&#91;0-9]$/) sub(/&#91;0-9]+$/, "*", n)
        s&#91;n] += $1 }
      END { for (n in s) printf "%s\t%.0f\n", n, s&#91;n] }' &gt; "$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 &#91;&#91; -n $datadir &amp;&amp; -e $datadir ]]; then
      { df -P -B1 -- "$datadir" 2&gt;/dev/null || true; } |
        awk 'NR == 2 { print "fs_mount=" $6; print "fs_used=" $3; print "fs_avail=" $4 }'
    fi
  } &gt; "$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 &gt;/dev/null 2&gt;&amp;1 || die "client 'mysql' non trovato"
  my -e "SELECT 1" &gt;/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 &#91;&#91; -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')" &gt; "$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" &gt; "$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 &gt;/dev/null 2&gt;&amp;1 || die "client 'psql' non trovato"
  pg -d "$maindb" -c "SELECT 1" &gt;/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" &gt; "$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).
  : &gt; "$dir/tables.tsv"
  while IFS= read -r db; do
    &#91;&#91; -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 &gt; 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 &lt;&gt; 'information_schema'
         AND s.schemaname NOT LIKE 'pg_toast%'" &lt; /dev/null &gt;&gt; "$dir/tables.tsv" \
      || warn "impossibile leggere le tabelle del database '$db'"
  done &lt; "$dir/.dblist"

  pg -d "$maindb" -c "SELECT datname, pg_database_size(oid) FROM pg_database WHERE datallowconn AND NOT datistemplate" &gt; "$dir/.dbsize"
  awk -F'\t' '
    FILENAME == ARGV&#91;1] { d&#91;$1] += $4; i&#91;$1] += $5; f&#91;$1] += $6; next }
    { printf "%s\t%.0f\t%.0f\t%.0f\t%s\n", $1, d&#91;$1], i&#91;$1], f&#91;$1], $2 }' \
    "$dir/tables.tsv" "$dir/.dbsize" &gt; "$dir/databases.tsv"

  datadir=$(pg -d "$maindb" -c "SHOW data_directory" 2&gt;/dev/null || true)
  version=$(pg -d "$maindb" -c "SHOW server_version")
  collect_disk "$datadir" "$dir/disk.tsv"

  # Dettaglio di base/&lt;oid&gt; con il nome del database al posto dell'oid
  if &#91;&#91; -n $datadir &amp;&amp; -r $datadir/base &amp;&amp; -x $datadir/base ]]; then
    pg -d "$maindb" -c "SELECT oid, datname FROM pg_database" &gt; "$dir/.oidmap"
    { du -sb -- "$datadir"/base/* 2&gt;/dev/null || true; } |
      awk -F'\t' '
        FILENAME == ARGV&#91;1] { name&#91;$1] = $2; next }
        { o = $2; sub(/.*\//, "", o); printf "base/%s\t%s\n", ((o in name) ? name&#91;o] : o), $1 }' \
        "$dir/.oidmap" - &gt;&gt; "$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)
  &#91;&#91; -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 &#91;&#91; ! -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 &lt;= top &amp;&amp; $1 &gt; 0 { printf "  %-60s %12s\n", $2, $3 }'
}

compare_section() {
  local fa=$1 fb=$2 nk=$3 vc=$4 title=$5
  &#91;&#91; -f $fa ]] || fa=/dev/null
  &#91;&#91; -f $fb ]] || fb=/dev/null
  echo
  echo "=== $title ==="
  if &#91;&#91; ! -s $fa &amp;&amp; ! -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&#91;1] { k = key(); A&#91;k] = val(); S&#91;k] = 1; next }
                        { k = key(); B&#91;k] = val(); S&#91;k] = 1 }
    END {
      for (k in S) {
        inA = (k in A); inB = (k in B)
        a = inA ? A&#91;k] : 0; b = inB ? B&#91;k] : 0; d = b - a
        if (!inA)                 note = "NUOVO"
        else if (!inB)            note = "RIMOSSO"
        else if (a &gt; 0 &amp;&amp; d != 0) note = sprintf("%+.1f%%", 100 * d / a)
        else                      note = ""
        printf "%.0f\t%s\t%s\t%s\t%s\t%s\n", (d &lt; 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 &lt;= 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')}
  &#91;&#91; $label =~ ^&#91;A-Za-z0-9]&#91;A-Za-z0-9._-]*$ ]] || die "etichetta non valida: usa solo lettere, numeri, . _ -"
  local dir=$OUTDIR/$label
  &#91;&#91; ! -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 &lt; "$dir/tables.tsv") tabelle)"
  print_fs "$dir"
  top_section "$dir/databases.tsv" 1 5 "Database più grandi"
}

cmd_report() {
  local label=${1:-}
  &#91;&#91; -n $label ]] || die "uso: report &lt;etichetta&gt;"
  local d=$OUTDIR/$label
  &#91;&#91; -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:-}
  &#91;&#91; -n $a &amp;&amp; -n $b ]] || die "uso: compare &lt;etichetta_prima&gt; &lt;etichetta_dopo&gt;"
  &#91;&#91; $a != "$b" ]] || die "le due etichette sono uguali"
  local da=$OUTDIR/$a db=$OUTDIR/$b
  &#91;&#91; -f $da/meta.txt ]] || die "fotografia '$a' non trovata in $OUTDIR"
  &#91;&#91; -f $db/meta.txt ]] || die "fotografia '$b' non trovata in $OUTDIR"
  &#91;&#91; $(meta_get "$da" engine) == "$(meta_get "$db" engine)" ]] || warn "le due fotografie sono di motori diversi"

  echo "Confronto '$a' ($(meta_get "$da" timestamp))  -&gt;  '$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 &#91;&#91; -n $ua &amp;&amp; -n $ub ]]; then
    awk -v a="$ua" -v b="$ub" -v m="$m" "$AWK_LIB"'
      BEGIN { printf "Filesystem %s: usato %s -&gt; %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 &lt;&lt;'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 &#91;&#91; ! -d $OUTDIR ]]; then echo "Nessuna fotografia in $OUTDIR"; return 0; fi
  local d found=0
  for d in "$OUTDIR"/*/; do
    &#91;&#91; -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
  &#91;&#91; $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</code></pre>



<h2 class="wp-block-heading">Note finali</h2>



<ul class="wp-block-list">
<li>Lo script è in <strong>sola lettura</strong> sui database: esegue solo <code>SELECT</code> sui metadati. Scrive esclusivamente nella cartella delle fotografie.</li>



<li>Su MySQL 8 imposta <code>information_schema_stats_expiry = 0</code> nella sessione. Altrimenti le statistiche in cache (fino a 24 ore) potrebbero mostrare i valori “vecchi” anche dopo l’ottimizzazione.</li>



<li>Se una fotografia fallisce a metà, viene rimossa automaticamente per non lasciare dati incompleti.</li>



<li>Se invece il problema è un database che non parte dopo un aggiornamento, leggi <a href="https://www.cisilino.com/2026/10/13/mysql-non-si-avvia-debian-12/">MySQL non si avvia dopo l’aggiornamento a Debian 12</a>.</li>
<li>Prima di usarlo in produzione, provalo su un ambiente di test e verifica i permessi dell’utente del database.</li>
</ul>



<p class="wp-block-paragraph">Se lo usi per tenere d’occhio lo spazio su disco e trovi un caso che non copre, o hai idee per estenderlo (altri motori, esportazione in CSV, avvisi via email), scrivimi nei commenti.</p><p>The post <a href="https://www.cisilino.com/2026/10/20/script-spazio-disco-database-mysql/">Quale database occupa spazio sul disco? Lo script per MySQL e PostgreSQL</a> first appeared on <a href="https://www.cisilino.com">Matteo Cisilino</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://www.cisilino.com/2026/10/20/script-spazio-disco-database-mysql/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>MySQL 5.7 in Docker protetto con nftables</title>
		<link>https://www.cisilino.com/2026/10/17/mysql-5-7-docker-nftables/</link>
					<comments>https://www.cisilino.com/2026/10/17/mysql-5-7-docker-nftables/#respond</comments>
		
		<dc:creator><![CDATA[matteocisilino]]></dc:creator>
		<pubDate>Sat, 17 Oct 2026 08:10:45 +0000</pubDate>
				<category><![CDATA[Server e database]]></category>
		<category><![CDATA[Docker]]></category>
		<category><![CDATA[firewall]]></category>
		<category><![CDATA[MySQL]]></category>
		<category><![CDATA[nftables]]></category>
		<guid isPermaLink="false">https://www.cisilino.com/?p=54</guid>

					<description><![CDATA[<p>Come far girare MySQL 5.7 in Docker su un server moderno senza esporlo a internet: docker-compose, porta chiusa e firewall nftables.</p>
<p>The post <a href="https://www.cisilino.com/2026/10/17/mysql-5-7-docker-nftables/">MySQL 5.7 in Docker protetto con nftables</a> first appeared on <a href="https://www.cisilino.com">Matteo Cisilino</a>.</p>]]></description>
										<content:encoded><![CDATA[<h2 class="wp-block-heading">Premessa</h2>



<p class="wp-block-paragraph">Ci siamo trovati nella necessità di avere un database <strong>MySQL 5.7 in Docker</strong> 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). Migrarle in tempi brevi non era un&#8217;opzione.</p>



<figure class="wp-block-image size-large"><img decoding="async" src="https://www.cisilino.com/wp-content/uploads/2026/10/mysql-5-7-docker-nftables.webp" alt="Un database dentro un riquadro tratteggiato arancione: MySQL 5.7 in Docker" width="1200" height="630"/></figure>



<p class="wp-block-paragraph">Installare una versione così datata direttamente sull&#8217;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. Un eventuale aggiornamento dell&#8217;OS potrebbe romperla.</p>



<p class="wp-block-paragraph">Abbiamo quindi deciso di <strong>isolarla in un container Docker</strong>: l&#8217;host resta pulito e aggiornato. La versione del database è fissata e riproducibile. Dati e configurazione stanno in una cartella facile da salvare e spostare.</p>



<p class="wp-block-paragraph">Questo articolo racconta come l&#8217;abbiamo configurata e, soprattutto, gli ostacoli incontrati lungo la strada. Sono l&#8217;esposizione involontaria della porta 3306 e un errore di iptables al riavvio. Poi la scrittura di un firewall con <strong>nftables</strong> che lasci passare solo gli IP autorizzati.</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><strong>Nota:</strong> MySQL 5.7 è giunto a fine vita (EOL) a ottobre 2023 e non riceve più patch di sicurezza. È una soluzione ponte: l&#8217;obiettivo resta migrare a MySQL 8.x o MariaDB. Proprio per questo è ancora più importante non lasciarlo esposto.</p>
</blockquote>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">1. Struttura della cartella</h2>



<pre class="wp-block-code"><code>/opt/mysql57/
├── docker-compose.yml
├── .env
├── conf.d/        # configurazioni personalizzate (my.cnf aggiuntivi)
├── data/          # dati del database (volume persistente)
└── logs/
</code></pre>



<p class="wp-block-paragraph">Il file <code>.env</code> contiene le credenziali, così non finiscono nel compose.</p>



<pre class="wp-block-code"><code>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
</code></pre>



<p class="wp-block-paragraph">Proteggilo con <code>chmod 600 .env</code>.</p>



<h2 class="wp-block-heading">2. Il docker-compose.yml</h2>



<pre class="wp-block-code"><code>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: &#91;"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
</code></pre>



<p class="wp-block-paragraph">Alcune scelte da notare per MySQL 5.7 in Docker:</p>



<ul class="wp-block-list">
<li><strong><code>restart: unless-stopped</code></strong>: il database riparte da solo dopo un riavvio del server.</li>



<li><strong>Volumi con bind mount</strong>: dati, configurazione e log sono sull&#8217;host, quindi backup e migrazioni sono semplici copie di cartelle.</li>



<li><strong>Healthcheck</strong>: permette agli altri servizi di aspettare che MySQL sia davvero pronto (<code>$$</code> serve a far espandere la variabile dentro il container e non da Compose).</li>



<li><strong><code>ulimits nofile</code></strong>: evita l&#8217;errore &#8220;too many open files&#8221; sotto carico.</li>
</ul>



<h2 class="wp-block-heading">3. Primo problema: MySQL 5.7 in Docker è aperto a tutto il mondo</h2>



<p class="wp-block-paragraph">La riga <code>- "3306:3306"</code> equivale a <code>0.0.0.0:3306:3306</code>. Docker pubblica la porta su <strong>tutte le interfacce</strong> dell&#8217;host, compresa quella pubblica.</p>



<p class="wp-block-paragraph">La parte più insidiosa è che Docker scrive le proprie regole direttamente nel firewall del kernel. Queste vengono valutate prima di quelle di UFW o firewalld. Avere un firewall attivo <strong>non basta</strong>: la porta resta raggiungibile. Lo spiega anche la <a href="https://docs.docker.com/engine/network/packet-filtering-firewalls/" target="_blank" rel="noopener">documentazione di Docker sul filtraggio dei pacchetti</a>.</p>



<p class="wp-block-paragraph">Le strade possibili sono:</p>



<ol class="wp-block-list">
<li><strong>Solo localhost</strong>: <code>"127.0.0.1:3306:3306"</code>, se il database serve solo all&#8217;host o tramite tunnel SSH.</li>



<li><strong>Nessuna porta pubblicata</strong>: se i client sono altri container, basta una rete Docker condivisa.</li>



<li><strong>IP specifico</strong>: pubblicare solo su un indirizzo di una rete privata/VPN.</li>



<li><strong>Porta pubblica con filtro per IP</strong>: il nostro caso. Un client esterno (un&#8217;istanza di phpMyAdmin su un altro server) deve poter collegarsi.</li>
</ol>



<p class="wp-block-paragraph">Per il caso 4 serve un firewall che filtri correttamente il traffico delle porte pubblicate da Docker.</p>



<h2 class="wp-block-heading">4. Secondo problema: errore di iptables all&#8217;avvio</h2>



<p class="wp-block-paragraph">Dopo aver toccato il firewall, <code>docker compose up</code> ha restituito.</p>



<pre class="wp-block-code"><code>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.
</code></pre>



<p class="wp-block-paragraph">L&#8217;errore non dipende dal compose: la catena <code>DOCKER-FORWARD</code> (introdotta nelle versioni recenti di Docker) non esisteva più. Era stata cancellata da qualcosa che aveva ripulito le regole <strong>dopo</strong> l&#8217;avvio del daemon. Un riavvio di Docker (<code>systemctl restart docker</code>) la ricrea. Il problema però si ripresenta finché il vero colpevole non viene eliminato.</p>



<h3 class="wp-block-heading">Il colpevole: <code>flush ruleset</code></h3>



<p class="wp-block-paragraph">Il nostro <code>/etc/nftables.conf</code> iniziava con.</p>



<pre class="wp-block-code"><code>flush ruleset
</code></pre>



<p class="wp-block-paragraph">Questo comando cancella <strong>tutte</strong> le tabelle nftables, comprese quelle create da Docker. Inoltre il file conteneva una copia statica delle regole di Docker, con il nome del bridge (<code>br-278550235c3f</code>) e l&#8217;IP del container (<code>172.18.0.2</code>) scritti a mano. Ma il nome del bridge cambia ogni volta che la rete Compose viene ricreata, quindi la copia diventava subito obsoleta. La tabella <code>ip nat</code> entrava in conflitto con quella gestita da Docker.</p>



<p class="wp-block-paragraph"><strong>Regola d&#8217;oro:</strong> con Docker, nel file nftables non si usa <code>flush ruleset</code> e non si copiano le sue regole. Si gestisce solo il proprio firewall, in tabelle dedicate.</p>



<h2 class="wp-block-heading">5. Il firewall con nftables per MySQL 5.7 in Docker</h2>



<p class="wp-block-paragraph">Il principio è questo: le porte pubblicate da Docker vengono riscritte con DNAT e poi inoltrate dall&#8217;host verso il container. Attraversano quindi la catena <strong><code>forward</code></strong>, non <code>input</code>. Una regola <code>drop</code> in una nostra catena <code>forward</code> con priorità leggermente più bassa (quindi valutata prima) scarta il pacchetto a prescindere dalle regole <code>accept</code> di Docker.</p>



<p class="wp-block-paragraph">Per non toccare le tabelle altrui, usiamo il pattern <code>table ...</code> + <code>delete table ...</code>: rende il file rieseguibile senza errori e senza <code>flush</code>.</p>



<p class="wp-block-paragraph"><code>/etc/nftables.conf</code>.</p>



<pre class="wp-block-code"><code>#!/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
    }
}
</code></pre>



<p class="wp-block-paragraph">Come funziona la guardia:</p>



<ul class="wp-block-list">
<li><code>ct status dnat</code> seleziona solo i pacchetti passati dal DNAT, cioè quelli diretti alle porte pubblicate da Docker.</li>



<li>La prima regola accetta chi è nel set <code>allowed</code>; la seconda scarta tutti gli altri e li conta (<code>counter</code>).</li>



<li>Il traffico locale (<code>127.0.0.1</code>) e quello tra container sulla stessa rete non passa dal forward del router, quindi non viene toccato.</li>
</ul>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><strong>Compatibilità:</strong> in una prima versione usavamo <code>ct original proto-dst 3306</code>, ma alcune versioni di <code>nft</code> rispondono con <em>&#8220;Can&#8217;t parse symbolic invalid expressions&#8221;</em>. Nel hook <code>forward</code> il DNAT è già avvenuto e la porta del container coincide con quella pubblicata (<code>3306:3306</code>). Perciò <code>tcp dport 3306</code> combinato con <code>ct status dnat</code> è equivalente e funziona ovunque. Se cambi il mapping (es. <code>3307:3306</code>), la regola va adattata alla porta interna.</p>
</blockquote>



<h3 class="wp-block-heading">Applicare le modifiche senza chiudersi fuori</h3>



<p class="wp-block-paragraph">Tieni aperta una sessione SSH di riserva, poi.</p>



<pre class="wp-block-code"><code>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 &amp;&amp; docker compose up -d
</code></pre>



<p class="wp-block-paragraph">Per il caricamento al boot, assicurati che il servizio sia attivo (<code>systemctl enable --now nftables</code>). Controlla anche che <code>/etc/nftables.conf</code> non contenga <code>flush ruleset</code>.</p>



<h3 class="wp-block-heading">Gestire gli IP a runtime</h3>



<pre class="wp-block-code"><code>nft add element ip mysql_guard allowed { 203.0.113.50 }
nft delete element ip mysql_guard allowed { 203.0.113.50 }
</code></pre>



<p class="wp-block-paragraph">Per renderlo permanente, aggiorna anche il set nel file.</p>



<h2 class="wp-block-heading">6. Verifica</h2>



<p class="wp-block-paragraph">Dal server autorizzato.</p>



<pre class="wp-block-code"><code>nc -zv IP_PUBBLICO_DB 3306        # deve rispondere "succeeded"
</code></pre>



<p class="wp-block-paragraph">Da qualsiasi altro IP.</p>



<pre class="wp-block-code"><code>nmap -Pn -p 3306 IP_PUBBLICO_DB   # deve risultare "filtered" / timeout
</code></pre>



<p class="wp-block-paragraph">Sul server, il contatore della regola di drop deve crescere a ogni tentativo non autorizzato.</p>



<pre class="wp-block-code"><code>nft list chain ip mysql_guard forward
ss -tlnp | grep 3306
</code></pre>



<h2 class="wp-block-heading">7. Seconda barriera: gli utenti MySQL</h2>



<p class="wp-block-paragraph">Il firewall non sostituisce i permessi del database. Crea l&#8217;utente con host limitato all&#8217;IP del client.</p>



<pre class="wp-block-code"><code>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;
</code></pre>



<p class="wp-block-paragraph">Se la connessione TCP riesce ma MySQL risponde <code>Access denied</code>, il problema è qui e non nel firewall.</p>



<h2 class="wp-block-heading">8. Cose da ricordare</h2>



<ul class="wp-block-list">
<li><strong>Docker e firewall dell&#8217;host non si ignorano a vicenda</strong>. Capire quale catena attraversa il traffico (<code>input</code> o <code>forward</code>) è la chiave di tutto.</li>



<li><strong>Mai <code>flush ruleset</code></strong> su un host con Docker; usa tabelle dedicate.</li>



<li><strong>Non copiare le regole generate da Docker</strong> nei tuoi file: nomi di bridge e IP cambiano.</li>



<li>Se hai già esposto la porta, controlla i log in <code>./logs</code> e valuta di <strong>cambiare le password</strong>, soprattutto quella di root.</li>



<li>Dove possibile, preferisci <strong>VPN (WireGuard) o tunnel SSH</strong> all&#8217;esposizione pubblica di un database.</li>



<li>Se dopo un aggiornamento di Debian MySQL non parte, leggi <a href="https://www.cisilino.com/2026/10/13/mysql-non-si-avvia-debian-12/">MySQL non si avvia dopo l&#8217;aggiornamento a Debian 12</a>.</li>
<li>Pianifica la <strong>migrazione</strong> da MySQL 5.7 a una versione supportata.</li>
</ul>



<p class="wp-block-paragraph">Hai domande o una soluzione diversa per MySQL 5.7 in Docker? Scrivilo nei commenti.</p><p>The post <a href="https://www.cisilino.com/2026/10/17/mysql-5-7-docker-nftables/">MySQL 5.7 in Docker protetto con nftables</a> first appeared on <a href="https://www.cisilino.com">Matteo Cisilino</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://www.cisilino.com/2026/10/17/mysql-5-7-docker-nftables/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>MySQL non si avvia dopo l&#8217;aggiornamento a Debian 12: causa e soluzione</title>
		<link>https://www.cisilino.com/2026/10/13/mysql-non-si-avvia-debian-12/</link>
					<comments>https://www.cisilino.com/2026/10/13/mysql-non-si-avvia-debian-12/#respond</comments>
		
		<dc:creator><![CDATA[matteocisilino]]></dc:creator>
		<pubDate>Tue, 13 Oct 2026 07:50:06 +0000</pubDate>
				<category><![CDATA[Server e database]]></category>
		<category><![CDATA[aggiornamento]]></category>
		<category><![CDATA[Debian 12]]></category>
		<category><![CDATA[MariaDB]]></category>
		<category><![CDATA[MySQL]]></category>
		<guid isPermaLink="false">https://www.cisilino.com/?p=49</guid>

					<description><![CDATA[<p>Dopo l'aggiornamento a Debian 12 MySQL non parte e nei log compare mariadb.conf.d. Come riconoscere la causa e risolvere senza toccare i dati.</p>
<p>The post <a href="https://www.cisilino.com/2026/10/13/mysql-non-si-avvia-debian-12/">MySQL non si avvia dopo l’aggiornamento a Debian 12: causa e soluzione</a> first appeared on <a href="https://www.cisilino.com">Matteo Cisilino</a>.</p>]]></description>
										<content:encoded><![CDATA[<h2 class="wp-block-heading">Il sintomo: mysql.service in stato failed</h2>



<p class="wp-block-paragraph">Quando MySQL non si avvia, il servizio risulta fallito e il log riporta l&#8217;errore <code>Can't read dir of '/etc/mysql/mariadb.conf.d/'</code>.</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" src="https://www.cisilino.com/wp-content/uploads/2026/10/mysql-non-si-avvia-debian-12.webp" alt="Un cilindro che rappresenta un database con un cerchio arancione di avviso: MySQL non si avvia" width="1200" height="630"/></figure>



<pre class="wp-block-code"><code># 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: &#91;ERROR] Stopped processing the 'includedir' directive in file /etc/mysql/my.cnf at line 29.
mysqld: &#91;ERROR] Fatal error in defaults handling. Program aborted!
systemd&#91;1]: Failed to start mysql.service - MySQL Community Server.</code></pre>



<p class="wp-block-paragraph">Controllando i pacchetti installati, MySQL è regolarmente presente (pacchetti <code>mysql-community-server</code> 8.0 dal repository ufficiale Oracle).</p>



<pre class="wp-block-code"><code>dpkg -l | grep mysql</code></pre>



<h2 class="wp-block-heading">Perché MySQL non si avvia: my.cnf punta a mariadb.cnf</h2>



<p class="wp-block-paragraph">Su Debian il file <code>/etc/mysql/my.cnf</code> non è un file vero e proprio, ma un collegamento gestito da <strong>update-alternatives</strong>. Più pacchetti possono registrare la propria versione e, in modalità automatica, vince quella con la priorità più alta.</p>



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



<p class="wp-block-paragraph"><strong>Buona notizia:</strong> i dati in <code>/var/lib/mysql</code> non sono stati toccati. È solo un problema di configurazione.</p>



<h2 class="wp-block-heading">Diagnosi: verificare dove punta my.cnf</h2>



<pre class="wp-block-code"><code>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</code></pre>



<p class="wp-block-paragraph">Se il problema è questo, l&#8217;output sarà simile al seguente.</p>



<pre class="wp-block-code"><code>/etc/alternatives/my.cnf -&gt; /etc/mysql/mariadb.cnf
/etc/mysql/my.cnf -&gt; /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</code></pre>



<h2 class="wp-block-heading">Soluzione passo passo</h2>



<h3 class="wp-block-heading">1. Fare un backup della configurazione</h3>



<pre class="wp-block-code"><code>cp -a /etc/mysql /root/etc-mysql-backup-$(date +%F)</code></pre>



<h3 class="wp-block-heading">2. Eliminare i residui di mariadb-common</h3>



<p class="wp-block-paragraph">Il purge rimuove <code>mariadb.cnf</code> e la relativa voce in update-alternatives.</p>



<pre class="wp-block-code"><code>apt purge mariadb-common</code></pre>



<h3 class="wp-block-heading">3. Ripuntare my.cnf su mysql.cnf</h3>



<pre class="wp-block-code"><code>update-alternatives --remove my.cnf /etc/mysql/mariadb.cnf 2&gt;/dev/null
update-alternatives --set my.cnf /etc/mysql/mysql.cnf</code></pre>



<h3 class="wp-block-heading">4. Verificare la configurazione</h3>



<pre class="wp-block-code"><code>update-alternatives --display my.cnf
cat /etc/mysql/my.cnf
my_print_defaults mysqld</code></pre>



<p class="wp-block-paragraph">Ora <code>my.cnf</code> deve puntare a <code>/etc/mysql/mysql.cnf</code> e includere <code>/etc/mysql/conf.d/</code> e <code>/etc/mysql/mysql.conf.d/</code>. Se <code>my_print_defaults mysqld</code> stampa i parametri senza errori, la configurazione è corretta.</p>



<h3 class="wp-block-heading">5. Avviare MySQL</h3>



<pre class="wp-block-code"><code>systemctl start mysql
systemctl status mysql
journalctl -u mysql -n 50 --no-pager</code></pre>



<h2 class="wp-block-heading">Se my.cnf non è un collegamento simbolico</h2>



<p class="wp-block-paragraph">Se <code>/etc/mysql/my.cnf</code> è un file normale oppure <code>/etc/mysql/mysql.cnf</code> non esiste, si può ricreare la configurazione a mano.</p>



<pre class="wp-block-code"><code>mv /etc/mysql/my.cnf /etc/mysql/my.cnf.mariadb.bak
cat &gt; /etc/mysql/my.cnf &lt;&lt;'EOF'
!includedir /etc/mysql/conf.d/
!includedir /etc/mysql/mysql.conf.d/
EOF
mkdir -p /etc/mysql/conf.d
systemctl start mysql</code></pre>



<h2 class="wp-block-heading">Soluzione rapida provvisoria</h2>



<p class="wp-block-paragraph">Se MySQL non si avvia e 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.</p>



<pre class="wp-block-code"><code>mkdir /etc/mysql/mariadb.conf.d &amp;&amp; systemctl start mysql</code></pre>



<h2 class="wp-block-heading">Come evitare che si ripresenti</h2>



<p class="wp-block-paragraph">Conviene capire quale aggiornamento ha installato MariaDB, consultando lo storico di apt.</p>



<pre class="wp-block-code"><code>grep -i -B3 -A3 mariadb /var/log/apt/history.log*</code></pre>



<p class="wp-block-paragraph">Per impedire che <code>mariadb-common</code> venga reinstallato in futuro si può bloccarlo con un pin APT a priorità negativa.</p>



<pre class="wp-block-code"><code>cat &gt; /etc/apt/preferences.d/no-mariadb &lt;&lt;'EOF'
Package: mariadb-*
Pin: release *
Pin-Priority: -1
EOF</code></pre>



<p class="wp-block-paragraph">Dopo ogni aggiornamento importante è buona abitudine controllare anche lo stato dell&#8217;alternativa con <code>update-alternatives --display my.cnf</code>. Prima del salto di versione, le <a href="https://www.debian.org/releases/bookworm/amd64/release-notes/" target="_blank" rel="noopener">note di rilascio di Debian 12</a> dicono quali pacchetti e configurazioni cambiano. Se invece ti serve una versione vecchia di MySQL su un server aggiornato, ho raccontato come ho isolato <a href="https://www.cisilino.com/2026/10/17/mysql-5-7-docker-nftables/">MySQL 5.7 in Docker</a>.</p>



<h2 class="wp-block-heading">Domande frequenti</h2>



<h3 class="wp-block-heading">Perdo i dati dei database?</h3>



<p class="wp-block-paragraph">No. Il problema riguarda solo i file di configurazione in <code>/etc/mysql</code>; i dati in <code>/var/lib/mysql</code> restano intatti.</p>



<h3 class="wp-block-heading">Perché Debian ha scelto mariadb.cnf?</h3>



<p class="wp-block-paragraph">Perché l&#8217;alternativa <code>my.cnf</code> era in modalità automatica e <code>mariadb.cnf</code> ha priorità 500, più alta dei 300 di <code>mysql.cnf</code>. Con <code>update-alternatives --set</code> la scelta diventa manuale e non cambia più da sola.</p>



<h3 class="wp-block-heading">Vale anche per Ubuntu?</h3>



<p class="wp-block-paragraph">Sì, il meccanismo di <code>update-alternatives</code> per <code>my.cnf</code> è lo stesso su Ubuntu e sulle altre distribuzioni derivate da Debian.</p>



<h2 class="wp-block-heading">Conclusione</h2>



<p class="wp-block-paragraph">Se MySQL non si avvia dopo un aggiornamento, di solito il database è intatto. L&#8217;errore <strong>&#8220;Can&#8217;t read dir of /etc/mysql/mariadb.conf.d/&#8221;</strong> dopo un aggiornamento di Debian non indica un danno a MySQL, ma una configurazione residua di MariaDB rimasta attiva tramite update-alternatives. Eliminando <code>mariadb-common</code> con <code>apt purge</code> e reimpostando <code>my.cnf</code> su <code>mysql.cnf</code>, MySQL torna a funzionare regolarmente.</p>



<p class="wp-block-paragraph"></p><p>The post <a href="https://www.cisilino.com/2026/10/13/mysql-non-si-avvia-debian-12/">MySQL non si avvia dopo l’aggiornamento a Debian 12: causa e soluzione</a> first appeared on <a href="https://www.cisilino.com">Matteo Cisilino</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://www.cisilino.com/2026/10/13/mysql-non-si-avvia-debian-12/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
	</channel>
</rss>
