«Remounting filesystem read-only»: il sito va giù in 500 e il disco non è pieno
Il sintomo: un 500 istantaneo, e SSH che non risponde
Il negozio, un PrestaShop su un server virtuale dedicato, è andato giù di colpo: pagina bianca, errore 500, e la cosa che colpisce subito è la velocità della risposta, 0,02 secondi. Un 500 "vero" di PHP, generato da un errore applicativo, di solito impiega qualche decimo di secondo, perché prima carica il framework. Una risposta così rapida è quasi sempre un segno che l'errore arriva molto prima, a un livello che non ha niente a che fare con il codice del negozio.
Il secondo indizio: la connessione SSH al container andava in timeout. Non "connessione rifiutata", proprio timeout, come se qualcosa si fosse bloccato a metà. Dentro al container, un comando semplice come touch test rispondeva con:
touch: cannot touch 'test': Read-only file system
Il filesystem si era rimontato da solo in sola lettura. Il nodo fisico che ospitava il container, verificato subito, stava bene: carico normale, nessun errore hardware segnalato.
Cosa sembrava e non era
Un filesystem che passa in sola lettura fa pensare, in ordine, a tre cose. Nessuna delle tre era quella giusta, ma escluderle una per una è stato il lavoro che ha portato alla causa vera.
- "Il disco è pieno." È la prima ipotesi, ed è anche la più facile da controllare:
df -hdentro al container mostrava 186 GB usati su 1,5 TB disponibili, il 14%. Non era quello. Uno spazio disco esaurito dà un errore diverso e molto più chiaro ("No space left on device"), non un remount in sola lettura. - "Il disco fisico si è rotto." Un remount read-only è anche il comportamento tipico di ext4 quando incontra errori I/O da un disco che sta morendo. Il controllo sul nodo fisico però non mostrava nessun errore hardware, nessun settore danneggiato, nessun avviso dell'array. Il disco era sano.
- "MySQL si è corrotto." Una delle tabelle di configurazione risultava effettivamente "marked as crashed" dopo il rientro. Ma questa era una conseguenza del filesystem in sola lettura durante una scrittura, non la causa dell'incidente: MySQL non può aver rotto un filesystem che non gestisce.
Il log del kernel del nodo fisico, controllato dopo aver escluso le prime due ipotesi, raccontava una storia precisa:
Buffer I/O error on device dm-4
EXT4-fs warning: I/O error 3 writing to inode ...
Aborting journal on device dm-4-8
EXT4-fs error (device dm-4):
ext4_journal_check_start: comm mysqld:
Detected aborted journal
EXT4-fs (dm-4): Remounting filesystem read-only
La causa vera: il pool sotto il disco era pieno, non il disco
Il volume del container non è un disco fisico dedicato: è un volume LVM con thin provisioning, appoggiato su un pool condiviso con altre macchine virtuali sullo stesso nodo (il meccanismo è identico su qualunque hypervisor che usi storage LVM a thin provisioning, Proxmox in questo caso, ma non è una particolarità di quel prodotto). Con il thin provisioning, il volume "vede" la sua dimensione dichiarata, 1,5 TB, ma i blocchi fisici vengono allocati dal pool solo quando servono davvero. Se il pool sottostante si riempie, le scritture falliscono a livello di blocco, anche se il volume del container ha ancora spazio libero da dentro: il 14% usato che si vedeva con df era del tutto vero, e del tutto irrilevante.
Il controllo sul pool confermava tutto: Data% al 100,00%, con l'attributo di stato che riporta la lettera D, out of data space. Pool pieno, scritture rifiutate a livello di blocco, ext4 che vede errori I/O, abortisce il journal per proteggere la coerenza dei dati, e si rimonta in sola lettura. Da lì in poi PHP e MySQL non possono più scrivere niente, e il negozio risponde 500 a ogni richiesta.
Il pool non si era riempito per un singolo evento, ma per tre difetti che si sono sommati nel tempo:
- Snapshot vecchi mai ripuliti. Un job automatico di snapshot era stato disattivato in passato, ma senza cancellare gli snapshot che aveva già creato. Erano rimasti lì per settimane, a trattenere blocchi che altrimenti sarebbero tornati liberi.
- La soglia di autoextend del pool era impostata al 100%. Un pool che si autoestende solo quando è già pieno, di fatto, non si autoestende mai in tempo: non c'era margine per intervenire prima che si saturasse, e nessun preavviso.
- Nessun discard/TRIM periodico. Quando un file viene cancellato dentro al filesystem del container, i blocchi che occupava restano comunque allocati nel pool finché qualcosa non esegue un TRIM che li restituisce. Il conto, fatto dopo l'incidente, è stato netto: 1,40 TB allocati nel pool contro 186 GB realmente in uso dentro il container, oltre un terabyte di spazio "fantasma" mai restituito.
L'innesco finale è stato un backup automatico: il suo snapshot è stato creato su un pool già al limite, e in pochi minuti il pool è saturato del tutto.
La correzione
L'intervento è andato in questo ordine, dal più urgente al più definitivo:
- Estensione di emergenza del pool (non distruttiva, se c'è margine nel volume group sottostante): il pool torna sotto al 100% e la scrittura riprende a funzionare.
- Rimozione dello snapshot del backup fallito, sicuro perché il sistema di backup lo ricrea comunque al giro successivo.
- Rimozione degli snapshot vecchi, con l'approvazione esplicita del cliente prima di toccarli: erano loro, da soli, a valere la parte più grande dello spazio recuperato. Il volume vivo del container non è mai stato toccato, verificato prima e dopo.
e2fscka container fermo sul volume, per far ripartire il journal e correggere gli inode rimasti orfani dalla scrittura interrotta.- Verifica dell'integrità del database: la riparazione automatica di MySQL era già scattata da sola sulla tabella segnalata come crashed; un controllo esplicito su tutte le tabelle a rischio ha confermato che i dati erano integri e identici a prima dell'incidente.
- Abbassamento della soglia di autoextend del pool, così che la prossima volta il sistema estenda da solo con margine, invece di aspettare di essere pieno.
Il TRIM per recuperare lo spazio "fantasma" resta un intervento a parte: su un container non privilegiato non si può lanciare dall'interno, e a container acceso spesso non si riesce a lanciarlo neanche dall'esterno. Serve una finestra con il container fermo, di pochi minuti, dedicata solo a quello.
Come verificare che tenga
- Il conteggio degli errori 5xx nel tempo, non solo il fatto che il sito risponda di nuovo: nel caso descritto, tutti gli errori erano concentrati nel minuto del riavvio (container che ripartiva, MySQL in fase di riparazione), e da lì in poi zero errori su migliaia di richieste successive. Un errore isolato al riavvio è normale, un errore che continua non lo è.
- La percentuale del pool nel tempo, non solo subito dopo l'intervento: un pool che torna a riempirsi nei giorni successivi allo stesso ritmo di prima dice che le cause di fondo (snapshot che si accumulano, autoextend troppo permissivo) non sono state davvero rimosse, solo rimandate.
- Un controllo esplicito sulle tabelle del database dopo qualunque incidente di questo tipo, anche se il sito sembra già a posto: un remount in sola lettura durante una scrittura può lasciare una tabella in uno stato inconsistente senza che nessun errore compaia subito in pagina.
- Una finestra di manutenzione programmata per il TRIM, se il volume ha accumulato molto spazio "fantasma": senza, il margine reale resta più basso di quello che sembra dai numeri del pool.
Il negozio su cui è successo resta anonimo di proposito: quello che conta è il meccanismo. Se un sito va giù con un 500 istantaneo, SSH che va in timeout e un filesystem che si scopre in sola lettura, il sospetto da controllare per primo non è "quanto disco resta dentro al container": è quanto spazio resta davvero nel pool che sta sotto, perché sono due numeri diversi, e solo uno dei due, quando arriva a zero, spegne il negozio.
