Классическая сцена из практики любого администратора: мониторинг рисует 100% занятого места на /var, оперативно находится огромный лог на 40 ГБ, его удаляют, и через минуту df снова показывает 100%. Файл исчез из каталога, а место не вернулось. Паника обычно приводит к двум неверным действиям: администратор начинает удалять что попало или перезагружает сервер целиком. Между тем причина проста и лежит в основах файловых систем Unix, и диагностируется за пару команд через lsof. Ниже разбирается механика inode и link count, реальные команды с разбором вывода, пошаговый план действий и правильная настройка logrotate, чтобы эпизод не повторялся.

Стандартная картина переполнения диска и первые проверки

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

df -h /var

Образец вывода:

Filesystem      Size  Used Avail Use% Mounted on
/dev/sda2        50G   50G   20K  100% /var

Здесь Used показывает 50G при нулевом фактическом запасе (Avail 20K). df читает статистику суперблока файловой системы, то есть видит реально занятые блоки, включая блоки удалённых, но ещё открытых файлов. Вторая команда:

du -sh /var

Образец вывода:

9.8G    /var

du обходит дерево каталогов и суммирует размеры только тех файлов, у которых есть имя в каталоге. Расхождение в 40 ГБ между df и du и есть главный симптом: на диске есть огромные блоки, не привязанные ни к одному видимому имени. Проверить расхождение быстро помогает пара команд рядом:

# сравнить занятое место по мнению файловой системы и по мнению дерева каталогов
df -h /var && du -sh --apparent-size /var
sync  # сбросить буферы, чтобы исключить просто незаписанный кэш

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

Параллельно имеет смысл проверить исчерпание индексных дескрипторов, потому что сообщение "No space left on device" возникает и при 100% IUse:

df -i /var

Образец вывода:

Filesystem      Inodes  IUsed   IFree IUse% Mounted on
/dev/sda2     3276800  51234 3225566    2% /var

Здесь IUse 2%, значит индексных дескрипторов хватает, и проблема именно в блоках данных. Бывает и обратная ситуация: миллионы мелких файлов сессий выедают все inode при 40% занятых блоков, тогда du с df сойдутся, а запись на диск будет отваливаться. В нашей истории сценарий другой, но проверка занимает секунду.

Что происходит с удалённым файлом на уровне inode и ссылок

В ext4 и xfs файл это inode, запись с метаданными и указателями на блоки данных. Имя файла в каталоге это отдельная структура, hard link, и у одного inode имён может быть несколько. В inode хранится счётчик ссылок link count, а ядро дополнительно ведёт счётчик открытых файловых дескрипторов.

Команда rm не удаляет файл как объект. Она вызывает unlink() и убирает имя из каталога, уменьшая link count на единицу. Блоки освобождаются только когда выполняются два условия одновременно: link count равен нулю и процесс ни одним дескриптором файл не держит. Пока хотя бы один дескриптор открыт, inode и все его блоки живут на диске, файл продолжает расти от записи в него, но из каталога он уже исчез. Посмотреть механику можно на простом эксперименте:

# создать файл и проверить его inode и счётчик ссылок
echo test > /tmp/demo.txt
stat /tmp/demo.txt

В выводе stat важны две строки: Inode: 262151 и Links: 1. Теперь открыть файл в фоновом читателе и удалить имя:

sleep 300 < /tmp/demo.txt &   # процесс держит дескриптор
rm /tmp/demo.txt              # имя исчезло, inode остался
ls /proc/$(pgrep -n sleep)/fd  # в списке дескрипторов файл помечен deleted

Дескриптор в /proc/<pid>/fd продолжает указывать на inode, и ядро честно сохраняет данные. Именно поэтому полная перезагрузка сервера помогает: все дескрипторы закрываются, и ядро наконец освобождает блоки. Но перезагружать продакшен ради этого, честно говоря, крайняя мера.

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

Быстрый способ найти виновника это lsof с фильтром по link count:

lsof +L1

Опция +L1 выводит только файлы с link count меньше единицы, то есть удалённые, но открытые. Образец вывода:

COMMAND    PID   USER   FD   TYPE DEVICE SIZE/OFF    NLINK  NAME
nginx     1234   root    6w   REG    8,2  42949672960    0   /var/log/nginx/access.log.1 (deleted)
java      2871   app   112w   REG    8,2  3221225472     0   /var/log/app/out.log (deleted)

Разбор по колонкам: COMMAND и PID называют процесс-держатель; FD равный 6w говорит, что дескриптор 6 открыт на запись (w), именно сюда процесс пишет прямо сейчас; SIZE/OFF в десятки гигабайт показывает реальный размер удерживаемого файла; NLINK 0 подтверждает, что ссылок нет; пометка (deleted) в NAME финальный диагноз. Альтернативный поиск, когда +L1 недоступен или нужна фильтрация:

lsof | grep deleted
lsof +L1 /var          # ограничить поиск конкретной точкой монтирования

grep по слову deleted хуже: шумнее и медленнее на сервере с сотнями тысяч открытых дескрипторов, +L1 делает то же самое на уровне самого lsof. Полезно сразу оценить совокупный урон:

# суммарный размер всех удалённых, но открытых файлов в гигабайтах
lsof +L1 | awk '{s+=$7} END {printf "%.1f GB
", s/1024/1024/1024}'

Колонка FD заслуживает отдельного взгляда: суффикс w означает запись, r чтение, u чтение и запись, а цифра это номер дескриптора в /proc/<pid>/fd. Если в FD значится DEL, значит файл удалён и маппируется в память, так бывает со старыми библиотеками после обновления пакетов, они тоже держат место. Проверить состояние маппингов помогает вывод:

# посмотреть удалённые файлы в памяти конкретного процесса
lsof -p 2871 | grep -E "deleted|DEL"

Отдельный частый случай это контейнеры: процесс внутри docker-контейнера держит файл на хостовой файловой системе, и lsof на хосте покажет PID уже в хостовом пространстве имён. Перезапускать нужно контейнер, а не что-либо на хосте, опознать его помогает docker ps -q в связке с docker inspect по PID процесса.

Типичные причины из практики: nginx или apache, которым после ротации логов не отправили сигнал переоткрыть файлы; java-приложение с выводом stdout в файл через nohup, у которого лог удалили руками; база данных, которая держит удалённый временный файл сортировки (это нормально, MySQL так работает штатно); docker-контейнеры, где процесс внутри контейнера держит файл на хостовой ФС.

Пошаговый план освобождения места в продакшене

Когда df показывает 100%, а du виновника не видит, действовать стоит в строгом порядке:

  1. Выполнить sync и снова сравнить df -h с du -sh, подтвердив разрыв в гигабайтах;
  2. Найти удалённые открытые файлы командой lsof +L1 и отсортировать по колонке SIZE/OFF;
  3. Определить по COMMAND и PID, чей это процесс, и проверить его статус через systemctl status;
  4. Освободить место без остановки процесса через truncate дескриптора или штатный сигнал переоткрытия логов;
  5. Если сигнал не предусмотрен, аккуратно перезапустить только этот сервис через systemctl restart;
  6. После освобождения проверить df -h повторно и зафиксировать причину для профилактики.

Ключевой приём в пункте 4: truncate работает и с удалённым файлом через дескриптор процесса, без переоткрытия и без остановки сервиса:

# обнулить файл, который nginx держит как дескриптор 6
: > /proc/1234/fd/6

Перенаправление пустоты укорачивает файл до нуля байт, блоки немедленно возвращаются файловой системе, df через секунду показывает свободное место. Процесс продолжает писать в тот же inode, файл начинает расти заново. Нюанс: если процесс пишет без O_APPEND, после truncate образуется sparse-область, и du будет показывать меньше, чем ls -l, это нормально. У штатных сервисов есть цивилизованный путь: nginx после ротации понимает kill -USR1, apache умеет graceful через apachectl, systemd-сервисы часто перечитывают логи по systemctl reload.

logrotate copytruncate и truncate вместо rm для растущих журналов

Постоянное решение это корректная ротация. Для сервисов, которые умеют переоткрывать логи по сигналу, logrotate настраивается с postrotate. Пример конфига /etc/logrotate.d/nginx:

/var/log/nginx/*.log {
    daily
    rotate 14
    compress
    delaycompress
    missingok
    notifempty
    sharedscripts
    postrotate
        [ -s /run/nginx.pid ] && kill -USR1 $(cat /run/nginx.pid)
    endscript
}

Здесь ротация ежедневная, хранятся 14 архивов, delaycompress откладывает сжатие предыдущего лога на один цикл, а postrotate отправляет nginx сигнал USR1, после которого nginx закрывает старые дескрипторы и открывает новые файлы. Место освобождается без рестарта.

Для приложений, которые не умеют переоткрывать файлы (типичный случай это stdout через nohup или systemd StandardOutput=file), единственно корректная директива это copytruncate:

/var/log/app/out.log {
    daily
    rotate 7
    copytruncate
    maxsize 500M
    notifempty
    compress
}

copytruncate копирует содержимое в архив, а затем укорачивает живой файл до нуля, дескриптор остаётся валидным. Стоимость: между копированием и усечением возможна потеря нескольких строк, плюс двойная запись при копировании большого лога. Для stdout-приложений это приемлемая цена. Ручной аналог copytruncate, когда logrotate ещё не настроен:

truncate -s 0 /var/log/app/out.log   # правильно, место вернётся сразу

Именно truncate, а не rm: удаление имени при живом дескрипторе прячет файл от глаз, но не освобождает блоки, и через неделю администратор снова окажется у любопытной загадки со 100% на df.

Дополнительная диагностическая палочка это journalctl: если логи пишет journald, стоит проверить его аппетит, он тоже удаляет старые файлы при vacuum, и процесс systemd-journald держит их до завершения:

journalctl --disk-usage                    # сколько занимают журналы
journalctl --vacuum-size=500M              # обрезать до 500 МБ
systemctl restart systemd-journald         # финально освободить дескрипторы

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

Эпизод с фантомными 40 ГБ это симптом пробелов в эксплуатации, и закрывать только его недостаточно. Рабочий минимум такой: на все сервисы, пишущие в файлы, ставится конфиг logrotate, для stdout-приложений обязательно с copytruncate; админский скрипт или мониторинг раз в час выполняет lsof +L1 и сигналит, когда суммарный SIZE/OFF превышает, скажем, 2 ГБ; алерты на df ставятся с порогом 80%, а не 100%, чтобы оставался запас времени на реакцию; в systemd-юнитах приложений предпочтителен StandardOutput=journal вместо file, тогда ротацией ведает journald с жёстким SystemMaxUse. Многие администраторы замечали, что после перевода stdout на journald проблема удалённых файлов просто перестаёт существовать, потому что firmware-подобная дисциплина journald не даёт журналам разрастись.

Отдельного упоминания заслуживает мониторинг этой ситуации в автоматическом режиме. Раз в несколько минут cron может выполнять короткую проверку: lsof +L1 2>/dev/null прогоняется по серверу, и если суммарный размер удерживаемых удалённых файлов превышает, скажем, десять гигабайт, администратор получает уведомление задолго до того, как df дотянется до критической отметки. Метрика строится на простой арифметике: седьмое поле вывода lsof содержит размер файла в байтах, поэтому awk суммирует его по всем строкам и сравнивает с порогом. Это дешёвая страховка против ночных инцидентов, потому что утечка места через дескрипторы развивается медленно и незаметно: лог-файл ротируется, место не возвращается, и через неделю раздел оказывается забит, хотя по каталогам всё выглядит чисто. Поймать процесс в момент, когда он удерживает первые гигабайты, гораздо приятнее, чем разбираться с отказавшей базой данных, которая не смогла дописать очередную транзакцию.

Итог прост. Разрыв между df и du это не баг файловой системы, а работающий механизм inode: ядро обязано хранить данные, пока жив хоть один дескриптор. lsof +L1 показывает виновника за секунды, truncate через /proc/<pid>/fd освобождает место без остановки сервиса, а правильный logrotate с postrotate или copytruncate решает вопрос навсегда. Перезагрузка сервера в этой истории это не лечение, а признание, что кто-то не дочитал документацию. Теперь документация дочитана, и в следующий раз, когда df упрямо твердит про 100% при пустом на вид каталоге, рука сама потянется к lsof +L1, а диагностика займёт меньше времени, чем доставание кофе из автомата.