Разреженные файлы: почему образ диска на 100 ГБ занимает три
Вы создаёте виртуальный диск на 100 ГБ под новую машину, ls -l честно показывает сто гигабайт, а df на хосте почти не шевелится — свободного места как будто не убыло. Это не глюк и не отложенная запись, которая вот-вот догонит. Файловая система с самого начала физически выделила под этот файл не сто гигабайт, а ровно столько, сколько вы туда реально записали — и почти вся эта "сотня" существует только в метаданных. Разберёмся, как это устроено, зачем оно так сделано и где на этом можно неожиданно потерять место или данные.
Содержание
- Что такое разреженный файл на уровне файловой системы
- Разница между ls -l и du: что каждая команда на самом деле показывает
- Как выглядит карта распределения изнутри
- Почему это критично именно для образов виртуальных дисков
- Практические риски: как sparse-файл "материализуется" против вашей воли
- Как проверить и контролировать разреженность на практике
Что такое разреженный файл на уровне файловой системы
Обычный файл в файловых системах вроде ext4, XFS, ZFS или NTFS — это не непрерывный кусок байт на диске, а набор блоков (обычно по 4 КБ), на которые ссылается индексный узел файла (inode в ext4/XFS). Когда вы пишете в файл, файловая система выделяет физические блоки под записанные данные и прописывает в метаданных, какой логический диапазон файла соответствует какому физическому блоку на устройстве.
Разреженный файл (sparse file) — это файл, у которого есть "дыры": диапазоны логических смещений, для которых физический блок вообще не выделен. Когда программа запрашивает чтение из такой дыры, файловая система не читает несуществующий блок с диска — она просто возвращает нули, синтезируя их на лету, потому что в метаданных явно записано "здесь блоков нет, отдавай нули". Диск при этом не трогается вообще: ни чтения, ни тем более записи.
Дыра появляется не потому, что кто-то стёр данные, а потому, что их никогда не записывали. Классический способ создать дыру — seek за пределы текущего конца файла с последующей записью:
# Создаём файл, "прыгаем" на 10 ГБ вперёд и пишем один байт
truncate -s 0 sparse-demo.img
dd if=/dev/zero of=sparse-demo.img bs=1 count=1 seek=10G
ls -lh sparse-demo.img
# -rw-r--r-- 1 user user 10G ... sparse-demo.img
du -h sparse-demo.img
# 4.0K sparse-demo.img
Файл "весит" 10 ГБ с точки зрения любой программы, которая его открывает и читает, но на диске занял один блок (4 КБ на выделение плюс метаданные). Всё пространство между началом файла и записанным байтом — чистая дыра, синтезируемая ядром при чтении.
Это работает благодаря тому, что файловые системы хранят не список всех блоков файла подряд, а карту распределения (extent map), где явно перечислены только выделенные диапазоны. Отсутствие записи в этой карте для диапазона означает "здесь дыра, отдавай нули по запросу" — ни ext4, ни XFS, ни ZFS не хранят где-то отдельно "миллиард нулевых байт".
Разница между ls -l и du: что каждая команда на самом деле показывает
Путаница почти всегда возникает из-за того, что ls и du отвечают на два принципиально разных вопроса.
ls -l (и системный вызов stat, который стоит за ним) показывает поле st_size — логический размер файла, то есть смещение последнего байта, который когда-либо был записан, плюс единица. Это чисто адресное понятие: "если я прочитаю файл с начала до конца, сколько байт я получу". Дыры в этот размер входят так же, как и реально записанные данные — с точки зрения читающей программы дыра неотличима от файла, полного нулей.
du (disk usage) показывает совсем другое — поле st_blocks из той же структуры stat, умноженное на размер блока (обычно 512 байт, независимо от реального размера блока ФС). Это количество физических блоков, которые файловая система реально выделила под файл на устройстве. Дыры блоков не потребляют, поэтому в du не попадают.
# Файл с логическим размером 100 ГБ, но почти пустой физически
ls -lh disk.qcow2
# -rw-r--r-- 1 user user 100G сен 8 12:00 disk.qcow2
du -h disk.qcow2
# 3.1G disk.qcow2
# du --apparent-size покажет логический размер, как ls
du -h --apparent-size disk.qcow2
# 100G disk.qcow2
Отсюда и заголовок: образ на 100 ГБ, у которого гостевая система реально использовала около 3 ГБ, покажет в ls -l ровно сто, а в du — три с небольшим. Обе команды правы, они просто отвечают на разные вопросы: "какой у файла адресный диапазон" против "сколько физического места он реально занимает".
Важный нюанс: du округляет использование до размера блока (обычно 4 КБ), а не до байта — файл с одним записанным байтом покажет 4.0K, а не 1B, потому что файловая система физически не может выделить меньше одного блока.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверКак выглядит карта распределения изнутри
Если хочется увидеть не агрегированную цифру, а саму структуру — какие диапазоны файла реально выделены, а какие являются дырами — можно использовать filefrag (для ext4/XFS) или системный вызов lseek с флагами SEEK_HOLE/SEEK_DATA, которые файловая система обязана поддерживать для sparse-файлов.
# Подробная карта экстентов файла (реально выделенных диапазонов)
filefrag -v sparse-demo.img
# Компактная сводка: сколько экстентов, есть ли фрагментация
filefrag sparse-demo.img
# sparse-demo.img: 1 extent found
SEEK_HOLE и SEEK_DATA — это режимы lseek, которые двигают позицию в файле не на заданное смещение, а до ближайшей границы дыры или данных. Программа может пройти по всему файлу, ни разу не читая дыры, узнавая только границы реально записанных диапазонов — именно так эффективные копировщики понимают, что можно пропустить.
Без поддержки SEEK_HOLE/SEEK_DATA (или анализа extent-карты через FIEMAP) программа не может отличить дыру от блока, реально заполненного нулями — обе читаются как одна и та же последовательность нулевых байт. Разница видна только на уровне метаданных, а не содержимого.
Почему это критично именно для образов виртуальных дисков
Образы виртуальных дисков — qcow2, vmdk, raw в sparse-режиме, vhdx — это ровно тот сценарий, где разреженность имеет наибольшее практическое значение, потому что сам формат построен вокруг предположения "заявленный размер почти всегда сильно больше реально используемого".
Когда вы создаёте диск виртуальной машины на 100 ГБ, вы не резервируете хостовой системе сто гигабайт — вы резервируете адресное пространство для гостевой ОС. Гостевая система видит блочное устройство на 100 ГБ и размечает его как обычно, но пока она не запишет данные в конкретный сектор, соответствующий блок на хосте не выделяется вообще.
# Создание qcow2-образа — формат изначально sparse
qemu-img create -f qcow2 disk.qcow2 100G
qemu-img info disk.qcow2
# virtual size: 100 GiB (107374182400 bytes)
# disk size: 196 KiB
virtual size здесь — то, что видит гостевая ОС и что покажет ls -l на хосте. disk size — то, что реально занято на физическом хранилище, аналог du. Разница на свежесозданном образе колоссальна именно потому, что ещё ничего не записано.
Формат raw в режиме sparse ведёт себя аналогично, но проще: это буквально файл файловой системы хоста с дырами, без собственного формата поверх. Формат qcow2 добавляет ещё один уровень — copy-on-write и собственную внутреннюю структуру кластеров, которая тоже выделяется лениво, но это отдельная механика, не тождественная sparse-файлам хостовой ФС (мы разбирали её отдельно в статье про copy-on-write в qcow2).
Практическое следствие: если у вас на хостовом хранилище 500 ГБ и вы создаёте пять виртуалок по 100 ГБ каждая, это не значит, что место закончилось — если внутри виртуалок реально используется по 20-30 ГБ, вы вполне уложитесь. Это тот же принцип переподписки, что и у тонких томов на уровне LVM или ZFS (мы разбирали механику ближе к тому уровню в статье про тонкие тома и переподписку диска) — только здесь переподписка происходит на уровне обычного файла в обычной файловой системе, без отдельной подсистемы тонкого выделения.
Важно понимать границу: после того как гостевая ОС один раз записала в сектор, блок на образе выделяется — и остаётся выделенным даже после удаления файла внутри гостя. Удаление файла в NTFS или ext4 внутри виртуалки не сообщает хостовой ФС "этот блок снова свободен", оно лишь убирает запись из каталога гостевой ФС. Поэтому образы виртуалок со временем растут в реально занятом месте и почти никогда не сжимаются сами по себе.
Практические риски: как sparse-файл "материализуется" против вашей воли
Главная опасность разреженных файлов не в них самих, а в инструментах, которые не умеют их распознавать. Утилита без поддержки дыр читает файл последовательно от начала до конца — и для дыры получает не "ничего", а поток нулей, который ей отдаёт ядро. Дальше эти нули честно записываются в файл назначения — и если инструмент назначения тоже не умеет создавать дыры, нули ложатся как обычные данные, занимая реальные физические блоки.
Это называют "материализацией" sparse-файла: логический размер остаётся прежним, но физически занятое место скачком становится равным логическому. Файл на 100 ГБ с тремя реально занятыми гигабайтами после наивного копирования может занять все сто.
Типичные ловушки:
# cp по умолчанию (современный GNU coreutils) умеет sparse --
# но полагаться на "по умолчанию" не стоит, лучше указать явно
cp --sparse=always disk.qcow2 disk-copy.qcow2
# rsync без -S копирует нули как данные -- файл раздувается
rsync -a disk.qcow2 remote:/backup/ # опасно для sparse
rsync -aS disk.qcow2 remote:/backup/ # -S сохраняет дыры
# tar по умолчанию тоже материализует дыры при архивации
tar cSf backup.tar disk.qcow2 # -S: sparse-режим tar
# scp вообще не имеет понятия о sparse-файлах --
# копия всегда будет полноразмерной на диске назначения
scp disk.qcow2 remote:/backup/ # нет флага для sparse
Флаг -S включает распознавание дыр через SEEK_HOLE/SEEK_DATA или сканирование нулевых блоков — без него инструменты читают файл как непрерывный поток байт и не имеют возможности узнать, что часть этого потока была дырой, а не реальным нулём.
Отдельная ловушка — файловая система назначения. Даже если источник честно распознал дыры, но целевая ФС вообще не поддерживает sparse-файлы (некоторые сетевые ФС, отдельные конфигурации FAT, объектные хранилища, куда файл монтируют через шлюз), дыры материализуются на записи независимо от источника — и с этим ничего не поделать на уровне инструмента копирования.
То же происходит при передаче через каналы, не сохраняющие структуру файла, — например, некоторые backup-агенты без поддержки sparse-режима или перенаправление вывода через pipe. Если вы бэкапите виртуалки именно как файлы образов, а не снапшотами хранилища, стоит заранее свериться, как выбранный инструмент обращается со sparse-структурой — мы разбирали разницу подходов в статье про бэкап виртуалок: образ или файлы.
Обратная проблема тоже решаема: если файл уже материализовался после копирования без sparse-поддержки, это не значит, что он навсегда потерял разреженность. Блоки, которые физически являются нулями, но выделены как обычные данные, можно снова превратить в дыры через fallocate --dig-holes (Linux) — команда сканирует файл и "продырявливает" нулевые диапазоны, освобождая место без изменения логического размера и содержимого.
# "Разредить" уже материализованный файл заново
fallocate --dig-holes disk-copy.qcow2
du -h disk-copy.qcow2 # место должно уменьшиться
Это не восстанавливает исходную структуру экстентов файла (фрагментация может отличаться от оригинала), но по логическому содержимому и занятому месту результат эквивалентен.
Как проверить и контролировать разреженность на практике
Перед тем как переносить или бэкапить образы, полезно заранее понимать, сколько места они реально занимают и насколько сильно "продырявлены".
# Логический и физический размер рядом
ls -lh disk.qcow2 && du -h disk.qcow2
# Для qcow2 конкретно -- родная утилита считает оба размера сама
qemu-img info disk.qcow2
При создании нового образа стоит осознанно выбирать между sparse и preallocated (толстым, с немедленным выделением всего объёма):
| Режим | Место при создании | Скорость первой записи | Риск переподписки |
|---|---|---|---|
| Sparse (по умолчанию у qcow2) | Минимальное | Чуть медленнее (нужно выделять блок на каждой новой записи) | Есть — можно исчерпать хранилище раньше, чем ожидалось |
preallocation=metadata | Метаданные выделены, данные — нет | Средняя | Частично снижен |
preallocation=full (raw, полностью материализованный) | Равно логическому размеру сразу | Максимальная, без ленивого выделения | Отсутствует — место физически зарезервировано |
# Полностью материализованный (толстый) образ -- без sparse-эффекта вовсе
qemu-img create -f qcow2 -o preallocation=full disk-thick.qcow2 100G
Толстое выделение имеет смысл там, где важна предсказуемость (гарантия, что место точно есть и не кончится в проде) или где фрагментация ленивого выделения заметно бьёт по производительности случайной записи — например, под высоконагруженную базу данных внутри виртуалки. Плата — вы сразу занимаете весь логический объём, и переподписка (создать виртуалок больше, чем физически влезет по номиналу) становится невозможной.
Для мониторинга хоста, где крутится много виртуалок со sparse-образами, полезно смотреть не на сумму virtual size, а на сумму реально занятого места (disk size / du) и держать запас — рост от 3 ГБ к 30 ГБ внутри одной виртуалки происходит незаметно, если ориентироваться только на логические размеры.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Файл выглядит как 100 ГБ в файловом менеджере — он правда столько весит?
Смотря что показывает менеджер: если это логический размер (st_size, то же самое, что ls -l), то нет — реально на диске может быть занято в разы меньше. Если менеджер явно считает "размер на диске" (некоторые графические оболочки делают это отдельным полем), это уже ближе к du.
Можно ли скопировать sparse-файл по сети (SMB/NFS) без раздувания?
Зависит от реализации протокола и клиента. Часть современных реализаций SMB и NFS умеют передавать sparse-информацию, но гарантии нет — при сомнениях используйте инструмент с явной поддержкой sparse (cp --sparse=always, rsync -S) и сверьте du на источнике и на назначении.
Почему бэкап образа виртуалки занял в разы больше места, чем сам образ?
Почти всегда это материализация: инструмент бэкапа прочитал дыры как нули и записал их как реальные данные. Проверьте, поддерживает ли инструмент и целевая ФС sparse-режим, и сравните du исходного файла с размером результата.
Если удалить файлы внутри гостевой ОС, образ на хосте уменьшится?
Сам по себе — нет. Гостевая ФС помечает свои внутренние блоки свободными, но не сообщает об этом наружу автоматически. Чтобы вернуть место хосту, нужен TRIM/discard, прокинутый в виртуалку, либо qemu-img convert с пересозданием образа, либо fallocate --dig-holes после обнуления содержимого.
Есть ли риск для целостности данных из-за sparse-механики?
Сама по себе разреженность не угрожает целостности — дыра надёжно отдаёт нули, пока файловая система жива. Риск косвенный: если хранилище хоста переподписано и реально занятое место растёт незаметно, в какой-то момент записи может не хватить места — и это уже угроза для данных всех виртуалок на этом хранилище, а не только той, что дописывала последней.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →