Archivos sparse en Linux
Sparse files en la práctica
Un sparse file se entiende creándolo y mirándolo, no leyendo sobre él. Vamos a tratar un archivo como el disco de una máquina virtual: se crea a tamaño completo y el guest le escribe encima. En la terminal, esas escrituras las simulamos con dd.
El "disco" de 1 GiB, todavía en blanco:
truncate -s 1G disk.img
Lo medimos de dos maneras —lo que declara y lo que ocupa de verdad—:
du -h --apparent-size disk.img # 1,0G ← lo que declara du -h disk.img # 0 ← lo que ocupa
Declara un gigabyte y ocupa cero. Escribimos 16 MiB al principio, como el guest grabando su arranque:
dd if=/dev/urandom of=disk.img bs=1M count=16 seek=0 conv=notrunc status=none
du -h --apparent-size disk.img # 1,0G du -h disk.img # 16M
El tamaño declarado no se movió; el real subió los 16 MiB exactos que escribimos. Ahora otros 16 MiB, pero lejos, en el offset 512 MiB:
dd if=/dev/urandom of=disk.img bs=1M count=16 seek=512 conv=notrunc status=none
du -h disk.img # 32M
Real 32 MiB, aparente todavía 1 GiB. Esos 32 MiB reales son solo las dos zonas de 16 que escribimos; entre ellas quedó un agujero de casi 500 MiB —espacio que el archivo declara pero para el que nunca se pidió un bloque en disco—.
Ahora al derecho y al revés. Llenamos parte del agujero, 128 MiB en el offset 200 MiB:
dd if=/dev/urandom of=disk.img bs=1M count=128 seek=200 conv=notrunc status=none
du -h disk.img # 160M
El real saltó a 160 MiB —los 32 de antes más los 128 nuevos—: escribir en un agujero cuesta disco. Y lo perforamos de vuelta, con fallocate:
fallocate --punch-hole --offset 200M --length 128M disk.img
du -h disk.img # 32M
El real volvió a 32 MiB: perforar libera los bloques y los devuelve al disco. Ese tramo, ahora vacío, se lee como ceros —no como basura, ni con error—:
dd if=disk.img bs=1M skip=200 count=128 status=none | tr -d '\0' | wc -c # 0
En todo el recorrido el tamaño aparente no se movió de 1 GiB; el real subió al escribir y bajó al perforar. Son las dos medidas de un sparse file: lo que declara y lo que realmente ocupa en disco.
Las dos direcciones del recorrido, que es lo que conviene no confundir:
| Operación | Partís de | Terminás en | st_blocks |
|---|---|---|---|
escribir (dd … seek=) |
agujero | datos en disco | sube |
perforar (--punch-hole) |
datos en disco | agujero (se lee como ceros) | baja |
Perforar no escribe ceros: libera los bloques. Que el agujero se lea después como ceros es el efecto observable; el mecanismo es la desasignación, y por eso el espacio real baja en vez de subir.
Dos tamaños, no uno
El lab mostró dos números que se mueven por separado. Tienen nombre, y el inodo guarda los dos; stat los muestra juntos:
stat -c 'aparente=%s bytes | reales=%b bloques de %B bytes' disk.img
-
st_size(el "aparente") es el offset del último byte del archivo más uno. Es lo que respondels -l. Es una declaración sobre el espacio de direcciones lógico del archivo. -
st_blocks(el "real") es el espacio que el archivo ocupa de verdad, contado —en Linux— en unidades fijas de 512 bytes. Es lo que informadu.
El mismo archivo, sus dos medidas: st_size declara 1 GiB —el ancho lógico completo—; st_blocks cuenta solo los dos bloques tocados. Entre los datos, agujeros: espacio declarado que no ocupa disco.
Un archivo denso cualquiera muestra las dos magnitudes juntas. /usr/bin/chmod mide 65920 bytes (st_size), pero en disco ocupa 69632: el filesystem asigna bloques enteros de 4096, y 65920 bytes necesitan 17 de esos bloques (17 × 4096 = 69632), con 3712 bytes del último reservados pero sin usar.1 En un archivo denso lo asignado iguala o supera al tamaño.
Un sparse file es el caso opuesto: st_size mayor que el espacio asignado, y la diferencia son los agujeros que vimos abrirse y cerrarse en el lab.
De dónde salen en la práctica
Los agujeros no son una curiosidad de laboratorio. Aparecen solos, sin que nadie los pida, cada vez que un programa escribe lejos del principio del archivo o libera rangos intermedios:
-
Imágenes de disco de máquinas virtuales. Un disco
rawde 100 GiB con 8 GiB usados es un sparse file.qcow2hace lo suyo por encima, pero el archivo subyacente también lo es. - Bases de datos que preasignan un archivo grande y lo llenan de a poco.
- Clientes de BitTorrent, que crean el archivo con su tamaño final y van completando piezas fuera de orden.
- Core dumps, que no vuelcan las regiones de memoria sin mapear.
-
/var/log/lastlog, que usa el UID del usuario como posición dentro del archivo — el caso más didáctico de todos.
Los filesystems que un sysadmin corporativo tiene enfrente —ext4, XFS, btrfs— soportan agujeros. También tmpfs. FAT y exFAT no, y son justo los que aparecen en un pendrive o en la partición EFI: copiar un sparse file a uno de ellos lo escribe entero, porque el destino no tiene forma de representar los agujeros.
Ver dónde están los agujeros
Para ver dónde están los agujeros —qué rangos tienen datos y cuáles son hueco—, xfs_io (del paquete xfsprogs) los marca con hole. Se apoya en FIEMAP, un ioctl genérico del kernel que ext4, XFS y btrfs implementan por igual, así que, a pesar de su nombre, sirve en los tres.
xfs_io -c fiemap disk.img 0: [0..32767]: … 1: [32768..1048575]: hole 2: [1048576..1081343]: … 3: [1081344..2097151]: hole
La tercera columna —los offsets físicos en disco— la recorté con …: cambia según el filesystem y no aporta acá. Los rangos van en sectores de 512 bytes: los primeros 16 MiB ([0..32767]) tienen datos, el tramo [32768..1048575] es un hole, en el offset 512 MiB ([1048576..1081343]) están los otros 16 MiB, y el resto hasta el final es otro hole.
Hay un camino más de bajo nivel: los flags SEEK_HOLE y SEEK_DATA de lseek (desde Linux 3.1) saltan al próximo agujero o al próximo rango con datos sin leer el archivo entero. Es el mecanismo que usan por dentro las herramientas que preservan agujeros al copiar, y el que las separa de las que re-inflan.
Una advertencia sobre filefrag, que suele ser lo primero que uno prueba: su conteo (N extents found) mide fragmentación física, no agujeros, y se lee distinto según el filesystem —el mismo archivo con el mismo agujero da "1 extent" en XFS y "2" en btrfs—. Para ver agujeros, fiemap.
Convertir ceros en agujeros
En el lab abrimos agujeros con --punch-hole. La otra variante, --dig-holes, recupera espacio: toma un archivo que ya ocupa disco lleno de ceros —una copia que se infló, una imagen descargada sin sparse— y reemplaza esas secuencias de ceros por agujeros. Un archivo de 64 MiB escritos en cero ocupa 64 MiB reales; después, cero:
dd if=/dev/zero of=lleno.img bs=1M count=64 status=none du -h lleno.img # 64M fallocate --dig-holes lleno.img du -h lleno.img # 0 (el aparente sigue en 64M)
Con --dig-holes se cierra el cuadro de las tres operaciones. --punch-hole y --dig-holes terminan igual —un agujero, st_blocks baja—, pero parten de cosas distintas:
| Operación | Partís de | Terminás en | st_blocks |
|---|---|---|---|
escribir ceros (dd if=/dev/zero) |
agujero | ceros reales en disco | sube |
perforar (--punch-hole) |
datos en disco | agujero | baja |
cavar (--dig-holes) |
ceros reales en disco | agujero | baja |
Escribir ceros y cavarlos son inversas: la primera los materializa en disco, la segunda los recupera. Perforar es el atajo cuando ni siquiera te importa qué había en el rango.
Cuándo se convierte en un problema
El diseño se sostiene mientras el archivo se quede quieto. El problema aparece cuando algo lo copia, porque una herramienta que lee a través de la API normal de bytes ve ceros, no agujeros. Si al escribir no vuelve a perforarlos, los materializa.
Con el disk.img de arriba —1 GiB aparente, 32 MiB reales—, copiado en RHEL 8 (XFS, coreutils 8.30):
| Operación | Ocupación en destino |
|---|---|
cp disk.img destino |
64 MiB |
cp --sparse=never disk.img destino |
1 GiB |
cat disk.img > destino |
1 GiB |
tar cf plain.tar disk.img y extraer |
1 GiB (y el .tar pesa 1,1 GiB) |
tar cSf sparse.tar disk.img y extraer |
64 MiB (y el .tar pesa 33 MiB) |
rsync -a disk.img destino |
1 GiB |
rsync -aS disk.img destino |
64 MiB |
Las cuatro que re-inflan —cp --sparse=never, cat, tar sin -S, rsync -a— llevan la copia a 1 GiB: materializan cada cero.2 Las tres que preservan el sparse —cp a secas, tar -S, rsync -aS— dan 64 MiB. Que cp sin flags preserve ya es contraintuitivo: su default es --sparse=auto, detecta los agujeros del origen y los recrea; solo los pierde con --sparse=never o si el destino no los soporta.
Pero 64 MiB es el doble de los 32 del origen, y no es re-inflado: los agujeros están intactos (xfs_io -c fiemap destino los muestra igual), y ese doble es XFS reservando bloques de más al escribir (speculative preallocation). Es una reserva transitoria —XFS la reclama con el tiempo (el barredor de eofblocks) o bajo presión de espacio, y su tamaño depende de la opción de montaje allocsize y de la versión—, así que el número exacto puede no reproducirse igual en otro XFS. En ext4 o btrfs la misma copia da los 32 MiB exactos: es un rasgo de XFS, no de las herramientas.
tar sin -S infla también el archivo .tar. Produce uno de 1,1 GiB en lugar de los 33 MiB que ocupa con -S. Ese gigabyte de ceros viaja por la red, se escribe en la cinta o en el bucket, y recién se nota cuando el job de backup llena el volumen de destino.
Con gzip, bzip2 o xz la falla es más difícil de ver, porque un gigabyte de ceros comprime a casi nada. El .gz es minúsculo y todo parece correcto. El gunzip en el destino escribe el gigabyte completo, sin agujeros.
De las herramientas de transferencia, scp y sftp no tienen ninguna noción de agujeros. Tampoco git: el blob incluye todos los ceros. dd los re-infla salvo que le pases conv=sparse.
La regla que se deduce de la tabla
Si una herramienta ofrece un flag explícito de sparse —rsync -S, tar -S, dd conv=sparse, cp --sparse=always, restic restore --sparse, borg extract --sparse— es señal de que por defecto no lo hace, o de que su default es una heurística que conviene verificar. Las que ni siquiera ofrecen la opción re-inflan siempre.
En backup empresarial el patrón se repite: Bacula y Bareos manejan agujeros solo si activás la directiva Sparse = yes, que viene desactivada. restic y borg deduplican los ceros muy bien —el repositorio queda chico— pero la restauración los re-infla si no le pedís lo contrario.
Hay una excepción importante, para no exagerar el problema: el backup a nivel de imagen o de bloque no lee archivos, lee bloques asignados del dispositivo. Los agujeros no le representan un problema. El riesgo descrito acá aplica al backup y la restauración a nivel de archivo.
El origen nunca se queja: el espacio real de un sparse file es minúsculo. La medición que importa no es la del archivo que tenés, sino la del que vas a producir —el destino de la copia, el .tar que se sube, el volumen donde se restaura—. Un du -h contra un du -h --apparent-size en el origen te dice cuánto puede crecer; los flags de la herramienta te dicen si va a crecer.
Fuentes y más recursos
-
stat(3type)— define los campos destruct stat. Sobrest_blocks: "the number of blocks allocated to the file, in 512-byte units". Elstat(2)advierte que esa unidad no es universal (HP-UX cuenta en 1024, AIX en 4K); en Linux es 512. -
lseek(2)— defineSEEK_HOLEySEEK_DATA, el mecanismo que permite recorrer agujeros sin leer el archivo entero. Disponibles desde Linux 3.1. -
fallocate(1)—--punch-holepara perforar un rango y--dig-holespara convertir en agujeros las secuencias de ceros ya escritas. -
filefrag(8)— inspecciona el mapa de extents de un archivo; viene ene2fsprogs. -
man 1 cp— la sección de--sparseaclara que la detección por defecto (auto) usa "un mecanismo heurístico muy básico": por eso conviene medir en lugar de asumir. -
man 1 tar—-S/--sparsey--hole-detection=seek|raw, que usaSEEK_HOLEcuando puede y cae a leer el archivo crudo cuando no. -
Restauración con restic — documenta que "By default, restic does not restore files as sparse"; hay que pasar
--sparse. -
borg extract— su opción--sparserecrea los agujeros a partir de los bloques de ceros.
-
Ese mismo espacio asignado lo expresa cada herramienta en su propia unidad, y los números no siempre coinciden:
statdast_blocks = 136(136 × 512 = 69632),ls -lspone68en su primera columna (68 × 1024 = 69632) ydudice68K. La diferencia entre 136 y 68 es solo la unidad —512 bytes contra 1024, el default de GNUls—. ConPOSIXLY_CORRECT, o conls -s --block-size=512,ls -sbaja a 512 y coincide const_blocks. ↩ -
En btrfs con compresión (el default de Fedora) esas cifras de 1 GiB salen mucho más chicas: los ceros reescritos se comprimen a casi nada. El archivo se re-infla igual en el espacio lógico, pero el filesystem disimula el costo en disco. ↩


