Сервер лагает. Смотришь на диск - df -h говорит 95% занято. Запускаешь du -sh /* - в сумме набирается 20%. Куда делись остальные 75%? Файлы не найти, место не освободить, сервис падает.
Это не баг и не магия. Это фундаментальная особенность того как Linux работает с файлами. Разберём почему так происходит и как это чинить за две команды.
Почему df и du показывают разное
df и du смотрят на файловую систему с разных сторон.
df читает метаданные файловой системы напрямую - сколько блоков выделено, сколько свободно. Это данные суперблока, они обновляются мгновенно при любом изменении.
du обходит дерево каталогов и суммирует размеры блоков которые видит. Ключевое слово - видит. du считает только то что доступно через файловую систему прямо сейчас.
Вот тут и начинается расхождение.
Файловый дескриптор и почему rm не удаляет файл сразу
В Linux файл - это не просто запись в каталоге. У каждого файла есть inode - структура в ядре которая хранит метаданные: права, владелец, размер, и главное - список блоков на диске где лежат данные.
Когда процесс открывает файл, ядро создаёт файловый дескриптор - ссылку на inode. Пока хотя бы один дескриптор на inode открыт, ядро не трогает блоки на диске.
Когда делаешь rm файл - удаляется только запись в каталоге (hardlink). Inode и блоки остаются нетронутыми пока счётчик ссылок не упадёт до нуля. Если процесс держит файл открытым - счётчик не упадёт.
Схема выглядит так:
каталог inode блоки на диске /var/log/ --> app.log --> [блок1][блок2][блок3] ^ | открытый дескриптор процесса nginx (pid 1234, fd 7) после rm: каталог inode блоки на диске /var/log/ --> (удалено) --> [блок1][блок2][блок3] ^ ^ | дескриптор | всё ещё занято! nginx (pid 1234, fd 7)
du обходит /var/log/ - файла там больше нет, не считает. df смотрит на блоки - они заняты, считает.
Отсюда расхождение.
Классический сценарий
Самый частый случай - ротация логов через rm.
Представь: logrotate настроен удалять старый лог и создавать новый. Он делает rm /var/log/app.log - запись из каталога пропала. Но nginx или java открыл этот файл на запись при старте и держит дескриптор. Процесс продолжает писать в удалённый файл, блоки заполняются, df видит рост, du ничего не находит.
Это может продолжаться часами и гигабайтами пока не кончится место или не перезапустят процесс.
Как найти виновника
$ lsof +L1
lsof - утилита которая показывает все открытые файлы в системе. Флаг +L1 означает "показать файлы у которых счётчик ссылок меньше 1" - то есть файл удалён из файловой системы, но процесс его ещё держит.
Вывод будет примерно такой:
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NLINK NODE NAME nginx 1234 www 7w REG 8,1 2147483648 0 12345 /var/log/app.log (deleted) java 5678 app 3w REG 8,1 536870912 0 67890 /var/log/service.log (deleted)
Разбираем колонки:
COMMAND - имя процесса
PID - идентификатор процесса
FD - номер дескриптора и режим доступа. 7w - номер 7, режим w (write). Буква означает что делает процесс: r читает, w пишет, u читает и пишет
SIZE/OFF - размер файла. Вот они, потерянные гигабайты
NLINK - счётчик ссылок. 0 означает что файл удалён из всех каталогов
NAME - путь с пометкой (deleted)
Теперь ясно: nginx пишет в удалённый лог размером 2 ГБ.
Как починить без рестарта процесса
Рестарт nginx освободит дескриптор и блоки. Но рестарт не всегда возможен - активные соединения, продакшен, согласования.
Есть способ освободить место не трогая процесс:
$ > /proc/1234/fd/7
Разбираем по частям:
>- оператор перенаправления в bash. Открывает файл на запись и сразу обнуляет его содержимое. Именно обнуляет - не удаляет, а делает размер ноль/proc/- это procfs, псевдофайловая система Linux. Не хранит данные на диске - представляет информацию о процессах и ядре в виде файлов и каталогов прямо в памяти/proc/1234/- каталог процесса с pid 1234fd/- подкаталог со всеми открытыми дескрипторами процесса в виде симлинков на реальные файлы7- номер дескриптора из вывода lsof
Команда идёт к файлу напрямую через дескриптор - минуя файловую систему где файл уже "удалён". Ядро обнуляет блоки, df сразу покажет освободившееся место. Процесс продолжает работать и писать в тот же дескриптор.
Проверить результат:
$ lsof +L1 | grep nginx # пусто или SIZE/OFF = 0
Как не попасть снова
Корень проблемы - logrotate использует rm + создание нового файла. Процесс продолжает писать в старый дескриптор.
Правильное решение - copytruncate в конфиге logrotate:
/var/log/app.log { daily rotate 7 compress copytruncate }
copytruncate работает иначе: копирует содержимое лога в архив, затем обнуляет оригинальный файл через тот же механизм что мы использовали вручную. Процесс продолжает писать в тот же файл, дескриптор не меняется, блоки освобождаются.
Минус: между копированием и обнулением есть небольшое окно где несколько строк лога могут потеряться. Для большинства случаев это приемлемо.
Если потеря строк недопустима - процесс должен поддерживать SIGHUP для переоткрытия лога. nginx умеет: nginx -s reopen. Тогда logrotate делает mv старого файла, создаёт новый, и посылает сигнал процессу.
Мониторинг
Чтобы не ловить это в 3 ночи - добавь проверку в мониторинг:
# удалённые но открытые файлы больше 100 МБ lsof +L1 -F s | awk '/^s/ && substr($0,2)+0 > 104857600 {count++} END {print count+0}'
-F s включает вывод размера в машиночитаемом формате - каждая строка выглядит как s2147483648. /^s/ фильтрует строки с размером. substr($0,2) убирает префикс s и оставляет число. +0 > 104857600 - больше 100 МБ (104857600 байт).
Или через Prometheus + node_exporter - метрика node_filefd_allocated показывает количество открытых дескрипторов. Резкий рост при падении свободного места - признак именно этой проблемы.
Итого
df и du расходятся когда файл удалён но процесс держит открытый дескриптор
rm не освобождает блоки пока счётчик ссылок не ноль
найти виновника:
lsof +L1починить без рестарта:
> /proc/<pid>/fd/<fd>не попасть снова: copytruncate в logrotate или SIGHUP после ротации
Механика одна и та же на любом Linux - от Raspberry Pi до серверов с терабайтными дисками.
Больше про Linux, DevOps и SRE - в Telegram-канале @b4shninja