Ошибка записи "No space left on device" выглядит однозначно: место кончилось. Администратор открывает /var, запускает подсчёт по дереву и получает противоречие: утилита обхода насчитывает девять гигабайт, а утилита по счётчикам файловой системы настаивает на девятнадцати. Обе правы, просто смотрят на раздел с разных сторон: одна идёт по именам из записей каталогов, другая читает таблицу свободных блоков в суперблоке. Ниже механика расхождения, четыре типовые причины невидимой занятости и порядок проверки, который приводит цифры к согласию.

Расхождение показателей df и du как отправная точка поиска пропавшего места

Диагностика начинается с двух снимков. Команда df читает счётчики из суперблока и отвечает мгновенно. Команда du идёт по записям каталогов и складывает объёмы файлов, до которых может дойти по именам, поэтому на большом разделе работает секунды и минуты. Ключ -x у du удерживает утилиту в границах одного тома, иначе в сумму попадут соседние точки монтирования:

df -h /var
du -shx /var
du -xh /var | sort -rh | head -25

Третья команда строит рейтинг подкаталогов: sort -rh понимает человекочитаемые размеры и ставит наверх самые крупные. На проблемном сервере снимки расходятся заметно:

Filesystem      Size  Used Avail Use% Mounted on
/dev/sdb1        20G   19G     0 100% /var
8.9G    /var

Девятнадцать занятых гигабайт против неполных девяти в дереве. Если бы цифры сходились, ответ лежал бы в банальной чистке: тяжёлые журналы, кэши, слои контейнеров. Когда du насчитывает вдвое меньше, объём держит то, чего в обходе нет физически.

В строке df прячутся два независимых расхождения. Первое, Used против суммы дерева, только что измерено. Второе видно в арифметике: Used и Avail дают в сумме девятнадцать при разделе в двадцать гигабайт. Недостающий гигабайт это резерв файловой системы: в ext по умолчанию пять процентов объёма отводится пользователю root, и для остальных процессов эти блоки выглядят занятыми. Защита разумна, потому что обычная запись не способна довести раздел до состояния, при котором даже root не запишет строку в системный журнал. Проверка резерва на разделе семейства ext:

tune2fs -l /dev/sdb1 | grep -i "Reserved block"
Reserved block count:     262144
Reserved blocks uid:      0 (user root)

Двести шестьдесят две тысячи сто сорок четыре блока по четыре килобайта это ровно гигабайт. Поэтому картина "Used 19G, Avail 0" не означает полного отказа в записи: root ещё пишет, и у его служб остаётся запас хода. Размер резерва меняется tune2fs -m с числом процентов, а решение принимают по роли раздела: на корневом резерв бережёт систему от остановки, на разделе с журналами пять процентов часто простаивают без пользы.

Интерактивный разбор дерева каталогов через ncdu и границы его обзора

Утилита ncdu упаковывает логику du в интерактивный экран и убирает ручной перебор. Пакет лежит в репозиториях основных дистрибутивов и ставится одной командой, например apt install ncdu. Полная картина по /var требует запуска от root:

ncdu -x /var

После сканирования появляется список: размер записи, полоса доли относительно самого крупного соседа и имя, сортировка по убыванию. Навигация на одиночных клавишах: стрелки и Enter спускают в каталог и возвращают наверх, s и n переключают сортировку по размеру и имени, g меняет отрисовку доли, i показывает свойства записи, r пересчитывает каталог, d запускает удаление, q закрывает экран.

Клавиша d с журналами заслуживает осторожности: удаление растущего файла прямо из ncdu повторяет классическую ошибку, из-за которой место и не возвращается. Если служба пишет через открытый дескриптор, между rm и освобождением объёма лежит целый перезапуск. Живой тяжёлый журнал сначала обнуляют, затем разбирают причину роста:

truncate -s 0 /var/log/app/server.log

Обнуление возвращает блоки немедленно. Нюанс в режиме записи: журнал с флагом дописывания продолжит писаться с нуля, а файл без флага продолжит запись со старой позиции и превратится в разрежённый, у которого заявленный размер велик, а блоков под ним мало.

Переключатель a меняет способ измерения между занятыми блоками и заявленным размером из метаданных. Разница между режимами сама по себе диагностический признак: образ диска в разрежённом формате заявляет сотню гигабайт, а по блокам весит вдвое меньше; миллион мелких файлов наоборот, каждый съедает минимум один блок, и заявленный размер выходит скромнее реального расхода.

Что обычно набирает вес в /var. Каталог /var/lib хранит состояние служб: у контейнерного окружения это образы, слои и тома, быструю сводку даёт docker system df, а журналы контейнеров растут без ограничений, если у драйвера json-file не задан лимит в /etc/docker/daemon.json. Каталог /var/log собирает журналы: объём журнала systemd показывает journalctl --disk-usage, сжатие до заданного размера выполняет journalctl --vacuum-size=500M. В /var/cache кэши менеджеров пакетов, в /var/spool очереди почты и печати.

Границы обзора у ncdu те же, что у du: утилита идёт по именам из записей каталогов. Удалённый файл с открытым дескриптором, спрятанное под точкой монтирования содержимое и заполненная таблица inode остаются за кадром. Поэтому итог ncdu читают с одним вопросом: если сумма дерева заметно меньше занятости по df, дальше разбираются не каталогами, а дескрипторами и точками монтирования.

Удалённые файлы с открытыми дескрипторами держат место в разделе

Механика удаления файла в Linux расходится с бытовой интуицией. Команда rm убирает не файл, а имя: из каталога исчезает запись, счётчик ссылок inode падает до нуля, но метаданные и блоки остаются закреплёнными за ядром, пока открыт хотя бы один дескриптор. Файл теряет имя и становится невидимым для du, ncdu и любого поиска по пути, а занятые блоки остаются в счётчиках суперблока и честно отражаются в df. Куда девается место после удаления? Никуда: оно закреплено за inode до последнего дескриптора.

Сценарий почти всегда один. Служба журналирования держит открытый дескриптор на файл в /var/log, файл растёт, администратор видит заполнение и среди ночи удаляет его командой rm, чтобы вернуть место. Имя исчезает, объём не возвращается: служба продолжает писать через старый дескриптор, и занятость даже растёт, потому что записи ничего не мешает.

Проверить подозрение можно без сторонних утилит, через каталог /proc:

ls -l /proc/812/fd | grep deleted
lrwx------ 1 root 64 Jun 12 02:31 7 -> /var/log/syslog (deleted)

Строка с пометкой "(deleted)" означает дескриптор на файл без имени. Место под ним освободится в момент закрытия дескриптора: когда процесс завершится, будет перезапущен или откроет новый файл. Перезагрузка тоже закрывает дескрипторы, но это самый дорогой путь: она убирает симптом сразу на всех службах, а причина остаётся неразобранной.

Поиск удалённых открытых файлов через lsof и освобождение занятого места

Каталог /proc подсказывает, есть ли подозрение вообще, но для полной картины нужна утилита lsof. Ключ +L с числом включает в вывод колонку счётчика ссылок и оставляет только записи со значением меньше единицы, то есть удалённые открытые файлы. Отбор по /var делает обычный конвейер:

lsof +L1 | grep /var

На сервере из примера ответ находится сразу:

COMMAND   PID USER  FD   TYPE DEVICE  SIZE/OFF NLINK  NODE NAME
rsyslogd  812 root   7w   REG   8,17 10737418240     0  4242 /var/log/syslog (deleted)

Одна строка закрывает все десять гигабайт разницы. COMMAND называет держателя, PID даёт его номер, SIZE/OFF показывает объём невидимого файла, NLINK с нулём подтверждает удаление, NAME хранит последний известный путь. Освобождение сводится к закрытию дескриптора, для службы под systemd это перезапуск:

systemctl restart rsyslog

Одиночному процессу отправляют сигнал завершения командой kill -TERM 812, но перед этим стоит понять его роль: завершение пишущей службы останавливает и запись в её журнал. После перезапуска контрольный df -h /var показывает занятость около девяти гигабайт, а повторный lsof +L1 остаётся пустым.

Профилактика укладывается в нормальную нарезку журналов. Утилита logrotate после среза отправляет службе сигнал, та открывает новый файл по прежнему пути, а срезанный экземпляр сжимается и покидает сервер по расписанию:

/var/log/nginx/*.log {
    weekly
    rotate 10
    compress
    missingok
    postrotate
        systemctl reload nginx.service >/dev/null
    endscript
}

Вариант copytruncate обходится без перезапуска: файл копируется, оригинал обнуляется, служба продолжает писать в тот же дескриптор. Плата за экономию времени это окно между копированием и обнулением, в котором параллельная запись способна потерять несколько строк.

Исчерпание таблицы inode при свободных байтах на разделе

Второй класс невидимой занятости зеркален первому: байты свободны, а записывать некуда. У файловой системы конечны два ресурса, блоки и записи таблицы inode. Каждая запись хранит метаданные файла и указатели на блоки, фиксируется в момент создания файловой системы и не расширяется на лету. Файлу любого размера нужна ровно одна запись, каталогу тоже. Когда таблица исчерпана, создание файла заканчивается той же ошибкой "No space left on device", хотя df -h показывает гигабайты свободного места. Показатели inode выведены отдельной командой:

df -i /var
Filesystem      Inodes  IUsed  IFree IUse% Mounted on
/dev/sdb1      1310720 1310711      9  100% /var

Одинаковые сообщения при разных причинах и есть главный подвох: различие видно только сравнением df -h с df -i. Заполненную таблицу почти всегда объясняет масса мелочи: сессии веб-приложений, где каждому посетителю отводится отдельный файл, почтовые хранилища с письмом на файл, очереди и кэши мелкими фрагментами. Найти каталог-рекордсмен помогает du с ключом --inodes, который считает записи вместо байтов:

du -x --inodes /var | sort -n | tail -10

Хвост вывода указывает на виновника:

1266643  /var/lib/php/sessions
1266721  /var/lib/php
1266847  /var/lib
1310711  /var

Миллион с четвертью файлов сессий. Очистка по возрасту выполняется find:

find /var/lib/php/sessions -type f -mtime +1 -delete

Команда уберёт файлы старше двух суток. Маска для rm не годится: конструкция rm /var/lib/php/sessions/* упрётся в ограничение длины списка аргументов и откажет с сообщением "Argument list too long", а find с -delete обрабатывает записи по одной и с лимитом не встречается. После очистки контрольный df -i показывает вернувшиеся записи, и раньше этого момента сообщения об отсутствии места не исчезнут.

Спрятанные под точкой монтирования файлы и способ увидеть скрытый объём

Третья причина расхождения живёт в устройстве монтирования. Точка монтирования накрывает каталог новым томом, прежнее содержимое остаётся лежать на нижнем разделе, но любой обход видит только верхний слой. du и ncdu честно обходят то, что смонтировано сверху, а df учитывает занятость каждого тома отдельно, и спрятанные файлы остаются в счётчиках нижнего раздела. Классический сценарий: том для контейнеров смонтировали поверх /var/lib/docker, а прежнее содержимое каталога осталось внизу; то же выходит с аварийной копией перед переносом.

Сначала смотрят карту точек монтирования:

findmnt -R /var
TARGET           SOURCE     FSTYPE  OPTIONS
/var             /dev/sdb1  ext4    rw,relatime
/var/lib/docker  /dev/sdc1  ext4    rw,relatime

Разбор спрятанного слоя не требует размонтирования. Достаточно bind-копии того тома, на котором лежит накрытый каталог: у простой bind-копии вложенные точки монтирования не воспроизводятся, и под ней видно исходное содержимое. Если скрыт сам каталог /var, копируют корень:

mkdir -p /mnt/inspect
mount --bind / /mnt/inspect
du -shx /mnt/inspect/var
umount /mnt/inspect

Если накрыт вложенный каталог, копируют том /var: mount --bind /var /mnt/inspect, затем du -shx /mnt/inspect/lib/docker. Сумма du и есть спрятанный объём, дальше ненужное удаляют, нужное переносят на верхний слой. Размонтирование верхнего тома тоже открывает вид на спрятанное, но живой /var с работающими службами просто так не освободить.

Практический порядок проверки раздела /var от первой команды до очистки

Всё перечисленное складывается в последовательность на случай первого сообщения о заполнении. Шаги идут от секундных проверок к действиям с перезапуском служб. Полный разбор выглядит так:

  1. снять показания df -h /var и df -i /var и зафиксировать занятые байты и записи таблицы индексов;
  2. сверить их с суммой дерева du -shx /var и оценить масштаб расхождения;
  3. открыть ncdu -x /var, спуститься в самые тяжёлые ветки и найти реальных потребителей объёма;
  4. запустить lsof +L1 и отыскать удалённые файлы по нулевому счётчику ссылок;
  5. перезапустить держателя из колонки COMMAND и проверить по df -h возврат места;
  6. при исчерпании индексов найти каталог-рекордсмен командой du -x --inodes и вычистить мелочь по возрасту;
  7. просмотреть findmnt -R /var и при подозрении на спрятанный слой заглянуть под него через bind-копию;
  8. сверить сумму Used и Avail с размером раздела и при недостаче вспомнить о резерве;
  9. очистить найденное, повторить замеры и убедиться, что цифры сошлись.

Пара правил делает порядок надёжнее. Замеры до и после каждого действия, без них легко принять случайность за результат. И сначала понять, кто держит объём, только потом удалять: быстрое rm в сторону растущего журнала выглядит спасением ровно до момента, когда место не возвращается, а имена для поиска исчезают. Место в разделе не пропадает бесследно: оно занято либо файлом с именем, либо файлом без имени, либо содержимым под точкой монтирования, и у каждого состояния есть своя команда, которая его честно показывает.