Archiviazione sicura dei dati e API di file in Linux

Mentre esploravo la resilienza dello storage nei sistemi cloud, ho deciso di mettermi alla prova per assicurarmi di comprendere i concetti di base. Ho iniziato a leggere la specifica NVMe per capire quali garanzie riguardanti la resilienza dello storage (cioè le garanzie che i dati saranno accessibili dopo un guasto del sistema) ci forniscono i dischi NVMe. Ho tratto le seguenti conclusioni principali: si deve considerare che i dati sono danneggiati dal momento in cui viene emesso il comando di scrittura fino a quando la scrittura non è completata sul supporto. Tuttavia, nella maggior parte dei programmi di scrittura, si utilizzano tranquillamente le chiamate di sistema.

In questo articolo esaminerò i meccanismi di resilienza dello storage forniti dalle API file di Linux. Sembra che qui tutto dovrebbe essere semplice: il programma chiama il comando write(), e dopo che questo comando è stato completato, i dati dovrebbero essere salvati in modo sicuro sul disco. Ma write() copierà solo i dati dell'applicazione nella cache del kernel, situata nella memoria. Per forzare il sistema a scrivere i dati sul disco, è necessario utilizzare alcuni meccanismi aggiuntivi.

Archiviazione sicura dei dati e API di file in Linux

In generale, questo materiale è un insieme di appunti che riguardano ciò che ho appreso sull'argomento che mi interessa. Se dobbiamo sintetizzare le informazioni più importanti, si può dire che per organizzare uno storage dati sostenibile è necessario utilizzare il comando fdatasync() o aprire i file con il flag O_DSYNC. Se siete interessati a conoscere i dettagli su cosa succede ai dati durante il passaggio dal codice sorgente al disco, date un'occhiata a questo articolo.

Le caratteristiche dell'uso della funzione write()

Chiamata di sistema write() sono definite nello standard IEEE POSIX come un tentativo di scrivere dati in un descrittore di file. Dopo il completamento riuscito write() le operazioni di lettura dei dati devono restituire esattamente i byte che sono stati precedentemente scritti, anche se i dati vengono acceduti da altri processi o thread (ecco la sezione corrispondente dello standard POSIX). Qui, nella sezione dedicata all'interazione dei thread con le normali operazioni sui file, c'è una nota che afferma che se ciascuno dei due thread chiama queste funzioni, ogni chiamata deve vedere o tutte le conseguenze designate che derivano dall'esecuzione dell'altra chiamata, oppure non vedere affatto conseguenze. Questo porta a concludere che tutte le operazioni di input/output su file devono trattenere il blocco della risorsa con cui stanno lavorando.

Significa che l'operazione write() è atomica? Dal punto di vista tecnico — sì. Le operazioni di lettura dei dati devono restituire o tutto, o niente di quanto è stato scritto con write(). Ma l'operazione write(), in conformità con lo standard, non è necessario che si completi, registrando tutto ciò che le è stato proposto di registrare. Le è consentito registrare solo una parte dei dati. Ad esempio, potremmo avere due flussi, ciascuno dei quali aggiunge 1024 byte a un file descritto dallo stesso descrittore di file. Dal punto di vista dello standard, si considererebbe accettabile il risultato in cui ciascuna delle operazioni di scrittura può aggiungere solo un byte al file. Queste operazioni rimarranno atomiche, ma dopo il loro completamento, i dati scritti nel file risulteranno mescolati. Ecco c'è una discussione molto interessante su questo argomento su Stack Overflow.

Le funzioni fsync() e fdatasync()

Il modo più semplice per forzare la scrittura dei dati su disco è chiamare la funzione fsync(). Questa funzione richiede al sistema operativo di trasferire tutti i blocchi modificati dalla cache al disco. Ciò include anche tutti i metadati del file (tempo di accesso, tempo di modifica del file e così via). Credo che la necessità di questi metadati sorga raramente, quindi, se sai che non sono importanti per te, puoi utilizzare la funzione fdatasync(). In documentazione per fdatasync() Si dice che durante l'esecuzione di questa funzione venga salvato su disco un volume di metadati «necessario per l'esecuzione corretta delle operazioni di lettura dei dati successive». E questo è proprio ciò che preoccupa la maggior parte delle applicazioni.

Uno dei problemi che potrebbe sorgere qui è che questi meccanismi non garantiscono che il file sarà recuperabile dopo un possibile guasto. In particolare, quando si crea un nuovo file, è necessario chiamare fsync() per la directory che lo contiene. Altrimenti, dopo un guasto, potrebbe risultare che quel file non esiste. La ragione è che in UNIX, a causa dell'uso dei collegamenti rigidi, un file può esistere in più directory. Pertanto, al momento della chiamata fsync() per il file non c'è modo di sapere quali dati di quale directory devono essere scritti su disco (qui puoi leggere di più a riguardo). Sembra che il file system ext4 sia in grado di automaticamente applicare fsync() alle directory che contengono i file appropriati, ma nel caso di altri file system questo potrebbe non essere così.

Questo meccanismo può essere implementato in vari modi nei diversi file system. Ho utilizzato blktrace per scoprire quali operazioni di disco vengono utilizzate nei file system ext4 e XFS. Entrambi emettono comuni comandi di scrittura su disco sia per il contenuto dei file che per il registro del file system, svuotano la cache e terminano l'operazione eseguendo la scrittura FUA (Force Unit Access, scrittura dei dati direttamente sul disco, saltando la cache) nel registro. Probabilmente, adottano questo approccio per confermare che l'operazione è stata effettuata. Su dischi che non supportano FUA, ciò provoca due svuotamenti della cache. I miei esperimenti hanno dimostrato che fdatasync() è leggermente più veloce fsync(). Strumento blktrace indica che fdatasync() di solito scrive meno dati sul disco (in ext4 fsync() scrive 20 KiB, mentre fdatasync() scrive 16 KiB). Inoltre, ho scoperto che XFS è leggermente più veloce di ext4. E qui con l'aiuto di blktrace sono riuscito a scoprire che fdatasync() svuota sul disco meno dati (4 KiB in XFS).

Situazioni ambigue che si presentano quando si utilizza fsync()

Posso ricordare tre situazioni ambigue riguardanti fsync(), con cui mi sono confrontato nella pratica.

Il primo caso di questo tipo si è verificato nel 2008. Allora, l'interfaccia di Firefox 3 si bloccava quando si tentava di scrivere su disco un grande numero di file. Il problema era che nella realizzazione dell'interfaccia per memorizzare le informazioni sul suo stato veniva utilizzato un database SQLite. Dopo ogni modifica apportata all'interfaccia, veniva chiamata la funzione fsync(), che garantiva buone garanzie di memorizzazione dei dati. Nella filesystem utilizzata allora, ext3, la funzione fsync() scriveva su disco tutte le pagine "sporche" nel sistema, non solo quelle relative al file specifico. Questo significava che un clic su un pulsante in Firefox poteva avviare la registrazione di megabyte di dati su disco rigido, richiedendo molti secondi. La soluzione al problema, da quanto ho compreso da questo materiale, consisteva nel trasferire il lavoro con il database in attività di sfondo asincrone. Questo significa che prima in Firefox erano implementati requisiti più severi per la robustezza della memorizzazione dei dati di quanto fosse realmente necessario, e le peculiarità del filesystem ext3 aggravavano ulteriormente questo problema.

Il secondo malfunzionamento si è verificato nel 2009. Allora, dopo un guasto del sistema, gli utenti del nuovo file system ext4 si sono trovati di fronte a file recentemente creati che avevano una lunghezza pari a zero, mentre con il più vecchio file system ext3 questo non è accaduto. Nel paragrafo precedente, ho accennato al fatto che ext3 scriveva su disco troppi dati, il che rallentava notevolmente l'operazione. fsync(). Per migliorare la situazione, in ext4 vengono scritte su disco solo le pagine "sporche" relative a un file specifico. I dati degli altri file rimangono in memoria per un tempo molto più lungo rispetto a quanto avviene con ext3. Questo è stato realizzato per migliorare le prestazioni (di default i dati rimangono in questo stato per 30 secondi, questo può essere configurato tramite dirty_expire_centisecs; qui è possibile trovare ulteriori materiali a riguardo). Questo significa che un grande volume di dati può andare irrimediabilmente perso dopo un guasto. La soluzione a questo problema consiste nell'utilizzo di fsync() in applicazioni che necessitano di garantire una memorizzazione dati sicura e massimizzare la protezione da eventuali malfunzionamenti. La funzione fsync() funziona con ext4 in modo molto più efficiente rispetto a ext3. Lo svantaggio di questo approccio è che, come prima, rallenta l'esecuzione di alcune operazioni, come l'installazione di programmi. Maggiori dettagli su questo si trovano qui e qui.

Il terzo problema riguarda fsync(), emerso nel 2018. Allora, nell'ambito del progetto PostgreSQL, si è scoperto che se la funzione fsync() incorre in un errore, contrassegna le pagine 'sporche' come 'pulite'. Di conseguenza, le chiamate successive fsync() non fanno nulla con queste pagine. Questo comporta che le pagine modificate rimangano in memoria e non vengano mai scritte su disco. È una vera e propria catastrofe, poiché l'applicazione riterrà che alcuni dati siano stati scritti su disco, mentre in realtà non è così. Tali guasti fsync() sono rari, e l'applicazione in tali situazioni può fare poco per combattere il problema. Oggigiorno, quando ciò avviene, PostgreSQL e altre applicazioni terminano in modo anomalo. Qui, nel materiale 'Can Applications Recover from fsync Failures?', questo problema viene approfondito in tutti i dettagli. Attualmente, la migliore soluzione a questo problema è utilizzare Direct I/O con il flag O_SYNC o con il flag O_DSYNC. Con questo approccio, il sistema segnalerà eventuali errori che possono verificarsi durante specifiche operazioni di scrittura dei dati, ma richiede che l'applicazione gestisca autonomamente i buffer. Maggiori dettagli su questo possono essere trovati qui e qui.

Apertura di file usando i flag O_SYNC e O_DSYNC

Torniamo a discutere dei meccanismi Linux che garantiscono la persistenza dei dati. In particolare, stiamo parlando dell'uso del flag O_SYNC o del flag O_DSYNC quando si apre file utilizzando la chiamata di sistema open(). Con questo approccio, ogni operazione di scrittura dei dati viene eseguita come se dopo ogni comando write() al sistema vengano date, rispettivamente, comandi fsync() e fdatasync(). In specifiche POSIX questo è noto come «Synchronized I/O File Integrity Completion» e «Data Integrity Completion». Il principale vantaggio di questo approccio è che per garantire l'integrità dei dati è necessario effettuare solo una chiamata di sistema, invece di due (ad esempio — write() e fdatasync()). Il principale svantaggio di questo approccio è che tutte le operazioni di scrittura che utilizzano il corrispondente descrittore di file saranno sincronizzate, il che può limitare le capacità di strutturazione del codice dell'applicazione.

Utilizzo di Direct I/O con il flag O_DIRECT

Chiamata di sistema open() supporta il flag O_DIRECT, che è progettato per eseguire operazioni di input/output bypassando la cache del sistema operativo e interagendo direttamente con il disco. Questo, in molti casi, significa che i comandi di scrittura emessi dal programma verranno tradotti direttamente in comandi diretti per lavorare con il disco. Tuttavia, in generale, questo meccanismo non sostituisce le funzioni fsync() o fdatasync(). Il fatto è che il disco stesso può ritardare o memorizzare nella cache i corrispondenti comandi di scrittura dei dati. E, ciò che è peggio, in alcuni casi particolari, le operazioni di input/output eseguite utilizzando il flag O_DIRECT, vengono tradotte in operazioni tradizionali con buffer. Il modo più semplice per risolvere questo problema è utilizzare anche il flag O_DSYNC, il che significa che per ogni operazione di scrittura ci sarà una chiamata fdatasync().

Si è scoperto che nel file system XFS è stata recentemente aggiunta una "via rapida" per O_DIRECT|O_DSYNC-la scrittura dei dati. Se si sovrascrive un blocco utilizzando O_DIRECT|O_DSYNC, quindi XFS, invece di svuotare la cache, eseguirà il comando FUA per la scrittura nel caso in cui il dispositivo lo supporti. Ne ho avuto la conferma utilizzando l'utility blktrace sul sistema Linux 5.4/Ubuntu 20.04. Questo approccio dovrebbe essere più efficiente, poiché comporta la scrittura di un numero minimo di dati sul disco e utilizza un'unica operazione, invece di due (scrittura e svuotamento della cache). Ho trovato un link a per correggere l'errore. un kernel del 2018, in cui è implementato questo meccanismo. Lì c'è una discussione riguardante l'applicazione di questa ottimizzazione anche in altri file system, ma, per quanto ne so, XFS è attualmente l'unico file system che la supporta.

La funzione sync_file_range()

In Linux, esiste una chiamata di sistema sync_file_range(), che consente di scrivere su disco solo una parte di un file, e non l'intero file. Questa chiamata avvia un flush asincrono dei dati e non aspetta il suo completamento. Ma nella documentazione di sync_file_range() si dice che questo comando è 'molto pericoloso'. Non si consiglia di usarlo. Le caratteristiche e i pericoli sync_file_range() sono ben descritti in questo materiale. In particolare, sembra che questa chiamata utilizzi RocksDB per gestire quando il kernel scrive i dati "sporchi" su disco. Tuttavia, per garantire la persistenza dei dati, viene utilizzato anche fdatasync(). In codice RocksDB ha commenti interessanti su questo argomento. Ad esempio, sembra che la chiamata sync_file_range() quando si utilizza ZFS non porti al salvataggio dei dati su disco. L'esperienza mi suggerisce che il codice utilizzato raramente potrebbe contenere errori. Pertanto, consiglierei di non utilizzare questa chiamata di sistema senza una ragione impellente.

Chiamate di sistema che aiutano a garantire la persistenza dei dati

Sono giunto alla conclusione che per eseguire operazioni di input/output che garantiscono la persistenza dei dati, si possono utilizzare tre approcci. Tutti richiedono la chiamata della funzione fsync() per la directory in cui è stato creato il file. Ecco questi approcci:

  1. Chiamata della funzione fdatasync() o fsync() dopo la funzione write() (meglio utilizzare fdatasync()).
  2. Lavorare con il descrittore di file aperto con il flag O_DSYNC o O_SYNC (meglio — con il flag O_DSYNC).
  3. Utilizzare il comando pwritev2() con il flag RWF_DSYNC o RWF_SYNC (preferibilmente — con il flag RWF_DSYNC).

Note sulle prestazioni

Non ho effettuato misurazioni dettagliate delle prestazioni dei vari meccanismi che ho esaminato. Le differenze di velocità che ho osservato sono piuttosto piccole. Questo significa che potrei sbagliarmi e che, in condizioni diverse, quello stesso meccanismo potrebbe mostrare risultati diversi. Inizierò parlando di ciò che influisce maggiormente sulle prestazioni e poi di ciò che ha un impatto minore.

  1. La riscrittura dei dati di un file è più veloce rispetto all'aggiunta di dati a un file (il guadagno in prestazioni può variare dal 2% al 100%). L'aggiunta di dati a un file richiede modifiche aggiuntive ai metadati del file, anche dopo la chiamata di sistema fallocate(), ma l'entità di questo effetto può variare. Raccomando, per garantire le migliori prestazioni, di chiamare fallocate() per la preallocazione dello spazio necessario. Successivamente, è necessario riempire esplicitamente questo spazio con zeri e chiamare fsync(). Questo rende i blocchi corrispondenti nel file system contrassegnati come «dedicati» invece di «non dedicati». Ciò fornisce un piccolo miglioramento delle prestazioni (circa il 2%). Inoltre, per alcuni dischi, la prima operazione di accesso a un blocco può richiedere più tempo di altre. Ciò significa che riempire lo spazio con zeri può portare a un miglioramento delle prestazioni significativo (circa il 100%). In particolare, questo può accadere con i dischi AWS EBS (questi dati non sono ufficiali, non sono riuscito a confermarli). Lo stesso vale per gli archivi GCP Persistent Disk (questo è invece un'informazione ufficiale, confermata da test). Altri esperti hanno fatto osservazioni simili osservazioni, relative a vari dischi.
  2. Meno chiamate di sistema ci sono, maggiore è la prestazione (il guadagno può essere di circa il 5%). Sembra che la chiamata open() con il flag O_DSYNC o la chiamata pwritev2() con il flag RWF_SYNC sia più veloce della chiamata fdatasync()Sospetto che il problema sia che, con questo approccio, il numero di chiamate di sistema per risolvere lo stesso compito è ridotto (una chiamata anziché due). Ma la differenza nelle prestazioni è molto piccola, quindi puoi tranquillamente non tenerne conto e usare nel tuo applicativo ciò che non complica la sua logica.

Se ti interessa il tema della conservazione sostenibile dei dati — ecco alcuni materiali utili:

  • Metodi di accesso I/O — panoramica dei principali meccanismi di input/output.
  • Assicurare che i dati raggiungano il disco — racconto su cosa succede ai dati lungo il percorso dall'applicazione al disco.
  • Quando dovresti eseguire fsync sulla directory contenente — risposta alla domanda su quando è necessario utilizzare fsync() per le directory. In breve, bisogna farlo quando si crea un nuovo file, e il motivo di questo consiglio è che in Linux possono esserci molteplici collegamenti a un file stesso.
  • SQL Server su Linux: Internals di FUA — qui descrive come è implementato l'archiviazione persistente dei dati in SQL Server sulla piattaforma Linux. Qui ci sono alcune interessanti comparazioni tra le chiamate di sistema di Windows e Linux. Sono quasi certo che è grazie a questo materiale che ho appreso dell'ottimizzazione FUA di XFS.

Hai mai perso dati che pensavi fossero salvati in modo sicuro su disco?

Archiviazione sicura dei dati e API di file in Linux

Archiviazione sicura dei dati e API di file in Linux

Fonte: habr.com

Acquista un hosting affidabile per siti con protezione DDoS, server VPS VDS 🔥 Acquista un hosting affidabile per siti con protezione DDoS, server VPS VDS | ProHoster