Сервер лагает. Смотришь на диск - 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 1234

  • fd/ - подкаталог со всеми открытыми дескрипторами процесса в виде симлинков на реальные файлы

  • 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