MAATRIX / Блог / Место кончилось, но файлы удалены: где оно спряталось

Место кончилось, но файлы удалены: где оно спряталось

MAATRIX

Классическая ситуация: df -h показывает диск заполненным на 100%, сервер начинает падать с ошибками записи, вы идёте искать виновника — а du по всем директориям суммарно даёт в разы меньше места, чем занято. Вы уже удалили самые толстые логи командой rm, но свободного места как не было, так и нет. Диск не врёт и вы тоже не сходите с ума — просто место занимает файл, которого физически больше не существует в файловой системе, но который жив для одного конкретного процесса.

Как Linux на самом деле удаляет файлы

В Unix-подобных системах файл — это не «имя», а inode: структура на диске с метаданными и указателями на блоки данных. Имя файла в директории — это просто ссылка (hard link) на inode, и таких ссылок может быть несколько. У каждого inode есть счётчик ссылок (link count) и отдельно — счётчик открытых файловых дескрипторов, которые держат на него процессы.

Команда rm не трогает данные на диске напрямую. Она делает ровно одну вещь: убирает запись из директории и уменьшает счётчик ссылок inode на единицу. Ядро освобождает блоки данных только тогда, когда выполняются оба условия одновременно:

  • счётчик ссылок (link count) равен нулю — то есть ни одно имя в файловой системе больше не указывает на этот inode;
  • счётчик открытых дескрипторов тоже равен нулю — то есть ни один процесс не держит этот файл открытым.

Если какой-то работающий процесс в момент rm держал файл открытым — например, писал в него лог, — то rm уберёт имя из листинга директории, но данные на диске останутся нетронутыми до тех пор, пока процесс не закроет дескриптор (явно или через завершение работы). Файл при этом становится «невидимым»: его больше нет ни в ls, ни в результатах find, но место он продолжает занимать реально, и df это честно показывает.

Отсюда и парадокс: df отражает состояние на уровне блоков файловой системы (что реально занято), а du (и любой листинг директорий) отражает то, что видно через дерево каталогов. Если файл удалён, но открыт, du его просто не видит — а df продолжает считать его блоки занятыми. Разница между показаниями df и du в такой ситуации — это ровно суммарный размер всех «удалённых, но открытых» файлов в системе. Более подробно про механику этого расхождения разобрано в статье про разницу df и du.

Диагностика: находим невидимые файлы, которые едят место

Первый шаг — подтвердить гипотезу количественно. Сравните, сколько диск занят по мнению файловой системы и сколько видно на уровне файлов:

df -h /
du -sh /* 2>/dev/null | sort -rh | head -20

Если df показывает, скажем, 95 ГБ занято, а сумма по du даёт 60 ГБ — где-то «зависло» около 35 ГБ в удалённых, но открытых файлах. Дальше нужен инструмент, который смотрит не на директории, а на таблицу открытых файловых дескрипторов процессов — это lsof.

Базовая команда для поиска именно таких файлов:

lsof | grep deleted

Более точный и быстрый вариант — искать по признаку «нулевой link count у открытого файла», это флаг +L1:

lsof +L1

Типичный вывод будет выглядеть примерно так:

COMMAND    PID   USER   FD   TYPE DEVICE  SIZE/OFF NLINK    NODE NAME
nginx     1842    www   13w   REG  253,0  38654705664     0  1181203 /var/log/nginx/access.log (deleted)
postgres  2201 postgres  9u   REG  253,0  12884901888     0   987541 /var/lib/postgresql/pg_wal/000000010000 (deleted)

Колонка NLINK равна 0 — это подтверждение того, что файла больше нет ни в одной директории. Колонка SIZE/OFF — это реальный текущий размер файла на диске, который прямо сейчас занят и не освобождается. Колонки COMMAND и PID называют процесс, который держит файл открытым — именно он и есть виновник.

Если lsof без параметров работает слишком долго на нагруженном сервере (он опрашивает все открытые дескрипторы всех процессов), можно сузить область поиска, если есть подозрение на конкретный процесс:

lsof -p $(pgrep -x nginx | tr '\n' ',' | sed 's/,$//') | grep deleted

Или наоборот — отфильтровать сразу по конкретной файловой системе, если диск, о котором идёт речь, смонтирован отдельно:

lsof +L1 | awk '$8 ~ /^253,0$/'

(число в DEVICE нужно подставить своё — его можно узнать через df или lsblk).

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать VPS

Как читать вывод и не перепутать процесс

Важно не путать «удалённый файл» с «файл, который просто временно не виден» — некоторые программы намеренно создают файл, сразу удаляют его имя и работают через дескриптор (это нормальный паттерн для временных файлов, например, у некоторых СУБД и у sort при обработке больших наборов данных). Не каждая строка (deleted) в выводе lsof — это проблема.

Признак настоящей проблемы — большой SIZE/OFF (десятки мегабайт, гигабайты) и путь, который явно указывает на лог или данные, а не на временный файл во /tmp. Обратите внимание на путь в колонке NAME до пометки (deleted) — обычно это подсказывает, что произошло: администратор вручную удалил растущий лог-файл вместо того, чтобы его ротировать.

Полезно также посмотреть на конкретный дескриптор процесса напрямую через /proc:

ls -la /proc/1842/fd | grep deleted
lrwx------ 1 www www 64 сен  5 09:12 13 -> /var/log/nginx/access.log (deleted)

А узнать точный текущий размер именно этого дескриптора можно так:

du -h --apparent-size /proc/1842/fd/13

Это надёжнее, чем ориентироваться на цифры из lsof, если прошло время между запуском команд.

Классический сценарий: лог, который удалили руками

Самый частый кейс на практике: диск веб-сервера или базы данных заполнился, администратор в панике зашёл по SSH и вместо настройки ротации сделал rm /var/log/nginx/access.log (или > access.log — это правильнее, но тоже не всегда спасает, если дело не в логах, а в WAL-файлах базы). Проблема в том, что nginx (или postgres, mysql, любой долгоживущий процесс) в этот момент уже открыл файл на запись и продолжает писать в него по старому файловому дескриптору. Имя исчезло из /var/log/, ls ничего не покажет, а место не освобождается — процесс как ни в чём не бывало продолжает дописывать байты в inode, у которого больше нет имени.

Через час-два администратор снова смотрит df, видит те же 100% и не понимает, что происходит — ведь он же только что всё удалил. Это тот самый момент, когда нужен lsof | grep deleted.

Быстрое решение: перезапустить процесс, который держит файл

Самый простой и почти всегда рабочий способ освободить место — перезапустить процесс из колонки COMMAND/PID. При штатном перезапуске (не kill -9, а graceful restart или systemctl restart) процесс закрывает все свои файловые дескрипторы, счётчик открытых хендлов у осиротевшего inode падает до нуля, и ядро реально освобождает блоки на диске:

systemctl restart nginx
# или для базы данных, где это безопаснее в maintenance-окно:
systemctl restart postgresql

Сразу после перезапуска стоит перепроверить:

df -h /
lsof +L1 | grep -i nginx

Если строка из lsof исчезла и df показал прирост свободного места — проблема решена. Обратите внимание: для веб-сервера под нагрузкой рестарт означает короткий даунтайм соединений (если не настроен graceful reload с сохранением воркеров), поэтому для production полезнее знать способ без перезапуска — см. ниже.

Решение без даунтайма: обнулить файл через /proc

Если процесс нельзя перезапускать (это боевая база данных, или критичный сервис без быстрого failover), можно обнулить содержимое уже удалённого файла напрямую через файловый дескриптор в /proc, не трогая сам процесс:

: > /proc/1842/fd/13

Это сработает, потому что /proc/PID/fd/N — это символическая ссылка на реальный открытый файл в файловой системе (даже если у него больше нет имени в обычной директории). Перенаправление вывода в эту ссылку truncate'ит именно тот inode, на который смотрит дескриптор процесса, — место освобождается немедленно, а процесс просто продолжит писать с позиции 0, ничего не заметив (кроме того, что старое содержимое исчезло).

Важные оговорки: этот трюк не подходит для файлов, куда процесс пишет с сохранением offset важных структур (например, WAL или журналы БД, где смещение имеет смысл) — там обнуление может привести к повреждению данных на уровне логики приложения, даже если файловая система в порядке. Для логов приложений (access.log, error.log) это, как правило, безопасно. Для файлов данных СУБД — только через штатные механизмы самой СУБД, не через /proc.

Профилактика: правильная ротация вместо ручного rm

Корень проблемы — не в lsof, а в привычке удалять растущие файлы вручную командой rm вместо настройки ротации. Правильный инструмент здесь — logrotate, который умеет либо пересоздавать файл с уведомлением процесса, либо использовать copytruncate.

Пример конфига для nginx с уведомлением процесса о необходимости переоткрыть файлы (postrotate-хук):

/var/log/nginx/*.log {
    daily
    rotate 14
    compress
    delaycompress
    missingok
    notifempty
    create 0640 www-data adm
    sharedscripts
    postrotate
        [ -f /var/run/nginx.pid ] && kill -USR1 `cat /var/run/nginx.pid`
    endscript
}

Здесь logrotate переименовывает старый файл, создаёт новый с тем же именем, а сигнал USR1 говорит nginx закрыть старые файловые дескрипторы логов и открыть новые — по актуальному имени. Старый файл (уже без имени, если logrotate его удалит после сжатия) корректно закрывается процессом сам, без зависших дескрипторов, потому что nginx явно уведомлён о ротации, а не узнаёт о ней постфактум через ошибку записи.

Для программ, которые не умеют переоткрывать файлы по сигналу, есть вариант copytruncate — logrotate копирует содержимое в архив, а затем обнуляет исходный файл на месте (тем же способом через truncate, что и в предыдущем разделе), не трогая файловый дескриптор процесса вообще:

/var/log/some-app/app.log {
    daily
    rotate 7
    copytruncate
    compress
    missingok
}

Минус copytruncate — небольшое окно между копированием и обнулением, где несколько строк лога теоретически могут потеряться, но для большинства логов это приемлемая цена за то, что не нужно ничего сигналить приложению. Общий принцип разобран в статье про ротацию логов, а типичные последствия отказа от неё — в материале про антипаттерн логов без ротации.

Отдельно стоит настроить мониторинг заполненности диска заранее, чтобы не доходить до аварийного rm в панике — про экстренные меры, когда место уже кончилось, есть отдельный разбор в статье про 100% заполненный диск на VPS.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать VPS

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Почему rm вообще не спрашивает, открыт ли файл, а просто позволяет удалить?

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

Поможет ли перезагрузка сервера вместо перезапуска процесса?

Да, полная перезагрузка закрывает вообще все файловые дескрипторы всех процессов, так что место освободится в любом случае. Но это избыточно и вызывает полный даунтайм там, где хватило бы точечного systemctl restart конкретного сервиса.

lsof +L1 не находит проблемных файлов, а df всё равно показывает диск заполненным — что дальше?

Проверьте, не относится ли занятое место к резервируемым для root пяти процентам (tune2fs -l /dev/sdXN | grep Reserved), не разрослись ли снапшоты LVM/ZFS, и не забиты ли инодовые лимиты при формально свободном месте на блоках — это отдельная и тоже нередкая причина.

Можно ли автоматизировать поиск таких файлов, чтобы не ловить проблему руками каждый раз?

Да, простой cron-скрипт, который раз в час гоняет lsof +L1 и алертит, если суммарный SIZE/OFF превышает пороговое значение (например, 1 ГБ), закрывает большую часть таких инцидентов на раннем этапе — до того, как диск реально заполнится на 100%.

Если файл был удалён процессом контейнера Docker — работает ли тот же подход?

Да, механика идентична, но искать дескриптор нужно на хосте, а не внутри контейнера — процесс всё равно виден в lsof хостовой системы по своему хостовому PID, просто путь к файлу будет вести в оверлей файловой системы контейнера.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →