Лог пустой, а диск заканчивается: что logrotate делает с файлами, которые приложение не закрывает

Принято считать, что logrotate — самая скучная часть сервера. Конфиг на шесть строк копируется из соседнего проекта или из ответа пятилетней давности, работает годами, и вспоминают о нём, только когда на диске кончается место.
Причём кончается оно обычно не из‑за того, что ротация не работает, а из‑за того, что она делает то, что написано в конфиге, а не то, что от неё ждали.
logrotate оперирует именами файлов: переименовывает, копирует, обрезает, создаёт новые. Приложение работает с открытым дескриптором, а дескриптор указывает на inode и хранит свою позицию записи. Имя для него ничего не значит.
Все истории ниже — это разные способы, которыми два этих взгляда на один файл расходятся.
Файл, в который пишут после удаления
Самый частый конфиг выглядит так:
/var/log/app/app.log {
rotate 3
create
compress
}create переименовывает app.log в app.log.1 и создаёт пустой app.log. Приложение об этом не знает. Его дескриптор по‑прежнему указывает на тот inode, который теперь называется app.log.1, и строки продолжают идти туда. Новый app.log остаётся пустым.
С compress становится хуже. Сразу после переименования logrotate сжимает app.log.1 в app.log.1.gz и удаляет исходник. Процесс же продолжает писать в inode, у которого больше нет имени.
На стенде logrotate даже предупредил об этом голосом gzip:
error: Compressing program wrote following message to stderr when compressing log /var/tmp/lr/e1/app.log.1:
gzip: stdin: file size changed while zippingЧерез несколько секунд картина в каталоге и у процесса выглядит так:
$ ls -l app.log*
-rw-r--r-- 1 root root 0 Oct 14 00:52 app.log
-rw-r--r-- 1 root root 15547 Oct 14 00:52 app.log.1.gz
$ lsof -nP +L1 | grep python
python3 461 root 3w REG 254,0 3642444 0 1483632 /var/tmp/lr/e1/app.log.1 (deleted)Лог пустой, архив маленький. А процесс пишет в удалённый файл, который вырос до 3,6 МБ и продолжает расти.
du по каталогу его не увидит, df по разделу увидит. Место освободится, только когда процесс закроет дескриптор, обычно при перезапуске, и вместе с местом пропадёт всё, что туда успело записаться.
Колонка NLINK со значением 0 в выводе lsof +L1 — это и есть признак: у файла ноль имён и как минимум один открытый дескриптор.
Если lsof на сервере нет, хватит одной строки:
find /proc/[0-9]*/fd -lname '*(deleted)'Она выводит дескрипторы всех процессов, которые смотрят на удалённые файлы.
Освободить место без перезапуска можно через тот же путь. Команда:
: > /proc/PID/fd/3обрезает файл, на который указывает дескриптор, хотя имени у него уже нет.
На стенде удалённый лог за секунду до обрезки весил 1,4 МБ, через секунду после — 200 КБ, и писатель ничего не заметил.
Сработало это чисто только потому, что писатель открыл файл с O_APPEND. Почему это важно, видно в следующем же сценарии.
Как сказать приложению про новый файл
У create есть обязательная вторая половина, без которой он не работает: приложение надо попросить переоткрыть лог.
Обычно это сигнал в postrotate. nginx переоткрывает логи по USR1, многие демоны — по HUP:
/var/log/nginx/*.log {
daily
rotate 14
create
compress
delaycompress
sharedscripts
postrotate
[ -f /run/nginx.pid ] && kill -USR1 "$(cat /run/nginx.pid)"
endscript
}sharedscripts нужен, чтобы при пяти файлах по маске сигнал ушёл один раз, а не пять.
delaycompress откладывает сжатие до следующей ротации: если приложение переоткроет файл не мгновенно, последние строки допишутся в app.log.1, пока тот ещё не сжат и не удалён.
Если приложение не умеет переоткрывать лог по сигналу, можно сделать так, чтобы оно замечало ротацию само.
В Python для этого есть logging.handlers.WatchedFileHandler: перед каждой записью он сверяет устройство и inode файла по имени с тем, что у него открыто, и при расхождении переоткрывает. Сигналы ему не нужны.
На том же примере с create, compress и delaycompress писатель на WatchedFileHandler после ротации держал открытым новый app.log, а сквозная нумерация строк в app.log.1 и app.log прошла без единого пропуска.
copytruncate и нули в начале файла
Сигналы настраивать никто не любит. Поэтому многие на этом месте ставят copytruncate: logrotate копирует лог в app.log.1, а исходник обрезает до нуля.
Дескриптор приложения остаётся на том же inode, сигналы не нужны, конфиг короче.
Работает это без сюрпризов только при одном условии, о котором в конфиге ничего не сказано: приложение должно открыть файл с флагом
O_APPEND.
Без этого флага позиция записи хранится в дескрипторе. После обрезки файл стал нулевой длины, но следующая запись идёт по старому смещению, например по 805 889-му байту. Ядро заполняет промежуток дырой, а читается она как нули.
Я запустил писателя с open(path, "w") вместо "a" и сделал одну ротацию:
режим open('w') app.log 1210303 байт по ls, 400 КБ на диске, нулевых байт в начале: 805889
режим open('a') app.log 406246 байт по ls, 400 КБ на диске, нулевых байт в начале: 0Файл с дырой занимает на диске столько же, сколько нормальный. Но ls показывает его втрое больше. А grep отвечает binary file matches вместо строк.
Хуже то, что происходит на следующих ротациях. Смещение у писателя только растёт, и каждая копия забирает с собой все предыдущие нули, причём копия уже не разреженная:
ротация 1: app.log 1107024 байт по ls, 104 КБ на диске; app.log.1 1004203 байт, 984 КБ на диске
ротация 2: app.log 2021879 байт по ls, 104 КБ на диске; app.log.1 1918829 байт, 1876 КБ на диске
ротация 3: app.log 2929444 байт по ls, 104 КБ на диске; app.log.1 2827094 байт, 2764 КБ на дискеПолезных данных за каждый интервал примерно одинаково, около 900 КБ. А архив каждый раз растёт на ту же величину.
Через месяц ежедневной ротации каждая копия лога состоит в основном из нулей. Сжатие их хорошо прячет, пока архив не понадобится распаковать.
Откуда берутся писатели без O_APPEND, если в коде никто не писал "w"?
Чаще всего из перенаправления: app > /var/log/app.log открывает файл с обрезкой и без дозаписи, а >> — с O_APPEND.
Второй частый источник — systemd. StandardOutput=file:/path по документации открывает файл «для записи с начала, но без обрезки», и O_APPEND там тоже нет. Для дозаписи нужен вариант append:/path, он появился в systemd 240.
Разница в одно слово в юните определяет, будет ли через месяц в логах сервиса гигабайт нулей.
Промежуток между копией и обрезкой
У copytruncate есть и вторая цена, которую документация logrotate признаёт сама: между копированием и обрезкой есть «очень маленький промежуток времени», и часть записей может потеряться.
Насколько он маленький, зависит от размера лога, потому что копирование идёт всё это время.
Я заполнил лог старыми строками до 307 МБ, запустил писателя с O_APPEND и сквозной нумерацией и сделал ротацию. Потом собрал номера из app.log.1 и app.log и поискал дыры:
logrotate работал 736 мс
записано 3153, потеряно 246, подряд: True, номера 2030–2275
logrotate работал 831 мс
записано 3204, потеряно 279, подряд: True, номера 2040–2318Всё, что приложение написало, пока шло копирование, легло в исходный файл дальше той точки, до которой logrotate уже дочитал, и копия эти строки не получила, а через долю секунды после окончания копирования logrotate обрезал исходник до нуля и вместе с ним выбросил всё, что успело туда попасть.
Около 250 строк подряд при скорости писателя примерно 800 строк в секунду.
Лог на несколько гигабайт копируется дольше, сервис под нагрузкой пишет чаще, и «маленький промежуток» превращается в несколько секунд на границе суток.
Если приложение умеет переоткрывать лог,
createс сигналом всегда лучшеcopytruncate. Если не умеет и переделать нельзя, разумный выход — писать в stdout и отдать ротацию тому, кто этот поток читает: journald или среде выполнения контейнеров.
size, который проверяют раз в сутки
Следующая ошибка не про дескрипторы, а про расписание. Последствия у неё те же: диск заканчивается при работающей ротации.
Конфиг выглядит даже неплохо:
/var/log/app/app.log {
size 100M
rotate 10
create
}Ожидание у нас в том, что лог не вырастет больше 100 МБ.
Только logrotate не демон. Сам он ничего не отслеживает. Он запускается по расписанию, проверяет условия и выходит.
В Ubuntu 24.04 его запускает logrotate.timer с OnCalendar=daily и AccuracySec=1h. Если сервис пишет 5 ГБ в сутки, size 100M раз в сутки обнаружит файл на 5 ГБ и повернёт его.
По документации size вообще отменяет временной интервал: файл ротируется, «только если вырос больше» заданного размера, без оглядки на время последней ротации.
Для «раз в день, но не больше 100 МБ» нужна другая директива, maxsize, а для «раз в день, но только если файл больше 100 МБ» — minsize.
И все три имеют смысл, только если logrotate запускается чаще, чем файл успевает вырасти.
Проще всего переопределить таймер:
# /etc/systemd/system/logrotate.timer.d/hourly.conf
[Timer]
OnCalendar=
OnCalendar=hourlyПустая строка OnCalendar= сбрасывает значение из основного юнита, без неё systemd добавит почасовой запуск к ежедневному, а не заменит его.
Проверить, как часто ротация случается на самом сервере, можно без догадок.
logrotate ведёт файл состояния, в Ubuntu это /var/lib/logrotate/status. Там для каждого лога записано время последней ротации, строкой вида:
"/var/log/app/app.log" 2026-10-14-0:56:31Если дата у лога, который растёт на гигабайты, вчерашняя, расписание не успевает. Если позавчерашняя, ротация для него ломается молча.
Про один такой случай следующий раздел.
Вторая ротация за день
Почасовой запуск открывает последнюю ошибку из этого набора.
Многие конфиги используют dateext, чтобы архивы назывались по дате, а не номером: app.log-20261014 вместо app.log.1.
Суффикс по умолчанию содержит только день. Вторая ротация в тот же день хочет создать файл с тем же именем:
error: destination /var/tmp/lr/e4/app.log-20261014 already exists, skipping rotationlogrotate пропускает этот файл и завершается с кодом 1. В журнале systemd это упавший logrotate.service.
Если на него нет алерта, никто не заметит, что лог перестал поворачиваться, пока он не вырастет.
Лечится форматом с часом:
dateext
dateformat -%Y%m%d%HС ним архивы получают имена вроде app.log-2026101400, и почасовая ротация работает.
Число в rotate после такой правки стоит пересчитать: rotate 7 при ежедневной ротации хранил неделю, а при почасовой хранит семь часов.
stdout тоже кто‑то должен поворачивать
Совет из раздела про copytruncate — писать в stdout и отдать ротацию тому, кто читает поток, — хорош, пока этот читатель настроен.
С journald всё обычно в порядке: у него свои лимиты на размер журнала, и о файлах приложение не думает вообще.
С Docker сложнее.
Драйвер логов по умолчанию у него json-file, и документация Docker пишет о нём без обиняков: по умолчанию ротация не выполняется.
У параметра max-size значение по умолчанию −1, без ограничения, а max-file без max-size ни на что не влияет.
Контейнер, который пишет в stdout, на хосте без настроенного daemon.json тихо растит файл /var/lib/docker/containers/<id>/<id>-json.log, и logrotate о нём ничего не знает.
Включается ротация в /etc/docker/daemon.json:
{
"log-driver": "json-file",
"log-opts": { "max-size": "100m", "max-file": "5" }
}У этой правки есть своя оговорка из той же документации: существующие контейнеры новую настройку автоматически не получают, их надо пересоздать.
После перезапуска демона старые контейнеры продолжают писать без ограничений, и это легко принять за то, что настройка не работает.
Сама документация Docker для большинства случаев советует драйвер local, который ротирует логи по умолчанию и хранит их компактнее.
Конфиг, который не знает о процессе
Общее у этих историй одно: конфиг logrotate описывает файлы, а ломается всё на процессах, которые эти файлы держат.
Как процесс открыл лог, с O_APPEND или без, умеет ли он переоткрывать файл по сигналу, сколько он пишет в час — ничего из этого в конфиге не видно, а от этого зависит, какой из шести строк копировать.
Разбирать ротацию полезно с другой стороны: не с /etc/logrotate.d, а с ls -l /proc/PID/fd и lsof +L1 у сервисов, которые пишут в файлы.
Там сразу видно, кто держит удалённые логи, а флаги открытия лежат в /proc/PID/fdinfo/N.
В восьмеричном поле flags за O_APPEND отвечает бит 02000: на стенде у писателя с "a" там было 02102001, у писателя с "w" — 02100001.
А у вас в /etc/logrotate.d есть copytruncate, про который известно, как приложение открывает свой лог?

Проблемы с логами часто обнаруживаются, когда диск уже заполнен, а сервис работает нестабильно. Чтобы не разбираться с последствиями под нагрузкой, полезно заранее понимать, как серверные приложения работают с файлами и что происходит при сбоях.
Углубиться в эксплуатацию Linux‑инфраструктуры и диагностику проблем можно на бесплатных открытых уроках:
2 ноября в 19:00. «Оптимизация Nginx и Angie под высокие нагрузки». Записаться.
19 ноября в 20:00. «Восстанавливаем MBR, GPT и LVM после критических сбоев». Записаться.
Больше открытых уроков по Linux, системному администрированию и другим IT‑направлениям — в календаре мероприятий OTUS.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.