Почему после ротации логов место на диске не освободилось
logrotate отработал по расписанию, в каталоге логов лежат аккуратные app.log, app.log.1.gz, app.log.2.gz — а df -h как показывал диск забитым на 95%, так и показывает. Ни ошибок в cron, ни жалоб в логах самого logrotate. Это не сбой ротации — это её нормальное поведение при одном частом условии: приложение держит старый файл открытым и логrotate не смог сказать ему об этом. Разберёмся, что физически происходит с файлом на диске, когда его переименовывают у процесса из-под ног, и как это чинится без перезапуска сервиса.
Содержание
Файл — это не только имя в каталоге
Первое, что стоит развидеть: то, что вы видите в ls, — это не файл, а запись в каталоге (dentry), которая ссылается на inode. Сам inode — это метаданные и указатели на блоки данных на диске: размер, права, время изменения, номер жёстких ссылок. Имя файла и его содержимое — разные сущности, связанные ссылкой.
Когда процесс открывает файл через open(), ядро возвращает файловый дескриптор, который привязан не к имени, а к inode. Дальше можно переименовать файл, можно вообще удалить запись о нём из каталога — уже открытый дескриптор продолжит работать как ни в чём не бывало, потому что он ссылается напрямую на inode, а не ищет файл по имени заново при каждой записи.
Отсюда правило, которое объясняет всё дальнейшее: пока хотя бы один процесс держит файл открытым, данные этого файла физически занимают место на диске — независимо от того, виден ли он в ls, есть ли у него вообще имя в каталоге.
Проверить это легко на любом сервере:
# создаём файл, открываем его дескриптором через exec
exec 3< /var/log/example.log
rm /var/log/example.log
ls /var/log/example.log # No such file or directory
df -h /var/log # место как было занято, так и остаётся
exec 3<&- # закрываем дескриптор
df -h /var/log # только теперь место освобождается
Ядро освобождает блоки данных inode только тогда, когда счётчик ссылок на него (и по именам, и по открытым дескрипторам) падает до нуля. Удаление имени из каталога снижает счётчик на единицу, но пока дескриптор открыт — счётчик не ноль, и блоки диска живы. Это тот же механизм, что описан в статье о разнице между занятым местом и inode — про что такое inode и почему место есть, а файл не создаётся, только здесь работает в обратную сторону: файл вроде бы есть (или недавно был), а место как будто пропало.
Что на самом деле делает logrotate
По умолчанию (директива rotate без copytruncate) logrotate делает три шага для каждого сегмента:
- Переименовывает
app.logвapp.log.1(или сдвигает нумерацию у существующих.1,.2и т.д.). - Создаёт новый пустой
app.logс нужными правами и владельцем (опцииcreateв конфиге). - Опционально сжимает
app.log.1вapp.log.1.gz(если заданcompress, часто с задержкой на один цикл черезdelaycompress).
Типичный конфиг в /etc/logrotate.d/:
/var/log/myapp/app.log {
daily
rotate 14
compress
delaycompress
missingok
notifempty
create 0640 myapp myapp
sharedscripts
postrotate
systemctl reload myapp.service > /dev/null 2>&1 || true
endscript
}
Ключевой момент — шаг 1 и шаг 2 сами по себе ничего не сообщают работающему процессу. Переименование файла — это операция над записью в каталоге, она не трогает открытые дескрипторы других процессов. Если приложение уже открыло app.log при старте и продолжает писать в тот же дескриптор, оно даже не заметит, что файл с таким именем теперь указывает на другой (новый, пустой) inode. Оно продолжает писать в старый — тот, что теперь называется app.log.1, а с точки зрения самого процесса вообще никак не называется, он просто пишет в дескриптор.
Директива postrotate для того и существует — она должна дать приложению сигнал переоткрыть файл лога заново, по актуальному имени. Если её нет, забыли добавить, или сигнал не долетает (сервис не поддерживает reload, скрипт падает молча из-за || true) — вы получаете именно ту ситуацию, из-за которой diск не освобождается.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверПочему процесс продолжает писать в переименованный (или уже "удалённый") файл
Смоделируем на пальцах то, что происходит без postrotate или при его сбое:
- Приложение стартовало, открыло
/var/log/myapp/app.log, получило дескриптор, привязанный к inode №123456. - Пришла ротация:
app.logпереименован вapp.log.1, создан новыйapp.logс inode №789012. - Приложение как ни в чём не бывало продолжает писать в дескриптор — а он всё ещё ссылается на inode №123456, который теперь физически лежит под именем
app.log.1. - Вторая ротация (на следующий день):
app.log.1пытаются сжать в.gzили сдвинуть в.2. Но именно этот файл всё ещё растёт — приложение продолжает писать байты в тот же самый дескриптор, в тот же inode, просто теперь под другим именем в каталоге. - Если
compressвключён безdelaycompress, logrotate может даже попытаться сжать файл, в который в этот момент кто-то дописывает — получится битый по логике gzip-поток (не факт что физически битый файл, но логически неконсистентный на момент сжатия).
Хуже: если приложение вообще не поддерживает переоткрытие лога и postrotate его не перезапускает, оно способно писать в один и тот же исходный inode неделями подряд — пока имя файла крутится через .1, .2, ..., .14, и потом исчезает из ротации вовсе (rotate 14 просто перестаёт хранить ссылку на старые сегменты). В этот момент запись о файле пропадает из каталога совсем — а данные всё ещё пишутся в inode, у которого больше нет имени. Для ls такого файла не существует. Для файловой системы он занимает место ровно так же, как обычный файл, — просто без имени.
Почему du показывает не то, что вы ожидали
Отсюда разница, которая обычно и приводит к разбирательству: du -sh /var/log/myapp/ считает только видимые в каталоге файлы — то, что реально можно перечислить через обход дерева каталогов. df -h показывает занятость файловой системы целиком, включая блоки, принадлежащие открытым, но безымянным inode.
Если сложить размеры всех файлов, которые видит du, и сравнить с тем, что показывает df для того же раздела, — при живом "потерянном" файле разница будет заметной и не будет объясняться видимыми файлами. Причём чем дольше приложение пишет в такой файл без переоткрытия, тем больше расходятся числа — файл в фоне растёт бесконтрольно, потому что никакая ротация его больше не видит и не может ни сжать, ни ограничить по rotate N.
Это ровно тот сценарий, который разобран в статье про разницу между показаниями df и du: открытые-но-удалённые файлы — одна из типичных причин расхождения, наравне с точками монтирования внутри директории и резервируемыми для root процентами файловой системы. Если у вас на сервере в принципе настроена ротация как описано в базовой статье про ротацию логов, чтобы не забивался диск, но диск всё равно заполняется — стоит в первую очередь проверить именно открытые дескрипторы, а не переписывать конфиг ротации заново.
Как найти такой файл: lsof и /proc
Найти процесс, который держит открытым удалённый или переименованный файл, довольно быстро:
# все дескрипторы, у которых файл помечен как (deleted)
lsof +L1 2>/dev/null
# то же самое, но конкретно для точки монтирования /var
lsof +L1 /var 2>/dev/null
Флаг +L1 у lsof показывает файлы, у которых число жёстких ссылок меньше 1 — то есть у файла больше нет имени в каталоге, но дескриптор его держит. В выводе будет PID процесса, его имя и размер файла — часто именно этот размер и объясняет расхождение между du и df.
Альтернативный способ — через /proc, без lsof:
# ищем дескрипторы, помеченные как (deleted)
for fd in /proc/[0-9]*/fd/*; do
link=$(readlink "$fd" 2>/dev/null)
case "$link" in
*"(deleted)"*) echo "$fd -> $link" ;;
esac
done
Каждая такая ссылка — это процесс (PID виден в пути /proc/<pid>/fd/...), который держит открытым файл без имени. Дальше вопрос практики: ls -la /proc/<pid>/fd/<fd> покажет размер прямо в выводе, а du -h --apparent-size /proc/<pid>/fd/<fd> — оценку по данным, которые физически лежат на диске под этим дескриптором.
Если процесс — тот самый, что должен был переоткрыть лог после ротации, но не сделал этого, вы нашли причину.
Два рабочих решения: reload по сигналу и copytruncate
Есть ровно два способа не попадать в эту ситуацию — и они решают разные случаи.
Решение 1. Сказать приложению переоткрыть файл лога. Это правильный путь для любого сервиса, который поддерживает переоткрытие логов по сигналу или команде — nginx, большинство демонов на systemd, rsyslog, PostgreSQL. Задача postrotate в конфиге — не "перезапустить сервис", а именно попросить его закрыть старый дескриптор и открыть новый файл по актуальному имени:
postrotate
# nginx: переоткрывает файлы логов по USR1, не разрывая соединения
/usr/sbin/nginx -s reopen > /dev/null 2>&1 || true
endscript
postrotate
# systemd-сервис с обработчиком SIGHUP на переоткрытие лога
systemctl kill -s HUP myapp.service || true
endscript
postrotate
# PostgreSQL, если используется его собственный логгер вместо journald
systemctl reload postgresql || true
endscript
Важно проверить две вещи: что postrotate реально выполняется (без опечаток в пути к бинарнику, без "тихого" || true, скрывающего реальную ошибку при отладке) и что приложение действительно умеет обрабатывать этот сигнал — не каждый демон реагирует на SIGHUP переоткрытием лога, у части это вообще полный рестарт с даунтаймом на доли секунды.
Решение 2. copytruncate — когда приложение нельзя попросить переоткрыть файл. Опция copytruncate в logrotate работает иначе: она не переименовывает исходный файл, а копирует его содержимое в новый файл (app.log.1), а затем обрезает (truncate) исходный app.log до нулевой длины прямо по тому же inode, который приложение уже держит открытым:
/var/log/legacy-app/app.log {
daily
rotate 7
copytruncate
compress
missingok
}
Дескриптор процесса при этом не меняется — он как писал в тот же inode, так и продолжает, просто файл под этим inode внезапно стал пустым, и запись продолжается с позиции 0. Приложению не нужно ничего переоткрывать, потому что имя файла для него не менялось — менялось только содержимое.
У copytruncate есть честный минус: между моментом копирования и моментом truncate есть небольшое окно, в которое приложение может дописать несколько строк — и эти строки будут потеряны (они попали в файл уже после копии, но до обрезки). Для большинства приложений (веб-сервер, обычный демон) потеря нескольких строк лога раз в сутки не критична. Для аудиторских или комплаенс-логов, где важна каждая запись, copytruncate — не лучший выбор; там нужен явный reopen по сигналу или логирование сразу через syslog/journald, которые устроены иначе и не подвержены этой проблеме на уровне ротации файла.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Как понять, что причина именно в открытом дескрипторе, а не в чём-то ещё?
Сравните df -h для раздела с суммой размеров файлов внутри du -sh по каталогам, где реально может расти диск. Если разница большая и не объясняется точками монтирования (когда du считает данные под смонтированным поверх каталогом, а df — нет), проверьте lsof +L1 — если там есть файлы с ненулевым размером, это почти наверняка ваш случай.
Поможет ли просто перезапустить сервис?
Да, полный перезапуск процесса гарантированно закрывает все его старые дескрипторы, и место освободится сразу — ядро уменьшит счётчик ссылок до нуля и вернёт блоки. Но это грубый способ: он даёт даунтайм там, где мог бы хватить мягкий reopen по сигналу, и не решает проблему на будущее — при следующей ротации всё повторится, если не поправить postrotate.
А если сервис вообще не поддерживает ни reload, ни copytruncate?
Тогда единственный надёжный вариант — контролируемый перезапуск по расписанию сразу после ротации (тем же postrotate, но с systemctl restart вместо reload), либо переход на логирование через journald/syslog, где ротацией занимается сам демон логирования, а не внешний файл-ориентированный инструмент.
Можно ли одновременно использовать compress и copytruncate?
Да, они не конфликтуют — copytruncate отвечает за то, как файл забирается у процесса, compress — за то, что происходит со скопированной версией дальше. Комбинация copytruncate + delaycompress — частый выбор для legacy-приложений: не теряется совместимость, а место всё равно освобождается по расписанию.
Почему logrotate не ругается на ошибку, если postrotate не сработал?
По умолчанию скрипты в postrotate/prerotate часто пишут с || true или подобным подавлением кода возврата — это осознанная защита, чтобы неудачный reload одного сервиса не остановил ротацию всех остальных логов в том же прогоне cron. Обратная сторона — ошибка реально проглатывается молча. Стоит логировать вывод postrotate отдельно (logger или запись в файл) хотя бы на время диагностики.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →